web based infinite canvas
at main 126 lines 3.8 kB view raw
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}