diff --git a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala index 6ca1032..a636f89 100644 --- a/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala +++ b/protocol/shared/src/main/scala/org/updraft0/controltower/protocol/map.scala @@ -300,10 +300,14 @@ enum MapRequest derives CanEqual: name: Option[NewSystemName] = None // TODO: weirdly cannot nest Option[Option[_]] ) - /** Remove a system from the map (this only deletes display data and clears the pinned status) + /** Remove a system from the map */ case RemoveSystem(systemId: SystemId) + /** Remove multiple systems from the map + */ + case RemoveSystems(systemIds: Array[SystemId]) + /** Remove a connection from the map */ case RemoveSystemConnection(connectionId: ConnectionId) diff --git a/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala b/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala index 3168507..fdb9d9a 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/map/MapReactive.scala @@ -267,6 +267,7 @@ enum MapRequest derives CanEqual: case UpdateSystemSignatures(systemId: SystemId, replaceAll: Boolean, scanned: List[NewMapSystemSignature]) case RenameSystem(systemId: SystemId, name: Option[String]) case RemoveSystem(systemId: SystemId) + case RemoveSystems(systemIds: Chunk[SystemId]) case RemoveSystemSignatures(systemId: SystemId, signatures: Option[NonEmptyChunk[SigId]]) case RemoveSystemConnection(connectionId: ConnectionId) // internals @@ -387,7 +388,17 @@ object MapEntity extends ReactiveEntity[MapEnv, MapId, MapState, Identified[MapR ) case Identified(Some(sid), rs: MapRequest.RemoveSystem) => whenSystemExists(rs.systemId, state)( - identified(sid, "removeFromDisplay", removeSystemAndConnection(mapId, state, sid, rs)) + identified(sid, "removeFromDisplay", removeSystemAndConnection(mapId, state, sid, rs.systemId)) + ) + case Identified(Some(sid), rss: MapRequest.RemoveSystems) => + // TODO this could be optimized further + foldLeft( + state, + rss.systemIds, + (systemId, state) => + whenSystemExists(systemId, state)( + identified(sid, "removeFromDisplay", removeSystemAndConnection(mapId, state, sid, systemId)) + ) ) case Identified(Some(sid), rsc: MapRequest.RemoveSystemConnection) => identified(sid, "removeSystemConnection", removeSystemConnection(mapId, state, sid, rsc)) @@ -623,26 +634,26 @@ object MapEntity extends ReactiveEntity[MapEnv, MapId, MapState, Identified[MapR mapId: MapId, state: MapState, sessionId: MapSessionId, - rs: MapRequest.RemoveSystem + systemId: SystemId ) = - val connections = state.connectionsForSystem(rs.systemId) + val connections = state.connectionsForSystem(systemId) val connectionIds = Chunk.from(connections.valuesIterator.map(_.connection.id)) - val otherSystemIds = Chunk.from(connections.valuesIterator.map(_.connection.toSystemId).filter(_ != rs.systemId)) ++ - Chunk.from(connections.valuesIterator.map(_.connection.fromSystemId).filter(_ != rs.systemId)) + val otherSystemIds = Chunk.from(connections.valuesIterator.map(_.connection.toSystemId).filter(_ != systemId)) ++ + Chunk.from(connections.valuesIterator.map(_.connection.fromSystemId).filter(_ != systemId)) for // mark connections as removed _ <- query.map.deleteMapWormholeConnections(mapId, connectionIds, sessionId.characterId) // remove signatures that have those connections _ <- query.map.deleteSignaturesWithConnectionIds(mapId, connectionIds, sessionId.characterId) // remove the display of the system - _ <- query.map.deleteMapSystemDisplay(mapId, rs.systemId) + _ <- query.map.deleteMapSystemDisplay(mapId, systemId) // recompute all the connection ranks connectionRanks <- MapQueries.getWormholeConnectionRanksAll(mapId) // load all the affected connections with sigs connectionsWithSigs <- MapQueries.getWormholeConnectionsWithSigsBySystemIds(mapId, otherSystemIds) yield withState( state.removeSystem( - rs.systemId, + systemId, connectionIds, otherSystemIds, connectionRanks, @@ -651,7 +662,7 @@ object MapEntity extends ReactiveEntity[MapEnv, MapId, MapState, Identified[MapR )(nextState => nextState -> broadcast( MapResponse.SystemRemoved( - state.systems(rs.systemId), + state.systems(systemId), connectionIds, connectionsWithSigs.map(whcs => whcs.connection.id -> whcs).toMap, connectionRanks = nextState.connectionRanks @@ -1066,6 +1077,14 @@ private inline def removeSignatureById(arr: Chunk[MapSystemSignature], idOpt: Op ) .getOrElse(arr) +private def foldLeft[A]( + initial: MapState, + xs: Chunk[A], + f: (A, MapState) => ZIO[MapEnv, Throwable, (MapState, Chunk[Identified[MapResponse]])] +) = + ZIO.foldLeft(xs)((initial, Chunk.empty[Identified[MapResponse]])): + case ((prev, responses), a) => f(a, prev).map((s, r) => (s, responses ++ r)) + private def combineMany( initial: MapState, fs: Chunk[MapState => ZIO[MapEnv, Throwable, (MapState, Chunk[Identified[MapResponse]])]] diff --git a/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala b/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala index 2fff06a..a8d96fd 100644 --- a/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala +++ b/server/src/main/scala/org/updraft0/controltower/server/map/MapSession.scala @@ -237,6 +237,8 @@ object MapSession: ) case protocol.MapRequest.RemoveSystem(systemId) => ctx.mapQ.offer(Identified(Some(ctx.sessionId), MapRequest.RemoveSystem(systemId))) + case protocol.MapRequest.RemoveSystems(systemIds) => + ctx.mapQ.offer(Identified(Some(ctx.sessionId), MapRequest.RemoveSystems(Chunk.fromArray(systemIds)))) // signatures case addSig: protocol.MapRequest.AddSystemSignature => ctx.mapQ.offer( diff --git a/ui/src/main/css/app.css b/ui/src/main/css/app.css index 870108f..6f46b7c 100644 --- a/ui/src/main/css/app.css +++ b/ui/src/main/css/app.css @@ -18,6 +18,7 @@ @import 'views/nav-top-view.css'; @import 'views/solar-system-info-view.css'; @import 'views/map-system-signature-view.css'; +@import 'views/map-selection-view.css'; @import 'views/system-view.css'; /* ok? */ @@ -228,6 +229,10 @@ $box-height: 40px; /*box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.16), 0px 0px 0px 3px rgba(51, 51, 51,1);*/ } +.system-selected-bulk { + background-color: $red-darkest; +} + .system-class-h { color: $green; diff --git a/ui/src/main/css/components/tooltip.css b/ui/src/main/css/components/tooltip.css index 4992a3e..676c0c0 100644 --- a/ui/src/main/css/components/tooltip.css +++ b/ui/src/main/css/components/tooltip.css @@ -1,6 +1,6 @@ .tooltip { position: absolute; - background-color: $gray-dark; + background-color: $gray; visibility: hidden; opacity: 0; @@ -23,7 +23,7 @@ color: $gray-lightest; font-size: 1em; margin: 0.2em; - border-bottom: 1px solid $gray-darker; + border-bottom: 1px solid $gray-dark; } } @@ -41,6 +41,6 @@ &:hover + .tooltip { visibility: visible; - opacity: 90%; + opacity: 0.95; } } \ No newline at end of file diff --git a/ui/src/main/css/views/map-selection-view.css b/ui/src/main/css/views/map-selection-view.css new file mode 100644 index 0000000..49510bc --- /dev/null +++ b/ui/src/main/css/views/map-selection-view.css @@ -0,0 +1,7 @@ +div.selection-rectangle { + position: absolute; + z-index: 20; + background-color: $orange-dark; + opacity: 0.4; + border: 1px dashed $gray-lighter; +} \ No newline at end of file diff --git a/ui/src/main/scala/controltower/page/map/MapAction.scala b/ui/src/main/scala/controltower/page/map/MapAction.scala index 1b5dc23..2b07775 100644 --- a/ui/src/main/scala/controltower/page/map/MapAction.scala +++ b/ui/src/main/scala/controltower/page/map/MapAction.scala @@ -31,6 +31,10 @@ enum MapAction: */ case Remove(systemId: SystemId) extends MapAction with SingleSystemAction + /** Remove a system from the map + */ + case RemoveMultiple(systemIds: Array[SystemId]) extends MapAction + /** Remove a single connection from the map */ case RemoveConnection(connectionId: ConnectionId) extends MapAction @@ -43,6 +47,10 @@ enum MapAction: */ case Select(systemId: Option[SystemId]) + /** Toggle bulk selection for a system + */ + case ToggleBulkSelection(systemId: SystemId) + /** Toggle whether a system is pinned on the map */ case TogglePinned(systemId: SystemId) extends MapAction with SingleSystemAction diff --git a/ui/src/main/scala/controltower/page/map/view/MapController.scala b/ui/src/main/scala/controltower/page/map/view/MapController.scala index dd82f21..10022c6 100644 --- a/ui/src/main/scala/controltower/page/map/view/MapController.scala +++ b/ui/src/main/scala/controltower/page/map/view/MapController.scala @@ -39,8 +39,9 @@ class MapController(rds: ReferenceDataStore, val clock: Signal[Instant])(using O val allConnections = HVar[Map[ConnectionId, MapWormholeConnectionWithSigs]](Map.empty) val allLocations = HVar[Map[SystemId, Array[CharacterLocation]]](Map.empty) - val selectedSystemId = Var[Option[Long]](None) - val selectedConnectionId = Var[Option[ConnectionId]](None) + val selectedSystemId = Var[Option[Long]](None) + val bulkSelectedSystemIds = Var[Array[SystemId]](Array.empty[SystemId]) + val selectedConnectionId = Var[Option[ConnectionId]](None) val lastError = Var[Option[String]](None) @@ -136,6 +137,8 @@ class MapController(rds: ReferenceDataStore, val clock: Signal[Instant])(using O ) case (MapAction.Remove(systemId), _) => Some(MapRequest.RemoveSystem(systemId)) + case (MapAction.RemoveMultiple(systemIds), _) => + Some(MapRequest.RemoveSystems(systemIds)) case (MapAction.RemoveConnection(connectionId), _) => Some(MapRequest.RemoveSystemConnection(connectionId)) case (MapAction.RemoveSignatures(systemId, sigIds), _) => @@ -153,6 +156,13 @@ class MapController(rds: ReferenceDataStore, val clock: Signal[Instant])(using O (selectedConnectionId, None) ) None + case (MapAction.ToggleBulkSelection(systemId), _) => + bulkSelectedSystemIds.update(arr => + arr.indexOf(SystemId(systemId)) match + case -1 => arr.appended(SystemId(systemId)) + case idx => arr.filterNot(_ == SystemId(systemId)) + ) + None case (MapAction.TogglePinned(systemId), allSystems) => allSystems.get(systemId).map(sys => MapRequest.UpdateSystem(systemId, isPinned = Some(!sys.system.isPinned))) diff --git a/ui/src/main/scala/controltower/page/map/view/MapView.scala b/ui/src/main/scala/controltower/page/map/view/MapView.scala index 9cd6c44..fd37fcc 100644 --- a/ui/src/main/scala/controltower/page/map/view/MapView.scala +++ b/ui/src/main/scala/controltower/page/map/view/MapView.scala @@ -11,6 +11,7 @@ import controltower.ui.* import io.laminext.websocket.* import org.updraft0.controltower.constant import org.updraft0.controltower.protocol.* +import org.scalajs.dom.KeyboardEvent import java.time.Instant import scala.concurrent.ExecutionContext.Implicits.global @@ -129,6 +130,7 @@ private final class MapView( mssV.signal, controller.pos, controller.selectedSystemId.signal, + controller.bulkSelectedSystemIds.signal, controller.allLocations.signal.map(_.getOrElse(constant.SystemId(systemId), Array.empty[CharacterLocation])), connectingSystem.current, ws.isConnected, @@ -165,7 +167,7 @@ private final class MapView( modalKeyBinding( "KeyA", controller.mapRole.map(RoleController.canAddSystem).combineWith(ws.isConnected).map(_ && _), - identity, + _.map(ev => (ev, ())), (_, onClose) => Modal.show( (closeMe, owner) => systemAddView(controller.actionsBus, closeMe, rds, controller.pos)(using owner), @@ -178,14 +180,26 @@ private final class MapView( modalKeyBinding( "Delete", controller.mapRole.map(RoleController.canRemoveSystem).combineWith(ws.isConnected).map(_ && _), - _.withCurrentValueOf(controller.selectedSystem).filterNot(_.isEmpty).map(_.get), + _.filterWith(controller.bulkSelectedSystemIds, _.isEmpty) + .filterWith(controller.selectedSystem, _.isDefined) + .withCurrentValueOf(controller.selectedSystem) + .map((ev, opt) => (ev, opt.get)), (system, onClose) => removeSystemConfirm(system, controller.actionsBus, onClose)(using rds) ), + // delete -> remove multiple systems + modalKeyBinding( + "Delete", + controller.mapRole.map(RoleController.canRemoveSystem).combineWith(ws.isConnected).map(_ && _), + _.filterWith(controller.bulkSelectedSystemIds, _.nonEmpty).withCurrentValueOf(controller.bulkSelectedSystemIds), + (systemIds, onClose) => removeMultipleSystems(systemIds, controller.actionsBus, onClose)(using rds) + ), // P -> paste system signatures modalKeyBinding( "KeyP", controller.mapRole.map(RoleController.canEditSignatures).combineWith(ws.isConnected).map(_ && _), - _.withCurrentValueOf(controller.selectedSystem).filterNot(_.isEmpty).map(_.get), + _.filterWith(controller.selectedSystem, _.isDefined) + .withCurrentValueOf(controller.selectedSystem) + .map((ev, opt) => (ev, opt.get)), { (system, onClose) => val solarSystem = static.solarSystemMap(system.system.systemId) Modal.show( @@ -205,21 +219,49 @@ private final class MapView( ) } ), + // R -> rename system + modalKeyBinding( + "KeyR", + controller.mapRole.map(RoleController.canRenameSystem).combineWith(ws.isConnected).map(_ && _), + _.filterWith(controller.selectedSystem, _.isDefined) + .withCurrentValueOf(controller.selectedSystem) + .map((ev, opt) => (ev, opt.get)), + (system, onClose) => + Modal.show( + (closeMe, owner) => + systemRenameView( + system.system.systemId, + system.system.name.getOrElse(""), + controller.actionsBus, + closeMe + ), + onClose, + true, + cls := "system-rename-dialog" + ) + ), div( idAttr := "map-parent", cls := "grid", cls := "g-20px", + toolbarView.view, inContext(self => - onClick.stopPropagation.compose(_.withCurrentValueOf(mapCtx.userPreferences)) --> ((ev, prefs) => + onPointerUp.compose(_.withCurrentValueOf(mapCtx.userPreferences)) --> ((ev, prefs) => + // TODO: unsure about the checks against self.ref - should always be true? + // note: this click handler cleans up any selection if (prefs.clickResetsSelection && ev.currentTarget == self.ref) Var.set(controller.selectedSystemId -> None, controller.selectedConnectionId -> None) + + if (ev.currentTarget == self.ref) + // always clean up the multiple system selection + controller.bulkSelectedSystemIds.set(Array.empty) ) ), - toolbarView.view, div( idAttr := "map-inner", children.command <-- systemNodes, + SelectionView(controller.selectedSystemId.signal, controller.bulkSelectedSystemIds.writer, systemNodes).view, svg.svg( svg.cls := "connection-container", svg.style := "position: absolute; left: 0px; top: 0px;", @@ -228,6 +270,9 @@ private final class MapView( svg.overflow := "visible", children.command <-- connectionNodes, connectionInProgress.view + ), + controller.bulkSelectedSystemIds.signal --> (sss => + org.scalajs.dom.console.log(s"ids selected: ${sss.mkString(",")}") ) ) ), @@ -241,12 +286,15 @@ private final class MapView( private def modalKeyBinding[B]( code: String, - isConnected: Signal[Boolean], - compose: EventStream[Unit] => EventStream[B], + shouldEnable: Signal[Boolean], + compose: EventStream[KeyboardEvent] => EventStream[(KeyboardEvent, B)], action: (B, Observer[Unit]) => Unit ) = - documentEvents(_.onKeyDown.filter(ev => !ev.repeat && ev.code == code).preventDefault).mapToUnit - .compose(es => compose(es).filterWith(isConnected).filterWith(notInKeyHandler.signal)) --> { b => + documentEvents( + _.onKeyDown + .filter(ev => !ev.repeat && ev.code == code && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) + ).compose(es => compose(es).filterWith(shouldEnable).filterWith(notInKeyHandler.signal)) --> { (ev, b) => + ev.preventDefault() inKeyHandler.set(true) action(b, Observer(_ => inKeyHandler.set(false))) } diff --git a/ui/src/main/scala/controltower/page/map/view/SelectionView.scala b/ui/src/main/scala/controltower/page/map/view/SelectionView.scala new file mode 100644 index 0000000..408d487 --- /dev/null +++ b/ui/src/main/scala/controltower/page/map/view/SelectionView.scala @@ -0,0 +1,165 @@ +package controltower.page.map.view + +import com.raquo.laminar.api.L.* +import controltower.page.map.Coord +import controltower.page.map.view.SelectionState.Selecting +import org.updraft0.controltower.constant.SystemId +import org.scalajs.dom +import scala.collection.mutable + +enum SelectionState derives CanEqual: + case Selecting(start: Coord, finish: Coord) + case Stopped + +case class ObserverState(rootElement: dom.Element, elements: mutable.ArrayBuffer[Element]) + +private val MouseButtonLeft = 0 +private val Threshold = 0.0 + +final class SelectionView( + singleSelected: Signal[Option[Long]], + 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) + + 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) + ) + ) + .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) + }.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 + }, + div( + cls := "selection-rectangle", + onMountUnmountCallback( + { ctx => + val parentParent = ctx.thisNode.maybeParent.get.ref.parentNode.asInstanceOf[dom.Element] + + Var.set( + (state, SelectionState.Stopped), + observerState -> ObserverState(parentParent, mutable.ArrayBuffer.empty) + ) + selection.onNext(Array.empty) + }, + { _ => + Var.update( + state -> ((_: SelectionState) => SelectionState.Stopped), + observerState -> { (obs: ObserverState) => null } + ) + selection.onNext(Array.empty) + } + ), + display <-- state.signal.map { + case SelectionState.Stopped => "none" + case _ => "" + }, + styleAttr <-- state.signal.map { + case SelectionState.Selecting(start, finish) => + 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) + + s"left: ${leftX}px; top: ${topY}px; width: ${rightX - leftX}px; height: ${bottomY - topY}px;" + case _ => "" + } + ) + ) + ) diff --git a/ui/src/main/scala/controltower/page/map/view/SystemView.scala b/ui/src/main/scala/controltower/page/map/view/SystemView.scala index 834da06..8dfc837 100644 --- a/ui/src/main/scala/controltower/page/map/view/SystemView.scala +++ b/ui/src/main/scala/controltower/page/map/view/SystemView.scala @@ -6,6 +6,7 @@ import controltower.backend.ESI import controltower.component.Modal import controltower.page.map.{Coord, MapAction, PositionController, RoleController} import controltower.ui.{ViewController, onEnterPress} +import org.updraft0.controltower.constant import org.updraft0.controltower.constant.{SystemId => _, *} import org.updraft0.controltower.protocol.* @@ -72,7 +73,8 @@ class SystemView( systemId: SystemId, system: Signal[MapSystemSnapshot], pos: PositionController, - selectedSystem: Signal[Option[SystemId]], + selectedSystem: Signal[Option[Long]], + bulkSelectedSystems: Signal[Array[constant.SystemId]], characters: Signal[Array[CharacterLocation]], connectingState: Var[MapNewConnectionState], isConnected: Signal[Boolean], @@ -123,12 +125,28 @@ class SystemView( idAttr := s"system-$systemId", cls := "system", cls("system-selected") <-- selectedSystem.map(_.exists(_ == systemId)), + cls("system-selected-bulk") <-- bulkSelectedSystems.map(_.exists(_.value == systemId)), // FIXME: this is also fired when we drag the element so selection is always changing - onClick.preventDefault.stopPropagation.mapToUnit --> ctx.actions.contramap(_ => - MapAction.Select(Some(systemId)) - ), - onDblClick.preventDefault.stopPropagation - .compose(_.sample(ctx.mapRole).filter(RoleController.canRenameSystem).withCurrentValueOf(system)) --> + onClick + .filter(ev => !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) + .preventDefault + .stopPropagation + .mapToUnit --> ctx.actions.contramap(_ => MapAction.Select(Some(systemId))), + onClick + .filter(ev => ev.ctrlKey && !ev.shiftKey && !ev.metaKey) + .preventDefault + .stopPropagation + .mapToUnit --> ctx.actions.contramap(_ => MapAction.ToggleBulkSelection(systemId)), + onDblClick + .filter(ev => !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) + .preventDefault + .stopPropagation + .compose( + _.filterWith(isConnected) + .sample(ctx.mapRole) + .filter(RoleController.canRenameSystem) + .withCurrentValueOf(system) + ) --> Observer({ case (_, mss: MapSystemSnapshot) => Modal.show( (closeMe, owner) => systemRenameView(systemId, mss.system.name.getOrElse(""), ctx.actions, closeMe), diff --git a/ui/src/main/scala/controltower/page/map/view/ToolbarView.scala b/ui/src/main/scala/controltower/page/map/view/ToolbarView.scala index 1ca49cc..6c66e7c 100644 --- a/ui/src/main/scala/controltower/page/map/view/ToolbarView.scala +++ b/ui/src/main/scala/controltower/page/map/view/ToolbarView.scala @@ -6,6 +6,7 @@ import controltower.component.Modal import controltower.db.ReferenceDataStore import controltower.page.map.{MapAction, PositionController, RoleController} import controltower.ui.* +import org.updraft0.controltower.constant import org.updraft0.controltower.protocol.* import scala.concurrent.ExecutionContext.Implicits.global @@ -243,3 +244,18 @@ private[map] def removeSystemConfirm(system: MapSystemSnapshot, actions: WriteBu isDestructive = true, onClose = onClose ) + +private[map] def removeMultipleSystems( + systemIds: Array[constant.SystemId], + actions: WriteBus[MapAction], + onClose: Observer[Unit] +)(using + rds: ReferenceDataStore +) = + Modal.showConfirmation( + "Remove multiple systems", + span(s"Remove ${systemIds.length} selected systems from map?"), + actions.contramap(_ => MapAction.RemoveMultiple(systemIds.map(_.value))), + isDestructive = true, + onClose = onClose + )