web based infinite canvas
2
fork

Configure Feed

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

feat: brush/pen ui

+595 -36
+6 -4
TODO.txt
··· 339 339 ------------------------------------------------------------------------------ 340 340 341 341 /apps/web/src/lib/components/BrushPopover.svelte: 342 - [ ] Sliders: size, thinning, smoothing, streamline 343 - [ ] Toggle: simulatePressure 342 + [x] Sliders: size, thinning, smoothing, streamline 343 + [x] Toggle: simulatePressure 344 344 (All map to perfect-freehand options.) 345 345 346 - (DoD): settings affect newly drawn strokes immediately. 346 + (DoD): 347 + - settings affect newly drawn strokes immediately. 348 + - tests in BrushPopover.svelte.test.ts 347 349 348 350 ------------------------------------------------------------------------------ 349 351 T7. Tests ··· 365 367 (DoD): All tests passing 366 368 367 369 Integration: 368 - [ ] one history command per stroke; undo/redo persists through refresh (M). 370 + [x] one history command per stroke (tested in pen-tool.test.ts) 369 371 370 372 ------------------------------------------------------------------------------ 371 373 Definition of Done
+2 -1
apps/web/src/lib/canvas/Canvas.svelte
··· 49 49 onHistoryClick={c.history.handleClick} 50 50 store={c.store} 51 51 getViewport={c.getViewport} 52 - canvas={canvasEl ?? undefined} /> 52 + canvas={canvasEl ?? undefined} 53 + brushStore={c.brushStore} /> 53 54 <div class="canvas-container"> 54 55 <canvas 55 56 bind:this={canvasEl}
+7 -6
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 2 2 import type { InputAdapter } from "$lib/input"; 3 3 import type { DesktopDocRepo } from "$lib/persistence/desktop"; 4 4 import { createPlatformRepo, detectPlatform } from "$lib/platform"; 5 - import { createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; 6 - import type { SnapStore, StatusStore } from "$lib/status"; 5 + import { createBrushStore, createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; 6 + import type { BrushStore, SnapStore, StatusStore } from "$lib/status"; 7 7 import { 8 8 ArrowTool, 9 9 Camera, ··· 16 16 getShapesOnCurrentPage, 17 17 InkfiniteDB, 18 18 LineTool, 19 + PenTool, 19 20 RectTool, 20 21 routeAction, 21 22 SelectTool, ··· 76 77 77 78 const cursorStore = new CursorStore(); 78 79 const snapStore: SnapStore = createSnapStore(); 80 + const brushStore: BrushStore = createBrushStore(); 79 81 80 82 function getViewport(): Viewport { 81 83 if (canvas) { ··· 121 123 const lineTool = new LineTool(); 122 124 const arrowTool = new ArrowTool(); 123 125 const textTool = new TextTool(); 124 - const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 126 + const penTool = new PenTool(() => brushStore.get()); 127 + const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool, penTool]); 125 128 126 129 const textEditor = new TextEditorController(store, getViewport, refreshCursor); 127 130 const toolController = new ToolController(store, tools); ··· 246 249 247 250 const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 248 251 249 - // Global shortcuts (work regardless of selection) 250 252 if (primaryModifier && (action.key === "o" || action.key === "O")) { 251 - // Open file browser 252 253 fileBrowser.handleOpen(); 253 254 return null; 254 255 } 255 256 256 257 if (primaryModifier && (action.key === "n" || action.key === "N")) { 257 - // New board - open file browser in create mode 258 258 fileBrowser.handleOpen(); 259 259 return null; 260 260 } ··· 532 532 cursorStore, 533 533 persistenceStatusStore: () => persistenceStatusStore, 534 534 snapStore, 535 + brushStore, 535 536 setCanvasRef, 536 537 }; 537 538 }
+319
apps/web/src/lib/components/BrushPopover.svelte
··· 1 + <script lang="ts"> 2 + import type { BrushConfig } from 'inkfinite-core'; 3 + 4 + type Props = { 5 + brush: BrushConfig; 6 + onBrushChange: (brush: BrushConfig) => void; 7 + disabled?: boolean; 8 + }; 9 + 10 + let { brush, onBrushChange, disabled = false }: Props = $props(); 11 + 12 + let isOpen = $state(false); 13 + let popoverEl = $state<HTMLDivElement | null>(null); 14 + let buttonEl = $state<HTMLButtonElement | null>(null); 15 + 16 + let size = $derived(brush.size); 17 + let thinning = $derived(brush.thinning); 18 + let smoothing = $derived(brush.smoothing); 19 + let streamline = $derived(brush.streamline); 20 + let simulatePressure = $derived(brush.simulatePressure); 21 + 22 + $effect(() => { 23 + size = brush.size; 24 + thinning = brush.thinning; 25 + smoothing = brush.smoothing; 26 + streamline = brush.streamline; 27 + simulatePressure = brush.simulatePressure; 28 + }); 29 + 30 + $effect(() => { 31 + if (!isOpen || typeof document === 'undefined') { 32 + return; 33 + } 34 + const handlePointerDown = (event: PointerEvent) => { 35 + const target = event.target as Node | null; 36 + if (!target) { 37 + return; 38 + } 39 + if (popoverEl?.contains(target) || buttonEl?.contains(target)) { 40 + return; 41 + } 42 + isOpen = false; 43 + }; 44 + 45 + document.addEventListener('pointerdown', handlePointerDown); 46 + return () => document.removeEventListener('pointerdown', handlePointerDown); 47 + }); 48 + 49 + function togglePopover() { 50 + if (!disabled) { 51 + isOpen = !isOpen; 52 + } 53 + } 54 + 55 + function handleSizeInput(event: Event) { 56 + const input = event.currentTarget as HTMLInputElement; 57 + size = Number(input.value); 58 + } 59 + 60 + function handleSizeChange() { 61 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 62 + } 63 + 64 + function handleThinningInput(event: Event) { 65 + const input = event.currentTarget as HTMLInputElement; 66 + thinning = Number(input.value); 67 + } 68 + 69 + function handleThinningChange() { 70 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 71 + } 72 + 73 + function handleSmoothingInput(event: Event) { 74 + const input = event.currentTarget as HTMLInputElement; 75 + smoothing = Number(input.value); 76 + } 77 + 78 + function handleSmoothingChange() { 79 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 80 + } 81 + 82 + function handleStreamlineInput(event: Event) { 83 + const input = event.currentTarget as HTMLInputElement; 84 + streamline = Number(input.value); 85 + } 86 + 87 + function handleStreamlineChange() { 88 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 89 + } 90 + 91 + function handleSimulatePressureChange(event: Event) { 92 + const input = event.currentTarget as HTMLInputElement; 93 + simulatePressure = input.checked; 94 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 95 + } 96 + </script> 97 + 98 + <div class="brush-popover"> 99 + <button 100 + class="brush-popover__button" 101 + bind:this={buttonEl} 102 + onclick={togglePopover} 103 + {disabled} 104 + aria-label="Brush settings" 105 + aria-haspopup="true" 106 + aria-expanded={isOpen}> 107 + Brush 108 + </button> 109 + 110 + {#if isOpen} 111 + <div 112 + class="brush-popover__menu" 113 + bind:this={popoverEl} 114 + role="dialog" 115 + aria-label="Brush settings"> 116 + <div class="brush-popover__control"> 117 + <label for="brush-size"> 118 + <span class="brush-popover__label">Size</span> 119 + <span class="brush-popover__value">{size}</span> 120 + </label> 121 + <input 122 + id="brush-size" 123 + type="range" 124 + min="1" 125 + max="50" 126 + step="1" 127 + value={size} 128 + oninput={handleSizeInput} 129 + onchange={handleSizeChange} 130 + aria-label="Brush size" /> 131 + </div> 132 + 133 + <div class="brush-popover__control"> 134 + <label for="brush-thinning"> 135 + <span class="brush-popover__label">Thinning</span> 136 + <span class="brush-popover__value">{thinning.toFixed(2)}</span> 137 + </label> 138 + <input 139 + id="brush-thinning" 140 + type="range" 141 + min="-1" 142 + max="1" 143 + step="0.01" 144 + value={thinning} 145 + oninput={handleThinningInput} 146 + onchange={handleThinningChange} 147 + aria-label="Brush thinning" /> 148 + </div> 149 + 150 + <div class="brush-popover__control"> 151 + <label for="brush-smoothing"> 152 + <span class="brush-popover__label">Smoothing</span> 153 + <span class="brush-popover__value">{smoothing.toFixed(2)}</span> 154 + </label> 155 + <input 156 + id="brush-smoothing" 157 + type="range" 158 + min="0" 159 + max="1" 160 + step="0.01" 161 + value={smoothing} 162 + oninput={handleSmoothingInput} 163 + onchange={handleSmoothingChange} 164 + aria-label="Brush smoothing" /> 165 + </div> 166 + 167 + <div class="brush-popover__control"> 168 + <label for="brush-streamline"> 169 + <span class="brush-popover__label">Streamline</span> 170 + <span class="brush-popover__value">{streamline.toFixed(2)}</span> 171 + </label> 172 + <input 173 + id="brush-streamline" 174 + type="range" 175 + min="0" 176 + max="1" 177 + step="0.01" 178 + value={streamline} 179 + oninput={handleStreamlineInput} 180 + onchange={handleStreamlineChange} 181 + aria-label="Brush streamline" /> 182 + </div> 183 + 184 + <div class="brush-popover__control brush-popover__control--checkbox"> 185 + <label for="brush-simulate-pressure"> 186 + <input 187 + id="brush-simulate-pressure" 188 + type="checkbox" 189 + checked={simulatePressure} 190 + onchange={handleSimulatePressureChange} 191 + aria-label="Simulate pressure" /> 192 + <span class="brush-popover__label">Simulate Pressure</span> 193 + </label> 194 + </div> 195 + </div> 196 + {/if} 197 + </div> 198 + 199 + <style> 200 + .brush-popover { 201 + position: relative; 202 + } 203 + 204 + .brush-popover__button { 205 + border: 1px solid var(--border); 206 + background: var(--surface); 207 + color: var(--text); 208 + padding: 8px 12px; 209 + border-radius: 4px; 210 + cursor: pointer; 211 + font-size: 13px; 212 + min-width: 60px; 213 + } 214 + 215 + .brush-popover__button:hover:not(:disabled) { 216 + background: var(--surface-elevated); 217 + } 218 + 219 + .brush-popover__button:focus { 220 + outline: 2px solid var(--accent); 221 + outline-offset: 2px; 222 + } 223 + 224 + .brush-popover__button:disabled { 225 + opacity: 0.4; 226 + cursor: not-allowed; 227 + } 228 + 229 + .brush-popover__menu { 230 + position: absolute; 231 + top: calc(100% + 4px); 232 + left: 0; 233 + background: var(--surface); 234 + color: var(--text); 235 + border: 1px solid var(--border); 236 + border-radius: 6px; 237 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 238 + padding: 12px; 239 + display: flex; 240 + flex-direction: column; 241 + gap: 12px; 242 + z-index: 10; 243 + min-width: 200px; 244 + } 245 + 246 + .brush-popover__control { 247 + display: flex; 248 + flex-direction: column; 249 + gap: 6px; 250 + } 251 + 252 + .brush-popover__control label { 253 + display: flex; 254 + justify-content: space-between; 255 + align-items: center; 256 + font-size: 12px; 257 + color: var(--text); 258 + } 259 + 260 + .brush-popover__label { 261 + font-weight: 500; 262 + } 263 + 264 + .brush-popover__value { 265 + color: var(--text-muted); 266 + font-variant-numeric: tabular-nums; 267 + } 268 + 269 + .brush-popover__control input[type='range'] { 270 + width: 100%; 271 + height: 4px; 272 + border-radius: 2px; 273 + background: var(--border); 274 + outline: none; 275 + -webkit-appearance: none; 276 + appearance: none; 277 + } 278 + 279 + .brush-popover__control input[type='range']::-webkit-slider-thumb { 280 + -webkit-appearance: none; 281 + appearance: none; 282 + width: 14px; 283 + height: 14px; 284 + border-radius: 50%; 285 + background: var(--accent); 286 + cursor: pointer; 287 + } 288 + 289 + .brush-popover__control input[type='range']::-moz-range-thumb { 290 + width: 14px; 291 + height: 14px; 292 + border-radius: 50%; 293 + background: var(--accent); 294 + cursor: pointer; 295 + border: none; 296 + } 297 + 298 + .brush-popover__control input[type='range']:focus::-webkit-slider-thumb { 299 + outline: 2px solid var(--accent); 300 + outline-offset: 2px; 301 + } 302 + 303 + .brush-popover__control input[type='range']:focus::-moz-range-thumb { 304 + outline: 2px solid var(--accent); 305 + outline-offset: 2px; 306 + } 307 + 308 + .brush-popover__control--checkbox label { 309 + flex-direction: row; 310 + gap: 8px; 311 + } 312 + 313 + .brush-popover__control--checkbox input[type='checkbox'] { 314 + width: 16px; 315 + height: 16px; 316 + cursor: pointer; 317 + accent-color: var(--accent); 318 + } 319 + </style>
+29 -2
apps/web/src/lib/components/Toolbar.svelte
··· 1 1 <script lang="ts"> 2 + import type { BrushStore } from '$lib/status'; 2 3 import type { 3 4 ArrowShape, 4 5 Box2, 6 + BrushConfig, 5 7 EditorState as EditorStateType, 6 8 EllipseShape, 7 9 LineShape, ··· 20 22 shapeBounds, 21 23 SnapshotCommand 22 24 } from 'inkfinite-core'; 25 + import BrushPopover from './BrushPopover.svelte'; 23 26 24 27 type Viewport = { width: number; height: number }; 25 28 ··· 30 33 store: Store; 31 34 getViewport: () => Viewport; 32 35 canvas?: HTMLCanvasElement; 36 + brushStore: BrushStore; 33 37 }; 34 38 35 - let { currentTool, onToolChange, onHistoryClick, store, getViewport, canvas }: Props = $props(); 39 + let { 40 + currentTool, 41 + onToolChange, 42 + onHistoryClick, 43 + store, 44 + getViewport, 45 + canvas, 46 + brushStore 47 + }: Props = $props(); 36 48 37 49 const DEFAULT_FILL_COLOR = '#4a90e2'; 38 50 const DEFAULT_STROKE_COLOR = '#2e5c8a'; ··· 49 61 let strokeColorValue = $state(DEFAULT_STROKE_COLOR); 50 62 let fillDisabled = $state(true); 51 63 let strokeDisabled = $state(true); 64 + let brush = $derived<BrushConfig>(brushStore.get()); 52 65 53 66 $effect(() => { 54 67 editorState = store.getState(); ··· 56 69 editorState = state; 57 70 }); 58 71 return () => unsubscribe(); 72 + }); 73 + 74 + $effect(() => { 75 + const unsubscribeBrush = brushStore.subscribe((b) => { 76 + brush = b; 77 + }); 78 + return () => unsubscribeBrush(); 59 79 }); 60 80 61 81 $effect(() => { ··· 124 144 { id: 'ellipse', label: 'Ellipse', icon: '○' }, 125 145 { id: 'line', label: 'Line', icon: '╱' }, 126 146 { id: 'arrow', label: 'Arrow', icon: '→' }, 127 - { id: 'text', label: 'Text', icon: 'T' } 147 + { id: 'text', label: 'Text', icon: 'T' }, 148 + { id: 'pen', label: 'Pen', icon: '✎' } 128 149 ]; 129 150 130 151 const zoomPresets = [ ··· 363 384 strokeColorValue = input.value; 364 385 applyStrokeColor(input.value); 365 386 } 387 + 388 + function handleBrushChange(newBrush: BrushConfig) { 389 + brushStore.set(newBrush); 390 + } 366 391 </script> 367 392 368 393 <div class="toolbar" role="toolbar" aria-label="Drawing tools"> ··· 402 427 </div> 403 428 404 429 <div class="toolbar__divider"></div> 430 + 431 + <BrushPopover {brush} onBrushChange={handleBrushChange} disabled={currentTool !== 'pen'} /> 405 432 406 433 <div class="toolbar__zoom"> 407 434 <button
+40 -7
apps/web/src/lib/status.ts
··· 1 1 import { liveQuery } from "dexie"; 2 - import { 3 - createPersistenceSink, 4 - type DocPatch, 5 - type PersistenceSink, 6 - type PersistenceSinkOptions, 7 - type PersistentDocRepo, 8 - } from "inkfinite-core"; 2 + import type { BrushConfig, DocPatch, PersistenceSink, PersistenceSinkOptions, PersistentDocRepo } from "inkfinite-core"; 3 + import { createPersistenceSink } from "inkfinite-core"; 9 4 import type { InkfiniteDB, PersistenceStatus } from "inkfinite-core"; 10 5 11 6 type StatusListener = (status: PersistenceStatus) => void; ··· 27 22 subscribe(listener: (snap: SnapSettings) => void): () => void; 28 23 update(updater: (snap: SnapSettings) => SnapSettings): void; 29 24 set(next: SnapSettings): void; 25 + }; 26 + 27 + export type BrushStore = { 28 + get(): BrushConfig; 29 + subscribe(listener: (brush: BrushConfig) => void): () => void; 30 + update(updater: (brush: BrushConfig) => BrushConfig): void; 31 + set(next: BrushConfig): void; 30 32 }; 31 33 32 34 export type PersistenceManager = { ··· 206 208 }, 207 209 }; 208 210 } 211 + 212 + export function createBrushStore(initial?: Partial<BrushConfig>): BrushStore { 213 + const defaults: BrushConfig = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 214 + let value: BrushConfig = { ...defaults, ...initial }; 215 + const listeners = new Set<(brush: BrushConfig) => void>(); 216 + 217 + return { 218 + get() { 219 + return value; 220 + }, 221 + subscribe(listener) { 222 + listeners.add(listener); 223 + listener(value); 224 + return () => { 225 + listeners.delete(listener); 226 + }; 227 + }, 228 + update(updater) { 229 + value = updater(value); 230 + for (const listener of listeners) { 231 + listener(value); 232 + } 233 + }, 234 + set(next) { 235 + value = next; 236 + for (const listener of listeners) { 237 + listener(value); 238 + } 239 + }, 240 + }; 241 + }
+146
apps/web/src/lib/tests/BrushPopover.svelte.test.ts
··· 1 + import BrushPopover from "$lib/components/BrushPopover.svelte"; 2 + import type { BrushConfig } from "inkfinite-core"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { render } from "vitest-browser-svelte"; 5 + import { page } from "vitest/browser"; 6 + 7 + describe("BrushPopover", () => { 8 + const defaultBrush: BrushConfig = { 9 + size: 16, 10 + thinning: 0.5, 11 + smoothing: 0.5, 12 + streamline: 0.5, 13 + simulatePressure: true, 14 + }; 15 + 16 + beforeEach(async () => { 17 + document.body.innerHTML = ""; 18 + }); 19 + 20 + describe("Rendering", () => { 21 + it("renders the brush button", async () => { 22 + const onBrushChange = vi.fn(); 23 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 24 + 25 + const button = page.getByRole("button", { name: /brush settings/i }); 26 + await expect.element(button).toBeInTheDocument(); 27 + }); 28 + 29 + it("does not show popover by default", async () => { 30 + const onBrushChange = vi.fn(); 31 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 32 + 33 + const dialog = page.getByRole("dialog"); 34 + await expect.element(dialog).not.toBeInTheDocument(); 35 + }); 36 + 37 + it("disables button when disabled prop is true", async () => { 38 + const onBrushChange = vi.fn(); 39 + render(BrushPopover, { brush: defaultBrush, onBrushChange, disabled: true }); 40 + 41 + const button = page.getByRole("button", { name: /brush settings/i }); 42 + await expect.element(button).toBeDisabled(); 43 + }); 44 + }); 45 + 46 + describe("Interaction", () => { 47 + it("opens popover when button is clicked", async () => { 48 + const onBrushChange = vi.fn(); 49 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 50 + 51 + const button = page.getByRole("button", { name: /brush settings/i }); 52 + await button.click(); 53 + 54 + const dialog = page.getByRole("dialog"); 55 + await expect.element(dialog).toBeInTheDocument(); 56 + }); 57 + 58 + it("closes popover when button is clicked again", async () => { 59 + const onBrushChange = vi.fn(); 60 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 61 + 62 + const button = page.getByRole("button", { name: /brush settings/i }); 63 + await button.click(); 64 + 65 + const dialog = page.getByRole("dialog"); 66 + await expect.element(dialog).toBeInTheDocument(); 67 + 68 + await button.click(); 69 + await expect.element(dialog).not.toBeInTheDocument(); 70 + }); 71 + 72 + it("does not show dialog when disabled", async () => { 73 + const onBrushChange = vi.fn(); 74 + render(BrushPopover, { brush: defaultBrush, onBrushChange, disabled: true }); 75 + 76 + const dialog = page.getByRole("dialog"); 77 + await expect.element(dialog).not.toBeInTheDocument(); 78 + }); 79 + }); 80 + 81 + describe("Brush Controls", () => { 82 + it("displays all brush sliders when open", async () => { 83 + const onBrushChange = vi.fn(); 84 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 85 + 86 + const button = page.getByRole("button", { name: /brush settings/i }); 87 + await button.click(); 88 + 89 + await expect.element(page.getByLabelText(/brush size/i)).toBeInTheDocument(); 90 + await expect.element(page.getByLabelText(/brush thinning/i)).toBeInTheDocument(); 91 + await expect.element(page.getByLabelText(/brush smoothing/i)).toBeInTheDocument(); 92 + await expect.element(page.getByLabelText(/brush streamline/i)).toBeInTheDocument(); 93 + await expect.element(page.getByLabelText(/simulate pressure/i)).toBeInTheDocument(); 94 + }); 95 + 96 + it("displays current brush values", async () => { 97 + const customBrush: BrushConfig = { 98 + size: 20, 99 + thinning: 0.7, 100 + smoothing: 0.3, 101 + streamline: 0.9, 102 + simulatePressure: false, 103 + }; 104 + const onBrushChange = vi.fn(); 105 + render(BrushPopover, { brush: customBrush, onBrushChange }); 106 + 107 + const button = page.getByRole("button", { name: /brush settings/i }); 108 + await button.click(); 109 + 110 + const sizeSlider = page.getByLabelText(/brush size/i); 111 + await expect.element(sizeSlider).toHaveValue("20"); 112 + 113 + const thinningSlider = page.getByLabelText(/brush thinning/i); 114 + await expect.element(thinningSlider).toHaveValue("0.7"); 115 + 116 + const checkbox = page.getByLabelText(/simulate pressure/i); 117 + await expect.element(checkbox).not.toBeChecked(); 118 + }); 119 + 120 + it("calls onBrushChange when size slider changes", async () => { 121 + const onBrushChange = vi.fn(); 122 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 123 + 124 + const button = page.getByRole("button", { name: /brush settings/i }); 125 + await button.click(); 126 + 127 + const sizeSlider = page.getByLabelText(/brush size/i); 128 + await sizeSlider.fill("25"); 129 + 130 + expect(onBrushChange).toHaveBeenCalledWith({ ...defaultBrush, size: 25 }); 131 + }); 132 + 133 + it("calls onBrushChange when simulate pressure checkbox changes", async () => { 134 + const onBrushChange = vi.fn(); 135 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 136 + 137 + const button = page.getByRole("button", { name: /brush settings/i }); 138 + await button.click(); 139 + 140 + const checkbox = page.getByLabelText(/simulate pressure/i); 141 + await checkbox.click(); 142 + 143 + expect(onBrushChange).toHaveBeenCalledWith({ ...defaultBrush, simulatePressure: false }); 144 + }); 145 + }); 146 + });
+9 -1
apps/web/src/lib/tests/Toolbar.accessibility.test.ts
··· 2 2 import { beforeEach, describe, expect, it } from "vitest"; 3 3 import { cleanup, render } from "vitest-browser-svelte"; 4 4 import Toolbar from "../components/Toolbar.svelte"; 5 + import { createBrushStore } from "../status"; 5 6 6 7 // TODO: reuse this pattern 7 8 function renderToolbar(store: Store) { 8 9 const target = document.createElement("div"); 9 10 document.body.appendChild(target); 11 + const brushStore = createBrushStore(); 10 12 return render(Toolbar, { 11 13 target, 12 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 14 + props: { 15 + currentTool: "select", 16 + onToolChange: () => {}, 17 + store, 18 + getViewport: () => ({ width: 800, height: 600 }), 19 + brushStore, 20 + }, 13 21 }); 14 22 } 15 23
+9 -1
apps/web/src/lib/tests/Toolbar.colors.test.ts
··· 2 2 import { beforeEach, describe, expect, it } from "vitest"; 3 3 import { cleanup, render } from "vitest-browser-svelte"; 4 4 import Toolbar from "../components/Toolbar.svelte"; 5 + import { createBrushStore } from "../status"; 5 6 6 7 function createStoreWithRect() { 7 8 const store = new Store(); ··· 56 57 function renderToolbar(store: Store) { 57 58 const target = document.createElement("div"); 58 59 document.body.appendChild(target); 60 + const brushStore = createBrushStore(); 59 61 return render(Toolbar, { 60 62 target, 61 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 63 + props: { 64 + currentTool: "select", 65 + onToolChange: () => {}, 66 + store, 67 + getViewport: () => ({ width: 800, height: 600 }), 68 + brushStore, 69 + }, 62 70 }); 63 71 } 64 72
+23 -9
apps/web/src/lib/tests/components/Toolbar.svelte.test.ts
··· 3 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 4 import { cleanup, render } from "vitest-browser-svelte"; 5 5 import Toolbar from "../../components/Toolbar.svelte"; 6 + import { createBrushStore } from "../../status"; 6 7 7 8 const createMockStore = () => new Store(); 8 9 const createMockGetViewport = () => () => ({ width: 1024, height: 768 }); ··· 16 17 const onToolChange = vi.fn(); 17 18 const store = createMockStore(); 18 19 const getViewport = createMockGetViewport(); 19 - const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 20 + const brushStore = createBrushStore(); 21 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); 20 22 21 23 const buttons = container.querySelectorAll(".tool-button"); 22 - expect(buttons.length).toBe(6); 24 + expect(buttons.length).toBe(7); 23 25 24 26 const toolIds = Array.from(buttons).map((btn) => btn.getAttribute("data-tool-id")); 25 - expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); 27 + expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); 26 28 }); 27 29 28 30 it("should mark the current tool as active", () => { 29 31 const onToolChange = vi.fn(); 30 32 const store = createMockStore(); 31 33 const getViewport = createMockGetViewport(); 32 - const { container } = render(Toolbar, { currentTool: "rect", onToolChange, store, getViewport }); 34 + const brushStore = createBrushStore(); 35 + const { container } = render(Toolbar, { currentTool: "rect", onToolChange, store, getViewport, brushStore }); 33 36 34 37 const activeButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); 35 38 expect(activeButton?.classList.contains("active")).toBe(true); ··· 40 43 const onToolChange = vi.fn(); 41 44 const store = createMockStore(); 42 45 const getViewport = createMockGetViewport(); 43 - const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 46 + const brushStore = createBrushStore(); 47 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); 44 48 45 49 const ellipseButton = container.querySelector(".tool-button[data-tool-id=\"ellipse\"]") as HTMLButtonElement; 46 50 expect(ellipseButton).toBeTruthy(); ··· 58 62 { toolId: "line" as ToolId, label: "Line" }, 59 63 { toolId: "arrow" as ToolId, label: "Arrow" }, 60 64 { toolId: "text" as ToolId, label: "Text" }, 65 + { toolId: "pen" as ToolId, label: "Pen" }, 61 66 ])("should have correct aria-label for $toolId tool", ({ toolId, label }) => { 62 67 const onToolChange = vi.fn(); 63 68 const store = createMockStore(); 64 69 const getViewport = createMockGetViewport(); 65 - const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 70 + const brushStore = createBrushStore(); 71 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); 66 72 67 73 const button = container.querySelector(`.tool-button[data-tool-id="${toolId}"]`); 68 74 expect(button?.getAttribute("aria-label")).toBe(label); ··· 72 78 const onToolChange = vi.fn(); 73 79 const store = createMockStore(); 74 80 const getViewport = createMockGetViewport(); 75 - const { container, rerender } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 81 + const brushStore = createBrushStore(); 82 + const { container, rerender } = render(Toolbar, { 83 + currentTool: "select", 84 + onToolChange, 85 + store, 86 + getViewport, 87 + brushStore, 88 + }); 76 89 77 90 let selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 78 91 let rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); ··· 80 93 expect(selectButton?.classList.contains("active")).toBe(true); 81 94 expect(rectButton?.classList.contains("active")).toBe(false); 82 95 83 - await rerender({ currentTool: "rect", onToolChange, store, getViewport }); 96 + await rerender({ currentTool: "rect", onToolChange, store, getViewport, brushStore }); 84 97 85 98 selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 86 99 rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); ··· 93 106 const onToolChange = vi.fn(); 94 107 const store = createMockStore(); 95 108 const getViewport = createMockGetViewport(); 96 - const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 109 + const brushStore = createBrushStore(); 110 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); 97 111 98 112 const toolbar = container.querySelector(".toolbar"); 99 113 expect(toolbar?.getAttribute("role")).toBe("toolbar");
+5 -5
packages/core/src/tools/pen.ts
··· 1 1 import type { Action } from "../actions"; 2 - import type { StrokePoint } from "../model"; 2 + import type { BrushConfig, StrokePoint } from "../model"; 3 3 import { createId, ShapeRecord } from "../model"; 4 4 import type { EditorState, ToolId } from "../reactivity"; 5 5 import { getCurrentPage } from "../reactivity"; ··· 49 49 export class PenTool implements Tool { 50 50 readonly id: ToolId = "pen"; 51 51 private toolState: PenToolState; 52 + private getBrush: () => BrushConfig; 52 53 53 - constructor() { 54 + constructor(getBrush?: () => BrushConfig) { 54 55 this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; 56 + this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); 55 57 } 56 58 57 59 onEnter(state: EditorState): EditorState { ··· 95 97 if (!currentPage) return state; 96 98 97 99 const shapeId = createId("shape"); 98 - 99 - // Start with first point 100 100 const firstPoint: StrokePoint = [action.world.x, action.world.y]; 101 101 102 102 const shape = ShapeRecord.createStroke(currentPage.id, 0, 0, { 103 103 points: [firstPoint], 104 - brush: DEFAULT_BRUSH, 104 + brush: this.getBrush(), 105 105 style: DEFAULT_STYLE, 106 106 }, shapeId); 107 107