diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index 264e5fd..fd167e8 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -19,6 +19,7 @@ let platform = $derived(c.platform()); let textEditorCurrent = $derived(c.textEditor.current); let persistenceStatusStore = $derived(c.persistenceStatusStore()); + let marqueeRect = $derived(c.marqueeRect()); $effect(() => { c.setCanvasRef(canvasEl); @@ -70,6 +71,12 @@ spellcheck="false"> {/if} {/if} + {#if marqueeRect} +
+
+ {/if} diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts index e7bb202..aef6ef3 100644 --- a/apps/web/src/lib/canvas/canvas-store.svelte.ts +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -26,7 +26,7 @@ import { Store, TextTool, } from "inkfinite-core"; -import type { Action, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "inkfinite-core"; +import type { Action, Box2, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "inkfinite-core"; import { createRenderer, type Renderer } from "inkfinite-renderer"; import { onDestroy, onMount } from "svelte"; import { SvelteSet } from "svelte/reactivity"; @@ -58,6 +58,14 @@ export function createCanvasController(bindings: CanvasControllerBindings) { let activeBoardId: string | null = null; let desktopRepo: DesktopDocRepo | null = null; let removeBeforeUnload: (() => void) | null = null; + const handleResize = () => { + if (marqueeBounds) { + updateMarquee(marqueeBounds); + } + }; + if (typeof window !== "undefined") { + window.addEventListener("resize", handleResize); + } let webDb: InkfiniteDB | null = null; let canvas = $state(null); @@ -78,6 +86,26 @@ export function createCanvasController(bindings: CanvasControllerBindings) { const cursorStore = new CursorStore(); const snapStore: SnapStore = createSnapStore(); const brushStore: BrushStore = createBrushStore(); + type ScreenRect = { left: number; top: number; width: number; height: number }; + let marqueeBounds: Box2 | null = null; + let marqueeRect = $state(null); + + function updateMarquee(bounds: Box2 | null, cameraOverride?: Camera) { + marqueeBounds = bounds ? { min: { ...bounds.min }, max: { ...bounds.max } } : null; + if (!marqueeBounds) { + marqueeRect = null; + return; + } + const viewport = getViewport(); + const cameraState = cameraOverride ?? store.getState().camera; + const minScreen = Camera.worldToScreen(cameraState, marqueeBounds.min, viewport); + const maxScreen = Camera.worldToScreen(cameraState, marqueeBounds.max, viewport); + const left = Math.min(minScreen.x, maxScreen.x); + const top = Math.min(minScreen.y, maxScreen.y); + const width = Math.abs(maxScreen.x - minScreen.x); + const height = Math.abs(maxScreen.y - minScreen.y); + marqueeRect = { left, top, width, height }; + } function getViewport(): Viewport { if (canvas) { @@ -117,7 +145,10 @@ export function createCanvasController(bindings: CanvasControllerBindings) { })); } - const selectTool = new SelectTool(); + const handleMarqueeChange = (bounds: Box2 | null) => { + updateMarquee(bounds); + }; + const selectTool = new SelectTool(handleMarqueeChange); const rectTool = new RectTool(); const ellipseTool = new EllipseTool(); const lineTool = new LineTool(); @@ -136,6 +167,11 @@ export function createCanvasController(bindings: CanvasControllerBindings) { const textEditor = new TextEditorController(store, getViewport, refreshCursor); const toolController = new ToolController(store, tools); + const unsubscribeMarqueeCamera = store.subscribe((state) => { + if (marqueeBounds) { + updateMarquee(marqueeBounds, state.camera); + } + }); const history = new HistoryController(bindings); const desktop = new DesktopFileController(() => repo, () => desktopRepo, (boardId, doc) => { setActiveBoardId(boardId); @@ -521,7 +557,11 @@ export function createCanvasController(bindings: CanvasControllerBindings) { renderer?.dispose(); inputAdapter?.dispose(); persistenceManager?.dispose(); + unsubscribeMarqueeCamera(); removeBeforeUnload?.(); + if (typeof window !== "undefined") { + window.removeEventListener("resize", handleResize); + } fallbackStatusStore.update(() => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 })); persistenceStatusStore = fallbackStatusStore; }); @@ -542,5 +582,6 @@ export function createCanvasController(bindings: CanvasControllerBindings) { snapStore, brushStore, setCanvasRef, + marqueeRect: () => marqueeRect, }; } diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index f903e56..2a84004 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -55,8 +55,10 @@ const MIN_RESIZE_SIZE = 5; export class SelectTool implements Tool { readonly id: ToolId = "select"; private toolState: SelectToolState; + private readonly marqueeListener?: (bounds: Box2 | null) => void; - constructor() { + constructor(onMarqueeChange?: (bounds: Box2 | null) => void) { + this.marqueeListener = onMarqueeChange; this.toolState = { isDragging: false, dragStartWorld: null, @@ -198,6 +200,7 @@ export class SelectTool implements Tool { if (!isShiftHeld) { this.toolState.marqueeStart = action.world; this.toolState.marqueeEnd = action.world; + this.notifyMarqueeChange(); return { ...state, ui: { ...state.ui, selectionIds: [] } }; } @@ -283,6 +286,7 @@ export class SelectTool implements Tool { if (action.type !== "pointer-move") return state; this.toolState.marqueeEnd = action.world; + this.notifyMarqueeChange(); return state; } @@ -310,6 +314,7 @@ export class SelectTool implements Tool { this.toolState.initialShapePositions.clear(); this.toolState.marqueeStart = null; this.toolState.marqueeEnd = null; + this.notifyMarqueeChange(); return newState; } @@ -410,6 +415,7 @@ export class SelectTool implements Tool { rotationCenter: null, rotationStartAngle: null, }; + this.notifyMarqueeChange(); } /** @@ -420,6 +426,12 @@ export class SelectTool implements Tool { return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); } + private notifyMarqueeChange(): void { + if (this.marqueeListener) { + this.marqueeListener(this.getMarqueeBounds()); + } + } + getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { const hit = this.hitTestHandle(state, point); return hit?.handle ?? null;