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