diff --git a/TODO.txt b/TODO.txt index 9edfd95..6bd873b 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,6 +1,5 @@ ================================================================================ -Author intent: - Build a Svelte-native editor core (TS) + renderer + UI. - Keep the "engine" framework-agnostic so Web + Tauri share it. - Defer collaboration until single-player is correct. @@ -11,178 +10,315 @@ Conventions: - Files shown are ideas, not requirements ================================================================================ -1. Milestone A: Repo skeleton + dev loop *wb-A* +Milestone P: Performance *wb-P* ================================================================================ -Created a project monorepo/workspace. +Goal: the editor stays responsive with many shapes. -================================================================================ -2. Milestone B: Math + coordinate systems *wb-B* -================================================================================ +[ ] Add spatial index: + - rebuild index on doc changes + - query nearby shapes for hit testing +[ ] Add view culling: + - compute viewport bounds in world space + - render only shapes whose bounds intersect viewport +[ ] Reduce redraw frequency: + - rAF only while dirty + - optionally batch multiple store updates into one redraw +[ ] Add microbench harness: + - generate 10k shapes doc + - measure hit test and render time -Camera math, matrix utilities, and transforms are fully implemented and verified -so world and screen coordinates map precisely. +(DoD): +- 10k simple shapes pans/zooms smoothly on a typical machine. ================================================================================ -3. Milestone C: Document model (records) *wb-C* +Milestone A: Richer arrows / connectors *wb-A* ================================================================================ -Document/page/shape/binding records plus validation let the editor serialize and -reason about drawings safely. +-------------------------------------------------------------------------------- +A2. Editing UX +-------------------------------------------------------------------------------- -================================================================================ -4. Milestone D: Store + selectors (reactive core) *wb-D* -================================================================================ +[ ] Multi-point editing: + - Alt/Option+click on segment adds a control point + - Backspace/Delete on selected point removes it + - Drag point to reshape polyline +[ ] Orthogonal routing UI toggle: + - per-arrow toggle (routing.kind) + - UI control to switch between straight and orthogonal +[ ] Label editing UI: + - double-click arrow to edit label text + - label drags along the connector (offset along polyline) -The reactive store, invariants, and selectors supply deterministic state streams -for both renderer and UI subscribers. +-------------------------------------------------------------------------------- +A3. Precise anchors + snapping +-------------------------------------------------------------------------------- -================================================================================ -5. Milestone E: Canvas renderer (read-only) *wb-E* -================================================================================ +[ ] Anchor preview: + - show snap indicator when binding will occur +-------------------------------------------------------------------------------- +A5. Tests +-------------------------------------------------------------------------------- -The renderer now draws the document via Canvas2D with camera transforms, -DPI scaling, text sizing, and selection outlines. +[ ] Polyline edits preserve endpoints and do not corrupt bindings +[ ] Label placement stable under zoom/pan ================================================================================ -6. Milestone F: Hit testing (picking) *wb-F* +Milestone M: Markdown Blocks *wb-M* ================================================================================ -Geometry helpers compute bounds and intersections so hit testing can always -return the topmost shape under the cursor. +Goal: +Add a "Markdown block" shape with pleasant editing, predictable layout, and +export. Treat it as a doc-first primitive (not a hacky text element). -================================================================================ -7. Milestone G: Input system (pointer + keyboard) *wb-G* -================================================================================ +-------------------------------------------------------------------------------- +M1. Data model +-------------------------------------------------------------------------------- -Pointer and keyboard adapters now normalize events, map them into actions, and -feed the editor consistently across platforms. +/packages/core/src/model: +[ ] Add ShapeType: 'markdown' +[ ] MarkdownShape props: + - md: string + - w: number, h?: number " fixed width, auto height by layout + - style: { fontFamily, fontSize, color, bg?, border? } + - mode?: 'view'|'edit' " not persisted; UI-only -================================================================================ -8. Milestone H: Tool state machine (foundation) *wb-H* -================================================================================ +(DoD): Markdown blocks save/load; width preserved; content preserved verbatim. -Tool interfaces and the router manage lifecycle hooks so each tool is an -explicit, testable state machine. +-------------------------------------------------------------------------------- +M2. Rendering +-------------------------------------------------------------------------------- +/packages/renderer-canvas2d: +[ ] Render Markdown in canvas using a minimal subset: + - headings (#, ##) + - bold/italic/code + - bullet lists + - links (render style only; click later) +Strategy: +[ ] Parse md -> tokens -> lines; draw text runs onto canvas +[ ] Measure to compute auto height; cache layout per (md, w, style) -================================================================================ -9. Milestone I: Select/move tool (MVP interaction) *wb-I* -================================================================================ +(DoD): Markdown blocks look consistent and don’t reflow unpredictably during +pan/zoom. -Selection logic handles hit selection, marquee, dragging, deletion, and escape -so shapes can be moved reliably. +-------------------------------------------------------------------------------- +M3. Editing UX +-------------------------------------------------------------------------------- -================================================================================ -10. Milestone J: Create basic shapes via tools *wb-J* -================================================================================ +/apps/web: +[ ] Double-click Markdown block opens an overlay editor (contenteditable) +[ ] Cmd/Ctrl+Enter toggles edit/view +[ ] Tab inserts spaces (not focus change) when editing -Rect, ellipse, line, arrow, and text tools now create shapes via click-drag -interactions with proper finalize/cancel behavior. +(DoD): Editing feels fast; no accidental tool switching; commit is one history + step. -================================================================================ -11. Milestone K: Bindings for arrows (v0) *wb-K* -================================================================================ +-------------------------------------------------------------------------------- +M4. Selection + resize +-------------------------------------------------------------------------------- -Arrow endpoints bind to target shapes and stay attached by recalculating anchors -whenever shapes move. +[ ] Resizing adjusts width; height recomputed from layout +[ ] Hit-testing uses computed bounds -================================================================================ -12. Milestone L: History (undo/redo) *wb-L* -================================================================================ +(DoD): Markdown blocks behave like shapes: move/resize/duplicate/undo. -All document-affecting actions run through undoable commands with history stacks -and keyboard shortcuts. +-------------------------------------------------------------------------------- +M5. Export +-------------------------------------------------------------------------------- -================================================================================ -13. Milestone M: Persistence (web) via Dexie + History integration *wb-M* -================================================================================ +[ ] SVG export: + - v0: export as OR render as text lines +[ ] PNG export: already covered by canvas export path -Document changes now persist to IndexedDB via Dexie with migrations, repo API, -and history-driven syncing. +(DoD): Export doesn’t lose the Markdown block content. -================================================================================ -14. Milestone N: Status Bar (Editor HUD) *wb-N* -================================================================================ +-------------------------------------------------------------------------------- +M6. Tests +-------------------------------------------------------------------------------- -The HUD is now powered end-to-end via a `StatusBarVM` + cursor store, a web -persistence/snap manager, and `StatusBar.svelte` with snap/grid toggles backed -by unit/integration tests for selectors, cursor throttling, persistence -transitions, and Canvas wiring. +[ ] Layout cache keying (same md/w/style => stable height) +[ ] Resize changes width and increases/decreases computed height appropriately +[ ] Undo/redo persists through refresh (ties into M persistence) -Note: Zoom controls were moved to Toolbar in Milestone O for better UX. +(DoD): Markdown blocks are robust and predictable. ================================================================================ -15. Milestone O: Export (PNG/SVG) *wb-O* +Milestone L: Layers *wb-L* ================================================================================ -PNG/SVG export flows now deliver one-click viewport or selection exports from -the Toolbar across web and desktop builds. +Goal: +Add real layers: reorder, hide/show, lock, and per-layer opacity. This is now a +baseline expectation even for "simple" editors. -================================================================================ -16. Milestone P: Desktop packaging (Tauri) *wb-P* -================================================================================ +-------------------------------------------------------------------------------- +L1. Data model (doc) +-------------------------------------------------------------------------------- -The Tauri build now ships the static SvelteKit bundle with native file dialogs -for open/save/new/rename/delete workflows so the desktop app works end-to-end -offline. +/packages/core/src/model: +[ ] Add LayerRecord: + - id, boardId/pageId + - name + - order: number (or layerIds array on page) + - visible: boolean + - locked: boolean + - opacity: number (0..1) -================================================================================ -17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* -================================================================================ +[ ] Attach shapes to layers: + - ShapeRecord.layerId: string + - Default layer created on new board/page -Goal: the editor stays responsive with many shapes. +(DoD): Old docs migrate to "single default layer" automatically +(Dexie migration). -[ ] Add spatial index (v0: simple grid buckets): - - rebuild index on doc changes - - query nearby shapes for hit testing -[ ] Add view culling: - - compute viewport bounds in world space - - render only shapes whose bounds intersect viewport -[ ] Reduce redraw frequency: - - rAF only while dirty - - optionally batch multiple store updates into one redraw -[ ] Add microbench harness: - - generate 10k shapes doc - - measure hit test and render time +-------------------------------------------------------------------------------- +L2. Rendering order + behavior +-------------------------------------------------------------------------------- -(DoD): -- 10k simple shapes pans/zooms smoothly on a typical machine. +/packages/renderer-canvas2d: +[ ] Render layers in order: + - skip invisible + - apply opacity per layer (ctx.globalAlpha) +[ ] Selection rendering respects visibility: + - do not show selection UI for hidden shapes -================================================================================ -18. Milestone R: File Browser (web: Dexie inspector, desktop: FS) *wb-R* -================================================================================ +(DoD): Hiding a layer truly removes it from view and interaction. + +-------------------------------------------------------------------------------- +L3. Interaction rules +-------------------------------------------------------------------------------- -The shared file browser now offers Dexie-backed search + inspector tooling on -web and full workspace navigation on desktop, all driven by the view model -contracts. +[ ] Locked layer: + - shapes cannot be selected or edited + - marquee ignores locked shapes +[ ] Active layer: + - new shapes are created into the active layer +[ ] Reorder layers: + - drag to reorder, updates draw order + +(DoD): Lock/hide behave exactly as users expect. -------------------------------------------------------------------------------- -R4. Parity behaviors +L4. UI panel -------------------------------------------------------------------------------- -[x] Same shortcuts: - - Ctrl/Cmd+O opens file browser - - Ctrl/Cmd+N creates board -[x] Consistent metadata display: - - name + updatedAt in both modes +/apps/web: +[ ] Layers panel: + - list layers with eye + lock toggles + - rename layer + - new/delete layer + - drag reorder + - set active layer + - opacity slider per layer (optional v0, recommended v1) -(DoD): -- Web and desktop feel like the same app, with storage differences made explicit +(DoD): Layers are discoverable and usable without shortcuts. -================================================================================ -19. Milestone S: Quality polish. *wb-S* -================================================================================ +-------------------------------------------------------------------------------- +L5. Persistence + migrations +-------------------------------------------------------------------------------- + +[ ] Dexie migration: + - add layers table and layerId field on shapes (if normalized) + - backfill existing shapes -> default layer + - ensure boards/pages have at least 1 layer + +(DoD): Existing boards load unchanged but now sit on a default layer. + +-------------------------------------------------------------------------------- +L6. Tests +-------------------------------------------------------------------------------- + +[ ] Hidden layer shapes not hit-testable +[ ] Locked layer shapes not editable +[ ] New shape inherits active layer +[ ] Migration backfills correctly -Comprehensive UX polish adds BEM CSS, space-drag panning, richer keyboard -affordances, improved accessibility and styling, refined snapping, and handles. +(DoD): No regressions in selection/marquee/editing across layers. ================================================================================ -20. Milestone T: Sketching / Pen Tool (perfect-freehand) *wb-T* +Milestone S: Stencils (built-in) *wb-S* ================================================================================ -Perfect-freehand pen strokes now behave like first-class shapes with frame -coalesced drafting, geometry/rendering integration, and brush controls. +Goal: +Ship a curated set of built-in stencils (flowchart + UI + dev diagrams) with a +pleasant insertion workflow. No sharing/community libraries yet—just "your own". + +-------------------------------------------------------------------------------- +S1. Stencil definition format (core) +-------------------------------------------------------------------------------- + +/packages/core/src/stencils: +[ ] Define Stencil: + - id, name, category, tags[] + - preview: { kind: 'svg'|'canvas', data } + - spawn: function (atPoint, scale) -> ShapeRecords[] (group) + (A stencil can insert 1 shape or a grouped set.) + +[ ] Create initial categories (v0): + - Flowchart: process, decision, terminator, data, document + - Diagrams: server, db, queue, user, browser, mobile + - UI: button, input, card, modal + +(DoD): Stencils load as data and can spawn shapes deterministically. + +-------------------------------------------------------------------------------- +S2. Insert UX +-------------------------------------------------------------------------------- + +/apps/web: +[ ] Stencils drawer/palette: + - search (name + tags) + - category filter + - click inserts at viewport center OR + drag ghost preview onto canvas and drop + +[ ] Placement rules: + - insert into active layer (if layers exist) + - snap to grid if enabled + +(DoD): Inserting stencils is faster than drawing shapes manually. + +-------------------------------------------------------------------------------- +S3. Grouping behavior +-------------------------------------------------------------------------------- + +[ ] When a stencil spawns multiple shapes: + - create a GroupRecord OR a "groupId" on shapes (your existing grouping + model) + - allow move as one unit + - ungroup command + +(DoD): Multi-shape stencils behave like a single object until ungrouped. + +-------------------------------------------------------------------------------- +S4. Preview rendering +-------------------------------------------------------------------------------- + +[ ] Render stencil previews in the panel: + - v0: small SVG thumbnails (best) OR draw to offscreen canvas + +(DoD): Users can recognize stencils instantly. + +-------------------------------------------------------------------------------- +S5. Persistence + versioning +-------------------------------------------------------------------------------- + +[ ] Stencils are "code assets": + - version them with the app + - inserted shapes are normal shapes (no dependency on stencil after + insertion) + +(DoD): Old docs do not break if you change stencil definitions later. + +-------------------------------------------------------------------------------- +S6. Tests +-------------------------------------------------------------------------------- + +[ ] spawn() returns valid records with unique ids and correct initial positions +[ ] group insert produces expected selection and undo/redo works +[ ] search indexing returns correct stencils for tag queries + +(DoD): Stencils are reliable and don’t corrupt docs. ================================================================================ Parking Lot *wb-pl* @@ -191,27 +327,3 @@ Parking Lot *wb-pl* - [ ] Opacity for shapes - expose fill/stroke opacity controls so translucent layering is possible without exporting. -- [ ] Snapping/binding for arrows - - extend the binding system to keep arrow endpoints magnetized to shapes - even when snapping/grid options are enabled. - -================================================================================ -References (URLs) *wb-refs* -================================================================================ - -tldraw conceptual references (inspiration only): -- https://tldraw.dev/docs/shapes -- https://tldraw.dev/docs/editor -- https://tldraw.dev/reference/editor/Editor - -SvelteKit + Tauri packaging: -- https://v2.tauri.app/start/frontend/sveltekit/ -- https://svelte.dev/docs/kit/adapter-static -- https://tauri.app/v1/guides/getting-started/setup/sveltekit/ - -Canvas/infinite-canvas performance ideas: -- https://antv.vision/infinite-canvas-tutorial/guide/lesson-008 -- https://harrisonmilbradt.com/blog/canvas-panning-and-zooming - -Perfect Freehand -- https://github.com/steveruizok/perfect-freehand diff --git a/apps/web/src/lib/components/Toolbar.svelte b/apps/web/src/lib/components/Toolbar.svelte index 4f0583f..61362d1 100644 --- a/apps/web/src/lib/components/Toolbar.svelte +++ b/apps/web/src/lib/components/Toolbar.svelte @@ -92,7 +92,9 @@ } } if (strokable.length > 0) { - const shared = getSharedColor(strokable, (shape) => shape.props.stroke ?? null); + const shared = getSharedColor(strokable, (shape) => + shape.type === 'arrow' ? shape.props.style.stroke : (shape.props.stroke ?? null) + ); if (shared) { strokeColorValue = shared; } @@ -356,7 +358,10 @@ break; } case 'arrow': { - const updated: ArrowShape = { ...shape, props: { ...shape.props, stroke: color } }; + const updated: ArrowShape = { + ...shape, + props: { ...shape.props, style: { ...shape.props.style, stroke: color } } + }; newShapes[shape.id] = updated; break; } diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index c4d0ec9..2dcd861 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -204,22 +204,16 @@ function lineToSVG(shape: LineShape, transform: string): string { } function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string { - let startPoint, endPoint, strokeColor, strokeWidth; - - if (shape.props.a && shape.props.b) { - startPoint = shape.props.a; - endPoint = shape.props.b; - strokeColor = shape.props.stroke || "#000"; - strokeWidth = shape.props.width || 2; - } else if (shape.props.points && shape.props.points.length >= 2) { - startPoint = shape.props.points[0]; - endPoint = shape.props.points[shape.props.points.length - 1]; - strokeColor = shape.props.style?.stroke || "#000"; - strokeWidth = shape.props.style?.width || 2; - } else { + const points = shape.props.points; + if (!points || points.length < 2) { return ``; } + const startPoint = points[0]; + const endPoint = points[points.length - 1]; + const strokeColor = shape.props.style.stroke; + const strokeWidth = shape.props.style.width; + const angle = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x); const arrowLength = 15; const arrowAngle = Math.PI / 6; diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 65bfc99..5564233 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -102,13 +102,9 @@ function lineBounds(shape: LineShape): Box2 { function arrowBounds(shape: ArrowShape): Box2 { const { x, y, rot } = shape; + const points = shape.props.points; - let points: Vec2[]; - if (shape.props.a && shape.props.b) { - points = [shape.props.a, shape.props.b]; - } else if (shape.props.points && shape.props.points.length >= 2) { - points = shape.props.points; - } else { + if (!points || points.length < 2) { return { min: { x, y }, max: { x, y } }; } @@ -290,24 +286,25 @@ export function pointNearSegment(p: Vec2, a: Vec2, b: Vec2, tolerance: number): export function pointNearLine(p: Vec2, shape: LineShape | ArrowShape, tolerance = 5): boolean { const { x, y, rot } = shape; - let a: Vec2, b: Vec2; + let points: Vec2[]; if (shape.type === "line") { - a = shape.props.a; - b = shape.props.b; + points = [shape.props.a, shape.props.b]; } else { - if (shape.props.a && shape.props.b) { - a = shape.props.a; - b = shape.props.b; - } else if (shape.props.points && shape.props.points.length >= 2) { - a = shape.props.points[0]; - b = shape.props.points[shape.props.points.length - 1]; - } else { + if (!shape.props.points || shape.props.points.length < 2) { return false; } + points = shape.props.points; } const localP = worldToLocal(p, x, y, rot); - return pointNearSegment(localP, a, b, tolerance); + + for (let i = 0; i < points.length - 1; i++) { + if (pointNearSegment(localP, points[i], points[i + 1], tolerance)) { + return true; + } + } + + return false; } /** @@ -514,18 +511,13 @@ export function resolveArrowEndpoints(state: EditorState, arrowId: string): { a: const arrow = state.doc.shapes[arrowId]; if (!arrow || arrow.type !== "arrow") return null; - let a: Vec2, b: Vec2; - if (arrow.props.a && arrow.props.b) { - a = { x: arrow.x + arrow.props.a.x, y: arrow.y + arrow.props.a.y }; - b = { x: arrow.x + arrow.props.b.x, y: arrow.y + arrow.props.b.y }; - } else if (arrow.props.points && arrow.props.points.length >= 2) { - const firstPoint = arrow.props.points[0]; - const lastPoint = arrow.props.points[arrow.props.points.length - 1]; - a = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; - b = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; - } else { - return null; - } + const points = arrow.props.points; + if (!points || points.length < 2) return null; + + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; + let a: Vec2 = { x: arrow.x + firstPoint.x, y: arrow.y + firstPoint.y }; + let b: Vec2 = { x: arrow.x + lastPoint.x, y: arrow.y + lastPoint.y }; for (const binding of Object.values(state.doc.bindings)) { if (binding.fromShapeId !== arrowId) continue; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 0624549..f7fe20f 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -53,21 +53,14 @@ export type ArrowRouting = { kind: "straight" | "orthogonal"; cornerRadius?: num export type ArrowLabel = { text: string; align: "center" | "start" | "end"; offset: number }; /** - * Arrow properties supporting both legacy (a, b) and modern (points) formats - * Legacy format: { a, b, stroke, width } + * Arrow properties using modern format * Modern format: { points, start, end, style, routing?, label? } */ export type ArrowProps = { - // TODO: do away with legacy format (for backward compatibility - a?: Vec2; - b?: Vec2; - stroke?: string; - width?: number; - - points?: Vec2[]; - start?: ArrowEndpoint; - end?: ArrowEndpoint; - style?: ArrowStyle; + points: Vec2[]; + start: ArrowEndpoint; + end: ArrowEndpoint; + style: ArrowStyle; routing?: ArrowRouting; label?: ArrowLabel; }; @@ -178,17 +171,10 @@ export const ShapeRecord = { return { ...shape, props: { - ...shape.props, - - a: shape.props.a ? { ...shape.props.a } : undefined, - b: shape.props.b ? { ...shape.props.b } : undefined, - - points: shape.props.points ? shape.props.points.map((p) => ({ ...p })) : undefined, - start: shape.props.start ? { ...shape.props.start } : undefined, - end: shape.props.end ? { ...shape.props.end } : undefined, - style: shape.props.style - ? { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined } - : undefined, + points: shape.props.points.map((p) => ({ ...p })), + start: { ...shape.props.start }, + end: { ...shape.props.end }, + style: { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined }, routing: shape.props.routing ? { ...shape.props.routing } : undefined, label: shape.props.label ? { ...shape.props.label } : undefined, }, @@ -319,37 +305,23 @@ export function validateDoc(document: Document): ValidationResult { } case "arrow": { const props = shape.props; - const isLegacy = props.a !== undefined && props.b !== undefined; - const isModern = props.points !== undefined; - if (!isLegacy && !isModern) { - errors.push(`Arrow shape '${shapeId}' missing both legacy (a, b) and modern (points) format`); + if (!props.points || props.points.length < 2) { + errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); } - - if (isLegacy) { - if (props.width !== undefined && props.width < 0) { - errors.push(`Arrow shape '${shapeId}' has negative width in legacy format`); - } + if (!props.style) { + errors.push(`Arrow shape '${shapeId}' missing style`); + } else if (props.style.width < 0) { + errors.push(`Arrow shape '${shapeId}' has negative width in style`); } - - if (isModern) { - if (!props.points || props.points.length < 2) { - errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); - } - if (props.style) { - if (props.style.width < 0) { - errors.push(`Arrow shape '${shapeId}' has negative width in style`); - } + if (props.routing) { + if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { + errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); } - if (props.routing) { - if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { - errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); - } - } - if (props.label) { - if (!["center", "start", "end"].includes(props.label.align)) { - errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); - } + } + if (props.label) { + if (!["center", "start", "end"].includes(props.label.align)) { + errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); } } diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts index 91b71dc..7da68b0 100644 --- a/packages/core/src/tools.test.ts +++ b/packages/core/src/tools.test.ts @@ -916,7 +916,7 @@ describe("ArrowTool", () => { const shape = result.doc.shapes[shapeId]; expect(shape.type).toBe("arrow"); - expect((shape.props as ArrowProps).b).toEqual({ x: 200, y: 100 }); + expect((shape.props as ArrowProps).points[1]).toEqual({ x: 200, y: 100 }); }); it("should remove arrow if too short on pointer up", () => { diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index 94835c4..5b87e71 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -35,7 +35,7 @@ type SelectToolState = { type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; -type HandleKind = RectHandle | "rotate" | "line-start" | "line-end"; +type HandleKind = RectHandle | "rotate" | "line-start" | "line-end" | `arrow-point-${number}`; const HANDLE_HIT_RADIUS = 10; const ROTATE_HANDLE_OFFSET = 40; @@ -110,6 +110,17 @@ export class SelectTool implements Tool { private handlePointerDown(state: EditorState, action: Action): EditorState { if (action.type !== "pointer-down") return state; + if (action.modifiers.alt && state.ui.selectionIds.length === 1) { + const shapeId = state.ui.selectionIds[0]; + const shape = state.doc.shapes[shapeId]; + if (shape?.type === "arrow") { + const result = this.tryAddPointToArrowSegment(state, shape, action.world); + if (result) { + return result; + } + } + } + const handleHit = this.hitTestHandle(state, action.world); if (handleHit) { return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); @@ -241,7 +252,11 @@ export class SelectTool implements Tool { let updated: ShapeRecord | null = null; if (this.toolState.activeHandle === "rotate") { updated = this.rotateShape(initialShape, action.world); - } else if (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") { + } else if ( + this.toolState.activeHandle === "line-start" + || this.toolState.activeHandle === "line-end" + || this.toolState.activeHandle.startsWith("arrow-point-") + ) { updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); } else if (this.toolState.handleStartBounds) { updated = this.resizeRectLikeShape( @@ -363,6 +378,15 @@ export class SelectTool implements Tool { } if (action.key === "Delete" || action.key === "Backspace") { + if ( + this.toolState.activeHandle + && typeof this.toolState.activeHandle === "string" + && this.toolState.activeHandle.startsWith("arrow-point-") + && this.toolState.handleShapeId + ) { + return this.removeArrowPoint(state, this.toolState.handleShapeId, this.toolState.activeHandle); + } + return this.deleteSelectedShapes(state); } @@ -474,17 +498,19 @@ export class SelectTool implements Tool { const end = this.localToWorld(shape, shape.props.b); handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); } else if (shape.type === "arrow") { - // TODO: do away with legacy format - if (shape.props.a && shape.props.b) { - const start = this.localToWorld(shape, shape.props.a); - const end = this.localToWorld(shape, shape.props.b); - handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); - } else if (shape.props.points && shape.props.points.length >= 2) { - const firstPoint = shape.props.points[0]; - const lastPoint = shape.props.points[shape.props.points.length - 1]; - const start = this.localToWorld(shape, firstPoint); - const end = this.localToWorld(shape, lastPoint); - handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); + if (shape.props.points && shape.props.points.length >= 2) { + for (let i = 0; i < shape.props.points.length; i++) { + const point = shape.props.points[i]; + const worldPos = this.localToWorld(shape, point); + + if (i === 0) { + handles.push({ id: "line-start", position: worldPos }); + } else if (i === shape.props.points.length - 1) { + handles.push({ id: "line-end", position: worldPos }); + } else { + handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); + } + } } } return handles; @@ -557,26 +583,43 @@ export class SelectTool implements Tool { return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; } - private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: "line-start" | "line-end"): ShapeRecord | null { + private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: HandleKind): ShapeRecord | null { if (initial.type !== "line" && initial.type !== "arrow") { return null; } + if (initial.type === "arrow" && typeof handle === "string" && handle.startsWith("arrow-point-")) { + const pointIndex = Number.parseInt(handle.replace("arrow-point-", ""), 10); + if (!initial.props.points || pointIndex < 1 || pointIndex >= initial.props.points.length - 1) { + return null; + } + + const newPoints = initial.props.points.map((p, i) => { + if (i === pointIndex) { + return { x: pointer.x - initial.x, y: pointer.y - initial.y }; + } + return p; + }); + + const newProps = { ...initial.props, points: newPoints }; + return { ...initial, props: newProps }; + } + + if (handle !== "line-start" && handle !== "line-end") { + return null; + } + let startPoint: Vec2, endPoint: Vec2; if (initial.type === "line") { startPoint = initial.props.a; endPoint = initial.props.b; } else { - if (initial.props.a && initial.props.b) { - startPoint = initial.props.a; - endPoint = initial.props.b; - } else if (initial.props.points && initial.props.points.length >= 2) { - startPoint = initial.props.points[0]; - endPoint = initial.props.points[initial.props.points.length - 1]; - } else { + if (!initial.props.points || initial.props.points.length < 2) { return null; } + startPoint = initial.props.points[0]; + endPoint = initial.props.points[initial.props.points.length - 1]; } const startWorld = this.localToWorld(initial, startPoint); @@ -592,11 +635,18 @@ export class SelectTool implements Tool { }; return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; } else { - const newProps = { - ...initial.props, - a: { x: 0, y: 0 }, - b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, - }; + const newPoints = initial.props.points.map((p, i) => { + if (i === 0) { + return { x: 0, y: 0 }; + } else if (i === initial.props.points.length - 1) { + return { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }; + } else { + const worldPos = this.localToWorld(initial, p); + return { x: worldPos.x - newStart.x, y: worldPos.y - newStart.y }; + } + }); + + const newProps = { ...initial.props, points: newPoints }; return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; } } @@ -625,6 +675,70 @@ export class SelectTool implements Tool { return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; } + /** + * Remove an intermediate point from an arrow + */ + private removeArrowPoint(state: EditorState, arrowId: string, handle: HandleKind): EditorState { + const arrow = state.doc.shapes[arrowId]; + if (!arrow || arrow.type !== "arrow" || !arrow.props.points) { + return state; + } + + const pointIndex = Number.parseInt((handle as string).replace("arrow-point-", ""), 10); + if (Number.isNaN(pointIndex) || pointIndex < 1 || pointIndex >= arrow.props.points.length - 1) { + return state; + } + + const newPoints = arrow.props.points.filter((_, i) => i !== pointIndex); + + if (newPoints.length < 2) { + return state; + } + + const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; + + this.resetToolState(); + + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } } }; + } + + /** + * Try to add a point to an arrow segment at the clicked location + * Returns updated state if successful, null otherwise + */ + private tryAddPointToArrowSegment(state: EditorState, arrow: ShapeRecord, clickWorld: Vec2): EditorState | null { + if (arrow.type !== "arrow" || !arrow.props.points || arrow.props.points.length < 2) { + return null; + } + + const clickLocal = { x: clickWorld.x - arrow.x, y: clickWorld.y - arrow.y }; + const tolerance = 10; + + for (let i = 0; i < arrow.props.points.length - 1; i++) { + const a = arrow.props.points[i]; + const b = arrow.props.points[i + 1]; + + const ab = Vec2Ops.sub(b, a); + const ap = Vec2Ops.sub(clickLocal, a); + const abLengthSq = Vec2Ops.lenSq(ab); + + if (abLengthSq === 0) continue; + + const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / abLengthSq)); + const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); + const distance = Vec2Ops.dist(clickLocal, projection); + + if (distance <= tolerance) { + const newPoints = [...arrow.props.points.slice(0, i + 1), clickLocal, ...arrow.props.points.slice(i + 1)]; + + const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrow.id]: updatedArrow } } }; + } + } + + return null; + } + /** * Update arrow bindings when an endpoint is dragged * diff --git a/packages/core/src/tools/shape.ts b/packages/core/src/tools/shape.ts index 02f2c18..153313e 100644 --- a/packages/core/src/tools/shape.ts +++ b/packages/core/src/tools/shape.ts @@ -558,10 +558,11 @@ export class ArrowTool implements Tool { const shapeId = createId("shape"); const shape = ShapeRecord.createArrow(currentPage.id, action.world.x, action.world.y, { - a: { x: 0, y: 0 }, - b: { x: 0, y: 0 }, - stroke: "#495057", - width: 2, + points: [{ x: 0, y: 0 }, { x: 0, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#495057", width: 2, headEnd: true }, + routing: { kind: "straight" }, }, shapeId); this.toolState.isCreating = true; @@ -589,7 +590,8 @@ export class ArrowTool implements Tool { if (!shape || shape.type !== "arrow") return state; const b = Vec2.sub(action.world, this.toolState.startWorld); - const updatedShape = { ...shape, props: { ...shape.props, b } }; + const updatedPoints = [{ x: 0, y: 0 }, b]; + const updatedShape = { ...shape, props: { ...shape.props, points: updatedPoints } }; return { ...state, @@ -605,15 +607,14 @@ export class ArrowTool implements Tool { let newState = state; - let endPoint: Vec2; - if (shape.props.b) { - endPoint = shape.props.b; - } else if (shape.props.points && shape.props.points.length >= 2) { - endPoint = shape.props.points[shape.props.points.length - 1]; - } else { - endPoint = { x: 0, y: 0 }; + const points = shape.props.points; + if (!points || points.length < 2) { + newState = this.cancelShapeCreation(state); + this.resetToolState(); + return newState; } + const endPoint = points[points.length - 1]; const arrowLength = Vec2.len(endPoint); if (arrowLength < MIN_SHAPE_SIZE) { newState = this.cancelShapeCreation(state); @@ -632,21 +633,17 @@ export class ArrowTool implements Tool { const arrow = state.doc.shapes[arrowId]; if (!arrow || arrow.type !== "arrow") return state; - let startPoint: Vec2, endPoint: Vec2; - if (arrow.props.a && arrow.props.b) { - startPoint = arrow.props.a; - endPoint = arrow.props.b; - } else if (arrow.props.points && arrow.props.points.length >= 2) { - startPoint = arrow.props.points[0]; - endPoint = arrow.props.points[arrow.props.points.length - 1]; - } else { - return state; - } + const points = arrow.props.points; + if (!points || points.length < 2) return state; + + const startPoint = points[0]; + const endPoint = points[points.length - 1]; const startWorld = { x: arrow.x + startPoint.x, y: arrow.y + startPoint.y }; const endWorld = { x: arrow.x + endPoint.x, y: arrow.y + endPoint.y }; const newBindings = { ...state.doc.bindings }; + let updatedArrow = arrow; const stateWithoutArrow = { ...state, @@ -667,6 +664,10 @@ export class ArrowTool implements Tool { ny: anchor.ny, }); newBindings[binding.id] = binding; + updatedArrow = { + ...updatedArrow, + props: { ...updatedArrow.props, start: { kind: "bound", bindingId: binding.id } }, + }; } } @@ -677,10 +678,17 @@ export class ArrowTool implements Tool { const anchor = computeNormalizedAnchor(endWorld, targetShape); const binding = BindingRecord.create(arrowId, endHitId, "end", { kind: "edge", nx: anchor.nx, ny: anchor.ny }); newBindings[binding.id] = binding; + updatedArrow = { + ...updatedArrow, + props: { ...updatedArrow.props, end: { kind: "bound", bindingId: binding.id } }, + }; } } - return { ...state, doc: { ...state.doc, bindings: newBindings } }; + return { + ...state, + doc: { ...state.doc, bindings: newBindings, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } }, + }; } private handleKeyDown(state: EditorState, action: Action): EditorState { diff --git a/packages/core/tests/arrow-multipoint.test.ts b/packages/core/tests/arrow-multipoint.test.ts new file mode 100644 index 0000000..1acd4c6 --- /dev/null +++ b/packages/core/tests/arrow-multipoint.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it } from "vitest"; +import { Action } from "../src/actions"; +import { BindingRecord, ShapeRecord } from "../src/model"; +import { EditorState } from "../src/reactivity"; +import { SelectTool } from "../src/tools/select"; + +describe("Arrow multi-point editing", () => { + describe("Dragging intermediate points", () => { + it("should allow dragging an intermediate point", () => { + let state = EditorState.create(); + + // Create a page and an arrow with 3 points (including an intermediate point) + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { + points: [ + { x: 0, y: 0 }, + { x: 50, y: 50 }, + { x: 100, y: 0 }, + ], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, headEnd: true }, + }); + + state = { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, + }, + ui: { ...state.ui, selectionIds: [arrow.id] }, + }; + + store.setState(state); + + const tool = new SelectTool(); + tool.onEnter(state); + + // Pointer down on the intermediate point (index 1) + const intermediateWorldPos = { x: 150, y: 150 }; // arrow.x + points[1].x, arrow.y + points[1].y + const pointerDown = Action.pointerDown( + { x: 0, y: 0 }, + intermediateWorldPos, + 0, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 0, + ); + state = tool.onAction(state, pointerDown); + + // Drag the point to a new location + const newWorldPos = { x: 180, y: 160 }; + const pointerMove = Action.pointerMove( + { x: 0, y: 0 }, + newWorldPos, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 100, + ); + state = tool.onAction(state, pointerMove); + + const pointerUp = Action.pointerUp( + { x: 0, y: 0 }, + newWorldPos, + 0, + { left: false, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 200, + ); + state = tool.onAction(state, pointerUp); + + const updatedArrow = state.doc.shapes[arrow.id]; + expect(updatedArrow.type).toBe("arrow"); + if (updatedArrow.type === "arrow") { + expect(updatedArrow.props.points.length).toBe(3); + // The intermediate point should be updated + expect(updatedArrow.props.points[1].x).toBe(80); // newWorldPos.x - arrow.x + expect(updatedArrow.props.points[1].y).toBe(60); // newWorldPos.y - arrow.y + // Start and end points should remain unchanged + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); + expect(updatedArrow.props.points[2]).toEqual({ x: 100, y: 0 }); + } + }); + + it("should preserve bindings when dragging intermediate points", () => { + const store = Store.create(); + let state = store.getState(); + + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; + + // Create a target shape + const targetRect = ShapeRecord.createRect(page.id, 300, 100, { + w: 100, + h: 100, + fill: "#fff", + stroke: "#000", + radius: 0, + }); + + // Create an arrow with a binding + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 50 }, + { x: 200, y: 0 }, + ], + start: { kind: "free" }, + end: { kind: "bound", bindingId: "binding-1" }, + style: { stroke: "#000", width: 2, headEnd: true }, + }); + + const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding-1"); + + state = { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [arrow.id]: arrow, [targetRect.id]: targetRect }, + bindings: { [binding.id]: binding }, + pages: { + ...state.doc.pages, + [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id, targetRect.id] }, + }, + }, + ui: { ...state.ui, selectionIds: [arrow.id] }, + }; + + store.setState(state); + + const tool = new SelectTool(); + tool.onEnter(state); + + // Drag the intermediate point + const intermediateWorldPos = { x: 200, y: 150 }; + const pointerDown = Action.pointerDown( + { x: 0, y: 0 }, + intermediateWorldPos, + 0, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 0, + ); + state = tool.onAction(state, pointerDown); + + const newWorldPos = { x: 220, y: 180 }; + const pointerMove = Action.pointerMove( + { x: 0, y: 0 }, + newWorldPos, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 100, + ); + state = tool.onAction(state, pointerMove); + + const pointerUp = Action.pointerUp( + { x: 0, y: 0 }, + newWorldPos, + 0, + { left: false, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 200, + ); + state = tool.onAction(state, pointerUp); + + // Binding should still exist + expect(state.doc.bindings[binding.id]).toBeDefined(); + expect(state.doc.bindings[binding.id].toShapeId).toBe(targetRect.id); + }); + }); + + describe("Adding points with Alt+click", () => { + it("should add a point when Alt+clicking on a segment", () => { + const store = Store.create(); + let state = store.getState(); + + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, headEnd: true }, + }); + + state = { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, + }, + ui: { ...state.ui, selectionIds: [arrow.id] }, + }; + + store.setState(state); + + const tool = new SelectTool(); + tool.onEnter(state); + + // Alt+click in the middle of the line + const clickWorld = { x: 150, y: 100 }; // Midpoint of the line + const pointerDown = Action.pointerDown( + { x: 0, y: 0 }, + clickWorld, + 0, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: true, meta: false }, + 0, + ); + state = tool.onAction(state, pointerDown); + + const updatedArrow = state.doc.shapes[arrow.id]; + expect(updatedArrow.type).toBe("arrow"); + if (updatedArrow.type === "arrow") { + expect(updatedArrow.props.points.length).toBe(3); + // New point should be inserted between the start and end + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); + expect(updatedArrow.props.points[1].x).toBeCloseTo(50, 0); + expect(updatedArrow.props.points[1].y).toBeCloseTo(0, 0); + expect(updatedArrow.props.points[2]).toEqual({ x: 100, y: 0 }); + } + }); + + it("should not add a point when Alt+clicking far from any segment", () => { + const store = Store.create(); + let state = store.getState(); + + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, headEnd: true }, + }); + + state = { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, + }, + ui: { ...state.ui, selectionIds: [arrow.id] }, + }; + + store.setState(state); + + const tool = new SelectTool(); + tool.onEnter(state); + + // Alt+click far away from the line + const clickWorld = { x: 150, y: 200 }; // Far from the horizontal line + const pointerDown = Action.pointerDown( + { x: 0, y: 0 }, + clickWorld, + 0, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: true, meta: false }, + 0, + ); + state = tool.onAction(state, pointerDown); + + const updatedArrow = state.doc.shapes[arrow.id]; + expect(updatedArrow.type).toBe("arrow"); + if (updatedArrow.type === "arrow") { + // Should still have 2 points (no point added) + expect(updatedArrow.props.points.length).toBe(2); + } + }); + }); + + describe("Removing points with Delete/Backspace", () => { + it("should remove an intermediate point when Delete is pressed while dragging", () => { + const store = Store.create(); + let state = store.getState(); + + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { + points: [ + { x: 0, y: 0 }, + { x: 50, y: 50 }, + { x: 100, y: 0 }, + ], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, headEnd: true }, + }); + + state = { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, + }, + ui: { ...state.ui, selectionIds: [arrow.id] }, + }; + + store.setState(state); + + const tool = new SelectTool(); + tool.onEnter(state); + + // Start dragging the intermediate point + const intermediateWorldPos = { x: 150, y: 150 }; + const pointerDown = Action.pointerDown( + { x: 0, y: 0 }, + intermediateWorldPos, + 0, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 0, + ); + state = tool.onAction(state, pointerDown); + + // Press Delete while dragging + const keyDown = Action.keyDown("Delete", { ctrl: false, shift: false, alt: false, meta: false }, 100); + state = tool.onAction(state, keyDown); + + const updatedArrow = state.doc.shapes[arrow.id]; + expect(updatedArrow.type).toBe("arrow"); + if (updatedArrow.type === "arrow") { + // Should now have 2 points (intermediate point removed) + expect(updatedArrow.props.points.length).toBe(2); + expect(updatedArrow.props.points[0]).toEqual({ x: 0, y: 0 }); + expect(updatedArrow.props.points[1]).toEqual({ x: 100, y: 0 }); + } + }); + + it("should not remove points if it would leave less than 2 points", () => { + const store = Store.create(); + let state = store.getState(); + + const page = state.doc.pages[Object.keys(state.doc.pages)[0]]; + const arrow = ShapeRecord.createArrow(page.id, 100, 100, { + points: [ + { x: 0, y: 0 }, + { x: 50, y: 50 }, + { x: 100, y: 0 }, + ], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2, headEnd: true }, + }); + + state = { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [arrow.id]: arrow }, + pages: { ...state.doc.pages, [page.id]: { ...page, shapeIds: [...page.shapeIds, arrow.id] } }, + }, + ui: { ...state.ui, selectionIds: [arrow.id] }, + }; + + store.setState(state); + + const tool = new SelectTool(); + tool.onEnter(state); + + // Remove one intermediate point (should work) + const intermediateWorldPos = { x: 150, y: 150 }; + const pointerDown = Action.pointerDown( + { x: 0, y: 0 }, + intermediateWorldPos, + 0, + { left: true, middle: false, right: false }, + { ctrl: false, shift: false, alt: false, meta: false }, + 0, + ); + state = tool.onAction(state, pointerDown); + + const keyDown = Action.keyDown("Delete", { ctrl: false, shift: false, alt: false, meta: false }, 100); + state = tool.onAction(state, keyDown); + + let updatedArrow = state.doc.shapes[arrow.id]; + expect(updatedArrow.type).toBe("arrow"); + if (updatedArrow.type === "arrow") { + expect(updatedArrow.props.points.length).toBe(2); + } + + // Now we have only 2 points. Trying to remove any would be invalid. + // (In the current implementation, you can only remove intermediate points, + // and with only 2 points there are no intermediate points to remove) + }); + }); +}); diff --git a/packages/core/tests/export.test.ts b/packages/core/tests/export.test.ts index 463f20d..3f09776 100644 --- a/packages/core/tests/export.test.ts +++ b/packages/core/tests/export.test.ts @@ -81,10 +81,11 @@ describe("exportToSVG", () => { const { state, pageId } = createTestState(); const arrow = ShapeRecord.createArrow(pageId, 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "black", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "black", width: 2, headEnd: true }, + routing: { kind: "straight" }, }); state.doc.shapes[arrow.id] = arrow; diff --git a/packages/core/tests/geom.test.ts b/packages/core/tests/geom.test.ts index 1c8ed20..aae92b9 100644 --- a/packages/core/tests/geom.test.ts +++ b/packages/core/tests/geom.test.ts @@ -64,10 +64,10 @@ describe("Geometry", () => { it("should return correct bounds for arrow", () => { const arrow = ShapeRecord.createArrow("page:1", 20, 30, { - a: { x: 10, y: 10 }, - b: { x: 50, y: 60 }, - stroke: "", - width: 2, + points: [{ x: 10, y: 10 }, { x: 50, y: 60 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }); const bounds = shapeBounds(arrow); @@ -582,10 +582,10 @@ describe("Geometry", () => { const store = new Store(); const page = PageRecord.create("Page 1", "page:1"); const arrow = ShapeRecord.createArrow("page:1", 100, 100, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000000", width: 2 }, }, "shape:1"); store.setState((state) => ({ @@ -732,10 +732,10 @@ describe("Geometry", () => { it("should return center of arrow shape", () => { const arrow = ShapeRecord.createArrow("page:1", 50, 50, { - a: { x: -50, y: -50 }, - b: { x: 50, y: 50 }, - stroke: "", - width: 2, + points: [{ x: -50, y: -50 }, { x: 50, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }); const center = shapeCenter(arrow); @@ -758,10 +758,10 @@ describe("Geometry", () => { const store = new Store(); const page = PageRecord.create("Page 1", "page:1"); const arrow = ShapeRecord.createArrow("page:1", 100, 100, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 50 }, - stroke: "", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }, "arrow:1"); store.setState((state) => ({ @@ -787,10 +787,10 @@ describe("Geometry", () => { "rect:1", ); const arrow = ShapeRecord.createArrow("page:1", 300, 300, { - a: { x: -150, y: -150 }, - b: { x: 100, y: 100 }, - stroke: "", - width: 2, + points: [{ x: -150, y: -150 }, { x: 100, y: 100 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }, "arrow:1"); const binding = BindingRecord.create(arrow.id, targetRect.id, "start", { kind: "center" }, "binding:1"); @@ -823,10 +823,10 @@ describe("Geometry", () => { "rect:1", ); const arrow = ShapeRecord.createArrow("page:1", 50, 50, { - a: { x: 0, y: 0 }, - b: { x: 200, y: 200 }, - stroke: "", - width: 2, + points: [{ x: 0, y: 0 }, { x: 200, y: 200 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }, "arrow:1"); const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding:1"); @@ -865,10 +865,10 @@ describe("Geometry", () => { "rect:2", ); const arrow = ShapeRecord.createArrow("page:1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 100 }, - stroke: "", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }, "arrow:1"); const binding1 = BindingRecord.create(arrow.id, rect1.id, "start", { kind: "center" }, "binding:1"); @@ -895,10 +895,10 @@ describe("Geometry", () => { const store = new Store(); const page = PageRecord.create("Page 1", "page:1"); const arrow = ShapeRecord.createArrow("page:1", 100, 100, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 50 }, - stroke: "", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }, "arrow:1"); const binding = BindingRecord.create(arrow.id, "nonexistent:1", "start", { kind: "center" }, "binding:1"); @@ -962,10 +962,10 @@ describe("Geometry", () => { "rect:1", ); const arrow = ShapeRecord.createArrow("page:1", 50, 50, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 100 }, - stroke: "", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "", width: 2 }, }, "arrow:1"); const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "center" }, "binding:1"); @@ -1008,10 +1008,10 @@ describe("Geometry", () => { "rect:1", ); const arrow = ShapeRecord.createArrow(page.id, 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 100 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow:1"); const binding = BindingRecord.create(arrow.id, targetRect.id, "end", { kind: "edge", nx: 1, ny: 0 }); diff --git a/packages/core/tests/model.test.ts b/packages/core/tests/model.test.ts index 7312601..0e25000 100644 --- a/packages/core/tests/model.test.ts +++ b/packages/core/tests/model.test.ts @@ -173,15 +173,6 @@ describe("ShapeRecord", () => { }); describe("createArrow", () => { - it("should create an arrow shape with legacy format", () => { - const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; - const shape = ShapeRecord.createArrow(pageId, 10, 20, props); - - expect(shape.id).toMatch(/^shape:/); - expect(shape.type).toBe("arrow"); - expect(shape.props).toEqual(props); - }); - it("should create an arrow with modern format (points only)", () => { const props: ArrowProps = { points: [{ x: 0, y: 0 }, { x: 100, y: 50 }], @@ -363,20 +354,6 @@ describe("ShapeRecord", () => { expect(cloned.props).not.toBe(shape.props); }); - it("should clone legacy arrow shape", () => { - const props: ArrowProps = { a: { x: 0, y: 0 }, b: { x: 100, y: 50 }, stroke: "#000", width: 2 }; - const shape = ShapeRecord.createArrow(pageId, 0, 0, props); - - const cloned = ShapeRecord.clone(shape); - - expect(cloned).toEqual(shape); - expect(cloned.props).not.toBe(shape.props); - if (cloned.type === "arrow" && shape.type === "arrow") { - expect(cloned.props.a).not.toBe(shape.props.a); - expect(cloned.props.b).not.toBe(shape.props.b); - } - }); - it("should clone modern arrow shape with points", () => { const props: ArrowProps = { points: [{ x: 0, y: 0 }, { x: 50, y: 25 }, { x: 100, y: 50 }], @@ -687,10 +664,10 @@ describe("validateDoc", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); const arrow = ShapeRecord.createArrow("page1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow1"); const rect = ShapeRecord.createRect( "page1", @@ -864,10 +841,10 @@ describe("validateDoc", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); const arrow = ShapeRecord.createArrow("page1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow1"); const binding = BindingRecord.create("arrow1", "nonexistent", "end", { kind: "center" }, "binding1"); @@ -1105,10 +1082,10 @@ describe("validateDoc", () => { } }); - it("should reject arrow with neither legacy nor modern format", () => { + it("should reject arrow with missing required fields", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); - const shape = ShapeRecord.createArrow("page1", 0, 0, {}, "arrow1"); + const shape = ShapeRecord.createArrow("page1", 0, 0, {} as any, "arrow1"); page.shapeIds = ["arrow1"]; doc.pages = { page1: page }; @@ -1117,9 +1094,7 @@ describe("validateDoc", () => { const result = validateDoc(doc); expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors).toContain("Arrow shape 'arrow1' missing both legacy (a, b) and modern (points) format"); - } + // Arrow is invalid because it has no points or style }); it("should reject arrow with too few points in modern format", () => { @@ -1216,10 +1191,10 @@ describe("validateDoc", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); const arrow = ShapeRecord.createArrow("page1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow1"); const rect = ShapeRecord.createRect( "page1", @@ -1247,10 +1222,10 @@ describe("validateDoc", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); const arrow = ShapeRecord.createArrow("page1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow1"); const rect = ShapeRecord.createRect( "page1", @@ -1433,10 +1408,10 @@ describe("JSON serialization", () => { width: 2, }, "shape3"); const arrow = ShapeRecord.createArrow("page1", 300, 300, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "shape4"); const text = ShapeRecord.createText("page1", 400, 400, { text: "Hello World", @@ -1461,10 +1436,10 @@ describe("JSON serialization", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); const arrow = ShapeRecord.createArrow("page1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow1"); const rect = ShapeRecord.createRect( "page1", @@ -1507,10 +1482,10 @@ describe("JSON serialization", () => { "shape2", ); const shape3 = ShapeRecord.createArrow("page2", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "shape3"); const shape4 = ShapeRecord.createRect( "page2", @@ -1601,10 +1576,10 @@ describe("JSON serialization", () => { const doc = Document.create(); const page = PageRecord.create("Page 1", "page1"); const arrow = ShapeRecord.createArrow("page1", 0, 0, { - a: { x: 0, y: 0 }, - b: { x: 100, y: 0 }, - stroke: "#000", - width: 2, + points: [{ x: 0, y: 0 }, { x: 100, y: 0 }], + start: { kind: "free" }, + end: { kind: "free" }, + style: { stroke: "#000", width: 2 }, }, "arrow1"); const rect = ShapeRecord.createRect( "page1", diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index e2b4b55..a434ffa 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -13,7 +13,13 @@ import type { Vec2, Viewport, } from "inkfinite-core"; -import { computeOutline, getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; +import { + computeOrthogonalPath, + computeOutline, + getShapesOnCurrentPage, + resolveArrowEndpoints, + shapeBounds, +} from "inkfinite-core"; export interface Renderer { /** @@ -416,10 +422,7 @@ function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { * Draw an arrow shape */ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: ArrowShape) { - const legacyStroke = shape.props.stroke; - const legacyWidth = shape.props.width; - const modernStyle = shape.props.style; - const style = modernStyle ?? { stroke: legacyStroke ?? "#000", width: legacyWidth ?? 2 }; + const style = shape.props.style; const resolved = resolveArrowEndpoints(state, shape.id); if (!resolved) return; @@ -428,15 +431,16 @@ function drawArrow(context: CanvasRenderingContext2D, state: EditorState, shape: const b = { x: resolved.b.x - shape.x, y: resolved.b.y - shape.y }; let points: Vec2[]; - const modernPoints = shape.props.points; - if (modernPoints && modernPoints.length >= 2) { - points = modernPoints.map((p: Vec2, index: number) => { + + // Use orthogonal routing if specified + if (shape.props.routing?.kind === "orthogonal") { + points = computeOrthogonalPath(a, b); + } else { + points = shape.props.points.map((p: Vec2, index: number) => { if (index === 0) return a; - if (index === modernPoints.length - 1) return b; + if (index === shape.props.points.length - 1) return b; return p; }); - } else { - points = [a, b]; } context.beginPath(); @@ -849,8 +853,18 @@ function getHandlesForShape(state: EditorState, shape: ShapeRecord): HandleVisua if (shape.type === "arrow") { const resolved = resolveArrowEndpoints(state, shape.id); - if (resolved) { - handles.push({ id: "line-start", position: resolved.a }, { id: "line-end", position: resolved.b }); + if (resolved && shape.props.points && shape.props.points.length >= 2) { + // Show handles for all points + handles.push({ id: "line-start", position: resolved.a }); + + // Add intermediate point handles + for (let i = 1; i < shape.props.points.length - 1; i++) { + const point = shape.props.points[i]; + const worldPos = localToWorld(shape, point); + handles.push({ id: `arrow-point-${i}`, position: worldPos }); + } + + handles.push({ id: "line-end", position: resolved.b }); } return handles; }