web based infinite canvas

feat: Implement draggable toolbar and integrate theming into renderer

+271 -39
+3
TODO.txt
··· 145 145 - Flowchart: process, decision, terminator, data, document 146 146 - Diagrams: server, db, queue, user, browser, mobile 147 147 - UI: button, input, card, modal 148 + - Etc: Post-It style/sticky notes, index cards, speech bubble 148 149 149 150 (DoD): Stencils load as data and can spawn shapes deterministically. 150 151 ··· 215 216 without exporting. 216 217 - [ ] Markdown layout caching 217 218 - cache layout per (md, w, style) to avoid re-parsing on every render 219 + - [ ] Bug Fix: Cursor Mapping 220 + - Investigate cursor position getting stuck or offsetting after window resize.
+110 -8
apps/web/src/app.css
··· 1 - @import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300..700&display=swap'); 1 + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300..700&display=swap'); 2 2 3 3 :root { 4 4 --bg-primary: #eceff4; ··· 105 105 --bg-primary: #161821; 106 106 --bg-secondary: #1e2132; 107 107 --bg-tertiary: #272c42; 108 - 109 108 --fg-primary: #c6c8d1; 110 109 --fg-secondary: #89b8c2; 111 110 --fg-tertiary: #84a0c6; 112 111 --fg-muted: #6b7089; 113 - 114 112 --accent-purple: #a093c7; 115 113 --accent-cyan: #89b8c2; 116 114 --accent-blue: #84a0c6; 117 115 --accent-search: #e4aa80; 118 - 119 116 --color-error: #e27878; 120 117 --color-warning: #e2a478; 121 118 --color-success: #b4be82; 122 119 --color-info: #e4aa80; 123 120 --color-purple: #a093c7; 124 - 125 121 --line-numbers: #444b71; 126 122 --selection: #272c42; 127 - 128 123 --surface: var(--bg-primary); 129 124 --surface-elevated: var(--bg-secondary); 130 125 --surface-overlay: var(--bg-tertiary); ··· 134 129 --border: var(--line-numbers); 135 130 --accent: var(--accent-blue); 136 131 --accent-hover: var(--accent-cyan); 132 + color-scheme: dark; 137 133 } 138 134 139 135 * { ··· 143 139 } 144 140 145 141 body { 146 - font-family: 'Work Sans', sans-serif; 142 + font-family: 'Inter', sans-serif; 143 + background-color: var(--surface); 144 + color: var(--text); 145 + line-height: 1.5; 146 + -webkit-font-smoothing: antialiased; 147 + -moz-osx-font-smoothing: grayscale; 148 + } 149 + 150 + button { 151 + font-family: 'Inter', sans-serif; 152 + } 153 + 154 + ::selection { 155 + background-color: var(--accent); 156 + color: var(--surface); 157 + } 158 + 159 + /* Markdown Styling */ 160 + .markdown-body { 161 + color: var(--text); 162 + font-size: 1rem; 163 + line-height: 1.6; 164 + } 165 + 166 + .markdown-body h1, 167 + .markdown-body h2, 168 + .markdown-body h3, 169 + .markdown-body h4, 170 + .markdown-body h5, 171 + .markdown-body h6 { 172 + margin-top: 1.5em; 173 + margin-bottom: 0.5em; 174 + font-weight: 600; 175 + line-height: 1.25; 176 + } 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; } 182 + 183 + .markdown-body p { 184 + margin-bottom: 1em; 185 + } 186 + 187 + .markdown-body ul, 188 + .markdown-body ol { 189 + padding-left: 2em; 190 + margin-bottom: 1em; 191 + } 192 + 193 + .markdown-body blockquote { 194 + padding: 0 1em; 195 + color: var(--text-muted); 196 + border-left: 0.25em solid var(--border); 197 + margin-bottom: 1em; 198 + } 199 + 200 + .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; 207 + } 208 + 209 + .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; 217 + } 218 + 219 + .markdown-body pre code { 220 + background-color: transparent; 221 + padding: 0; 222 + } 223 + 224 + .markdown-body a { 225 + color: var(--accent); 226 + text-decoration: none; 227 + } 228 + 229 + .markdown-body a:hover { 230 + text-decoration: underline; 231 + } 232 + 233 + .markdown-body hr { 234 + height: 0.25em; 235 + padding: 0; 236 + margin: 24px 0; 237 + background-color: var(--border); 238 + border: 0; 239 + } 240 + 241 + * { 242 + margin: 0; 243 + padding: 0; 244 + box-sizing: border-box; 245 + } 246 + 247 + body { 248 + font-family: 'Inter', sans-serif; 147 249 background-color: var(--surface); 148 250 color: var(--text); 149 251 line-height: 1.25; 150 252 } 151 253 152 254 button { 153 - font-family: 'Work Sans', sans-serif; 255 + font-family: 'Inter', sans-serif; 154 256 } 155 257 156 258 ::selection {
+1
apps/web/src/lib/assets/grip-vertical.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-vertical"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
+7
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 4 4 import { createPlatformRepo, detectPlatform } from "$lib/platform"; 5 5 import { createBrushStore, createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; 6 6 import type { BrushStore, SnapStore, StatusStore } from "$lib/status"; 7 + import { themeStore } from "$lib/theme.svelte"; 7 8 import { 8 9 ArrowTool, 9 10 Camera, ··· 526 527 get: () => ({ isPointerDown: pointerState.isPointerDown, snappedWorld: pointerState.snappedWorld }), 527 528 }, 528 529 handleProvider: { get: () => handleState.getSnapshot() }, 530 + themeProvider: { get: () => themeStore.current }, 529 531 }); 530 532 531 533 const unsubStore = store.subscribe(() => renderer?.markDirty()); 532 534 const unsubSnap = snapStore.subscribe(() => renderer?.markDirty()); 535 + 536 + $effect(() => { 537 + themeStore.current; 538 + renderer?.markDirty(); 539 + }); 533 540 534 541 inputAdapter = createInputAdapter({ 535 542 canvas,
+3 -1
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 5 import InfoCircleIcon from '$lib/assets/info-circle.svg?raw'; 5 6 import MoonIcon from '$lib/assets/moon.svg?raw'; 6 7 import PencilIcon from '$lib/assets/pencil.svg?raw'; 7 8 import SunIcon from '$lib/assets/sun.svg?raw'; 8 9 import TrashIcon from '$lib/assets/trash.svg?raw'; 9 10 10 - export type IconName = 'close' | 'folder' | 'info-circle' | 'moon' | 'pencil' | 'sun' | 'trash'; 11 + export type IconName = 'close' | 'folder' | 'grip-vertical' | 'info-circle' | 'moon' | 'pencil' | 'sun' | 'trash'; 11 12 12 13 type Props = { name: IconName; size?: number; color?: string }; 13 14 ··· 16 17 const icons: Record<IconName, string> = { 17 18 close: CloseIcon, 18 19 folder: FolderIcon, 20 + 'grip-vertical': GripVerticalIcon, 19 21 'info-circle': InfoCircleIcon, 20 22 moon: MoonIcon, 21 23 pencil: PencilIcon,
+128 -12
apps/web/src/lib/components/Toolbar.svelte
··· 33 33 shapeBounds, 34 34 SnapshotCommand 35 35 } from 'inkfinite-core'; 36 + import { fade } from 'svelte/transition'; 36 37 import icon from '../assets/favicon.svg'; 37 38 import ArrowPopover from './ArrowPopover.svelte'; 38 39 import BrushPopover from './BrushPopover.svelte'; ··· 129 130 } 130 131 }); 131 132 133 + let showColorControls = $derived( 134 + toolSupportsStyles(currentTool) || 135 + toolSupportsFill(currentTool) || 136 + getSelectedShapes(editorState).some(s => shapeSupportsFill(s) || shapeSupportsStroke(s)) 137 + ); 138 + 139 + let position = $state({ x: 20, y: 20 }); 140 + let isDragging = $state(false); 141 + let dragOffset = $state({ x: 0, y: 0 }); 142 + let toolbarEl = $state<HTMLElement | null>(null); 143 + 132 144 $effect(() => { 133 145 if (!zoomMenuOpen || typeof document === 'undefined') { 134 146 return; ··· 167 179 return () => document.removeEventListener('pointerdown', handlePointerDown); 168 180 }); 169 181 182 + function handleDragStart(event: PointerEvent) { 183 + isDragging = true; 184 + dragOffset = { 185 + x: event.clientX - position.x, 186 + y: event.clientY - position.y 187 + }; 188 + 189 + if(typeof document !== 'undefined') document.body.style.userSelect = 'none'; 190 + 191 + 192 + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); 193 + } 194 + 195 + function handleDragMove(event: PointerEvent) { 196 + if (!isDragging) return; 197 + position = { 198 + x: event.clientX - dragOffset.x, 199 + y: event.clientY - dragOffset.y 200 + }; 201 + } 202 + 203 + function handleDragEnd(event: PointerEvent) { 204 + isDragging = false; 205 + if(typeof document !== 'undefined') document.body.style.userSelect = ''; 206 + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); 207 + } 208 + 170 209 function openInfo() { 171 210 infoOpen = true; 172 211 } ··· 308 347 ); 309 348 } 310 349 350 + function toolSupportsStyles(tool: ToolId): boolean { 351 + return ( 352 + tool === 'rect' || 353 + tool === 'ellipse' || 354 + tool === 'line' || 355 + tool === 'arrow' 356 + ); 357 + } 358 + 359 + function toolSupportsFill(tool: ToolId): boolean { 360 + return tool === 'rect' || tool === 'ellipse' || tool === 'text'; 361 + } 362 + 311 363 function getSharedColor<T extends ShapeRecord>( 312 364 shapes: T[], 313 365 extract: (shape: T) => string | null | undefined ··· 436 488 } 437 489 </script> 438 490 439 - <div class="toolbar" role="toolbar" aria-label="Drawing tools"> 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> 511 + 440 512 <div class="toolbar__brand"> 441 513 <div class="toolbar__logo"> 442 514 <img src={icon} alt="Inkfinite Icon" /> ··· 500 572 </button> 501 573 {/each} 502 574 503 - <div class="toolbar__colors" aria-label="Color controls"> 575 + {#if showColorControls} 576 + <div class="toolbar__colors" aria-label="Color controls" transition:fade={{ duration: 150 }}> 577 + {#if toolSupportsFill(currentTool) || getSelectedShapes(editorState).some(shapeSupportsFill)} 504 578 <label class="toolbar__color-control"> 505 579 <span>Fill</span> 506 580 <input 507 581 type="color" 508 582 value={fillColorValue} 509 583 onchange={handleFillChange} 510 - disabled={fillDisabled} 584 + disabled={fillDisabled && !toolSupportsFill(currentTool)} 511 585 aria-label="Fill color" /> 512 586 </label> 587 + {/if} 588 + {#if toolSupportsStyles(currentTool) || getSelectedShapes(editorState).some(shapeSupportsStroke)} 513 589 <label class="toolbar__color-control"> 514 590 <span>Stroke</span> 515 591 <input 516 592 type="color" 517 593 value={strokeColorValue} 518 594 onchange={handleStrokeChange} 519 - disabled={strokeDisabled} 595 + disabled={strokeDisabled && !toolSupportsStyles(currentTool)} 520 596 aria-label="Stroke color" /> 521 597 </label> 598 + {/if} 522 599 </div> 600 + {/if} 523 601 524 602 <div class="toolbar__divider"></div> 525 603 ··· 680 758 .toolbar { 681 759 display: flex; 682 760 gap: 0.75rem; 683 - padding: 0.75rem 1rem; 761 + padding: 0.75rem 1rem 0.75rem 0.25rem; /* Adjusted padding for handle */ 684 762 background: var(--surface-elevated); 685 763 border-bottom: 1px solid var(--border); 764 + border: 1px solid var(--border); 765 + border-radius: 0.75rem; 686 766 align-items: center; 687 767 box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 688 - z-index: 10; 689 - position: relative; 768 + z-index: 100; 769 + transition: transform 0.1s; 770 + touch-action: none; 690 771 } 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 + 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 + } 691 801 692 802 .toolbar__brand { 693 803 display: flex; ··· 720 830 align-items: center; 721 831 gap: 0.375rem; 722 832 padding: 0.625rem 0.875rem; 723 - border: 1px solid transparent; 833 + border: 1px solid var(--border); 724 834 border-radius: 0.5rem; 725 835 background: transparent; 726 - color: var(--text-muted); 836 + color: var(--text); 727 837 cursor: pointer; 728 838 transition: all 0.2s ease; 729 839 min-width: 68px; 840 + opacity: 0.8; 730 841 } 731 842 732 843 .toolbar__tool-button:hover { 733 844 background: var(--bg-tertiary); 734 845 color: var(--text); 846 + opacity: 1; 847 + border-color: var(--text-muted); 735 848 } 736 849 737 850 .toolbar__tool-button:focus { ··· 743 856 .tool-button.active { 744 857 background: var(--accent); 745 858 color: var(--surface); 859 + border-color: var(--accent); 746 860 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 861 + opacity: 1; 747 862 } 748 863 749 864 .toolbar__tool-icon { ··· 759 874 } 760 875 761 876 .toolbar__divider { 762 - width: 1px; 877 + width: 2px; 763 878 background-color: var(--border); 764 - margin: 0 1rem; 765 - height: 48px; 879 + margin: 0 1.25rem; 880 + height: 32px; 881 + opacity: 0.5; 766 882 } 767 883 768 884 .toolbar__info {
+1 -9
packages/core/src/tools/markdown.ts
··· 4 4 import { getCurrentPage } from "../reactivity"; 5 5 import type { Tool } from "./base"; 6 6 7 - /** 8 - * Markdown tool creates markdown block shapes on click 9 - * 10 - * Features: 11 - * - Click to create a markdown block at the pointer position 12 - * - Block is created with default content and dimensions 13 - * - Shape is immediately selected after creation 14 - */ 15 7 export class MarkdownTool implements Tool { 16 8 readonly id: ToolId = "markdown"; 17 9 ··· 47 39 w: 300, 48 40 h: 200, 49 41 fontSize: 16, 50 - fontFamily: "sans-serif", 42 + fontFamily: "Inter", 51 43 color: "#1f2933", 52 44 }, shapeId); 53 45
+18 -9
packages/renderer/src/index.ts
··· 47 47 cursorProvider?: { get(): CursorState }; 48 48 pointerStateProvider?: { get(): PointerVisualState }; 49 49 handleProvider?: { get(): HandleRenderState }; 50 + themeProvider?: { get(): "light" | "dark" }; 50 51 }; 51 52 52 53 /** ··· 111 112 const cursorState = options?.cursorProvider?.get(); 112 113 const pointerState = options?.pointerStateProvider?.get(); 113 114 const handleState = options?.handleProvider?.get(); 114 - drawScene(context, state, viewport, snapSettings, cursorState, pointerState, handleState); 115 + const theme = options?.themeProvider?.get() ?? "light"; 116 + drawScene(context, state, viewport, snapSettings, cursorState, pointerState, handleState, theme); 115 117 } 116 118 117 119 /** ··· 169 171 cursorState?: CursorState, 170 172 pointerState?: PointerVisualState, 171 173 handleState?: HandleRenderState, 174 + theme: "light" | "dark" = "light", 172 175 ) { 173 176 context.clearRect(0, 0, viewport.width, viewport.height); 174 177 ··· 180 183 181 184 const shapes = getShapesOnCurrentPage(state); 182 185 for (const shape of shapes) { 183 - drawShape(context, state, shape); 186 + drawShape(context, state, shape, theme); 184 187 } 185 188 186 189 drawSelection(context, state, shapes, handleState); ··· 349 352 /** 350 353 * Draw a single shape 351 354 */ 352 - function drawShape(context: CanvasRenderingContext2D, state: EditorState, shape: ShapeRecord) { 355 + function drawShape( 356 + context: CanvasRenderingContext2D, 357 + state: EditorState, 358 + shape: ShapeRecord, 359 + theme: "light" | "dark" = "light", 360 + ) { 353 361 context.save(); 354 362 355 363 context.translate(shape.x, shape.y); ··· 379 387 break; 380 388 } 381 389 case "markdown": { 382 - drawMarkdown(context, shape); 390 + drawMarkdown(context, shape, theme); 383 391 break; 384 392 } 385 393 case "stroke": { ··· 627 635 * - Lists (ordered and unordered) 628 636 * - Code blocks (```) 629 637 */ 630 - function drawMarkdown(context: CanvasRenderingContext2D, shape: MarkdownShape) { 638 + function drawMarkdown(context: CanvasRenderingContext2D, shape: MarkdownShape, theme: "light" | "dark" = "light") { 631 639 const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; 632 640 633 641 const width = w; ··· 662 670 let prefix = ""; 663 671 664 672 if (line.startsWith("```")) { 665 - context.fillStyle = "#f4f4f4"; 673 + context.fillStyle = theme === "dark" ? "#2e3440" : "#f4f4f4"; 666 674 const codeBlockLines = []; 667 675 lineIndex++; 668 676 while (lineIndex < lines.length && !lines[lineIndex].startsWith("```")) { ··· 674 682 if (yOffset + codeBlockHeight <= height - padding) { 675 683 context.fillRect(padding, yOffset, width - padding * 2, codeBlockHeight); 676 684 677 - context.fillStyle = "#333"; 685 + context.fillStyle = theme === "dark" ? "#e5e9f0" : "#333"; 678 686 context.font = `normal normal ${fontSize}px monospace`; 679 687 680 688 for (const [index, codeLine] of codeBlockLines.entries()) { ··· 728 736 const { text: segmentText, bold, italic, code } = segment; 729 737 730 738 if (code) { 731 - context.fillStyle = "#f4f4f4"; 739 + context.fillStyle = theme === "dark" ? "#2e3440" : "#f4f4f4"; 732 740 const metrics = context.measureText(segmentText); 733 741 context.fillRect(xOffset, yOffset, metrics.width + 4, currentFontSize * 1.2); 734 - context.fillStyle = "#333"; 742 + 743 + context.fillStyle = theme === "dark" ? "#e5e9f0" : "#333"; 735 744 context.font = `normal normal ${currentFontSize * 0.9}px monospace`; 736 745 context.fillText(segmentText, xOffset + 2, yOffset); 737 746 xOffset += metrics.width + 4;