Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296import { describe, expect, it } from 'vitest';import { Action, Modifiers, PointerButtons } from '../src/actions';import { convertSelectedShapes, duplicateAndConnectSelection, duplicateSelection } from '../src/selection';import { EditorPageRecord, EditorShapeRecord } from '../src/editor-model';import { EditorState } from '../src/reactivity';import { RectTool, SelectTool } from '../src/tools';
const down = PointerButtons.create(true, false, false);function selectionState() { const page = EditorPageRecord.create('Selection page', 'page:selection'); const shape = EditorShapeRecord.createRect(page.id, 0, 0, { w: 40, h: 30, fill: '', stroke: '', radius: 0 }, 'shape:one'); page.shapeIds = [shape.id]; return { ...EditorState.create(), doc: { pages: { [page.id]: page }, shapes: { [shape.id]: shape }, bindings: {} }, ui: { currentPageId: page.id, selectionIds: [shape.id], toolId: 'select' as const } };}
describe('selection and movement refinements', () => { it('duplicates and connects before an Alt+Shift drag', () => { const tool = new SelectTool(); const state = selectionState(); let next = tool.onAction( state, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create(false, true, true)) ); const duplicateId = next.ui.selectionIds[0]; next = tool.onAction( next, Action.pointerMove({ x: 30, y: 20 }, { x: 30, y: 20 }, down, Modifiers.create(false, true, true)) );
expect(next.doc.shapes[duplicateId]).toMatchObject({ x: 20, y: 0 }); expect(Object.values(next.doc.shapes).filter((shape) => shape.type === 'arrow')).toHaveLength(1); });
it('constrains a selection drag to its dominant axis with Shift', () => { const tool = new SelectTool(); const state = selectionState(); let next = tool.onAction( state, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create()) ); next = tool.onAction( next, Action.pointerMove({ x: 40, y: 30 }, { x: 40, y: 30 }, down, Modifiers.create(false, true)) );
expect(next.doc.shapes['shape:one']).toMatchObject({ x: 30, y: 0 }); });
it('duplicates a selected shape before an Alt-drag', () => { const tool = new SelectTool(); const state = selectionState(); let next = tool.onAction( state, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create(false, false, true)) ); expect(Object.keys(next.doc.shapes)).toHaveLength(2); next = tool.onAction( next, Action.pointerMove({ x: 30, y: 20 }, { x: 30, y: 20 }, down, Modifiers.create(false, false, true)) ); const duplicateId = next.ui.selectionIds[0]; expect(next.doc.shapes[duplicateId]).toMatchObject({ x: 20, y: 10 }); expect(next.doc.shapes['shape:one']).toMatchObject({ x: 0, y: 0 }); });
it('cycles through overlapping shapes on repeated clicks at one point', () => { const page = EditorPageRecord.create('Overlap page', 'page:overlap'); const back = EditorShapeRecord.createRect( page.id, 0, 0, { w: 40, h: 30, fill: '', stroke: '', radius: 0 }, 'shape:back' ); const front = EditorShapeRecord.createRect( page.id, 0, 0, { w: 40, h: 30, fill: '', stroke: '', radius: 0 }, 'shape:front' ); page.shapeIds = [back.id, front.id]; const state = { ...EditorState.create(), doc: { pages: { [page.id]: page }, shapes: { [back.id]: back, [front.id]: front }, bindings: {} }, ui: { currentPageId: page.id, selectionIds: [], toolId: 'select' as const } }; const tool = new SelectTool(); const click = (nextState: EditorState) => { const downState = tool.onAction( nextState, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create()) ); return tool.onAction( downState, Action.pointerUp({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, PointerButtons.create(), Modifiers.create()) ); };
const first = click(state); const dragTool = new SelectTool(); const dragStart = dragTool.onAction( first, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create()) ); const dragged = dragTool.onAction( dragStart, Action.pointerMove({ x: 20, y: 10 }, { x: 20, y: 10 }, down, Modifiers.create()) ); const dragEnd = dragTool.onAction( dragged, Action.pointerUp({ x: 20, y: 10 }, { x: 20, y: 10 }, 0, PointerButtons.create(), Modifiers.create()) ); expect(dragEnd.doc.shapes['shape:front']).toMatchObject({ x: 10, y: 0 }); expect(dragEnd.doc.shapes['shape:back']).toMatchObject({ x: 0, y: 0 });
const second = click(first); const third = click(second); expect(first.ui.selectionIds).toEqual(['shape:front']); expect(second.ui.selectionIds).toEqual(['shape:back']); expect(third.ui.selectionIds).toEqual(['shape:front']); });
it('cycles independently inside a nested selection scope', () => { const page = EditorPageRecord.create('Nested page', 'page:nested'); const frame = EditorShapeRecord.createContainer( page.id, 0, 0, { w: 50, h: 50, fill: '', stroke: '', radius: 0 }, 'shape:frame' ); const back = EditorShapeRecord.createRect( page.id, 0, 0, { w: 40, h: 30, fill: '', stroke: '', radius: 0 }, 'shape:nested-back' ); const front = EditorShapeRecord.createRect( page.id, 0, 0, { w: 40, h: 30, fill: '', stroke: '', radius: 0 }, 'shape:nested-front' ); back.groupId = frame.id; front.groupId = frame.id; page.shapeIds = [frame.id, back.id, front.id]; const state = { ...EditorState.create(), doc: { pages: { [page.id]: page }, shapes: { [frame.id]: frame, [back.id]: back, [front.id]: front }, bindings: {} }, ui: { currentPageId: page.id, selectionIds: [], toolId: 'select' as const, containerPath: [frame.id] } }; const tool = new SelectTool(); const click = (nextState: EditorState) => { const downState = tool.onAction( nextState, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create()) ); return tool.onAction( downState, Action.pointerUp({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, PointerButtons.create(), Modifiers.create()) ); };
const first = click(state); const second = click(first); expect(first.ui.selectionIds).toEqual([front.id]); expect(second.ui.selectionIds).toEqual([back.id]); });
it('duplicates selected roots and connects each copy to its source', () => { const state = selectionState(); const next = duplicateAndConnectSelection(state); if (!next) throw new Error('expected a duplicate'); const copiedId = next.ui.selectionIds[0]; const arrows = Object.values(next.doc.shapes).filter((shape) => shape.type === 'arrow'); const arrow = arrows[0]; expect(Object.keys(next.doc.shapes)).toHaveLength(3); expect(next.doc.shapes[copiedId]).toMatchObject({ x: 160, y: 0 }); expect(arrow).toBeDefined(); if (!arrow || arrow.type !== 'arrow') throw new Error('expected a connector'); expect(arrow.props.start.kind).toBe('bound'); expect(arrow.props.end.kind).toBe('bound'); expect(Object.values(next.doc.bindings)).toEqual( expect.arrayContaining([ expect.objectContaining({ fromShapeId: arrow.id, toShapeId: 'shape:one', handle: 'start' }), expect.objectContaining({ fromShapeId: arrow.id, toShapeId: copiedId, handle: 'end' }) ]) ); });
it('preserves semantic metadata through duplication and conversion', () => { const state = selectionState(); const shape = state.doc.shapes['shape:one']; shape.metadata = { name: 'Gateway', title: null, role: 'architecture.service', description: 'Routes requests', body: null, tags: ['api'], source: 'architecture.md', link: null, customMetadata: { owner: 'platform', config: { retries: 2 } }, locked: false, agentEditable: true, provenance: { actorId: 'actor:test', origin: 'human', timestamp: 42, source: 'seed' } }; const duplicated = duplicateSelection(state, { x: 10, y: 10 }); if (!duplicated) throw new Error('expected a duplicate'); const duplicateId = duplicated.ui.selectionIds[0]; expect(duplicated.doc.shapes[duplicateId]?.metadata).toEqual(shape.metadata); expect(duplicated.doc.shapes[duplicateId]?.metadata).not.toBe(shape.metadata); expect(duplicated.doc.shapes[duplicateId]?.metadata?.customMetadata).not.toBe( shape.metadata?.customMetadata ); expect( (duplicated.doc.shapes[duplicateId]?.metadata?.customMetadata.config as { retries: number }) ).not.toBe(shape.metadata?.customMetadata.config);
const next = convertSelectedShapes(state, 'ellipse'); const converted = next.doc.shapes['shape:one']; expect(converted.metadata).toEqual(shape.metadata); });
it('converts a selection while preserving its common shape fields', () => { const state = selectionState(); const next = convertSelectedShapes(state, 'ellipse'); const shape = next.doc.shapes['shape:one']; expect(shape.type).toBe('ellipse'); expect(shape).toMatchObject({ x: 0, y: 0, rot: 0 }); if (shape.type !== 'ellipse') throw new Error('expected ellipse'); expect(shape.props).toEqual({ w: 40, h: 30, fill: '', stroke: '' }); });
it('does not convert shapes that participate in a connector binding', () => { const state = selectionState() as EditorState; const arrow = EditorShapeRecord.createArrow( state.doc.pages['page:selection']!.id, 0, 0, { points: [ { x: 0, y: 0 }, { x: 100, y: 0 } ], start: { kind: 'free' }, end: { kind: 'free' }, style: { stroke: '#000', width: 2 } }, 'shape:arrow' ); state.doc.shapes[arrow.id] = arrow; state.doc.bindings['binding:one'] = { id: 'binding:one', type: 'arrow-end', fromShapeId: arrow.id, toShapeId: 'shape:one', handle: 'end', anchor: { kind: 'center' } }; const next = convertSelectedShapes(state, 'ellipse'); expect(next.doc.shapes['shape:one']?.type).toBe('rect'); });
it('uses Shift and Alt for square centered rectangle creation', () => { const tool = new RectTool(); const page = EditorPageRecord.create('Draw page', 'page:draw'); const state = { ...EditorState.create(), doc: { pages: { [page.id]: page }, shapes: {}, bindings: {} }, ui: { currentPageId: page.id, selectionIds: [], toolId: 'rect' as const } }; let next = tool.onAction( state, Action.pointerDown({ x: 10, y: 10 }, { x: 10, y: 10 }, 0, down, Modifiers.create(false, false, true)) ); next = tool.onAction( next, Action.pointerMove({ x: 20, y: 30 }, { x: 20, y: 30 }, down, Modifiers.create(false, true, true)) ); const shape = next.doc.shapes[next.ui.selectionIds[0]]; expect(shape).toMatchObject({ x: -10, y: -10, props: { w: 40, h: 40 } }); });});