web based infinite canvas
2
fork

Configure Feed

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

feat: icon component

* perfect-freehand milestone

+221 -33
+111
TODO.txt
··· 257 257 so drawing feels production-ready. 258 258 259 259 ================================================================================ 260 + 20. Milestone T: Sketching / Pen Tool (perfect-freehand) *wb-T* 261 + ================================================================================ 262 + 263 + Goal: 264 + Add a pen tool that produces smooth freehand strokes using perfect-freehand. 265 + Strokes are shapes, undo/redo-able (L), persisted (M), selectable, and render on 266 + Canvas2D. 267 + 268 + Refs: 269 + - perfect-freehand getStroke returns outline polygon points. 270 + - Options: size/thinning/smoothing/streamline/simulatePressure. 271 + 272 + ------------------------------------------------------------------------------ 273 + T1. Data model: Stroke shape 274 + ------------------------------------------------------------------------------ 275 + 276 + /packages/core/src/model: 277 + [ ] Add ShapeType: 'stroke' 278 + [ ] StrokeShape props (persisted): 279 + - points: Array<[x,y,p?]> " world coords + optional pressure 280 + - style: { color, opacity } 281 + - brush: { size, thinning, smoothing, streamline, simulatePressure } 282 + [ ] Derived (NOT persisted): 283 + - outline?: Array<[x,y]> " computed polygon 284 + - bounds?: Box2 285 + 286 + (DoD): stroke serializes to JSON and loads back identically. 287 + 288 + ------------------------------------------------------------------------------ 289 + T2. Tool: pen (state machine) 290 + ------------------------------------------------------------------------------ 291 + 292 + /packages/core/src/tools.ts (PenTool) 293 + [ ] PointerDown: start draft, push first point 294 + [ ] PointerMove: append point if moved > eps; include pressure if available 295 + [ ] PointerUp: create ONE history command that inserts the stroke; clear draft 296 + 297 + Perf: 298 + [ ] Coalesce draft updates (rAF) so you don’t recompute per event. 299 + 300 + (DoD): one stroke == one undo step; no DB writes until finalize (via M). 301 + 302 + ------------------------------------------------------------------------------ 303 + T3. Geometry: compute outline via perfect-freehand 304 + ------------------------------------------------------------------------------ 305 + 306 + /packages/core/src/geom.ts: 307 + [ ] computeOutline(points, brush) -> outlinePoints using getStroke() 308 + [ ] boundsFromOutline(outline) -> Box2 309 + 310 + (DoD): outline non-empty for >= 2 points; bounds contain outline. 311 + 312 + ------------------------------------------------------------------------------ 313 + T4. Rendering: fill the outline polygon 314 + ------------------------------------------------------------------------------ 315 + 316 + /packages/renderer/src/draw.ts: 317 + [ ] drawStroke(ctx, stroke): 318 + - outline = cached || computeOutline(...) 319 + - ctx.beginPath(); moveTo/lineTo...; closePath(); fill() 320 + [ ] Render draft stroke above shapes, below selection UI. 321 + 322 + (DoD): strokes look stable while drawing; committed strokes match preview. 323 + 324 + ------------------------------------------------------------------------------ 325 + T5. Hit testing 326 + ------------------------------------------------------------------------------ 327 + 328 + /packages/core/src/geom.ts: 329 + [ ] hitTestStroke(p, stroke): 330 + - bounds check first 331 + - inside-outline polygon test (ray cast) (or tolerance-to-segment fallback) 332 + 333 + (DoD): clicking a stroke selects it reliably. 334 + 335 + ------------------------------------------------------------------------------ 336 + T6. Brush settings (thin UI slice) 337 + ------------------------------------------------------------------------------ 338 + 339 + /apps/web/src/lib/components/BrushPopover.svelte: 340 + [ ] Sliders: size, thinning, smoothing, streamline 341 + [ ] Toggle: simulatePressure 342 + (All map to perfect-freehand options.) 343 + 344 + (DoD): settings affect newly drawn strokes immediately. 345 + 346 + ------------------------------------------------------------------------------ 347 + T7. Tests 348 + ------------------------------------------------------------------------------ 349 + 350 + /packages/core/test/pen.test.ts: 351 + [ ] outline computed for a simple polyline 352 + [ ] bounds correctness 353 + [ ] hit test inside/outside sanity 354 + 355 + Integration: 356 + [ ] one history command per stroke; undo/redo persists through refresh (M). 357 + 358 + ------------------------------------------------------------------------------ 359 + Definition of Done 360 + ------------------------------------------------------------------------------ 361 + 362 + - Pen tool draws smooth strokes using perfect-freehand outlines. 363 + - Strokes are selectable, undo/redo-able in one step, and persisted via Dexie. 364 + - Brush controls change appearance of new strokes. 365 + 366 + ================================================================================ 260 367 References (URLs) *wb-refs* 261 368 ================================================================================ 262 369 ··· 273 380 Canvas/infinite-canvas performance ideas: 274 381 - https://antv.vision/infinite-canvas-tutorial/guide/lesson-008 275 382 - https://harrisonmilbradt.com/blog/canvas-panning-and-zooming 383 + 384 + Perfect Freehand 385 + - https://github.com/steveruizok/perfect-freehand 386 + - (Options) https://github.com/steveruizok/perfect-freehand/blob/main/packages/perfect-freehand/src/types.ts
+1
apps/web/.prettierrc
··· 4 4 "trailingComma": "none", 5 5 "printWidth": 99, 6 6 "objectWrap": "collapse", 7 + "tabWidth": 2, 7 8 "bracketSameLine": true, 8 9 "plugins": [ "prettier-plugin-svelte" ], 9 10 "overrides": [ { "files": "*.svelte", "options": { "parser": "svelte" } } ]
+4
apps/web/src/lib/assets/close.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 + <path fill="currentColor" 3 + d="M17.414 16L24 9.414L22.586 8L16 14.586L9.414 8L8 9.414L14.586 16L8 22.586L9.414 24L16 17.414L22.586 24L24 22.586z" /> 4 + </svg>
+1 -1
apps/web/src/lib/assets/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> 2 2 <path fill="#5e81ac" 3 3 d="M9.75 20.85c1.78-.7 1.39-2.63.49-3.85c-.89-1.25-2.12-2.11-3.36-2.94A9.8 9.8 0 0 1 4.54 12c-.28-.33-.85-.94-.27-1.06c.59-.12 1.61.46 2.13.68c.91.38 1.81.82 2.65 1.34l1.01-1.7C8.5 10.23 6.5 9.32 4.64 9.05c-1.06-.16-2.18.06-2.54 1.21c-.32.99.19 1.99.77 2.77c1.37 1.83 3.5 2.71 5.09 4.29c.34.33.75.72.95 1.18c.21.44.16.47-.31.47c-1.24 0-2.79-.97-3.8-1.61l-1.01 1.7c1.53.94 4.09 2.41 5.96 1.79m9.21-13.52L13.29 13H11v-2.29l5.67-5.68zm3.4-.78c-.01.3-.32.61-.64.92L19.2 10l-.87-.87l2.6-2.59l-.59-.59l-.67.67l-2.29-2.29l2.15-2.15c.24-.24.63-.24.86 0l1.43 1.43c.24.22.24.62 0 .86c-.21.21-.41.41-.41.61c-.02.2.18.42.38.59c.29.3.58.58.57.88" /> 4 4 </svg>
+4
apps/web/src/lib/assets/folder.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> 2 + <path fill="currentColor" 3 + d="M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v.64c.57.265.94.876.856 1.546l-.64 5.124A2.5 2.5 0 0 1 12.733 15H3.266a2.5 2.5 0 0 1-2.481-2.19l-.64-5.124A1.5 1.5 0 0 1 1 6.14zM2 6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3H2.5a.5.5 0 0 0-.5.5zm-.367 1a.5.5 0 0 0-.496.562l.64 5.124A1.5 1.5 0 0 0 3.266 14h9.468a1.5 1.5 0 0 0 1.489-1.314l.64-5.124A.5.5 0 0 0 14.367 7z" /> 4 + </svg>
+7
apps/web/src/lib/assets/info-circle.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> 2 + <g fill="currentColor"> 3 + <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" /> 4 + <path 5 + d="m8.93 6.588l-2.29.287l-.082.38l.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319c.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246c-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0" /> 6 + </g> 7 + </svg>
+8
apps/web/src/lib/assets/pencil.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> 2 + <g fill="currentColor"> 3 + <path 4 + d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456l-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z" /> 5 + <path fill-rule="evenodd" 6 + d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z" /> 7 + </g> 8 + </svg>
+8
apps/web/src/lib/assets/trash.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> 2 + <g fill="currentColor"> 3 + <path 4 + d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z" /> 5 + <path 6 + d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z" /> 7 + </g> 8 + </svg>
+43
apps/web/src/lib/components/Icon.svelte
··· 1 + <script lang="ts"> 2 + import CloseIcon from '$lib/assets/close.svg?raw'; 3 + import FolderIcon from '$lib/assets/folder.svg?raw'; 4 + import InfoCircleIcon from '$lib/assets/info-circle.svg?raw'; 5 + import PencilIcon from '$lib/assets/pencil.svg?raw'; 6 + import TrashIcon from '$lib/assets/trash.svg?raw'; 7 + 8 + export type IconName = 'close' | 'folder' | 'info-circle' | 'pencil' | 'trash'; 9 + 10 + type Props = { name: IconName; size?: number; color?: string }; 11 + 12 + const { name, size = 16, color = '#4c566a' }: Props = $props(); 13 + 14 + const icons: Record<IconName, string> = { 15 + close: CloseIcon, 16 + folder: FolderIcon, 17 + 'info-circle': InfoCircleIcon, 18 + pencil: PencilIcon, 19 + trash: TrashIcon 20 + }; 21 + 22 + const svg = $derived(icons[name]); 23 + </script> 24 + 25 + <!-- eslint-disable svelte/no-at-html-tags --> 26 + <span class="icon" style:width="{size}px" style:height="{size}px" style:color aria-hidden="true"> 27 + {@html svg} 28 + </span> 29 + 30 + <style> 31 + .icon { 32 + display: inline-flex; 33 + align-items: center; 34 + justify-content: center; 35 + flex-shrink: 0; 36 + padding: 1px; 37 + } 38 + 39 + .icon :global(svg) { 40 + width: 100%; 41 + height: 100%; 42 + } 43 + </style>
+1 -14
apps/web/src/lib/components/Sheet.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from 'svelte'; 3 3 4 - /** 5 - * Sheet (Drawer) component 6 - * 7 - * A sliding panel that appears from the side of the screen. 8 - * Built on top of Dialog primitive with custom positioning. 9 - */ 10 - 4 + /** Which side the sheet slides in from */ 11 5 type Side = 'left' | 'right' | 'top' | 'bottom'; 12 6 13 7 type Props = { 14 - /** Whether the sheet is open */ 15 8 open: boolean; 16 - /** Callback when sheet should close */ 17 9 onClose?: () => void; 18 - /** Sheet title (for accessibility) */ 19 10 title?: string; 20 - /** Which side the sheet slides in from (default: 'right') */ 21 11 side?: Side; 22 - /** Whether clicking backdrop closes sheet (default: true) */ 23 12 closeOnBackdrop?: boolean; 24 - /** Whether escape key closes sheet (default: true) */ 25 13 closeOnEscape?: boolean; 26 - /** Custom class for the sheet content */ 27 14 class?: string; 28 15 children?: Snippet; 29 16 };
+3 -2
apps/web/src/lib/components/StatusBar.svelte
··· 167 167 <span class="status-bar__label">Sync</span> 168 168 <span 169 169 class="status-bar__value" 170 - class:status-bar__value--error={statusVm.persistence.state === 'error'} 171 - >{formatPersistenceSummary()}</span> 170 + class:status-bar__value--error={statusVm.persistence.state === 'error'}> 171 + {formatPersistenceSummary()} 172 + </span> 172 173 </div> 173 174 </div> 174 175
+4 -3
apps/web/src/lib/components/TitleBar.svelte
··· 1 1 <script lang="ts"> 2 2 import Dialog from '$lib/components/Dialog.svelte'; 3 + import Icon from '$lib/components/Icon.svelte'; 3 4 import type { Platform } from '$lib/platform'; 4 5 import type { BoardMeta } from 'inkfinite-core'; 5 6 import icon from '../assets/favicon.svg'; ··· 119 120 <div class="titlebar__spacer"></div> 120 121 {#if platform === 'web' && onOpenBrowser} 121 122 <button class="titlebar__info" onclick={onOpenBrowser} aria-label="Browse boards"> 122 - <span aria-hidden="true">📁</span> 123 + <Icon name="folder" size={16} /> 123 124 <span class="titlebar__info-label">Boards</span> 124 125 </button> 125 126 {/if} 126 127 <button class="titlebar__info" onclick={openInfo} aria-label="About Inkfinite"> 127 - <span aria-hidden="true">ℹ︎</span> 128 + <Icon name="info-circle" size={16} /> 128 129 <span class="titlebar__info-label">Info</span> 129 130 </button> 130 131 </header> ··· 258 259 .titlebar__info { 259 260 display: inline-flex; 260 261 align-items: center; 261 - gap: 6px; 262 + gap: 8px; 262 263 border: 1px solid var(--border); 263 264 background: var(--surface); 264 265 color: var(--text);
+21 -12
apps/web/src/lib/filebrowser/FileBrowser.svelte
··· 1 1 <script lang="ts"> 2 + import Icon from '$lib/components/Icon.svelte'; 2 3 import Sheet from '$lib/components/Sheet.svelte'; 3 4 import type { 4 5 BoardInspectorData, ··· 154 155 type="button" 155 156 onclick={closeBrowser} 156 157 aria-label="Close board browser"> 157 - × 158 + <Icon name="close" size={20} color="#e27878" /> 158 159 </button> 159 160 </div> 160 161 <button ··· 166 167 </div> 167 168 168 169 <div class="filebrowser__search"> 170 + <!-- FIXME: reactivity is broken --> 169 171 <input 170 172 type="search" 171 173 class="filebrowser__search-input" ··· 173 175 value={searchQuery} 174 176 oninput={handleSearchInput} 175 177 onchange={handleSearchChange} 176 - aria-label="Search boards" /> 178 + aria-label="Search boards" 179 + disabled /> 177 180 </div> 178 181 179 182 {#if isCreating} ··· 247 250 handleInspectBoard(board); 248 251 }} 249 252 aria-label="Inspect board"> 250 - ℹ️ 253 + <Icon name="info-circle" size={16} /> 251 254 </button> 252 255 <button 253 256 class="filebrowser__board-action" ··· 256 259 startRename(board); 257 260 }} 258 261 aria-label="Rename board"> 259 - ✏️ 262 + <Icon name="pencil" size={16} /> 260 263 </button> 261 264 <button 262 265 class="filebrowser__board-action" ··· 265 268 handleDeleteBoard(board.id); 266 269 }} 267 270 aria-label="Delete board"> 268 - 🗑️ 271 + <Icon name="trash" size={16} /> 269 272 </button> 270 273 </div> 271 274 {/if} ··· 284 287 class="inspector__close" 285 288 onclick={() => (inspectorOpen = false)} 286 289 aria-label="Close inspector"> 287 - × 290 + <Icon name="close" size={20} color="#e27878" /> 288 291 </button> 289 292 </div> 290 293 ··· 298 301 <h4 class="inspector__section-title">Storage</h4> 299 302 <div class="inspector__item"> 300 303 <span class="inspector__label">Storage Type:</span> 304 + <!-- TODO: local? browser? --> 301 305 <span class="inspector__value">IndexedDB (Dexie)</span> 302 306 </div> 303 307 </section> ··· 335 339 </div> 336 340 <div class="inspector__item"> 337 341 <span class="inspector__label">Last Updated:</span> 338 - <span class="inspector__value" 339 - >{formatTimestamp(inspectorData.stats.lastUpdated)}</span> 342 + <span class="inspector__value"> 343 + {formatTimestamp(inspectorData.stats.lastUpdated)} 344 + </span> 340 345 </div> 341 346 </section> 342 347 ··· 349 354 {#each inspectorData.migrations as migration (migration.id)} 350 355 <div class="inspector__migration"> 351 356 <span class="inspector__migration-id">{migration.id}</span> 352 - <span class="inspector__migration-date" 353 - >{formatTimestamp(migration.appliedAt)}</span> 357 + <span class="inspector__migration-date"> 358 + {formatTimestamp(migration.appliedAt)} 359 + </span> 354 360 </div> 355 361 {/each} 356 362 </div> ··· 405 411 406 412 .filebrowser__close { 407 413 background: none; 408 - border: none; 414 + border: 1px solid transparent; 409 415 color: var(--text-secondary, #666); 410 - font-size: 1.25rem; 416 + font-size: 1.5rem; 411 417 cursor: pointer; 412 418 padding: 4px; 413 419 border-radius: 4px; 420 + display: flex; 421 + align-items: center; 414 422 } 415 423 416 424 .filebrowser__close:hover, 417 425 .filebrowser__close:focus-visible { 418 426 background-color: rgba(0, 0, 0, 0.05); 419 427 color: var(--text); 428 + border: 1px solid #e27878; 420 429 } 421 430 422 431 .filebrowser__action {
+5 -1
apps/web/svelte.config.js
··· 4 4 /** @type {import('@sveltejs/kit').Config} */ 5 5 const config = { 6 6 preprocess: vitePreprocess(), 7 - kit: { adapter: adapter({ fallback: "index.html" }), prerender: { entries: [] } }, 7 + kit: { 8 + adapter: adapter({ fallback: "index.html" }), 9 + prerender: { entries: [] }, 10 + alias: { "$assets": "./src/lib/assets", "$components": "./src/lib/components" }, 11 + }, 8 12 }; 9 13 10 14 export default config;