web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(wip): stencil palette with drag-and-drop functionality for stencils.

+1014 -247
+6 -2
TODO.txt
··· 141 141 - spawn: function (atPoint, scale) -> ShapeRecords[] (group) 142 142 (A stencil can insert 1 shape or a grouped set.) 143 143 144 - [ ] Create initial categories (v0): 144 + [ ] Create initial categories: 145 145 - Flowchart: process, decision, terminator, data, document 146 146 - Diagrams: server, db, queue, user, browser, mobile 147 147 - UI: button, input, card, modal ··· 162 162 [ ] Placement rules: 163 163 - insert into active layer (if layers exist) 164 164 - snap to grid if enabled 165 + [ ] Toolbar 166 + - Open menu to insert stencils with button to open drawer 167 + [ ] Drawer 168 + - Stencil selection with search, category filter, and thumbnail previews 165 169 166 170 (DoD): Inserting stencils is faster than drawing shapes manually. 167 171 ··· 182 186 -------------------------------------------------------------------------------- 183 187 184 188 [ ] Render stencil previews in the panel: 185 - - v0: small SVG thumbnails (best) OR draw to offscreen canvas 189 + - small SVG thumbnails (best) & draw to offscreen canvas 186 190 187 191 (DoD): Users can recognize stencils instantly. 188 192
+2 -2
apps/web/.prettierrc
··· 6 6 "objectWrap": "collapse", 7 7 "tabWidth": 2, 8 8 "bracketSameLine": true, 9 - "plugins": [ "prettier-plugin-svelte" ], 10 - "overrides": [ { "files": "*.svelte", "options": { "parser": "svelte" } } ] 9 + "plugins": ["prettier-plugin-svelte"], 10 + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 11 11 }
+63 -44
apps/web/src/app.css
··· 129 129 --border: var(--line-numbers); 130 130 --accent: var(--accent-blue); 131 131 --accent-hover: var(--accent-cyan); 132 - color-scheme: dark; 132 + color-scheme: dark; 133 133 } 134 134 135 135 * { ··· 143 143 background-color: var(--surface); 144 144 color: var(--text); 145 145 line-height: 1.5; 146 - -webkit-font-smoothing: antialiased; 147 - -moz-osx-font-smoothing: grayscale; 146 + -webkit-font-smoothing: antialiased; 147 + -moz-osx-font-smoothing: grayscale; 148 148 } 149 149 150 150 button { ··· 158 158 159 159 /* Markdown Styling */ 160 160 .markdown-body { 161 - color: var(--text); 162 - font-size: 1rem; 163 - line-height: 1.6; 161 + color: var(--text); 162 + font-size: 1rem; 163 + line-height: 1.6; 164 164 } 165 165 166 166 .markdown-body h1, ··· 169 169 .markdown-body h4, 170 170 .markdown-body h5, 171 171 .markdown-body h6 { 172 - margin-top: 1.5em; 173 - margin-bottom: 0.5em; 174 - font-weight: 600; 175 - line-height: 1.25; 172 + margin-top: 1.5em; 173 + margin-bottom: 0.5em; 174 + font-weight: 600; 175 + line-height: 1.25; 176 176 } 177 177 178 - .markdown-body h1 { font-size: 2em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } 179 - .markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } 180 - .markdown-body h3 { font-size: 1.25em; } 181 - .markdown-body h4 { font-size: 1em; } 178 + .markdown-body h1 { 179 + font-size: 2em; 180 + border-bottom: 1px solid var(--border); 181 + padding-bottom: 0.3em; 182 + } 183 + .markdown-body h2 { 184 + font-size: 1.5em; 185 + border-bottom: 1px solid var(--border); 186 + padding-bottom: 0.3em; 187 + } 188 + .markdown-body h3 { 189 + font-size: 1.25em; 190 + } 191 + .markdown-body h4 { 192 + font-size: 1em; 193 + } 182 194 183 195 .markdown-body p { 184 - margin-bottom: 1em; 196 + margin-bottom: 1em; 185 197 } 186 198 187 199 .markdown-body ul, 188 200 .markdown-body ol { 189 - padding-left: 2em; 190 - margin-bottom: 1em; 201 + padding-left: 2em; 202 + margin-bottom: 1em; 191 203 } 192 204 193 205 .markdown-body blockquote { 194 - padding: 0 1em; 195 - color: var(--text-muted); 196 - border-left: 0.25em solid var(--border); 197 - margin-bottom: 1em; 206 + padding: 0 1em; 207 + color: var(--text-muted); 208 + border-left: 0.25em solid var(--border); 209 + margin-bottom: 1em; 198 210 } 199 211 200 212 .markdown-body code { 201 - padding: 0.2em 0.4em; 202 - margin: 0; 203 - font-size: 85%; 204 - background-color: var(--bg-secondary); 205 - border-radius: 6px; 206 - font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 213 + padding: 0.2em 0.4em; 214 + margin: 0; 215 + font-size: 85%; 216 + background-color: var(--bg-secondary); 217 + border-radius: 6px; 218 + font-family: 219 + ui-monospace, 220 + SFMono-Regular, 221 + SF Mono, 222 + Menlo, 223 + Consolas, 224 + Liberation Mono, 225 + monospace; 207 226 } 208 227 209 228 .markdown-body pre { 210 - padding: 16px; 211 - overflow: auto; 212 - font-size: 85%; 213 - line-height: 1.45; 214 - background-color: var(--bg-secondary); 215 - border-radius: 6px; 216 - margin-bottom: 1em; 229 + padding: 16px; 230 + overflow: auto; 231 + font-size: 85%; 232 + line-height: 1.45; 233 + background-color: var(--bg-secondary); 234 + border-radius: 6px; 235 + margin-bottom: 1em; 217 236 } 218 237 219 238 .markdown-body pre code { 220 - background-color: transparent; 221 - padding: 0; 239 + background-color: transparent; 240 + padding: 0; 222 241 } 223 242 224 243 .markdown-body a { 225 - color: var(--accent); 226 - text-decoration: none; 244 + color: var(--accent); 245 + text-decoration: none; 227 246 } 228 247 229 248 .markdown-body a:hover { 230 - text-decoration: underline; 249 + text-decoration: underline; 231 250 } 232 251 233 252 .markdown-body hr { 234 - height: 0.25em; 235 - padding: 0; 236 - margin: 24px 0; 237 - background-color: var(--border); 238 - border: 0; 253 + height: 0.25em; 254 + padding: 0; 255 + margin: 24px 0; 256 + background-color: var(--border); 257 + border: 0; 239 258 } 240 259 241 260 * {
+1
apps/web/src/lib/assets/grid-dots.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
+1
apps/web/src/lib/assets/search.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
+33 -2
apps/web/src/lib/canvas/Canvas.svelte
··· 3 3 import StatusBar from '$lib/components/StatusBar.svelte'; 4 4 import Toolbar from '$lib/components/Toolbar.svelte'; 5 5 import FileBrowser from '$lib/filebrowser/FileBrowser.svelte'; 6 + import StencilPalette from '$lib/components/StencilPalette.svelte'; 6 7 import { createCanvasController } from './canvas-store.svelte.ts'; 8 + import { draggingStencil, endDrag } from '$lib/dnd.svelte'; 9 + import { Camera } from 'inkfinite-core'; 7 10 8 11 let canvasEl = $state<HTMLCanvasElement | null>(null); 9 12 let textEditorEl = $state<HTMLTextAreaElement | null>(null); ··· 43 46 c.markdownEditor.setRef(markdownEditorEl); 44 47 return () => c.markdownEditor.setRef(null); 45 48 }); 49 + 50 + function handleDrop(e: DragEvent) { 51 + e.preventDefault(); 52 + const stencil = draggingStencil.current; 53 + if (!stencil || !canvasEl) return; 54 + 55 + const rect = canvasEl.getBoundingClientRect(); 56 + const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; 57 + const viewport = c.getViewport(); 58 + const world = Camera.screenToWorld(c.store.getState().camera, screen, viewport); 59 + 60 + c.insertStencil(stencil, world); 61 + endDrag(); 62 + } 63 + 64 + function handleStencilsClick() { 65 + c.stencilPaletteOpen = !c.stencilPaletteOpen; 66 + } 46 67 </script> 47 68 48 69 <div class="editor"> ··· 60 81 currentTool={c.tools.currentToolId} 61 82 onToolChange={c.tools.handleChange} 62 83 onHistoryClick={c.history.handleClick} 84 + onStencilsClick={handleStencilsClick} 63 85 store={c.store} 64 86 getViewport={c.getViewport} 65 87 canvas={canvasEl ?? undefined} 66 88 brushStore={c.brushStore} /> 67 - <div class="canvas-container"> 89 + <div 90 + class="canvas-container" 91 + ondragover={(e) => { 92 + e.preventDefault(); 93 + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; 94 + }} 95 + ondrop={handleDrop} 96 + role="application"> 68 97 <canvas 69 98 bind:this={canvasEl} 70 99 ondblclick={c.handleCanvasDoubleClick} ··· 157 186 bind:vm={c.fileBrowser.vm} 158 187 bind:open={c.fileBrowser.open} 159 188 onUpdate={c.fileBrowser.handleUpdate} 160 - fetchInspectorData={c.fileBrowser.fetchInspectorData} 161 189 onClose={c.fileBrowser.handleClose} 162 190 desktopRepo={c.desktop.repo} /> 163 191 {/if} 192 + <StencilPalette 193 + bind:open={c.stencilPaletteOpen} 194 + onClose={() => (c.stencilPaletteOpen = false)} /> 164 195 </div> 165 196 166 197 <style>
+50
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 29 29 TextTool, 30 30 } from "inkfinite-core"; 31 31 import type { Action, Box2, LoadedDoc, PersistenceSink, PersistentDocRepo, Viewport } from "inkfinite-core"; 32 + import { stencils } from "inkfinite-core"; 32 33 import { createRenderer, type Renderer } from "inkfinite-renderer"; 34 + 35 + type Stencil = stencils.Stencil; 33 36 import { onDestroy, onMount } from "svelte"; 34 37 import { SvelteSet } from "svelte/reactivity"; 35 38 import { computeCursor, describeAction, getCommandKind, statesEqual } from "./canvas-helpers"; ··· 62 65 let activeBoardId: string | null = null; 63 66 let desktopRepo: DesktopDocRepo | null = null; 64 67 let removeBeforeUnload: (() => void) | null = null; 68 + let stencilPaletteOpen = $state(false); 65 69 const handleResize = () => { 66 70 if (marqueeBounds) { 67 71 updateMarquee(marqueeBounds); ··· 630 634 brushStore, 631 635 setCanvasRef, 632 636 marqueeRect: () => marqueeRect, 637 + insertStencil, 638 + get stencilPaletteOpen() { 639 + return stencilPaletteOpen; 640 + }, 641 + set stencilPaletteOpen(val: boolean) { 642 + stencilPaletteOpen = val; 643 + }, 633 644 }; 645 + 646 + function insertStencil(stencil: Stencil, worldPos: { x: number; y: number }) { 647 + const state = store.getState(); 648 + const pageId = state.ui.currentPageId; 649 + if (!pageId) return; 650 + 651 + const shapes = stencil.spawn(worldPos); 652 + const groupId = shapes.length > 1 ? createId("group") : undefined; 653 + 654 + const newShapes = { ...state.doc.shapes }; 655 + const page = state.doc.pages[pageId]; 656 + if (!page) return; 657 + 658 + const newPageShapeIds = [...page.shapeIds]; 659 + const newSelection: string[] = []; 660 + 661 + for (const shape of shapes) { 662 + shape.pageId = pageId; 663 + if (groupId) { 664 + shape.groupId = groupId; 665 + } 666 + newShapes[shape.id] = shape; 667 + newPageShapeIds.push(shape.id); 668 + newSelection.push(shape.id); 669 + } 670 + 671 + const nextState = { 672 + ...state, 673 + doc: { 674 + ...state.doc, 675 + shapes: newShapes, 676 + pages: { ...state.doc.pages, [pageId]: { ...page, shapeIds: newPageShapeIds } }, 677 + }, 678 + ui: { ...state.ui, selectionIds: newSelection }, 679 + }; 680 + 681 + const command = new SnapshotCommand("Insert Stencil", "doc", state, nextState); 682 + store.executeCommand(command); 683 + } 634 684 }
+5 -1
apps/web/src/lib/components/Dialog.svelte
··· 61 61 </script> 62 62 63 63 {#if open} 64 - <div class="dialog__backdrop" role="presentation" onclick={handleBackdropClick} onkeydown={handleKeyDown}> 64 + <div 65 + class="dialog__backdrop" 66 + role="presentation" 67 + onclick={handleBackdropClick} 68 + onkeydown={handleKeyDown}> 65 69 <div 66 70 bind:this={dialogElement} 67 71 class="dialog__content {className}"
+17 -3
apps/web/src/lib/components/Icon.svelte
··· 1 1 <script lang="ts"> 2 2 import CloseIcon from '$lib/assets/close.svg?raw'; 3 3 import FolderIcon from '$lib/assets/folder.svg?raw'; 4 - import GripVerticalIcon from '$lib/assets/grip-vertical.svg?raw'; 4 + import GridDotsIcon from '$lib/assets/grid-dots.svg?raw'; 5 5 import InfoCircleIcon from '$lib/assets/info-circle.svg?raw'; 6 6 import MoonIcon from '$lib/assets/moon.svg?raw'; 7 7 import PencilIcon from '$lib/assets/pencil.svg?raw'; 8 + import SearchIcon from '$lib/assets/search.svg?raw'; 8 9 import SunIcon from '$lib/assets/sun.svg?raw'; 9 10 import TrashIcon from '$lib/assets/trash.svg?raw'; 11 + import GripVerticalIcon from '$lib/assets/grip-vertical.svg?raw'; 10 12 11 - export type IconName = 'close' | 'folder' | 'grip-vertical' | 'info-circle' | 'moon' | 'pencil' | 'sun' | 'trash'; 13 + export type IconName = 14 + | 'close' 15 + | 'folder' 16 + | 'grid-dots' 17 + | 'grip-vertical' 18 + | 'info-circle' 19 + | 'moon' 20 + | 'pencil' 21 + | 'search' 22 + | 'sun' 23 + | 'trash'; 12 24 13 25 type Props = { name: IconName; size?: number; color?: string }; 14 26 ··· 17 29 const icons: Record<IconName, string> = { 18 30 close: CloseIcon, 19 31 folder: FolderIcon, 20 - 'grip-vertical': GripVerticalIcon, 32 + 'grid-dots': GridDotsIcon, 33 + 'grip-vertical': GripVerticalIcon, 21 34 'info-circle': InfoCircleIcon, 22 35 moon: MoonIcon, 23 36 pencil: PencilIcon, 37 + search: SearchIcon, 24 38 sun: SunIcon, 25 39 trash: TrashIcon 26 40 };
+4 -2
apps/web/src/lib/components/Sheet.svelte
··· 12 12 closeOnBackdrop?: boolean; 13 13 closeOnEscape?: boolean; 14 14 class?: string; 15 + backdropClass?: string; 15 16 children?: Snippet; 16 17 }; 17 18 ··· 23 24 side = 'right', 24 25 closeOnBackdrop = true, 25 26 closeOnEscape = true, 26 - class: className = '' 27 + class: className = '', 28 + backdropClass = '' 27 29 }: Props = $props(); 28 30 29 31 let sheetElement = $state<HTMLDivElement>(); ··· 61 63 62 64 {#if open} 63 65 <div 64 - class="sheet__backdrop" 66 + class="sheet__backdrop {backdropClass}" 65 67 role="presentation" 66 68 onclick={handleBackdropClick} 67 69 onkeydown={handleKeyDown}>
+6 -6
apps/web/src/lib/components/StatusBar.svelte
··· 209 209 .status-bar__toggle input { 210 210 margin: 0; 211 211 cursor: pointer; 212 - opacity: 0.8; 212 + opacity: 0.8; 213 213 } 214 214 215 - .status-bar__toggle:hover input { 216 - opacity: 1; 217 - } 215 + .status-bar__toggle:hover input { 216 + opacity: 1; 217 + } 218 218 219 219 .status-bar__toggle input:focus { 220 220 outline: 2px solid var(--accent); ··· 226 226 color: var(--text-muted); 227 227 text-transform: uppercase; 228 228 letter-spacing: 0.075em; 229 - font-weight: 600; 229 + font-weight: 600; 230 230 } 231 231 232 232 .status-bar__value { 233 233 font-weight: 500; 234 234 color: var(--text); 235 - font-variant-numeric: tabular-nums; 235 + font-variant-numeric: tabular-nums; 236 236 } 237 237 238 238 .status-bar__value--error {
+393
apps/web/src/lib/components/StencilPalette.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { stencils } from 'inkfinite-core'; 4 + import { startDrag, endDrag, draggingStencil } from '../dnd.svelte'; 5 + import Sheet from '$lib/components/Sheet.svelte'; 6 + import Icon from '$lib/components/Icon.svelte'; 7 + 8 + type Stencil = stencils.Stencil; 9 + const { registry, registerBuiltinStencils } = stencils; 10 + 11 + let { open = $bindable(false), onClose }: { open: boolean; onClose: () => void } = $props(); 12 + 13 + let categories = $state([] as string[]); 14 + let stencilsByCategory = $state({} as Record<string, Stencil[]>); 15 + let searchQuery = $state(''); 16 + 17 + onMount(() => { 18 + registerBuiltinStencils(); 19 + refreshStencils(); 20 + }); 21 + 22 + function refreshStencils() { 23 + const allStencils = registry.search(searchQuery); 24 + const grouped: Record<string, Stencil[]> = {}; 25 + const cats = new Set<string>(); 26 + 27 + for (const stencil of allStencils) { 28 + if (!grouped[stencil.category]) { 29 + grouped[stencil.category] = []; 30 + cats.add(stencil.category); 31 + } 32 + grouped[stencil.category].push(stencil); 33 + } 34 + 35 + categories = Array.from(cats).sort(); 36 + stencilsByCategory = grouped; 37 + } 38 + 39 + function handleSearchInput(e: Event) { 40 + searchQuery = (e.target as HTMLInputElement).value; 41 + refreshStencils(); 42 + } 43 + 44 + function onDragStart(e: DragEvent, stencil: Stencil) { 45 + if (e.dataTransfer) { 46 + e.dataTransfer.effectAllowed = 'copy'; 47 + } 48 + startDrag(stencil); 49 + } 50 + 51 + function closePalette() { 52 + open = false; 53 + onClose?.(); 54 + } 55 + </script> 56 + 57 + <Sheet 58 + bind:open 59 + onClose={closePalette} 60 + side="left" 61 + title="Stencils" 62 + class="stencil-palette-sheet" 63 + backdropClass={draggingStencil.current 64 + ? 'pointer-events-none bg-transparent transition-none' 65 + : ''}> 66 + <div class="palette"> 67 + <div class="palette__header"> 68 + <div class="palette__title-row"> 69 + <h2 class="palette__title">Components</h2> 70 + <button 71 + class="palette__close" 72 + type="button" 73 + onclick={closePalette} 74 + aria-label="Close stencil palette"> 75 + <Icon name="close" size={20} color="#e27878" /> 76 + </button> 77 + </div> 78 + </div> 79 + 80 + <div class="palette__search"> 81 + <div class="palette__search-wrapper"> 82 + <div class="palette__search-icon"> 83 + <Icon name="search" size={14} /> 84 + </div> 85 + <input 86 + type="text" 87 + class="palette__search-input" 88 + placeholder="Filter components..." 89 + bind:value={searchQuery} 90 + oninput={handleSearchInput} 91 + aria-label="Filter components" /> 92 + </div> 93 + </div> 94 + 95 + <div class="palette__content custom-scrollbar"> 96 + <div class="palette__list"> 97 + {#each categories as category} 98 + <div class="palette__category"> 99 + <h3 class="palette__category-title"> 100 + <span class="palette__category-dot"></span> 101 + {category} 102 + </h3> 103 + <div class="palette__grid"> 104 + {#each stencilsByCategory[category] as stencil} 105 + <div 106 + role="button" 107 + tabindex="0" 108 + draggable="true" 109 + ondragstart={(e) => onDragStart(e, stencil)} 110 + ondragend={endDrag} 111 + class="palette__item" 112 + title={stencil.name}> 113 + <div class="palette__item-preview"> 114 + <div class="palette__item-preview-content"> 115 + {@html stencil.preview.data} 116 + </div> 117 + </div> 118 + <span class="palette__item-name"> 119 + {stencil.name} 120 + </span> 121 + 122 + <div class="palette__item-hover-ring"></div> 123 + </div> 124 + {/each} 125 + </div> 126 + </div> 127 + {/each} 128 + 129 + {#if categories.length === 0} 130 + <div class="palette__empty"> 131 + <Icon name="search" size={24} color="var(--text-muted, #6c757d)" /> 132 + <span>No components found</span> 133 + </div> 134 + {/if} 135 + </div> 136 + </div> 137 + </div> 138 + </Sheet> 139 + 140 + <style> 141 + :global(.stencil-palette-sheet) { 142 + padding: 0; 143 + width: 288px; /* w-72 */ 144 + } 145 + 146 + .palette { 147 + display: flex; 148 + flex-direction: column; 149 + height: 100%; 150 + background-color: var(--surface, #ffffff); 151 + color: var(--text, #333333); 152 + } 153 + 154 + .palette__header { 155 + display: flex; 156 + align-items: center; 157 + justify-content: space-between; 158 + padding: 1rem; 159 + border-bottom: 1px solid var(--border, #e0e0e0); 160 + background-color: var(--surface, #ffffff); 161 + } 162 + 163 + .palette__title-row { 164 + display: flex; 165 + align-items: center; 166 + justify-content: space-between; 167 + width: 100%; 168 + } 169 + 170 + .palette__title { 171 + margin: 0; 172 + font-size: 0.75rem; /* text-xs */ 173 + font-weight: 700; 174 + text-transform: uppercase; 175 + letter-spacing: 0.05em; 176 + color: var(--text-secondary, #666); 177 + } 178 + 179 + .palette__close { 180 + background: none; 181 + border: 1px solid transparent; 182 + cursor: pointer; 183 + padding: 0.25rem; 184 + border-radius: 0.25rem; 185 + display: flex; 186 + align-items: center; 187 + } 188 + 189 + .palette__close:hover { 190 + background-color: var(--surface-hover, #f5f5f5); 191 + } 192 + 193 + .palette__search { 194 + padding: 0.75rem 1rem; 195 + border-bottom: 1px solid var(--border, #e0e0e0); 196 + background-color: var(--surface, #ffffff); 197 + } 198 + 199 + .palette__search-wrapper { 200 + position: relative; 201 + } 202 + 203 + .palette__search-icon { 204 + position: absolute; 205 + left: 0.625rem; 206 + top: 50%; 207 + transform: translateY(-50%); 208 + pointer-events: none; 209 + color: var(--text-muted, #9ca3af); 210 + display: flex; 211 + } 212 + 213 + .palette__search-input { 214 + width: 100%; 215 + padding: 0.375rem 0.75rem 0.375rem 2.25rem; 216 + font-size: 0.875rem; 217 + background-color: var(--surface-secondary, #f9f9f9); 218 + border: 1px solid var(--border, #e0e0e0); 219 + border-radius: 0.375rem; 220 + color: var(--text, #333); 221 + transition: all 0.2s; 222 + box-sizing: border-box; 223 + } 224 + 225 + .palette__search-input:focus { 226 + outline: none; 227 + border-color: var(--primary, #007bff); 228 + background-color: var(--surface, #ffffff); 229 + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1); 230 + } 231 + 232 + .palette__content { 233 + flex: 1; 234 + overflow-y: auto; 235 + } 236 + 237 + .palette__list { 238 + padding: 0.75rem; 239 + display: flex; 240 + flex-direction: column; 241 + gap: 1.5rem; 242 + } 243 + 244 + .palette__category { 245 + display: flex; 246 + flex-direction: column; 247 + gap: 0.5rem; 248 + } 249 + 250 + .palette__category-title { 251 + display: flex; 252 + align-items: center; 253 + gap: 0.5rem; 254 + font-size: 0.625rem; /* ~10px */ 255 + font-weight: 700; 256 + text-transform: uppercase; 257 + letter-spacing: 0.1em; 258 + color: var(--text-muted, #9ca3af); 259 + padding-left: 0.25rem; 260 + margin: 0; 261 + } 262 + 263 + .palette__category-dot { 264 + width: 0.375rem; 265 + height: 0.375rem; 266 + border-radius: 50%; 267 + background-color: var(--border-dark, #cbd5e1); 268 + } 269 + 270 + .palette__grid { 271 + display: grid; 272 + grid-template-columns: repeat(2, 1fr); 273 + gap: 0.5rem; 274 + } 275 + 276 + .palette__item { 277 + position: relative; 278 + display: flex; 279 + flex-direction: column; 280 + align-items: center; 281 + padding: 0.5rem; 282 + background-color: var(--surface, #ffffff); 283 + border: 1px solid var(--border, #e0e0e0); 284 + border-radius: 0.5rem; 285 + cursor: grab; 286 + user-select: none; 287 + transition: all 0.2s; 288 + } 289 + 290 + .palette__item:hover { 291 + border-color: var(--primary-light, #60a5fa); 292 + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); 293 + } 294 + 295 + .palette__item:active { 296 + cursor: grabbing; 297 + } 298 + 299 + .palette__item-preview { 300 + width: 100%; 301 + aspect-ratio: 4/3; 302 + display: flex; 303 + align-items: center; 304 + justify-content: center; 305 + margin-bottom: 0.5rem; 306 + background-color: var(--surface-secondary, #f9f9f9); 307 + border-radius: 0.25rem; 308 + overflow: hidden; 309 + transition: background-color 0.2s; 310 + } 311 + 312 + .palette__item:hover .palette__item-preview { 313 + background-color: var(--surface-hover, #f0f9ff); 314 + } 315 + 316 + .palette__item-preview-content { 317 + transform: scale(0.75); 318 + transform-origin: center; 319 + pointer-events: none; 320 + color: var(--text, #333); 321 + width: 100%; 322 + height: 100%; 323 + display: flex; 324 + align-items: center; 325 + justify-content: center; 326 + } 327 + 328 + .palette__item-preview-content :global(svg) { 329 + width: 100%; 330 + height: 100%; 331 + } 332 + 333 + .palette__item-name { 334 + font-size: 0.75rem; 335 + font-weight: 500; 336 + color: var(--text-secondary, #4b5563); 337 + width: 100%; 338 + text-align: center; 339 + white-space: nowrap; 340 + overflow: hidden; 341 + text-overflow: ellipsis; 342 + transition: color 0.2s; 343 + } 344 + 345 + .palette__item:hover .palette__item-name { 346 + color: var(--primary, #007bff); 347 + } 348 + 349 + .palette__item-hover-ring { 350 + position: absolute; 351 + inset: 0; 352 + border: 2px solid var(--primary, #007bff); 353 + border-radius: 0.5rem; 354 + opacity: 0; 355 + pointer-events: none; 356 + transition: opacity 0.2s; 357 + } 358 + 359 + .palette__item:hover .palette__item-hover-ring { 360 + opacity: 0.1; 361 + } 362 + 363 + .palette__empty { 364 + display: flex; 365 + flex-direction: column; 366 + align-items: center; 367 + justify-content: center; 368 + padding: 3rem 0; 369 + color: var(--text-muted, #9ca3af); 370 + gap: 0.5rem; 371 + font-size: 0.875rem; 372 + font-weight: 500; 373 + opacity: 0.7; 374 + } 375 + 376 + /* Scrollbar styling to match Tailwind's scrollbar-thin */ 377 + .custom-scrollbar::-webkit-scrollbar { 378 + width: 6px; 379 + } 380 + 381 + .custom-scrollbar::-webkit-scrollbar-track { 382 + background: transparent; 383 + } 384 + 385 + .custom-scrollbar::-webkit-scrollbar-thumb { 386 + background-color: var(--border, #e0e0e0); 387 + border-radius: 3px; 388 + } 389 + 390 + .custom-scrollbar::-webkit-scrollbar-thumb:hover { 391 + background-color: var(--text-muted, #9ca3af); 392 + } 393 + </style>
+178 -172
apps/web/src/lib/components/Toolbar.svelte
··· 10 10 } from '$lib/constants'; 11 11 import type { Platform } from '$lib/platform'; 12 12 import type { BrushSettings, BrushStore } from '$lib/status'; 13 - import { themeStore } from '$lib/theme.svelte'; 13 + import { themeStore } from '$lib/theme.svelte'; 14 14 import type { 15 15 ArrowShape, 16 16 BoardMeta, ··· 33 33 shapeBounds, 34 34 SnapshotCommand 35 35 } from 'inkfinite-core'; 36 - import { fade } from 'svelte/transition'; 36 + import { fade } from 'svelte/transition'; 37 37 import icon from '../assets/favicon.svg'; 38 38 import ArrowPopover from './ArrowPopover.svelte'; 39 39 import BrushPopover from './BrushPopover.svelte'; ··· 61 61 platform?: Platform; 62 62 desktop?: DesktopControls; 63 63 onOpenBrowser?: () => void; 64 + onStencilsClick?: () => void; 64 65 }; 65 66 66 67 let { ··· 73 74 brushStore, 74 75 platform = 'web', 75 76 desktop, 76 - onOpenBrowser 77 + onOpenBrowser, 78 + onStencilsClick 77 79 }: Props = $props(); 78 80 79 81 let editorState = $derived<EditorStateType>(store.getState()); ··· 130 132 } 131 133 }); 132 134 133 - let showColorControls = $derived( 134 - toolSupportsStyles(currentTool) || 135 - toolSupportsFill(currentTool) || 136 - getSelectedShapes(editorState).some(s => shapeSupportsFill(s) || shapeSupportsStroke(s)) 137 - ); 135 + let showColorControls = $derived( 136 + toolSupportsStyles(currentTool) || 137 + toolSupportsFill(currentTool) || 138 + getSelectedShapes(editorState).some((s) => shapeSupportsFill(s) || shapeSupportsStroke(s)) 139 + ); 138 140 139 141 let position = $state({ x: 20, y: 20 }); 140 142 let isDragging = $state(false); ··· 181 183 182 184 function handleDragStart(event: PointerEvent) { 183 185 isDragging = true; 184 - dragOffset = { 185 - x: event.clientX - position.x, 186 - y: event.clientY - position.y 187 - }; 186 + dragOffset = { x: event.clientX - position.x, y: event.clientY - position.y }; 188 187 189 - if(typeof document !== 'undefined') document.body.style.userSelect = 'none'; 190 - 188 + if (typeof document !== 'undefined') document.body.style.userSelect = 'none'; 191 189 192 - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); 190 + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); 193 191 } 194 192 195 193 function handleDragMove(event: PointerEvent) { 196 194 if (!isDragging) return; 197 - position = { 198 - x: event.clientX - dragOffset.x, 199 - y: event.clientY - dragOffset.y 200 - }; 195 + position = { x: event.clientX - dragOffset.x, y: event.clientY - dragOffset.y }; 201 196 } 202 197 203 198 function handleDragEnd(event: PointerEvent) { 204 199 isDragging = false; 205 - if(typeof document !== 'undefined') document.body.style.userSelect = ''; 206 - (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); 200 + if (typeof document !== 'undefined') document.body.style.userSelect = ''; 201 + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); 207 202 } 208 203 209 204 function openInfo() { ··· 347 342 ); 348 343 } 349 344 350 - function toolSupportsStyles(tool: ToolId): boolean { 351 - return ( 352 - tool === 'rect' || 353 - tool === 'ellipse' || 354 - tool === 'line' || 355 - tool === 'arrow' 356 - ); 357 - } 345 + function toolSupportsStyles(tool: ToolId): boolean { 346 + return tool === 'rect' || tool === 'ellipse' || tool === 'line' || tool === 'arrow'; 347 + } 358 348 359 - function toolSupportsFill(tool: ToolId): boolean { 360 - return tool === 'rect' || tool === 'ellipse' || tool === 'text'; 361 - } 349 + function toolSupportsFill(tool: ToolId): boolean { 350 + return tool === 'rect' || tool === 'ellipse' || tool === 'text'; 351 + } 362 352 363 353 function getSharedColor<T extends ShapeRecord>( 364 354 shapes: T[], ··· 488 478 } 489 479 </script> 490 480 491 - <div 492 - class="toolbar" 493 - role="toolbar" 494 - aria-label="Drawing tools" 495 - bind:this={toolbarEl} 496 - style="position: fixed; left: {position.x}px; top: {position.y}px;" 497 - data-dragging={isDragging} 498 - > 499 - <!-- Drag Handle --> 500 - <div 501 - class="toolbar__drag-handle" 502 - onpointerdown={handleDragStart} 503 - onpointermove={handleDragMove} 504 - onpointerup={handleDragEnd} 505 - aria-label="Drag toolbar" 506 - role="button" 507 - tabindex="0" 508 - > 509 - <Icon name="grip-vertical" size={16} /> 510 - </div> 481 + <div 482 + class="toolbar" 483 + role="toolbar" 484 + aria-label="Drawing tools" 485 + bind:this={toolbarEl} 486 + style="position: fixed; left: {position.x}px; top: {position.y}px;" 487 + data-dragging={isDragging}> 488 + <!-- Drag Handle --> 489 + <div 490 + class="toolbar__drag-handle" 491 + onpointerdown={handleDragStart} 492 + onpointermove={handleDragMove} 493 + onpointerup={handleDragEnd} 494 + aria-label="Drag toolbar" 495 + role="button" 496 + tabindex="0"> 497 + <Icon name="grip-vertical" size={16} /> 498 + </div> 511 499 512 500 <div class="toolbar__brand"> 513 501 <div class="toolbar__logo"> ··· 572 560 </button> 573 561 {/each} 574 562 575 - {#if showColorControls} 576 - <div class="toolbar__colors" aria-label="Color controls" transition:fade={{ duration: 150 }}> 577 - {#if toolSupportsFill(currentTool) || getSelectedShapes(editorState).some(shapeSupportsFill)} 578 - <label class="toolbar__color-control"> 579 - <span>Fill</span> 580 - <input 581 - type="color" 582 - value={fillColorValue} 583 - onchange={handleFillChange} 584 - disabled={fillDisabled && !toolSupportsFill(currentTool)} 585 - aria-label="Fill color" /> 586 - </label> 587 - {/if} 588 - {#if toolSupportsStyles(currentTool) || getSelectedShapes(editorState).some(shapeSupportsStroke)} 589 - <label class="toolbar__color-control"> 590 - <span>Stroke</span> 591 - <input 592 - type="color" 593 - value={strokeColorValue} 594 - onchange={handleStrokeChange} 595 - disabled={strokeDisabled && !toolSupportsStyles(currentTool)} 596 - aria-label="Stroke color" /> 597 - </label> 598 - {/if} 599 - </div> 600 - {/if} 563 + {#if showColorControls} 564 + <div class="toolbar__colors" aria-label="Color controls" transition:fade={{ duration: 150 }}> 565 + {#if toolSupportsFill(currentTool) || getSelectedShapes(editorState).some(shapeSupportsFill)} 566 + <label class="toolbar__color-control"> 567 + <span>Fill</span> 568 + <input 569 + type="color" 570 + value={fillColorValue} 571 + onchange={handleFillChange} 572 + disabled={fillDisabled && !toolSupportsFill(currentTool)} 573 + aria-label="Fill color" /> 574 + </label> 575 + {/if} 576 + {#if toolSupportsStyles(currentTool) || getSelectedShapes(editorState).some(shapeSupportsStroke)} 577 + <label class="toolbar__color-control"> 578 + <span>Stroke</span> 579 + <input 580 + type="color" 581 + value={strokeColorValue} 582 + onchange={handleStrokeChange} 583 + disabled={strokeDisabled && !toolSupportsStyles(currentTool)} 584 + aria-label="Stroke color" /> 585 + </label> 586 + {/if} 587 + </div> 588 + {/if} 601 589 602 590 <div class="toolbar__divider"></div> 603 591 ··· 687 675 </div> 688 676 689 677 <div class="toolbar__info-actions"> 690 - <button 691 - class="toolbar__info" 692 - onclick={() => themeStore.toggle()} 693 - aria-label="Toggle Dark Mode" 694 - title="Toggle Dark Mode"> 695 - <Icon name={themeStore.current === 'dark' ? 'sun' : 'moon'} size={16} /> 678 + <button 679 + class="toolbar__info" 680 + onclick={() => themeStore.toggle()} 681 + aria-label="Toggle Dark Mode" 682 + title="Toggle Dark Mode"> 683 + <Icon name={themeStore.current === 'dark' ? 'sun' : 'moon'} size={16} /> 696 684 <span class="toolbar__info-label">{themeStore.current === 'dark' ? 'Light' : 'Dark'}</span> 697 - </button> 685 + </button> 698 686 {#if platform === 'web' && onOpenBrowser} 699 687 <button class="toolbar__info" onclick={onOpenBrowser} aria-label="Browse boards"> 700 688 <Icon name="folder" size={16} /> ··· 717 705 aria-pressed="false"> 718 706 <span class="toolbar__tool-icon">⏱</span> 719 707 <span class="toolbar__tool-label">History</span> 708 + </button> 709 + {/if} 710 + {#if onStencilsClick} 711 + <button 712 + class="toolbar__tool-button tool-button" 713 + onclick={onStencilsClick} 714 + aria-label="Stencils" 715 + title="Stencils"> 716 + <span class="toolbar__tool-icon"> 717 + <Icon name="grid-dots" size={18} /> 718 + </span> 719 + <span class="toolbar__tool-label">Stencils</span> 720 720 </button> 721 721 {/if} 722 722 </div> ··· 761 761 padding: 0.75rem 1rem 0.75rem 0.25rem; /* Adjusted padding for handle */ 762 762 background: var(--surface-elevated); 763 763 border-bottom: 1px solid var(--border); 764 - border: 1px solid var(--border); 765 - border-radius: 0.75rem; 764 + border: 1px solid var(--border); 765 + border-radius: 0.75rem; 766 766 align-items: center; 767 - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 768 - z-index: 100; 769 - transition: transform 0.1s; 770 - touch-action: none; 767 + box-shadow: 768 + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 769 + 0 2px 4px -1px rgba(0, 0, 0, 0.06); 770 + z-index: 100; 771 + transition: transform 0.1s; 772 + touch-action: none; 771 773 } 772 - 773 - .toolbar[data-dragging="true"] { 774 - transform: scale(1.02); 775 - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); 776 - } 777 774 778 - .toolbar__drag-handle { 779 - display: flex; 780 - align-items: center; 781 - justify-content: center; 782 - width: 24px; 783 - height: 100%; 784 - cursor: grab; 785 - color: var(--text-muted); 786 - opacity: 0.5; 787 - transition: opacity 0.2s; 788 - touch-action: none; 789 - } 790 - 791 - .toolbar__drag-handle:hover { 792 - opacity: 1; 793 - color: var(--text); 794 - } 795 - 796 - .toolbar[data-dragging="true"] .toolbar__drag-handle { 797 - cursor: grabbing; 798 - opacity: 1; 799 - color: var(--accent); 800 - } 775 + .toolbar[data-dragging='true'] { 776 + transform: scale(1.02); 777 + box-shadow: 778 + 0 20px 25px -5px rgba(0, 0, 0, 0.1), 779 + 0 10px 10px -5px rgba(0, 0, 0, 0.04); 780 + } 801 781 802 - .toolbar__brand { 803 - display: flex; 804 - align-items: center; 805 - gap: 0.75rem; 806 - margin-right: 1.5rem; 807 - } 782 + .toolbar__drag-handle { 783 + display: flex; 784 + align-items: center; 785 + justify-content: center; 786 + width: 24px; 787 + height: 100%; 788 + cursor: grab; 789 + color: var(--text-muted); 790 + opacity: 0.5; 791 + transition: opacity 0.2s; 792 + touch-action: none; 793 + } 808 794 809 - .toolbar__logo img { 810 - width: 32px; 811 - height: 32px; 812 - } 795 + .toolbar__drag-handle:hover { 796 + opacity: 1; 797 + color: var(--text); 798 + } 813 799 814 - .toolbar__name { 815 - font-weight: 600; 816 - font-size: 1.125rem; 817 - letter-spacing: -0.025em; 818 - color: var(--text); 819 - } 800 + .toolbar[data-dragging='true'] .toolbar__drag-handle { 801 + cursor: grabbing; 802 + opacity: 1; 803 + color: var(--accent); 804 + } 805 + 806 + .toolbar__brand { 807 + display: flex; 808 + align-items: center; 809 + gap: 0.75rem; 810 + margin-right: 1.5rem; 811 + } 812 + 813 + .toolbar__logo img { 814 + width: 32px; 815 + height: 32px; 816 + } 817 + 818 + .toolbar__name { 819 + font-weight: 600; 820 + font-size: 1.125rem; 821 + letter-spacing: -0.025em; 822 + color: var(--text); 823 + } 820 824 821 - .toolbar__tagline { 822 - font-size: 0.75rem; 823 - color: var(--text-muted); 824 - font-weight: 500; 825 - } 825 + .toolbar__tagline { 826 + font-size: 0.75rem; 827 + color: var(--text-muted); 828 + font-weight: 500; 829 + } 826 830 827 831 .toolbar__tool-button { 828 832 display: flex; ··· 837 841 cursor: pointer; 838 842 transition: all 0.2s ease; 839 843 min-width: 68px; 840 - opacity: 0.8; 844 + opacity: 0.8; 841 845 } 842 846 843 847 .toolbar__tool-button:hover { 844 848 background: var(--bg-tertiary); 845 849 color: var(--text); 846 - opacity: 1; 847 - border-color: var(--text-muted); 850 + opacity: 1; 851 + border-color: var(--text-muted); 848 852 } 849 853 850 854 .toolbar__tool-button:focus { ··· 856 860 .tool-button.active { 857 861 background: var(--accent); 858 862 color: var(--surface); 859 - border-color: var(--accent); 860 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 861 - opacity: 1; 863 + border-color: var(--accent); 864 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 865 + opacity: 1; 862 866 } 863 867 864 868 .toolbar__tool-icon { ··· 868 872 869 873 .toolbar__tool-label { 870 874 font-size: 0.75rem; 871 - font-weight: 500; 875 + font-weight: 500; 872 876 line-height: 1; 873 877 white-space: nowrap; 874 878 } ··· 878 882 background-color: var(--border); 879 883 margin: 0 1.25rem; 880 884 height: 32px; 881 - opacity: 0.5; 885 + opacity: 0.5; 886 + } 887 + 888 + .toolbar__info { 889 + display: flex; 890 + align-items: center; 891 + gap: 0.5rem; 892 + padding: 0.5rem 0.75rem; 893 + border-radius: 0.375rem; 894 + background: transparent; 895 + border: none; 896 + color: var(--text-muted); 897 + cursor: pointer; 898 + transition: color 0.2s; 899 + font-size: 0.875rem; 882 900 } 883 901 884 - .toolbar__info { 885 - display: flex; 886 - align-items: center; 887 - gap: 0.5rem; 888 - padding: 0.5rem 0.75rem; 889 - border-radius: 0.375rem; 890 - background: transparent; 891 - border: none; 892 - color: var(--text-muted); 893 - cursor: pointer; 894 - transition: color 0.2s; 895 - font-size: 0.875rem; 896 - } 897 - 898 - .toolbar__info:hover { 899 - background: var(--bg-tertiary); 900 - color: var(--text); 901 - } 902 + .toolbar__info:hover { 903 + background: var(--bg-tertiary); 904 + color: var(--text); 905 + } 902 906 903 907 .toolbar__zoom, 904 908 .toolbar__export { ··· 914 918 border-radius: 0.375rem; 915 919 cursor: pointer; 916 920 font-size: 0.875rem; 917 - font-weight: 500; 921 + font-weight: 500; 918 922 min-width: 72px; 919 - transition: all 0.2s; 923 + transition: all 0.2s; 920 924 } 921 925 922 926 .toolbar__zoom-button:hover, 923 927 .toolbar__export-button:hover { 924 928 background: var(--bg-tertiary); 925 - border-color: var(--text-muted); 929 + border-color: var(--text-muted); 926 930 } 927 931 928 932 .toolbar__zoom-menu, ··· 934 938 color: var(--text); 935 939 border: 1px solid var(--border); 936 940 border-radius: 0.5rem; 937 - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 941 + box-shadow: 942 + 0 10px 15px -3px rgba(0, 0, 0, 0.1), 943 + 0 4px 6px -2px rgba(0, 0, 0, 0.05); 938 944 padding: 0.5rem; 939 945 display: flex; 940 946 flex-direction: column; 941 947 gap: 0.25rem; 942 - min-width: 160px; 943 - z-index: 20; 948 + min-width: 160px; 949 + z-index: 20; 944 950 z-index: 10; 945 951 min-width: 150px; 946 952 }
+19
apps/web/src/lib/dnd.svelte.ts
··· 1 + import { stencils } from "inkfinite-core"; 2 + 3 + type Stencil = stencils.Stencil; 4 + 5 + let currentStencil = $state<Stencil | null>(null); 6 + 7 + export const draggingStencil = { 8 + get current() { 9 + return currentStencil; 10 + }, 11 + }; 12 + 13 + export function startDrag(stencil: Stencil) { 14 + currentStencil = stencil; 15 + } 16 + 17 + export function endDrag() { 18 + currentStencil = null; 19 + }
+2 -2
apps/web/src/routes/+layout.svelte
··· 4 4 import '../app.css'; 5 5 6 6 let { children } = $props(); 7 - 8 - const _ = themeStore; 7 + 8 + const _ = themeStore; 9 9 </script> 10 10 11 11 <svelte:head>
+9 -5
apps/web/src/routes/+page.svelte
··· 3 3 </script> 4 4 5 5 <div class="editor"> 6 - <Canvas /> 6 + <div class="canvas-wrapper"> 7 + <Canvas /> 8 + </div> 7 9 </div> 8 10 9 11 <style> 10 - .editor { 11 - position: fixed; 12 - top: 0; 13 - left: 0; 12 + :global(body), 13 + :global(html) { 14 + margin: 0; 15 + padding: 0; 14 16 width: 100vw; 15 17 height: 100vh; 16 18 overflow: hidden; 19 + position: relative; 20 + min-width: 0; 17 21 } 18 22 </style>
+1
packages/core/src/index.ts
··· 12 12 export * from "./persistence/stats"; 13 13 export * from "./persistence/web"; 14 14 export * from "./reactivity"; 15 + export * as stencils from "./stencils"; 15 16 export * from "./tools"; 16 17 export * from "./ui/filebrowser"; 17 18 export * from "./ui/statusbar";
+9 -1
packages/core/src/model.ts
··· 116 116 export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; 117 117 118 118 export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke" | "markdown"; 119 - export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number }; 119 + export type BaseShape = { 120 + id: string; 121 + type: ShapeType; 122 + pageId: string; 123 + x: number; 124 + y: number; 125 + rot: number; 126 + groupId?: string; 127 + }; 120 128 export type RectShape = BaseShape & { type: "rect"; props: RectProps }; 121 129 export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps }; 122 130 export type LineShape = BaseShape & { type: "line"; props: LineProps };
+127
packages/core/src/stencils/definitions.ts
··· 1 + import { Vec2 } from "../math"; 2 + import { ShapeRecord } from "../model"; 3 + 4 + import { registry } from "./registry"; 5 + import type { Stencil } from "./types"; 6 + 7 + const processStencil: Stencil = { 8 + id: "flowchart:process", 9 + name: "Process", 10 + category: "Flowchart", 11 + tags: ["rect", "box", "action"], 12 + preview: { 13 + kind: "svg", 14 + data: 15 + `<svg viewBox="0 0 100 60"><rect x="2" y="2" width="96" height="56" fill="none" stroke="currentColor" stroke-width="2"/></svg>`, 16 + }, 17 + spawn: ( 18 + at: Vec2, 19 + ) => [ 20 + ShapeRecord.createRect("placeholder_page", at.x, at.y, { 21 + w: 120, 22 + h: 80, 23 + fill: "#ffffff", 24 + stroke: "#000000", 25 + radius: 0, 26 + }), 27 + ], 28 + }; 29 + 30 + const decisionStencil: Stencil = { 31 + id: "flowchart:decision", 32 + name: "Decision", 33 + category: "Flowchart", 34 + tags: ["diamond", "if", "branch"], 35 + preview: { 36 + kind: "svg", 37 + data: 38 + `<svg viewBox="0 0 100 60"><path d="M50 2 L98 30 L50 58 L2 30 Z" fill="none" stroke="currentColor" stroke-width="2"/></svg>`, 39 + }, 40 + spawn: (at: Vec2) => { 41 + const shape = ShapeRecord.createRect("placeholder_page", at.x, at.y, { 42 + w: 80, 43 + h: 80, 44 + fill: "#ffffff", 45 + stroke: "#000000", 46 + radius: 0, 47 + }); 48 + shape.rot = Math.PI / 4; 49 + return [shape]; 50 + }, 51 + }; 52 + 53 + const terminatorStencil: Stencil = { 54 + id: "flowchart:terminator", 55 + name: "Terminator", 56 + category: "Flowchart", 57 + tags: ["ellipse", "start", "end"], 58 + preview: { 59 + kind: "svg", 60 + data: 61 + `<svg viewBox="0 0 100 60"><rect x="2" y="2" width="96" height="56" rx="28" fill="none" stroke="currentColor" stroke-width="2"/></svg>`, 62 + }, 63 + spawn: ( 64 + at: Vec2, 65 + ) => [ 66 + ShapeRecord.createRect("placeholder_page", at.x, at.y, { 67 + w: 120, 68 + h: 60, 69 + fill: "#ffffff", 70 + stroke: "#000000", 71 + radius: 30, 72 + }), 73 + ], 74 + }; 75 + 76 + const stickyNoteStencil: Stencil = { 77 + id: "etc:stickynote", 78 + name: "Sticky Note", 79 + category: "Etc", 80 + tags: ["note", "memo", "yellow"], 81 + preview: { 82 + kind: "svg", 83 + data: `<svg viewBox="0 0 100 100"><rect x="2" y="2" width="96" height="96" fill="#fff740" stroke="none"/></svg>`, 84 + }, 85 + spawn: ( 86 + at: Vec2, 87 + ) => [ 88 + ShapeRecord.createRect("placeholder_page", at.x, at.y, { 89 + w: 200, 90 + h: 200, 91 + fill: "#fff740", 92 + stroke: "transparent", 93 + radius: 0, 94 + }), 95 + ], 96 + }; 97 + 98 + const cardStencil: Stencil = { 99 + id: "ui:card", 100 + name: "Card", 101 + category: "UI", 102 + tags: ["container", "panel"], 103 + preview: { 104 + kind: "svg", 105 + data: 106 + `<svg viewBox="0 0 100 80"><rect x="2" y="2" width="96" height="76" rx="4" fill="none" stroke="currentColor" stroke-width="2"/><line x1="2" y1="20" x2="98" y2="20" stroke="currentColor" stroke-width="1"/></svg>`, 107 + }, 108 + spawn: ( 109 + at: Vec2, 110 + ) => [ 111 + ShapeRecord.createRect("placeholder_page", at.x, at.y, { 112 + w: 300, 113 + h: 200, 114 + fill: "#ffffff", 115 + stroke: "#dddddd", 116 + radius: 8, 117 + }), 118 + ], 119 + }; 120 + 121 + export function registerBuiltinStencils() { 122 + registry.register(processStencil); 123 + registry.register(decisionStencil); 124 + registry.register(terminatorStencil); 125 + registry.register(stickyNoteStencil); 126 + registry.register(cardStencil); 127 + }
+3
packages/core/src/stencils/index.ts
··· 1 + export * from "./definitions"; 2 + export * from "./registry"; 3 + export * from "./types";
+31
packages/core/src/stencils/registry.ts
··· 1 + import type { Stencil } from "./types"; 2 + 3 + class StencilRegistry { 4 + private stencils = new Map<string, Stencil>(); 5 + 6 + register(stencil: Stencil) { 7 + if (this.stencils.has(stencil.id)) { 8 + console.warn(`Stencil with id ${stencil.id} already registered. Overwriting.`); 9 + } 10 + this.stencils.set(stencil.id, stencil); 11 + } 12 + 13 + get(id: string): Stencil | undefined { 14 + return this.stencils.get(id); 15 + } 16 + 17 + getAll(): Stencil[] { 18 + return Array.from(this.stencils.values()); 19 + } 20 + 21 + search(query: string): Stencil[] { 22 + const q = query.toLowerCase(); 23 + return this.getAll().filter((s) => 24 + s.name.toLowerCase().includes(q) 25 + || s.category.toLowerCase().includes(q) 26 + || s.tags.some((t) => t.toLowerCase().includes(q)) 27 + ); 28 + } 29 + } 30 + 31 + export const registry = new StencilRegistry();
+17
packages/core/src/stencils/types.ts
··· 1 + import { Vec2 } from "../math"; 2 + import { ShapeRecord } from "../model"; 3 + 4 + export type StencilCategory = "Flowchart" | "Diagrams" | "UI" | "Etc"; 5 + 6 + export interface Stencil { 7 + id: string; 8 + name: string; 9 + category: StencilCategory; 10 + tags: string[]; 11 + preview: { kind: "svg" | "canvas"; data: string }; 12 + /** 13 + * Create the shapes for this stencil at the given position. 14 + * If multiple shapes are returned, they should ideally share a groupId. 15 + */ 16 + spawn: (atPoint: Vec2) => ShapeRecord[]; 17 + }
+37 -5
packages/core/src/tools/select.ts
··· 180 180 private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { 181 181 if (action.type !== "pointer-down") return state; 182 182 183 + const clickedShape = state.doc.shapes[shapeId]; 184 + if (!clickedShape) return state; 185 + 183 186 const isShiftHeld = action.modifiers.shift; 184 - const isAlreadySelected = state.ui.selectionIds.includes(shapeId); 187 + 188 + let idsToInteractWith: string[] = [shapeId]; 189 + if (clickedShape.groupId) { 190 + idsToInteractWith = Object.values(state.doc.shapes).filter((s) => s.groupId === clickedShape.groupId).map((s) => 191 + s.id 192 + ); 193 + } 194 + 195 + const isAnySelected = idsToInteractWith.some(id => state.ui.selectionIds.includes(id)); 185 196 186 197 let newSelectionIds: string[]; 187 198 188 199 if (isShiftHeld) { 189 - newSelectionIds = isAlreadySelected 190 - ? state.ui.selectionIds.filter((id) => id !== shapeId) 191 - : [...state.ui.selectionIds, shapeId]; 200 + if (isAnySelected) { 201 + newSelectionIds = state.ui.selectionIds.filter((id) => !idsToInteractWith.includes(id)); 202 + } else { 203 + newSelectionIds = [...state.ui.selectionIds, ...idsToInteractWith]; 204 + } 192 205 } else { 193 - newSelectionIds = isAlreadySelected ? state.ui.selectionIds : [shapeId]; 206 + if (isAnySelected && !isShiftHeld) { 207 + newSelectionIds = state.ui.selectionIds; 208 + } else { 209 + newSelectionIds = idsToInteractWith; 210 + } 211 + } 212 + 213 + if (isShiftHeld) { 214 + const shouldSelect = !isAnySelected; 215 + if (shouldSelect) { 216 + newSelectionIds = [...new Set([...state.ui.selectionIds, ...idsToInteractWith])]; 217 + } else { 218 + newSelectionIds = state.ui.selectionIds.filter(id => !idsToInteractWith.includes(id)); 219 + } 220 + } else { 221 + if (isAnySelected) { 222 + newSelectionIds = state.ui.selectionIds; 223 + } else { 224 + newSelectionIds = idsToInteractWith; 225 + } 194 226 } 195 227 196 228 this.toolState.isDragging = true;