Skip to content

Commit

Permalink
fix(map,selection): selection now works when the map is scrolled
Browse files Browse the repository at this point in the history
This is a tricky one - the map has the outer container which has the scrollbar and the inner container which overflows.
Event handlers must go on the outer container otherwise you cannot start a selection when the inner container is scrolled
and you click into the overflow area, but the rectangle that is created must go in the inner container itself because it
should overflow inside the scrollable area not outside on the whole page.
  • Loading branch information
updraft0 committed Jun 28, 2024
1 parent 2e5e70d commit 2547e71
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 110 deletions.
2 changes: 1 addition & 1 deletion ui/src/main/css/views/nav-top-view.css
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ div#nav-top-view {
}

& button:last-of-type {
margin-right: 0.2em;
padding-right: 1.1em;
}

/* current user indicator */
Expand Down
8 changes: 6 additions & 2 deletions ui/src/main/scala/controltower/page/map/view/MapView.scala
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ private final class MapView(
val connectionInProgress =
ConnectionInProgressView(connectingSystem, controller.pos, controller.BoxSize, controller.actionsBus)

val selectionView =
SelectionView(controller.selectedSystemId.signal, controller.bulkSelectedSystemIds.writer, systemNodes)

div(
idAttr := "map-view-inner",
// A -> add system
Expand Down Expand Up @@ -245,6 +248,8 @@ private final class MapView(
cls := "grid",
cls := "g-20px",
toolbarView.view,
// note: the selection event handler must come before the cancel one
selectionView.eventHandlers,
inContext(self =>
onPointerUp.compose(_.withCurrentValueOf(mapCtx.userPreferences)) --> ((ev, prefs) =>
// TODO: unsure about the checks against self.ref - should always be true?
Expand Down Expand Up @@ -285,8 +290,7 @@ private final class MapView(
children.command <-- connectionNodes,
connectionInProgress.view
),
// TODO important??
SelectionView(controller.selectedSystemId.signal, controller.bulkSelectedSystemIds.writer, systemNodes).view
selectionView.view
)
),
div(
Expand Down
220 changes: 113 additions & 107 deletions ui/src/main/scala/controltower/page/map/view/SelectionView.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.scalajs.dom
import scala.collection.mutable

enum SelectionState derives CanEqual:
case Selecting(start: Coord, finish: Coord)
case Selecting(start: Coord, finish: Coord, bbox: Coord)
case Stopped

case class ObserverState(rootElement: dom.Element, elements: mutable.ArrayBuffer[Element])
Expand All @@ -21,113 +21,117 @@ final class SelectionView(
selection: Observer[Array[SystemId]],
systemNodes: EventStream[CollectionCommand[Element]]
):
def view: Modifier[HtmlElement] =
val state = Var(SelectionState.Stopped)
val stateSelecting = state.signal.map:
case _: SelectionState.Selecting => true
case _ => false

val observerState = Var[ObserverState](null)
private val state = Var(SelectionState.Stopped)
private val stateSelecting = state.signal.map:
case _: SelectionState.Selecting => true
case _ => false

private val observerState = Var[ObserverState](null)

def eventHandlers: Modifier[HtmlElement] =
modSeq(
onPointerDown
.filter(pev => pev.isPrimary && pev.button == MouseButtonLeft && !pev.shiftKey && !pev.ctrlKey && !pev.metaKey)
.compose(_.withCurrentValueOf(observerState.signal))
--> { (pev, obsState) =>
val parentBounds = obsState.rootElement.getBoundingClientRect()
val bbox = Coord(parentBounds.x, parentBounds.y)
val mouseCoord = Coord(x = pev.clientX - bbox.x, y = pev.clientY - bbox.y)

state.set(SelectionState.Selecting(mouseCoord, mouseCoord, bbox))
},
onPointerMove.compose(
_.filterWith(stateSelecting)
.withCurrentValueOf(state.signal)
) --> { (pev, ss) =>
ss match
case SelectionState.Selecting(start, _, bbox) =>
// set pointer capture to the selection rectangle div (unfortunately easier to find via DOM)
val el = org.scalajs.dom.document.querySelector("div.selection-rectangle")
if (el != null && !el.hasPointerCapture(pev.pointerId))
el.setPointerCapture(pev.pointerId)

inContext(self =>
modSeq(
onPointerDown
.filter(pev =>
pev.isPrimary && pev.button == MouseButtonLeft && !pev.shiftKey && !pev.ctrlKey && !pev.metaKey
)
--> { pev =>
val bbox = self.ref.getBoundingClientRect()
val mouseCoord = Coord(x = pev.clientX - bbox.x, y = pev.clientY - bbox.y)
state.set(SelectionState.Selecting(mouseCoord, mouseCoord))
},
onPointerMove.compose(
_.filterWith(stateSelecting)
.withCurrentValueOf(state.signal)
) --> { (pev, ss) =>
ss match
case SelectionState.Selecting(start, _) =>
// set pointer capture to the selection rectangle div (unfortunately easier to find via DOM)
val el = org.scalajs.dom.document.querySelector("div.selection-rectangle")
if (el != null && !el.hasPointerCapture(pev.pointerId))
el.setPointerCapture(pev.pointerId)

val bbox = self.ref.getBoundingClientRect()
val mouseCoord = Coord(x = pev.clientX - bbox.x, y = pev.clientY - bbox.y)
state.set(Selecting(start, mouseCoord))
case _ => ()
},
onPointerMove.compose(
_.throttle(100)
.filterWith(stateSelecting)
.mapToUnit
.withCurrentValueOf(state.signal, observerState.signal, singleSelected)
) --> { (ss, obsState, singleSelectedOpt) =>
ss match
case SelectionState.Selecting(start, finish) =>
val opts = new dom.IntersectionObserverInit {}
opts.root = obsState.rootElement
opts.threshold = Threshold

val parentBounds = obsState.rootElement.getBoundingClientRect()
val (leftX, rightX) = if (start.x > finish.x) (finish.x, start.x) else (start.x, finish.x)
val (topY, bottomY) = if (start.y < finish.y) (start.y, finish.y) else (finish.y, start.y)

val leftMargin = s"${-leftX}px"
val topMargin = s"${-topY}px"
val rightMargin = s"${-parentBounds.width + rightX}px"
val bottomMargin = s"${-parentBounds.height + bottomY}px"

// very hacky - because you cannot set a root element outside the ancestor elements, we set the root
// to be the map container and then apply negative margins that correspond to the selection rectangle
// we are drawing. the margin is static so need to create a new intersection observer every time...
opts.rootMargin = s"$topMargin $rightMargin $bottomMargin $leftMargin"

val observer = new dom.IntersectionObserver(
{ (entries, _) =>
val systemIds = entries.view
.filter(e =>
e.isIntersecting && e.target.id.startsWith("system-") && singleSelectedOpt.forall(sId =>
!e.target.id.endsWith(sId.toString)
)
state.set(Selecting(start, mouseCoord, bbox))
case _ => ()
},
onPointerMove.compose(
_.throttle(100)
.filterWith(stateSelecting)
.mapToUnit
.withCurrentValueOf(state.signal, observerState.signal, singleSelected)
) --> { (ss, obsState, singleSelectedOpt) =>
ss match
case SelectionState.Selecting(start, finish, _) =>
val opts = new dom.IntersectionObserverInit {}
opts.root = obsState.rootElement
opts.threshold = Threshold

val parentBounds = obsState.rootElement.getBoundingClientRect()
val (leftX, rightX) = if (start.x > finish.x) (finish.x, start.x) else (start.x, finish.x)
val (topY, bottomY) = if (start.y > finish.y) (finish.y, start.y) else (start.y, finish.y)

val leftMargin = s"${-leftX}px"
val topMargin = s"${-topY}px"
val rightMargin = s"${-(parentBounds.width - rightX)}px"
val bottomMargin = s"${-(parentBounds.height - bottomY)}px"

// very hacky - because you cannot set a root element outside the ancestor elements, we set the root
// to be the map container and then apply negative margins that correspond to the selection rectangle
// we are drawing. the margin is static so need to create a new intersection observer every time...
opts.rootMargin = s"$topMargin $rightMargin $bottomMargin $leftMargin"

val observer = new dom.IntersectionObserver(
{ (entries, _) =>
val systemIds = entries.view
.filter(e =>
e.isIntersecting && e.target.id.startsWith("system-") && singleSelectedOpt.forall(sId =>
!e.target.id.endsWith(sId.toString)
)
.map(e => SystemId(e.target.id.stripPrefix("system-").toLong))
.toArray
selection.onNext(systemIds)
},
opts
)
val targets = obsState.elements
targets.foreach((t: Element) => observer.observe(t.ref))

// hacky - wait for the intersection observer to compute intersections and then kill it
scala.scalajs.js.timers.setTimeout(80)(observer.disconnect())
)
.map(e => SystemId(e.target.id.stripPrefix("system-").toLong))
.toArray
selection.onNext(systemIds)
},
opts
)
val targets = obsState.elements
targets.foreach((t: Element) => observer.observe(t.ref))

// hacky - wait for the intersection observer to compute intersections and then kill it
scala.scalajs.js.timers.setTimeout(80)(observer.disconnect())

case _ => ()
},
onPointerCancel.mapTo(SelectionState.Stopped) --> state,
onPointerUp.mapTo(SelectionState.Stopped) --> state,
onPointerUp.filter { pev =>
// very hacky again - we do not propagate events that have the capture on the selection rectangle
val tgt = org.scalajs.dom.document.querySelector("div.selection-rectangle")
tgt != null && tgt.hasPointerCapture(pev.pointerId)
}.stopImmediatePropagation --> Observer.empty,
systemNodes
.compose(_.withCurrentValueOf(observerState)) --> { (cmd, obsState) =>
cmd match
case CollectionCommand.Append(el) => obsState.elements.append(el)
case CollectionCommand.Remove(el) =>
obsState.elements.indexOf(el) match
case -1 => ()
case idx => obsState.elements.remove(idx)

case CollectionCommand.Replace(prev, next) =>
obsState.elements.indexOf(prev) match
case -1 =>
obsState.elements.append(next)
case idx =>
obsState.elements(idx) = next
case _ => () // no-op, assume unsupported
}
)

case _ => ()
},
onPointerCancel.mapTo(SelectionState.Stopped) --> state,
onPointerUp.mapTo(SelectionState.Stopped) --> state,
onPointerUp.filter { pev =>
// very hacky again - we do not propagate events that have the capture on the selection rectangle
val tgt = org.scalajs.dom.document.querySelector("div.selection-rectangle")
tgt != null && tgt.hasPointerCapture(pev.pointerId)
}.stopPropagation --> Observer.empty,
systemNodes
.compose(_.withCurrentValueOf(observerState)) --> { (cmd, obsState) =>
cmd match
case CollectionCommand.Append(el) => obsState.elements.append(el)
case CollectionCommand.Remove(el) =>
obsState.elements.indexOf(el) match
case -1 => ()
case idx => obsState.elements.remove(idx)

case CollectionCommand.Replace(prev, next) =>
obsState.elements.indexOf(prev) match
case -1 =>
obsState.elements.append(next)
case idx =>
obsState.elements(idx) = next
case _ => () // no-op, assume unsupported
},
def view: Modifier[HtmlElement] =
inContext(self =>
modSeq(
div(
cls := "selection-rectangle",
onMountUnmountCallback(
Expand All @@ -153,11 +157,13 @@ final class SelectionView(
case _ => ""
},
styleAttr <-- state.signal.map {
case SelectionState.Selecting(start, finish) =>
case SelectionState.Selecting(start, finish, pbbox) =>
val bbox = self.ref.getBoundingClientRect()

val (leftX, rightX) = if (start.x > finish.x) (finish.x, start.x) else (start.x, finish.x)
val (topY, bottomY) = if (start.y < finish.y) (start.y, finish.y) else (finish.y, start.y)
val (topY, bottomY) = if (start.y > finish.y) (finish.y, start.y) else (start.y, finish.y)

s"left: ${leftX}px; top: ${topY}px; width: ${rightX - leftX}px; height: ${bottomY - topY}px;"
s"left: ${leftX - bbox.x + pbbox.x}px; top: ${topY - bbox.y + pbbox.y}px; width: ${rightX - leftX}px; height: ${bottomY - topY}px;"
case _ => ""
}
)
Expand Down

0 comments on commit 2547e71

Please sign in to comment.