diff --git a/TODO.txt b/TODO.txt index 7f7585a..ec2acdc 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,5 +1,4 @@ ================================================================================ - - Build a Svelte-native editor core (TS) + renderer + UI. - Keep the "engine" framework-agnostic so Web + Tauri share it. - Defer collaboration until single-player is correct. @@ -31,86 +30,6 @@ Goal: the editor stays responsive with many shapes. (DoD): - 10k simple shapes pans/zooms smoothly on a typical machine. -================================================================================ -Milestone M: Markdown Blocks *wb-M* -================================================================================ - -Goal: -Add a "Markdown block" shape with pleasant editing, predictable layout, and -export. Treat it as a doc-first primitive (not a hacky text element). - --------------------------------------------------------------------------------- -M1. Data model --------------------------------------------------------------------------------- - -/packages/core/src/model: -[ ] Add ShapeType: 'markdown' -[ ] MarkdownShape props: - - md: string - - w: number, h?: number " fixed width, auto height by layout - - style: { fontFamily, fontSize, color, bg?, border? } - - mode?: 'view'|'edit' " not persisted; UI-only - -(DoD): Markdown blocks save/load; width preserved; content preserved verbatim. - --------------------------------------------------------------------------------- -M2. Rendering --------------------------------------------------------------------------------- - -/packages/renderer: -[ ] Render Markdown in canvas using a minimal subset: - - headings (#, ##) - - bold/italic/code - - bullet lists - - links (render style only; click later) -Strategy: -[ ] Parse md -> tokens -> lines; draw text runs onto canvas -[ ] Measure to compute auto height; cache layout per (md, w, style) - -(DoD): Markdown blocks look consistent and don’t reflow unpredictably during -pan/zoom. - --------------------------------------------------------------------------------- -M3. Editing UX --------------------------------------------------------------------------------- - -/apps/web: -[ ] Double-click Markdown block opens an overlay editor (contenteditable) -[ ] Cmd/Ctrl+Enter toggles edit/view -[ ] Tab inserts spaces (not focus change) when editing - -(DoD): Editing feels fast; no accidental tool switching; commit is one history - step. - --------------------------------------------------------------------------------- -M4. Selection + resize --------------------------------------------------------------------------------- - -[ ] Resizing adjusts width; height recomputed from layout -[ ] Hit-testing uses computed bounds - -(DoD): Markdown blocks behave like shapes: move/resize/duplicate/undo. - --------------------------------------------------------------------------------- -M5. Export --------------------------------------------------------------------------------- - -[ ] SVG export: - - v0: export as OR render as text lines -[ ] PNG export: already covered by canvas export path - -(DoD): Export doesn’t lose the Markdown block content. - --------------------------------------------------------------------------------- -M6. Tests --------------------------------------------------------------------------------- - -[ ] Layout cache keying (same md/w/style => stable height) -[ ] Resize changes width and increases/decreases computed height appropriately -[ ] Undo/redo persists through refresh (ties into M persistence) - -(DoD): Markdown blocks are robust and predictable. - ================================================================================ Milestone L: Layers *wb-L* ================================================================================ @@ -136,8 +55,8 @@ L1. Data model (doc) - ShapeRecord.layerId: string - Default layer created on new board/page -(DoD): Old docs migrate to "single default layer" automatically -(Dexie migration). +(DoD): Old docs migrate to "single default layer" automatically (Dexie + migration). -------------------------------------------------------------------------------- L2. Rendering order + behavior @@ -239,7 +158,6 @@ S2. Insert UX - category filter - click inserts at viewport center OR drag ghost preview onto canvas and drop - [ ] Placement rules: - insert into active layer (if layers exist) - snap to grid if enabled @@ -295,3 +213,5 @@ Parking Lot *wb-pl* - [ ] Opacity for shapes - expose fill/stroke opacity controls so translucent layering is possible without exporting. +- [ ] Markdown layout caching + - cache layout per (md, w, style) to avoid re-parsing on every render diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index d6fc994..ea0044c 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -9,6 +9,7 @@ let canvasEl = $state(null); let textEditorEl = $state(null); let arrowLabelEditorEl = $state(null); + let markdownEditorEl = $state(null); let historyViewerOpen = $state(false); const c = createCanvasController({ @@ -20,6 +21,7 @@ let platform = $derived(c.platform()); let textEditorCurrent = $derived(c.textEditor.current); let arrowLabelEditorCurrent = $derived(c.arrowLabelEditor.current); + let markdownEditorCurrent = $derived(c.markdownEditor.current); let persistenceStatusStore = $derived(c.persistenceStatusStore()); let marqueeRect = $derived(c.marqueeRect()); @@ -37,6 +39,11 @@ c.arrowLabelEditor.setRef(arrowLabelEditorEl); return () => c.arrowLabelEditor.setRef(null); }); + + $effect(() => { + c.markdownEditor.setRef(markdownEditorEl); + return () => c.markdownEditor.setRef(null); + });
@@ -70,7 +77,14 @@ + {/if} + {/if} {#if marqueeRect}
+ style={[ + `left:${marqueeRect.left}px`, + `top:${marqueeRect.top}px`, + `width:${marqueeRect.width}px`, + `height:${marqueeRect.height}px`, + '' + ].join('; ')}>
{/if}
@@ -175,6 +222,25 @@ border-radius: 4px; } + .canvas-markdown-editor { + position: absolute; + border: 1px solid var(--accent); + background: var(--surface); + color: var(--text); + padding: 8px; + transform-origin: top left; + resize: none; + outline: none; + line-height: 1.4; + font-family: monospace; + z-index: 2; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0 8px 20px rgba(0, 0, 0, 0.15); + white-space: pre-wrap; + overflow: auto; + } + .canvas-marquee { position: absolute; border: 1px solid rgba(136, 192, 208, 0.7); diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts index c7c94ad..659db36 100644 --- a/apps/web/src/lib/canvas/canvas-store.svelte.ts +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -16,6 +16,7 @@ import { getShapesOnCurrentPage, InkfiniteDB, LineTool, + MarkdownTool, PenTool, RectTool, routeAction, @@ -35,6 +36,7 @@ import { ArrowLabelEditorController } from "./controllers/arrowlabel-controller. import { DesktopFileController } from "./controllers/desktop-file-controller.svelte"; import { FileBrowserController } from "./controllers/filebrowser-controller.svelte"; import { HistoryController } from "./controllers/history-controller"; +import { MarkdownEditorController } from "./controllers/markdown-controller.svelte"; import { TextEditorController } from "./controllers/texteditor-controller.svelte"; import { ToolController } from "./controllers/tool-controller.svelte"; import { HandleState } from "./store/handle-state.svelte"; @@ -124,7 +126,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { return; } const cursor = computeCursor( - textEditor.isEditing || arrowLabelEditor.isEditing, + textEditor.isEditing || arrowLabelEditor.isEditing || markdownEditor.isEditing, { isPanning: panState.isPanning, spaceHeld: panState.spaceHeld }, { hover: handleState.hover, active: handleState.active }, pointerState.isPointerDown, @@ -155,6 +157,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { const lineTool = new LineTool(); const arrowTool = new ArrowTool(); const textTool = new TextTool(); + const markdownTool = new MarkdownTool(); const getPenBrushConfig = () => { const { color: _color, ...config } = brushStore.get(); return config; @@ -164,10 +167,20 @@ export function createCanvasController(bindings: CanvasControllerBindings) { return { color: brush.color, opacity: 1 }; }; const penTool = new PenTool(getPenBrushConfig, getPenStrokeStyle); - const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool, penTool]); + const tools = createToolMap([ + selectTool, + rectTool, + ellipseTool, + lineTool, + arrowTool, + textTool, + markdownTool, + penTool, + ]); const textEditor = new TextEditorController(store, getViewport, refreshCursor); const arrowLabelEditor = new ArrowLabelEditorController(store, getViewport, refreshCursor); + const markdownEditor = new MarkdownEditorController(store, getViewport, refreshCursor); const toolController = new ToolController(store, tools); const unsubscribeMarqueeCamera = store.subscribe((state) => { if (marqueeBounds) { @@ -370,6 +383,10 @@ export function createCanvasController(bindings: CanvasControllerBindings) { textEditor.commit(); } + if (markdownEditor.isEditing && (action.type === "pointer-down" || action.type === "pointer-up")) { + markdownEditor.commit(); + } + if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { const hover = selectTool.getHandleAtPoint(store.getState(), action.world); setHandleHover(hover); @@ -475,6 +492,13 @@ export function createCanvasController(bindings: CanvasControllerBindings) { return; } } + if (shape.type === "markdown") { + const bounds = shapeBounds(shape); + if (world.x >= bounds.min.x && world.x <= bounds.max.x && world.y >= bounds.min.y && world.y <= bounds.max.y) { + markdownEditor.start(shape.id); + return; + } + } } } @@ -583,6 +607,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { history, textEditor, arrowLabelEditor, + markdownEditor, store, getViewport, handleCanvasDoubleClick, diff --git a/apps/web/src/lib/canvas/controllers/markdown-controller.svelte.ts b/apps/web/src/lib/canvas/controllers/markdown-controller.svelte.ts new file mode 100644 index 0000000..1fef455 --- /dev/null +++ b/apps/web/src/lib/canvas/controllers/markdown-controller.svelte.ts @@ -0,0 +1,126 @@ +import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from "inkfinite-core"; + +/** + * Controller for markdown block editing + * + * Handles: + * - Opening/closing markdown editor overlay + * - Cmd/Ctrl+Enter to toggle edit/view + * - Tab key inserts spaces (not focus change) + * - Commit on blur + */ +export class MarkdownEditorController { + current = $state<{ shapeId: string; value: string } | null>(null); + private markdownEditorEl: HTMLTextAreaElement | null = null; + + constructor(private store: Store, private getViewport: () => Viewport, private refreshCursor: () => void) {} + + get isEditing() { + return this.current !== null; + } + + setRef = (el: HTMLTextAreaElement | null) => { + this.markdownEditorEl = el; + }; + + getLayout = () => { + if (!this.current) { + return null; + } + const state = this.store.getState(); + const shape = state.doc.shapes[this.current.shapeId]; + if (!shape || shape.type !== "markdown") { + return null; + } + const viewport = this.getViewport(); + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); + const zoom = state.camera.zoom; + const widthWorld = shape.props.w; + const heightWorld = shape.props.h ?? shape.props.fontSize * 10; + return { + left: screenPos.x, + top: screenPos.y, + width: widthWorld * zoom, + height: heightWorld * zoom, + fontSize: shape.props.fontSize * zoom, + }; + }; + + start = (shapeId: string) => { + const state = this.store.getState(); + const shape = state.doc.shapes[shapeId]; + if (!shape || shape.type !== "markdown") { + return; + } + this.current = { shapeId, value: shape.props.md }; + this.refreshCursor(); + queueMicrotask(() => { + this.markdownEditorEl?.focus(); + this.markdownEditorEl?.select(); + }); + }; + + handleInput = (event: Event) => { + if (!this.current) { + return; + } + const target = event.currentTarget as HTMLTextAreaElement; + this.current = { ...this.current, value: target.value }; + }; + + handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab") { + event.preventDefault(); + const target = event.currentTarget as HTMLTextAreaElement; + const start = target.selectionStart; + const end = target.selectionEnd; + const spaces = " "; + const newValue = this.current!.value.substring(0, start) + spaces + this.current!.value.substring(end); + this.current = { ...this.current!, value: newValue }; + queueMicrotask(() => { + target.selectionStart = target.selectionEnd = start + spaces.length; + }); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + this.cancel(); + return; + } + + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.commit(); + } + }; + + handleBlur = () => { + this.commit(); + }; + + commit = () => { + if (!this.current) { + return; + } + const { shapeId, value } = this.current; + const currentState = this.store.getState(); + const shape = currentState.doc.shapes[shapeId]; + this.current = null; + this.refreshCursor(); + if (!shape || shape.type !== "markdown" || shape.props.md === value) { + return; + } + const before = EditorState.clone(currentState); + const updatedShape = { ...shape, props: { ...shape.props, md: value } }; + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand("Edit markdown", "doc", before, EditorState.clone(after)); + this.store.executeCommand(command); + }; + + cancel = () => { + this.current = null; + this.refreshCursor(); + }; +} diff --git a/apps/web/src/lib/components/Toolbar.svelte b/apps/web/src/lib/components/Toolbar.svelte index 790832e..750f952 100644 --- a/apps/web/src/lib/components/Toolbar.svelte +++ b/apps/web/src/lib/components/Toolbar.svelte @@ -56,15 +56,12 @@ let exportMenuOpen = $state(false); let exportMenuEl = $state(null); let exportButtonEl = $state(null); - let fillColorValue = $state(DEFAULT_FILL_COLOR); let strokeColorValue = $state(DEFAULT_STROKE_COLOR); let fillDisabled = $state(true); let strokeDisabled = $state(true); let brush = $derived(brushStore.get()); - let hasArrowSelection = $derived( - getSelectedShapes(editorState).some((s) => s.type === 'arrow') - ); + let hasArrowSelection = $derived(getSelectedShapes(editorState).some((s) => s.type === 'arrow')); $effect(() => { editorState = store.getState(); @@ -150,6 +147,7 @@ { id: 'line', label: 'Line', icon: '╱' }, { id: 'arrow', label: 'Arrow', icon: '→' }, { id: 'text', label: 'Text', icon: 'T' }, + { id: 'markdown', label: 'Markdown', icon: 'M↓' }, { id: 'pen', label: 'Pen', icon: '✎' } ]; diff --git a/apps/web/src/lib/tests/Canvas.svelte.test.ts b/apps/web/src/lib/tests/Canvas.svelte.test.ts index 4182949..4fa7f59 100644 --- a/apps/web/src/lib/tests/Canvas.svelte.test.ts +++ b/apps/web/src/lib/tests/Canvas.svelte.test.ts @@ -128,11 +128,11 @@ describe("Canvas component", () => { const { container } = render(Canvas); const toolButtons = container.querySelectorAll(".tool-button"); - expect(toolButtons.length).toBe(8); + expect(toolButtons.length).toBe(9); const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); const coreToolIds = toolIds.filter((id) => id && id !== "history"); - expect(coreToolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); + expect(coreToolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "markdown", "pen"]); const historyButton = container.querySelector(".tool-button.history-button"); expect(historyButton).toBeTruthy(); diff --git a/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts b/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts index 6844149..bdc1978 100644 --- a/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts +++ b/apps/web/src/lib/tests/components/Toolbar.svelte.test.ts @@ -21,10 +21,10 @@ describe("Toolbar component", () => { const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport, brushStore }); const buttons = container.querySelectorAll(".tool-button"); - expect(buttons.length).toBe(7); + expect(buttons.length).toBe(8); const toolIds = Array.from(buttons).map((btn) => btn.getAttribute("data-tool-id")); - expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); + expect(toolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "markdown", "pen"]); }); it("should mark the current tool as active", () => { diff --git a/apps/web/src/lib/tests/markdown-editor.test.ts b/apps/web/src/lib/tests/markdown-editor.test.ts new file mode 100644 index 0000000..19a8db2 --- /dev/null +++ b/apps/web/src/lib/tests/markdown-editor.test.ts @@ -0,0 +1,397 @@ +import { EditorState, PageRecord, ShapeRecord, Store } from "inkfinite-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownEditorController } from "../canvas/controllers/markdown-controller.svelte"; + +describe("MarkdownEditorController", () => { + let store: Store; + let controller: MarkdownEditorController; + const mockRefreshCursor = vi.fn(); + const mockGetViewport = () => ({ width: 1024, height: 768 }); + + beforeEach(() => { + store = new Store(); + mockRefreshCursor.mockClear(); + controller = new MarkdownEditorController(store, mockGetViewport, mockRefreshCursor); + }); + + describe("start", () => { + it("should start editing a markdown shape", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Hello World", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + ui: { ...state.ui, currentPageId: "page1" }, + })); + + controller.start("shape1"); + + expect(controller.isEditing).toBe(true); + expect(controller.current).toEqual({ shapeId: "shape1", value: "# Hello World" }); + expect(mockRefreshCursor).toHaveBeenCalled(); + }); + + it("should not start editing if shape is not markdown", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createRect("page1", 100, 200, { + w: 100, + h: 50, + fill: "#fff", + stroke: "#000", + radius: 0, + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + + expect(controller.isEditing).toBe(false); + expect(controller.current).toBeNull(); + }); + + it("should not start editing if shape does not exist", () => { + controller.start("nonexistent"); + + expect(controller.isEditing).toBe(false); + expect(controller.current).toBeNull(); + }); + }); + + describe("getLayout", () => { + it("should return null when not editing", () => { + expect(controller.getLayout()).toBeNull(); + }); + + it("should compute layout when editing", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Test", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + ui: { ...state.ui, currentPageId: "page1" }, + camera: { ...state.camera, x: 0, y: 0, zoom: 1 }, + })); + + controller.start("shape1"); + const layout = controller.getLayout(); + + expect(layout).toBeTruthy(); + expect(layout?.width).toBe(300); + expect(layout?.height).toBe(200); + expect(layout?.fontSize).toBe(16); + }); + + it("should handle auto-computed height", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Test", + w: 300, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + const layout = controller.getLayout(); + + expect(layout).toBeTruthy(); + expect(layout?.height).toBe(160); + }); + }); + + describe("handleInput", () => { + it("should update current value on input", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Hello", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + + const mockEvent = { currentTarget: { value: "# Hello World" } as HTMLTextAreaElement } as unknown as Event; + + controller.handleInput(mockEvent); + + expect(controller.current?.value).toBe("# Hello World"); + }); + + it("should do nothing if not editing", () => { + const mockEvent = { currentTarget: { value: "test" } as HTMLTextAreaElement } as unknown as Event; + + controller.handleInput(mockEvent); + + expect(controller.current).toBeNull(); + }); + }); + + describe("handleKeyDown", () => { + beforeEach(() => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Test", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + }); + + it("should insert spaces on Tab key", () => { + const mockTextarea = { selectionStart: 6, selectionEnd: 6, value: "# Test" } as HTMLTextAreaElement; + + const mockEvent = { + key: "Tab", + preventDefault: vi.fn(), + currentTarget: mockTextarea, + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.current?.value).toBe("# Test "); + }); + + it("should replace selection with spaces on Tab", () => { + controller.current!.value = "# Test Content"; + + const mockTextarea = { selectionStart: 2, selectionEnd: 6, value: "# Test Content" } as HTMLTextAreaElement; + + const mockEvent = { + key: "Tab", + preventDefault: vi.fn(), + currentTarget: mockTextarea, + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.current?.value).toBe("# Content"); + }); + + it("should cancel on Escape key", () => { + const mockEvent = { key: "Escape", preventDefault: vi.fn() } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.isEditing).toBe(false); + expect(mockRefreshCursor).toHaveBeenCalled(); + }); + + it("should commit on Cmd+Enter", () => { + controller.current!.value = "# Updated"; + + const mockEvent = { + key: "Enter", + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.isEditing).toBe(false); + + const updatedShape = store.getState().doc.shapes["shape1"]; + expect(updatedShape).toBeTruthy(); + if (updatedShape?.type === "markdown") { + expect(updatedShape.props.md).toBe("# Updated"); + } + }); + + it("should commit on Ctrl+Enter", () => { + controller.current!.value = "# Updated"; + + const mockEvent = { + key: "Enter", + metaKey: false, + ctrlKey: true, + preventDefault: vi.fn(), + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.isEditing).toBe(false); + + const updatedShape = store.getState().doc.shapes["shape1"]; + if (updatedShape?.type === "markdown") { + expect(updatedShape.props.md).toBe("# Updated"); + } + }); + }); + + describe("commit", () => { + it("should update markdown content and create history entry", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Original", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + controller.current!.value = "# Updated Content"; + controller.commit(); + + expect(controller.isEditing).toBe(false); + expect(mockRefreshCursor).toHaveBeenCalled(); + + const updatedShape = store.getState().doc.shapes["shape1"]; + expect(updatedShape).toBeTruthy(); + if (updatedShape?.type === "markdown") { + expect(updatedShape.props.md).toBe("# Updated Content"); + } + }); + + it("should not update if value is unchanged", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Original", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + const initialState = EditorState.create(); + initialState.doc = { ...initialState.doc, pages: { page1: page }, shapes: { shape1: shape } }; + store.setState(() => initialState); + + controller.start("shape1"); + controller.commit(); + + const finalState = store.getState(); + expect(finalState).toEqual(initialState); + }); + + it("should do nothing if not editing", () => { + const initialState = store.getState(); + controller.commit(); + expect(store.getState()).toBe(initialState); + }); + }); + + describe("cancel", () => { + it("should stop editing without saving", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Original", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + controller.current!.value = "# Modified"; + controller.cancel(); + + expect(controller.isEditing).toBe(false); + expect(mockRefreshCursor).toHaveBeenCalled(); + + const originalShape = store.getState().doc.shapes["shape1"]; + if (originalShape?.type === "markdown") { + expect(originalShape.props.md).toBe("# Original"); + } + }); + }); + + describe("handleBlur", () => { + it("should commit on blur", () => { + const page = PageRecord.create("Test Page", "page1"); + const shape = ShapeRecord.createMarkdown("page1", 100, 200, { + md: "# Original", + w: 300, + h: 200, + fontSize: 16, + fontFamily: "sans-serif", + color: "#000", + }, "shape1"); + + page.shapeIds = ["shape1"]; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + })); + + controller.start("shape1"); + controller.current!.value = "# Updated on Blur"; + controller.handleBlur(); + + expect(controller.isEditing).toBe(false); + + const updatedShape = store.getState().doc.shapes["shape1"]; + if (updatedShape?.type === "markdown") { + expect(updatedShape.props.md).toBe("# Updated on Blur"); + } + }); + }); +}); diff --git a/packages/core/tests/markdown.test.ts b/packages/core/tests/markdown.test.ts index 9bf75ec..7c02e31 100644 --- a/packages/core/tests/markdown.test.ts +++ b/packages/core/tests/markdown.test.ts @@ -106,7 +106,12 @@ describe("MarkdownShape", () => { }); it("should compute bounds for markdown shape with auto height", () => { - const shape = ShapeRecord.createMarkdown(pageId, 0, 0, createProps({ md: "# Test", color: "#000" })); + const shape = ShapeRecord.createMarkdown( + pageId, + 0, + 0, + createProps({ md: "# Test", h: undefined, color: "#000" }), + ); const bounds = shapeBounds(shape); expect(bounds.min.x).toBe(0); expect(bounds.min.y).toBe(0);