diff --git a/TODO.txt b/TODO.txt index 6bd873b..7f7585a 100644 --- a/TODO.txt +++ b/TODO.txt @@ -31,38 +31,6 @@ Goal: the editor stays responsive with many shapes. (DoD): - 10k simple shapes pans/zooms smoothly on a typical machine. -================================================================================ -Milestone A: Richer arrows / connectors *wb-A* -================================================================================ - --------------------------------------------------------------------------------- -A2. Editing UX --------------------------------------------------------------------------------- - -[ ] Multi-point editing: - - Alt/Option+click on segment adds a control point - - Backspace/Delete on selected point removes it - - Drag point to reshape polyline -[ ] Orthogonal routing UI toggle: - - per-arrow toggle (routing.kind) - - UI control to switch between straight and orthogonal -[ ] Label editing UI: - - double-click arrow to edit label text - - label drags along the connector (offset along polyline) - --------------------------------------------------------------------------------- -A3. Precise anchors + snapping --------------------------------------------------------------------------------- - -[ ] Anchor preview: - - show snap indicator when binding will occur --------------------------------------------------------------------------------- -A5. Tests --------------------------------------------------------------------------------- - -[ ] Polyline edits preserve endpoints and do not corrupt bindings -[ ] Label placement stable under zoom/pan - ================================================================================ Milestone M: Markdown Blocks *wb-M* ================================================================================ @@ -89,7 +57,7 @@ M1. Data model M2. Rendering -------------------------------------------------------------------------------- -/packages/renderer-canvas2d: +/packages/renderer: [ ] Render Markdown in canvas using a minimal subset: - headings (#, ##) - bold/italic/code diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index fd167e8..d6fc994 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -8,6 +8,7 @@ let canvasEl = $state(null); let textEditorEl = $state(null); + let arrowLabelEditorEl = $state(null); let historyViewerOpen = $state(false); const c = createCanvasController({ @@ -18,6 +19,7 @@ let platform = $derived(c.platform()); let textEditorCurrent = $derived(c.textEditor.current); + let arrowLabelEditorCurrent = $derived(c.arrowLabelEditor.current); let persistenceStatusStore = $derived(c.persistenceStatusStore()); let marqueeRect = $derived(c.marqueeRect()); @@ -30,6 +32,11 @@ c.textEditor.setRef(textEditorEl); return () => c.textEditor.setRef(null); }); + + $effect(() => { + c.arrowLabelEditor.setRef(arrowLabelEditorEl); + return () => c.arrowLabelEditor.setRef(null); + });
@@ -71,6 +78,22 @@ spellcheck="false"> {/if} {/if} + {#if arrowLabelEditorCurrent} + {@const layout = c.arrowLabelEditor.getLayout()} + {#if layout} + + {/if} + {/if} {#if marqueeRect}
{ if (marqueeBounds) { @@ -466,6 +468,13 @@ export function createCanvasController(bindings: CanvasControllerBindings) { return; } } + if (shape.type === "arrow") { + 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) { + arrowLabelEditor.start(shape.id); + return; + } + } } } @@ -573,6 +582,7 @@ export function createCanvasController(bindings: CanvasControllerBindings) { tools: toolController, history, textEditor, + arrowLabelEditor, store, getViewport, handleCanvasDoubleClick, diff --git a/apps/web/src/lib/canvas/controllers/arrowlabel-controller.svelte.ts b/apps/web/src/lib/canvas/controllers/arrowlabel-controller.svelte.ts new file mode 100644 index 0000000..725e091 --- /dev/null +++ b/apps/web/src/lib/canvas/controllers/arrowlabel-controller.svelte.ts @@ -0,0 +1,144 @@ +import { + type ArrowShape, + Camera, + computePolylineLength, + EditorState, + getPointAtDistance, + SnapshotCommand, + type Store, + type Viewport, +} from "inkfinite-core"; + +export class ArrowLabelEditorController { + current = $state<{ shapeId: string; value: string } | null>(null); + private inputEl: HTMLInputElement | null = null; + + constructor(private store: Store, private getViewport: () => Viewport, private refreshCursor: () => void) {} + + get isEditing() { + return this.current !== null; + } + + setRef = (el: HTMLInputElement | null) => { + this.inputEl = el; + }; + + getLayout = () => { + if (!this.current) { + return null; + } + const state = this.store.getState(); + const shape = state.doc.shapes[this.current.shapeId]; + if (!shape || shape.type !== "arrow") { + return null; + } + const arrow = shape as ArrowShape; + + const points = arrow.props.points; + if (points.length < 2) { + return null; + } + + const polylineLength = computePolylineLength(points); + const align = arrow.props.label?.align ?? "center"; + const offset = arrow.props.label?.offset ?? 0; + + let distance: number; + if (align === "center") { + distance = polylineLength / 2 + offset; + } else if (align === "start") { + distance = offset; + } else { + distance = polylineLength - offset; + } + + distance = Math.max(0, Math.min(distance, polylineLength)); + const labelPos = getPointAtDistance(points, distance); + + const viewport = this.getViewport(); + const screenPos = Camera.worldToScreen(state.camera, labelPos, viewport); + const zoom = state.camera.zoom; + + return { left: screenPos.x - 100, top: screenPos.y - 10, width: 200, fontSize: 14 * zoom }; + }; + + start = (shapeId: string) => { + const state = this.store.getState(); + const shape = state.doc.shapes[shapeId]; + if (!shape || shape.type !== "arrow") { + return; + } + const arrow = shape as ArrowShape; + this.current = { shapeId, value: arrow.props.label?.text ?? "" }; + this.refreshCursor(); + queueMicrotask(() => { + this.inputEl?.focus(); + this.inputEl?.select(); + }); + }; + + handleInput = (event: Event) => { + if (!this.current) { + return; + } + const target = event.currentTarget as HTMLInputElement; + this.current = { ...this.current, value: target.value }; + }; + + handleKeyDown = (event: KeyboardEvent) => { + 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 !== "arrow") { + return; + } + const arrow = shape as ArrowShape; + const trimmedValue = value.trim(); + + const currentLabel = arrow.props.label?.text ?? ""; + if (currentLabel === trimmedValue) { + return; + } + + const before = EditorState.clone(currentState); + const updatedArrow: ArrowShape = { + ...arrow, + props: { + ...arrow.props, + label: trimmedValue + ? { text: trimmedValue, align: arrow.props.label?.align ?? "center", offset: arrow.props.label?.offset ?? 0 } + : undefined, + }, + }; + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedArrow }; + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand("Edit arrow label", "doc", before, EditorState.clone(after)); + this.store.executeCommand(command); + }; + + cancel = () => { + this.current = null; + this.refreshCursor(); + }; +} diff --git a/apps/web/src/lib/components/ArrowPopover.svelte b/apps/web/src/lib/components/ArrowPopover.svelte new file mode 100644 index 0000000..7280ed8 --- /dev/null +++ b/apps/web/src/lib/components/ArrowPopover.svelte @@ -0,0 +1,306 @@ + + +
+ + + {#if isOpen} + + {/if} +
+ + diff --git a/apps/web/src/lib/components/Toolbar.svelte b/apps/web/src/lib/components/Toolbar.svelte index 61362d1..790832e 100644 --- a/apps/web/src/lib/components/Toolbar.svelte +++ b/apps/web/src/lib/components/Toolbar.svelte @@ -21,6 +21,7 @@ shapeBounds, SnapshotCommand } from 'inkfinite-core'; + import ArrowPopover from './ArrowPopover.svelte'; import BrushPopover from './BrushPopover.svelte'; type Viewport = { width: number; height: number }; @@ -61,6 +62,9 @@ let fillDisabled = $state(true); let strokeDisabled = $state(true); let brush = $derived(brushStore.get()); + let hasArrowSelection = $derived( + getSelectedShapes(editorState).some((s) => s.type === 'arrow') + ); $effect(() => { editorState = store.getState(); @@ -434,6 +438,8 @@ + +