web based infinite canvas
1import { 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 */
12export 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}