web based infinite canvas

feat (wip): updated arrow ux

+981 -401
+252 -140
TODO.txt
··· 1 1 ================================================================================ 2 2 3 - Author intent: 4 3 - Build a Svelte-native editor core (TS) + renderer + UI. 5 4 - Keep the "engine" framework-agnostic so Web + Tauri share it. 6 5 - Defer collaboration until single-player is correct. ··· 11 10 - Files shown are ideas, not requirements 12 11 13 12 ================================================================================ 14 - 1. Milestone A: Repo skeleton + dev loop *wb-A* 13 + Milestone P: Performance *wb-P* 15 14 ================================================================================ 16 15 17 - Created a project monorepo/workspace. 16 + Goal: the editor stays responsive with many shapes. 18 17 19 - ================================================================================ 20 - 2. Milestone B: Math + coordinate systems *wb-B* 21 - ================================================================================ 18 + [ ] Add spatial index: 19 + - rebuild index on doc changes 20 + - query nearby shapes for hit testing 21 + [ ] Add view culling: 22 + - compute viewport bounds in world space 23 + - render only shapes whose bounds intersect viewport 24 + [ ] Reduce redraw frequency: 25 + - rAF only while dirty 26 + - optionally batch multiple store updates into one redraw 27 + [ ] Add microbench harness: 28 + - generate 10k shapes doc 29 + - measure hit test and render time 22 30 23 - Camera math, matrix utilities, and transforms are fully implemented and verified 24 - so world and screen coordinates map precisely. 31 + (DoD): 32 + - 10k simple shapes pans/zooms smoothly on a typical machine. 25 33 26 34 ================================================================================ 27 - 3. Milestone C: Document model (records) *wb-C* 35 + Milestone A: Richer arrows / connectors *wb-A* 28 36 ================================================================================ 29 37 30 - Document/page/shape/binding records plus validation let the editor serialize and 31 - reason about drawings safely. 38 + -------------------------------------------------------------------------------- 39 + A2. Editing UX 40 + -------------------------------------------------------------------------------- 32 41 33 - ================================================================================ 34 - 4. Milestone D: Store + selectors (reactive core) *wb-D* 35 - ================================================================================ 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) 36 52 37 - The reactive store, invariants, and selectors supply deterministic state streams 38 - for both renderer and UI subscribers. 53 + -------------------------------------------------------------------------------- 54 + A3. Precise anchors + snapping 55 + -------------------------------------------------------------------------------- 39 56 40 - ================================================================================ 41 - 5. Milestone E: Canvas renderer (read-only) *wb-E* 42 - ================================================================================ 57 + [ ] Anchor preview: 58 + - show snap indicator when binding will occur 59 + -------------------------------------------------------------------------------- 60 + A5. Tests 61 + -------------------------------------------------------------------------------- 43 62 44 - The renderer now draws the document via Canvas2D with camera transforms, 45 - DPI scaling, text sizing, and selection outlines. 63 + [ ] Polyline edits preserve endpoints and do not corrupt bindings 64 + [ ] Label placement stable under zoom/pan 46 65 47 66 ================================================================================ 48 - 6. Milestone F: Hit testing (picking) *wb-F* 67 + Milestone M: Markdown Blocks *wb-M* 49 68 ================================================================================ 50 69 51 - Geometry helpers compute bounds and intersections so hit testing can always 52 - return the topmost shape under the cursor. 70 + Goal: 71 + Add a "Markdown block" shape with pleasant editing, predictable layout, and 72 + export. Treat it as a doc-first primitive (not a hacky text element). 53 73 54 - ================================================================================ 55 - 7. Milestone G: Input system (pointer + keyboard) *wb-G* 56 - ================================================================================ 74 + -------------------------------------------------------------------------------- 75 + M1. Data model 76 + -------------------------------------------------------------------------------- 57 77 58 - Pointer and keyboard adapters now normalize events, map them into actions, and 59 - feed the editor consistently across platforms. 78 + /packages/core/src/model: 79 + [ ] Add ShapeType: 'markdown' 80 + [ ] MarkdownShape props: 81 + - md: string 82 + - w: number, h?: number " fixed width, auto height by layout 83 + - style: { fontFamily, fontSize, color, bg?, border? } 84 + - mode?: 'view'|'edit' " not persisted; UI-only 60 85 61 - ================================================================================ 62 - 8. Milestone H: Tool state machine (foundation) *wb-H* 63 - ================================================================================ 86 + (DoD): Markdown blocks save/load; width preserved; content preserved verbatim. 64 87 65 - Tool interfaces and the router manage lifecycle hooks so each tool is an 66 - explicit, testable state machine. 88 + -------------------------------------------------------------------------------- 89 + M2. Rendering 90 + -------------------------------------------------------------------------------- 67 91 92 + /packages/renderer-canvas2d: 93 + [ ] Render Markdown in canvas using a minimal subset: 94 + - headings (#, ##) 95 + - bold/italic/code 96 + - bullet lists 97 + - links (render style only; click later) 98 + Strategy: 99 + [ ] Parse md -> tokens -> lines; draw text runs onto canvas 100 + [ ] Measure to compute auto height; cache layout per (md, w, style) 68 101 69 - ================================================================================ 70 - 9. Milestone I: Select/move tool (MVP interaction) *wb-I* 71 - ================================================================================ 102 + (DoD): Markdown blocks look consistent and don’t reflow unpredictably during 103 + pan/zoom. 72 104 73 - Selection logic handles hit selection, marquee, dragging, deletion, and escape 74 - so shapes can be moved reliably. 105 + -------------------------------------------------------------------------------- 106 + M3. Editing UX 107 + -------------------------------------------------------------------------------- 75 108 76 - ================================================================================ 77 - 10. Milestone J: Create basic shapes via tools *wb-J* 78 - ================================================================================ 109 + /apps/web: 110 + [ ] Double-click Markdown block opens an overlay editor (contenteditable) 111 + [ ] Cmd/Ctrl+Enter toggles edit/view 112 + [ ] Tab inserts spaces (not focus change) when editing 79 113 80 - Rect, ellipse, line, arrow, and text tools now create shapes via click-drag 81 - interactions with proper finalize/cancel behavior. 114 + (DoD): Editing feels fast; no accidental tool switching; commit is one history 115 + step. 82 116 83 - ================================================================================ 84 - 11. Milestone K: Bindings for arrows (v0) *wb-K* 85 - ================================================================================ 117 + -------------------------------------------------------------------------------- 118 + M4. Selection + resize 119 + -------------------------------------------------------------------------------- 86 120 87 - Arrow endpoints bind to target shapes and stay attached by recalculating anchors 88 - whenever shapes move. 121 + [ ] Resizing adjusts width; height recomputed from layout 122 + [ ] Hit-testing uses computed bounds 89 123 90 - ================================================================================ 91 - 12. Milestone L: History (undo/redo) *wb-L* 92 - ================================================================================ 124 + (DoD): Markdown blocks behave like shapes: move/resize/duplicate/undo. 93 125 94 - All document-affecting actions run through undoable commands with history stacks 95 - and keyboard shortcuts. 126 + -------------------------------------------------------------------------------- 127 + M5. Export 128 + -------------------------------------------------------------------------------- 96 129 97 - ================================================================================ 98 - 13. Milestone M: Persistence (web) via Dexie + History integration *wb-M* 99 - ================================================================================ 130 + [ ] SVG export: 131 + - v0: export as <foreignObject> OR render as text lines 132 + [ ] PNG export: already covered by canvas export path 100 133 101 - Document changes now persist to IndexedDB via Dexie with migrations, repo API, 102 - and history-driven syncing. 134 + (DoD): Export doesn’t lose the Markdown block content. 103 135 104 - ================================================================================ 105 - 14. Milestone N: Status Bar (Editor HUD) *wb-N* 106 - ================================================================================ 136 + -------------------------------------------------------------------------------- 137 + M6. Tests 138 + -------------------------------------------------------------------------------- 107 139 108 - The HUD is now powered end-to-end via a `StatusBarVM` + cursor store, a web 109 - persistence/snap manager, and `StatusBar.svelte` with snap/grid toggles backed 110 - by unit/integration tests for selectors, cursor throttling, persistence 111 - transitions, and Canvas wiring. 140 + [ ] Layout cache keying (same md/w/style => stable height) 141 + [ ] Resize changes width and increases/decreases computed height appropriately 142 + [ ] Undo/redo persists through refresh (ties into M persistence) 112 143 113 - Note: Zoom controls were moved to Toolbar in Milestone O for better UX. 144 + (DoD): Markdown blocks are robust and predictable. 114 145 115 146 ================================================================================ 116 - 15. Milestone O: Export (PNG/SVG) *wb-O* 147 + Milestone L: Layers *wb-L* 117 148 ================================================================================ 118 149 119 - PNG/SVG export flows now deliver one-click viewport or selection exports from 120 - the Toolbar across web and desktop builds. 150 + Goal: 151 + Add real layers: reorder, hide/show, lock, and per-layer opacity. This is now a 152 + baseline expectation even for "simple" editors. 121 153 122 - ================================================================================ 123 - 16. Milestone P: Desktop packaging (Tauri) *wb-P* 124 - ================================================================================ 154 + -------------------------------------------------------------------------------- 155 + L1. Data model (doc) 156 + -------------------------------------------------------------------------------- 125 157 126 - The Tauri build now ships the static SvelteKit bundle with native file dialogs 127 - for open/save/new/rename/delete workflows so the desktop app works end-to-end 128 - offline. 158 + /packages/core/src/model: 159 + [ ] Add LayerRecord: 160 + - id, boardId/pageId 161 + - name 162 + - order: number (or layerIds array on page) 163 + - visible: boolean 164 + - locked: boolean 165 + - opacity: number (0..1) 129 166 130 - ================================================================================ 131 - 17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* 132 - ================================================================================ 167 + [ ] Attach shapes to layers: 168 + - ShapeRecord.layerId: string 169 + - Default layer created on new board/page 133 170 134 - Goal: the editor stays responsive with many shapes. 171 + (DoD): Old docs migrate to "single default layer" automatically 172 + (Dexie migration). 173 + 174 + -------------------------------------------------------------------------------- 175 + L2. Rendering order + behavior 176 + -------------------------------------------------------------------------------- 177 + 178 + /packages/renderer-canvas2d: 179 + [ ] Render layers in order: 180 + - skip invisible 181 + - apply opacity per layer (ctx.globalAlpha) 182 + [ ] Selection rendering respects visibility: 183 + - do not show selection UI for hidden shapes 135 184 136 - [ ] Add spatial index (v0: simple grid buckets): 137 - - rebuild index on doc changes 138 - - query nearby shapes for hit testing 139 - [ ] Add view culling: 140 - - compute viewport bounds in world space 141 - - render only shapes whose bounds intersect viewport 142 - [ ] Reduce redraw frequency: 143 - - rAF only while dirty 144 - - optionally batch multiple store updates into one redraw 145 - [ ] Add microbench harness: 146 - - generate 10k shapes doc 147 - - measure hit test and render time 185 + (DoD): Hiding a layer truly removes it from view and interaction. 148 186 149 - (DoD): 150 - - 10k simple shapes pans/zooms smoothly on a typical machine. 187 + -------------------------------------------------------------------------------- 188 + L3. Interaction rules 189 + -------------------------------------------------------------------------------- 151 190 152 - ================================================================================ 153 - 18. Milestone R: File Browser (web: Dexie inspector, desktop: FS) *wb-R* 154 - ================================================================================ 191 + [ ] Locked layer: 192 + - shapes cannot be selected or edited 193 + - marquee ignores locked shapes 194 + [ ] Active layer: 195 + - new shapes are created into the active layer 196 + [ ] Reorder layers: 197 + - drag to reorder, updates draw order 155 198 156 - The shared file browser now offers Dexie-backed search + inspector tooling on 157 - web and full workspace navigation on desktop, all driven by the view model 158 - contracts. 199 + (DoD): Lock/hide behave exactly as users expect. 159 200 160 201 -------------------------------------------------------------------------------- 161 - R4. Parity behaviors 202 + L4. UI panel 162 203 -------------------------------------------------------------------------------- 163 204 164 - [x] Same shortcuts: 165 - - Ctrl/Cmd+O opens file browser 166 - - Ctrl/Cmd+N creates board 167 - [x] Consistent metadata display: 168 - - name + updatedAt in both modes 205 + /apps/web: 206 + [ ] Layers panel: 207 + - list layers with eye + lock toggles 208 + - rename layer 209 + - new/delete layer 210 + - drag reorder 211 + - set active layer 212 + - opacity slider per layer (optional v0, recommended v1) 213 + 214 + (DoD): Layers are discoverable and usable without shortcuts. 215 + 216 + -------------------------------------------------------------------------------- 217 + L5. Persistence + migrations 218 + -------------------------------------------------------------------------------- 169 219 170 - (DoD): 171 - - Web and desktop feel like the same app, with storage differences made explicit 220 + [ ] Dexie migration: 221 + - add layers table and layerId field on shapes (if normalized) 222 + - backfill existing shapes -> default layer 223 + - ensure boards/pages have at least 1 layer 224 + 225 + (DoD): Existing boards load unchanged but now sit on a default layer. 226 + 227 + -------------------------------------------------------------------------------- 228 + L6. Tests 229 + -------------------------------------------------------------------------------- 172 230 173 - ================================================================================ 174 - 19. Milestone S: Quality polish. *wb-S* 175 - ================================================================================ 231 + [ ] Hidden layer shapes not hit-testable 232 + [ ] Locked layer shapes not editable 233 + [ ] New shape inherits active layer 234 + [ ] Migration backfills correctly 176 235 177 - Comprehensive UX polish adds BEM CSS, space-drag panning, richer keyboard 178 - affordances, improved accessibility and styling, refined snapping, and handles. 236 + (DoD): No regressions in selection/marquee/editing across layers. 179 237 180 238 ================================================================================ 181 - 20. Milestone T: Sketching / Pen Tool (perfect-freehand) *wb-T* 239 + Milestone S: Stencils (built-in) *wb-S* 182 240 ================================================================================ 183 241 184 - Perfect-freehand pen strokes now behave like first-class shapes with frame 185 - coalesced drafting, geometry/rendering integration, and brush controls. 242 + Goal: 243 + Ship a curated set of built-in stencils (flowchart + UI + dev diagrams) with a 244 + pleasant insertion workflow. No sharing/community libraries yet—just "your own". 245 + 246 + -------------------------------------------------------------------------------- 247 + S1. Stencil definition format (core) 248 + -------------------------------------------------------------------------------- 249 + 250 + /packages/core/src/stencils: 251 + [ ] Define Stencil: 252 + - id, name, category, tags[] 253 + - preview: { kind: 'svg'|'canvas', data } 254 + - spawn: function (atPoint, scale) -> ShapeRecords[] (group) 255 + (A stencil can insert 1 shape or a grouped set.) 256 + 257 + [ ] Create initial categories (v0): 258 + - Flowchart: process, decision, terminator, data, document 259 + - Diagrams: server, db, queue, user, browser, mobile 260 + - UI: button, input, card, modal 261 + 262 + (DoD): Stencils load as data and can spawn shapes deterministically. 263 + 264 + -------------------------------------------------------------------------------- 265 + S2. Insert UX 266 + -------------------------------------------------------------------------------- 267 + 268 + /apps/web: 269 + [ ] Stencils drawer/palette: 270 + - search (name + tags) 271 + - category filter 272 + - click inserts at viewport center OR 273 + drag ghost preview onto canvas and drop 274 + 275 + [ ] Placement rules: 276 + - insert into active layer (if layers exist) 277 + - snap to grid if enabled 278 + 279 + (DoD): Inserting stencils is faster than drawing shapes manually. 280 + 281 + -------------------------------------------------------------------------------- 282 + S3. Grouping behavior 283 + -------------------------------------------------------------------------------- 284 + 285 + [ ] When a stencil spawns multiple shapes: 286 + - create a GroupRecord OR a "groupId" on shapes (your existing grouping 287 + model) 288 + - allow move as one unit 289 + - ungroup command 290 + 291 + (DoD): Multi-shape stencils behave like a single object until ungrouped. 292 + 293 + -------------------------------------------------------------------------------- 294 + S4. Preview rendering 295 + -------------------------------------------------------------------------------- 296 + 297 + [ ] Render stencil previews in the panel: 298 + - v0: small SVG thumbnails (best) OR draw to offscreen canvas 299 + 300 + (DoD): Users can recognize stencils instantly. 301 + 302 + -------------------------------------------------------------------------------- 303 + S5. Persistence + versioning 304 + -------------------------------------------------------------------------------- 305 + 306 + [ ] Stencils are "code assets": 307 + - version them with the app 308 + - inserted shapes are normal shapes (no dependency on stencil after 309 + insertion) 310 + 311 + (DoD): Old docs do not break if you change stencil definitions later. 312 + 313 + -------------------------------------------------------------------------------- 314 + S6. Tests 315 + -------------------------------------------------------------------------------- 316 + 317 + [ ] spawn() returns valid records with unique ids and correct initial positions 318 + [ ] group insert produces expected selection and undo/redo works 319 + [ ] search indexing returns correct stencils for tag queries 320 + 321 + (DoD): Stencils are reliable and don’t corrupt docs. 186 322 187 323 ================================================================================ 188 324 Parking Lot *wb-pl* ··· 191 327 - [ ] Opacity for shapes 192 328 - expose fill/stroke opacity controls so translucent layering is possible 193 329 without exporting. 194 - - [ ] Snapping/binding for arrows 195 - - extend the binding system to keep arrow endpoints magnetized to shapes 196 - even when snapping/grid options are enabled. 197 - 198 - ================================================================================ 199 - References (URLs) *wb-refs* 200 - ================================================================================ 201 - 202 - tldraw conceptual references (inspiration only): 203 - - https://tldraw.dev/docs/shapes 204 - - https://tldraw.dev/docs/editor 205 - - https://tldraw.dev/reference/editor/Editor 206 - 207 - SvelteKit + Tauri packaging: 208 - - https://v2.tauri.app/start/frontend/sveltekit/ 209 - - https://svelte.dev/docs/kit/adapter-static 210 - - https://tauri.app/v1/guides/getting-started/setup/sveltekit/ 211 - 212 - Canvas/infinite-canvas performance ideas: 213 - - https://antv.vision/infinite-canvas-tutorial/guide/lesson-008 214 - - https://harrisonmilbradt.com/blog/canvas-panning-and-zooming 215 - 216 - Perfect Freehand 217 - - https://github.com/steveruizok/perfect-freehand
+7 -2
apps/web/src/lib/components/Toolbar.svelte
··· 92 92 } 93 93 } 94 94 if (strokable.length > 0) { 95 - const shared = getSharedColor(strokable, (shape) => shape.props.stroke ?? null); 95 + const shared = getSharedColor(strokable, (shape) => 96 + shape.type === 'arrow' ? shape.props.style.stroke : (shape.props.stroke ?? null) 97 + ); 96 98 if (shared) { 97 99 strokeColorValue = shared; 98 100 } ··· 356 358 break; 357 359 } 358 360 case 'arrow': { 359 - const updated: ArrowShape = { ...shape, props: { ...shape.props, stroke: color } }; 361 + const updated: ArrowShape = { 362 + ...shape, 363 + props: { ...shape.props, style: { ...shape.props.style, stroke: color } } 364 + }; 360 365 newShapes[shape.id] = updated; 361 366 break; 362 367 }
+7 -13
packages/core/src/export.ts
··· 204 204 } 205 205 206 206 function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string { 207 - let startPoint, endPoint, strokeColor, strokeWidth; 208 - 209 - if (shape.props.a && shape.props.b) { 210 - startPoint = shape.props.a; 211 - endPoint = shape.props.b; 212 - strokeColor = shape.props.stroke || "#000"; 213 - strokeWidth = shape.props.width || 2; 214 - } else if (shape.props.points && shape.props.points.length >= 2) { 215 - startPoint = shape.props.points[0]; 216 - endPoint = shape.props.points[shape.props.points.length - 1]; 217 - strokeColor = shape.props.style?.stroke || "#000"; 218 - strokeWidth = shape.props.style?.width || 2; 219 - } else { 207 + const points = shape.props.points; 208 + if (!points || points.length < 2) { 220 209 return `<g transform="${transform}"></g>`; 221 210 } 211 + 212 + const startPoint = points[0]; 213 + const endPoint = points[points.length - 1]; 214 + const strokeColor = shape.props.style.stroke; 215 + const strokeWidth = shape.props.style.width; 222 216 223 217 const angle = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x); 224 218 const arrowLength = 15;
+21 -29
packages/core/src/geom.ts
··· 102 102 103 103 function arrowBounds(shape: ArrowShape): Box2 { 104 104 const { x, y, rot } = shape; 105 + const points = shape.props.points; 105 106 106 - let points: Vec2[]; 107 - if (shape.props.a && shape.props.b) { 108 - points = [shape.props.a, shape.props.b]; 109 - } else if (shape.props.points && shape.props.points.length >= 2) { 110 - points = shape.props.points; 111 - } else { 107 + if (!points || points.length < 2) { 112 108 return { min: { x, y }, max: { x, y } }; 113 109 } 114 110 ··· 290 286 export function pointNearLine(p: Vec2, shape: LineShape | ArrowShape, tolerance = 5): boolean { 291 287 const { x, y, rot } = shape; 292 288 293 - let a: Vec2, b: Vec2; 289 + let points: Vec2[]; 294 290 if (shape.type === "line") { 295 - a = shape.props.a; 296 - b = shape.props.b; 291 + points = [shape.props.a, shape.props.b]; 297 292 } else { 298 - if (shape.props.a && shape.props.b) { 299 - a = shape.props.a; 300 - b = shape.props.b; 301 - } else if (shape.props.points && shape.props.points.length >= 2) { 302 - a = shape.props.points[0]; 303 - b = shape.props.points[shape.props.points.length - 1]; 304 - } else { 293 + if (!shape.props.points || shape.props.points.length < 2) { 305 294 return false; 306 295 } 296 + points = shape.props.points; 307 297 } 308 298 309 299 const localP = worldToLocal(p, x, y, rot); 310 - return pointNearSegment(localP, a, b, tolerance); 300 + 301 + for (let i = 0; i < points.length - 1; i++) { 302 + if (pointNearSegment(localP, points[i], points[i + 1], tolerance)) { 303 + return true; 304 + } 305 + } 306 + 307 + return false; 311 308 } 312 309 313 310 /** ··· 514 511 const arrow = state.doc.shapes[arrowId]; 515 512 if (!arrow || arrow.type !== "arrow") return null; 516 513 517 - let a: Vec2, b: Vec2; 518 - if (arrow.props.a && arrow.props.b) { 519 - a = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; 520 - b = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; 521 - } else if (arrow.props.points && arrow.props.points.length >= 2) { 522 - const firstPoint = arrow.props.points[0]; 523 - const lastPoint = arrow.props.points[arrow.props.points.length - 1]; 524 - a = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; 525 - b = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; 526 - } else { 527 - return null; 528 - } 514 + const points = arrow.props.points; 515 + if (!points || points.length < 2) return null; 516 + 517 + const firstPoint = points[0]; 518 + const lastPoint = points[points.length - 1]; 519 + let a: Vec2 = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; 520 + let b: Vec2 = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; 529 521 530 522 for (const binding of Object.values(state.doc.bindings)) { 531 523 if (binding.fromShapeId !== arrowId) continue;
+22 -50
packages/core/src/model.ts
··· 53 53 export type ArrowLabel = { text: string; align: "center" | "start" | "end"; offset: number }; 54 54 55 55 /** 56 - * Arrow properties supporting both legacy (a, b) and modern (points) formats 57 - * Legacy format: { a, b, stroke, width } 56 + * Arrow properties using modern format 58 57 * Modern format: { points, start, end, style, routing?, label? } 59 58 */ 60 59 export type ArrowProps = { 61 - // TODO: do away with legacy format (for backward compatibility 62 - a?: Vec2; 63 - b?: Vec2; 64 - stroke?: string; 65 - width?: number; 66 - 67 - points?: Vec2[]; 68 - start?: ArrowEndpoint; 69 - end?: ArrowEndpoint; 70 - style?: ArrowStyle; 60 + points: Vec2[]; 61 + start: ArrowEndpoint; 62 + end: ArrowEndpoint; 63 + style: ArrowStyle; 71 64 routing?: ArrowRouting; 72 65 label?: ArrowLabel; 73 66 }; ··· 178 171 return { 179 172 ...shape, 180 173 props: { 181 - ...shape.props, 182 - 183 - a: shape.props.a ? { ...shape.props.a } : undefined, 184 - b: shape.props.b ? { ...shape.props.b } : undefined, 185 - 186 - points: shape.props.points ? shape.props.points.map((p) => ({ ...p })) : undefined, 187 - start: shape.props.start ? { ...shape.props.start } : undefined, 188 - end: shape.props.end ? { ...shape.props.end } : undefined, 189 - style: shape.props.style 190 - ? { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined } 191 - : undefined, 174 + points: shape.props.points.map((p) => ({ ...p })), 175 + start: { ...shape.props.start }, 176 + end: { ...shape.props.end }, 177 + style: { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined }, 192 178 routing: shape.props.routing ? { ...shape.props.routing } : undefined, 193 179 label: shape.props.label ? { ...shape.props.label } : undefined, 194 180 }, ··· 319 305 } 320 306 case "arrow": { 321 307 const props = shape.props; 322 - const isLegacy = props.a !== undefined && props.b !== undefined; 323 - const isModern = props.points !== undefined; 324 308 325 - if (!isLegacy && !isModern) { 326 - errors.push(`Arrow shape '${shapeId}' missing both legacy (a, b) and modern (points) format`); 309 + if (!props.points || props.points.length < 2) { 310 + errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); 327 311 } 328 - 329 - if (isLegacy) { 330 - if (props.width !== undefined && props.width < 0) { 331 - errors.push(`Arrow shape '${shapeId}' has negative width in legacy format`); 332 - } 312 + if (!props.style) { 313 + errors.push(`Arrow shape '${shapeId}' missing style`); 314 + } else if (props.style.width < 0) { 315 + errors.push(`Arrow shape '${shapeId}' has negative width in style`); 333 316 } 334 - 335 - if (isModern) { 336 - if (!props.points || props.points.length < 2) { 337 - errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); 338 - } 339 - if (props.style) { 340 - if (props.style.width < 0) { 341 - errors.push(`Arrow shape '${shapeId}' has negative width in style`); 342 - } 343 - } 344 - if (props.routing) { 345 - if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { 346 - errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); 347 - } 317 + if (props.routing) { 318 + if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { 319 + errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); 348 320 } 349 - if (props.label) { 350 - if (!["center", "start", "end"].includes(props.label.align)) { 351 - errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); 352 - } 321 + } 322 + if (props.label) { 323 + if (!["center", "start", "end"].includes(props.label.align)) { 324 + errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); 353 325 } 354 326 } 355 327
+1 -1
packages/core/src/tools.test.ts
··· 916 916 const shape = result.doc.shapes[shapeId]; 917 917 918 918 expect(shape.type).toBe("arrow"); 919 - expect((shape.props as ArrowProps).b).toEqual({ x: 200, y: 100 }); 919 + expect((shape.props as ArrowProps).points[1]).toEqual({ x: 200, y: 100 }); 920 920 }); 921 921 922 922 it("should remove arrow if too short on pointer up", () => {
+140 -26
packages/core/src/tools/select.ts
··· 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"; 38 + type HandleKind = RectHandle | "rotate" | "line-start" | "line-end" | `arrow-point-${number}`; 39 39 40 40 const HANDLE_HIT_RADIUS = 10; 41 41 const ROTATE_HANDLE_OFFSET = 40; ··· 110 110 private handlePointerDown(state: EditorState, action: Action): EditorState { 111 111 if (action.type !== "pointer-down") return state; 112 112 113 + if (action.modifiers.alt && state.ui.selectionIds.length === 1) { 114 + const shapeId = state.ui.selectionIds[0]; 115 + const shape = state.doc.shapes[shapeId]; 116 + if (shape?.type === "arrow") { 117 + const result = this.tryAddPointToArrowSegment(state, shape, action.world); 118 + if (result) { 119 + return result; 120 + } 121 + } 122 + } 123 + 113 124 const handleHit = this.hitTestHandle(state, action.world); 114 125 if (handleHit) { 115 126 return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); ··· 241 252 let updated: ShapeRecord | null = null; 242 253 if (this.toolState.activeHandle === "rotate") { 243 254 updated = this.rotateShape(initialShape, action.world); 244 - } else if (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") { 255 + } else if ( 256 + this.toolState.activeHandle === "line-start" 257 + || this.toolState.activeHandle === "line-end" 258 + || this.toolState.activeHandle.startsWith("arrow-point-") 259 + ) { 245 260 updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); 246 261 } else if (this.toolState.handleStartBounds) { 247 262 updated = this.resizeRectLikeShape( ··· 363 378 } 364 379 365 380 if (action.key === "Delete" || action.key === "Backspace") { 381 + if ( 382 + this.toolState.activeHandle 383 + && typeof this.toolState.activeHandle === "string" 384 + && this.toolState.activeHandle.startsWith("arrow-point-") 385 + && this.toolState.handleShapeId 386 + ) { 387 + return this.removeArrowPoint(state, this.toolState.handleShapeId, this.toolState.activeHandle); 388 + } 389 + 366 390 return this.deleteSelectedShapes(state); 367 391 } 368 392 ··· 474 498 const end = this.localToWorld(shape, shape.props.b); 475 499 handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 476 500 } else if (shape.type === "arrow") { 477 - // TODO: do away with legacy format 478 - if (shape.props.a && shape.props.b) { 479 - const start = this.localToWorld(shape, shape.props.a); 480 - const end = this.localToWorld(shape, shape.props.b); 481 - handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 482 - } else if (shape.props.points && shape.props.points.length >= 2) { 483 - const firstPoint = shape.props.points[0]; 484 - const lastPoint = shape.props.points[shape.props.points.length - 1]; 485 - const start = this.localToWorld(shape, firstPoint); 486 - const end = this.localToWorld(shape, lastPoint); 487 - handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 501 + if (shape.props.points && shape.props.points.length >= 2) { 502 + for (let i = 0; i < shape.props.points.length; i++) { 503 + const point = shape.props.points[i]; 504 + const worldPos = this.localToWorld(shape, point); 505 + 506 + if (i === 0) { 507 + handles.push({ id: "line-start", position: worldPos }); 508 + } else if (i === shape.props.points.length - 1) { 509 + handles.push({ id: "line-end", position: worldPos }); 510 + } else { 511 + handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); 512 + } 513 + } 488 514 } 489 515 } 490 516 return handles; ··· 557 583 return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 558 584 } 559 585 560 - private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: "line-start" | "line-end"): ShapeRecord | null { 586 + private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: HandleKind): ShapeRecord | null { 561 587 if (initial.type !== "line" && initial.type !== "arrow") { 562 588 return null; 563 589 } 564 590 591 + if (initial.type === "arrow" && typeof handle === "string" && handle.startsWith("arrow-point-")) { 592 + const pointIndex = Number.parseInt(handle.replace("arrow-point-", ""), 10); 593 + if (!initial.props.points || pointIndex < 1 || pointIndex >= initial.props.points.length - 1) { 594 + return null; 595 + } 596 + 597 + const newPoints = initial.props.points.map((p, i) => { 598 + if (i === pointIndex) { 599 + return { x: pointer.x - initial.x, y: pointer.y - initial.y }; 600 + } 601 + return p; 602 + }); 603 + 604 + const newProps = { ...initial.props, points: newPoints }; 605 + return { ...initial, props: newProps }; 606 + } 607 + 608 + if (handle !== "line-start" && handle !== "line-end") { 609 + return null; 610 + } 611 + 565 612 let startPoint: Vec2, endPoint: Vec2; 566 613 567 614 if (initial.type === "line") { 568 615 startPoint = initial.props.a; 569 616 endPoint = initial.props.b; 570 617 } else { 571 - if (initial.props.a && initial.props.b) { 572 - startPoint = initial.props.a; 573 - endPoint = initial.props.b; 574 - } else if (initial.props.points && initial.props.points.length >= 2) { 575 - startPoint = initial.props.points[0]; 576 - endPoint = initial.props.points[initial.props.points.length - 1]; 577 - } else { 618 + if (!initial.props.points || initial.props.points.length < 2) { 578 619 return null; 579 620 } 621 + startPoint = initial.props.points[0]; 622 + endPoint = initial.props.points[initial.props.points.length - 1]; 580 623 } 581 624 582 625 const startWorld = this.localToWorld(initial, startPoint); ··· 592 635 }; 593 636 return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 594 637 } else { 595 - const newProps = { 596 - ...initial.props, 597 - a: { x: 0, y: 0 }, 598 - b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, 599 - }; 638 + const newPoints = initial.props.points.map((p, i) => { 639 + if (i === 0) { 640 + return { x: 0, y: 0 }; 641 + } else if (i === initial.props.points.length - 1) { 642 + return { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }; 643 + } else { 644 + const worldPos = this.localToWorld(initial, p); 645 + return { x: worldPos.x - newStart.x, y: worldPos.y - newStart.y }; 646 + } 647 + }); 648 + 649 + const newProps = { ...initial.props, points: newPoints }; 600 650 return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 601 651 } 602 652 } ··· 623 673 const cos = Math.cos(shape.rot); 624 674 const sin = Math.sin(shape.rot); 625 675 return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 676 + } 677 + 678 + /** 679 + * Remove an intermediate point from an arrow 680 + */ 681 + private removeArrowPoint(state: EditorState, arrowId: string, handle: HandleKind): EditorState { 682 + const arrow = state.doc.shapes[arrowId]; 683 + if (!arrow || arrow.type !== "arrow" || !arrow.props.points) { 684 + return state; 685 + } 686 + 687 + const pointIndex = Number.parseInt((handle as string).replace("arrow-point-", ""), 10); 688 + if (Number.isNaN(pointIndex) || pointIndex < 1 || pointIndex >= arrow.props.points.length - 1) { 689 + return state; 690 + } 691 + 692 + const newPoints = arrow.props.points.filter((_, i) => i !== pointIndex); 693 + 694 + if (newPoints.length < 2) { 695 + return state; 696 + } 697 + 698 + const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; 699 + 700 + this.resetToolState(); 701 + 702 + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } } }; 703 + } 704 + 705 + /** 706 + * Try to add a point to an arrow segment at the clicked location 707 + * Returns updated state if successful, null otherwise 708 + */ 709 + private tryAddPointToArrowSegment(state: EditorState, arrow: ShapeRecord, clickWorld: Vec2): EditorState | null { 710 + if (arrow.type !== "arrow" || !arrow.props.points || arrow.props.points.length < 2) { 711 + return null; 712 + } 713 + 714 + const clickLocal = { x: clickWorld.x - arrow.x, y: clickWorld.y - arrow.y }; 715 + const tolerance = 10; 716 + 717 + for (let i = 0; i < arrow.props.points.length - 1; i++) { 718 + const a = arrow.props.points[i]; 719 + const b = arrow.props.points[i + 1]; 720 + 721 + const ab = Vec2Ops.sub(b, a); 722 + const ap = Vec2Ops.sub(clickLocal, a); 723 + const abLengthSq = Vec2Ops.lenSq(ab); 724 + 725 + if (abLengthSq === 0) continue; 726 + 727 + const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / abLengthSq)); 728 + const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); 729 + const distance = Vec2Ops.dist(clickLocal, projection); 730 + 731 + if (distance <= tolerance) { 732 + const newPoints = [...arrow.props.points.slice(0, i + 1), clickLocal, ...arrow.props.points.slice(i + 1)]; 733 + 734 + const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; 735 + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrow.id]: updatedArrow } } }; 736 + } 737 + } 738 + 739 + return null; 626 740 } 627 741 628 742 /**
+31 -23
packages/core/src/tools/shape.ts
··· 558 558 const shapeId = createId("shape"); 559 559 560 560 const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, { 561 - a: { x: 0, y: 0 }, 562 - b: { x: 0, y: 0 }, 563 - stroke: "#495057", 564 - width: 2, 561 + points: [{ x: 0, y: 0 }, { x: 0, y: 0 }], 562 + start: { kind: "free" }, 563 + end: { kind: "free" }, 564 + style: { stroke: "#495057", width: 2, headEnd: true }, 565 + routing: { kind: "straight" }, 565 566 }, shapeId); 566 567 567 568 this.toolState.isCreating = true; ··· 589 590 if (!shape || shape.type !== "arrow") return state; 590 591 591 592 const b = Vec2.sub(action.world, this.toolState.startWorld); 592 - const updatedShape = { ...shape, props: { ...shape.props, b } }; 593 + const updatedPoints = [{ x: 0, y: 0 }, b]; 594 + const updatedShape = { ...shape, props: { ...shape.props, points: updatedPoints } }; 593 595 594 596 return { 595 597 ...state, ··· 605 607 606 608 let newState = state; 607 609 608 - let endPoint: Vec2; 609 - if (shape.props.b) { 610 - endPoint = shape.props.b; 611 - } else if (shape.props.points && shape.props.points.length >= 2) { 612 - endPoint = shape.props.points[shape.props.points.length - 1]; 613 - } else { 614 - endPoint = { x: 0, y: 0 }; 610 + const points = shape.props.points; 611 + if (!points || points.length < 2) { 612 + newState = this.cancelShapeCreation(state); 613 + this.resetToolState(); 614 + return newState; 615 615 } 616 616 617 + const endPoint = points[points.length - 1]; 617 618 const arrowLength = Vec2.len(endPoint); 618 619 if (arrowLength < MIN_SHAPE_SIZE) { 619 620 newState = this.cancelShapeCreation(state); ··· 632 633 const arrow = state.doc.shapes[arrowId]; 633 634 if (!arrow || arrow.type !== "arrow") return state; 634 635 635 - let startPoint: Vec2, endPoint: Vec2; 636 - if (arrow.props.a && arrow.props.b) { 637 - startPoint = arrow.props.a; 638 - endPoint = arrow.props.b; 639 - } else if (arrow.props.points && arrow.props.points.length >= 2) { 640 - startPoint = arrow.props.points[0]; 641 - endPoint = arrow.props.points[arrow.props.points.length - 1]; 642 - } else { 643 - return state; 644 - } 636 + const points = arrow.props.points; 637 + if (!points || points.length < 2) return state; 638 + 639 + const startPoint = points[0]; 640 + const endPoint = points[points.length - 1]; 645 641 646 642 const startWorld = { x: arrow.x + startPoint.x, y: arrow.y + startPoint.y }; 647 643 const endWorld = { x: arrow.x + endPoint.x, y: arrow.y + endPoint.y }; 648 644 649 645 const newBindings = { ...state.doc.bindings }; 646 + let updatedArrow = arrow; 650 647 651 648 const stateWithoutArrow = { 652 649 ...state, ··· 667 664 ny: anchor.ny, 668 665 }); 669 666 newBindings[binding.id] = binding; 667 + updatedArrow = { 668 + ...updatedArrow, 669 + props: { ...updatedArrow.props, start: { kind: "bound", bindingId: binding.id } }, 670 + }; 670 671 } 671 672 } 672 673 ··· 677 678 const anchor = computeNormalizedAnchor(endWorld, targetShape); 678 679 const binding = BindingRecord.create(arrowId, endHitId, "end", { kind: "edge", nx: anchor.nx, ny: anchor.ny }); 679 680 newBindings[binding.id] = binding; 681 + updatedArrow = { 682 + ...updatedArrow, 683 + props: { ...updatedArrow.props, end: { kind: "bound", bindingId: binding.id } }, 684 + }; 680 685 } 681 686 } 682 687 683 - return { ...state, doc: { ...state.doc, bindings: newBindings } }; 688 + return { 689 + ...state, 690 + doc: { ...state.doc, bindings: newBindings, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } }, 691 + }; 684 692 } 685 693 686 694 private handleKeyDown(state: EditorState, action: Action): EditorState {
+393
packages/core/tests/arrow-multipoint.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { Action } from "../src/actions"; 3 + import { BindingRecord, ShapeRecord } from "../src/model"; 4 + import { EditorState } from "../src/reactivity"; 5 + import { SelectTool } from "../src/tools/select"; 6 + 7 + describe("Arrow multi-point editing", () => { 8 + describe("Dragging intermediate points", () => { 9 + it("should allow dragging an intermediate point", () => { 10 + let state = EditorState.create(); 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]]; 14 + 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 + ], 20 + start: { kind: "free" }, 21 + end: { kind: "free" }, 22 + style: { stroke: "#000", width: 2, headEnd: true }, 23 + }); 24 + 25 + state = { 26 + ...state, 27 + doc: { 28 + ...state.doc, 29 + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, 30 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, 31 + }, 32 + ui: { ...state.ui, selectionIds: [arrow.id] }, 33 + }; 34 + 35 + store.setState(state); 36 + 37 + const tool = new SelectTool(); 38 + tool.onEnter(state); 39 + 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 42 + const pointerDown = Action.pointerDown( 43 + { x: 0, y: 0 }, 44 + intermediateWorldPos, 45 + 0, 46 + { left: true, middle: false, right: false }, 47 + { ctrl: false, shift: false, alt: false, meta: false }, 48 + 0, 49 + ); 50 + state = tool.onAction(state, pointerDown); 51 + 52 + // Drag the point to a new location 53 + 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 + ); 61 + state = tool.onAction(state, pointerMove); 62 + 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 + ); 71 + state = tool.onAction(state, pointerUp); 72 + 73 + const updatedArrow = state.doc.shapes[arrow.id]; 74 + expect(updatedArrow.type).toBe("arrow"); 75 + if (updatedArrow.type === "arrow") { 76 + 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 81 + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 82 + expect(updatedArrow.props.points[2]).toEqual({ x: 100, y: 0 }); 83 + } 84 + }); 85 + 86 + it("should preserve bindings when dragging intermediate points", () => { 87 + const store = Store.create(); 88 + let state = store.getState(); 89 + 90 + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 91 + 92 + // Create a target shape 93 + const targetRect = ShapeRecord.createRect(page.id, 300, 100, { 94 + w: 100, 95 + h: 100, 96 + fill: "#fff", 97 + stroke: "#000", 98 + radius: 0, 99 + }); 100 + 101 + // Create an arrow with a binding 102 + 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 + ], 108 + start: { kind: "free" }, 109 + end: { kind: "bound", bindingId: "binding-1" }, 110 + style: { stroke: "#000", width: 2, headEnd: true }, 111 + }); 112 + 113 + const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding-1"); 114 + 115 + state = { 116 + ...state, 117 + doc: { 118 + ...state.doc, 119 + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [targetRect.id]: targetRect }, 120 + bindings: { [binding.id]: binding }, 121 + pages: { 122 + ...state.doc.pages, 123 + [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id, targetRect.id] }, 124 + }, 125 + }, 126 + ui: { ...state.ui, selectionIds: [arrow.id] }, 127 + }; 128 + 129 + store.setState(state); 130 + 131 + const tool = new SelectTool(); 132 + tool.onEnter(state); 133 + 134 + // Drag the intermediate point 135 + const intermediateWorldPos = { x: 200, y: 150 }; 136 + const pointerDown = Action.pointerDown( 137 + { x: 0, y: 0 }, 138 + intermediateWorldPos, 139 + 0, 140 + { left: true, middle: false, right: false }, 141 + { ctrl: false, shift: false, alt: false, meta: false }, 142 + 0, 143 + ); 144 + state = tool.onAction(state, pointerDown); 145 + 146 + 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 + ); 154 + state = tool.onAction(state, pointerMove); 155 + 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 + ); 164 + state = tool.onAction(state, pointerUp); 165 + 166 + // Binding should still exist 167 + expect(state.doc.bindings[binding.id]).toBeDefined(); 168 + expect(state.doc.bindings[binding.id].toShapeId).toBe(targetRect.id); 169 + }); 170 + }); 171 + 172 + describe("Adding points with Alt+click", () => { 173 + it("should add a point when Alt+clicking on a segment", () => { 174 + const store = Store.create(); 175 + let state = store.getState(); 176 + 177 + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 178 + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 179 + points: [ 180 + { x: 0, y: 0 }, 181 + { x: 100, y: 0 }, 182 + ], 183 + start: { kind: "free" }, 184 + end: { kind: "free" }, 185 + style: { stroke: "#000", width: 2, headEnd: true }, 186 + }); 187 + 188 + state = { 189 + ...state, 190 + doc: { 191 + ...state.doc, 192 + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, 193 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, 194 + }, 195 + ui: { ...state.ui, selectionIds: [arrow.id] }, 196 + }; 197 + 198 + store.setState(state); 199 + 200 + const tool = new SelectTool(); 201 + tool.onEnter(state); 202 + 203 + // Alt+click in the middle of the line 204 + const clickWorld = { x: 150, y: 100 }; // Midpoint of the line 205 + const pointerDown = Action.pointerDown( 206 + { x: 0, y: 0 }, 207 + clickWorld, 208 + 0, 209 + { left: true, middle: false, right: false }, 210 + { ctrl: false, shift: false, alt: true, meta: false }, 211 + 0, 212 + ); 213 + state = tool.onAction(state, pointerDown); 214 + 215 + const updatedArrow = state.doc.shapes[arrow.id]; 216 + expect(updatedArrow.type).toBe("arrow"); 217 + if (updatedArrow.type === "arrow") { 218 + expect(updatedArrow.props.points.length).toBe(3); 219 + // New point should be inserted between the start and end 220 + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 221 + expect(updatedArrow.props.points[1].x).toBeCloseTo(50, 0); 222 + expect(updatedArrow.props.points[1].y).toBeCloseTo(0, 0); 223 + expect(updatedArrow.props.points[2]).toEqual({ x: 100, y: 0 }); 224 + } 225 + }); 226 + 227 + it("should not add a point when Alt+clicking far from any segment", () => { 228 + const store = Store.create(); 229 + let state = store.getState(); 230 + 231 + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 232 + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { 233 + points: [ 234 + { x: 0, y: 0 }, 235 + { x: 100, y: 0 }, 236 + ], 237 + start: { kind: "free" }, 238 + end: { kind: "free" }, 239 + style: { stroke: "#000", width: 2, headEnd: true }, 240 + }); 241 + 242 + state = { 243 + ...state, 244 + doc: { 245 + ...state.doc, 246 + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, 247 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, 248 + }, 249 + ui: { ...state.ui, selectionIds: [arrow.id] }, 250 + }; 251 + 252 + store.setState(state); 253 + 254 + const tool = new SelectTool(); 255 + tool.onEnter(state); 256 + 257 + // Alt+click far away from the line 258 + const clickWorld = { x: 150, y: 200 }; // Far from the horizontal line 259 + const pointerDown = Action.pointerDown( 260 + { x: 0, y: 0 }, 261 + clickWorld, 262 + 0, 263 + { left: true, middle: false, right: false }, 264 + { ctrl: false, shift: false, alt: true, meta: false }, 265 + 0, 266 + ); 267 + state = tool.onAction(state, pointerDown); 268 + 269 + const updatedArrow = state.doc.shapes[arrow.id]; 270 + expect(updatedArrow.type).toBe("arrow"); 271 + if (updatedArrow.type === "arrow") { 272 + // Should still have 2 points (no point added) 273 + expect(updatedArrow.props.points.length).toBe(2); 274 + } 275 + }); 276 + }); 277 + 278 + describe("Removing points with Delete/Backspace", () => { 279 + it("should remove an intermediate point when Delete is pressed while dragging", () => { 280 + const store = Store.create(); 281 + let state = store.getState(); 282 + 283 + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 284 + 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 + ], 290 + start: { kind: "free" }, 291 + end: { kind: "free" }, 292 + style: { stroke: "#000", width: 2, headEnd: true }, 293 + }); 294 + 295 + state = { 296 + ...state, 297 + doc: { 298 + ...state.doc, 299 + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, 300 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, 301 + }, 302 + ui: { ...state.ui, selectionIds: [arrow.id] }, 303 + }; 304 + 305 + store.setState(state); 306 + 307 + const tool = new SelectTool(); 308 + tool.onEnter(state); 309 + 310 + // Start dragging the intermediate point 311 + const intermediateWorldPos = { x: 150, y: 150 }; 312 + const pointerDown = Action.pointerDown( 313 + { x: 0, y: 0 }, 314 + intermediateWorldPos, 315 + 0, 316 + { left: true, middle: false, right: false }, 317 + { ctrl: false, shift: false, alt: false, meta: false }, 318 + 0, 319 + ); 320 + state = tool.onAction(state, pointerDown); 321 + 322 + // Press Delete while dragging 323 + const keyDown = Action.keyDown("Delete", { ctrl: false, shift: false, alt: false, meta: false }, 100); 324 + state = tool.onAction(state, keyDown); 325 + 326 + const updatedArrow = state.doc.shapes[arrow.id]; 327 + expect(updatedArrow.type).toBe("arrow"); 328 + if (updatedArrow.type === "arrow") { 329 + // Should now have 2 points (intermediate point removed) 330 + expect(updatedArrow.props.points.length).toBe(2); 331 + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); 332 + expect(updatedArrow.props.points[1]).toEqual({ x: 100, y: 0 }); 333 + } 334 + }); 335 + 336 + it("should not remove points if it would leave less than 2 points", () => { 337 + const store = Store.create(); 338 + let state = store.getState(); 339 + 340 + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; 341 + 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 + ], 347 + start: { kind: "free" }, 348 + end: { kind: "free" }, 349 + style: { stroke: "#000", width: 2, headEnd: true }, 350 + }); 351 + 352 + state = { 353 + ...state, 354 + doc: { 355 + ...state.doc, 356 + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, 357 + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, 358 + }, 359 + ui: { ...state.ui, selectionIds: [arrow.id] }, 360 + }; 361 + 362 + store.setState(state); 363 + 364 + const tool = new SelectTool(); 365 + tool.onEnter(state); 366 + 367 + // Remove one intermediate point (should work) 368 + const intermediateWorldPos = { x: 150, y: 150 }; 369 + const pointerDown = Action.pointerDown( 370 + { x: 0, y: 0 }, 371 + intermediateWorldPos, 372 + 0, 373 + { left: true, middle: false, right: false }, 374 + { ctrl: false, shift: false, alt: false, meta: false }, 375 + 0, 376 + ); 377 + state = tool.onAction(state, pointerDown); 378 + 379 + const keyDown = Action.keyDown("Delete", { ctrl: false, shift: false, alt: false, meta: false }, 100); 380 + state = tool.onAction(state, keyDown); 381 + 382 + let updatedArrow = state.doc.shapes[arrow.id]; 383 + expect(updatedArrow.type).toBe("arrow"); 384 + if (updatedArrow.type === "arrow") { 385 + expect(updatedArrow.props.points.length).toBe(2); 386 + } 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 + }); 392 + }); 393 + });
+5 -4
packages/core/tests/export.test.ts
··· 81 81 const { state, pageId } = createTestState(); 82 82 83 83 const arrow = ShapeRecord.createArrow(pageId, 0, 0, { 84 - a: { x: 0, y: 0 }, 85 - b: { x: 100, y: 0 }, 86 - stroke: "black", 87 - width: 2, 84 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 85 + start: { kind: "free" }, 86 + end: { kind: "free" }, 87 + style: { stroke: "black", width: 2, headEnd: true }, 88 + routing: { kind: "straight" }, 88 89 }); 89 90 90 91 state.doc.shapes[arrow.id] = arrow;
+40 -40
packages/core/tests/geom.test.ts
··· 64 64 65 65 it("should return correct bounds for arrow", () => { 66 66 const arrow = ShapeRecord.createArrow("page:1", 20, 30, { 67 - a: { x: 10, y: 10 }, 68 - b: { x: 50, y: 60 }, 69 - stroke: "", 70 - width: 2, 67 + points: [{ x: 10, y: 10 }, { x: 50, y: 60 }], 68 + start: { kind: "free" }, 69 + end: { kind: "free" }, 70 + style: { stroke: "", width: 2 }, 71 71 }); 72 72 73 73 const bounds = shapeBounds(arrow); ··· 582 582 const store = new Store(); 583 583 const page = PageRecord.create("Page 1", "page:1"); 584 584 const arrow = ShapeRecord.createArrow("page:1", 100, 100, { 585 - a: { x: 0, y: 0 }, 586 - b: { x: 100, y: 0 }, 587 - stroke: "#000000", 588 - width: 2, 585 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 586 + start: { kind: "free" }, 587 + end: { kind: "free" }, 588 + style: { stroke: "#000000", width: 2 }, 589 589 }, "shape:1"); 590 590 591 591 store.setState((state) => ({ ··· 732 732 733 733 it("should return center of arrow shape", () => { 734 734 const arrow = ShapeRecord.createArrow("page:1", 50, 50, { 735 - a: { x: -50, y: -50 }, 736 - b: { x: 50, y: 50 }, 737 - stroke: "", 738 - width: 2, 735 + points: [{ x: -50, y: -50 }, { x: 50, y: 50 }], 736 + start: { kind: "free" }, 737 + end: { kind: "free" }, 738 + style: { stroke: "", width: 2 }, 739 739 }); 740 740 741 741 const center = shapeCenter(arrow); ··· 758 758 const store = new Store(); 759 759 const page = PageRecord.create("Page 1", "page:1"); 760 760 const arrow = ShapeRecord.createArrow("page:1", 100, 100, { 761 - a: { x: 0, y: 0 }, 762 - b: { x: 100, y: 50 }, 763 - stroke: "", 764 - width: 2, 761 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], 762 + start: { kind: "free" }, 763 + end: { kind: "free" }, 764 + style: { stroke: "", width: 2 }, 765 765 }, "arrow:1"); 766 766 767 767 store.setState((state) => ({ ··· 787 787 "rect:1", 788 788 ); 789 789 const arrow = ShapeRecord.createArrow("page:1", 300, 300, { 790 - a: { x: -150, y: -150 }, 791 - b: { x: 100, y: 100 }, 792 - stroke: "", 793 - width: 2, 790 + points: [{ x: -150, y: -150 }, { x: 100, y: 100 }], 791 + start: { kind: "free" }, 792 + end: { kind: "free" }, 793 + style: { stroke: "", width: 2 }, 794 794 }, "arrow:1"); 795 795 796 796 const binding = BindingRecord.create(arrow.id, targetRect.id, "start", { kind: "center" }, "binding:1"); ··· 823 823 "rect:1", 824 824 ); 825 825 const arrow = ShapeRecord.createArrow("page:1", 50, 50, { 826 - a: { x: 0, y: 0 }, 827 - b: { x: 200, y: 200 }, 828 - stroke: "", 829 - width: 2, 826 + points: [{ x: 0, y: 0 }, { x: 200, y: 200 }], 827 + start: { kind: "free" }, 828 + end: { kind: "free" }, 829 + style: { stroke: "", width: 2 }, 830 830 }, "arrow:1"); 831 831 832 832 const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding:1"); ··· 865 865 "rect:2", 866 866 ); 867 867 const arrow = ShapeRecord.createArrow("page:1", 0, 0, { 868 - a: { x: 0, y: 0 }, 869 - b: { x: 100, y: 100 }, 870 - stroke: "", 871 - width: 2, 868 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 869 + start: { kind: "free" }, 870 + end: { kind: "free" }, 871 + style: { stroke: "", width: 2 }, 872 872 }, "arrow:1"); 873 873 874 874 const binding1 = BindingRecord.create(arrow.id, rect1.id, "start", { kind: "center" }, "binding:1"); ··· 895 895 const store = new Store(); 896 896 const page = PageRecord.create("Page 1", "page:1"); 897 897 const arrow = ShapeRecord.createArrow("page:1", 100, 100, { 898 - a: { x: 0, y: 0 }, 899 - b: { x: 100, y: 50 }, 900 - stroke: "", 901 - width: 2, 898 + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], 899 + start: { kind: "free" }, 900 + end: { kind: "free" }, 901 + style: { stroke: "", width: 2 }, 902 902 }, "arrow:1"); 903 903 904 904 const binding = BindingRecord.create(arrow.id, "nonexistent:1", "start", { kind: "center" }, "binding:1"); ··· 962 962 "rect:1", 963 963 ); 964 964 const arrow = ShapeRecord.createArrow("page:1", 50, 50, { 965 - a: { x: 0, y: 0 }, 966 - b: { x: 100, y: 100 }, 967 - stroke: "", 968 - width: 2, 965 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 966 + start: { kind: "free" }, 967 + end: { kind: "free" }, 968 + style: { stroke: "", width: 2 }, 969 969 }, "arrow:1"); 970 970 971 971 const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding:1"); ··· 1008 1008 "rect:1", 1009 1009 ); 1010 1010 const arrow = ShapeRecord.createArrow(page.id, 0, 0, { 1011 - a: { x: 0, y: 0 }, 1012 - b: { x: 100, y: 100 }, 1013 - stroke: "#000", 1014 - width: 2, 1011 + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], 1012 + start: { kind: "free" }, 1013 + end: { kind: "free" }, 1014 + style: { stroke: "#000", width: 2 }, 1015 1015 }, "arrow:1"); 1016 1016 1017 1017 const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "edge", nx: 1, ny: 0 });
+35 -60
packages/core/tests/model.test.ts
··· 173 173 }); 174 174 175 175 describe("createArrow", () => { 176 - it("should create an arrow shape with legacy format", () => { 177 - const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 178 - const shape = ShapeRecord.createArrow(pageId, 10, 20, props); 179 - 180 - expect(shape.id).toMatch(/^shape:/); 181 - expect(shape.type).toBe("arrow"); 182 - expect(shape.props).toEqual(props); 183 - }); 184 - 185 176 it("should create an arrow with modern format (points only)", () => { 186 177 const props: ArrowProps = { 187 178 points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], ··· 361 352 362 353 expect(cloned).toEqual(shape); 363 354 expect(cloned.props).not.toBe(shape.props); 364 - }); 365 - 366 - it("should clone legacy arrow shape", () => { 367 - const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; 368 - const shape = ShapeRecord.createArrow(pageId, 0, 0, props); 369 - 370 - const cloned = ShapeRecord.clone(shape); 371 - 372 - expect(cloned).toEqual(shape); 373 - expect(cloned.props).not.toBe(shape.props); 374 - if (cloned.type === "arrow" && shape.type === "arrow") { 375 - expect(cloned.props.a).not.toBe(shape.props.a); 376 - expect(cloned.props.b).not.toBe(shape.props.b); 377 - } 378 355 }); 379 356 380 357 it("should clone modern arrow shape with points", () => { ··· 687 664 const doc = Document.create(); 688 665 const page = PageRecord.create("Page 1", "page1"); 689 666 const arrow = ShapeRecord.createArrow("page1", 0, 0, { 690 - a: { x: 0, y: 0 }, 691 - b: { x: 100, y: 0 }, 692 - stroke: "#000", 693 - width: 2, 667 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 668 + start: { kind: "free" }, 669 + end: { kind: "free" }, 670 + style: { stroke: "#000", width: 2 }, 694 671 }, "arrow1"); 695 672 const rect = ShapeRecord.createRect( 696 673 "page1", ··· 864 841 const doc = Document.create(); 865 842 const page = PageRecord.create("Page 1", "page1"); 866 843 const arrow = ShapeRecord.createArrow("page1", 0, 0, { 867 - a: { x: 0, y: 0 }, 868 - b: { x: 100, y: 0 }, 869 - stroke: "#000", 870 - width: 2, 844 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 845 + start: { kind: "free" }, 846 + end: { kind: "free" }, 847 + style: { stroke: "#000", width: 2 }, 871 848 }, "arrow1"); 872 849 const binding = BindingRecord.create("arrow1", "nonexistent", "end", { kind: "center" }, "binding1"); 873 850 ··· 1105 1082 } 1106 1083 }); 1107 1084 1108 - it("should reject arrow with neither legacy nor modern format", () => { 1085 + it("should reject arrow with missing required fields", () => { 1109 1086 const doc = Document.create(); 1110 1087 const page = PageRecord.create("Page 1", "page1"); 1111 - const shape = ShapeRecord.createArrow("page1", 0, 0, {}, "arrow1"); 1088 + const shape = ShapeRecord.createArrow("page1", 0, 0, {} as any, "arrow1"); 1112 1089 1113 1090 page.shapeIds = ["arrow1"]; 1114 1091 doc.pages = { page1: page }; ··· 1117 1094 const result = validateDoc(doc); 1118 1095 1119 1096 expect(result.ok).toBe(false); 1120 - if (!result.ok) { 1121 - expect(result.errors).toContain("Arrow shape 'arrow1' missing both legacy (a, b) and modern (points) format"); 1122 - } 1097 + // Arrow is invalid because it has no points or style 1123 1098 }); 1124 1099 1125 1100 it("should reject arrow with too few points in modern format", () => { ··· 1216 1191 const doc = Document.create(); 1217 1192 const page = PageRecord.create("Page 1", "page1"); 1218 1193 const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1219 - a: { x: 0, y: 0 }, 1220 - b: { x: 100, y: 0 }, 1221 - stroke: "#000", 1222 - width: 2, 1194 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1195 + start: { kind: "free" }, 1196 + end: { kind: "free" }, 1197 + style: { stroke: "#000", width: 2 }, 1223 1198 }, "arrow1"); 1224 1199 const rect = ShapeRecord.createRect( 1225 1200 "page1", ··· 1247 1222 const doc = Document.create(); 1248 1223 const page = PageRecord.create("Page 1", "page1"); 1249 1224 const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1250 - a: { x: 0, y: 0 }, 1251 - b: { x: 100, y: 0 }, 1252 - stroke: "#000", 1253 - width: 2, 1225 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1226 + start: { kind: "free" }, 1227 + end: { kind: "free" }, 1228 + style: { stroke: "#000", width: 2 }, 1254 1229 }, "arrow1"); 1255 1230 const rect = ShapeRecord.createRect( 1256 1231 "page1", ··· 1433 1408 width: 2, 1434 1409 }, "shape3"); 1435 1410 const arrow = ShapeRecord.createArrow("page1", 300, 300, { 1436 - a: { x: 0, y: 0 }, 1437 - b: { x: 100, y: 0 }, 1438 - stroke: "#000", 1439 - width: 2, 1411 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1412 + start: { kind: "free" }, 1413 + end: { kind: "free" }, 1414 + style: { stroke: "#000", width: 2 }, 1440 1415 }, "shape4"); 1441 1416 const text = ShapeRecord.createText("page1", 400, 400, { 1442 1417 text: "Hello World", ··· 1461 1436 const doc = Document.create(); 1462 1437 const page = PageRecord.create("Page 1", "page1"); 1463 1438 const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1464 - a: { x: 0, y: 0 }, 1465 - b: { x: 100, y: 0 }, 1466 - stroke: "#000", 1467 - width: 2, 1439 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1440 + start: { kind: "free" }, 1441 + end: { kind: "free" }, 1442 + style: { stroke: "#000", width: 2 }, 1468 1443 }, "arrow1"); 1469 1444 const rect = ShapeRecord.createRect( 1470 1445 "page1", ··· 1507 1482 "shape2", 1508 1483 ); 1509 1484 const shape3 = ShapeRecord.createArrow("page2", 0, 0, { 1510 - a: { x: 0, y: 0 }, 1511 - b: { x: 100, y: 0 }, 1512 - stroke: "#000", 1513 - width: 2, 1485 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1486 + start: { kind: "free" }, 1487 + end: { kind: "free" }, 1488 + style: { stroke: "#000", width: 2 }, 1514 1489 }, "shape3"); 1515 1490 const shape4 = ShapeRecord.createRect( 1516 1491 "page2", ··· 1601 1576 const doc = Document.create(); 1602 1577 const page = PageRecord.create("Page 1", "page1"); 1603 1578 const arrow = ShapeRecord.createArrow("page1", 0, 0, { 1604 - a: { x: 0, y: 0 }, 1605 - b: { x: 100, y: 0 }, 1606 - stroke: "#000", 1607 - width: 2, 1579 + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], 1580 + start: { kind: "free" }, 1581 + end: { kind: "free" }, 1582 + style: { stroke: "#000", width: 2 }, 1608 1583 }, "arrow1"); 1609 1584 const rect = ShapeRecord.createRect( 1610 1585 "page1",
+27 -13
packages/renderer/src/index.ts
··· 13 13 Vec2, 14 14 Viewport, 15 15 } from "inkfinite-core"; 16 - import { computeOutline, getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; 16 + import { 17 + computeOrthogonalPath, 18 + computeOutline, 19 + getShapesOnCurrentPage, 20 + resolveArrowEndpoints, 21 + shapeBounds, 22 + } from "inkfinite-core"; 17 23 18 24 export interface Renderer { 19 25 /** ··· 416 422 * Draw an arrow shape 417 423 */ 418 424 function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: ArrowShape) { 419 - const legacyStroke = shape.props.stroke; 420 - const legacyWidth = shape.props.width; 421 - const modernStyle = shape.props.style; 422 - const style = modernStyle ?? { stroke: legacyStroke ?? "#000", width: legacyWidth ?? 2 }; 425 + const style = shape.props.style; 423 426 424 427 const resolved = resolveArrowEndpoints(state, shape.id); 425 428 if (!resolved) return; ··· 428 431 const b = { x: resolved.b.x - shape.x, y: resolved.b.y - shape.y }; 429 432 430 433 let points: Vec2[]; 431 - const modernPoints = shape.props.points; 432 - if (modernPoints && modernPoints.length >= 2) { 433 - points = modernPoints.map((p: Vec2, index: number) => { 434 + 435 + // Use orthogonal routing if specified 436 + if (shape.props.routing?.kind === "orthogonal") { 437 + points = computeOrthogonalPath(a, b); 438 + } else { 439 + points = shape.props.points.map((p: Vec2, index: number) => { 434 440 if (index === 0) return a; 435 - if (index === modernPoints.length - 1) return b; 441 + if (index === shape.props.points.length - 1) return b; 436 442 return p; 437 443 }); 438 - } else { 439 - points = [a, b]; 440 444 } 441 445 442 446 context.beginPath(); ··· 849 853 850 854 if (shape.type === "arrow") { 851 855 const resolved = resolveArrowEndpoints(state, shape.id); 852 - if (resolved) { 853 - handles.push({ id: "line-start", position: resolved.a }, { id: "line-end", position: resolved.b }); 856 + if (resolved && shape.props.points && shape.props.points.length >= 2) { 857 + // Show handles for all points 858 + handles.push({ id: "line-start", position: resolved.a }); 859 + 860 + // Add intermediate point handles 861 + for (let i = 1; i < shape.props.points.length - 1; i++) { 862 + const point = shape.props.points[i]; 863 + const worldPos = localToWorld(shape, point); 864 + handles.push({ id: `arrow-point-${i}`, position: worldPos }); 865 + } 866 + 867 + handles.push({ id: "line-end", position: resolved.b }); 854 868 } 855 869 return handles; 856 870 }