web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: connecting arrow ux with label editing

+1195 -187
+1 -33
TODO.txt
··· 32 32 - 10k simple shapes pans/zooms smoothly on a typical machine. 33 33 34 34 ================================================================================ 35 - Milestone A: Richer arrows / connectors *wb-A* 36 - ================================================================================ 37 - 38 - -------------------------------------------------------------------------------- 39 - A2. Editing UX 40 - -------------------------------------------------------------------------------- 41 - 42 - [ ] Multi-point editing: 43 - - Alt/Option+click on segment adds a control point 44 - - Backspace/Delete on selected point removes it 45 - - Drag point to reshape polyline 46 - [ ] Orthogonal routing UI toggle: 47 - - per-arrow toggle (routing.kind) 48 - - UI control to switch between straight and orthogonal 49 - [ ] Label editing UI: 50 - - double-click arrow to edit label text 51 - - label drags along the connector (offset along polyline) 52 - 53 - -------------------------------------------------------------------------------- 54 - A3. Precise anchors + snapping 55 - -------------------------------------------------------------------------------- 56 - 57 - [ ] Anchor preview: 58 - - show snap indicator when binding will occur 59 - -------------------------------------------------------------------------------- 60 - A5. Tests 61 - -------------------------------------------------------------------------------- 62 - 63 - [ ] Polyline edits preserve endpoints and do not corrupt bindings 64 - [ ] Label placement stable under zoom/pan 65 - 66 - ================================================================================ 67 35 Milestone M: Markdown Blocks *wb-M* 68 36 ================================================================================ 69 37 ··· 89 57 M2. Rendering 90 58 -------------------------------------------------------------------------------- 91 59 92 - /packages/renderer-canvas2d: 60 + /packages/renderer: 93 61 [ ] Render Markdown in canvas using a minimal subset: 94 62 - headings (#, ##) 95 63 - bold/italic/code
+40
apps/web/src/lib/canvas/Canvas.svelte
··· 8 8 9 9 let canvasEl = $state<HTMLCanvasElement | null>(null); 10 10 let textEditorEl = $state<HTMLTextAreaElement | null>(null); 11 + let arrowLabelEditorEl = $state<HTMLInputElement | null>(null); 11 12 let historyViewerOpen = $state(false); 12 13 13 14 const c = createCanvasController({ ··· 18 19 19 20 let platform = $derived(c.platform()); 20 21 let textEditorCurrent = $derived(c.textEditor.current); 22 + let arrowLabelEditorCurrent = $derived(c.arrowLabelEditor.current); 21 23 let persistenceStatusStore = $derived(c.persistenceStatusStore()); 22 24 let marqueeRect = $derived(c.marqueeRect()); 23 25 ··· 29 31 $effect(() => { 30 32 c.textEditor.setRef(textEditorEl); 31 33 return () => c.textEditor.setRef(null); 34 + }); 35 + 36 + $effect(() => { 37 + c.arrowLabelEditor.setRef(arrowLabelEditorEl); 38 + return () => c.arrowLabelEditor.setRef(null); 32 39 }); 33 40 </script> 34 41 ··· 71 78 spellcheck="false"></textarea> 72 79 {/if} 73 80 {/if} 81 + {#if arrowLabelEditorCurrent} 82 + {@const layout = c.arrowLabelEditor.getLayout()} 83 + {#if layout} 84 + <input 85 + bind:this={arrowLabelEditorEl} 86 + class="canvas-arrow-label-editor" 87 + style={`left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;font-size:${layout.fontSize}px;`} 88 + type="text" 89 + value={arrowLabelEditorCurrent.value} 90 + oninput={c.arrowLabelEditor.handleInput} 91 + onkeydown={c.arrowLabelEditor.handleKeyDown} 92 + onblur={c.arrowLabelEditor.handleBlur} 93 + spellcheck="false" 94 + placeholder="Enter arrow label..." /> 95 + {/if} 96 + {/if} 74 97 {#if marqueeRect} 75 98 <div 76 99 class="canvas-marquee" ··· 133 156 box-shadow: 134 157 0 0 0 1px rgba(0, 0, 0, 0.05), 135 158 0 8px 20px rgba(0, 0, 0, 0.15); 159 + } 160 + 161 + .canvas-arrow-label-editor { 162 + position: absolute; 163 + border: 1px solid var(--accent); 164 + background: var(--surface); 165 + color: var(--text); 166 + padding: 6px 8px; 167 + transform-origin: center; 168 + outline: none; 169 + font-family: sans-serif; 170 + text-align: center; 171 + z-index: 2; 172 + box-shadow: 173 + 0 0 0 1px rgba(0, 0, 0, 0.05), 174 + 0 8px 20px rgba(0, 0, 0, 0.15); 175 + border-radius: 4px; 136 176 } 137 177 138 178 .canvas-marquee {
+11 -1
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 31 31 import { onDestroy, onMount } from "svelte"; 32 32 import { SvelteSet } from "svelte/reactivity"; 33 33 import { computeCursor, describeAction, getCommandKind, statesEqual } from "./canvas-helpers"; 34 + import { ArrowLabelEditorController } from "./controllers/arrowlabel-controller.svelte"; 34 35 import { DesktopFileController } from "./controllers/desktop-file-controller.svelte"; 35 36 import { FileBrowserController } from "./controllers/filebrowser-controller.svelte"; 36 37 import { HistoryController } from "./controllers/history-controller"; ··· 123 124 return; 124 125 } 125 126 const cursor = computeCursor( 126 - textEditor.isEditing, 127 + textEditor.isEditing || arrowLabelEditor.isEditing, 127 128 { isPanning: panState.isPanning, spaceHeld: panState.spaceHeld }, 128 129 { hover: handleState.hover, active: handleState.active }, 129 130 pointerState.isPointerDown, ··· 166 167 const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool, penTool]); 167 168 168 169 const textEditor = new TextEditorController(store, getViewport, refreshCursor); 170 + const arrowLabelEditor = new ArrowLabelEditorController(store, getViewport, refreshCursor); 169 171 const toolController = new ToolController(store, tools); 170 172 const unsubscribeMarqueeCamera = store.subscribe((state) => { 171 173 if (marqueeBounds) { ··· 466 468 return; 467 469 } 468 470 } 471 + if (shape.type === "arrow") { 472 + const bounds = shapeBounds(shape); 473 + if (world.x >= bounds.min.x && world.x <= bounds.max.x && world.y >= bounds.min.y && world.y <= bounds.max.y) { 474 + arrowLabelEditor.start(shape.id); 475 + return; 476 + } 477 + } 469 478 } 470 479 } 471 480 ··· 573 582 tools: toolController, 574 583 history, 575 584 textEditor, 585 + arrowLabelEditor, 576 586 store, 577 587 getViewport, 578 588 handleCanvasDoubleClick,
+144
apps/web/src/lib/canvas/controllers/arrowlabel-controller.svelte.ts
··· 1 + import { 2 + type ArrowShape, 3 + Camera, 4 + computePolylineLength, 5 + EditorState, 6 + getPointAtDistance, 7 + SnapshotCommand, 8 + type Store, 9 + type Viewport, 10 + } from "inkfinite-core"; 11 + 12 + export class ArrowLabelEditorController { 13 + current = $state<{ shapeId: string; value: string } | null>(null); 14 + private inputEl: HTMLInputElement | 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: HTMLInputElement | null) => { 23 + this.inputEl = 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 !== "arrow") { 33 + return null; 34 + } 35 + const arrow = shape as ArrowShape; 36 + 37 + const points = arrow.props.points; 38 + if (points.length < 2) { 39 + return null; 40 + } 41 + 42 + const polylineLength = computePolylineLength(points); 43 + const align = arrow.props.label?.align ?? "center"; 44 + const offset = arrow.props.label?.offset ?? 0; 45 + 46 + let distance: number; 47 + if (align === "center") { 48 + distance = polylineLength / 2 + offset; 49 + } else if (align === "start") { 50 + distance = offset; 51 + } else { 52 + distance = polylineLength - offset; 53 + } 54 + 55 + distance = Math.max(0, Math.min(distance, polylineLength)); 56 + const labelPos = getPointAtDistance(points, distance); 57 + 58 + const viewport = this.getViewport(); 59 + const screenPos = Camera.worldToScreen(state.camera, labelPos, viewport); 60 + const zoom = state.camera.zoom; 61 + 62 + return { left: screenPos.x - 100, top: screenPos.y - 10, width: 200, fontSize: 14 * zoom }; 63 + }; 64 + 65 + start = (shapeId: string) => { 66 + const state = this.store.getState(); 67 + const shape = state.doc.shapes[shapeId]; 68 + if (!shape || shape.type !== "arrow") { 69 + return; 70 + } 71 + const arrow = shape as ArrowShape; 72 + this.current = { shapeId, value: arrow.props.label?.text ?? "" }; 73 + this.refreshCursor(); 74 + queueMicrotask(() => { 75 + this.inputEl?.focus(); 76 + this.inputEl?.select(); 77 + }); 78 + }; 79 + 80 + handleInput = (event: Event) => { 81 + if (!this.current) { 82 + return; 83 + } 84 + const target = event.currentTarget as HTMLInputElement; 85 + this.current = { ...this.current, value: target.value }; 86 + }; 87 + 88 + handleKeyDown = (event: KeyboardEvent) => { 89 + if (event.key === "Escape") { 90 + event.preventDefault(); 91 + this.cancel(); 92 + return; 93 + } 94 + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { 95 + event.preventDefault(); 96 + this.commit(); 97 + } 98 + }; 99 + 100 + handleBlur = () => { 101 + this.commit(); 102 + }; 103 + 104 + commit = () => { 105 + if (!this.current) { 106 + return; 107 + } 108 + const { shapeId, value } = this.current; 109 + const currentState = this.store.getState(); 110 + const shape = currentState.doc.shapes[shapeId]; 111 + this.current = null; 112 + this.refreshCursor(); 113 + if (!shape || shape.type !== "arrow") { 114 + return; 115 + } 116 + const arrow = shape as ArrowShape; 117 + const trimmedValue = value.trim(); 118 + 119 + const currentLabel = arrow.props.label?.text ?? ""; 120 + if (currentLabel === trimmedValue) { 121 + return; 122 + } 123 + 124 + const before = EditorState.clone(currentState); 125 + const updatedArrow: ArrowShape = { 126 + ...arrow, 127 + props: { 128 + ...arrow.props, 129 + label: trimmedValue 130 + ? { text: trimmedValue, align: arrow.props.label?.align ?? "center", offset: arrow.props.label?.offset ?? 0 } 131 + : undefined, 132 + }, 133 + }; 134 + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedArrow }; 135 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 136 + const command = new SnapshotCommand("Edit arrow label", "doc", before, EditorState.clone(after)); 137 + this.store.executeCommand(command); 138 + }; 139 + 140 + cancel = () => { 141 + this.current = null; 142 + this.refreshCursor(); 143 + }; 144 + }
+306
apps/web/src/lib/components/ArrowPopover.svelte
··· 1 + <script lang="ts"> 2 + import type { ArrowShape, Store } from 'inkfinite-core'; 3 + import { EditorState, getSelectedShapes, SnapshotCommand } from 'inkfinite-core'; 4 + 5 + type Props = { store: Store; disabled?: boolean }; 6 + 7 + let { store, disabled = false }: Props = $props(); 8 + 9 + let isOpen = $state(false); 10 + let popoverEl = $state<HTMLDivElement | null>(null); 11 + let buttonEl = $state<HTMLButtonElement | null>(null); 12 + 13 + let editorState = $derived(store.getState()); 14 + 15 + let selectedArrows = $derived<ArrowShape[]>( 16 + getSelectedShapes(editorState).filter((s): s is ArrowShape => s.type === 'arrow') 17 + ); 18 + 19 + let routingKind = $derived<'straight' | 'orthogonal' | 'mixed'>( 20 + (() => { 21 + if (selectedArrows.length === 0) return 'straight'; 22 + const first = selectedArrows[0].props.routing?.kind ?? 'straight'; 23 + const allSame = selectedArrows.every( 24 + (arrow) => (arrow.props.routing?.kind ?? 'straight') === first 25 + ); 26 + return allSame ? first : 'mixed'; 27 + })() 28 + ); 29 + 30 + let labelText = $derived<string>( 31 + (() => { 32 + if (selectedArrows.length === 0) return ''; 33 + if (selectedArrows.length === 1) return selectedArrows[0].props.label?.text ?? ''; 34 + const first = selectedArrows[0].props.label?.text ?? ''; 35 + const allSame = selectedArrows.every((arrow) => (arrow.props.label?.text ?? '') === first); 36 + return allSame ? first : ''; 37 + })() 38 + ); 39 + 40 + $effect(() => { 41 + const unsubscribe = store.subscribe((state) => { 42 + editorState = state; 43 + }); 44 + return () => unsubscribe(); 45 + }); 46 + 47 + $effect(() => { 48 + if (!isOpen || typeof document === 'undefined') { 49 + return; 50 + } 51 + const handlePointerDown = (event: PointerEvent) => { 52 + const target = event.target as Node | null; 53 + if (!target) { 54 + return; 55 + } 56 + if (popoverEl?.contains(target) || buttonEl?.contains(target)) { 57 + return; 58 + } 59 + isOpen = false; 60 + }; 61 + 62 + document.addEventListener('pointerdown', handlePointerDown); 63 + return () => document.removeEventListener('pointerdown', handlePointerDown); 64 + }); 65 + 66 + function togglePopover() { 67 + if (!disabled) { 68 + isOpen = !isOpen; 69 + } 70 + } 71 + 72 + function setRouting(kind: 'straight' | 'orthogonal') { 73 + const state = store.getState(); 74 + const arrows = getSelectedShapes(state).filter((s): s is ArrowShape => s.type === 'arrow'); 75 + if (arrows.length === 0) return; 76 + 77 + const before = EditorState.clone(state); 78 + const newShapes = { ...state.doc.shapes }; 79 + 80 + for (const arrow of arrows) { 81 + const updated: ArrowShape = { ...arrow, props: { ...arrow.props, routing: { kind } } }; 82 + newShapes[arrow.id] = updated; 83 + } 84 + 85 + const after = { ...state, doc: { ...state.doc, shapes: newShapes } }; 86 + const command = new SnapshotCommand( 87 + 'Set arrow routing', 88 + 'doc', 89 + before, 90 + EditorState.clone(after) 91 + ); 92 + store.executeCommand(command); 93 + } 94 + 95 + function handleLabelChange(event: Event) { 96 + const input = event.currentTarget as HTMLInputElement; 97 + const text = input.value; 98 + const state = store.getState(); 99 + const arrows = getSelectedShapes(state).filter((s): s is ArrowShape => s.type === 'arrow'); 100 + if (arrows.length === 0) return; 101 + 102 + const before = EditorState.clone(state); 103 + const newShapes = { ...state.doc.shapes }; 104 + 105 + for (const arrow of arrows) { 106 + const updated: ArrowShape = { 107 + ...arrow, 108 + props: { 109 + ...arrow.props, 110 + label: text.trim() 111 + ? { 112 + text, 113 + align: arrow.props.label?.align ?? 'center', 114 + offset: arrow.props.label?.offset ?? 0 115 + } 116 + : undefined 117 + } 118 + }; 119 + newShapes[arrow.id] = updated; 120 + } 121 + 122 + const after = { ...state, doc: { ...state.doc, shapes: newShapes } }; 123 + const command = new SnapshotCommand( 124 + 'Set arrow label', 125 + 'doc', 126 + before, 127 + EditorState.clone(after) 128 + ); 129 + store.executeCommand(command); 130 + } 131 + </script> 132 + 133 + <div class="arrow-popover"> 134 + <button 135 + class="arrow-popover__button" 136 + bind:this={buttonEl} 137 + onclick={togglePopover} 138 + {disabled} 139 + aria-label="Arrow settings" 140 + aria-haspopup="true" 141 + aria-expanded={isOpen}> 142 + Arrow 143 + </button> 144 + 145 + {#if isOpen} 146 + <div 147 + class="arrow-popover__menu" 148 + bind:this={popoverEl} 149 + role="dialog" 150 + aria-label="Arrow settings"> 151 + <div class="arrow-popover__section"> 152 + <div class="arrow-popover__label">Routing</div> 153 + <div class="arrow-popover__routing-buttons"> 154 + <button 155 + class="arrow-popover__routing-btn" 156 + class:arrow-popover__routing-btn--active={routingKind === 'straight'} 157 + onclick={() => setRouting('straight')} 158 + aria-label="Straight routing" 159 + aria-pressed={routingKind === 'straight'}> 160 + Straight 161 + </button> 162 + <button 163 + class="arrow-popover__routing-btn" 164 + class:arrow-popover__routing-btn--active={routingKind === 'orthogonal'} 165 + onclick={() => setRouting('orthogonal')} 166 + aria-label="Orthogonal routing" 167 + aria-pressed={routingKind === 'orthogonal'}> 168 + Orthogonal 169 + </button> 170 + </div> 171 + </div> 172 + 173 + <div class="arrow-popover__divider"></div> 174 + 175 + <div class="arrow-popover__section"> 176 + <label for="arrow-label"> 177 + <span class="arrow-popover__label">Label</span> 178 + </label> 179 + <input 180 + id="arrow-label" 181 + type="text" 182 + class="arrow-popover__input" 183 + value={labelText} 184 + onchange={handleLabelChange} 185 + placeholder="Enter label..." 186 + aria-label="Arrow label" /> 187 + </div> 188 + </div> 189 + {/if} 190 + </div> 191 + 192 + <style> 193 + .arrow-popover { 194 + position: relative; 195 + } 196 + 197 + .arrow-popover__button { 198 + border: 1px solid var(--border); 199 + background: var(--surface); 200 + color: var(--text); 201 + padding: 8px 12px; 202 + border-radius: 4px; 203 + cursor: pointer; 204 + font-size: 13px; 205 + min-width: 60px; 206 + } 207 + 208 + .arrow-popover__button:hover:not(:disabled) { 209 + background: var(--surface-elevated); 210 + } 211 + 212 + .arrow-popover__button:focus { 213 + outline: 2px solid var(--accent); 214 + outline-offset: 2px; 215 + } 216 + 217 + .arrow-popover__button:disabled { 218 + opacity: 0.4; 219 + cursor: not-allowed; 220 + } 221 + 222 + .arrow-popover__menu { 223 + position: absolute; 224 + top: calc(100% + 4px); 225 + left: 0; 226 + background: var(--surface); 227 + color: var(--text); 228 + border: 1px solid var(--border); 229 + border-radius: 6px; 230 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 231 + padding: 12px; 232 + display: flex; 233 + flex-direction: column; 234 + gap: 12px; 235 + z-index: 10; 236 + min-width: 200px; 237 + } 238 + 239 + .arrow-popover__section { 240 + display: flex; 241 + flex-direction: column; 242 + gap: 8px; 243 + } 244 + 245 + .arrow-popover__label { 246 + font-size: 12px; 247 + font-weight: 500; 248 + color: var(--text); 249 + } 250 + 251 + .arrow-popover__routing-buttons { 252 + display: flex; 253 + gap: 6px; 254 + } 255 + 256 + .arrow-popover__routing-btn { 257 + flex: 1; 258 + border: 1px solid var(--border); 259 + background: var(--surface); 260 + color: var(--text); 261 + padding: 6px 12px; 262 + border-radius: 4px; 263 + cursor: pointer; 264 + font-size: 12px; 265 + transition: all 0.15s; 266 + } 267 + 268 + .arrow-popover__routing-btn:hover { 269 + background: var(--surface-elevated); 270 + } 271 + 272 + .arrow-popover__routing-btn:focus { 273 + outline: 2px solid var(--accent); 274 + outline-offset: 2px; 275 + } 276 + 277 + .arrow-popover__routing-btn--active { 278 + background: var(--accent); 279 + color: var(--surface); 280 + border-color: var(--accent); 281 + } 282 + 283 + .arrow-popover__input { 284 + width: 100%; 285 + border: 1px solid var(--border); 286 + background: var(--surface); 287 + color: var(--text); 288 + padding: 6px 8px; 289 + border-radius: 4px; 290 + font-size: 13px; 291 + } 292 + 293 + .arrow-popover__input:focus { 294 + outline: 2px solid var(--accent); 295 + outline-offset: 2px; 296 + } 297 + 298 + .arrow-popover__input::placeholder { 299 + color: var(--text-muted); 300 + } 301 + 302 + .arrow-popover__divider { 303 + height: 1px; 304 + background: var(--border); 305 + } 306 + </style>
+6
apps/web/src/lib/components/Toolbar.svelte
··· 21 21 shapeBounds, 22 22 SnapshotCommand 23 23 } from 'inkfinite-core'; 24 + import ArrowPopover from './ArrowPopover.svelte'; 24 25 import BrushPopover from './BrushPopover.svelte'; 25 26 26 27 type Viewport = { width: number; height: number }; ··· 61 62 let fillDisabled = $state(true); 62 63 let strokeDisabled = $state(true); 63 64 let brush = $derived<BrushSettings>(brushStore.get()); 65 + let hasArrowSelection = $derived( 66 + getSelectedShapes(editorState).some((s) => s.type === 'arrow') 67 + ); 64 68 65 69 $effect(() => { 66 70 editorState = store.getState(); ··· 433 437 <div class="toolbar__divider"></div> 434 438 435 439 <BrushPopover {brush} onBrushChange={handleBrushChange} disabled={currentTool !== 'pen'} /> 440 + 441 + <ArrowPopover {store} disabled={!hasArrowSelection} /> 436 442 437 443 <div class="toolbar__zoom"> 438 444 <button
+31
packages/core/src/geom.ts
··· 572 572 573 573 return [start, { x: midX, y: start.y }, { x: midX, y: end.y }, end]; 574 574 } 575 + 576 + /** 577 + * Compute the total length of a polyline 578 + */ 579 + export function computePolylineLength(points: Vec2[]): number { 580 + let length = 0; 581 + for (let i = 1; i < points.length; i++) { 582 + const dx = points[i].x - points[i - 1].x; 583 + const dy = points[i].y - points[i - 1].y; 584 + length += Math.sqrt(dx * dx + dy * dy); 585 + } 586 + return length; 587 + } 588 + 589 + /** 590 + * Get a point at a specific distance along a polyline 591 + */ 592 + export function getPointAtDistance(points: Vec2[], targetDist: number): Vec2 { 593 + let accum = 0; 594 + for (let i = 1; i < points.length; i++) { 595 + const dx = points[i].x - points[i - 1].x; 596 + const dy = points[i].y - points[i - 1].y; 597 + const segLen = Math.sqrt(dx * dx + dy * dy); 598 + if (accum + segLen >= targetDist) { 599 + const t = (targetDist - accum) / segLen; 600 + return { x: points[i - 1].x + dx * t, y: points[i - 1].y + dy * t }; 601 + } 602 + accum += segLen; 603 + } 604 + return points[points.length - 1]; 605 + }
+14 -2
packages/core/src/reactivity.ts
··· 14 14 15 15 export type ToolId = "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen"; 16 16 17 - export type UIState = { currentPageId: string | null; selectionIds: string[]; toolId: ToolId }; 17 + export type BindingPreview = { arrowId: string; targetShapeId: string; handle: "start" | "end" }; 18 + 19 + export type UIState = { 20 + currentPageId: string | null; 21 + selectionIds: string[]; 22 + toolId: ToolId; 23 + bindingPreview?: BindingPreview; 24 + }; 18 25 19 26 export type EditorState = { doc: Document; ui: UIState; camera: Camera }; 20 27 ··· 36 43 clone(state: EditorState): EditorState { 37 44 return { 38 45 doc: DocumentOps.clone(state.doc), 39 - ui: { currentPageId: state.ui.currentPageId, selectionIds: [...state.ui.selectionIds], toolId: state.ui.toolId }, 46 + ui: { 47 + currentPageId: state.ui.currentPageId, 48 + selectionIds: [...state.ui.selectionIds], 49 + toolId: state.ui.toolId, 50 + bindingPreview: state.ui.bindingPreview ? { ...state.ui.bindingPreview } : undefined, 51 + }, 40 52 camera: CameraOps.clone(state.camera), 41 53 }; 42 54 },
+115 -3
packages/core/src/tools/select.ts
··· 1 1 import type { Action } from "../actions"; 2 - import { computeNormalizedAnchor, hitTestPoint, shapeBounds } from "../geom"; 2 + import { computeNormalizedAnchor, computePolylineLength, getPointAtDistance, hitTestPoint, shapeBounds } from "../geom"; 3 3 import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; 4 4 import { BindingRecord, ShapeRecord } from "../model"; 5 5 import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; ··· 35 35 36 36 type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; 37 37 38 - type HandleKind = RectHandle | "rotate" | "line-start" | "line-end" | `arrow-point-${number}`; 38 + type HandleKind = RectHandle | "rotate" | "line-start" | "line-end" | `arrow-point-${number}` | "arrow-label"; 39 39 40 40 const HANDLE_HIT_RADIUS = 10; 41 41 const ROTATE_HANDLE_OFFSET = 40; ··· 252 252 let updated: ShapeRecord | null = null; 253 253 if (this.toolState.activeHandle === "rotate") { 254 254 updated = this.rotateShape(initialShape, action.world); 255 + } else if (this.toolState.activeHandle === "arrow-label") { 256 + updated = this.adjustArrowLabel(initialShape, action.world); 255 257 } else if ( 256 258 this.toolState.activeHandle === "line-start" 257 259 || this.toolState.activeHandle === "line-end" ··· 271 273 return state; 272 274 } 273 275 274 - return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; 276 + let newState = { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; 277 + 278 + if ( 279 + currentShape.type === "arrow" 280 + && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") 281 + ) { 282 + const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; 283 + 284 + const stateWithoutArrow = { 285 + ...newState, 286 + doc: { 287 + ...newState.doc, 288 + shapes: Object.fromEntries(Object.entries(newState.doc.shapes).filter(([id]) => id !== shapeId)), 289 + }, 290 + }; 291 + 292 + const hitShapeId = hitTestPoint(stateWithoutArrow, action.world); 293 + 294 + if (hitShapeId) { 295 + newState = { 296 + ...newState, 297 + ui: { ...newState.ui, bindingPreview: { arrowId: shapeId, targetShapeId: hitShapeId, handle } }, 298 + }; 299 + } else { 300 + newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 301 + } 302 + } 303 + 304 + return newState; 275 305 } 276 306 277 307 /** ··· 337 367 this.toolState.marqueeStart = null; 338 368 this.toolState.marqueeEnd = null; 339 369 this.notifyMarqueeChange(); 370 + 371 + if (newState.ui.bindingPreview) { 372 + newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 373 + } 340 374 341 375 return newState; 342 376 } ··· 511 545 handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); 512 546 } 513 547 } 548 + 549 + if (shape.props.label) { 550 + const polylineLength = computePolylineLength(shape.props.points); 551 + const align = shape.props.label.align ?? "center"; 552 + const offset = shape.props.label.offset ?? 0; 553 + 554 + let distance: number; 555 + if (align === "center") { 556 + distance = polylineLength / 2 + offset; 557 + } else if (align === "start") { 558 + distance = offset; 559 + } else { 560 + distance = polylineLength - offset; 561 + } 562 + 563 + distance = Math.max(0, Math.min(distance, polylineLength)); 564 + const labelPos = getPointAtDistance(shape.props.points, distance); 565 + const worldLabelPos = this.localToWorld(shape, labelPos); 566 + handles.push({ id: "arrow-label", position: worldLabelPos }); 567 + } 514 568 } 515 569 } 516 570 return handles; ··· 583 637 return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 584 638 } 585 639 640 + private adjustArrowLabel(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { 641 + if (initial.type !== "arrow" || !initial.props.points || initial.props.points.length < 2 || !initial.props.label) { 642 + return null; 643 + } 644 + 645 + const localPointer = this.worldToLocal(initial, pointer); 646 + const points = initial.props.points; 647 + const polylineLength = computePolylineLength(points); 648 + 649 + let closestDistance = 0; 650 + let minDistToLine = Number.POSITIVE_INFINITY; 651 + 652 + for (let i = 0; i < points.length - 1; i++) { 653 + const a = points[i]; 654 + const b = points[i + 1]; 655 + const segmentLength = Vec2Ops.dist(a, b); 656 + 657 + const ab = Vec2Ops.sub(b, a); 658 + const ap = Vec2Ops.sub(localPointer, a); 659 + const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / Vec2Ops.dot(ab, ab))); 660 + const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); 661 + const distToLine = Vec2Ops.dist(localPointer, projection); 662 + 663 + if (distToLine < minDistToLine) { 664 + minDistToLine = distToLine; 665 + let distanceToSegmentStart = 0; 666 + for (let j = 0; j < i; j++) { 667 + distanceToSegmentStart += Vec2Ops.dist(points[j], points[j + 1]); 668 + } 669 + closestDistance = distanceToSegmentStart + t * segmentLength; 670 + } 671 + } 672 + 673 + const align = initial.props.label.align ?? "center"; 674 + let newOffset: number; 675 + 676 + if (align === "center") { 677 + newOffset = closestDistance - polylineLength / 2; 678 + } else if (align === "start") { 679 + newOffset = closestDistance; 680 + } else { 681 + newOffset = polylineLength - closestDistance; 682 + } 683 + 684 + return { ...initial, props: { ...initial.props, label: { ...initial.props.label, offset: newOffset } } }; 685 + } 686 + 586 687 private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: HandleKind): ShapeRecord | null { 587 688 if (initial.type !== "line" && initial.type !== "arrow") { 588 689 return null; ··· 673 774 const cos = Math.cos(shape.rot); 674 775 const sin = Math.sin(shape.rot); 675 776 return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 777 + } 778 + 779 + private worldToLocal(shape: ShapeRecord, point: Vec2): Vec2 { 780 + if (shape.rot === 0) { 781 + return { x: point.x - shape.x, y: point.y - shape.y }; 782 + } 783 + const dx = point.x - shape.x; 784 + const dy = point.y - shape.y; 785 + const cos = Math.cos(-shape.rot); 786 + const sin = Math.sin(-shape.rot); 787 + return { x: dx * cos - dy * sin, y: dx * sin + dy * cos }; 676 788 } 677 789 678 790 /**
+32 -2
packages/core/src/tools/shape.ts
··· 561 561 points: [{ x: 0, y: 0 }, { x: 0, y: 0 }], 562 562 start: { kind: "free" }, 563 563 end: { kind: "free" }, 564 - style: { stroke: "#495057", width: 2, headEnd: true }, 564 + style: { stroke: "#2563eb", width: 2, headEnd: true }, 565 565 routing: { kind: "straight" }, 566 566 }, shapeId); 567 567 ··· 593 593 const updatedPoints = [{ x: 0, y: 0 }, b]; 594 594 const updatedShape = { ...shape, props: { ...shape.props, points: updatedPoints } }; 595 595 596 - return { 596 + let newState = { 597 597 ...state, 598 598 doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.creatingShapeId]: updatedShape } }, 599 599 }; 600 + 601 + const stateWithoutArrow = { 602 + ...newState, 603 + doc: { 604 + ...newState.doc, 605 + shapes: Object.fromEntries( 606 + Object.entries(newState.doc.shapes).filter(([id]) => id !== this.toolState.creatingShapeId), 607 + ), 608 + }, 609 + }; 610 + 611 + const hitShapeId = hitTestPoint(stateWithoutArrow, action.world); 612 + 613 + if (hitShapeId) { 614 + newState = { 615 + ...newState, 616 + ui: { 617 + ...newState.ui, 618 + bindingPreview: { arrowId: this.toolState.creatingShapeId, targetShapeId: hitShapeId, handle: "end" }, 619 + }, 620 + }; 621 + } else { 622 + newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 623 + } 624 + 625 + return newState; 600 626 } 601 627 602 628 private handlePointerUp(state: EditorState, action: Action): EditorState { ··· 620 646 newState = this.cancelShapeCreation(state); 621 647 } else { 622 648 newState = this.createBindingsForArrow(state, this.toolState.creatingShapeId); 649 + } 650 + 651 + if (newState.ui.bindingPreview) { 652 + newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; 623 653 } 624 654 625 655 this.resetToolState();
+363
packages/core/tests/arrow-label-routing.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { computePolylineLength, getPointAtDistance } from "../src/geom"; 3 + import type { ArrowShape } from "../src/model"; 4 + 5 + describe("Arrow label placement under zoom/pan", () => { 6 + it("should maintain label position relative to arrow when zooming", () => { 7 + const arrow: ArrowShape = { 8 + id: "arrow1", 9 + pageId: "page1", 10 + type: "arrow", 11 + x: 100, 12 + y: 100, 13 + rot: 0, 14 + props: { 15 + points: [{ x: 0, y: 0 }, { x: 200, y: 0 }], 16 + start: { kind: "free" }, 17 + end: { kind: "free" }, 18 + style: { stroke: "#000", width: 2 }, 19 + routing: { kind: "straight" }, 20 + label: { text: "Test", align: "center", offset: 0 }, 21 + }, 22 + }; 23 + 24 + const polylineLength = computePolylineLength(arrow.props.points); 25 + const align = arrow.props.label!.align; 26 + const offset = arrow.props.label!.offset; 27 + 28 + let distance: number; 29 + if (align === "center") { 30 + distance = polylineLength / 2 + offset; 31 + } else if (align === "start") { 32 + distance = offset; 33 + } else { 34 + distance = polylineLength - offset; 35 + } 36 + 37 + const labelPos = getPointAtDistance(arrow.props.points, distance); 38 + 39 + expect(labelPos.x).toBe(100); 40 + expect(labelPos.y).toBe(0); 41 + 42 + expect(polylineLength).toBe(200); 43 + expect(distance).toBe(100); 44 + }); 45 + 46 + it("should correctly place label at start alignment", () => { 47 + const points = [{ x: 0, y: 0 }, { x: 200, y: 0 }]; 48 + 49 + const offset = 20; 50 + const distance = offset; 51 + 52 + const labelPos = getPointAtDistance(points, distance); 53 + 54 + expect(labelPos.x).toBe(20); 55 + expect(labelPos.y).toBe(0); 56 + }); 57 + 58 + it("should correctly place label at end alignment", () => { 59 + const points = [{ x: 0, y: 0 }, { x: 200, y: 0 }]; 60 + 61 + const polylineLength = computePolylineLength(points); 62 + const offset = 20; 63 + const distance = polylineLength - offset; 64 + 65 + const labelPos = getPointAtDistance(points, distance); 66 + 67 + expect(labelPos.x).toBe(180); 68 + expect(labelPos.y).toBe(0); 69 + }); 70 + 71 + it("should correctly place label with positive offset from center", () => { 72 + const points = [{ x: 0, y: 0 }, { x: 200, y: 0 }]; 73 + 74 + const polylineLength = computePolylineLength(points); 75 + const offset = 30; 76 + const distance = polylineLength / 2 + offset; 77 + 78 + const labelPos = getPointAtDistance(points, distance); 79 + 80 + expect(labelPos.x).toBe(130); 81 + expect(labelPos.y).toBe(0); 82 + }); 83 + 84 + it("should correctly place label with negative offset from center", () => { 85 + const points = [{ x: 0, y: 0 }, { x: 200, y: 0 }]; 86 + 87 + const polylineLength = computePolylineLength(points); 88 + const offset = -30; 89 + const distance = polylineLength / 2 + offset; 90 + 91 + const labelPos = getPointAtDistance(points, distance); 92 + 93 + expect(labelPos.x).toBe(70); 94 + expect(labelPos.y).toBe(0); 95 + }); 96 + 97 + it("should handle multi-segment polyline labels", () => { 98 + const points = [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }, { x: 200, y: 100 }]; 99 + 100 + const polylineLength = computePolylineLength(points); 101 + expect(polylineLength).toBe(300); 102 + 103 + const centerDistance = polylineLength / 2; 104 + const labelPos = getPointAtDistance(points, centerDistance); 105 + 106 + expect(labelPos.x).toBe(100); 107 + expect(labelPos.y).toBe(50); 108 + }); 109 + 110 + it("should clamp label position within polyline bounds", () => { 111 + const points = [{ x: 0, y: 0 }, { x: 100, y: 0 }]; 112 + 113 + const polylineLength = computePolylineLength(points); 114 + 115 + const tooFar = polylineLength + 50; 116 + const clampedDistance = Math.max(0, Math.min(tooFar, polylineLength)); 117 + 118 + expect(clampedDistance).toBe(polylineLength); 119 + 120 + const labelPos = getPointAtDistance(points, clampedDistance); 121 + expect(labelPos.x).toBe(100); 122 + expect(labelPos.y).toBe(0); 123 + }); 124 + }); 125 + 126 + describe("Arrow routing toggle", () => { 127 + it("should default to straight routing", () => { 128 + const arrow: ArrowShape = { 129 + id: "arrow1", 130 + type: "arrow", 131 + x: 0, 132 + y: 0, 133 + rot: 0, 134 + pageId: "page1", 135 + props: { 136 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 137 + start: { kind: "free" }, 138 + end: { kind: "free" }, 139 + style: { stroke: "#000", width: 2 }, 140 + }, 141 + }; 142 + 143 + const routing = arrow.props.routing?.kind ?? "straight"; 144 + expect(routing).toBe("straight"); 145 + }); 146 + 147 + it("should support orthogonal routing", () => { 148 + const arrow: ArrowShape = { 149 + id: "arrow1", 150 + type: "arrow", 151 + x: 0, 152 + y: 0, 153 + rot: 0, 154 + pageId: "page1", 155 + props: { 156 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 157 + start: { kind: "free" }, 158 + end: { kind: "free" }, 159 + style: { stroke: "#000", width: 2 }, 160 + routing: { kind: "orthogonal" }, 161 + }, 162 + }; 163 + 164 + expect(arrow.props.routing?.kind).toBe("orthogonal"); 165 + }); 166 + 167 + it("should toggle routing from straight to orthogonal", () => { 168 + let arrow: ArrowShape = { 169 + id: "arrow1", 170 + type: "arrow", 171 + x: 0, 172 + y: 0, 173 + rot: 0, 174 + pageId: "page1", 175 + props: { 176 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 177 + start: { kind: "free" }, 178 + end: { kind: "free" }, 179 + style: { stroke: "#000", width: 2 }, 180 + routing: { kind: "straight" }, 181 + }, 182 + }; 183 + 184 + expect(arrow.props.routing?.kind).toBe("straight"); 185 + 186 + arrow = { ...arrow, props: { ...arrow.props, routing: { kind: "orthogonal" } } }; 187 + 188 + expect(arrow.props.routing?.kind).toBe("orthogonal"); 189 + }); 190 + 191 + it("should toggle routing from orthogonal to straight", () => { 192 + let arrow: ArrowShape = { 193 + id: "arrow1", 194 + type: "arrow", 195 + x: 0, 196 + y: 0, 197 + pageId: "page1", 198 + rot: 0, 199 + props: { 200 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 201 + start: { kind: "free" }, 202 + end: { kind: "free" }, 203 + style: { stroke: "#000", width: 2 }, 204 + routing: { kind: "orthogonal" }, 205 + }, 206 + }; 207 + 208 + expect(arrow.props.routing?.kind).toBe("orthogonal"); 209 + 210 + arrow = { ...arrow, props: { ...arrow.props, routing: { kind: "straight" } } }; 211 + 212 + expect(arrow.props.routing?.kind).toBe("straight"); 213 + }); 214 + 215 + it("should preserve arrow points when toggling routing", () => { 216 + const initialPoints = [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 100 }]; 217 + 218 + let arrow: ArrowShape = { 219 + id: "arrow1", 220 + type: "arrow", 221 + x: 0, 222 + y: 0, 223 + rot: 0, 224 + pageId: "page1", 225 + props: { 226 + points: initialPoints, 227 + start: { kind: "free" }, 228 + end: { kind: "free" }, 229 + style: { stroke: "#000", width: 2 }, 230 + routing: { kind: "straight" }, 231 + }, 232 + }; 233 + 234 + arrow = { ...arrow, props: { ...arrow.props, routing: { kind: "orthogonal" } } }; 235 + 236 + expect(arrow.props.points).toEqual(initialPoints); 237 + }); 238 + 239 + it("should preserve label when toggling routing", () => { 240 + const label = { text: "Test Label", align: "center" as const, offset: 10 }; 241 + 242 + let arrow: ArrowShape = { 243 + id: "arrow1", 244 + type: "arrow", 245 + x: 0, 246 + y: 0, 247 + pageId: "page1", 248 + rot: 0, 249 + props: { 250 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 251 + start: { kind: "free" }, 252 + end: { kind: "free" }, 253 + style: { stroke: "#000", width: 2 }, 254 + routing: { kind: "straight" }, 255 + label, 256 + }, 257 + }; 258 + 259 + arrow = { ...arrow, props: { ...arrow.props, routing: { kind: "orthogonal" } } }; 260 + 261 + expect(arrow.props.label).toEqual(label); 262 + }); 263 + }); 264 + 265 + describe("Arrow label editing", () => { 266 + it("should add label to arrow", () => { 267 + let arrow: ArrowShape = { 268 + id: "arrow1", 269 + type: "arrow", 270 + x: 0, 271 + y: 0, 272 + rot: 0, 273 + pageId: "page1", 274 + props: { 275 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 276 + start: { kind: "free" }, 277 + end: { kind: "free" }, 278 + style: { stroke: "#000", width: 2 }, 279 + }, 280 + }; 281 + 282 + expect(arrow.props.label).toBeUndefined(); 283 + 284 + arrow = { ...arrow, props: { ...arrow.props, label: { text: "New Label", align: "center", offset: 0 } } }; 285 + 286 + expect(arrow.props.label?.text).toBe("New Label"); 287 + }); 288 + 289 + it("should remove label when text is empty", () => { 290 + let arrow: ArrowShape = { 291 + id: "arrow1", 292 + type: "arrow", 293 + x: 0, 294 + y: 0, 295 + rot: 0, 296 + pageId: "page1", 297 + props: { 298 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 299 + start: { kind: "free" }, 300 + end: { kind: "free" }, 301 + style: { stroke: "#000", width: 2 }, 302 + label: { text: "Test", align: "center", offset: 0 }, 303 + }, 304 + }; 305 + 306 + expect(arrow.props.label).toBeDefined(); 307 + 308 + arrow = { ...arrow, props: { ...arrow.props, label: undefined } }; 309 + 310 + expect(arrow.props.label).toBeUndefined(); 311 + }); 312 + 313 + it("should update label text", () => { 314 + let arrow: ArrowShape = { 315 + id: "arrow1", 316 + type: "arrow", 317 + x: 0, 318 + y: 0, 319 + rot: 0, 320 + pageId: "page1", 321 + props: { 322 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 323 + start: { kind: "free" }, 324 + end: { kind: "free" }, 325 + style: { stroke: "#000", width: 2 }, 326 + label: { text: "Old Text", align: "center", offset: 0 }, 327 + }, 328 + }; 329 + 330 + arrow = { ...arrow, props: { ...arrow.props, label: { text: "New Text", align: "center", offset: 0 } } }; 331 + 332 + expect(arrow.props.label?.text).toBe("New Text"); 333 + }); 334 + 335 + it("should adjust label offset when dragging", () => { 336 + let arrow: ArrowShape = { 337 + id: "arrow1", 338 + type: "arrow", 339 + x: 0, 340 + y: 0, 341 + rot: 0, 342 + pageId: "page1", 343 + props: { 344 + points: [{ x: 0, y: 0 }, { x: 200, y: 0 }], 345 + start: { kind: "free" }, 346 + end: { kind: "free" }, 347 + style: { stroke: "#000", width: 2 }, 348 + label: { text: "Test", align: "center", offset: 0 }, 349 + }, 350 + }; 351 + 352 + const newOffset = 30; 353 + arrow = { ...arrow, props: { ...arrow.props, label: { text: "Test", align: "center", offset: newOffset } } }; 354 + 355 + expect(arrow.props.label?.offset).toBe(30); 356 + 357 + const polylineLength = computePolylineLength(arrow.props.points); 358 + const distance = polylineLength / 2 + newOffset; 359 + const labelPos = getPointAtDistance(arrow.props.points, distance); 360 + 361 + expect(labelPos.x).toBe(130); 362 + }); 363 + });
+80 -118
packages/core/tests/arrow-multipoint.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { Action } from "../src/actions"; 3 - import { BindingRecord, ShapeRecord } from "../src/model"; 3 + import { BindingRecord, PageRecord, ShapeRecord } from "../src/model"; 4 4 import { EditorState } from "../src/reactivity"; 5 5 import { SelectTool } from "../src/tools/select"; 6 6 ··· 9 9 it("should allow dragging an intermediate point", () => { 10 10 let state = EditorState.create(); 11 11 12 - // Create a page and an arrow with 3 points (including an intermediate point) 13 - const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 12 + const page = PageRecord.create("Test Page"); 13 + state = { 14 + ...state, 15 + doc: { ...state.doc, pages: { [page.id]: page } }, 16 + ui: { ...state.ui, currentPageId: page.id }, 17 + }; 14 18 const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 15 - points: [ 16 - { x: 0, y: 0 }, 17 - { x: 50, y: 50 }, 18 - { x: 100, y: 0 }, 19 - ], 19 + points: [{ x: 0, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 0 }], 20 20 start: { kind: "free" }, 21 21 end: { kind: "free" }, 22 22 style: { stroke: "#000", width: 2, headEnd: true }, ··· 32 32 ui: { ...state.ui, selectionIds: [arrow.id] }, 33 33 }; 34 34 35 - store.setState(state); 36 - 37 35 const tool = new SelectTool(); 38 36 tool.onEnter(state); 39 37 40 - // Pointer down on the intermediate point (index 1) 41 - const intermediateWorldPos = { x: 150, y: 150 }; // arrow.x + points[1].x, arrow.y + points[1].y 38 + const intermediateWorldPos = { x: 150, y: 150 }; 42 39 const pointerDown = Action.pointerDown( 43 40 { x: 0, y: 0 }, 44 41 intermediateWorldPos, ··· 49 46 ); 50 47 state = tool.onAction(state, pointerDown); 51 48 52 - // Drag the point to a new location 53 49 const newWorldPos = { x: 180, y: 160 }; 54 - const pointerMove = Action.pointerMove( 55 - { x: 0, y: 0 }, 56 - newWorldPos, 57 - { left: true, middle: false, right: false }, 58 - { ctrl: false, shift: false, alt: false, meta: false }, 59 - 100, 60 - ); 50 + const pointerMove = Action.pointerMove({ x: 0, y: 0 }, newWorldPos, { left: true, middle: false, right: false }, { 51 + ctrl: false, 52 + shift: false, 53 + alt: false, 54 + meta: false, 55 + }, 100); 61 56 state = tool.onAction(state, pointerMove); 62 57 63 - const pointerUp = Action.pointerUp( 64 - { x: 0, y: 0 }, 65 - newWorldPos, 66 - 0, 67 - { left: false, middle: false, right: false }, 68 - { ctrl: false, shift: false, alt: false, meta: false }, 69 - 200, 70 - ); 58 + const pointerUp = Action.pointerUp({ x: 0, y: 0 }, newWorldPos, 0, { left: false, middle: false, right: false }, { 59 + ctrl: false, 60 + shift: false, 61 + alt: false, 62 + meta: false, 63 + }, 200); 71 64 state = tool.onAction(state, pointerUp); 72 65 73 66 const updatedArrow = state.doc.shapes[arrow.id]; 74 67 expect(updatedArrow.type).toBe("arrow"); 75 68 if (updatedArrow.type === "arrow") { 76 69 expect(updatedArrow.props.points.length).toBe(3); 77 - // The intermediate point should be updated 78 - expect(updatedArrow.props.points[1].x).toBe(80); // newWorldPos.x - arrow.x 79 - expect(updatedArrow.props.points[1].y).toBe(60); // newWorldPos.y - arrow.y 80 - // Start and end points should remain unchanged 70 + expect(updatedArrow.props.points[1].x).toBe(80); 71 + expect(updatedArrow.props.points[1].y).toBe(60); 81 72 expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 82 73 expect(updatedArrow.props.points[2]).toEqual({ x: 100, y: 0 }); 83 74 } 84 75 }); 85 76 86 77 it("should preserve bindings when dragging intermediate points", () => { 87 - const store = Store.create(); 88 - let state = store.getState(); 78 + let state = EditorState.create(); 89 79 90 - const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 80 + const page = PageRecord.create("Test Page"); 81 + state = { 82 + ...state, 83 + doc: { ...state.doc, pages: { [page.id]: page } }, 84 + ui: { ...state.ui, currentPageId: page.id }, 85 + }; 91 86 92 87 // Create a target shape 93 88 const targetRect = ShapeRecord.createRect(page.id, 300, 100, { ··· 98 93 radius: 0, 99 94 }); 100 95 101 - // Create an arrow with a binding 102 96 const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 103 - points: [ 104 - { x: 0, y: 0 }, 105 - { x: 100, y: 50 }, 106 - { x: 200, y: 0 }, 107 - ], 97 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }, { x: 200, y: 0 }], 108 98 start: { kind: "free" }, 109 99 end: { kind: "bound", bindingId: "binding-1" }, 110 100 style: { stroke: "#000", width: 2, headEnd: true }, ··· 118 108 ...state.doc, 119 109 shapes: { ...state.doc.shapes, [arrow.id]: arrow, [targetRect.id]: targetRect }, 120 110 bindings: { [binding.id]: binding }, 121 - pages: { 122 - ...state.doc.pages, 123 - [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id, targetRect.id] }, 124 - }, 111 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id, targetRect.id] } }, 125 112 }, 126 113 ui: { ...state.ui, selectionIds: [arrow.id] }, 127 114 }; 128 115 129 - store.setState(state); 130 - 131 116 const tool = new SelectTool(); 132 117 tool.onEnter(state); 133 118 134 - // Drag the intermediate point 135 119 const intermediateWorldPos = { x: 200, y: 150 }; 136 120 const pointerDown = Action.pointerDown( 137 121 { x: 0, y: 0 }, ··· 144 128 state = tool.onAction(state, pointerDown); 145 129 146 130 const newWorldPos = { x: 220, y: 180 }; 147 - const pointerMove = Action.pointerMove( 148 - { x: 0, y: 0 }, 149 - newWorldPos, 150 - { left: true, middle: false, right: false }, 151 - { ctrl: false, shift: false, alt: false, meta: false }, 152 - 100, 153 - ); 131 + const pointerMove = Action.pointerMove({ x: 0, y: 0 }, newWorldPos, { left: true, middle: false, right: false }, { 132 + ctrl: false, 133 + shift: false, 134 + alt: false, 135 + meta: false, 136 + }, 100); 154 137 state = tool.onAction(state, pointerMove); 155 138 156 - const pointerUp = Action.pointerUp( 157 - { x: 0, y: 0 }, 158 - newWorldPos, 159 - 0, 160 - { left: false, middle: false, right: false }, 161 - { ctrl: false, shift: false, alt: false, meta: false }, 162 - 200, 163 - ); 139 + const pointerUp = Action.pointerUp({ x: 0, y: 0 }, newWorldPos, 0, { left: false, middle: false, right: false }, { 140 + ctrl: false, 141 + shift: false, 142 + alt: false, 143 + meta: false, 144 + }, 200); 164 145 state = tool.onAction(state, pointerUp); 165 146 166 - // Binding should still exist 167 147 expect(state.doc.bindings[binding.id]).toBeDefined(); 168 148 expect(state.doc.bindings[binding.id].toShapeId).toBe(targetRect.id); 169 149 }); ··· 171 151 172 152 describe("Adding points with Alt+click", () => { 173 153 it("should add a point when Alt+clicking on a segment", () => { 174 - const store = Store.create(); 175 - let state = store.getState(); 154 + let state = EditorState.create(); 176 155 177 - const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 156 + const page = PageRecord.create("Test Page"); 157 + state = { 158 + ...state, 159 + doc: { ...state.doc, pages: { [page.id]: page } }, 160 + ui: { ...state.ui, currentPageId: page.id }, 161 + }; 178 162 const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 179 - points: [ 180 - { x: 0, y: 0 }, 181 - { x: 100, y: 0 }, 182 - ], 163 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 183 164 start: { kind: "free" }, 184 165 end: { kind: "free" }, 185 166 style: { stroke: "#000", width: 2, headEnd: true }, ··· 195 176 ui: { ...state.ui, selectionIds: [arrow.id] }, 196 177 }; 197 178 198 - store.setState(state); 199 - 200 179 const tool = new SelectTool(); 201 180 tool.onEnter(state); 202 181 203 - // Alt+click in the middle of the line 204 - const clickWorld = { x: 150, y: 100 }; // Midpoint of the line 182 + const clickWorld = { x: 150, y: 100 }; 205 183 const pointerDown = Action.pointerDown( 206 184 { x: 0, y: 0 }, 207 185 clickWorld, ··· 216 194 expect(updatedArrow.type).toBe("arrow"); 217 195 if (updatedArrow.type === "arrow") { 218 196 expect(updatedArrow.props.points.length).toBe(3); 219 - // New point should be inserted between the start and end 220 197 expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 221 198 expect(updatedArrow.props.points[1].x).toBeCloseTo(50, 0); 222 199 expect(updatedArrow.props.points[1].y).toBeCloseTo(0, 0); ··· 225 202 }); 226 203 227 204 it("should not add a point when Alt+clicking far from any segment", () => { 228 - const store = Store.create(); 229 - let state = store.getState(); 205 + let state = EditorState.create(); 230 206 231 - const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 207 + const page = PageRecord.create("Test Page"); 208 + state = { 209 + ...state, 210 + doc: { ...state.doc, pages: { [page.id]: page } }, 211 + ui: { ...state.ui, currentPageId: page.id }, 212 + }; 232 213 const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 233 - points: [ 234 - { x: 0, y: 0 }, 235 - { x: 100, y: 0 }, 236 - ], 214 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 237 215 start: { kind: "free" }, 238 216 end: { kind: "free" }, 239 217 style: { stroke: "#000", width: 2, headEnd: true }, ··· 249 227 ui: { ...state.ui, selectionIds: [arrow.id] }, 250 228 }; 251 229 252 - store.setState(state); 253 - 254 230 const tool = new SelectTool(); 255 231 tool.onEnter(state); 256 232 257 - // Alt+click far away from the line 258 - const clickWorld = { x: 150, y: 200 }; // Far from the horizontal line 233 + const clickWorld = { x: 150, y: 200 }; 259 234 const pointerDown = Action.pointerDown( 260 235 { x: 0, y: 0 }, 261 236 clickWorld, ··· 269 244 const updatedArrow = state.doc.shapes[arrow.id]; 270 245 expect(updatedArrow.type).toBe("arrow"); 271 246 if (updatedArrow.type === "arrow") { 272 - // Should still have 2 points (no point added) 273 247 expect(updatedArrow.props.points.length).toBe(2); 274 248 } 275 249 }); ··· 277 251 278 252 describe("Removing points with Delete/Backspace", () => { 279 253 it("should remove an intermediate point when Delete is pressed while dragging", () => { 280 - const store = Store.create(); 281 - let state = store.getState(); 254 + let state = EditorState.create(); 282 255 283 - const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 256 + const page = PageRecord.create("Test Page"); 257 + state = { 258 + ...state, 259 + doc: { ...state.doc, pages: { [page.id]: page } }, 260 + ui: { ...state.ui, currentPageId: page.id }, 261 + }; 284 262 const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 285 - points: [ 286 - { x: 0, y: 0 }, 287 - { x: 50, y: 50 }, 288 - { x: 100, y: 0 }, 289 - ], 263 + points: [{ x: 0, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 0 }], 290 264 start: { kind: "free" }, 291 265 end: { kind: "free" }, 292 266 style: { stroke: "#000", width: 2, headEnd: true }, ··· 301 275 }, 302 276 ui: { ...state.ui, selectionIds: [arrow.id] }, 303 277 }; 304 - 305 - store.setState(state); 306 278 307 279 const tool = new SelectTool(); 308 280 tool.onEnter(state); 309 281 310 - // Start dragging the intermediate point 311 282 const intermediateWorldPos = { x: 150, y: 150 }; 312 283 const pointerDown = Action.pointerDown( 313 284 { x: 0, y: 0 }, ··· 319 290 ); 320 291 state = tool.onAction(state, pointerDown); 321 292 322 - // Press Delete while dragging 323 - const keyDown = Action.keyDown("Delete", { ctrl: false, shift: false, alt: false, meta: false }, 100); 293 + const keyDown = Action.keyDown("Delete", "Delete", { ctrl: false, shift: false, alt: false, meta: false }); 324 294 state = tool.onAction(state, keyDown); 325 295 326 296 const updatedArrow = state.doc.shapes[arrow.id]; 327 297 expect(updatedArrow.type).toBe("arrow"); 328 298 if (updatedArrow.type === "arrow") { 329 - // Should now have 2 points (intermediate point removed) 330 299 expect(updatedArrow.props.points.length).toBe(2); 331 300 expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 332 301 expect(updatedArrow.props.points[1]).toEqual({ x: 100, y: 0 }); ··· 334 303 }); 335 304 336 305 it("should not remove points if it would leave less than 2 points", () => { 337 - const store = Store.create(); 338 - let state = store.getState(); 306 + let state = EditorState.create(); 339 307 340 - const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 308 + const page = PageRecord.create("Test Page"); 309 + state = { 310 + ...state, 311 + doc: { ...state.doc, pages: { [page.id]: page } }, 312 + ui: { ...state.ui, currentPageId: page.id }, 313 + }; 341 314 const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 342 - points: [ 343 - { x: 0, y: 0 }, 344 - { x: 50, y: 50 }, 345 - { x: 100, y: 0 }, 346 - ], 315 + points: [{ x: 0, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 0 }], 347 316 start: { kind: "free" }, 348 317 end: { kind: "free" }, 349 318 style: { stroke: "#000", width: 2, headEnd: true }, ··· 359 328 ui: { ...state.ui, selectionIds: [arrow.id] }, 360 329 }; 361 330 362 - store.setState(state); 363 - 364 331 const tool = new SelectTool(); 365 332 tool.onEnter(state); 366 333 367 - // Remove one intermediate point (should work) 368 334 const intermediateWorldPos = { x: 150, y: 150 }; 369 335 const pointerDown = Action.pointerDown( 370 336 { x: 0, y: 0 }, ··· 376 342 ); 377 343 state = tool.onAction(state, pointerDown); 378 344 379 - const keyDown = Action.keyDown("Delete", { ctrl: false, shift: false, alt: false, meta: false }, 100); 345 + const keyDown = Action.keyDown("Delete", "Delete", { ctrl: false, shift: false, alt: false, meta: false }); 380 346 state = tool.onAction(state, keyDown); 381 347 382 348 let updatedArrow = state.doc.shapes[arrow.id]; ··· 384 350 if (updatedArrow.type === "arrow") { 385 351 expect(updatedArrow.props.points.length).toBe(2); 386 352 } 387 - 388 - // Now we have only 2 points. Trying to remove any would be invalid. 389 - // (In the current implementation, you can only remove intermediate points, 390 - // and with only 2 points there are no intermediate points to remove) 391 353 }); 392 354 }); 393 355 });
+52 -28
packages/renderer/src/index.ts
··· 16 16 import { 17 17 computeOrthogonalPath, 18 18 computeOutline, 19 + computePolylineLength, 20 + getPointAtDistance, 19 21 getShapesOnCurrentPage, 20 22 resolveArrowEndpoints, 21 23 shapeBounds, ··· 182 184 183 185 drawSelection(context, state, shapes, handleState); 184 186 187 + drawBindingPreview(context, state); 188 + 185 189 drawSnapGuides(context, state.camera, viewport, snapSettings, cursorState, pointerState); 186 190 187 191 context.restore(); ··· 307 311 } 308 312 309 313 /** 314 + * Draw binding preview indicator when dragging arrow endpoints 315 + */ 316 + function drawBindingPreview(context: CanvasRenderingContext2D, state: EditorState) { 317 + if (!state.ui.bindingPreview) return; 318 + 319 + const targetShape = state.doc.shapes[state.ui.bindingPreview.targetShapeId]; 320 + if (!targetShape) return; 321 + 322 + const bounds = shapeBounds(targetShape); 323 + 324 + context.save(); 325 + context.strokeStyle = "rgba(59, 130, 246, 0.8)"; 326 + context.lineWidth = 3 / state.camera.zoom; 327 + context.setLineDash([8 / state.camera.zoom, 4 / state.camera.zoom]); 328 + 329 + const padding = 4; 330 + context.strokeRect( 331 + bounds.min.x - padding, 332 + bounds.min.y - padding, 333 + bounds.max.x - bounds.min.x + padding * 2, 334 + bounds.max.y - bounds.min.y + padding * 2, 335 + ); 336 + 337 + context.setLineDash([]); 338 + context.restore(); 339 + } 340 + 341 + /** 310 342 * Draw a single shape 311 343 */ 312 344 function drawShape(context: CanvasRenderingContext2D, state: EditorState, shape: ShapeRecord) { ··· 432 464 433 465 let points: Vec2[]; 434 466 435 - // Use orthogonal routing if specified 436 467 if (shape.props.routing?.kind === "orthogonal") { 437 468 points = computeOrthogonalPath(a, b); 438 469 } else { ··· 552 583 context.restore(); 553 584 } 554 585 555 - function computePolylineLength(points: Vec2[]): number { 556 - let length = 0; 557 - for (let i = 1; i < points.length; i++) { 558 - const dx = points[i].x - points[i - 1].x; 559 - const dy = points[i].y - points[i - 1].y; 560 - length += Math.sqrt(dx * dx + dy * dy); 561 - } 562 - return length; 563 - } 564 - 565 - function getPointAtDistance(points: Vec2[], targetDist: number): Vec2 { 566 - let accum = 0; 567 - for (let i = 1; i < points.length; i++) { 568 - const dx = points[i].x - points[i - 1].x; 569 - const dy = points[i].y - points[i - 1].y; 570 - const segLen = Math.sqrt(dx * dx + dy * dy); 571 - if (accum + segLen >= targetDist) { 572 - const t = (targetDist - accum) / segLen; 573 - return { x: points[i - 1].x + dx * t, y: points[i - 1].y + dy * t }; 574 - } 575 - accum += segLen; 576 - } 577 - return points[points.length - 1]; 578 - } 579 - 580 586 /** 581 587 * Draw a text shape 582 588 */ ··· 854 860 if (shape.type === "arrow") { 855 861 const resolved = resolveArrowEndpoints(state, shape.id); 856 862 if (resolved && shape.props.points && shape.props.points.length >= 2) { 857 - // Show handles for all points 858 863 handles.push({ id: "line-start", position: resolved.a }); 859 864 860 - // Add intermediate point handles 861 865 for (let i = 1; i < shape.props.points.length - 1; i++) { 862 866 const point = shape.props.points[i]; 863 867 const worldPos = localToWorld(shape, point); ··· 865 869 } 866 870 867 871 handles.push({ id: "line-end", position: resolved.b }); 872 + 873 + if (shape.props.label) { 874 + const polylineLength = computePolylineLength(shape.props.points); 875 + const align = shape.props.label.align ?? "center"; 876 + const offset = shape.props.label.offset ?? 0; 877 + 878 + let distance: number; 879 + if (align === "center") { 880 + distance = polylineLength / 2 + offset; 881 + } else if (align === "start") { 882 + distance = offset; 883 + } else { 884 + distance = polylineLength - offset; 885 + } 886 + 887 + distance = Math.max(0, Math.min(distance, polylineLength)); 888 + const labelPos = getPointAtDistance(shape.props.points, distance); 889 + const worldLabelPos = localToWorld(shape, labelPos); 890 + handles.push({ id: "arrow-label", position: worldLabelPos }); 891 + } 868 892 } 869 893 return handles; 870 894 }