web based infinite canvas

feat: markdown ux - textarea shape

+635 -98
+4 -84
TODO.txt
··· 1 ================================================================================ 2 - 3 - Build a Svelte-native editor core (TS) + renderer + UI. 4 - Keep the "engine" framework-agnostic so Web + Tauri share it. 5 - Defer collaboration until single-player is correct. ··· 32 - 10k simple shapes pans/zooms smoothly on a typical machine. 33 34 ================================================================================ 35 - Milestone M: Markdown Blocks *wb-M* 36 - ================================================================================ 37 - 38 - Goal: 39 - Add a "Markdown block" shape with pleasant editing, predictable layout, and 40 - export. Treat it as a doc-first primitive (not a hacky text element). 41 - 42 - -------------------------------------------------------------------------------- 43 - M1. Data model 44 - -------------------------------------------------------------------------------- 45 - 46 - /packages/core/src/model: 47 - [ ] Add ShapeType: 'markdown' 48 - [ ] MarkdownShape props: 49 - - md: string 50 - - w: number, h?: number " fixed width, auto height by layout 51 - - style: { fontFamily, fontSize, color, bg?, border? } 52 - - mode?: 'view'|'edit' " not persisted; UI-only 53 - 54 - (DoD): Markdown blocks save/load; width preserved; content preserved verbatim. 55 - 56 - -------------------------------------------------------------------------------- 57 - M2. Rendering 58 - -------------------------------------------------------------------------------- 59 - 60 - /packages/renderer: 61 - [ ] Render Markdown in canvas using a minimal subset: 62 - - headings (#, ##) 63 - - bold/italic/code 64 - - bullet lists 65 - - links (render style only; click later) 66 - Strategy: 67 - [ ] Parse md -> tokens -> lines; draw text runs onto canvas 68 - [ ] Measure to compute auto height; cache layout per (md, w, style) 69 - 70 - (DoD): Markdown blocks look consistent and don’t reflow unpredictably during 71 - pan/zoom. 72 - 73 - -------------------------------------------------------------------------------- 74 - M3. Editing UX 75 - -------------------------------------------------------------------------------- 76 - 77 - /apps/web: 78 - [ ] Double-click Markdown block opens an overlay editor (contenteditable) 79 - [ ] Cmd/Ctrl+Enter toggles edit/view 80 - [ ] Tab inserts spaces (not focus change) when editing 81 - 82 - (DoD): Editing feels fast; no accidental tool switching; commit is one history 83 - step. 84 - 85 - -------------------------------------------------------------------------------- 86 - M4. Selection + resize 87 - -------------------------------------------------------------------------------- 88 - 89 - [ ] Resizing adjusts width; height recomputed from layout 90 - [ ] Hit-testing uses computed bounds 91 - 92 - (DoD): Markdown blocks behave like shapes: move/resize/duplicate/undo. 93 - 94 - -------------------------------------------------------------------------------- 95 - M5. Export 96 - -------------------------------------------------------------------------------- 97 - 98 - [ ] SVG export: 99 - - v0: export as <foreignObject> OR render as text lines 100 - [ ] PNG export: already covered by canvas export path 101 - 102 - (DoD): Export doesn’t lose the Markdown block content. 103 - 104 - -------------------------------------------------------------------------------- 105 - M6. Tests 106 - -------------------------------------------------------------------------------- 107 - 108 - [ ] Layout cache keying (same md/w/style => stable height) 109 - [ ] Resize changes width and increases/decreases computed height appropriately 110 - [ ] Undo/redo persists through refresh (ties into M persistence) 111 - 112 - (DoD): Markdown blocks are robust and predictable. 113 - 114 - ================================================================================ 115 Milestone L: Layers *wb-L* 116 ================================================================================ 117 ··· 136 - ShapeRecord.layerId: string 137 - Default layer created on new board/page 138 139 - (DoD): Old docs migrate to "single default layer" automatically 140 - (Dexie migration). 141 142 -------------------------------------------------------------------------------- 143 L2. Rendering order + behavior ··· 239 - category filter 240 - click inserts at viewport center OR 241 drag ghost preview onto canvas and drop 242 - 243 [ ] Placement rules: 244 - insert into active layer (if layers exist) 245 - snap to grid if enabled ··· 295 - [ ] Opacity for shapes 296 - expose fill/stroke opacity controls so translucent layering is possible 297 without exporting.
··· 1 ================================================================================ 2 - Build a Svelte-native editor core (TS) + renderer + UI. 3 - Keep the "engine" framework-agnostic so Web + Tauri share it. 4 - Defer collaboration until single-player is correct. ··· 31 - 10k simple shapes pans/zooms smoothly on a typical machine. 32 33 ================================================================================ 34 Milestone L: Layers *wb-L* 35 ================================================================================ 36 ··· 55 - ShapeRecord.layerId: string 56 - Default layer created on new board/page 57 58 + (DoD): Old docs migrate to "single default layer" automatically (Dexie 59 + migration). 60 61 -------------------------------------------------------------------------------- 62 L2. Rendering order + behavior ··· 158 - category filter 159 - click inserts at viewport center OR 160 drag ghost preview onto canvas and drop 161 [ ] Placement rules: 162 - insert into active layer (if layers exist) 163 - snap to grid if enabled ··· 213 - [ ] Opacity for shapes 214 - expose fill/stroke opacity controls so translucent layering is possible 215 without exporting. 216 + - [ ] Markdown layout caching 217 + - cache layout per (md, w, style) to avoid re-parsing on every render
+69 -3
apps/web/src/lib/canvas/Canvas.svelte
··· 9 let canvasEl = $state<HTMLCanvasElement | null>(null); 10 let textEditorEl = $state<HTMLTextAreaElement | null>(null); 11 let arrowLabelEditorEl = $state<HTMLInputElement | null>(null); 12 let historyViewerOpen = $state(false); 13 14 const c = createCanvasController({ ··· 20 let platform = $derived(c.platform()); 21 let textEditorCurrent = $derived(c.textEditor.current); 22 let arrowLabelEditorCurrent = $derived(c.arrowLabelEditor.current); 23 let persistenceStatusStore = $derived(c.persistenceStatusStore()); 24 let marqueeRect = $derived(c.marqueeRect()); 25 ··· 37 c.arrowLabelEditor.setRef(arrowLabelEditorEl); 38 return () => c.arrowLabelEditor.setRef(null); 39 }); 40 </script> 41 42 <div class="editor"> ··· 70 <textarea 71 bind:this={textEditorEl} 72 class="canvas-text-editor" 73 - style={`left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px;font-size:${layout.fontSize}px;`} 74 value={textEditorCurrent.value} 75 oninput={c.textEditor.handleInput} 76 onkeydown={c.textEditor.handleKeyDown} ··· 84 <input 85 bind:this={arrowLabelEditorEl} 86 class="canvas-arrow-label-editor" 87 - style={`left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;font-size:${layout.fontSize}px;`} 88 type="text" 89 value={arrowLabelEditorCurrent.value} 90 oninput={c.arrowLabelEditor.handleInput} ··· 94 placeholder="Enter arrow label..." /> 95 {/if} 96 {/if} 97 {#if marqueeRect} 98 <div 99 class="canvas-marquee" 100 - style={`left:${marqueeRect.left}px;top:${marqueeRect.top}px;width:${marqueeRect.width}px;height:${marqueeRect.height}px;`}> 101 </div> 102 {/if} 103 </div> ··· 173 0 0 0 1px rgba(0, 0, 0, 0.05), 174 0 8px 20px rgba(0, 0, 0, 0.15); 175 border-radius: 4px; 176 } 177 178 .canvas-marquee {
··· 9 let canvasEl = $state<HTMLCanvasElement | null>(null); 10 let textEditorEl = $state<HTMLTextAreaElement | null>(null); 11 let arrowLabelEditorEl = $state<HTMLInputElement | null>(null); 12 + let markdownEditorEl = $state<HTMLTextAreaElement | null>(null); 13 let historyViewerOpen = $state(false); 14 15 const c = createCanvasController({ ··· 21 let platform = $derived(c.platform()); 22 let textEditorCurrent = $derived(c.textEditor.current); 23 let arrowLabelEditorCurrent = $derived(c.arrowLabelEditor.current); 24 + let markdownEditorCurrent = $derived(c.markdownEditor.current); 25 let persistenceStatusStore = $derived(c.persistenceStatusStore()); 26 let marqueeRect = $derived(c.marqueeRect()); 27 ··· 39 c.arrowLabelEditor.setRef(arrowLabelEditorEl); 40 return () => c.arrowLabelEditor.setRef(null); 41 }); 42 + 43 + $effect(() => { 44 + c.markdownEditor.setRef(markdownEditorEl); 45 + return () => c.markdownEditor.setRef(null); 46 + }); 47 </script> 48 49 <div class="editor"> ··· 77 <textarea 78 bind:this={textEditorEl} 79 class="canvas-text-editor" 80 + style={[ 81 + `left:${layout.left}px`, 82 + `top:${layout.top}px`, 83 + `width:${layout.width}px`, 84 + `height:${layout.height}px`, 85 + `font-size:${layout.fontSize}px`, 86 + '' 87 + ].join('; ')} 88 value={textEditorCurrent.value} 89 oninput={c.textEditor.handleInput} 90 onkeydown={c.textEditor.handleKeyDown} ··· 98 <input 99 bind:this={arrowLabelEditorEl} 100 class="canvas-arrow-label-editor" 101 + style={[ 102 + `left:${layout.left}px`, 103 + `top:${layout.top}px`, 104 + `width:${layout.width}px`, 105 + `font-size:${layout.fontSize}px`, 106 + '' 107 + ].join('; ')} 108 type="text" 109 value={arrowLabelEditorCurrent.value} 110 oninput={c.arrowLabelEditor.handleInput} ··· 114 placeholder="Enter arrow label..." /> 115 {/if} 116 {/if} 117 + {#if markdownEditorCurrent} 118 + {@const layout = c.markdownEditor.getLayout()} 119 + {#if layout} 120 + <textarea 121 + bind:this={markdownEditorEl} 122 + class="canvas-markdown-editor" 123 + style={[ 124 + `left:${layout.left}px`, 125 + `top:${layout.top}px`, 126 + `width:${layout.width}px`, 127 + `height:${layout.height}px`, 128 + `font-size:${layout.fontSize}px`, 129 + '' 130 + ].join('; ')} 131 + value={markdownEditorCurrent.value} 132 + oninput={c.markdownEditor.handleInput} 133 + onkeydown={c.markdownEditor.handleKeyDown} 134 + onblur={c.markdownEditor.handleBlur} 135 + spellcheck="false"></textarea> 136 + {/if} 137 + {/if} 138 {#if marqueeRect} 139 <div 140 class="canvas-marquee" 141 + style={[ 142 + `left:${marqueeRect.left}px`, 143 + `top:${marqueeRect.top}px`, 144 + `width:${marqueeRect.width}px`, 145 + `height:${marqueeRect.height}px`, 146 + '' 147 + ].join('; ')}> 148 </div> 149 {/if} 150 </div> ··· 220 0 0 0 1px rgba(0, 0, 0, 0.05), 221 0 8px 20px rgba(0, 0, 0, 0.15); 222 border-radius: 4px; 223 + } 224 + 225 + .canvas-markdown-editor { 226 + position: absolute; 227 + border: 1px solid var(--accent); 228 + background: var(--surface); 229 + color: var(--text); 230 + padding: 8px; 231 + transform-origin: top left; 232 + resize: none; 233 + outline: none; 234 + line-height: 1.4; 235 + font-family: monospace; 236 + z-index: 2; 237 + box-shadow: 238 + 0 0 0 1px rgba(0, 0, 0, 0.05), 239 + 0 8px 20px rgba(0, 0, 0, 0.15); 240 + white-space: pre-wrap; 241 + overflow: auto; 242 } 243 244 .canvas-marquee {
+27 -2
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 16 getShapesOnCurrentPage, 17 InkfiniteDB, 18 LineTool, 19 PenTool, 20 RectTool, 21 routeAction, ··· 35 import { DesktopFileController } from "./controllers/desktop-file-controller.svelte"; 36 import { FileBrowserController } from "./controllers/filebrowser-controller.svelte"; 37 import { HistoryController } from "./controllers/history-controller"; 38 import { TextEditorController } from "./controllers/texteditor-controller.svelte"; 39 import { ToolController } from "./controllers/tool-controller.svelte"; 40 import { HandleState } from "./store/handle-state.svelte"; ··· 124 return; 125 } 126 const cursor = computeCursor( 127 - textEditor.isEditing || arrowLabelEditor.isEditing, 128 { isPanning: panState.isPanning, spaceHeld: panState.spaceHeld }, 129 { hover: handleState.hover, active: handleState.active }, 130 pointerState.isPointerDown, ··· 155 const lineTool = new LineTool(); 156 const arrowTool = new ArrowTool(); 157 const textTool = new TextTool(); 158 const getPenBrushConfig = () => { 159 const { color: _color, ...config } = brushStore.get(); 160 return config; ··· 164 return { color: brush.color, opacity: 1 }; 165 }; 166 const penTool = new PenTool(getPenBrushConfig, getPenStrokeStyle); 167 - const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool, penTool]); 168 169 const textEditor = new TextEditorController(store, getViewport, refreshCursor); 170 const arrowLabelEditor = new ArrowLabelEditorController(store, getViewport, refreshCursor); 171 const toolController = new ToolController(store, tools); 172 const unsubscribeMarqueeCamera = store.subscribe((state) => { 173 if (marqueeBounds) { ··· 370 textEditor.commit(); 371 } 372 373 if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { 374 const hover = selectTool.getHandleAtPoint(store.getState(), action.world); 375 setHandleHover(hover); ··· 475 return; 476 } 477 } 478 } 479 } 480 ··· 583 history, 584 textEditor, 585 arrowLabelEditor, 586 store, 587 getViewport, 588 handleCanvasDoubleClick,
··· 16 getShapesOnCurrentPage, 17 InkfiniteDB, 18 LineTool, 19 + MarkdownTool, 20 PenTool, 21 RectTool, 22 routeAction, ··· 36 import { DesktopFileController } from "./controllers/desktop-file-controller.svelte"; 37 import { FileBrowserController } from "./controllers/filebrowser-controller.svelte"; 38 import { HistoryController } from "./controllers/history-controller"; 39 + import { MarkdownEditorController } from "./controllers/markdown-controller.svelte"; 40 import { TextEditorController } from "./controllers/texteditor-controller.svelte"; 41 import { ToolController } from "./controllers/tool-controller.svelte"; 42 import { HandleState } from "./store/handle-state.svelte"; ··· 126 return; 127 } 128 const cursor = computeCursor( 129 + textEditor.isEditing || arrowLabelEditor.isEditing || markdownEditor.isEditing, 130 { isPanning: panState.isPanning, spaceHeld: panState.spaceHeld }, 131 { hover: handleState.hover, active: handleState.active }, 132 pointerState.isPointerDown, ··· 157 const lineTool = new LineTool(); 158 const arrowTool = new ArrowTool(); 159 const textTool = new TextTool(); 160 + const markdownTool = new MarkdownTool(); 161 const getPenBrushConfig = () => { 162 const { color: _color, ...config } = brushStore.get(); 163 return config; ··· 167 return { color: brush.color, opacity: 1 }; 168 }; 169 const penTool = new PenTool(getPenBrushConfig, getPenStrokeStyle); 170 + const tools = createToolMap([ 171 + selectTool, 172 + rectTool, 173 + ellipseTool, 174 + lineTool, 175 + arrowTool, 176 + textTool, 177 + markdownTool, 178 + penTool, 179 + ]); 180 181 const textEditor = new TextEditorController(store, getViewport, refreshCursor); 182 const arrowLabelEditor = new ArrowLabelEditorController(store, getViewport, refreshCursor); 183 + const markdownEditor = new MarkdownEditorController(store, getViewport, refreshCursor); 184 const toolController = new ToolController(store, tools); 185 const unsubscribeMarqueeCamera = store.subscribe((state) => { 186 if (marqueeBounds) { ··· 383 textEditor.commit(); 384 } 385 386 + if (markdownEditor.isEditing && (action.type === "pointer-down" || action.type === "pointer-up")) { 387 + markdownEditor.commit(); 388 + } 389 + 390 if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { 391 const hover = selectTool.getHandleAtPoint(store.getState(), action.world); 392 setHandleHover(hover); ··· 492 return; 493 } 494 } 495 + if (shape.type === "markdown") { 496 + const bounds = shapeBounds(shape); 497 + if (world.x >= bounds.min.x && world.x <= bounds.max.x && world.y >= bounds.min.y && world.y <= bounds.max.y) { 498 + markdownEditor.start(shape.id); 499 + return; 500 + } 501 + } 502 } 503 } 504 ··· 607 history, 608 textEditor, 609 arrowLabelEditor, 610 + markdownEditor, 611 store, 612 getViewport, 613 handleCanvasDoubleClick,
+126
apps/web/src/lib/canvas/controllers/markdown-controller.svelte.ts
···
··· 1 + import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from "inkfinite-core"; 2 + 3 + /** 4 + * Controller for markdown block editing 5 + * 6 + * Handles: 7 + * - Opening/closing markdown editor overlay 8 + * - Cmd/Ctrl+Enter to toggle edit/view 9 + * - Tab key inserts spaces (not focus change) 10 + * - Commit on blur 11 + */ 12 + export class MarkdownEditorController { 13 + current = $state<{ shapeId: string; value: string } | null>(null); 14 + private markdownEditorEl: HTMLTextAreaElement | null = null; 15 + 16 + constructor(private store: Store, private getViewport: () => Viewport, private refreshCursor: () => void) {} 17 + 18 + get isEditing() { 19 + return this.current !== null; 20 + } 21 + 22 + setRef = (el: HTMLTextAreaElement | null) => { 23 + this.markdownEditorEl = el; 24 + }; 25 + 26 + getLayout = () => { 27 + if (!this.current) { 28 + return null; 29 + } 30 + const state = this.store.getState(); 31 + const shape = state.doc.shapes[this.current.shapeId]; 32 + if (!shape || shape.type !== "markdown") { 33 + return null; 34 + } 35 + const viewport = this.getViewport(); 36 + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); 37 + const zoom = state.camera.zoom; 38 + const widthWorld = shape.props.w; 39 + const heightWorld = shape.props.h ?? shape.props.fontSize * 10; 40 + return { 41 + left: screenPos.x, 42 + top: screenPos.y, 43 + width: widthWorld * zoom, 44 + height: heightWorld * zoom, 45 + fontSize: shape.props.fontSize * zoom, 46 + }; 47 + }; 48 + 49 + start = (shapeId: string) => { 50 + const state = this.store.getState(); 51 + const shape = state.doc.shapes[shapeId]; 52 + if (!shape || shape.type !== "markdown") { 53 + return; 54 + } 55 + this.current = { shapeId, value: shape.props.md }; 56 + this.refreshCursor(); 57 + queueMicrotask(() => { 58 + this.markdownEditorEl?.focus(); 59 + this.markdownEditorEl?.select(); 60 + }); 61 + }; 62 + 63 + handleInput = (event: Event) => { 64 + if (!this.current) { 65 + return; 66 + } 67 + const target = event.currentTarget as HTMLTextAreaElement; 68 + this.current = { ...this.current, value: target.value }; 69 + }; 70 + 71 + handleKeyDown = (event: KeyboardEvent) => { 72 + if (event.key === "Tab") { 73 + event.preventDefault(); 74 + const target = event.currentTarget as HTMLTextAreaElement; 75 + const start = target.selectionStart; 76 + const end = target.selectionEnd; 77 + const spaces = " "; 78 + const newValue = this.current!.value.substring(0, start) + spaces + this.current!.value.substring(end); 79 + this.current = { ...this.current!, value: newValue }; 80 + queueMicrotask(() => { 81 + target.selectionStart = target.selectionEnd = start + spaces.length; 82 + }); 83 + return; 84 + } 85 + 86 + if (event.key === "Escape") { 87 + event.preventDefault(); 88 + this.cancel(); 89 + return; 90 + } 91 + 92 + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { 93 + event.preventDefault(); 94 + this.commit(); 95 + } 96 + }; 97 + 98 + handleBlur = () => { 99 + this.commit(); 100 + }; 101 + 102 + commit = () => { 103 + if (!this.current) { 104 + return; 105 + } 106 + const { shapeId, value } = this.current; 107 + const currentState = this.store.getState(); 108 + const shape = currentState.doc.shapes[shapeId]; 109 + this.current = null; 110 + this.refreshCursor(); 111 + if (!shape || shape.type !== "markdown" || shape.props.md === value) { 112 + return; 113 + } 114 + const before = EditorState.clone(currentState); 115 + const updatedShape = { ...shape, props: { ...shape.props, md: value } }; 116 + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; 117 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 118 + const command = new SnapshotCommand("Edit markdown", "doc", before, EditorState.clone(after)); 119 + this.store.executeCommand(command); 120 + }; 121 + 122 + cancel = () => { 123 + this.current = null; 124 + this.refreshCursor(); 125 + }; 126 + }
+2 -4
apps/web/src/lib/components/Toolbar.svelte
··· 56 let exportMenuOpen = $state(false); 57 let exportMenuEl = $state<HTMLDivElement | null>(null); 58 let exportButtonEl = $state<HTMLButtonElement | null>(null); 59 - 60 let fillColorValue = $state(DEFAULT_FILL_COLOR); 61 let strokeColorValue = $state(DEFAULT_STROKE_COLOR); 62 let fillDisabled = $state(true); 63 let strokeDisabled = $state(true); 64 let brush = $derived<BrushSettings>(brushStore.get()); 65 - let hasArrowSelection = $derived( 66 - getSelectedShapes(editorState).some((s) => s.type === 'arrow') 67 - ); 68 69 $effect(() => { 70 editorState = store.getState(); ··· 150 { id: 'line', label: 'Line', icon: '╱' }, 151 { id: 'arrow', label: 'Arrow', icon: '→' }, 152 { id: 'text', label: 'Text', icon: 'T' }, 153 { id: 'pen', label: 'Pen', icon: '✎' } 154 ]; 155
··· 56 let exportMenuOpen = $state(false); 57 let exportMenuEl = $state<HTMLDivElement | null>(null); 58 let exportButtonEl = $state<HTMLButtonElement | null>(null); 59 let fillColorValue = $state(DEFAULT_FILL_COLOR); 60 let strokeColorValue = $state(DEFAULT_STROKE_COLOR); 61 let fillDisabled = $state(true); 62 let strokeDisabled = $state(true); 63 let brush = $derived<BrushSettings>(brushStore.get()); 64 + let hasArrowSelection = $derived(getSelectedShapes(editorState).some((s) => s.type === 'arrow')); 65 66 $effect(() => { 67 editorState = store.getState(); ··· 147 { id: 'line', label: 'Line', icon: '╱' }, 148 { id: 'arrow', label: 'Arrow', icon: '→' }, 149 { id: 'text', label: 'Text', icon: 'T' }, 150 + { id: 'markdown', label: 'Markdown', icon: 'M↓' }, 151 { id: 'pen', label: 'Pen', icon: '✎' } 152 ]; 153
+2 -2
apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 128 const { container } = render(Canvas); 129 const toolButtons = container.querySelectorAll(".tool-button"); 130 131 - expect(toolButtons.length).toBe(8); 132 133 const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); 134 const coreToolIds = toolIds.filter((id) => id && id !== "history"); 135 - expect(coreToolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); 136 137 const historyButton = container.querySelector(".tool-button.history-button"); 138 expect(historyButton).toBeTruthy();
··· 128 const { container } = render(Canvas); 129 const toolButtons = container.querySelectorAll(".tool-button"); 130 131 + expect(toolButtons.length).toBe(9); 132 133 const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); 134 const coreToolIds = toolIds.filter((id) => id && id !== "history"); 135 + expect(coreToolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "markdown", "pen"]); 136 137 const historyButton = container.querySelector(".tool-button.history-button"); 138 expect(historyButton).toBeTruthy();
+2 -2
apps/web/src/lib/tests/components/Toolbar.svelte.test.ts
··· 21 const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); 22 23 const buttons = container.querySelectorAll(".tool-button"); 24 - expect(buttons.length).toBe(7); 25 26 const toolIds = Array.from(buttons).map((btn) => btn.getAttribute("data-tool-id")); 27 - expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); 28 }); 29 30 it("should mark the current tool as active", () => {
··· 21 const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); 22 23 const buttons = container.querySelectorAll(".tool-button"); 24 + expect(buttons.length).toBe(8); 25 26 const toolIds = Array.from(buttons).map((btn) => btn.getAttribute("data-tool-id")); 27 + expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "markdown", "pen"]); 28 }); 29 30 it("should mark the current tool as active", () => {
+397
apps/web/src/lib/tests/markdown-editor.test.ts
···
··· 1 + import { EditorState, PageRecord, ShapeRecord, Store } from "inkfinite-core"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { MarkdownEditorController } from "../canvas/controllers/markdown-controller.svelte"; 4 + 5 + describe("MarkdownEditorController", () => { 6 + let store: Store; 7 + let controller: MarkdownEditorController; 8 + const mockRefreshCursor = vi.fn(); 9 + const mockGetViewport = () => ({ width: 1024, height: 768 }); 10 + 11 + beforeEach(() => { 12 + store = new Store(); 13 + mockRefreshCursor.mockClear(); 14 + controller = new MarkdownEditorController(store, mockGetViewport, mockRefreshCursor); 15 + }); 16 + 17 + describe("start", () => { 18 + it("should start editing a markdown shape", () => { 19 + const page = PageRecord.create("Test Page", "page1"); 20 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 21 + md: "# Hello World", 22 + w: 300, 23 + h: 200, 24 + fontSize: 16, 25 + fontFamily: "sans-serif", 26 + color: "#000", 27 + }, "shape1"); 28 + 29 + page.shapeIds = ["shape1"]; 30 + store.setState((state) => ({ 31 + ...state, 32 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 33 + ui: { ...state.ui, currentPageId: "page1" }, 34 + })); 35 + 36 + controller.start("shape1"); 37 + 38 + expect(controller.isEditing).toBe(true); 39 + expect(controller.current).toEqual({ shapeId: "shape1", value: "# Hello World" }); 40 + expect(mockRefreshCursor).toHaveBeenCalled(); 41 + }); 42 + 43 + it("should not start editing if shape is not markdown", () => { 44 + const page = PageRecord.create("Test Page", "page1"); 45 + const shape = ShapeRecord.createRect("page1", 100, 200, { 46 + w: 100, 47 + h: 50, 48 + fill: "#fff", 49 + stroke: "#000", 50 + radius: 0, 51 + }, "shape1"); 52 + 53 + page.shapeIds = ["shape1"]; 54 + store.setState((state) => ({ 55 + ...state, 56 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 57 + })); 58 + 59 + controller.start("shape1"); 60 + 61 + expect(controller.isEditing).toBe(false); 62 + expect(controller.current).toBeNull(); 63 + }); 64 + 65 + it("should not start editing if shape does not exist", () => { 66 + controller.start("nonexistent"); 67 + 68 + expect(controller.isEditing).toBe(false); 69 + expect(controller.current).toBeNull(); 70 + }); 71 + }); 72 + 73 + describe("getLayout", () => { 74 + it("should return null when not editing", () => { 75 + expect(controller.getLayout()).toBeNull(); 76 + }); 77 + 78 + it("should compute layout when editing", () => { 79 + const page = PageRecord.create("Test Page", "page1"); 80 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 81 + md: "# Test", 82 + w: 300, 83 + h: 200, 84 + fontSize: 16, 85 + fontFamily: "sans-serif", 86 + color: "#000", 87 + }, "shape1"); 88 + 89 + page.shapeIds = ["shape1"]; 90 + store.setState((state) => ({ 91 + ...state, 92 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 93 + ui: { ...state.ui, currentPageId: "page1" }, 94 + camera: { ...state.camera, x: 0, y: 0, zoom: 1 }, 95 + })); 96 + 97 + controller.start("shape1"); 98 + const layout = controller.getLayout(); 99 + 100 + expect(layout).toBeTruthy(); 101 + expect(layout?.width).toBe(300); 102 + expect(layout?.height).toBe(200); 103 + expect(layout?.fontSize).toBe(16); 104 + }); 105 + 106 + it("should handle auto-computed height", () => { 107 + const page = PageRecord.create("Test Page", "page1"); 108 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 109 + md: "# Test", 110 + w: 300, 111 + fontSize: 16, 112 + fontFamily: "sans-serif", 113 + color: "#000", 114 + }, "shape1"); 115 + 116 + page.shapeIds = ["shape1"]; 117 + store.setState((state) => ({ 118 + ...state, 119 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 120 + })); 121 + 122 + controller.start("shape1"); 123 + const layout = controller.getLayout(); 124 + 125 + expect(layout).toBeTruthy(); 126 + expect(layout?.height).toBe(160); 127 + }); 128 + }); 129 + 130 + describe("handleInput", () => { 131 + it("should update current value on input", () => { 132 + const page = PageRecord.create("Test Page", "page1"); 133 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 134 + md: "# Hello", 135 + w: 300, 136 + h: 200, 137 + fontSize: 16, 138 + fontFamily: "sans-serif", 139 + color: "#000", 140 + }, "shape1"); 141 + 142 + page.shapeIds = ["shape1"]; 143 + store.setState((state) => ({ 144 + ...state, 145 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 146 + })); 147 + 148 + controller.start("shape1"); 149 + 150 + const mockEvent = { currentTarget: { value: "# Hello World" } as HTMLTextAreaElement } as unknown as Event; 151 + 152 + controller.handleInput(mockEvent); 153 + 154 + expect(controller.current?.value).toBe("# Hello World"); 155 + }); 156 + 157 + it("should do nothing if not editing", () => { 158 + const mockEvent = { currentTarget: { value: "test" } as HTMLTextAreaElement } as unknown as Event; 159 + 160 + controller.handleInput(mockEvent); 161 + 162 + expect(controller.current).toBeNull(); 163 + }); 164 + }); 165 + 166 + describe("handleKeyDown", () => { 167 + beforeEach(() => { 168 + const page = PageRecord.create("Test Page", "page1"); 169 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 170 + md: "# Test", 171 + w: 300, 172 + h: 200, 173 + fontSize: 16, 174 + fontFamily: "sans-serif", 175 + color: "#000", 176 + }, "shape1"); 177 + 178 + page.shapeIds = ["shape1"]; 179 + store.setState((state) => ({ 180 + ...state, 181 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 182 + })); 183 + 184 + controller.start("shape1"); 185 + }); 186 + 187 + it("should insert spaces on Tab key", () => { 188 + const mockTextarea = { selectionStart: 6, selectionEnd: 6, value: "# Test" } as HTMLTextAreaElement; 189 + 190 + const mockEvent = { 191 + key: "Tab", 192 + preventDefault: vi.fn(), 193 + currentTarget: mockTextarea, 194 + } as unknown as KeyboardEvent; 195 + 196 + controller.handleKeyDown(mockEvent); 197 + 198 + expect(mockEvent.preventDefault).toHaveBeenCalled(); 199 + expect(controller.current?.value).toBe("# Test "); 200 + }); 201 + 202 + it("should replace selection with spaces on Tab", () => { 203 + controller.current!.value = "# Test Content"; 204 + 205 + const mockTextarea = { selectionStart: 2, selectionEnd: 6, value: "# Test Content" } as HTMLTextAreaElement; 206 + 207 + const mockEvent = { 208 + key: "Tab", 209 + preventDefault: vi.fn(), 210 + currentTarget: mockTextarea, 211 + } as unknown as KeyboardEvent; 212 + 213 + controller.handleKeyDown(mockEvent); 214 + 215 + expect(mockEvent.preventDefault).toHaveBeenCalled(); 216 + expect(controller.current?.value).toBe("# Content"); 217 + }); 218 + 219 + it("should cancel on Escape key", () => { 220 + const mockEvent = { key: "Escape", preventDefault: vi.fn() } as unknown as KeyboardEvent; 221 + 222 + controller.handleKeyDown(mockEvent); 223 + 224 + expect(mockEvent.preventDefault).toHaveBeenCalled(); 225 + expect(controller.isEditing).toBe(false); 226 + expect(mockRefreshCursor).toHaveBeenCalled(); 227 + }); 228 + 229 + it("should commit on Cmd+Enter", () => { 230 + controller.current!.value = "# Updated"; 231 + 232 + const mockEvent = { 233 + key: "Enter", 234 + metaKey: true, 235 + ctrlKey: false, 236 + preventDefault: vi.fn(), 237 + } as unknown as KeyboardEvent; 238 + 239 + controller.handleKeyDown(mockEvent); 240 + 241 + expect(mockEvent.preventDefault).toHaveBeenCalled(); 242 + expect(controller.isEditing).toBe(false); 243 + 244 + const updatedShape = store.getState().doc.shapes["shape1"]; 245 + expect(updatedShape).toBeTruthy(); 246 + if (updatedShape?.type === "markdown") { 247 + expect(updatedShape.props.md).toBe("# Updated"); 248 + } 249 + }); 250 + 251 + it("should commit on Ctrl+Enter", () => { 252 + controller.current!.value = "# Updated"; 253 + 254 + const mockEvent = { 255 + key: "Enter", 256 + metaKey: false, 257 + ctrlKey: true, 258 + preventDefault: vi.fn(), 259 + } as unknown as KeyboardEvent; 260 + 261 + controller.handleKeyDown(mockEvent); 262 + 263 + expect(mockEvent.preventDefault).toHaveBeenCalled(); 264 + expect(controller.isEditing).toBe(false); 265 + 266 + const updatedShape = store.getState().doc.shapes["shape1"]; 267 + if (updatedShape?.type === "markdown") { 268 + expect(updatedShape.props.md).toBe("# Updated"); 269 + } 270 + }); 271 + }); 272 + 273 + describe("commit", () => { 274 + it("should update markdown content and create history entry", () => { 275 + const page = PageRecord.create("Test Page", "page1"); 276 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 277 + md: "# Original", 278 + w: 300, 279 + h: 200, 280 + fontSize: 16, 281 + fontFamily: "sans-serif", 282 + color: "#000", 283 + }, "shape1"); 284 + 285 + page.shapeIds = ["shape1"]; 286 + store.setState((state) => ({ 287 + ...state, 288 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 289 + })); 290 + 291 + controller.start("shape1"); 292 + controller.current!.value = "# Updated Content"; 293 + controller.commit(); 294 + 295 + expect(controller.isEditing).toBe(false); 296 + expect(mockRefreshCursor).toHaveBeenCalled(); 297 + 298 + const updatedShape = store.getState().doc.shapes["shape1"]; 299 + expect(updatedShape).toBeTruthy(); 300 + if (updatedShape?.type === "markdown") { 301 + expect(updatedShape.props.md).toBe("# Updated Content"); 302 + } 303 + }); 304 + 305 + it("should not update if value is unchanged", () => { 306 + const page = PageRecord.create("Test Page", "page1"); 307 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 308 + md: "# Original", 309 + w: 300, 310 + h: 200, 311 + fontSize: 16, 312 + fontFamily: "sans-serif", 313 + color: "#000", 314 + }, "shape1"); 315 + 316 + page.shapeIds = ["shape1"]; 317 + const initialState = EditorState.create(); 318 + initialState.doc = { ...initialState.doc, pages: { page1: page }, shapes: { shape1: shape } }; 319 + store.setState(() => initialState); 320 + 321 + controller.start("shape1"); 322 + controller.commit(); 323 + 324 + const finalState = store.getState(); 325 + expect(finalState).toEqual(initialState); 326 + }); 327 + 328 + it("should do nothing if not editing", () => { 329 + const initialState = store.getState(); 330 + controller.commit(); 331 + expect(store.getState()).toBe(initialState); 332 + }); 333 + }); 334 + 335 + describe("cancel", () => { 336 + it("should stop editing without saving", () => { 337 + const page = PageRecord.create("Test Page", "page1"); 338 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 339 + md: "# Original", 340 + w: 300, 341 + h: 200, 342 + fontSize: 16, 343 + fontFamily: "sans-serif", 344 + color: "#000", 345 + }, "shape1"); 346 + 347 + page.shapeIds = ["shape1"]; 348 + store.setState((state) => ({ 349 + ...state, 350 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 351 + })); 352 + 353 + controller.start("shape1"); 354 + controller.current!.value = "# Modified"; 355 + controller.cancel(); 356 + 357 + expect(controller.isEditing).toBe(false); 358 + expect(mockRefreshCursor).toHaveBeenCalled(); 359 + 360 + const originalShape = store.getState().doc.shapes["shape1"]; 361 + if (originalShape?.type === "markdown") { 362 + expect(originalShape.props.md).toBe("# Original"); 363 + } 364 + }); 365 + }); 366 + 367 + describe("handleBlur", () => { 368 + it("should commit on blur", () => { 369 + const page = PageRecord.create("Test Page", "page1"); 370 + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { 371 + md: "# Original", 372 + w: 300, 373 + h: 200, 374 + fontSize: 16, 375 + fontFamily: "sans-serif", 376 + color: "#000", 377 + }, "shape1"); 378 + 379 + page.shapeIds = ["shape1"]; 380 + store.setState((state) => ({ 381 + ...state, 382 + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, 383 + })); 384 + 385 + controller.start("shape1"); 386 + controller.current!.value = "# Updated on Blur"; 387 + controller.handleBlur(); 388 + 389 + expect(controller.isEditing).toBe(false); 390 + 391 + const updatedShape = store.getState().doc.shapes["shape1"]; 392 + if (updatedShape?.type === "markdown") { 393 + expect(updatedShape.props.md).toBe("# Updated on Blur"); 394 + } 395 + }); 396 + }); 397 + });
+6 -1
packages/core/tests/markdown.test.ts
··· 106 }); 107 108 it("should compute bounds for markdown shape with auto height", () => { 109 - const shape = ShapeRecord.createMarkdown(pageId, 0, 0, createProps({ md: "# Test", color: "#000" })); 110 const bounds = shapeBounds(shape); 111 expect(bounds.min.x).toBe(0); 112 expect(bounds.min.y).toBe(0);
··· 106 }); 107 108 it("should compute bounds for markdown shape with auto height", () => { 109 + const shape = ShapeRecord.createMarkdown( 110 + pageId, 111 + 0, 112 + 0, 113 + createProps({ md: "# Test", h: undefined, color: "#000" }), 114 + ); 115 const bounds = shapeBounds(shape); 116 expect(bounds.min.x).toBe(0); 117 expect(bounds.min.y).toBe(0);