From 1fc812b3710164e928a3c4605cdd0c8834e89316 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 25 Aug 2026 11:40:00 -0500 Subject: [PATCH] refactor: decompose monolithic components --- ROADMAP.md | 8 +- TODO.md | 22 +- packages/core/src/arrow-operations.ts | 142 ++ packages/core/src/index.ts | 2 + packages/core/src/inspector.ts | 294 +++ packages/core/tests/arrow-operations.test.ts | 96 + packages/core/tests/inspector.test.ts | 73 + .../ui/src/lib/editor/canvas/Canvas.svelte | 248 +- .../editor/canvas/clipboard-actions.test.ts | 29 + .../lib/editor/canvas/clipboard-actions.ts | 149 ++ .../lib/editor/canvas/drop-handler.test.ts | 32 + .../ui/src/lib/editor/canvas/drop-handler.ts | 54 + packages/ui/src/lib/editor/commands.ts | 13 + .../lib/editor/components/ArrowPopover.svelte | 165 +- .../lib/editor/components/LayerPanel.svelte | 37 +- .../components/ProposalGhostLayer.svelte | 207 +- .../components/SelectionAppearance.svelte | 103 + .../components/SelectionContainer.svelte | 146 ++ .../components/SelectionControls.svelte | 2204 +---------------- .../editor/components/SelectionEffects.svelte | 54 + .../editor/components/SelectionImage.svelte | 136 + .../components/SelectionMetadata.svelte | 188 ++ .../components/SelectionPathVector.svelte | 130 + .../editor/components/SelectionText.svelte | 68 + .../components/SelectionTransform.svelte | 263 ++ .../src/lib/editor/components/Toolbar.svelte | 115 +- .../editor/components/floating-position.ts | 34 + .../src/lib/editor/components/layer-policy.ts | 26 + .../src/lib/editor/components/model.test.ts | 56 + .../editor/components/proposal-ghost.test.ts | 27 + .../lib/editor/components/proposal-ghost.ts | 182 ++ .../stories/SelectionControls.stories.ts | 25 + .../stories/SelectionMetadata.stories.ts | 17 + .../stories/SelectionPathVector.stories.ts | 19 + .../stories/SelectionTransform.stories.ts | 17 + .../src/lib/editor/components/toolbar-menu.ts | 106 + .../lib/editor/selection-inspector.test.ts | 34 + .../ui/src/lib/editor/selection-inspector.ts | 298 +++ .../editor/stories/editor.stories.fixtures.ts | 89 + .../styles/components/selection-controls.css | 513 ++++ 40 files changed, 3609 insertions(+), 2812 deletions(-) create mode 100644 packages/core/src/arrow-operations.ts create mode 100644 packages/core/src/inspector.ts create mode 100644 packages/core/tests/arrow-operations.test.ts create mode 100644 packages/core/tests/inspector.test.ts create mode 100644 packages/ui/src/lib/editor/canvas/clipboard-actions.test.ts create mode 100644 packages/ui/src/lib/editor/canvas/clipboard-actions.ts create mode 100644 packages/ui/src/lib/editor/canvas/drop-handler.test.ts create mode 100644 packages/ui/src/lib/editor/canvas/drop-handler.ts create mode 100644 packages/ui/src/lib/editor/components/SelectionAppearance.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionContainer.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionEffects.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionImage.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionMetadata.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionPathVector.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionText.svelte create mode 100644 packages/ui/src/lib/editor/components/SelectionTransform.svelte create mode 100644 packages/ui/src/lib/editor/components/floating-position.ts create mode 100644 packages/ui/src/lib/editor/components/layer-policy.ts create mode 100644 packages/ui/src/lib/editor/components/model.test.ts create mode 100644 packages/ui/src/lib/editor/components/proposal-ghost.test.ts create mode 100644 packages/ui/src/lib/editor/components/proposal-ghost.ts create mode 100644 packages/ui/src/lib/editor/components/stories/SelectionControls.stories.ts create mode 100644 packages/ui/src/lib/editor/components/stories/SelectionMetadata.stories.ts create mode 100644 packages/ui/src/lib/editor/components/stories/SelectionPathVector.stories.ts create mode 100644 packages/ui/src/lib/editor/components/stories/SelectionTransform.stories.ts create mode 100644 packages/ui/src/lib/editor/components/toolbar-menu.ts create mode 100644 packages/ui/src/lib/editor/selection-inspector.test.ts create mode 100644 packages/ui/src/lib/editor/selection-inspector.ts create mode 100644 packages/ui/src/lib/styles/components/selection-controls.css diff --git a/ROADMAP.md b/ROADMAP.md index 82dfe78..e6a7e24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,8 +105,12 @@ adapter are independently tested. Keyboard, menu, command-palette, and context-menu entry points use the shared editor document operations rather than runtime-local copies. -Break large inspector components into capability-focused sections without -moving domain behavior into Svelte components. +Large inspectors are split into capability-focused Svelte sections for +appearance, text, vector/path, effects, images, containers, transforms, and +metadata. Selection-derived state and document mutations live in shared editor +operations, while Canvas, proposal previews, floating positioning, and menu +models use focused helpers. StatusBar, ProposalReview, and the remaining small +editor components were reviewed and do not need further splits. ### Rust module organization diff --git a/TODO.md b/TODO.md index bd76c3f..6a4df70 100644 --- a/TODO.md +++ b/TODO.md @@ -119,36 +119,36 @@ ### Decompose large UI inspectors -- [ ] Split `SelectionControls.svelte` into capability-focused inspector +- [x] Split `SelectionControls.svelte` into capability-focused inspector sections for transform, appearance, text, path/vector, effects, image, container/layout, and metadata controls while preserving current inspector behavior -- [ ] Move reusable selection-derived state and mutation helpers out of Svelte +- [x] Move reusable selection-derived state and mutation helpers out of Svelte components and add focused unit tests for the extracted behavior -- [ ] Keep inspector sections driven by shared editor commands rather than +- [x] Keep inspector sections driven by shared editor commands rather than introducing component-specific document mutations -- [ ] Move arrow selection derivation and arrow mutations out of +- [x] Move arrow selection derivation and arrow mutations out of `ArrowPopover.svelte`; expose shared editor operations for routing, labels, styling, arrowheads, and endpoint disconnection, with focused unit tests -- [ ] Continue reducing `Canvas.svelte` to a composition root by extracting +- [x] Continue reducing `Canvas.svelte` to a composition root by extracting clipboard/drop orchestration and replacing remaining direct document mutations such as frame entry and image cropping with shared editor operations -- [ ] Extract proposal preview normalization/projection from +- [x] Extract proposal preview normalization/projection from `ProposalGhostLayer.svelte` into independently tested pure helpers; leave SVG rendering in the component -- [ ] Deduplicate Toolbar/LayerPanel floating-position behavior, starting with +- [x] Deduplicate Toolbar/LayerPanel floating-position behavior, starting with pure clamping and movement helpers; introduce a shared stateful controller only if the resulting interface is simpler for both callers -- [ ] Extract Toolbar import/export menu derivation into a tested menu model +- [x] Extract Toolbar import/export menu derivation into a tested menu model while keeping UI event dispatch in the component -- [ ] Move LayerPanel delete/rehome policy out of the component where it +- [x] Move LayerPanel delete/rehome policy out of the component where it represents editor semantics rather than presentation -- [ ] Record `StatusBar.svelte`, `ProposalReview.svelte`, and the remaining +- [x] Record `StatusBar.svelte`, `ProposalReview.svelte`, and the remaining small editor components as reviewed/no-split unless a new independently testable responsibility emerges -- [ ] Preserve existing component-level regression tests and add unit tests +- [x] Preserve existing component-level regression tests and add unit tests for every extracted behavior module; do not refactor solely to reduce component line counts diff --git a/packages/core/src/arrow-operations.ts b/packages/core/src/arrow-operations.ts new file mode 100644 index 0000000..ffc8c34 --- /dev/null +++ b/packages/core/src/arrow-operations.ts @@ -0,0 +1,142 @@ +/** Pure arrow inspector operations shared by UI entry points. */ +import type { ArrowShape } from './editor-model'; +import type { EditorState } from './reactivity'; + +export type ArrowHandle = 'start' | 'end'; + +export type ArrowInspectorValue = { value: T; mixed: boolean }; +export type ArrowConnectionState = { connected: boolean; mixed: boolean; anyConnected: boolean }; +export type ArrowInspectorState = { + arrows: ArrowShape[]; + routingKind: 'straight' | 'curved' | 'orthogonal' | 'mixed'; + label: ArrowInspectorValue; + strokeWidth: ArrowInspectorValue; + startHead: ArrowInspectorValue; + endHead: ArrowInspectorValue; + startConnection: ArrowConnectionState; + endConnection: ArrowConnectionState; +}; + +function shared(values: T[]): T | null { + if (values.length === 0) return null; + const first = values[0]; + return values.every((value) => Object.is(value, first)) ? first : null; +} + +function booleanState(values: boolean[]): ArrowInspectorValue { + const value = shared(values); + return { value: value ?? false, mixed: values.length > 1 && value === null }; +} + +function connectionState(arrows: ArrowShape[], handle: ArrowHandle): ArrowConnectionState { + const values = arrows.map((arrow) => arrow.props[handle].kind === 'bound'); + const value = shared(values); + return { + connected: value === true, + mixed: values.length > 1 && value === null, + anyConnected: values.some(Boolean) + }; +} + +/** Derives all values shown by the arrow inspector. */ +export function getArrowInspectorState(state: EditorState): ArrowInspectorState { + const arrows = selectedArrows(state); + const routing = shared(arrows.map((arrow) => arrow.props.routing?.kind ?? 'straight')); + const label = shared(arrows.map((arrow) => arrow.props.label?.text ?? '')); + const width = shared(arrows.map((arrow) => arrow.props.style.width)); + return { + arrows, + routingKind: routing ?? (arrows.length > 1 ? 'mixed' : 'straight'), + label: { value: label ?? '', mixed: arrows.length > 1 && label === null }, + strokeWidth: { value: width ?? 2, mixed: arrows.length > 1 && width === null }, + startHead: booleanState(arrows.map((arrow) => arrow.props.style.headStart === true)), + endHead: booleanState(arrows.map((arrow) => arrow.props.style.headEnd !== false)), + startConnection: connectionState(arrows, 'start'), + endConnection: connectionState(arrows, 'end') + }; +} + +/** Returns the selected arrow records in selection order. */ +export function selectedArrows(state: EditorState): ArrowShape[] { + return state.ui.selectionIds + .map((id) => state.doc.shapes[id]) + .filter((shape): shape is ArrowShape => shape?.type === 'arrow'); +} + +/** Applies an update to all selected arrows. */ +export function updateSelectedArrows(state: EditorState, update: (arrow: ArrowShape) => ArrowShape): EditorState { + const arrows = selectedArrows(state); + if (arrows.length === 0) return state; + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const arrow of arrows) { + const next = update(arrow); + if (next === arrow) continue; + shapes[arrow.id] = next; + changed = true; + } + return changed ? { ...state, doc: { ...state.doc, shapes } } : state; +} + +/** Sets routing on all selected arrows. */ +export function setSelectedArrowRouting(state: EditorState, kind: 'straight' | 'curved' | 'orthogonal'): EditorState { + return updateSelectedArrows(state, (arrow) => ({ + ...arrow, + props: { ...arrow.props, routing: { ...arrow.props.routing, kind } } + })); +} + +/** Sets stroke width on all selected arrows. */ +export function setSelectedArrowStrokeWidth(state: EditorState, width: number): EditorState { + if (!Number.isFinite(width) || width <= 0) return state; + return updateSelectedArrows(state, (arrow) => ({ + ...arrow, + props: { ...arrow.props, style: { ...arrow.props.style, width } } + })); +} + +/** Sets one arrowhead flag on all selected arrows. */ +export function setSelectedArrowHead(state: EditorState, handle: ArrowHandle, value: boolean): EditorState { + return updateSelectedArrows(state, (arrow) => ({ + ...arrow, + props: { + ...arrow.props, + style: { ...arrow.props.style, [handle === 'start' ? 'headStart' : 'headEnd']: value } + } + })); +} + +/** Sets or clears the label on all selected arrows. */ +export function setSelectedArrowLabel(state: EditorState, text: string): EditorState { + return updateSelectedArrows(state, (arrow) => ({ + ...arrow, + props: { + ...arrow.props, + label: text.trim() + ? { + text, + align: arrow.props.label?.align ?? 'center', + offset: arrow.props.label?.offset ?? 0, + ...(arrow.props.label?.distance === undefined ? {} : { distance: arrow.props.label.distance }) + } + : undefined + } + })); +} + +/** Disconnects one endpoint from all selected arrows and removes its binding. */ +export function disconnectSelectedArrowEndpoints(state: EditorState, handle: ArrowHandle): EditorState { + const arrows = selectedArrows(state); + if (arrows.length === 0) return state; + const shapes = { ...state.doc.shapes }; + const bindings = { ...state.doc.bindings }; + let changed = false; + for (const arrow of arrows) { + const endpoint = arrow.props[handle]; + if (endpoint.kind !== 'bound') continue; + if (endpoint.bindingId) delete bindings[endpoint.bindingId]; + shapes[arrow.id] = { ...arrow, props: { ...arrow.props, [handle]: { kind: 'free' } } }; + changed = true; + } + return changed ? { ...state, doc: { ...state.doc, shapes, bindings } } : state; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9273399..89bc22e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ // and ./persistence so platform concerns remain visible at import sites. export * from './actions'; export * from './arrow-geometry'; +export * from './arrow-operations'; export * from './boolean-paths'; export * from './camera'; export * from './cards'; @@ -11,6 +12,7 @@ export * from './export'; export * from './geom'; export * from './history'; export * from './interchange'; +export * from './inspector'; export * from './layers'; export * from './layout'; export * from './math'; diff --git a/packages/core/src/inspector.ts b/packages/core/src/inspector.ts new file mode 100644 index 0000000..bac9bed --- /dev/null +++ b/packages/core/src/inspector.ts @@ -0,0 +1,294 @@ +/** Pure document operations used by selection inspectors and host menus. */ +import type { EditorShapeRecord, FilterEffect, PaintValue, ShapeMetadata } from './editor-model'; +import type { EditorState } from './reactivity'; + +/** Applies a shape update to the current selection without creating history. */ +export function updateSelectedShapes( + state: EditorState, + update: (shape: EditorShapeRecord) => EditorShapeRecord +): EditorState { + if (state.ui.selectionIds.length === 0) return state; + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const id of state.ui.selectionIds) { + const shape = state.doc.shapes[id]; + if (!shape) continue; + const next = update(shape); + if (next === shape) continue; + shapes[id] = next; + changed = true; + } + return changed ? { ...state, doc: { ...state.doc, shapes } } : state; +} + +/** Returns a shape metadata object with defaults for older editor records. */ +export function metadataForShape(shape: EditorShapeRecord): ShapeMetadata { + return ( + shape.metadata ?? { + name: null, + title: null, + role: null, + description: null, + body: null, + tags: [], + source: null, + link: null, + customMetadata: {}, + locked: shape.locked ?? false, + agentEditable: shape.agentEditable !== false + } + ); +} + +/** Sets fill paint on every selected shape that supports a fill. */ +export function setSelectedFillPaint(state: EditorState, paint: PaintValue): EditorState { + return updateSelectedShapes(state, (shape) => { + switch (shape.type) { + case 'text': + return { ...shape, props: { ...shape.props, color: paint } } as EditorShapeRecord; + case 'rect': + case 'ellipse': + case 'path': + case 'container': + return { ...shape, props: { ...shape.props, fill: paint } } as EditorShapeRecord; + case 'markdown': + return { ...shape, props: { ...shape.props, bg: paint } } as EditorShapeRecord; + default: + return shape; + } + }); +} + +/** Sets stroke paint on every selected shape that supports a stroke. */ +export function setSelectedStrokePaint(state: EditorState, paint: PaintValue): EditorState { + return updateSelectedShapes(state, (shape) => { + switch (shape.type) { + case 'arrow': + return { + ...shape, + props: { ...shape.props, style: { ...shape.props.style, stroke: paint } } + } as EditorShapeRecord; + case 'stroke': + return { + ...shape, + props: { ...shape.props, style: { ...shape.props.style, color: paint } } + } as EditorShapeRecord; + case 'rect': + case 'ellipse': + case 'line': + case 'path': + case 'container': + return { ...shape, props: { ...shape.props, stroke: paint } } as EditorShapeRecord; + case 'markdown': + return { ...shape, props: { ...shape.props, border: paint } } as EditorShapeRecord; + default: + return shape; + } + }); +} + +/** Sets one of the common opacity fields on the current selection. */ +export function setSelectedOpacity( + state: EditorState, + field: 'opacity' | 'fillOpacity' | 'strokeOpacity', + value: number +): EditorState { + const next = Math.min(1, Math.max(0, value)); + return updateSelectedShapes(state, (shape) => ({ ...shape, [field]: next }) as EditorShapeRecord); +} + +/** Sets font size or family on selected text, Markdown, and card text children. */ +export function setSelectedTypography( + state: EditorState, + field: 'fontSize' | 'fontFamily', + value: number | string +): EditorState { + const targetIds = new Set(); + for (const id of state.ui.selectionIds) { + const shape = state.doc.shapes[id]; + if (!shape) continue; + if (shape.type === 'text' || shape.type === 'markdown') targetIds.add(id); + if (shape.type !== 'container' || shape.metadata?.title == null) continue; + for (const child of Object.values(state.doc.shapes)) { + if (child.groupId === shape.id && (child.type === 'text' || child.type === 'markdown')) { + targetIds.add(child.id); + } + } + } + if (targetIds.size === 0) return state; + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const id of targetIds) { + const shape = shapes[id]; + if (!shape || (shape.type !== 'text' && shape.type !== 'markdown')) continue; + shapes[id] = { ...shape, props: { ...shape.props, [field]: value } } as EditorShapeRecord; + changed = true; + } + return changed ? { ...state, doc: { ...state.doc, shapes } } : state; +} + +/** Updates semantic metadata on every selected shape. */ +export function setSelectedMetadata(state: EditorState, fields: Partial): EditorState { + return updateSelectedShapes(state, (shape) => { + const current = metadataForShape(shape); + return { + ...shape, + metadata: { + ...current, + ...fields, + ...(fields.tags ? { tags: [...fields.tags] } : {}), + ...(fields.customMetadata ? { customMetadata: { ...fields.customMetadata } } : {}) + } + } as EditorShapeRecord; + }); +} + +/** Updates a card container and mirrors title/body changes to its text children. */ +export function setSelectedCardFields( + state: EditorState, + fields: Partial> +): EditorState { + const cards = state.ui.selectionIds + .map((id) => state.doc.shapes[id]) + .filter( + (shape): shape is Extract => + shape?.type === 'container' && shape.metadata?.title != null + ); + const card = cards.length === 1 ? cards[0] : undefined; + if (!card || !card.metadata) return state; + const metadata: ShapeMetadata = { + ...card.metadata, + ...fields, + ...(fields.title !== undefined ? { name: fields.title || null, title: fields.title } : {}), + ...(fields.body !== undefined ? { description: fields.body || null, body: fields.body } : {}) + }; + const shapes = { ...state.doc.shapes, [card.id]: { ...card, metadata } }; + for (const shape of Object.values(state.doc.shapes)) { + if (shape.groupId !== card.id) continue; + if (fields.title !== undefined && shape.type === 'text') { + shapes[shape.id] = { ...shape, props: { ...shape.props, text: fields.title ?? '' } }; + } + if (fields.body !== undefined && shape.type === 'markdown') { + shapes[shape.id] = { ...shape, props: { ...shape.props, md: fields.body ?? '' } }; + } + } + return { ...state, doc: { ...state.doc, shapes } }; +} + +/** Updates the editable fields of the selected image. */ +export function setSelectedImageFields( + state: EditorState, + fields: Partial['props']> +): EditorState { + return updateSelectedShapes( + state, + (shape) => + (shape.type === 'image' ? { ...shape, props: { ...shape.props, ...fields } } : shape) as EditorShapeRecord + ); +} + +/** Updates the editable fields of the selected reference. */ +export function setSelectedReferenceFields( + state: EditorState, + fields: Partial['props']> +): EditorState { + return updateSelectedShapes( + state, + (shape) => + (shape.type === 'reference' + ? { ...shape, props: { ...shape.props, ...fields } } + : shape) as EditorShapeRecord + ); +} + +/** Updates one text-on-path setting for selected text. */ +export function setSelectedTextPathField( + state: EditorState, + field: 'align' | 'side' | 'direction', + value: string +): EditorState { + return updateSelectedShapes(state, (shape) => + shape.type === 'text' && shape.props.textPath + ? ({ + ...shape, + props: { ...shape.props, textPath: { ...shape.props.textPath, [field]: value } } + } as EditorShapeRecord) + : shape + ); +} + +/** Removes text-on-path attachments from selected text. */ +export function detachSelectedTextPath(state: EditorState): EditorState { + return updateSelectedShapes( + state, + (shape) => + (shape.type === 'text' + ? { ...shape, props: { ...shape.props, textPath: undefined } } + : shape) as EditorShapeRecord + ); +} + +/** Changes the mask mode on selected shapes that have a mask. */ +export function setSelectedMaskMode(state: EditorState, mode: 'alpha' | 'luminance'): EditorState { + return updateSelectedShapes(state, (shape) => + shape.props.maskEffect + ? ({ + ...shape, + props: { ...shape.props, maskEffect: { ...shape.props.maskEffect, mode } } + } as EditorShapeRecord) + : shape + ); +} + +/** Applies one of the inspector's filter presets to the selection. */ +export function setSelectedFilterPreset(state: EditorState, preset: string): EditorState { + const filter: FilterEffect | undefined = + preset === 'blur' + ? { primitives: [{ type: 'blur', radius: 4 }] } + : preset === 'grayscale' + ? { primitives: [{ type: 'grayscale', amount: 1 }] } + : preset === 'drop_shadow' + ? { + primitives: [ + { type: 'drop_shadow', dx: 3, dy: 3, radius: 4, color: '#000000', opacity: 0.35 } + ] + } + : undefined; + return updateSelectedShapes( + state, + (shape) => ({ ...shape, props: { ...shape.props, filter } }) as EditorShapeRecord + ); +} + +/** Applies a square crop or clears the crop on the single selected image. */ +export function setSelectedImageSquareCrop(state: EditorState, square: boolean): EditorState { + const selected = state.ui.selectionIds.length === 1 ? state.doc.shapes[state.ui.selectionIds[0]] : undefined; + if (!selected || selected.type !== 'image') return state; + const ratio = selected.props.w / Math.max(selected.props.h, 1); + const crop = square + ? ratio > 1 + ? { top: 0, right: (1 - 1 / ratio) / 2, bottom: 0, left: (1 - 1 / ratio) / 2 } + : { top: (1 - ratio) / 2, right: 0, bottom: (1 - ratio) / 2, left: 0 } + : undefined; + return { + ...state, + doc: { + ...state.doc, + shapes: { ...state.doc.shapes, [selected.id]: { ...selected, props: { ...selected.props, crop } } } + } + }; +} + +/** Enters a selected frame in the editor UI without changing the document. */ +export function enterSelectedFrame(state: EditorState, frameId: string): EditorState { + if (state.doc.shapes[frameId]?.type !== 'container') return state; + return { + ...state, + ui: { ...state.ui, containerPath: [...(state.ui.containerPath ?? []), frameId], selectionIds: [] } + }; +} + +/** Returns the first primitive name used by an effect preset selector. */ +export function filterPresetForShape(shape: EditorShapeRecord | undefined): string { + return shape?.props.filter?.primitives[0]?.type ?? 'none'; +} diff --git a/packages/core/tests/arrow-operations.test.ts b/packages/core/tests/arrow-operations.test.ts new file mode 100644 index 0000000..a4b49a9 --- /dev/null +++ b/packages/core/tests/arrow-operations.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + EditorBindingRecord, + EditorPageRecord, + EditorShapeRecord, + EditorState, + disconnectSelectedArrowEndpoints, + getArrowInspectorState, + setSelectedArrowLabel, + setSelectedArrowRouting +} from '../src'; + +describe('arrow inspector operations', () => { + it('derives mixed values and updates selected routing and labels', () => { + const page = EditorPageRecord.create('Arrow test', 'page:arrows'); + const first = EditorShapeRecord.createArrow( + page.id, + 0, + 0, + { + points: [ + { x: 0, y: 0 }, + { x: 40, y: 0 } + ], + start: { kind: 'free' }, + end: { kind: 'free' }, + style: { stroke: '#000', width: 2 } + }, + 'first' + ); + const second = EditorShapeRecord.createArrow( + page.id, + 0, + 20, + { + points: [ + { x: 0, y: 0 }, + { x: 40, y: 0 } + ], + start: { kind: 'free' }, + end: { kind: 'free' }, + style: { stroke: '#000', width: 4 } + }, + 'second' + ); + const state = EditorState.create(); + state.doc.pages[page.id] = { ...page, shapeIds: [first.id, second.id] }; + state.doc.shapes[first.id] = first; + state.doc.shapes[second.id] = second; + state.ui.currentPageId = page.id; + state.ui.selectionIds = [first.id, second.id]; + expect(getArrowInspectorState(state).strokeWidth.mixed).toBe(true); + const routed = setSelectedArrowRouting(state, 'orthogonal'); + const labelled = setSelectedArrowLabel(routed, 'Depends on'); + expect(labelled.doc.shapes.first?.type === 'arrow' ? labelled.doc.shapes.first.props.routing?.kind : null).toBe( + 'orthogonal' + ); + expect(labelled.doc.shapes.second?.type === 'arrow' ? labelled.doc.shapes.second.props.label?.text : null).toBe( + 'Depends on' + ); + }); + + it('disconnects endpoints and removes their binding', () => { + const page = EditorPageRecord.create('Arrow test', 'page:arrows'); + const arrow = EditorShapeRecord.createArrow( + page.id, + 0, + 0, + { + points: [ + { x: 0, y: 0 }, + { x: 40, y: 0 } + ], + start: { kind: 'bound', bindingId: 'binding:start' }, + end: { kind: 'free' }, + style: { stroke: '#000', width: 2 } + }, + 'arrow' + ); + const state = EditorState.create(); + state.doc.pages[page.id] = { ...page, shapeIds: [arrow.id] }; + state.doc.shapes[arrow.id] = arrow; + state.doc.bindings['binding:start'] = EditorBindingRecord.create( + arrow.id, + 'target', + 'start', + undefined, + 'binding:start' + ); + state.ui.currentPageId = page.id; + state.ui.selectionIds = [arrow.id]; + const next = disconnectSelectedArrowEndpoints(state, 'start'); + expect(next.doc.bindings['binding:start']).toBeUndefined(); + expect(next.doc.shapes.arrow?.type === 'arrow' ? next.doc.shapes.arrow.props.start.kind : null).toBe('free'); + }); +}); diff --git a/packages/core/tests/inspector.test.ts b/packages/core/tests/inspector.test.ts new file mode 100644 index 0000000..2992a0c --- /dev/null +++ b/packages/core/tests/inspector.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + EditorPageRecord, + EditorShapeRecord, + cardMetadata, + type EditorShapeRecord as Shape, + EditorState, + enterSelectedFrame, + setSelectedFillPaint, + setSelectedImageSquareCrop, + setSelectedTypography +} from '../src'; + +function stateWithShapes(shapes: Shape[]) { + const page = EditorPageRecord.create('Inspector test', 'page:inspector'); + const state = EditorState.create(); + state.doc.pages[page.id] = { ...page, shapeIds: shapes.map((shape) => shape.id) }; + for (const shape of shapes) state.doc.shapes[shape.id] = shape; + state.ui.currentPageId = page.id; + state.ui.selectionIds = shapes.map((shape) => shape.id); + return state; +} + +describe('selection inspector operations', () => { + it('updates selected paints and leaves an empty selection untouched', () => { + const page = EditorPageRecord.create('Inspector test', 'page:inspector'); + const rect = EditorShapeRecord.createRect( + page.id, + 0, + 0, + { w: 20, h: 20, fill: '#fff', stroke: '#000', radius: 0 }, + 'rect' + ); + const state = stateWithShapes([rect]); + const empty = { ...state, ui: { ...state.ui, selectionIds: [] } }; + expect(setSelectedFillPaint(empty, '#f00')).toBe(empty); + const next = setSelectedFillPaint(state, '#f00'); + expect(next.doc.shapes.rect?.type === 'rect' ? next.doc.shapes.rect.props.fill : null).toBe('#f00'); + }); + + it('updates typography for selected cards and their text children', () => { + const page = EditorPageRecord.create('Inspector test', 'page:inspector'); + const card = EditorShapeRecord.createContainer(page.id, 0, 0, { w: 100, h: 80, title: 'Title' }, 'card'); + card.metadata = cardMetadata({ title: 'Title', body: '' }); + const title = EditorShapeRecord.createText( + page.id, + 8, + 8, + { text: 'Title', fontSize: 16, fontFamily: 'Inter', color: '#000' }, + 'title' + ); + const state = stateWithShapes([card, title]); + state.doc.shapes.title = { ...title, groupId: card.id }; + state.ui.selectionIds = [card.id]; + const next = setSelectedTypography(state, 'fontSize', 22); + expect(next.doc.shapes.title?.type === 'text' ? next.doc.shapes.title.props.fontSize : null).toBe(22); + }); + + it('projects frame entry and square image crop as separate state operations', () => { + const page = EditorPageRecord.create('Inspector test', 'page:inspector'); + const frame = EditorShapeRecord.createContainer(page.id, 0, 0, { w: 100, h: 80 }, 'frame'); + const entered = enterSelectedFrame(stateWithShapes([frame]), frame.id); + expect(entered.ui.containerPath).toEqual([frame.id]); + const image = EditorShapeRecord.createImage(page.id, 0, 0, { w: 200, h: 100, assetId: 'asset' }, 'image'); + const cropped = setSelectedImageSquareCrop(stateWithShapes([image]), true); + expect(cropped.doc.shapes.image?.type === 'image' ? cropped.doc.shapes.image.props.crop : undefined).toEqual({ + top: 0, + right: 0.25, + bottom: 0, + left: 0.25 + }); + }); +}); diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index e1d0705..c2ca173 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -6,20 +6,13 @@ getSelectedShapes, hitTestPoint, selectionTarget, + setSelectedImageSquareCrop, + enterSelectedFrame, stencils } from '@inkfinite/core'; import { untrack } from 'svelte'; import { Button, ContextMenu, Dialog, type ContextMenuEntry } from '../../index'; - import { - copyPngBlob, - copySelection, - copySvgMarkup, - pasteClipboard, - pasteImage, - pasteText, - renderSvgToPng, - readClipboardContent - } from '../clipboard'; + import { createClipboardActions } from './clipboard-actions'; import type { SelectionCommand } from '../commands'; import { executeSelectionCommand, @@ -40,6 +33,7 @@ import type { EditorPlatformAdapter } from '../platform'; import { createCanvasController } from './canvas-store.svelte'; import NavigationControls from './NavigationControls.svelte'; + import { handleCanvasDrop } from './drop-handler'; let { platform: platformAdapter }: { platform: EditorPlatformAdapter } = $props(); @@ -74,89 +68,19 @@ } async function copyCurrentSelection() { - try { - await copySelection(c.store.getState()); - } catch (error) { - reportEditorError(error, 'Clipboard error'); - } + await clipboardActions.copyCurrentSelection(); } async function cutCurrentSelection() { - try { - await copySelection(c.store.getState()); - c.handleAction( - Action.keyDown('Delete', 'Delete', { - ctrl: false, - shift: false, - alt: false, - meta: false - }) - ); - } catch (error) { - reportEditorError(error, 'Clipboard error'); - } + await clipboardActions.cutCurrentSelection(); } async function pasteFromClipboard(options: { inPlace?: boolean; atCursor?: boolean } = {}) { - try { - const content = await readClipboardContent(); - if (!content) - throw new Error('The clipboard is empty or contains unsupported content.'); - const position = options.atCursor ? c.cursorStore.getState().cursorWorld : undefined; - if (content.type === 'native') { - c.commitLayerState( - options.inPlace ? 'Paste in place' : 'Paste', - pasteClipboard(c.store.getState(), content.payload, { - inPlace: options.inPlace, - position: options.inPlace ? undefined : position - }) - ); - } else if (content.type === 'text') { - c.commitLayerState( - 'Paste text', - pasteText(c.store.getState(), content.text, content.markdown, position) - ); - } else if (content.type === 'image') { - c.commitLayerState( - 'Paste image', - await pasteImage(c.store.getState(), content, position) - ); - } else { - await c.importSvgMarkup(content.contents); - } - } catch (error) { - reportEditorError(error, 'Clipboard error'); - } + await clipboardActions.pasteFromClipboard(options); } async function handlePaste(event: ClipboardEvent) { - event.preventDefault(); - try { - const content = await readClipboardContent(event.clipboardData ?? undefined); - if (!content) - throw new Error('The clipboard is empty or contains unsupported content.'); - const position = c.cursorStore.getState().cursorWorld; - if (content.type === 'native') { - c.commitLayerState( - 'Paste', - pasteClipboard(c.store.getState(), content.payload, { position }) - ); - } else if (content.type === 'text') { - c.commitLayerState( - 'Paste text', - pasteText(c.store.getState(), content.text, content.markdown, position) - ); - } else if (content.type === 'image') { - c.commitLayerState( - 'Paste image', - await pasteImage(c.store.getState(), content, position) - ); - } else { - await c.importSvgMarkup(content.contents); - } - } catch (error) { - reportEditorError(error, 'Clipboard error'); - } + await clipboardActions.handlePaste(event); } function announceClipboardStatus(message: string) { @@ -169,26 +93,7 @@ } async function copySvg(selectedOnly: boolean) { - try { - const exported = await c.renderSvg(selectedOnly); - const result = await copySvgMarkup(exported.contents); - if (result === 'rich') { - announceClipboardStatus( - 'SVG copied. It is ready to paste into a vector tool or text editor.' - ); - } else if (result === 'text') { - announceClipboardStatus( - 'SVG copied as plain text. Paste it into a text editor or import it manually into a vector tool.' - ); - } else { - svgClipboardFallbackMarkup = exported.contents; - svgClipboardFallbackMessage = - 'This browser could not access the clipboard. Select the markup below and copy it manually.'; - svgClipboardFallbackOpen = true; - } - } catch (error) { - reportEditorError(error, 'Clipboard error'); - } + await clipboardActions.copySvg(selectedOnly); } function closeSvgClipboardFallback() { @@ -203,21 +108,11 @@ } async function copyPng(selectedOnly: boolean, transparentBackground = false) { - try { - const exported = await c.renderSvg(selectedOnly, { transparentBackground }); - const blob = await renderSvgToPng(exported.contents, { transparentBackground }); - const filename = selectedOnly ? 'selection.png' : 'drawing.png'; - const result = await copyPngBlob(blob, filename); - announceClipboardStatus( - result === 'rich' - ? `${selectedOnly ? 'Selection' : 'Document'} PNG copied to the clipboard${transparentBackground ? ' with transparency' : ''}.` - : `PNG clipboard access is unavailable. Downloaded ${filename} instead.` - ); - } catch (error) { - reportEditorError(error, 'Clipboard error'); - } + await clipboardActions.copyPng(selectedOnly, transparentBackground); } + let clipboardActions: ReturnType; + // The composition root fixes the platform adapter for this component's lifetime. const c = untrack(() => createCanvasController(platformAdapter, { @@ -237,15 +132,32 @@ }) ); + clipboardActions = createClipboardActions({ + getState: () => c.store.getState(), + getCursorWorld: () => c.cursorStore.getState().cursorWorld, + commit: c.commitLayerState, + deleteSelection: () => + c.handleAction( + Action.keyDown('Delete', 'Delete', { + ctrl: false, + shift: false, + alt: false, + meta: false + }) + ), + importSvgMarkup: c.importSvgMarkup, + renderSvg: c.renderSvg, + reportError: reportEditorError, + announceStatus: announceClipboardStatus, + showSvgFallback: (markup, message) => { + svgClipboardFallbackMarkup = markup; + svgClipboardFallbackMessage = message; + svgClipboardFallbackOpen = true; + } + }); + function enterFrame(frameId: string) { - c.store.setState((state) => ({ - ...state, - ui: { - ...state.ui, - containerPath: [...(state.ui.containerPath ?? []), frameId], - selectionIds: [] - } - })); + c.store.setState((state) => enterSelectedFrame(state, frameId)); } let platformKind = $derived(c.platform()); @@ -286,58 +198,19 @@ }); function handleDrop(e: DragEvent) { - e.preventDefault(); svgDragActive = false; - - const droppedFile = e.dataTransfer?.files?.[0]; - if (!draggingStencil.current && droppedFile) { - const rect = canvasEl?.getBoundingClientRect(); - const screen = rect - ? { x: e.clientX - rect.left, y: e.clientY - rect.top } - : { x: 0, y: 0 }; - const world = Camera.screenToWorld(c.store.getState().camera, screen, c.getViewport()); - const name = droppedFile.name.toLowerCase(); - if (name.endsWith('.svg') || droppedFile.type === 'image/svg+xml') { - void c.importSvgFile(droppedFile); - return; - } - if ( - droppedFile.type.startsWith('image/') || - /\.(?:png|jpe?g|gif|webp|bmp|avif)$/i.test(name) - ) { - void c.importImageFile(droppedFile, world); - return; - } - if ( - name.endsWith('.excalidraw') || - name.endsWith('.canvas') || - name.endsWith('.inkfinite') - ) { - void c.importDroppedFile(droppedFile); - return; - } - } - - let stencil = draggingStencil.current; - - if (!stencil && e.dataTransfer) { - const stencilId = e.dataTransfer.getData('application/x-inkfinite-stencil'); - if (stencilId) { - stencil = stencils.registry.get(stencilId) ?? null; - } - } - - if (!stencil || !canvasEl) { - return; - } - - const rect = canvasEl.getBoundingClientRect(); - const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - const viewport = c.getViewport(); - const world = Camera.screenToWorld(c.store.getState().camera, screen, viewport); - - c.insertStencil(stencil, world); - endDrag(); + handleCanvasDrop(e, { + canvas: canvasEl, + getState: () => c.store.getState(), + getViewport: () => c.getViewport(), + getDraggedStencil: () => draggingStencil.current, + findStencil: (id) => stencils.registry.get(id) ?? null, + clearStencilDrag: endDrag, + importSvgFile: (file) => void c.importSvgFile(file), + importImageFile: (file, world) => void c.importImageFile(file, world), + importDroppedFile: (file) => void c.importDroppedFile(file), + insertStencil: (stencil, world) => c.insertStencil(stencil, world) + }); } function handleStencilsClick() { @@ -350,27 +223,8 @@ function cropSelectedImage(square: boolean) { const state = c.store.getState(); - const selected = - state.ui.selectionIds.length === 1 - ? state.doc.shapes[state.ui.selectionIds[0]] - : undefined; - if (!selected || selected.type !== 'image') return; - const ratio = selected.props.w / Math.max(selected.props.h, 1); - const crop = square - ? ratio > 1 - ? { top: 0, right: (1 - 1 / ratio) / 2, bottom: 0, left: (1 - 1 / ratio) / 2 } - : { top: (1 - ratio) / 2, right: 0, bottom: (1 - ratio) / 2, left: 0 } - : undefined; - c.commitLayerState(square ? 'Crop image' : 'Reset image crop', { - ...state, - doc: { - ...state.doc, - shapes: { - ...state.doc.shapes, - [selected.id]: { ...selected, props: { ...selected.props, crop } } - } - } - }); + const next = setSelectedImageSquareCrop(state, square); + if (next !== state) c.commitLayerState(square ? 'Crop image' : 'Reset image crop', next); } async function handleImageReplacement(event: Event) { diff --git a/packages/ui/src/lib/editor/canvas/clipboard-actions.test.ts b/packages/ui/src/lib/editor/canvas/clipboard-actions.test.ts new file mode 100644 index 0000000..70741c0 --- /dev/null +++ b/packages/ui/src/lib/editor/canvas/clipboard-actions.test.ts @@ -0,0 +1,29 @@ +import { Camera, EditorState } from '@inkfinite/core'; +import { describe, expect, it, vi } from 'vitest'; +import { createClipboardActions } from './clipboard-actions'; + +describe('canvas clipboard actions', () => { + it('uses the visible fallback when SVG clipboard access is unavailable', async () => { + const fallback = vi.fn(); + const actions = createClipboardActions({ + getState: () => ({ ...EditorState.create(), camera: Camera.create() }), + getCursorWorld: () => ({ x: 0, y: 0 }), + commit: vi.fn(), + deleteSelection: vi.fn(), + importSvgMarkup: vi.fn(async () => {}), + renderSvg: vi.fn(async () => ({ + format: 'svg' as const, + contents: '', + extension: 'svg' as const, + mimeType: 'image/svg+xml' as const, + warnings: [] + })), + reportError: vi.fn(), + announceStatus: vi.fn(), + showSvgFallback: fallback + }); + + await actions.copySvg(false); + expect(fallback).toHaveBeenCalledWith('', expect.any(String)); + }); +}); diff --git a/packages/ui/src/lib/editor/canvas/clipboard-actions.ts b/packages/ui/src/lib/editor/canvas/clipboard-actions.ts new file mode 100644 index 0000000..1fa4714 --- /dev/null +++ b/packages/ui/src/lib/editor/canvas/clipboard-actions.ts @@ -0,0 +1,149 @@ +import type { EditorState, SvgExport } from '@inkfinite/core'; +import { + copyPngBlob, + copySelection, + copySvgMarkup, + pasteClipboard, + pasteImage, + pasteText, + renderSvgToPng, + readClipboardContent +} from '../clipboard'; + +export type ClipboardActionDependencies = { + getState: () => EditorState; + getCursorWorld: () => { x: number; y: number }; + commit: (name: string, state: EditorState) => void; + deleteSelection: () => void; + importSvgMarkup: (contents: string) => Promise; + renderSvg: ( + selectedOnly: boolean, + options?: { transparentBackground?: boolean } + ) => Promise; + reportError: (error: unknown, title: string) => void; + announceStatus: (message: string) => void; + showSvgFallback: (markup: string, message: string) => void; +}; + +/** Coordinates clipboard read/write, paste, and export effects for Canvas. */ +export function createClipboardActions(deps: ClipboardActionDependencies) { + async function copyCurrentSelection() { + try { + await copySelection(deps.getState()); + } catch (error) { + deps.reportError(error, 'Clipboard error'); + } + } + + async function cutCurrentSelection() { + try { + await copySelection(deps.getState()); + deps.deleteSelection(); + } catch (error) { + deps.reportError(error, 'Clipboard error'); + } + } + + async function pasteFromClipboard(options: { inPlace?: boolean; atCursor?: boolean } = {}) { + try { + const content = await readClipboardContent(); + if (!content) + throw new Error('The clipboard is empty or contains unsupported content.'); + const position = options.atCursor ? deps.getCursorWorld() : undefined; + if (content.type === 'native') { + deps.commit( + options.inPlace ? 'Paste in place' : 'Paste', + pasteClipboard(deps.getState(), content.payload, { + inPlace: options.inPlace, + position: options.inPlace ? undefined : position + }) + ); + } else if (content.type === 'text') { + deps.commit( + 'Paste text', + pasteText(deps.getState(), content.text, content.markdown, position) + ); + } else if (content.type === 'image') { + deps.commit('Paste image', await pasteImage(deps.getState(), content, position)); + } else { + await deps.importSvgMarkup(content.contents); + } + } catch (error) { + deps.reportError(error, 'Clipboard error'); + } + } + + async function handlePaste(event: ClipboardEvent) { + event.preventDefault(); + try { + const content = await readClipboardContent(event.clipboardData ?? undefined); + if (!content) + throw new Error('The clipboard is empty or contains unsupported content.'); + const position = deps.getCursorWorld(); + if (content.type === 'native') { + deps.commit( + 'Paste', + pasteClipboard(deps.getState(), content.payload, { position }) + ); + } else if (content.type === 'text') { + deps.commit( + 'Paste text', + pasteText(deps.getState(), content.text, content.markdown, position) + ); + } else if (content.type === 'image') { + deps.commit('Paste image', await pasteImage(deps.getState(), content, position)); + } else { + await deps.importSvgMarkup(content.contents); + } + } catch (error) { + deps.reportError(error, 'Clipboard error'); + } + } + + async function copySvg(selectedOnly: boolean) { + try { + const exported = await deps.renderSvg(selectedOnly); + const result = await copySvgMarkup(exported.contents); + if (result === 'rich') + deps.announceStatus( + 'SVG copied. It is ready to paste into a vector tool or text editor.' + ); + else if (result === 'text') + deps.announceStatus( + 'SVG copied as plain text. Paste it into a text editor or import it manually into a vector tool.' + ); + else + deps.showSvgFallback( + exported.contents, + 'This browser could not access the clipboard. Select the markup below and copy it manually.' + ); + } catch (error) { + deps.reportError(error, 'Clipboard error'); + } + } + + async function copyPng(selectedOnly: boolean, transparentBackground = false) { + try { + const exported = await deps.renderSvg(selectedOnly, { transparentBackground }); + const blob = await renderSvgToPng(exported.contents, { transparentBackground }); + const filename = selectedOnly ? 'selection.png' : 'drawing.png'; + const result = await copyPngBlob(blob, filename); + deps.announceStatus( + result === 'rich' + ? `${selectedOnly ? 'Selection' : 'Document'} PNG copied to the clipboard${transparentBackground ? ' with transparency' : ''}.` + : `PNG clipboard access is unavailable. Downloaded ${filename} instead.` + ); + } catch (error) { + deps.reportError(error, 'Clipboard error'); + } + } + + return { + copyCurrentSelection, + cutCurrentSelection, + pasteFromClipboard, + handlePaste, + copySvg, + copyPng + }; +} diff --git a/packages/ui/src/lib/editor/canvas/drop-handler.test.ts b/packages/ui/src/lib/editor/canvas/drop-handler.test.ts new file mode 100644 index 0000000..6a5ea51 --- /dev/null +++ b/packages/ui/src/lib/editor/canvas/drop-handler.test.ts @@ -0,0 +1,32 @@ +import { Camera, EditorState } from '@inkfinite/core'; +import { describe, expect, it, vi } from 'vitest'; +import { handleCanvasDrop } from './drop-handler'; + +describe('canvas drop handler', () => { + it('routes SVG files to the SVG importer before stencil handling', () => { + const importSvgFile = vi.fn(); + const event = { + preventDefault: vi.fn(), + clientX: 20, + clientY: 30, + dataTransfer: { + files: [{ name: 'diagram.svg', type: 'image/svg+xml' }], + getData: vi.fn() + } + } as unknown as DragEvent; + handleCanvasDrop(event, { + canvas: null, + getState: () => ({ ...EditorState.create(), camera: Camera.create() }), + getViewport: () => ({ width: 100, height: 100 }), + getDraggedStencil: () => null, + findStencil: () => null, + clearStencilDrag: vi.fn(), + importSvgFile, + importImageFile: vi.fn(), + importDroppedFile: vi.fn(), + insertStencil: vi.fn() + }); + expect(event.preventDefault).toHaveBeenCalled(); + expect(importSvgFile).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/lib/editor/canvas/drop-handler.ts b/packages/ui/src/lib/editor/canvas/drop-handler.ts new file mode 100644 index 0000000..1436205 --- /dev/null +++ b/packages/ui/src/lib/editor/canvas/drop-handler.ts @@ -0,0 +1,54 @@ +import { Camera, type EditorState, type Viewport, type stencils } from '@inkfinite/core'; + +export type CanvasDropHandlers = { + canvas: HTMLCanvasElement | null; + getState: () => EditorState; + getViewport: () => Viewport; + getDraggedStencil: () => stencils.Stencil | null; + findStencil: (id: string) => stencils.Stencil | null; + clearStencilDrag: () => void; + importSvgFile: (file: File) => void; + importImageFile: (file: File, world: { x: number; y: number }) => void; + importDroppedFile: (file: File) => void; + insertStencil: (stencil: stencils.Stencil, world: { x: number; y: number }) => void; +}; + +/** Handles file and stencil drops without coupling Canvas to import policy. */ +export function handleCanvasDrop(event: DragEvent, handlers: CanvasDropHandlers): void { + event.preventDefault(); + const file = event.dataTransfer?.files?.[0]; + if (!handlers.getDraggedStencil() && file) { + const rect = handlers.canvas?.getBoundingClientRect(); + const screen = rect + ? { x: event.clientX - rect.left, y: event.clientY - rect.top } + : { x: 0, y: 0 }; + const world = Camera.screenToWorld( + handlers.getState().camera, + screen, + handlers.getViewport() + ); + const name = file.name.toLowerCase(); + if (name.endsWith('.svg') || file.type === 'image/svg+xml') + return void handlers.importSvgFile(file); + if (file.type.startsWith('image/') || /\.(?:png|jpe?g|gif|webp|bmp|avif)$/i.test(name)) + return void handlers.importImageFile(file, world); + if ( + name.endsWith('.excalidraw') || + name.endsWith('.canvas') || + name.endsWith('.inkfinite') + ) + return void handlers.importDroppedFile(file); + } + + let stencil = handlers.getDraggedStencil(); + if (!stencil && event.dataTransfer) { + const stencilId = event.dataTransfer.getData('application/x-inkfinite-stencil'); + if (stencilId) stencil = handlers.findStencil(stencilId); + } + if (!stencil || !handlers.canvas) return; + const rect = handlers.canvas.getBoundingClientRect(); + const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const world = Camera.screenToWorld(handlers.getState().camera, screen, handlers.getViewport()); + handlers.insertStencil(stencil, world); + handlers.clearStencilDrag(); +} diff --git a/packages/ui/src/lib/editor/commands.ts b/packages/ui/src/lib/editor/commands.ts index a0ba964..de28ade 100644 --- a/packages/ui/src/lib/editor/commands.ts +++ b/packages/ui/src/lib/editor/commands.ts @@ -170,6 +170,19 @@ export function applySelectionCommand(state: EditorState, command: SelectionComm } } +/** Applies a pure editor-state operation as one undoable editor command. */ +export function executeEditorStateCommand( + store: Store, + label: string, + apply: (state: EditorState) => EditorState +): boolean { + const before = store.getState(); + const after = apply(before); + if (after === before) return false; + store.executeCommand(new SnapshotCommand(label, 'doc', before, after)); + return true; +} + /** Applies a selection command as one undoable editor command. */ export function executeSelectionCommand(store: Store, command: SelectionCommand): boolean { const before = store.getState(); diff --git a/packages/ui/src/lib/editor/components/ArrowPopover.svelte b/packages/ui/src/lib/editor/components/ArrowPopover.svelte index 4d963aa..07c4dea 100644 --- a/packages/ui/src/lib/editor/components/ArrowPopover.svelte +++ b/packages/ui/src/lib/editor/components/ArrowPopover.svelte @@ -1,6 +1,15 @@ diff --git a/packages/ui/src/lib/editor/components/LayerPanel.svelte b/packages/ui/src/lib/editor/components/LayerPanel.svelte index 5c03a19..c107093 100644 --- a/packages/ui/src/lib/editor/components/LayerPanel.svelte +++ b/packages/ui/src/lib/editor/components/LayerPanel.svelte @@ -14,6 +14,8 @@ import { tick, untrack } from 'svelte'; import { Button, ContextMenu, Icon, IconButton, type ContextMenuEntry } from '../../index'; + import { clampFloatingPosition } from './floating-position'; + import { deleteLayerDestinations, nearestWritableDestination } from './layer-policy'; let { store, @@ -95,18 +97,12 @@ function clampPanelPosition(left: number, top: number) { const parent = panelParent(); if (!parent || !panelEl) return { left, top }; - - const gutter = 8; - return { - left: Math.min( - Math.max(gutter, left), - Math.max(gutter, parent.clientWidth - panelEl.offsetWidth - gutter) - ), - top: Math.min( - Math.max(gutter, top), - Math.max(gutter, parent.clientHeight - panelEl.offsetHeight - gutter) - ) - }; + return clampFloatingPosition(left, top, { + width: panelEl.offsetWidth, + height: panelEl.offsetHeight, + availableWidth: parent.clientWidth, + availableHeight: parent.clientHeight + }); } function currentPanelPosition() { @@ -198,20 +194,7 @@ function beginDelete(layer: EditorLayerRecord) { deletingLayerId = layer.id; - deleteDestinationId = nearestWritableDestination(layer.id)?.id ?? null; - } - - function nearestWritableDestination(sourceId: string): EditorLayerRecord | null { - const sourceIndex = layers.findIndex((layer) => layer.id === sourceId); - return ( - [...layers] - .filter((layer) => layer.id !== sourceId && isWritableLayer(layer)) - .sort( - (a, b) => - Math.abs(layers.findIndex((layer) => layer.id === a.id) - sourceIndex) - - Math.abs(layers.findIndex((layer) => layer.id === b.id) - sourceIndex) - )[0] ?? null - ); + deleteDestinationId = nearestWritableDestination(layers, layer.id)?.id ?? null; } function handleMenuAction(id: string) { @@ -400,7 +383,7 @@ diff --git a/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte b/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte index 968b28f..7219951 100644 --- a/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte +++ b/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte @@ -1,195 +1,32 @@

