From 207c84c7c42b077e8fb267f198980686bc31ad0b Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 26 Dec 2025 18:53:26 -0600 Subject: [PATCH] feat: stencil enhancements * ungroup functionality with kb shortcut * grid snapping for stencil placement * stencil dnd recovery * update default stencil definitions. --- TODO.txt | 74 +------------------ apps/web/src/lib/canvas/Canvas.svelte | 12 ++- .../web/src/lib/canvas/canvas-store.svelte.ts | 51 ++++++++++++- apps/web/src/lib/components/Toolbar.svelte | 13 ++++ packages/core/src/stencils/definitions.ts | 8 +- 5 files changed, 83 insertions(+), 75 deletions(-) diff --git a/TODO.txt b/TODO.txt index 3d56ce9..3a6026b 100644 --- a/TODO.txt +++ b/TODO.txt @@ -130,87 +130,17 @@ Goal: Ship a curated set of built-in stencils (flowchart + UI + dev diagrams) with a pleasant insertion workflow. No sharing/community libraries yet—just "your own". --------------------------------------------------------------------------------- -S1. Stencil definition format (core) --------------------------------------------------------------------------------- - -/packages/core/src/stencils: -[x] Define Stencil: - - id, name, category, tags[] - - preview: { kind: 'svg'|'canvas', data } - - spawn: function (atPoint, scale) -> ShapeRecords[] (group) - (A stencil can insert 1 shape or a grouped set.) - -[x] Create initial categories: - - Flowchart: process, decision, terminator, data, document - - Diagrams: server, db, queue, user, browser, mobile - - UI: button, input, card, modal - - Etc: Post-It style/sticky notes, index cards, speech bubble - -(DoD): Stencils load as data and can spawn shapes deterministically. - -------------------------------------------------------------------------------- S2. Insert UX -------------------------------------------------------------------------------- /apps/web: -[x] Stencils drawer/palette: - - search (name + tags) - - category filter - - click inserts at viewport center OR - drag ghost preview onto canvas and drop [ ] Placement rules: - - insert into active layer (if layers exist) - - snap to grid if enabled -[x] Toolbar - - Open menu to insert stencils with button to open drawer -[x] Drawer - - Stencil selection with search, category filter, and thumbnail previews + - insert into active layer (when layers are added) + - [x] snap to grid if enabled (DoD): Inserting stencils is faster than drawing shapes manually. --------------------------------------------------------------------------------- -S3. Grouping behavior --------------------------------------------------------------------------------- - -[ ] When a stencil spawns multiple shapes: - - create a GroupRecord OR a "groupId" on shapes (your existing grouping - model) - - allow move as one unit - - ungroup command - -(DoD): Multi-shape stencils behave like a single object until ungrouped. - --------------------------------------------------------------------------------- -S4. Preview rendering --------------------------------------------------------------------------------- - -[x] Render stencil previews in the panel: - - small SVG thumbnails (best) & draw to offscreen canvas - -(DoD): Users can recognize stencils instantly. - --------------------------------------------------------------------------------- -S5. Persistence + versioning --------------------------------------------------------------------------------- - -[ ] Stencils are "code assets": - - version them with the app - - inserted shapes are normal shapes (no dependency on stencil after - insertion) - -(DoD): Old docs do not break if you change stencil definitions later. - --------------------------------------------------------------------------------- -S6. Tests --------------------------------------------------------------------------------- - -[ ] spawn() returns valid records with unique ids and correct initial positions -[ ] group insert produces expected selection and undo/redo works -[ ] search indexing returns correct stencils for tag queries - -(DoD): Stencils are reliable and don’t corrupt docs. - ================================================================================ Parking Lot *wb-pl* ================================================================================ diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index b8e1b08..305f3d8 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -54,7 +54,17 @@ dataTransferTypes: e.dataTransfer?.types }); e.preventDefault(); - const stencil = draggingStencil.current; + + let stencil = draggingStencil.current; + + if (!stencil && e.dataTransfer) { + const stencilId = e.dataTransfer.getData('application/x-inkfinite-stencil'); + if (stencilId) { + console.log('[Canvas] Recovering stencil from dataTransfer:', stencilId); + stencil = stencils.registry.get(stencilId) ?? null; + } + } + console.log('[Canvas] Dragging stencil state:', stencil); if (!stencil || !canvasEl) { diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts index 4f55bbc..4047b1c 100644 --- a/apps/web/src/lib/canvas/canvas-store.svelte.ts +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -306,6 +306,44 @@ export function createCanvasController(bindings: CanvasControllerBindings) { return { ...state, doc: { ...state.doc, pages: { ...state.doc.pages, [pageId]: { ...page, shapeIds } } } }; } + function ungroupSelection(state: EditorState): EditorState | null { + const selectionIds = state.ui.selectionIds; + if (selectionIds.length === 0) { + return null; + } + + const groupsToDissolve = new Set(); + const shapes = state.doc.shapes; + + for (const id of selectionIds) { + const shape = shapes[id]; + if (shape && shape.groupId) { + groupsToDissolve.add(shape.groupId); + } + } + + if (groupsToDissolve.size === 0) { + return null; + } + + const newShapes = { ...shapes }; + let changed = false; + + for (const id in newShapes) { + const shape = newShapes[id]; + if (shape.groupId && groupsToDissolve.has(shape.groupId)) { + const newShape = { ...shape }; + delete newShape.groupId; + newShapes[id] = newShape; + changed = true; + } + } + + if (!changed) return null; + + return { ...state, doc: { ...state.doc, shapes: newShapes } }; + } + function handleKeyboardShortcuts(state: EditorState, action: Action): EditorState | null { if (action.type !== "key-down") { return null; @@ -372,6 +410,10 @@ export function createCanvasController(bindings: CanvasControllerBindings) { return reorderSelection(state, "backward"); } + if (primaryModifier && action.modifiers.shift && (action.key === "g" || action.key === "G")) { + return ungroupSelection(state); + } + return null; } @@ -644,11 +686,18 @@ export function createCanvasController(bindings: CanvasControllerBindings) { }; function insertStencil(stencil: Stencil, worldPos: { x: number; y: number }) { + const snap = snapStore.get(); + let pos = { ...worldPos }; + if (snap.snapEnabled && snap.gridEnabled) { + const gridSize = snap.gridSize; + pos = { x: Math.round(pos.x / gridSize) * gridSize, y: Math.round(pos.y / gridSize) * gridSize }; + } + const state = store.getState(); const pageId = state.ui.currentPageId; if (!pageId) return; - const shapes = stencil.spawn(worldPos); + const shapes = stencil.spawn(pos); const groupId = shapes.length > 1 ? createId("group") : undefined; const newShapes = { ...state.doc.shapes }; diff --git a/apps/web/src/lib/components/Toolbar.svelte b/apps/web/src/lib/components/Toolbar.svelte index d902ff5..98ea3a8 100644 --- a/apps/web/src/lib/components/Toolbar.svelte +++ b/apps/web/src/lib/components/Toolbar.svelte @@ -1125,4 +1125,17 @@ flex-direction: column; gap: 0.25rem; } + + .toolbar__colors { + display: flex; + flex-direction: column; + gap: 8px; + } + + .toolbar__color-control { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + text-align: right; + } diff --git a/packages/core/src/stencils/definitions.ts b/packages/core/src/stencils/definitions.ts index 27edfc2..8db7156 100644 --- a/packages/core/src/stencils/definitions.ts +++ b/packages/core/src/stencils/definitions.ts @@ -112,9 +112,15 @@ const cardStencil: Stencil = { w: 300, h: 200, fill: "#ffffff", - stroke: "#dddddd", + stroke: "#333333", radius: 8, }), + ShapeRecord.createLine("placeholder_page", at.x, at.y + 50, { + a: { x: 0, y: 0 }, + b: { x: 300, y: 0 }, + stroke: "#333333", + width: 1, + }), ], }; -- 2.51.2