Appearance

+
+ {#if selection.fillTargets.length > 0} +
+ Fill + +
+ {/if} + {#if selection.strokeTargets.length > 0} +
+ Stroke + +
+ {/if} + {#if selection.fillOpacityTargets.length > 0} + {@render OpacityControl('Fill opacity', selection.fillOpacityState, (event) => + handleOpacityChange(event, 'fillOpacity') + )} + {/if} + {#if selection.strokeOpacityTargets.length > 0} + {@render OpacityControl('Stroke opacity', selection.strokeOpacityState, (event) => + handleOpacityChange(event, 'strokeOpacity') + )} + {/if} + {#if selection.selectionCount > 0} + {@render OpacityControl('Opacity', selection.opacityState, (event) => + handleOpacityChange(event, 'opacity') + )} + {/if} +
+ +{/if} + +{#snippet OpacityControl( + label: string, + state: { value: number; mixed: boolean }, + onChange: (event: Event) => void +)} + +{/snippet} diff --git a/packages/ui/src/lib/editor/components/SelectionContainer.svelte b/packages/ui/src/lib/editor/components/SelectionContainer.svelte new file mode 100644 index 0000000..1fa5199 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionContainer.svelte @@ -0,0 +1,146 @@ + + +{#if selection.referenceTarget} +
+

Reference

+
+ + {#if selection.referenceTarget.props.referenceType === 'page'} + + {:else} + + {/if} + +
+
+{/if} + +{#if selection.cardTarget && selection.cardMetadata} +
+

Card

+
+ {selection.cardMetadata.title ?? 'Untitled card'} +
+
+{/if} + +{#if cardOpen && selection.cardTarget && selection.cardMetadata} + +
+ +
+ + +
+
+
+{/if} + +{#if selection.frameTarget} +
+

Frame

+
+ + +
+
+{/if} diff --git a/packages/ui/src/lib/editor/components/SelectionControls.svelte b/packages/ui/src/lib/editor/components/SelectionControls.svelte index 29551f9..3c20b07 100644 --- a/packages/ui/src/lib/editor/components/SelectionControls.svelte +++ b/packages/ui/src/lib/editor/components/SelectionControls.svelte @@ -1,43 +1,17 @@ {#if showContextControls} @@ -912,8 +94,8 @@ data-agent-occlusion>
{selectionCount} - {selectionCount === 1 ? 'object' : 'objects'} selected + >{selection.selectionCount} + {selection.selectionCount === 1 ? 'object' : 'objects'} selected - {#if fillTargets.length > 0 || strokeTargets.length > 0 || fillOpacityTargets.length > 0 || strokeOpacityTargets.length > 0} -
-

Appearance

-
- {#if fillTargets.length > 0} -
- Fill - -
- {/if} - {#if strokeTargets.length > 0} -
- Stroke - -
- {/if} - {#if fillOpacityTargets.length > 0} - - {/if} - {#if strokeOpacityTargets.length > 0} - - {/if} - {#if selectionCount > 0} - - {/if} -
-
- {/if} - - {#if selectionCount > 0} - - {/if} - - {#if metadataOpen && selectionCount > 0} - - {/if} - - {#if imageTarget} -
-

Image

-
- - - - {#if imageTarget.props.mask?.kind === 'rounded'} - - {/if} -
-
- - {#each sampledColors as sampled} - - {/each} - {#if sampledColorMessage}{sampledColorMessage}{/if} -
-
- {/if} - - {#if referenceTarget} -
-

Reference

-
- - {#if referenceTarget.props.referenceType === 'page'} - - {:else} - - {/if} - -
-
- {/if} - - {#if cardTarget && cardMetadata} -
-

Card

-
- {cardMetadata.title ?? 'Untitled card'} - -
-
- {/if} - - {#if cardOpen && cardTarget && cardMetadata} - -
- -
- - -
-
-
- {/if} - - {#if typographyTargets.length > 0} -
-

Typography

-
- - -
-
- {/if} - - {#if textPathSelectionAvailable} -
-

Text on path

-
- -
-
- {/if} - - {#if textPathTarget && textPathAttachment} -
-

Text on path

-
- - - -
-
- -
-
- {/if} - - {#if frameTarget} -
-

Frame

-
- - -
-
- {/if} - - {#if arrowTargets.length > 0} -
-

Arrow

- -
- {/if} - - {#if effectTarget} -
-

Effects

-
- {#if effectTarget.props.maskEffect} - - {/if} - -
-
- {/if} - - {#if clipSelectionAvailable || selectedClipCount > 0} -
-

Clipping

-
- {#if clipSelectionAvailable} - - {/if} - {#if selectedClipCount > 0} - - {/if} -
-
- {/if} - - {#if booleanPathSelection} -
-

Boolean paths

-
- - - - -
-
- {/if} - - {#if selectionCount >= 2} -
-

Arrange selection

-
- - -
-
- {/if} - -
-

Selection

-
- {#if selectionCount >= 2} - - {/if} - - {#if showAgentControl} - - {/if} -
-
+ + + + + + + +
{/if}
- - (layoutMenuOpen = value)} - onSelect={handleLayoutMenuAction} /> {/if} - - diff --git a/packages/ui/src/lib/editor/components/SelectionEffects.svelte b/packages/ui/src/lib/editor/components/SelectionEffects.svelte new file mode 100644 index 0000000..4cca358 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionEffects.svelte @@ -0,0 +1,54 @@ + + +{#if selection.effectTarget} +
+

Effects

+
+ {#if selection.effectTarget.props.maskEffect} + + {/if} + +
+
+{/if} diff --git a/packages/ui/src/lib/editor/components/SelectionImage.svelte b/packages/ui/src/lib/editor/components/SelectionImage.svelte new file mode 100644 index 0000000..0098720 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionImage.svelte @@ -0,0 +1,136 @@ + + +{#if selection.imageTarget} +
+

Image

+
+ + + + {#if selection.imageTarget.props.mask?.kind === 'rounded'} + + {/if} +
+
+ + {#each sampledColors as sampled}{/each} + {#if sampledColorMessage}{sampledColorMessage}{/if} +
+
+{/if} diff --git a/packages/ui/src/lib/editor/components/SelectionMetadata.svelte b/packages/ui/src/lib/editor/components/SelectionMetadata.svelte new file mode 100644 index 0000000..15f4853 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionMetadata.svelte @@ -0,0 +1,188 @@ + + +{#if selection.selectionCount > 0} + +{/if} + +{#if open && selection.selectionCount > 0} + +{/if} + +{#snippet Field( + label: string, + value: { value: string; mixed: boolean }, + placeholder: string, + ariaLabel: string, + onchange: (event: Event) => void, + wide = false, + type = 'text' +)} + +{/snippet} + +{#snippet TextareaField( + label: string, + value: { value: string; mixed: boolean }, + placeholder: string, + ariaLabel: string, + onchange: (event: Event) => void, + wide = false +)} + +{/snippet} diff --git a/packages/ui/src/lib/editor/components/SelectionPathVector.svelte b/packages/ui/src/lib/editor/components/SelectionPathVector.svelte new file mode 100644 index 0000000..260d944 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionPathVector.svelte @@ -0,0 +1,130 @@ + + +{#if selection.textPathSelectionAvailable} +
+

Text on path

+
+ +
+
+{/if} + +{#if selection.textPathTarget && selection.textPathAttachment} +
+

Text on path

+
+ + + +
+
+ +
+
+{/if} + +{#if selection.arrowTargets.length > 0} +
+

Arrow

+ +
+{/if} + +{#if selection.clipSelectionAvailable || selection.selectedClipCount > 0} +
+

Clipping

+
+ {#if selection.clipSelectionAvailable}{/if} + {#if selection.selectedClipCount > 0}{/if} +
+
+{/if} + +{#if selection.booleanPathSelection} +
+

Boolean paths

+
+ + + + +
+
+{/if} diff --git a/packages/ui/src/lib/editor/components/SelectionText.svelte b/packages/ui/src/lib/editor/components/SelectionText.svelte new file mode 100644 index 0000000..633bbdc --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionText.svelte @@ -0,0 +1,68 @@ + + +{#if selection.typographyTargets.length > 0} +
+

Typography

+
+ + +
+
+{/if} diff --git a/packages/ui/src/lib/editor/components/SelectionTransform.svelte b/packages/ui/src/lib/editor/components/SelectionTransform.svelte new file mode 100644 index 0000000..0264c32 --- /dev/null +++ b/packages/ui/src/lib/editor/components/SelectionTransform.svelte @@ -0,0 +1,263 @@ + + +{#if selection.selectionCount >= 2} +
+

Arrange selection

+
+ + +
+
+{/if} + +
+

Selection

+
+ {#if selection.selectionCount >= 2}{/if} + + {#if showAgentControl}{/if} +
+
+ + (layoutMenuOpen = value)} + onSelect={handleMenuAction} /> diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index 1b20448..98d6d89 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -12,6 +12,11 @@ import { TOOLS } from '../constants'; import type { BrushSettings, BrushStore } from '../status'; import SelectionControls from './SelectionControls.svelte'; + import { clampFloatingPosition } from './floating-position'; + import { + getExportMenuItems as deriveExportMenuItems, + getImportMenuItems as deriveImportMenuItems + } from './toolbar-menu'; type Props = { currentTool: ToolId; @@ -120,11 +125,13 @@ } function moveToolbar(x: number, y: number) { - const width = toolbarEl?.offsetWidth ?? 0; - const height = toolbarEl?.offsetHeight ?? 0; - const maxX = Math.max(8, window.innerWidth - width - 8); - const maxY = Math.max(8, window.innerHeight - height - 8); - position = { x: Math.min(maxX, Math.max(8, x)), y: Math.min(maxY, Math.max(8, y)) }; + const next = clampFloatingPosition(x, y, { + width: toolbarEl?.offsetWidth ?? 0, + height: toolbarEl?.offsetHeight ?? 0, + availableWidth: window.innerWidth, + availableHeight: window.innerHeight + }); + position = { x: next.left, y: next.top }; } function handleDragEnd(event: PointerEvent) { @@ -238,72 +245,11 @@ } function getExportMenuItems(): ContextMenuEntry[] { - return [ - { - id: 'excalidraw', - label: 'Excalidraw', - accessibleLabel: 'Export as Excalidraw editable document' - }, - { - id: 'json-canvas', - label: 'Obsidian Canvas', - accessibleLabel: 'Export as Obsidian Canvas editable document' - }, - { type: 'separator' }, - { id: 'png', label: 'PNG (Viewport)', accessibleLabel: 'Export current view as PNG' }, - { id: 'svg-all', label: 'SVG (All)', accessibleLabel: 'Export all shapes as SVG' }, - { - id: 'svg-selection', - label: 'SVG (Selection)', - accessibleLabel: 'Export selected shapes as SVG' - }, - ...(onCopySvg || onCopyPng - ? [ - { type: 'separator' as const }, - ...(onCopySvg - ? [ - { - id: 'copy-svg-all', - label: 'Copy as SVG (All)', - accessibleLabel: 'Copy all shapes as SVG' - }, - { - id: 'copy-svg-selection', - label: 'Copy as SVG (Selection)', - accessibleLabel: 'Copy selected shapes as SVG', - disabled: editorState.ui.selectionIds.length === 0 - } - ] - : []), - ...(onCopyPng - ? [ - { - id: 'copy-png-all', - label: 'Copy as PNG (All)', - accessibleLabel: 'Copy all shapes as PNG' - }, - { - id: 'copy-png-selection', - label: 'Copy as PNG (Selection)', - accessibleLabel: 'Copy selected shapes as PNG', - disabled: editorState.ui.selectionIds.length === 0 - }, - { - id: 'copy-png-all-transparent', - label: 'Copy as PNG (All, Transparent)', - accessibleLabel: 'Copy all shapes as transparent PNG' - }, - { - id: 'copy-png-selection-transparent', - label: 'Copy as PNG (Selection, Transparent)', - accessibleLabel: 'Copy selected shapes as transparent PNG', - disabled: editorState.ui.selectionIds.length === 0 - } - ] - : []) - ] - : []) - ]; + return deriveExportMenuItems({ + selectionCount: editorState.ui.selectionIds.length, + canCopySvg: Boolean(onCopySvg), + canCopyPng: Boolean(onCopyPng) + }); } function handleExportMenuAction(id: string) { @@ -354,27 +300,12 @@ } function getImportMenuItems(): ContextMenuEntry[] { - const items: ContextMenuEntry[] = []; - if (onImportEditable) { - items.push({ id: 'import-document', label: 'Editable document', icon: 'layers' }); - } - if (onImportSvg || onCreateFromSvg || onImportSvgMarkup) { - if (items.length > 0) items.push({ type: 'separator' }); - } - if (onImportSvg) { - items.push({ - id: 'import-svg-file', - label: 'Add SVG to current document', - icon: 'folder' - }); - } - if (onCreateFromSvg) { - items.push({ id: 'create-from-svg', label: 'New document from SVG', icon: 'svg' }); - } - if (onImportSvgMarkup) { - items.push({ id: 'import-svg-markup', label: 'Add SVG code / markup', icon: 'svg' }); - } - return items; + return deriveImportMenuItems({ + canImportEditable: Boolean(onImportEditable), + canImportSvg: Boolean(onImportSvg), + canCreateFromSvg: Boolean(onCreateFromSvg), + canImportSvgMarkup: Boolean(onImportSvgMarkup) + }); } function handleImportMenuAction(id: string) { diff --git a/packages/ui/src/lib/editor/components/floating-position.ts b/packages/ui/src/lib/editor/components/floating-position.ts new file mode 100644 index 0000000..4d8bf63 --- /dev/null +++ b/packages/ui/src/lib/editor/components/floating-position.ts @@ -0,0 +1,34 @@ +export type FloatingSize = { width: number; height: number }; +export type FloatingBounds = FloatingSize & { + availableWidth: number; + availableHeight: number; + gutter?: number; +}; + +/** Keeps a floating control inside its available rectangle. */ +export function clampFloatingPosition( + left: number, + top: number, + bounds: FloatingBounds +): { left: number; top: number } { + const gutter = bounds.gutter ?? 8; + return { + left: Math.min( + Math.max(gutter, left), + Math.max(gutter, bounds.availableWidth - bounds.width - gutter) + ), + top: Math.min( + Math.max(gutter, top), + Math.max(gutter, bounds.availableHeight - bounds.height - gutter) + ) + }; +} + +/** Moves a floating control by a delta while applying the same edge clamping. */ +export function moveFloatingPosition( + position: { left: number; top: number }, + delta: { x: number; y: number }, + bounds: FloatingBounds +): { left: number; top: number } { + return clampFloatingPosition(position.left + delta.x, position.top + delta.y, bounds); +} diff --git a/packages/ui/src/lib/editor/components/layer-policy.ts b/packages/ui/src/lib/editor/components/layer-policy.ts new file mode 100644 index 0000000..38b53c6 --- /dev/null +++ b/packages/ui/src/lib/editor/components/layer-policy.ts @@ -0,0 +1,26 @@ +import { isWritableLayer, type EditorLayerRecord } from '@inkfinite/core'; + +/** Chooses the nearest writable layer that can receive deleted layer contents. */ +export function nearestWritableDestination( + layers: EditorLayerRecord[], + sourceId: string +): EditorLayerRecord | null { + const sourceIndex = layers.findIndex((layer) => layer.id === sourceId); + return ( + [...layers] + .filter((layer) => layer.id !== sourceId && isWritableLayer(layer)) + .sort( + (a, b) => + Math.abs(layers.findIndex((layer) => layer.id === a.id) - sourceIndex) - + Math.abs(layers.findIndex((layer) => layer.id === b.id) - sourceIndex) + )[0] ?? null + ); +} + +/** Returns writable rehome destinations for the delete confirmation. */ +export function deleteLayerDestinations( + layers: EditorLayerRecord[], + sourceId: string +): EditorLayerRecord[] { + return layers.filter((layer) => layer.id !== sourceId && isWritableLayer(layer)); +} diff --git a/packages/ui/src/lib/editor/components/model.test.ts b/packages/ui/src/lib/editor/components/model.test.ts new file mode 100644 index 0000000..fe77976 --- /dev/null +++ b/packages/ui/src/lib/editor/components/model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { EditorLayerRecord } from '@inkfinite/core'; +import { clampFloatingPosition, moveFloatingPosition } from './floating-position'; +import { nearestWritableDestination } from './layer-policy'; +import { getExportMenuItems, getImportMenuItems } from './toolbar-menu'; + +describe('editor component models', () => { + it('clamps and moves floating controls through one positioning rule', () => { + expect( + clampFloatingPosition(-20, 1000, { + width: 100, + height: 80, + availableWidth: 400, + availableHeight: 300 + }) + ).toEqual({ left: 8, top: 212 }); + expect( + moveFloatingPosition( + { left: 10, top: 10 }, + { x: 40, y: 20 }, + { width: 100, height: 80, availableWidth: 400, availableHeight: 300 } + ) + ).toEqual({ left: 50, top: 30 }); + }); + + it('keeps layer rehome policy out of LayerPanel', () => { + const back = EditorLayerRecord.create('page:test', 'Back', 'layer:back'); + const locked = { + ...EditorLayerRecord.create('page:test', 'Locked', 'layer:locked'), + locked: true + }; + const front = EditorLayerRecord.create('page:test', 'Front', 'layer:front'); + expect(nearestWritableDestination([back, locked, front], locked.id)?.id).toBe(back.id); + }); + + it('derives import and export menus without component event handlers', () => { + expect( + getImportMenuItems({ + canImportEditable: true, + canImportSvg: false, + canCreateFromSvg: true, + canImportSvgMarkup: false + }).map((item) => (item.type === 'separator' ? 'separator' : item.id)) + ).toEqual(['import-document', 'separator', 'create-from-svg']); + const exports = getExportMenuItems({ + selectionCount: 0, + canCopySvg: true, + canCopyPng: false + }); + const selectionExport = exports.find( + (item): item is Extract<(typeof exports)[number], { id: string }> => + item.type !== 'separator' && item.id === 'copy-svg-selection' + ); + expect(selectionExport?.disabled).toBe(true); + }); +}); diff --git a/packages/ui/src/lib/editor/components/proposal-ghost.test.ts b/packages/ui/src/lib/editor/components/proposal-ghost.test.ts new file mode 100644 index 0000000..22e1e18 --- /dev/null +++ b/packages/ui/src/lib/editor/components/proposal-ghost.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import type { ProposalObjectPreview } from '../platform'; +import { legacyShape, shapeSegmentsFor } from './proposal-ghost'; + +describe('proposal ghost projection', () => { + it('projects moved previews into before and after segments', () => { + const preview: ProposalObjectPreview = { + record_id: { kind: 'shape', id: 'shape:moved' }, + change: 'moved', + before: { kind: 'shape', record: { kind: 'rect' } }, + after: { kind: 'shape', record: { kind: 'rect' } }, + before_bounds: { x: 0, y: 0, width: 10, height: 10 }, + after_bounds: { x: 20, y: 20, width: 10, height: 10 }, + operation_positions: [0], + changed_fields: ['transform.translation'] + }; + expect(shapeSegmentsFor(preview).map((segment) => segment.side)).toEqual([ + 'before', + 'after' + ]); + }); + + it('rejects malformed legacy shape operations', () => { + expect(legacyShape({ type: 'create_shape', shape: { id: 'shape:bad' } })).toBeNull(); + expect(legacyShape({ type: 'patch_shape' })).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/editor/components/proposal-ghost.ts b/packages/ui/src/lib/editor/components/proposal-ghost.ts new file mode 100644 index 0000000..3af9100 --- /dev/null +++ b/packages/ui/src/lib/editor/components/proposal-ghost.ts @@ -0,0 +1,182 @@ +import { Camera, type Camera as CameraState, type Viewport } from '@inkfinite/core'; +import type { LiveProposal, ProposalObjectPreview } from '../platform'; + +export type NativeRecord = Record; +export type PreviewSide = 'before' | 'after'; +export type PreviewSegment = { + id: string; + recordId: string; + change: ProposalObjectPreview['change']; + side: PreviewSide; + bounds: NonNullable; + record: NativeRecord; +}; + +export type LegacyShape = { + id: string; + kind: string; + transform: { + translation: { x: number; y: number }; + rotation: number; + scale_x: number; + scale_y: number; + }; + properties: Record; +}; + +export function isRecordPreview(preview: ProposalObjectPreview, kind: string): boolean { + return preview.record_id.kind === kind; +} + +export function recordFor(preview: ProposalObjectPreview, side: PreviewSide): NativeRecord | null { + return side === 'before' ? (preview.before?.record ?? null) : (preview.after?.record ?? null); +} + +export function shapeSegmentsFor(preview: ProposalObjectPreview): PreviewSegment[] { + const segments: PreviewSegment[] = []; + const add = (side: PreviewSide, bounds: ProposalObjectPreview['before_bounds']) => { + const record = recordFor(preview, side); + if (!bounds || !record) return; + segments.push({ + id: `${preview.record_id.id}:${side}`, + recordId: preview.record_id.id, + change: preview.change, + side, + bounds, + record + }); + }; + if (preview.change === 'removed') add('before', preview.before_bounds); + else if (preview.change === 'added' || preview.change === 'modified') + add('after', preview.after_bounds); + else { + add('before', preview.before_bounds); + add('after', preview.after_bounds); + } + return segments; +} + +export function legacyShape(operation: unknown): LegacyShape | null { + if (typeof operation !== 'object' || operation === null) return null; + const candidate = operation as { type?: unknown; shape?: unknown }; + if ( + candidate.type !== 'create_shape' || + typeof candidate.shape !== 'object' || + !candidate.shape + ) + return null; + const shape = candidate.shape as Partial; + const translation = shape.transform?.translation; + const width = shape.properties?.width; + const height = shape.properties?.height; + if ( + typeof shape.id !== 'string' || + typeof shape.kind !== 'string' || + !translation || + ![ + translation.x, + translation.y, + shape.transform?.rotation, + shape.transform?.scale_x, + shape.transform?.scale_y + ].every((value) => typeof value === 'number' && Number.isFinite(value)) || + typeof width !== 'number' || + !Number.isFinite(width) || + typeof height !== 'number' || + !Number.isFinite(height) + ) + return null; + return shape as LegacyShape; +} + +export function screenBounds( + camera: CameraState, + viewport: Viewport, + bounds: NonNullable +) { + const topLeft = Camera.worldToScreen(camera, { x: bounds.x, y: bounds.y }, viewport); + const bottomRight = Camera.worldToScreen( + camera, + { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, + viewport + ); + return { + x: Math.min(topLeft.x, bottomRight.x), + y: Math.min(topLeft.y, bottomRight.y), + width: Math.abs(bottomRight.x - topLeft.x), + height: Math.abs(bottomRight.y - topLeft.y) + }; +} + +export function properties(record: NativeRecord): Record { + return typeof record.properties === 'object' && record.properties !== null + ? (record.properties as Record) + : {}; +} + +export function numberProperty(record: NativeRecord, key: string, fallback: number): number { + const value = properties(record)[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +export function stringProperty(record: NativeRecord, key: string): string | null { + const value = properties(record)[key]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +export function fill(record: NativeRecord): string { + return stringProperty(record, 'fill') ?? 'var(--ink-accent)'; +} + +export function stroke(record: NativeRecord): string { + return stringProperty(record, 'stroke') ?? 'var(--ink-accent)'; +} + +export function recordLabel(preview: ProposalObjectPreview): string { + const record = recordFor(preview, preview.change === 'removed' ? 'before' : 'after'); + if (preview.record_id.kind === 'binding') { + const relation = record?.relation_type; + return typeof relation === 'string' && relation + ? `Relationship · ${relation}` + : 'Relationship'; + } + const metadata = record?.metadata; + if (typeof metadata === 'object' && metadata !== null) { + const name = (metadata as Record).name; + if (typeof name === 'string' && name) return name; + const role = (metadata as Record).role; + if (typeof role === 'string' && role) return role; + } + return `${preview.record_id.kind} · ${preview.record_id.id}`; +} + +export function bindingBox( + camera: CameraState, + viewport: Viewport, + preview: ProposalObjectPreview +) { + const bounds = preview.change === 'removed' ? preview.before_bounds : preview.after_bounds; + return bounds ? screenBounds(camera, viewport, bounds) : null; +} + +export function bindingClass(change: ProposalObjectPreview['change']): string { + return `proposal-binding proposal-binding--${change}`; +} + +export function proposalSegments(proposal: LiveProposal) { + const objectPreviews = proposal.object_previews ?? []; + const shapePreviews = objectPreviews.filter((preview) => isRecordPreview(preview, 'shape')); + const bindingPreviews = objectPreviews.filter((preview) => + isRecordPreview(preview, 'binding') + ); + return { + shapePreviews, + bindingPreviews, + shapeSegments: shapePreviews.flatMap(shapeSegmentsFor), + legacyShapes: proposal.object_previews + ? [] + : proposal.transaction.operations + .map(legacyShape) + .filter((shape): shape is LegacyShape => shape !== null) + }; +} diff --git a/packages/ui/src/lib/editor/components/stories/SelectionControls.stories.ts b/packages/ui/src/lib/editor/components/stories/SelectionControls.stories.ts new file mode 100644 index 0000000..8744e47 --- /dev/null +++ b/packages/ui/src/lib/editor/components/stories/SelectionControls.stories.ts @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import { createStoryInspectorStore } from '../../stories/editor.stories.fixtures'; +import SelectionControls from '../SelectionControls.svelte'; + +const meta = { + title: 'Editor/Inspector/Selection controls', + component: SelectionControls, + tags: ['autodocs'], + args: { + currentTool: 'select', + orientation: 'vertical', + store: createStoryInspectorStore('appearance') + }, + parameters: { layout: 'fullscreen' } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Appearance: Story = {}; +export const Text: Story = { args: { store: createStoryInspectorStore('text') } }; +export const Image: Story = { args: { store: createStoryInspectorStore('image') } }; +export const Card: Story = { args: { store: createStoryInspectorStore('card') } }; +export const Layout: Story = { args: { store: createStoryInspectorStore('layout') } }; diff --git a/packages/ui/src/lib/editor/components/stories/SelectionMetadata.stories.ts b/packages/ui/src/lib/editor/components/stories/SelectionMetadata.stories.ts new file mode 100644 index 0000000..a24eada --- /dev/null +++ b/packages/ui/src/lib/editor/components/stories/SelectionMetadata.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import { createStoryInspectorSelection } from '../../stories/editor.stories.fixtures'; +import SelectionMetadata from '../SelectionMetadata.svelte'; + +const namedObject = createStoryInspectorSelection('appearance'); +const meta = { + title: 'Editor/Inspector/Metadata', + component: SelectionMetadata, + tags: ['autodocs'], + args: namedObject +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NamedObject: Story = {}; diff --git a/packages/ui/src/lib/editor/components/stories/SelectionPathVector.stories.ts b/packages/ui/src/lib/editor/components/stories/SelectionPathVector.stories.ts new file mode 100644 index 0000000..7d55af1 --- /dev/null +++ b/packages/ui/src/lib/editor/components/stories/SelectionPathVector.stories.ts @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import { getSelectionInspectorState } from '../../selection-inspector'; +import { createStoryStore } from '../../stories/editor.stories.fixtures'; +import SelectionPathVector from '../SelectionPathVector.svelte'; + +const arrowStore = createStoryStore(); +const arrowSelection = getSelectionInspectorState(arrowStore.getState()); +const meta = { + title: 'Editor/Inspector/Path and vector', + component: SelectionPathVector, + tags: ['autodocs'], + args: { store: arrowStore, selection: arrowSelection } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Arrow: Story = { args: { store: arrowStore, selection: arrowSelection } }; diff --git a/packages/ui/src/lib/editor/components/stories/SelectionTransform.stories.ts b/packages/ui/src/lib/editor/components/stories/SelectionTransform.stories.ts new file mode 100644 index 0000000..6fba6d8 --- /dev/null +++ b/packages/ui/src/lib/editor/components/stories/SelectionTransform.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/sveltekit'; + +import { createStoryInspectorSelection } from '../../stories/editor.stories.fixtures'; +import SelectionTransform from '../SelectionTransform.svelte'; + +const layoutSelection = createStoryInspectorSelection('layout'); +const meta = { + title: 'Editor/Inspector/Transform and layout', + component: SelectionTransform, + tags: ['autodocs'], + args: { ...layoutSelection, showAgentControl: true } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const MultipleSelection: Story = {}; diff --git a/packages/ui/src/lib/editor/components/toolbar-menu.ts b/packages/ui/src/lib/editor/components/toolbar-menu.ts new file mode 100644 index 0000000..93330ab --- /dev/null +++ b/packages/ui/src/lib/editor/components/toolbar-menu.ts @@ -0,0 +1,106 @@ +import type { ContextMenuEntry } from '../../index'; + +export type ExportMenuOptions = { + selectionCount: number; + canCopySvg: boolean; + canCopyPng: boolean; +}; + +/** Derives export actions independently from Toolbar event dispatch. */ +export function getExportMenuItems(options: ExportMenuOptions): ContextMenuEntry[] { + const selectionDisabled = options.selectionCount === 0; + return [ + { + id: 'excalidraw', + label: 'Excalidraw', + accessibleLabel: 'Export as Excalidraw editable document' + }, + { + id: 'json-canvas', + label: 'Obsidian Canvas', + accessibleLabel: 'Export as Obsidian Canvas editable document' + }, + { type: 'separator' }, + { id: 'png', label: 'PNG (Viewport)', accessibleLabel: 'Export current view as PNG' }, + { id: 'svg-all', label: 'SVG (All)', accessibleLabel: 'Export all shapes as SVG' }, + { + id: 'svg-selection', + label: 'SVG (Selection)', + accessibleLabel: 'Export selected shapes as SVG' + }, + ...(options.canCopySvg || options.canCopyPng + ? [ + { type: 'separator' as const }, + ...(options.canCopySvg + ? [ + { + id: 'copy-svg-all', + label: 'Copy as SVG (All)', + accessibleLabel: 'Copy all shapes as SVG' + }, + { + id: 'copy-svg-selection', + label: 'Copy as SVG (Selection)', + accessibleLabel: 'Copy selected shapes as SVG', + disabled: selectionDisabled + } + ] + : []), + ...(options.canCopyPng + ? [ + { + id: 'copy-png-all', + label: 'Copy as PNG (All)', + accessibleLabel: 'Copy all shapes as PNG' + }, + { + id: 'copy-png-selection', + label: 'Copy as PNG (Selection)', + accessibleLabel: 'Copy selected shapes as PNG', + disabled: selectionDisabled + }, + { + id: 'copy-png-all-transparent', + label: 'Copy as PNG (All, Transparent)', + accessibleLabel: 'Copy all shapes as transparent PNG' + }, + { + id: 'copy-png-selection-transparent', + label: 'Copy as PNG (Selection, Transparent)', + accessibleLabel: 'Copy selected shapes as transparent PNG', + disabled: selectionDisabled + } + ] + : []) + ] + : []) + ]; +} + +export type ImportMenuOptions = { + canImportEditable: boolean; + canImportSvg: boolean; + canCreateFromSvg: boolean; + canImportSvgMarkup: boolean; +}; + +/** Derives import actions independently from Toolbar event dispatch. */ +export function getImportMenuItems(options: ImportMenuOptions): ContextMenuEntry[] { + const items: ContextMenuEntry[] = []; + if (options.canImportEditable) + items.push({ id: 'import-document', label: 'Editable document', icon: 'layers' }); + if (options.canImportSvg || options.canCreateFromSvg || options.canImportSvgMarkup) { + if (items.length > 0) items.push({ type: 'separator' }); + } + if (options.canImportSvg) + items.push({ + id: 'import-svg-file', + label: 'Add SVG to current document', + icon: 'folder' + }); + if (options.canCreateFromSvg) + items.push({ id: 'create-from-svg', label: 'New document from SVG', icon: 'svg' }); + if (options.canImportSvgMarkup) + items.push({ id: 'import-svg-markup', label: 'Add SVG code / markup', icon: 'svg' }); + return items; +} diff --git a/packages/ui/src/lib/editor/selection-inspector.test.ts b/packages/ui/src/lib/editor/selection-inspector.test.ts new file mode 100644 index 0000000..cea81c9 --- /dev/null +++ b/packages/ui/src/lib/editor/selection-inspector.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { EditorPageRecord, EditorShapeRecord, EditorState } from '@inkfinite/core'; +import { getSelectionInspectorState } from './selection-inspector'; + +describe('selection inspector model', () => { + it('derives shared and mixed appearance values', () => { + const page = EditorPageRecord.create('Inspector test', 'page:inspector'); + const first = EditorShapeRecord.createRect( + page.id, + 0, + 0, + { w: 20, h: 20, fill: '#fff', stroke: '#000', radius: 0 }, + 'first' + ); + const second = EditorShapeRecord.createRect( + page.id, + 30, + 0, + { w: 20, h: 20, fill: '#000', stroke: '#000', radius: 0 }, + 'second' + ); + const state = EditorState.create(); + state.doc.pages[page.id] = { ...page, shapeIds: [first.id, second.id] }; + state.doc.shapes[first.id] = first; + state.doc.shapes[second.id] = second; + state.ui.currentPageId = page.id; + state.ui.selectionIds = [first.id, second.id]; + const model = getSelectionInspectorState(state); + expect(model.selectionCount).toBe(2); + expect(model.fillColorState.mixed).toBe(true); + expect(model.strokeColorState.value).toBe('#000'); + expect(model.allSelectedLocked).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/editor/selection-inspector.ts b/packages/ui/src/lib/editor/selection-inspector.ts new file mode 100644 index 0000000..8e706ae --- /dev/null +++ b/packages/ui/src/lib/editor/selection-inspector.ts @@ -0,0 +1,298 @@ +import { + canBooleanPathSelection, + canClipSelection, + canTextPathSelection, + cardChildren, + getSelectedShapes, + type ArrowShape, + type EditorShapeRecord, + type EditorState, + type ImportedAsset, + type PaintValue, + type ShapeMetadata, + type TextShape, + type MarkdownShape +} from '@inkfinite/core'; + +export type InspectorValue = { value: T; mixed: boolean }; + +export type SelectionInspectorState = { + selectedShapes: EditorShapeRecord[]; + selectionCount: number; + semanticMetadata: ShapeMetadata[]; + semanticTarget?: ShapeMetadata; + semanticNameState: InspectorValue; + semanticRoleState: InspectorValue; + semanticTagsState: InspectorValue; + semanticDescriptionState: InspectorValue; + semanticSourceState: InspectorValue; + semanticLinkState: InspectorValue; + semanticCustomMetadataState: InspectorValue; + fillTargets: EditorShapeRecord[]; + strokeTargets: EditorShapeRecord[]; + fillOpacityTargets: EditorShapeRecord[]; + strokeOpacityTargets: EditorShapeRecord[]; + textTargets: Array; + cardTargets: Array>; + cardTarget?: Extract; + cardMetadata?: ShapeMetadata; + typographyTargets: Array; + imageTargets: Array>; + imageTarget?: Extract; + imageAsset?: ImportedAsset; + referenceTarget?: Extract; + frameTarget?: Extract; + arrowTargets: ArrowShape[]; + hasGroupedSelection: boolean; + allSelectedLocked: boolean; + booleanPathSelection: boolean; + clipSelectionAvailable: boolean; + textPathSelectionAvailable: boolean; + textPathTarget?: TextShape; + textPathAttachment?: TextShape['props']['textPath']; + selectedClipCount: number; + effectTarget?: EditorShapeRecord; + fillColorState: InspectorValue; + strokeColorState: InspectorValue; + opacityState: InspectorValue; + fillOpacityState: InspectorValue; + strokeOpacityState: InspectorValue; + fontSizeState: InspectorValue; + fontFamilyState: InspectorValue; + agentEditableState: InspectorValue; +}; + +export function getSharedValue(values: T[]): T | null { + if (values.length === 0) return null; + const first = values[0]; + return values.every((value) => Object.is(value, first)) ? first : null; +} + +export function getSharedPaintValue(values: Array): PaintValue | null { + if (values.length === 0) return null; + const first = JSON.stringify(values[0]); + return values.every((value) => JSON.stringify(value) === first) ? values[0] : null; +} + +export function getNumericState(values: number[]): InspectorValue { + const shared = getSharedValue(values); + return { value: shared ?? 1, mixed: values.length > 1 && shared === null }; +} + +export function getTextState(values: string[]): InspectorValue { + const shared = getSharedValue(values); + return { value: shared ?? '', mixed: values.length > 1 && shared === null }; +} + +export function getBooleanState(values: boolean[]): InspectorValue { + const shared = getSharedValue(values); + return { value: shared ?? true, mixed: values.length > 1 && shared === null }; +} + +export function shapeSupportsFill(shape: EditorShapeRecord): boolean { + return ['rect', 'ellipse', 'text', 'path', 'markdown', 'container'].includes(shape.type); +} + +export function shapeSupportsStroke(shape: EditorShapeRecord): boolean { + return [ + 'rect', + 'ellipse', + 'line', + 'arrow', + 'stroke', + 'markdown', + 'path', + 'container' + ].includes(shape.type); +} + +export function shapeSupportsFillOpacity(shape: EditorShapeRecord): boolean { + return ['rect', 'ellipse', 'text', 'markdown', 'path', 'image', 'container'].includes( + shape.type + ); +} + +export function shapeSupportsStrokeOpacity(shape: EditorShapeRecord): boolean { + return [ + 'rect', + 'ellipse', + 'line', + 'arrow', + 'stroke', + 'markdown', + 'path', + 'container' + ].includes(shape.type); +} + +export function getFillPaint(shape: EditorShapeRecord): PaintValue | null { + switch (shape.type) { + case 'text': + return shape.props.color; + case 'rect': + case 'ellipse': + case 'path': + case 'container': + return shape.props.fill ?? null; + case 'markdown': + return shape.props.bg ?? null; + default: + return null; + } +} + +export function getStrokePaint(shape: EditorShapeRecord): PaintValue | null { + switch (shape.type) { + case 'arrow': + return shape.props.style.stroke; + case 'stroke': + return shape.props.style.color; + case 'rect': + case 'ellipse': + case 'line': + case 'path': + case 'container': + return shape.props.stroke ?? null; + case 'markdown': + return shape.props.border ?? null; + default: + return null; + } +} + +export function getSelectionInspectorState(state: EditorState): SelectionInspectorState { + const selectedShapes = getSelectedShapes(state); + const selectionCount = selectedShapes.length; + const semanticMetadata = selectedShapes.map( + (shape) => shape.metadata ?? defaultMetadata(shape) + ); + const semanticTarget = selectionCount === 1 ? semanticMetadata[0] : undefined; + const cardTargets = selectedShapes.filter( + (shape): shape is Extract => + shape.type === 'container' && shape.metadata?.title != null + ); + const cardTarget = cardTargets.length === 1 ? cardTargets[0] : undefined; + const textTargets = selectedShapes.filter( + (shape): shape is TextShape | MarkdownShape => + shape.type === 'text' || shape.type === 'markdown' + ); + const typographyTargets = [...textTargets]; + for (const card of cardTargets) + typographyTargets.push( + ...cardChildren(card, state.doc).filter( + (shape): shape is TextShape | MarkdownShape => + shape.type === 'text' || shape.type === 'markdown' + ) + ); + const imageTargets = selectedShapes.filter( + (shape): shape is Extract => shape.type === 'image' + ); + const imageTarget = imageTargets.length === 1 ? imageTargets[0] : undefined; + const textPathTarget = + selectedShapes.length === 1 && + selectedShapes[0]?.type === 'text' && + selectedShapes[0].props.textPath + ? selectedShapes[0] + : undefined; + const fillTargets = selectedShapes.filter(shapeSupportsFill); + const strokeTargets = selectedShapes.filter(shapeSupportsStroke); + const fillOpacityTargets = selectedShapes.filter(shapeSupportsFillOpacity); + const strokeOpacityTargets = selectedShapes.filter(shapeSupportsStrokeOpacity); + const arrowTargets = selectedShapes.filter( + (shape): shape is ArrowShape => shape.type === 'arrow' + ); + const fillColor = getSharedPaintValue(fillTargets.map(getFillPaint)); + const strokeColor = getSharedPaintValue(strokeTargets.map(getStrokePaint)); + + return { + selectedShapes, + selectionCount, + semanticMetadata, + semanticTarget, + semanticNameState: getTextState(semanticMetadata.map((metadata) => metadata.name ?? '')), + semanticRoleState: getTextState(semanticMetadata.map((metadata) => metadata.role ?? '')), + semanticTagsState: getTextState( + semanticMetadata.map((metadata) => metadata.tags.join(', ')) + ), + semanticDescriptionState: getTextState( + semanticMetadata.map((metadata) => metadata.description ?? '') + ), + semanticSourceState: getTextState( + semanticMetadata.map((metadata) => metadata.source ?? '') + ), + semanticLinkState: getTextState(semanticMetadata.map((metadata) => metadata.link ?? '')), + semanticCustomMetadataState: getTextState( + semanticMetadata.map((metadata) => JSON.stringify(metadata.customMetadata)) + ), + fillTargets, + strokeTargets, + fillOpacityTargets, + strokeOpacityTargets, + textTargets, + cardTargets, + cardTarget, + cardMetadata: cardTarget?.metadata, + typographyTargets, + imageTargets, + imageTarget, + imageAsset: imageTarget ? state.doc.assets?.[imageTarget.props.assetId] : undefined, + referenceTarget: + selectionCount === 1 && selectedShapes[0]?.type === 'reference' + ? selectedShapes[0] + : undefined, + frameTarget: + selectionCount === 1 && selectedShapes[0]?.type === 'container' + ? selectedShapes[0] + : undefined, + arrowTargets, + hasGroupedSelection: selectedShapes.some( + (shape) => Boolean(shape.groupId) || shape.type === 'container' + ), + allSelectedLocked: selectionCount > 0 && selectedShapes.every((shape) => shape.locked), + booleanPathSelection: canBooleanPathSelection(state), + clipSelectionAvailable: canClipSelection(state), + textPathSelectionAvailable: canTextPathSelection(state), + textPathTarget, + textPathAttachment: textPathTarget?.props.textPath, + selectedClipCount: selectedShapes.filter((shape) => + Boolean('clipPath' in shape.props && shape.props.clipPath) + ).length, + effectTarget: selectionCount === 1 ? selectedShapes[0] : undefined, + fillColorState: { + value: fillColor ?? '#4a90e2', + mixed: fillTargets.length > 1 && fillColor === null + }, + strokeColorState: { + value: strokeColor ?? '#2e5c8a', + mixed: strokeTargets.length > 1 && strokeColor === null + }, + opacityState: getNumericState(selectedShapes.map((shape) => shape.opacity ?? 1)), + fillOpacityState: getNumericState( + fillOpacityTargets.map((shape) => shape.fillOpacity ?? 1) + ), + strokeOpacityState: getNumericState( + strokeOpacityTargets.map((shape) => shape.strokeOpacity ?? 1) + ), + fontSizeState: getNumericState(typographyTargets.map((shape) => shape.props.fontSize)), + fontFamilyState: getTextState(typographyTargets.map((shape) => shape.props.fontFamily)), + agentEditableState: getBooleanState( + selectedShapes.map((shape) => shape.agentEditable !== false) + ) + }; +} + +function defaultMetadata(shape: EditorShapeRecord): ShapeMetadata { + return { + name: null, + title: null, + role: null, + description: null, + body: null, + tags: [], + source: null, + link: null, + customMetadata: {}, + locked: shape.locked ?? false, + agentEditable: shape.agentEditable !== false + }; +} diff --git a/packages/ui/src/lib/editor/stories/editor.stories.fixtures.ts b/packages/ui/src/lib/editor/stories/editor.stories.fixtures.ts index 8a1e3ef..905405d 100644 --- a/packages/ui/src/lib/editor/stories/editor.stories.fixtures.ts +++ b/packages/ui/src/lib/editor/stories/editor.stories.fixtures.ts @@ -1,5 +1,6 @@ import { CursorStore, + contentObjectToCard, EditorState, EditorPageRecord, EditorShapeRecord, @@ -11,6 +12,7 @@ import type { BoardMeta, DocRepo } from '@inkfinite/core/persistence'; import type { EditorPlatformAdapter } from '../platform'; import { createBrushStore, createSnapStore, createStatusStore } from '../status'; +import { getSelectionInspectorState, type SelectionInspectorState } from '../selection-inspector'; export const storyBoards: BoardMeta[] = [ { @@ -71,6 +73,93 @@ export function createStoryStore(): Store { } /** Props shared by editor toolbar and status stories. */ +export type InspectorStoryVariant = 'appearance' | 'text' | 'image' | 'card' | 'layout'; + +/** Creates a small selected document for focused inspector stories. */ +export function createStoryInspectorStore(variant: InspectorStoryVariant): Store { + const state = EditorState.create(); + const page = EditorPageRecord.create('Inspector story', `page:inspector-${variant}`); + state.doc.pages[page.id] = page; + state.ui.currentPageId = page.id; + + if (variant === 'text') { + const text = EditorShapeRecord.createText( + page.id, + 24, + 24, + { + text: 'Inspector headline', + fontSize: 28, + fontFamily: 'Newsreader Variable', + color: '#302c2a' + }, + 'shape:inspector-text' + ); + page.shapeIds = [text.id]; + state.doc.shapes[text.id] = text; + state.ui.selectionIds = [text.id]; + } else if (variant === 'image') { + const image = EditorShapeRecord.createImage( + page.id, + 24, + 24, + { w: 240, h: 160, assetId: 'asset:inspector', caption: 'Sample asset' }, + 'shape:inspector-image' + ); + page.shapeIds = [image.id]; + state.doc.shapes[image.id] = image; + state.doc.assets = { + 'asset:inspector': { + id: 'asset:inspector', + name: 'Inspector image', + mediaType: 'image/png', + digest: 'sha256:inspector', + bytes: [0] + } + }; + state.ui.selectionIds = [image.id]; + } else if (variant === 'card') { + const card = contentObjectToCard( + page.id, + { x: 24, y: 24 }, + { title: 'Inspector card', body: 'Card body', role: 'note' } + ); + page.shapeIds = card.map((shape) => shape.id); + for (const shape of card) state.doc.shapes[shape.id] = shape; + state.ui.selectionIds = [card[0].id]; + } else { + const first = EditorShapeRecord.createRect( + page.id, + 24, + 24, + { w: 120, h: 80, fill: '#e7d9ff', stroke: '#6f42c1', radius: 12 }, + 'shape:inspector-first' + ); + const second = EditorShapeRecord.createRect( + page.id, + 190, + 24, + { w: 120, h: 80, fill: '#d8f1e8', stroke: '#347a5a', radius: 12 }, + 'shape:inspector-second' + ); + page.shapeIds = [first.id, second.id]; + state.doc.shapes[first.id] = first; + state.doc.shapes[second.id] = second; + state.ui.selectionIds = variant === 'layout' ? [first.id, second.id] : [first.id]; + } + + return new Store(state); +} + +/** Creates the derived selection data passed to focused inspector stories. */ +export function createStoryInspectorSelection(variant: InspectorStoryVariant): { + store: Store; + selection: SelectionInspectorState; +} { + const store = createStoryInspectorStore(variant); + return { store, selection: getSelectionInspectorState(store.getState()) }; +} + export function createStoryEditorControls() { return { store: createStoryStore(), diff --git a/packages/ui/src/lib/styles/components/selection-controls.css b/packages/ui/src/lib/styles/components/selection-controls.css new file mode 100644 index 0000000..8820b71 --- /dev/null +++ b/packages/ui/src/lib/styles/components/selection-controls.css @@ -0,0 +1,513 @@ +.selection-controls { + position: fixed; + top: 5.25rem; + left: 50%; + z-index: 95; + display: flex; + align-items: center; + gap: var(--ink-space-3); + width: min(81rem, calc(100vw - 9rem)); + max-width: calc(100vw - 9rem); + padding: var(--ink-space-2) var(--ink-space-3); + border: 1px solid color-mix(in srgb, var(--ink-border) 68%, transparent); + border-radius: var(--ink-radius-panel-small); + color: var(--ink-text); + background: color-mix(in srgb, var(--ink-surface-raised) 97%, transparent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ink-border) 18%, transparent), + var(--ink-shadow-panel); + translate: -50% 0; + backdrop-filter: blur(14px); + transition-property: width, box-shadow; + transition-duration: var(--ink-duration-overlay); + transition-timing-function: var(--ink-ease-out); +} + +.selection-controls--horizontal { + top: 11rem; +} + +.selection-controls.selection-controls--collapsed { + width: 13rem; + overflow: clip; +} + +.selection-controls__header { + display: flex; + flex: 0 0 auto; + min-width: max-content; + align-items: center; + gap: var(--ink-space-2); +} + +.selection-controls__header strong { + white-space: nowrap; +} + +.selection-controls__section h2 { + color: var(--ink-text-muted); + font: 700 var(--ink-type-xs) / 1.1 var(--ink-font-body); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.selection-controls__header strong { + color: var(--ink-heading); + font: 650 var(--ink-type-sm) / 1.2 var(--ink-font-body); +} + +.selection-controls__sections { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: stretch; + flex-wrap: nowrap; + gap: var(--ink-space-3); + overflow-x: auto; + scrollbar-width: none; +} + +.selection-controls__sections::-webkit-scrollbar { + display: none; +} + +.selection-controls__scroll-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--ink-space-1); +} + +.selection-controls__scroll-actions button { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + padding: 0; + border: 0; + border-radius: var(--ink-radius-control-small); + background: transparent; + color: var(--ink-text-muted); + cursor: pointer; +} + +.selection-controls__scroll-actions button:hover:not(:disabled) { + background: var(--ink-surface-hover); + color: var(--ink-text); +} +.selection-controls__scroll-actions button:focus-visible { + outline: 2px solid var(--ink-accent); + outline-offset: 1px; +} +.selection-controls__scroll-actions button:disabled { + opacity: 0.25; + cursor: default; +} + +.selection-controls__section { + display: grid; + flex: 0 0 auto; + min-width: max-content; + align-content: start; + gap: var(--ink-space-2); + padding-inline-end: var(--ink-space-3); + border-inline-end: 1px solid color-mix(in srgb, var(--ink-border) 48%, transparent); +} + +.selection-controls__section:last-child { + padding-inline-end: 0; + border-inline-end: 0; +} + +.selection-controls__section h2 { + margin: 0; +} + +.selection-controls__controls, +.selection-controls__actions { + display: flex; + min-height: 2.5rem; + align-items: center; + gap: var(--ink-space-2); +} + +.selection-controls__color-control, +.selection-controls__range-control, +.selection-controls__field { + display: inline-flex; + align-items: center; + gap: var(--ink-space-2); + color: var(--ink-text-muted); + font: 650 var(--ink-type-xs) / 1 var(--ink-font-body); + white-space: nowrap; +} + +.selection-controls__range-control { + display: grid; + grid-template-columns: auto 5.5rem 3.25rem; +} + +.selection-controls__range-control input { + width: 100%; + accent-color: var(--ink-accent); +} + +.selection-controls__range-control output { + color: var(--ink-text); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.selection-controls__section--image { + min-width: 24rem; +} + +.selection-controls__section--card { + min-width: 15rem; +} + +.selection-controls__card-summary { + display: flex; + align-items: center; + gap: var(--ink-space-2); +} + +.selection-controls__card-summary span { + max-width: 12rem; + overflow: hidden; + color: var(--ink-text); + font: 650 var(--ink-type-sm) / 1.2 var(--ink-font-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.selection-controls__card-dialog { + width: min(34rem, 92vw); + padding: var(--ink-space-5); +} + +.dialog__content.card-details-dialog { + border-radius: var(--ink-radius-panel); +} + +.selection-controls__section--metadata { + min-width: 15rem; +} + +.selection-controls__metadata-summary { + display: flex; + align-items: center; + gap: var(--ink-space-2); +} + +.selection-controls__metadata-name { + min-width: 0; + max-width: 12rem; + overflow: hidden; + color: var(--ink-text); + font: 650 var(--ink-type-sm) / 1.2 var(--ink-font-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.selection-controls__metadata-drawer { + width: min(34rem, 92vw); + padding: var(--ink-space-5); +} + +.selection-controls__metadata-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ink-space-4); + margin-bottom: var(--ink-space-4); +} + +.selection-controls__metadata-header span { + color: var(--ink-text-muted); + font: 650 var(--ink-type-xs) / 1 var(--ink-font-body); +} + +.selection-controls__metadata-header h2 { + margin: var(--ink-space-1) 0 0; + font-size: var(--ink-type-lg); +} + +.dialog__content.object-metadata-dialog { + border-radius: var(--ink-radius-panel); +} + +.selection-controls__image-fields { + display: grid; + grid-template-columns: repeat(2, minmax(9rem, 1fr)); + gap: var(--ink-space-2); +} + +.selection-controls__image-fields .selection-controls__field--wide { + grid-column: 1 / -1; +} + +.selection-controls__image-sampling { + display: flex; + min-height: var(--ink-control-height); + align-items: center; + gap: var(--ink-space-1); + flex-wrap: wrap; +} + +.selection-controls__image-sampling small { + color: var(--ink-text-muted); + font: 600 var(--ink-type-xs) / 1 var(--ink-font-body); +} + +.selection-controls__sample { + width: 1.5rem; + height: 1.5rem; + border: 2px solid var(--ink-canvas); + border-radius: 50%; + background: var(--sample-color); + box-shadow: 0 0 0 1px var(--ink-border); + cursor: pointer; +} + +.selection-controls__sample:focus-visible { + outline: 3px solid var(--ink-focus); + outline-offset: 2px; +} + +.selection-controls__card-fields, +.selection-controls__metadata-fields { + display: grid; + grid-template-columns: repeat(2, minmax(9rem, 1fr)); + gap: var(--ink-space-2); +} + +.selection-controls__card-fields .selection-controls__field--wide, +.selection-controls__metadata-fields .selection-controls__field--wide { + grid-column: 1 / -1; +} + +.selection-controls__card-fields .selection-controls__field input, +.selection-controls__metadata-fields .selection-controls__field input, +.selection-controls__metadata-fields .selection-controls__field textarea { + width: 100%; +} + +.selection-controls__provenance { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ink-space-1) var(--ink-space-3); + margin: 0; + padding-top: var(--ink-space-2); + border-top: 1px solid color-mix(in srgb, var(--ink-border) 48%, transparent); + font: 600 var(--ink-type-xs) / 1.2 var(--ink-font-body); +} + +.selection-controls__provenance div { + display: grid; + gap: 0.125rem; + min-width: 0; +} + +.selection-controls__provenance dt { + color: var(--ink-text-muted); +} + +.selection-controls__provenance dd { + margin: 0; + overflow: hidden; + color: var(--ink-text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.selection-controls__field input, +.selection-controls__field select { + box-sizing: border-box; + width: 9rem; + height: var(--ink-control-height); + padding: 0 var(--ink-space-2); + border: 1px solid var(--ink-border); + border-radius: var(--ink-radius-control-small); + color: var(--ink-text); + background: var(--ink-canvas); + font: 600 var(--ink-type-xs) / 1 var(--ink-font-body); +} + +.selection-controls__field--small input { + width: 4.5rem; +} + +.selection-controls__field select { + padding: 0 var(--ink-space-2); + border: 1px solid var(--ink-border); + border-radius: var(--ink-radius-control-small); + color: var(--ink-text); + background: var(--ink-canvas); + font: 600 var(--ink-type-xs) / 1 var(--ink-font-body); +} + +.selection-controls__field input::placeholder { + color: var(--ink-text-muted); +} + +.selection-controls__field textarea { + box-sizing: border-box; + width: 14rem; + min-height: 4rem; + padding: var(--ink-space-2); + border: 1px solid var(--ink-border); + border-radius: var(--ink-radius-control-small); + color: var(--ink-text); + background: var(--ink-canvas); + font: 600 var(--ink-type-xs) / 1.3 var(--ink-font-body); + resize: vertical; +} + +.selection-controls__field--wide { + align-items: flex-start; +} + +.selection-controls__field input:focus-visible, +.selection-controls__field select:focus-visible, +.selection-controls__field textarea:focus-visible { + outline: 3px solid var(--ink-focus); + outline-offset: 2px; +} + +.selection-controls__action, +.selection-controls__agent-control { + display: inline-flex; + min-height: var(--ink-control-height); + align-items: center; + justify-content: center; + gap: var(--ink-space-1); + padding: 0 var(--ink-space-2); + border: 1px solid var(--ink-border); + border-radius: var(--ink-radius-control-small); + color: var(--ink-text); + background: var(--ink-canvas); + font: 650 var(--ink-type-xs) / 1 var(--ink-font-body); + white-space: nowrap; + cursor: pointer; + transition-property: color, background-color, border-color, transform; + transition-duration: var(--ink-duration-fast); + transition-timing-function: var(--ink-ease-out); +} + +.selection-controls__action:hover, +.selection-controls__agent-control:hover { + border-color: var(--ink-accent); + background: var(--ink-surface-hover); +} + +.selection-controls__action:active { + transform: scale(0.96); +} + +.selection-controls__action:focus-visible, +.selection-controls__agent-control:focus-within { + outline: 3px solid var(--ink-focus); + outline-offset: 2px; +} + +.selection-controls__agent-control { + padding-inline: var(--ink-space-2); + color: var(--ink-text-muted); +} + +.selection-controls__agent-control input { + width: 0.875rem; + height: 0.875rem; + margin: 0; + accent-color: var(--ink-accent); +} + +.selection-controls__section--arrow :global(.arrow-popover) { + display: flex; +} + +@media (min-width: 1181px) { + .selection-controls { + left: auto; + right: var(--ink-space-3); + translate: 0 0; + } +} + +@media (max-width: 1180px) { + .selection-controls { + width: fit-content; + max-width: calc(100vw - 12rem); + } + + .selection-controls__sections { + flex-wrap: nowrap; + overflow-x: auto; + } + + .selection-controls__section { + padding-block: var(--ink-space-1); + } +} + +@media (max-width: 760px) { + .selection-controls, + .selection-controls--horizontal { + top: 11rem; + left: 0.75rem; + right: 0.75rem; + width: auto; + max-width: none; + max-height: calc(100vh - 11rem); + translate: 0 0; + } + + .selection-controls__sections { + align-items: stretch; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + } + + .selection-controls__section { + min-width: max-content; + padding-block: var(--ink-space-2); + border-inline-end: 1px solid color-mix(in srgb, var(--ink-border) 48%, transparent); + } + + .selection-controls__section:last-child { + border-inline-end: 0; + } + + .selection-controls__controls, + .selection-controls__actions { + flex-wrap: nowrap; + } + + .selection-controls__range-control { + grid-template-columns: auto 5.5rem 3.25rem; + width: auto; + } + + .selection-controls__typography { + align-items: center; + flex-direction: row; + } + + .selection-controls__field input { + width: auto; + } + + .selection-controls__section--card { + min-width: max-content; + } +} + +@media (prefers-reduced-motion: reduce) { + .selection-controls__action, + .selection-controls__agent-control { + transition: none; + } +} -- 2.51.2