diff --git a/ROADMAP.md b/ROADMAP.md index 8c0f80c..82dfe78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -92,17 +92,18 @@ using one broad root barrel as the primary internal dependency surface. ### Editor decomposition -Keep the existing editor architecture while splitting large implementation -modules by responsibility. - -Separate renderer lifecycle and scene traversal from shape drawing, text and -asset rendering, effects, and editor overlays. Keep shape dispatch exhaustive -and centralized rather than introducing a renderer class hierarchy. - -Reduce the editor runtime to interaction state, command routing, and transaction -boundaries. Keyboard shortcuts, host requests, and reusable document commands -should be independently testable and shared by keyboard, menu, command-palette, -and context-menu entry points. +The editor implementation keeps one Canvas renderer API while splitting its +internals by responsibility. `renderer.ts` owns the render loop and lifecycle; +scene traversal, shape drawing, text and assets, Canvas helpers, renderer +resources, and overlays live in focused modules under `packages/editor/src/renderer/`. +Shape drawing uses one exhaustive dispatch point, and renderer caches and image +loading belong to each renderer instance. + +`EditorRuntime` owns gestures, interaction state, command routing, previews, and +transaction boundaries. Pure keyboard shortcut resolution and the host request +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. diff --git a/TODO.md b/TODO.md index f387530..3c1a98f 100644 --- a/TODO.md +++ b/TODO.md @@ -91,29 +91,29 @@ ### Decompose the editor implementation -- [ ] Split `packages/editor/src/renderer.ts` into renderer lifecycle, scene +- [x] Split `packages/editor/src/renderer.ts` into renderer lifecycle, scene traversal, shape rendering, text/assets, and overlay responsibilities without changing its public API or rendering fixtures -- [ ] Keep one exhaustive shape-render dispatch point and require existing +- [x] Keep one exhaustive shape-render dispatch point and require existing rendering coverage to pass for every supported shape kind -- [ ] Centralize Canvas path, transform, effect, and paint helpers shared by +- [x] Centralize Canvas path, transform, effect, and paint helpers shared by shape renderers; verify Canvas and SVG output remain consistent for the supported effects subset -- [ ] Move renderer caches and image-loading behavior behind focused helpers +- [x] Move renderer caches and image-loading behavior behind focused helpers with explicit lifetimes and preserve redraw behavior for asynchronously loaded assets -- [ ] Extract keyboard shortcut resolution from `EditorRuntime` into a pure, +- [x] Extract keyboard shortcut resolution from `EditorRuntime` into a pure, independently tested module -- [ ] Define a small host-request boundary for clipboard, board-browser, +- [x] Define a small host-request boundary for clipboard, board-browser, command-palette, shortcut-panel, undo, and redo requests, with tests for browser-host dispatch -- [ ] Move reusable document operations out of the runtime so keyboard +- [x] Move reusable document operations out of the runtime so keyboard shortcuts, menus, context menus, and the command palette dispatch the same editor commands -- [ ] Keep `EditorRuntime` focused on gestures, interaction state, command +- [x] Keep `EditorRuntime` focused on gestures, interaction state, command routing, previews, and transaction boundaries; preserve gesture and undo transaction semantics in runtime tests -- [ ] Keep the DOM input adapter limited to browser-event normalization and +- [x] Keep the DOM input adapter limited to browser-event normalization and dispatch, retaining pointer-capture, pressure, wheel, and keyboard coverage diff --git a/packages/editor/package.json b/packages/editor/package.json index a637587..16f1d10 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -23,6 +23,14 @@ "./runtime": { "types": "./dist/runtime.d.ts", "default": "./dist/runtime.js" + }, + "./shortcuts": { + "types": "./dist/shortcuts.d.ts", + "default": "./dist/shortcuts.js" + }, + "./host": { + "types": "./dist/host.d.ts", + "default": "./dist/host.js" } }, "files": [ diff --git a/packages/editor/src/host.ts b/packages/editor/src/host.ts new file mode 100644 index 0000000..a225aa4 --- /dev/null +++ b/packages/editor/src/host.ts @@ -0,0 +1,52 @@ +/** Requests an application host can handle outside the headless editor runtime. */ +export type EditorHostRequest = + | { type: 'browse' } + | { type: 'shortcuts' } + | { type: 'command-palette' } + | { type: 'undo' } + | { type: 'redo' } + | { type: 'copy' } + | { type: 'cut' } + | { type: 'paste' }; + +/** Host effects requested by keyboard commands and editor menus. */ +export type EditorHostRequests = { + onBrowseRequested?: () => void; + onShortcutsRequested?: () => void; + onCommandPaletteRequested?: () => void; + onUndoRequested?: () => void; + onRedoRequested?: () => void; + onCopyRequested?: () => void; + onCutRequested?: () => void; + onPasteRequested?: () => void; +}; + +/** Dispatch one host request without making the editor runtime platform-aware. */ +export function dispatchHostRequest(request: EditorHostRequest, handlers: EditorHostRequests): void { + switch (request.type) { + case 'browse': + handlers.onBrowseRequested?.(); + break; + case 'shortcuts': + handlers.onShortcutsRequested?.(); + break; + case 'command-palette': + handlers.onCommandPaletteRequested?.(); + break; + case 'undo': + handlers.onUndoRequested?.(); + break; + case 'redo': + handlers.onRedoRequested?.(); + break; + case 'copy': + handlers.onCopyRequested?.(); + break; + case 'cut': + handlers.onCutRequested?.(); + break; + case 'paste': + handlers.onPasteRequested?.(); + break; + } +} diff --git a/packages/editor/src/input-dom.ts b/packages/editor/src/input-dom.ts index 1310082..8fbfda5 100644 --- a/packages/editor/src/input-dom.ts +++ b/packages/editor/src/input-dom.ts @@ -7,6 +7,7 @@ import { type Vec2, type Viewport } from './runtime.js'; +import { shouldPreventKeyboardDefault, type PrimaryModifierPlatform } from './shortcuts.js'; /** * Pointer state tracked by the input adapter @@ -60,7 +61,7 @@ export type InputAdapterConfig = { */ export class InputAdapter { private config: InputAdapterConfig & { preventDefault: boolean; captureKeyboard: boolean }; - private readonly primaryModifierPlatform: 'mac' | 'other'; + private readonly primaryModifierPlatform: PrimaryModifierPlatform; private pointerState: PointerState; private boundHandlers: { pointerDown: (e: PointerEvent) => void; @@ -342,7 +343,10 @@ export class InputAdapter { this.config.onAction(Action.keyDown(e.key, e.code, modifiers, e.repeat)); - if (this.config.preventDefault && this.shouldPreventDefault(e)) { + if ( + this.config.preventDefault && + shouldPreventKeyboardDefault(e.key, e.code, modifiers, this.primaryModifierPlatform) + ) { e.preventDefault(); } } @@ -405,58 +409,6 @@ export class InputAdapter { this.pendingCursorWorld = null; this.pendingCursorScreen = null; } - - /** - * Determine if default behavior should be prevented for a key event - * - * Prevents default for: - * - Space (scroll) - * - Arrow keys (scroll) - * - Backspace/Delete (navigation) - * - Cmd/Ctrl+B (board browser), Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) - * - Camera shortcuts (+, -, 0, Shift+1, Shift+2) - * - Tab (focus change) - */ - private shouldPreventDefault(e: KeyboardEvent): boolean { - const key = e.key; - const modifiers = Modifiers.fromEvent(e); - - if (key === ' ' || key.startsWith('Arrow')) { - return true; - } - - if (key === 'Backspace' || key === 'Delete') { - return true; - } - - if (key === 'Tab') { - return true; - } - - if (key === '+' || key === '=' || key === '-' || key === '_' || key === '0') { - return true; - } - - if (modifiers.shift && (e.code === 'Digit1' || e.code === 'Digit2')) { - return true; - } - - if (Modifiers.isPrimaryModifier(modifiers, this.primaryModifierPlatform) && (key === 'z' || key === 'Z')) { - return true; - } - - if (Modifiers.isPrimaryModifier(modifiers, this.primaryModifierPlatform) && (key === 'y' || key === 'Y')) { - return true; - } - - if (Modifiers.isPrimaryModifier(modifiers, this.primaryModifierPlatform)) { - if ('abgldcxvzy'.includes(key.toLowerCase()) || key === '[' || key === ']') return true; - } - - if (key === '?' || (key === '/' && e.shiftKey)) return true; - - return false; - } } /** diff --git a/packages/editor/src/paint.ts b/packages/editor/src/paint.ts index c2965c6..5ff1c86 100644 --- a/packages/editor/src/paint.ts +++ b/packages/editor/src/paint.ts @@ -1,69 +1,2 @@ -import type { PaintValue } from '@inkfinite/core'; - -type PaintBounds = - | { x: number; y: number; width: number; height: number } - | { min: { x: number; y: number }; max: { x: number; y: number } }; - -/** Resolve a paint to a Canvas fill or stroke style in local space. */ -export function paintForCanvas( - context: CanvasRenderingContext2D, - value: PaintValue | undefined, - bounds: PaintBounds -): string | CanvasGradient | null { - if (typeof value === 'string') return value || null; - if (!value) return null; - if (value.kind === 'solid') return value.color || null; - const area = - 'min' in bounds - ? { - x: bounds.min.x, - y: bounds.min.y, - width: bounds.max.x - bounds.min.x, - height: bounds.max.y - bounds.min.y - } - : bounds; - const point = (x: number, y: number) => { - const localX = value.units === 'object_bounding_box' ? area.x + x * area.width : x; - const localY = value.units === 'object_bounding_box' ? area.y + y * area.height : y; - const transform = value.transform; - return { - x: transform.a * localX + transform.c * localY + transform.e, - y: transform.b * localX + transform.d * localY + transform.f - }; - }; - const stopColor = (color: string, opacity: number) => withOpacity(color, opacity); - if (value.kind === 'linear_gradient') { - const start = point(value.x1, value.y1); - const end = point(value.x2, value.y2); - const gradient = context.createLinearGradient(start.x, start.y, end.x, end.y); - for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); - return gradient; - } - const center = point(value.cx, value.cy); - const focus = point(value.fx, value.fy); - const radius = value.units === 'object_bounding_box' ? value.r * Math.min(area.width, area.height) : value.r; - const gradient = context.createRadialGradient(focus.x, focus.y, 0, center.x, center.y, Math.max(0, radius)); - for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); - return gradient; -} - -function withOpacity(color: string, opacity: number): string { - if (opacity >= 1) return color; - const hex = color.match(/^#([\da-f]{3,8})$/i)?.[1]; - if (hex) { - const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((part) => part + part).join('') : hex; - const rgb = expanded - .slice(0, 6) - .match(/[\da-f]{2}/gi) - ?.map((part) => parseInt(part, 16)); - if (rgb?.length === 3) - return `rgba(${rgb.join(', ')}, ${opacity * (expanded.length === 8 ? parseInt(expanded.slice(6), 16) / 255 : 1)})`; - } - const rgba = color.match(/^rgba?\(([^)]+)\)$/i); - if (rgba) { - const parts = rgba[1].split(',').map((part) => part.trim()); - if (parts.length >= 3) - return `rgba(${parts.slice(0, 3).join(', ')}, ${opacity * (parts[3] ? Number(parts[3]) : 1)})`; - } - return color; -} +/** @deprecated Import the shared Canvas paint helper from `@inkfinite/editor/renderer`. */ +export { paintForCanvas } from './renderer/canvas.js'; diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index 7acd589..6bd3133 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -1,1863 +1,94 @@ -import type { - ArrowLabel, - ArrowShape, - BindingIndex, - EditorBindingRecord, - Camera, - CursorState, - EditorState, - EllipseShape, - FilterEffect, - LineShape, - MarkdownShape, - PathGeometry, - PathShape, - RectShape, - TextPathLayout, - EditorShapeRecord, - Store, - StrokeShape, - TextShape, - Vec2, - Viewport, - SnapGuide -} from '@inkfinite/core'; -import { - arrowBendHandleForShape, - arrowGeometryForShape, - arrowHeadGeometry, - arrowLabelPlacement, - arrowPathForShape, - arrowShaftGeometry, - getLayersOnCurrentPage, - getStrokeOutline, - getShapesOnCurrentPage, - localToWorld, - localShapeBounds, - pathAnchorPosition, - pathAnchorRefs, - pathGeometryBounds, - pathControlHandles, - resolveArrowEndpoints, - shapeBounds, - shapeBoundsForState, - shapeTransform, - textPathAnchorForShape, - textPathLayoutForShape, - worldToLocal, - pathAnchorHandleId, - pathControlHandleId, - strokeWidthHandleId, - strokeWidthHandles -} from '@inkfinite/core'; -import { paintForCanvas } from './paint.js'; - -export interface Renderer { - /** - * Clean up the renderer and stop rendering - */ - dispose(): void; - - /** - * Force a redraw on the next frame - */ - markDirty(): void; -} - -export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; - -export type PointerVisualState = { isPointerDown: boolean; snappedWorld?: Vec2 | null; snapGuides?: SnapGuide[] }; - -export type HandleRenderState = { hover: string | null; active: string | null } | null | undefined; - -export type RendererOptions = { - snapProvider?: { get(): SnapSettings }; - cursorProvider?: { get(): CursorState }; - pointerStateProvider?: { get(): PointerVisualState }; - handleProvider?: { get(): HandleRenderState }; - themeProvider?: { get(): 'light' | 'dark' }; -}; - -const imageCache = new Map(); - -class LruCache { - readonly #entries = new Map(); - - constructor(private readonly capacity: number) {} - - get(key: Key): Value | undefined { - const value = this.#entries.get(key); - if (value === undefined) return undefined; - this.#entries.delete(key); - this.#entries.set(key, value); - return value; - } - - set(key: Key, value: Value): void { - this.#entries.delete(key); - this.#entries.set(key, value); - if (this.#entries.size <= this.capacity) return; - const oldestKey = this.#entries.keys().next().value; - if (oldestKey !== undefined) this.#entries.delete(oldestKey); - } -} - -/** - * Create a canvas renderer - * - * The renderer subscribes to the store and redraws the canvas - * whenever the state changes. It uses requestAnimationFrame with - * a dirty flag to optimize rendering. - * - * @param canvas - The HTMLCanvasElement to render to - * @param store - The editor state store - * @param gridProvider - Optional provider for grid settings (snap store) - * @returns Renderer instance with dispose method - */ +import type { Store, Viewport } from '@inkfinite/core'; +import { drawScene } from './renderer/scene.js'; +import { RendererResources } from './renderer/resources.js'; +import type { Renderer, RendererOptions } from './renderer/types.js'; + +export type { + CanvasViewport, + HandleRenderState, + PointerVisualState, + Renderer, + RendererOptions, + SceneRenderOptions, + ShapeRenderState, + SnapSettings +} from './renderer/types.js'; + +/** Create a Canvas renderer with one lifecycle, scene traversal, and resource set. */ export function createRenderer(canvas: HTMLCanvasElement, store: Store, options?: RendererOptions): Renderer { const maybeContext = canvas.getContext('2d'); - if (!maybeContext) { - throw new Error('Failed to get 2D context from canvas'); - } + if (!maybeContext) throw new Error('Failed to get 2D context from canvas'); const context: CanvasRenderingContext2D = maybeContext; + const resources = new RendererResources(); let isDirty = true; let animationFrameId: number | null = null; let isDisposed = false; - const textLayoutCache = new LruCache(512); - const textMetricCache = new LruCache(2_048); - const markdownLayoutCache = new LruCache(256); - /** - * Mark the canvas as needing a redraw - */ - function markDirty() { + /** Schedule the latest state for one animation-frame render. */ + function markDirty(): void { if (isDisposed) return; isDirty = true; - if (animationFrameId === null) { - scheduleRender(); - } + if (animationFrameId === null) scheduleRender(); } - /** - * Schedule a render on the next animation frame - */ - function scheduleRender() { + function scheduleRender(): void { animationFrameId = requestAnimationFrame(() => { animationFrameId = null; - if (isDirty && !isDisposed) { - isDirty = false; - render(); - } + if (!isDirty || isDisposed) return; + isDirty = false; + render(); }); } - /** - * Render the current state to the canvas - */ - function render() { + function render(): void { const state = store.getState(); - const viewport = setupCanvas(canvas, context); - - const snapSettings = options?.snapProvider?.get(); - const cursorState = options?.cursorProvider?.get(); - const pointerState = options?.pointerStateProvider?.get(); - const handleState = options?.handleProvider?.get(); - const theme = options?.themeProvider?.get() ?? 'light'; drawScene( context, state, viewport, - snapSettings, - cursorState, - pointerState, - handleState, - theme, - textLayoutCache, - textMetricCache, - markdownLayoutCache, + options?.snapProvider?.get(), + options?.cursorProvider?.get(), + options?.pointerStateProvider?.get(), + options?.handleProvider?.get(), + options?.themeProvider?.get() ?? 'light', + resources, markDirty ); } - /** - * Subscribe to store updates and mark dirty - */ - const unsubscribe = store.subscribe(() => { - markDirty(); - }); + const unsubscribe = store.subscribe(() => markDirty()); - /** - * Dispose the renderer - */ - function dispose() { + /** Stop rendering and clear instance-owned caches. */ + function dispose(): void { isDisposed = true; unsubscribe(); if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } + resources.dispose(); } markDirty(); - return { dispose, markDirty }; } -/** - * Setup canvas with proper pixel ratio for sharp rendering - */ +/** Resize the backing store and reset its transform before every frame. */ function setupCanvas(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D): Viewport { const pixelRatio = getPixelRatio(); const rect = canvas.getBoundingClientRect(); const width = Math.max(1, Math.round(rect.width * pixelRatio)); const height = Math.max(1, Math.round(rect.height * pixelRatio)); - if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } - - // Canvas state survives between frames. Establish the backing-store transform - // explicitly so a previous camera transform can never leak into the next draw. context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); - return { width: rect.width || 1, height: rect.height || 1 }; } -/** - * Get device pixel ratio for sharp rendering on high-DPI displays - */ function getPixelRatio(): number { return globalThis.window !== undefined && window.devicePixelRatio ? window.devicePixelRatio : 1; } - -/** - * Draw the entire scene - */ -function drawScene( - context: CanvasRenderingContext2D, - state: EditorState, - viewport: Viewport, - snapSettings?: SnapSettings, - cursorState?: CursorState, - pointerState?: PointerVisualState, - handleState?: HandleRenderState, - theme: 'light' | 'dark' = 'light', - textLayoutCache = new LruCache(512), - textMetricCache = new LruCache(2_048), - markdownLayoutCache = new LruCache(256), - onImageLoaded?: () => void -) { - context.save(); - context.setTransform(1, 0, 0, 1, 0, 0); - context.clearRect(0, 0, context.canvas.width, context.canvas.height); - context.restore(); - - context.save(); - - applyCameraTransform(context, state.camera, viewport); - - drawGrid(context, state.camera, viewport, snapSettings, theme); - - const shapes = getShapesOnCurrentPage(state); - const bindingsBySource = new Map(); - for (const binding of Object.values(state.doc.bindings)) { - const bindings = bindingsBySource.get(binding.fromShapeId); - if (bindings) bindings.push(binding); - else bindingsBySource.set(binding.fromShapeId, [binding]); - } - const visibleBounds = getExpandedViewportBounds(state.camera, viewport); - const layers = getLayersOnCurrentPage(state); - if (layers.length === 0) { - for (const shape of shapes) { - if (!isShapeVisible(state, shape, visibleBounds, bindingsBySource)) continue; - drawShape( - context, - state, - shape, - bindingsBySource, - theme, - textLayoutCache, - textMetricCache, - markdownLayoutCache, - onImageLoaded - ); - } - } else { - for (const layer of layers) { - if (!layer.visible) continue; - context.save(); - context.globalAlpha *= layer.opacity; - for (const shapeId of layer.shapeIds) { - const shape = state.doc.shapes[shapeId]; - if (!shape || !isShapeVisible(state, shape, visibleBounds, bindingsBySource)) continue; - drawShape( - context, - state, - shape, - bindingsBySource, - theme, - textLayoutCache, - textMetricCache, - markdownLayoutCache, - onImageLoaded - ); - } - context.restore(); - } - } - - drawSelection(context, state, shapes, handleState, bindingsBySource, theme); - - drawBindingPreview(context, state); - - drawSnapGuides(context, state.camera, viewport, snapSettings, cursorState, pointerState); - - context.restore(); -} - -type VisibleBounds = { minX: number; minY: number; maxX: number; maxY: number }; - -function getExpandedViewportBounds(camera: Camera, viewport: Viewport): VisibleBounds { - const padding = 48 / camera.zoom; - const halfWidth = viewport.width / (2 * camera.zoom); - const halfHeight = viewport.height / (2 * camera.zoom); - return { - minX: camera.x - halfWidth - padding, - minY: camera.y - halfHeight - padding, - maxX: camera.x + halfWidth + padding, - maxY: camera.y + halfHeight + padding - }; -} - -function isShapeVisible( - state: EditorState, - shape: EditorShapeRecord, - viewport: VisibleBounds, - bindingsBySource?: BindingIndex -): boolean { - if (shape.type === 'arrow') { - const points = arrowPathForShape(state, shape, bindingsBySource); - if (points.length >= 2) { - const worldPoints = points.map((point) => localToWorld(shape, point)); - const minX = Math.min(...worldPoints.map((point) => point.x)); - const minY = Math.min(...worldPoints.map((point) => point.y)); - const maxX = Math.max(...worldPoints.map((point) => point.x)); - const maxY = Math.max(...worldPoints.map((point) => point.y)); - return maxX >= viewport.minX && minX <= viewport.maxX && maxY >= viewport.minY && minY <= viewport.maxY; - } - } - - const bounds = shapeBoundsForState(state, shape); - return ( - bounds.max.x >= viewport.minX && - bounds.min.x <= viewport.maxX && - bounds.max.y >= viewport.minY && - bounds.min.y <= viewport.maxY - ); -} - -/** - * Apply camera transform to the canvas context - * - * This transforms the coordinate system so that drawing in world - * coordinates appears correctly on screen. - */ -function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport) { - context.translate(viewport.width / 2, viewport.height / 2); - - context.scale(camera.zoom, camera.zoom); - - context.translate(-camera.x, -camera.y); -} - -/** - * Default grid size in world units - * This must match the default in the snap store to ensure grid lines and snapping align - */ -const DEFAULT_GRID_SIZE = 25; - -/** - * Draw a dot-grid background at the same world-space positions used by snapping. - * - * At distant zoom levels, the renderer skips intermediate dots so the grid - * stays legible without changing the snapping interval. - */ -function drawGrid( - context: CanvasRenderingContext2D, - camera: Camera, - viewport: Viewport, - snapSettings?: SnapSettings, - theme: 'light' | 'dark' = 'light' -) { - if (snapSettings && !snapSettings.gridEnabled) { - return; - } - const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; - const minimumScreenSpacing = 10; - const stepMultiplier = Math.max(1, Math.ceil(minimumScreenSpacing / (gridSize * camera.zoom))); - const visibleGridSize = gridSize * stepMultiplier; - - const topLeft = { - x: camera.x - viewport.width / (2 * camera.zoom), - y: camera.y - viewport.height / (2 * camera.zoom) - }; - const bottomRight = { - x: camera.x + viewport.width / (2 * camera.zoom), - y: camera.y + viewport.height / (2 * camera.zoom) - }; - - const startX = Math.floor(topLeft.x / visibleGridSize) * visibleGridSize; - const endX = Math.ceil(bottomRight.x / visibleGridSize) * visibleGridSize; - const startY = Math.floor(topLeft.y / visibleGridSize) * visibleGridSize; - const endY = Math.ceil(bottomRight.y / visibleGridSize) * visibleGridSize; - const dotRadius = 1.25 / camera.zoom; - - context.fillStyle = theme === 'dark' ? 'rgba(167, 180, 188, 0.38)' : 'rgba(73, 80, 99, 0.32)'; - context.beginPath(); - for (let x = startX; x <= endX; x += visibleGridSize) { - for (let y = startY; y <= endY; y += visibleGridSize) { - context.moveTo(x + dotRadius, y); - context.arc(x, y, dotRadius, 0, Math.PI * 2); - } - } - context.fill(); -} - -function drawSnapGuides( - context: CanvasRenderingContext2D, - camera: Camera, - viewport: Viewport, - snapSettings?: SnapSettings, - cursorState?: CursorState, - pointerState?: PointerVisualState -) { - if (!snapSettings?.snapEnabled || !pointerState?.isPointerDown) { - return; - } - - const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; - const guideWorld = pointerState.snappedWorld ?? cursorState?.cursorWorld; - if (!guideWorld) { - return; - } - - const snappedX = pointerState.snappedWorld - ? pointerState.snappedWorld.x - : Math.round(guideWorld.x / gridSize) * gridSize; - const snappedY = pointerState.snappedWorld - ? pointerState.snappedWorld.y - : Math.round(guideWorld.y / gridSize) * gridSize; - - const guides = pointerState.snapGuides ?? []; - if (guides.length === 0 && !snapSettings.gridEnabled) return; - if (guides.length > 0) { - const halfWidth = viewport.width / (2 * camera.zoom); - const halfHeight = viewport.height / (2 * camera.zoom); - context.save(); - context.setLineDash([4 / camera.zoom, 4 / camera.zoom]); - context.lineWidth = 1 / camera.zoom; - context.strokeStyle = 'rgba(236, 72, 153, 0.82)'; - for (const guide of guides) { - const start = guide.start ?? (guide.axis === 'x' ? camera.y - halfHeight : camera.x - halfWidth); - const end = guide.end ?? (guide.axis === 'x' ? camera.y + halfHeight : camera.x + halfWidth); - context.beginPath(); - if (guide.axis === 'x') { - context.moveTo(guide.position, start); - context.lineTo(guide.position, end); - } else { - context.moveTo(start, guide.position); - context.lineTo(end, guide.position); - } - context.stroke(); - } - context.restore(); - return; - } - - const halfWidth = viewport.width / (2 * camera.zoom); - const halfHeight = viewport.height / (2 * camera.zoom); - const minX = camera.x - halfWidth; - const maxX = camera.x + halfWidth; - const minY = camera.y - halfHeight; - const maxY = camera.y + halfHeight; - - context.save(); - const dashLength = 4 / camera.zoom; - context.setLineDash([dashLength, dashLength]); - context.lineWidth = 1 / camera.zoom; - context.strokeStyle = 'rgba(59, 130, 246, 0.6)'; - - context.beginPath(); - context.moveTo(minX, snappedY); - context.lineTo(maxX, snappedY); - context.stroke(); - - context.beginPath(); - context.moveTo(snappedX, minY); - context.lineTo(snappedX, maxY); - context.stroke(); - - context.setLineDash([]); - context.fillStyle = 'rgba(59, 130, 246, 0.6)'; - context.beginPath(); - context.arc(snappedX, snappedY, 4 / camera.zoom, 0, Math.PI * 2); - context.fill(); - - context.restore(); -} - -/** - * Draw binding preview indicator when dragging arrow endpoints - */ -function drawBindingPreview(context: CanvasRenderingContext2D, state: EditorState) { - if (!state.ui.bindingPreview) return; - - const targetShape = state.doc.shapes[state.ui.bindingPreview.targetShapeId]; - if (!targetShape) return; - - const bounds = shapeBounds(targetShape); - - context.save(); - context.strokeStyle = 'rgba(59, 130, 246, 0.8)'; - context.lineWidth = 3 / state.camera.zoom; - context.setLineDash([8 / state.camera.zoom, 4 / state.camera.zoom]); - - const padding = 4; - context.strokeRect( - bounds.min.x - padding, - bounds.min.y - padding, - bounds.max.x - bounds.min.x + padding * 2, - bounds.max.y - bounds.min.y + padding * 2 - ); - - context.setLineDash([]); - context.restore(); -} - -/** - * Draw a single shape - */ -function drawShape( - context: CanvasRenderingContext2D, - state: EditorState, - shape: EditorShapeRecord, - bindingsBySource?: BindingIndex, - theme: 'light' | 'dark' = 'light', - textLayoutCache = new LruCache(512), - textMetricCache = new LruCache(2_048), - markdownLayoutCache = new LruCache(256), - onImageLoaded?: () => void -) { - context.save(); - context.globalAlpha *= shape.opacity ?? 1; - - const attachedText = - shape.type === 'text' && shape.props.textPath - ? (() => { - context.font = `${shape.props.fontSize}px ${shape.props.fontFamily}`; - return textPathLayoutForShape(state, shape, (value) => context.measureText(value).width); - })() - : null; - if (!attachedText) { - applyShapeTransform(context, shape); - applyShapeEffects(context, shape); - } - - switch (shape.type) { - case 'rect': { - drawRect(context, shape); - break; - } - case 'ellipse': { - drawEllipse(context, shape); - break; - } - case 'line': { - drawLine(context, shape); - break; - } - case 'arrow': { - drawArrow(context, state, shape, bindingsBySource); - break; - } - case 'text': { - if (attachedText) drawTextOnPath(context, shape, attachedText.path, attachedText.layout); - else drawText(context, shape, textLayoutCache, textMetricCache); - break; - } - case 'markdown': { - drawMarkdown(context, shape, theme, textLayoutCache, textMetricCache, markdownLayoutCache); - break; - } - case 'image': { - drawImage(context, state, shape, onImageLoaded); - break; - } - case 'reference': { - drawReference(context, shape); - break; - } - case 'stroke': { - drawStroke(context, shape); - break; - } - case 'path': { - drawPath(context, shape); - break; - } - case 'container': { - drawContainer(context, shape); - break; - } - } - - context.restore(); -} - -/** Apply the native clip, mask, and filter subset before drawing one shape. */ -function applyShapeEffects(context: CanvasRenderingContext2D, shape: EditorShapeRecord) { - const props = shape.props as EditorShapeRecord['props'] & { - clipPath?: PathGeometry; - maskEffect?: { geometry: PathGeometry; opacity?: number }; - filter?: { - primitives: Array< - | { type: 'blur'; radius: number } - | { type: 'brightness'; amount: number } - | { type: 'contrast'; amount: number } - | { type: 'grayscale'; amount: number } - | { type: 'hue_rotate'; degrees: number } - | { type: 'invert'; amount: number } - | { type: 'saturate'; amount: number } - | { type: 'sepia'; amount: number } - | { type: 'opacity'; amount: number } - | { type: 'drop_shadow'; dx: number; dy: number; radius: number; color: string; opacity: number } - >; - }; - }; - if (props.filter) context.filter = filterToCanvas(props.filter); - if (props.clipPath) { - drawNativePath(context, props.clipPath); - context.clip(props.clipPath.fill_rule === 'evenodd' ? 'evenodd' : 'nonzero'); - } - if (props.maskEffect) { - context.globalAlpha *= props.maskEffect.opacity ?? 1; - drawNativePath(context, props.maskEffect.geometry); - context.clip(props.maskEffect.geometry.fill_rule === 'evenodd' ? 'evenodd' : 'nonzero'); - } -} - -function filterToCanvas(filter: FilterEffect): string { - return filter.primitives - .map((primitive) => { - switch (primitive.type) { - case 'blur': - return `blur(${Math.max(0, primitive.radius)}px)`; - case 'brightness': - return `brightness(${Math.max(0, primitive.amount)})`; - case 'contrast': - return `contrast(${Math.max(0, primitive.amount)})`; - case 'grayscale': - return `grayscale(${Math.max(0, Math.min(1, primitive.amount))})`; - case 'hue_rotate': - return `hue-rotate(${primitive.degrees}deg)`; - case 'invert': - return `invert(${Math.max(0, Math.min(1, primitive.amount))})`; - case 'saturate': - return `saturate(${Math.max(0, primitive.amount)})`; - case 'sepia': - return `sepia(${Math.max(0, Math.min(1, primitive.amount))})`; - case 'opacity': - return `opacity(${Math.max(0, Math.min(1, primitive.amount))})`; - case 'drop_shadow': - return `drop-shadow(${primitive.dx}px ${primitive.dy}px ${Math.max(0, primitive.radius)}px ${primitive.color})`; - } - }) - .join(' '); -} - -/** Draw an embedded image, preserving its crop window when one is set. */ -function drawImage( - context: CanvasRenderingContext2D, - state: EditorState, - shape: Extract, - onImageLoaded?: () => void -) { - const asset = state.doc.assets?.[shape.props.assetId]; - const { w, h, crop, mask, caption } = shape.props; - context.globalAlpha *= shape.fillOpacity ?? 1; - if (!asset) { - context.fillStyle = '#e5e7eb'; - context.fillRect(0, 0, w, h); - return; - } - let image = imageCache.get(asset.digest); - if (!image && typeof Image !== 'undefined') { - image = new Image(); - image.onload = () => onImageLoaded?.(); - image.src = `data:${asset.mediaType};base64,${bytesToBase64(asset.bytes)}`; - imageCache.set(asset.digest, image); - } - if (!image || !image.complete || image.naturalWidth === 0) { - context.fillStyle = '#e5e7eb'; - context.fillRect(0, 0, w, h); - context.strokeStyle = '#9ca3af'; - context.strokeRect(0, 0, w, h); - return; - } - const top = crop?.top ?? 0; - const right = crop?.right ?? 0; - const bottom = crop?.bottom ?? 0; - const left = crop?.left ?? 0; - const sourceX = image.naturalWidth * left; - const sourceY = image.naturalHeight * top; - const sourceWidth = image.naturalWidth * Math.max(0.001, 1 - left - right); - const sourceHeight = image.naturalHeight * Math.max(0.001, 1 - top - bottom); - context.save(); - if (mask?.kind === 'ellipse') { - context.beginPath(); - context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); - context.clip(); - } else if (mask?.kind === 'rounded') { - const radius = Math.min(mask.radius ?? 16, w / 2, h / 2); - context.beginPath(); - context.roundRect(0, 0, w, h, radius); - context.clip(); - } - context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, w, h); - context.restore(); - if (caption?.trim()) { - const captionHeight = Math.min(24, h); - context.fillStyle = 'rgba(0, 0, 0, 0.58)'; - context.fillRect(0, h - captionHeight, w, captionHeight); - context.fillStyle = '#ffffff'; - context.font = '12px sans-serif'; - context.textBaseline = 'middle'; - context.fillText(caption, 8, h - captionHeight / 2, Math.max(0, w - 16)); - } -} - -function drawReference(context: CanvasRenderingContext2D, shape: Extract) { - const { w, h, referenceType, value, label } = shape.props; - const accent = referenceType === 'url' ? '#2563eb' : referenceType === 'file' ? '#16a34a' : '#7c3aed'; - context.fillStyle = '#f8fafc'; - context.strokeStyle = accent; - context.lineWidth = 2; - context.beginPath(); - context.roundRect(0, 0, w, h, 8); - context.fill(); - context.stroke(); - context.fillStyle = accent; - context.font = '600 12px sans-serif'; - context.fillText(referenceType.toUpperCase(), 12, 20); - context.fillStyle = '#1f2937'; - context.font = '13px sans-serif'; - context.fillText(label || value, 12, 42, Math.max(0, w - 24)); -} - -function bytesToBase64(bytes: number[]): string { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return typeof btoa === 'function' ? btoa(binary) : ''; -} - -/** - * Draw a rectangle shape - */ -function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { - const { w, h, fill, stroke, radius } = shape.props; - const shapeAlpha = context.globalAlpha; - - context.beginPath(); - if (radius > 0) { - const r = Math.min(radius, w / 2, h / 2); - context.moveTo(r, 0); - context.lineTo(w - r, 0); - context.arcTo(w, 0, w, r, r); - context.lineTo(w, h - r); - context.arcTo(w, h, w - r, h, r); - context.lineTo(r, h); - context.arcTo(0, h, 0, h - r, r); - context.lineTo(0, r); - context.arcTo(0, 0, r, 0, r); - context.closePath(); - } else { - context.rect(0, 0, w, h); - } - - if (fill) { - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - const fillStyle = paintForCanvas(context, fill, { x: 0, y: 0, width: w, height: h }); - if (fillStyle) { - context.fillStyle = fillStyle; - context.fill(); - } - } - - if (stroke) { - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - const strokeStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }); - if (strokeStyle) { - context.strokeStyle = strokeStyle; - context.lineWidth = 2; - context.stroke(); - } - } -} - -/** - * Draw an ellipse shape - */ -function drawEllipse(context: CanvasRenderingContext2D, shape: EllipseShape) { - const { w, h, fill, stroke } = shape.props; - const shapeAlpha = context.globalAlpha; - - context.beginPath(); - context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); - - if (fill) { - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - const fillStyle = paintForCanvas(context, fill, { x: 0, y: 0, width: w, height: h }); - if (fillStyle) { - context.fillStyle = fillStyle; - context.fill(); - } - } - - if (stroke) { - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - const strokeStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }); - if (strokeStyle) { - context.strokeStyle = strokeStyle; - context.lineWidth = 2; - context.stroke(); - } - } -} - -/** - * Draw a line shape - */ -function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { - const { a, b, stroke, width } = shape.props; - - context.beginPath(); - context.moveTo(a.x, a.y); - context.lineTo(b.x, b.y); - - context.globalAlpha *= shape.strokeOpacity ?? 1; - const strokeStyle = paintForCanvas(context, stroke, { - x: Math.min(a.x, b.x), - y: Math.min(a.y, b.y), - width: Math.abs(b.x - a.x), - height: Math.abs(b.y - a.y) - }); - if (strokeStyle) { - context.strokeStyle = strokeStyle; - context.lineWidth = width; - context.stroke(); - } -} - -function drawContainer(context: CanvasRenderingContext2D, shape: Extract) { - const { w = 0, h = 0, title, fill, stroke, radius = 0 } = shape.props; - const shapeAlpha = context.globalAlpha; - context.beginPath(); - if (radius > 0) { - const r = Math.min(radius, w / 2, h / 2); - context.moveTo(r, 0); - context.lineTo(w - r, 0); - context.arcTo(w, 0, w, r, r); - context.lineTo(w, h - r); - context.arcTo(w, h, w - r, h, r); - context.lineTo(r, h); - context.arcTo(0, h, 0, h - r, r); - context.lineTo(0, r); - context.arcTo(0, 0, r, 0, r); - context.closePath(); - } else { - context.rect(0, 0, w, h); - } - if (fill) { - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - const fillStyle = paintForCanvas(context, fill, { x: 0, y: 0, width: w, height: h }); - if (fillStyle) { - context.fillStyle = fillStyle; - context.fill(); - } - } - if (stroke) { - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - const strokeStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }); - if (strokeStyle) { - context.strokeStyle = strokeStyle; - context.lineWidth = 1.5; - context.stroke(); - } - } - if (title) { - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - context.fillStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }) ?? '#69717d'; - context.font = '600 14px sans-serif'; - context.textBaseline = 'top'; - context.fillText(title, 8, 6); - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - context.strokeStyle = - paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }) ?? 'rgba(37, 99, 235, 0.45)'; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(0, 26); - context.lineTo(w, 26); - context.stroke(); - } -} - -function drawNativePath(context: CanvasRenderingContext2D, geometry: PathGeometry) { - context.beginPath(); - for (const subpath of geometry.subpaths) { - for (const segment of subpath.segments) { - switch (segment.type) { - case 'move': - context.moveTo(segment.to.x, segment.to.y); - break; - case 'line': - context.lineTo(segment.to.x, segment.to.y); - break; - case 'quadratic': - context.quadraticCurveTo(segment.control.x, segment.control.y, segment.to.x, segment.to.y); - break; - case 'cubic': - context.bezierCurveTo( - segment.control_1.x, - segment.control_1.y, - segment.control_2.x, - segment.control_2.y, - segment.to.x, - segment.to.y - ); - break; - } - } - if (subpath.closed) context.closePath(); - } -} - -/** - * Draw an arrow shape - */ -function drawArrow( - context: CanvasRenderingContext2D, - state: EditorState, - shape: ArrowShape, - bindingsBySource?: BindingIndex -) { - const style = shape.props.style; - const shapeAlpha = context.globalAlpha; - - const geometry = arrowGeometryForShape(state, shape, bindingsBySource); - if (!geometry) return; - - const shaft = arrowShaftGeometry(geometry.path, style); - drawNativePath(context, shaft); - - const strokePaint = paintForCanvas(context, style.stroke, pathGeometryBounds(geometry.path)); - if (!strokePaint) return; - context.strokeStyle = strokePaint; - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - context.lineWidth = style.width; - if (style.dash) context.setLineDash(style.dash); - context.stroke(); - if (style.dash) context.setLineDash([]); - - const drawHead = (atStart: boolean) => { - const head = arrowHeadGeometry(geometry.path, atStart); - if (!head) return; - const headStyle = atStart ? style.headStartStyle : style.headEndStyle; - context.beginPath(); - context.moveTo(head.tip.x, head.tip.y); - if (headStyle === 'triangle') { - context.lineTo(head.left.x, head.left.y); - context.lineTo(head.right.x, head.right.y); - context.closePath(); - const headFill = paintForCanvas(context, style.stroke, pathGeometryBounds(geometry.path)); - if (headFill) { - context.fillStyle = headFill; - context.fill(); - } - } else { - context.lineTo(head.left.x, head.left.y); - context.moveTo(head.tip.x, head.tip.y); - context.lineTo(head.right.x, head.right.y); - } - const headStroke = paintForCanvas(context, style.stroke, pathGeometryBounds(geometry.path)); - if (headStroke) { - context.strokeStyle = headStroke; - context.lineWidth = style.width; - context.stroke(); - } - }; - - if (style.headEnd !== false) drawHead(false); - if (style.headStart) drawHead(true); - - const label = shape.props.label; - if (label) { - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - drawArrowLabel(context, state, geometry.path, label); - } -} - -/** - * Draw an arrow label - */ -function drawArrowLabel( - context: CanvasRenderingContext2D, - state: EditorState, - geometry: PathGeometry, - label: ArrowLabel -) { - if (!label.text) return; - - const placement = arrowLabelPlacement(geometry, label); - if (!placement) return; - const labelPos = placement.point; - - context.save(); - context.font = '14px sans-serif'; - context.fillStyle = '#000'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - const metrics = context.measureText(label.text); - const padding = 4; - const bgWidth = metrics.width + padding * 2; - const bgHeight = 18; - - context.fillStyle = 'rgba(255, 255, 255, 0.9)'; - context.fillRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight / 2, bgWidth, bgHeight); - context.strokeStyle = '#ccc'; - context.lineWidth = 1 / state.camera.zoom; - context.strokeRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight / 2, bgWidth, bgHeight); - - context.fillStyle = '#000'; - context.fillText(label.text, labelPos.x, labelPos.y); - context.restore(); -} - -/** - * Draw a text shape - */ -function drawText( - context: CanvasRenderingContext2D, - shape: TextShape, - textLayoutCache: LruCache, - textMetricCache: LruCache -) { - const { text, fontSize, fontFamily, color, w } = shape.props; - - context.globalAlpha *= shape.fillOpacity ?? 1; - context.font = `${fontSize}px ${fontFamily}`; - context.fillStyle = - paintForCanvas(context, color, { x: 0, y: 0, width: w ?? fontSize * 10, height: fontSize * 1.2 }) ?? '#000000'; - context.textBaseline = 'top'; - - if (w === undefined) { - context.fillText(text, 0, 0); - } else { - const lines = wrapText(context, text, w, textLayoutCache, textMetricCache); - for (const [index, line] of lines.entries()) { - context.fillText(line, 0, index * fontSize * 1.2); - } - } -} - -/** Draw attached text in the supporting path's local coordinate system. */ -function drawTextOnPath( - context: CanvasRenderingContext2D, - shape: TextShape, - path: PathShape, - layout: TextPathLayout -): void { - const matrix = shapeTransform(path); - context.save(); - context.globalAlpha *= shape.fillOpacity ?? 1; - context.transform(matrix[0], matrix[1], matrix[3], matrix[4], matrix[6], matrix[7]); - context.font = `${shape.props.fontSize}px ${shape.props.fontFamily}`; - context.fillStyle = - paintForCanvas(context, shape.props.color, { - x: layout.bounds.min.x, - y: layout.bounds.min.y, - width: layout.bounds.max.x - layout.bounds.min.x, - height: layout.bounds.max.y - layout.bounds.min.y - }) ?? '#000000'; - context.textAlign = 'center'; - context.textBaseline = 'alphabetic'; - for (const glyph of layout.glyphs) { - context.save(); - context.translate(glyph.point.x, glyph.point.y); - context.rotate(glyph.angle); - context.fillText(glyph.character, 0, 0); - context.restore(); - } - context.restore(); -} - -/** - * Parse and render markdown to canvas - * - * Renders markdown with basic formatting: - * - Headings (h1-h6) with appropriate sizes - * - Bold (**text** or __text__) - * - Italic (*text* or _text_) - * - Code (`code`) - * - Paragraphs with line wrapping - * - Lists (ordered and unordered) - * - Code blocks (```) - */ -type MarkdownLine = { source: string; kind: 'code' | 'text'; fontSize: number; weight: string; prefix: string }; - -function prepareMarkdownLines(source: string, baseFontSize: number): MarkdownLine[] { - const sourceLines = source.split('\n'); - const lines: MarkdownLine[] = []; - - for (let index = 0; index < sourceLines.length; index++) { - let line = sourceLines[index]; - if (line.startsWith('```')) { - const codeLines: string[] = []; - index++; - while (index < sourceLines.length && !sourceLines[index].startsWith('```')) { - codeLines.push(sourceLines[index]); - index++; - } - lines.push({ - source: codeLines.join('\n'), - kind: 'code', - fontSize: baseFontSize, - weight: 'normal', - prefix: '' - }); - continue; - } - - let fontSize = baseFontSize; - let weight = 'normal'; - let prefix = ''; - const heading = line.match(/^(#{1,6})\s(.*)$/); - const orderedItem = line.match(/^(\d+)\.\s(.*)$/); - if (heading) { - fontSize = baseFontSize * (2 - heading[1].length * 0.15); - weight = 'bold'; - line = heading[2]; - } else if (/^[-*+]\s/.test(line)) { - prefix = '• '; - line = line.replace(/^[-*+]\s/, ''); - } else if (orderedItem) { - prefix = `${orderedItem[1]}. `; - line = orderedItem[2]; - } - lines.push({ source: line, kind: 'text', fontSize, weight, prefix }); - } - - return lines; -} - -function drawMarkdown( - context: CanvasRenderingContext2D, - shape: MarkdownShape, - theme: 'light' | 'dark' = 'light', - textLayoutCache = new LruCache(512), - textMetricCache = new LruCache(2_048), - markdownLayoutCache = new LruCache(256) -) { - const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; - - const width = w; - const height = h ?? fontSize * 10; - const shapeAlpha = context.globalAlpha; - - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - context.fillStyle = paintForCanvas(context, bg, { x: 0, y: 0, width, height }) ?? '#ffffff'; - context.fillRect(0, 0, width, height); - - if (border) { - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - context.strokeStyle = paintForCanvas(context, border, { x: 0, y: 0, width, height }) ?? '#000000'; - context.lineWidth = 1; - context.strokeRect(0, 0, width, height); - } - - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - context.fillStyle = paintForCanvas(context, color, { x: 0, y: 0, width, height }) ?? '#000000'; - context.textBaseline = 'top'; - - const padding = 8; - let yOffset = padding; - const lineHeight = fontSize * 1.4; - - const layoutKey = `${md}\u0000${w}\u0000${fontSize}\u0000${fontFamily}\u0000${theme}`; - let preparedLines = markdownLayoutCache.get(layoutKey); - if (!preparedLines) { - preparedLines = prepareMarkdownLines(md, fontSize); - markdownLayoutCache.set(layoutKey, preparedLines); - } - - for (let lineIndex = 0; lineIndex < preparedLines.length; lineIndex++) { - const prepared = preparedLines[lineIndex]; - let line = prepared.source; - - if (yOffset + lineHeight > height - padding) break; - - let currentFontSize = prepared.fontSize; - let currentStyle = 'normal'; - let currentWeight = 'normal'; - let prefix = prepared.prefix; - - if (prepared.kind === 'code') { - context.fillStyle = theme === 'dark' ? '#2e3440' : '#f4f4f4'; - const codeBlockLines = line.split('\n'); - - const codeBlockHeight = codeBlockLines.length * lineHeight + padding * 2; - if (yOffset + codeBlockHeight <= height - padding) { - context.fillRect(padding, yOffset, width - padding * 2, codeBlockHeight); - - context.fillStyle = theme === 'dark' ? '#e5e9f0' : '#333'; - context.font = `normal normal ${fontSize}px monospace`; - - for (const [index, codeLine] of codeBlockLines.entries()) { - context.fillText(codeLine, padding + 4, yOffset + padding + index * lineHeight); - } - - yOffset += codeBlockHeight + padding; - } - - context.fillStyle = paintForCanvas(context, color, { x: 0, y: 0, width, height }) ?? '#000000'; - context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; - continue; - } - - currentWeight = prepared.weight; - - line = prefix + line; - - line = line.replace(/`([^`]+)`/g, '$1'); - - context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; - - const wrappedLines = wrapText(context, line, width - padding * 2, textLayoutCache, textMetricCache); - - for (const wrappedLine of wrappedLines) { - if (yOffset + currentFontSize * 1.4 > height - padding) break; - - const styledLine = wrappedLine; - let xOffset = padding; - - const segments = parseInlineStyles(styledLine); - - for (const segment of segments) { - const { text: segmentText, bold, italic, code } = segment; - - if (code) { - context.fillStyle = theme === 'dark' ? '#2e3440' : '#f4f4f4'; - const metrics = context.measureText(segmentText); - context.fillRect(xOffset, yOffset, metrics.width + 4, currentFontSize * 1.2); - - context.fillStyle = theme === 'dark' ? '#e5e9f0' : '#333'; - context.font = `normal normal ${currentFontSize * 0.9}px monospace`; - context.fillText(segmentText, xOffset + 2, yOffset); - xOffset += metrics.width + 4; - context.fillStyle = paintForCanvas(context, color, { x: 0, y: 0, width, height }) ?? '#000000'; - context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; - } else { - const weight = bold ? 'bold' : currentWeight; - const style = italic ? 'italic' : currentStyle; - context.font = `${weight} ${style} ${currentFontSize}px ${fontFamily}`; - context.fillText(segmentText, xOffset, yOffset); - const metrics = context.measureText(segmentText); - xOffset += metrics.width; - context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; - } - } - - yOffset += currentFontSize * 1.4; - } - } -} - -/** - * Parse inline markdown styles (bold, italic, code) into segments - */ -function parseInlineStyles(text: string): Array<{ text: string; bold: boolean; italic: boolean; code: boolean }> { - const segments: Array<{ text: string; bold: boolean; italic: boolean; code: boolean }> = []; - - const codeRegex = /`([^`]+)`/g; - const parts = []; - let lastIndex = 0; - let match; - - while ((match = codeRegex.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push({ text: text.slice(lastIndex, match.index), code: false }); - } - parts.push({ text: match[1], code: true }); - lastIndex = codeRegex.lastIndex; - } - - if (lastIndex < text.length) { - parts.push({ text: text.slice(lastIndex), code: false }); - } - - for (const part of parts) { - if (part.code) { - segments.push({ text: part.text, bold: false, italic: false, code: true }); - } else { - const boldItalicRegex = /(\*\*\*|___)([^*_]+)(\*\*\*|___)|(\*\*|__)([^*_]+)(\*\*|__)|(\*|_)([^*_]+)(\*|_)/g; - let lastPartIndex = 0; - let partMatch; - - while ((partMatch = boldItalicRegex.exec(part.text)) !== null) { - if (partMatch.index > lastPartIndex) { - segments.push({ - text: part.text.slice(lastPartIndex, partMatch.index), - bold: false, - italic: false, - code: false - }); - } - - if (partMatch[1]) { - segments.push({ text: partMatch[2], bold: true, italic: true, code: false }); - } else if (partMatch[4]) { - segments.push({ text: partMatch[5], bold: true, italic: false, code: false }); - } else if (partMatch[7]) { - segments.push({ text: partMatch[8], bold: false, italic: true, code: false }); - } - - lastPartIndex = boldItalicRegex.lastIndex; - } - - if (lastPartIndex < part.text.length) { - segments.push({ text: part.text.slice(lastPartIndex), bold: false, italic: false, code: false }); - } - - if (segments.length === 0 || lastPartIndex === 0) { - if (segments.length === 0) { - segments.push({ text: part.text, bold: false, italic: false, code: false }); - } - } - } - } - - if (segments.length === 0) { - segments.push({ text, bold: false, italic: false, code: false }); - } - - return segments; -} - -/** Draw a native path with Canvas' compound fill rule and stroke. */ -function drawPath(context: CanvasRenderingContext2D, shape: PathShape) { - const { subpaths, fill_rule: fillRule, fill, stroke, stroke_width: strokeWidth } = shape.props; - const shapeAlpha = context.globalAlpha; - context.beginPath(); - for (const subpath of subpaths) { - const first = subpath.segments[0]; - if (!first || first.type !== 'move') continue; - context.moveTo(first.to.x, first.to.y); - for (const segment of subpath.segments.slice(1)) { - switch (segment.type) { - case 'move': - context.moveTo(segment.to.x, segment.to.y); - break; - case 'line': - context.lineTo(segment.to.x, segment.to.y); - break; - case 'quadratic': - context.quadraticCurveTo(segment.control.x, segment.control.y, segment.to.x, segment.to.y); - break; - case 'cubic': - context.bezierCurveTo( - segment.control_1.x, - segment.control_1.y, - segment.control_2.x, - segment.control_2.y, - segment.to.x, - segment.to.y - ); - break; - } - } - if (subpath.closed) context.closePath(); - } - if (fill && fill !== 'none' && fill !== 'transparent') { - context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); - const fillStyle = paintForCanvas(context, fill, pathGeometryBounds(shape.props)); - if (fillStyle) { - context.fillStyle = fillStyle; - context.fill(fillRule); - } - } - if (stroke && stroke !== 'none' && stroke !== 'transparent') { - context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); - const strokeStyle = paintForCanvas(context, stroke, pathGeometryBounds(shape.props)); - if (strokeStyle) { - context.strokeStyle = strokeStyle; - context.lineWidth = Math.max(0, strokeWidth ?? 2); - context.stroke(); - } - } -} - -/** - * Draw a stroke shape (freehand drawing) - */ -function drawStroke(context: CanvasRenderingContext2D, shape: StrokeShape) { - const { points, style } = shape.props; - - if (points.length < 2) { - return; - } - - const outline = getStrokeOutline(shape); - - if (outline.length === 0) { - return; - } - - context.globalAlpha *= shape.strokeOpacity ?? style.opacity; - const outlineBounds = { - x: Math.min(...outline.map((point) => point.x)), - y: Math.min(...outline.map((point) => point.y)), - width: Math.max(...outline.map((point) => point.x)) - Math.min(...outline.map((point) => point.x)), - height: Math.max(...outline.map((point) => point.y)) - Math.min(...outline.map((point) => point.y)) - }; - context.fillStyle = paintForCanvas(context, style.color, outlineBounds) ?? '#000000'; - context.beginPath(); - context.moveTo(outline[0].x, outline[0].y); - - for (let i = 1; i < outline.length; i++) { - context.lineTo(outline[i].x, outline[i].y); - } - - context.closePath(); - context.fill(); -} - -/** - * Wrap text to fit within a given width - */ -function wrapText( - context: CanvasRenderingContext2D, - text: string, - maxWidth: number, - layoutCache = new LruCache(512), - metricCache = new LruCache(2_048) -): string[] { - const cacheKey = `${context.font}\u0000${maxWidth}\u0000${text}`; - const cached = layoutCache.get(cacheKey); - if (cached) return cached; - const lines: string[] = []; - for (const sourceLine of text.split('\n')) { - let currentLine = ''; - for (const word of sourceLine.split(' ')) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const widthKey = `${context.font}\u0000${testLine}`; - let measuredWidth = metricCache.get(widthKey); - if (measuredWidth === undefined) { - measuredWidth = context.measureText(testLine).width; - metricCache.set(widthKey, measuredWidth); - } - - if (measuredWidth > maxWidth && currentLine) { - lines.push(currentLine); - currentLine = word; - } else { - currentLine = testLine; - } - } - lines.push(currentLine); - } - - layoutCache.set(cacheKey, lines); - return lines; -} - -/** - * Draw selection outlines for selected shapes - */ -const SELECTION_COLOR = '#34d399'; - -function drawSelection( - context: CanvasRenderingContext2D, - state: EditorState, - shapes: EditorShapeRecord[], - handleState?: HandleRenderState, - bindingsBySource?: BindingIndex, - theme: 'light' | 'dark' = 'light' -) { - const selectedIds = new Set(state.ui.selectionIds); - const singleSelectionId = state.ui.selectionIds.length === 1 ? state.ui.selectionIds[0] : null; - const hovered = state.ui.hoveredShapeId ? state.doc.shapes[state.ui.hoveredShapeId] : undefined; - if (hovered && !selectedIds.has(hovered.id)) { - const bounds = shapeBoundsForState(state, hovered); - context.save(); - context.setLineDash([5 / state.camera.zoom, 4 / state.camera.zoom]); - context.strokeStyle = 'rgba(37, 99, 235, 0.65)'; - context.lineWidth = 1.5 / state.camera.zoom; - context.strokeRect(bounds.min.x, bounds.min.y, bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y); - context.restore(); - } - - for (const shape of shapes) { - if (!selectedIds.has(shape.id)) continue; - - context.save(); - const attachedText = shape.type === 'text' && shape.props.textPath && textPathLayoutForShape(state, shape); - if (!attachedText) applyShapeTransform(context, shape); - - const strokeSelectionBounds = () => { - switch (shape.type) { - case 'rect': - case 'ellipse': { - const { w, h } = shape.props; - context.strokeRect(0, 0, w, h); - break; - } - case 'line': { - const { a, b } = shape.props; - const minX = Math.min(a.x, b.x); - const minY = Math.min(a.y, b.y); - const maxX = Math.max(a.x, b.x); - const maxY = Math.max(a.y, b.y); - const padding = 5; - context.strokeRect( - minX - padding, - minY - padding, - maxX - minX + padding * 2, - maxY - minY + padding * 2 - ); - break; - } - case 'arrow': { - const geometry = arrowGeometryForShape(state, shape, bindingsBySource); - if (geometry) { - drawNativePath(context, arrowShaftGeometry(geometry.path, shape.props.style)); - context.stroke(); - } else { - const points = arrowPathForShape(state, shape, bindingsBySource); - if (points.length > 0) { - context.beginPath(); - context.moveTo(points[0].x, points[0].y); - for (const point of points.slice(1)) context.lineTo(point.x, point.y); - context.stroke(); - } - } - break; - } - case 'path': { - const bounds = localShapeBounds(shape); - const padding = 5; - context.strokeRect( - bounds.min.x - padding, - bounds.min.y - padding, - bounds.max.x - bounds.min.x + padding * 2, - bounds.max.y - bounds.min.y + padding * 2 - ); - break; - } - case 'text': { - if (attachedText) { - const bounds = shapeBoundsForState(state, shape); - context.strokeRect( - bounds.min.x, - bounds.min.y, - bounds.max.x - bounds.min.x, - bounds.max.y - bounds.min.y - ); - break; - } - const { fontSize, fontFamily, text, w } = shape.props; - context.font = `${fontSize}px ${fontFamily}`; - const width = w ?? context.measureText(text).width; - context.strokeRect(0, 0, width, fontSize * 1.2); - break; - } - case 'markdown': { - const { w, h, fontSize } = shape.props; - context.strokeRect(0, 0, w, h ?? fontSize * 10); - break; - } - case 'container': - case 'reference': { - const bounds = localShapeBounds(shape); - context.strokeRect( - bounds.min.x, - bounds.min.y, - bounds.max.x - bounds.min.x, - bounds.max.y - bounds.min.y - ); - break; - } - case 'stroke': { - const outline = shape.props.points.length >= 2 ? getStrokeOutline(shape) : []; - if (outline.length === 0) break; - let minX = outline[0].x; - let maxX = outline[0].x; - let minY = outline[0].y; - let maxY = outline[0].y; - for (const point of outline) { - minX = Math.min(minX, point.x); - maxX = Math.max(maxX, point.x); - minY = Math.min(minY, point.y); - maxY = Math.max(maxY, point.y); - } - context.strokeRect(minX, minY, maxX - minX, maxY - minY); - break; - } - } - }; - - context.setLineDash([7 / state.camera.zoom, 5 / state.camera.zoom]); - context.strokeStyle = SELECTION_COLOR; - context.lineWidth = 2.5 / state.camera.zoom; - strokeSelectionBounds(); - - context.restore(); - - if (singleSelectionId === shape.id) { - drawHandles(context, state, shape, handleState, bindingsBySource, theme); - if (state.ui.toolId === 'direct-select' && state.ui.pathSelection?.pathId === shape.id) { - if (shape.type === 'path') drawPathEditingHandles(context, state, shape, handleState); - if (shape.type === 'stroke') drawStrokeEditingHandles(context, state, shape, handleState); - } - } - } -} - -type HandleVisual = { id: string; position: Vec2; connectorFrom?: Vec2 }; -const ROTATE_HANDLE_OFFSET = 40; -const TEXT_HANDLE_OFFSET = 7; - -function drawHandles( - context: CanvasRenderingContext2D, - state: EditorState, - shape: EditorShapeRecord, - handleState?: HandleRenderState, - bindingsBySource?: BindingIndex, - theme: 'light' | 'dark' = 'light' -) { - if (!handleState) { - return; - } - const handles = getHandlesForShape(state, shape, bindingsBySource); - if (handles.length === 0) { - return; - } - - for (const handle of handles) { - if (handle.connectorFrom) { - context.save(); - context.strokeStyle = 'rgba(52, 211, 153, 0.72)'; - context.lineWidth = 1 / state.camera.zoom; - context.beginPath(); - context.moveTo(handle.connectorFrom.x, handle.connectorFrom.y); - context.lineTo(handle.position.x, handle.position.y); - context.stroke(); - context.restore(); - } - - context.save(); - const isActive = handleState.active === handle.id; - const isHover = handleState.hover === handle.id; - const fill = isActive || isHover ? SELECTION_COLOR : theme === 'dark' ? '#171928' : '#f0f3f4'; - const size = (handle.id === 'rotate' ? 7 : 6) / state.camera.zoom; - - context.translate(handle.position.x, handle.position.y); - context.lineWidth = 3 / state.camera.zoom; - context.strokeStyle = SELECTION_COLOR; - context.fillStyle = fill; - context.beginPath(); - context.arc(0, 0, size, 0, Math.PI * 2); - context.fill(); - context.stroke(); - - context.restore(); - } -} - -function drawPathEditingHandles( - context: CanvasRenderingContext2D, - state: EditorState, - shape: PathShape, - handleState?: HandleRenderState -): void { - const selectedAnchors = new Set( - (state.ui.pathSelection?.anchors ?? []).map((anchor) => `${anchor.subpathIndex}:${anchor.segmentIndex}`) - ); - const scale = state.camera.zoom; - const anchorSize = 5 / scale; - const controlSize = 4 / scale; - - context.save(); - applyShapeTransform(context, shape); - context.lineWidth = 1 / scale; - context.setLineDash([]); - - for (const handle of pathControlHandles(shape)) { - const handleId = pathControlHandleId(handle.ref); - context.strokeStyle = 'rgba(37, 99, 235, 0.6)'; - context.beginPath(); - context.moveTo(handle.anchor.x, handle.anchor.y); - context.lineTo(handle.position.x, handle.position.y); - context.stroke(); - const active = handleState?.active === handleId; - const hover = handleState?.hover === handleId; - context.fillStyle = active ? '#2563eb' : hover ? '#dbeafe' : '#ffffff'; - context.strokeStyle = active || hover ? '#1d4ed8' : '#2563eb'; - context.beginPath(); - context.arc(handle.position.x, handle.position.y, controlSize, 0, Math.PI * 2); - context.fill(); - context.stroke(); - } - - for (const anchor of pathAnchorRefs(shape)) { - const position = pathAnchorPosition(shape, anchor); - if (!position) continue; - const anchorId = pathAnchorHandleId(anchor); - const key = `${anchor.subpathIndex}:${anchor.segmentIndex}`; - const selected = selectedAnchors.has(key); - const hover = handleState?.hover === anchorId; - context.fillStyle = selected || handleState?.active === anchorId ? '#2563eb' : hover ? '#dbeafe' : '#ffffff'; - context.strokeStyle = hover ? '#1e40af' : '#1d4ed8'; - context.lineWidth = 1.5 / scale; - context.beginPath(); - context.rect(position.x - anchorSize, position.y - anchorSize, anchorSize * 2, anchorSize * 2); - context.fill(); - context.stroke(); - } - context.restore(); -} - -function drawStrokeEditingHandles( - context: CanvasRenderingContext2D, - state: EditorState, - shape: StrokeShape, - handleState?: HandleRenderState -): void { - const selected = new Set(state.ui.pathSelection?.widthPoints ?? []); - const scale = state.camera.zoom; - context.save(); - applyShapeTransform(context, shape); - context.lineWidth = 1 / scale; - context.setLineDash([]); - for (const handle of strokeWidthHandles(shape)) { - const id = strokeWidthHandleId(handle.index); - const active = handleState?.active === id; - const hover = handleState?.hover === id; - context.strokeStyle = 'rgba(37, 99, 235, 0.6)'; - context.beginPath(); - context.moveTo(handle.center.x, handle.center.y); - context.lineTo(handle.position.x, handle.position.y); - context.stroke(); - context.fillStyle = selected.has(handle.index) || active ? '#2563eb' : hover ? '#dbeafe' : '#ffffff'; - context.strokeStyle = active || hover ? '#1d4ed8' : '#2563eb'; - context.beginPath(); - context.arc(handle.position.x, handle.position.y, 5 / scale, 0, Math.PI * 2); - context.fill(); - context.stroke(); - } - context.restore(); -} - -function getHandlesForShape( - state: EditorState, - shape: EditorShapeRecord, - bindingsBySource?: BindingIndex -): HandleVisual[] { - const handles: HandleVisual[] = []; - if (shape.type === 'text' && shape.props.textPath) { - const position = textPathAnchorForShape(state, shape); - if (position) handles.push({ id: 'text-path-offset', position }); - return handles; - } - if ( - shape.type === 'rect' || - shape.type === 'ellipse' || - shape.type === 'text' || - shape.type === 'markdown' || - shape.type === 'container' - ) { - const bounds = localShapeBounds(shape); - const minX = bounds.min.x; - const maxX = bounds.max.x; - const minY = bounds.min.y; - const maxY = bounds.max.y; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - const offset = shape.type === 'text' ? TEXT_HANDLE_OFFSET / state.camera.zoom : 0; - const world = (point: Vec2) => localToWorld(shape, point); - handles.push( - { id: 'nw', position: world({ x: minX - offset, y: minY - offset }) }, - { id: 'n', position: world({ x: centerX, y: minY - offset }) }, - { id: 'ne', position: world({ x: maxX + offset, y: minY - offset }) }, - { id: 'e', position: world({ x: maxX + offset, y: centerY }) }, - { id: 'se', position: world({ x: maxX + offset, y: maxY + offset }) }, - { id: 's', position: world({ x: centerX, y: maxY + offset }) }, - { id: 'sw', position: world({ x: minX - offset, y: maxY + offset }) }, - { id: 'w', position: world({ x: minX - offset, y: centerY }) }, - { - id: 'rotate', - position: world({ x: centerX, y: minY - ROTATE_HANDLE_OFFSET }), - connectorFrom: world({ x: centerX, y: minY - offset }) - } - ); - return handles; - } - - if (shape.type === 'line') { - const start = localToWorld(shape, shape.props.a); - const end = localToWorld(shape, shape.props.b); - handles.push({ id: 'line-start', position: start }, { id: 'line-end', position: end }); - return handles; - } - - if (shape.type === 'arrow') { - const resolved = resolveArrowEndpoints(state, shape.id, bindingsBySource); - const arrowGeometry = arrowGeometryForShape(state, shape, bindingsBySource); - if (resolved && arrowGeometry && shape.props.points && shape.props.points.length >= 2) { - handles.push({ id: 'line-start', position: resolved.a }); - - for (let i = 1; i < shape.props.points.length - 1; i++) { - const point = shape.props.points[i]; - const worldPos = localToWorld(shape, point); - handles.push({ id: `arrow-point-${i}`, position: worldPos }); - } - - const bendHandle = arrowBendHandleForShape(state, shape, bindingsBySource); - if (bendHandle) handles.push({ id: 'arrow-bend', ...bendHandle }); - - handles.push({ id: 'line-end', position: resolved.b }); - - if (shape.props.label) { - const placement = arrowLabelPlacement(arrowGeometry.path, shape.props.label); - if (placement) { - const worldLabelPos = localToWorld(shape, placement.point); - handles.push({ id: 'arrow-label', position: worldLabelPos }); - } - } - } - return handles; - } - - return handles; -} - -function applyShapeTransform(context: CanvasRenderingContext2D, shape: EditorShapeRecord): void { - const matrix = shapeTransform(shape); - if (shape.editorTransform && typeof context.transform === 'function') { - context.transform(matrix[0], matrix[1], matrix[3], matrix[4], matrix[6], matrix[7]); - return; - } - context.translate(shape.x, shape.y); - if (shape.rot !== 0) context.rotate(shape.rot); -} diff --git a/packages/editor/src/renderer/assets.ts b/packages/editor/src/renderer/assets.ts new file mode 100644 index 0000000..f14ae75 --- /dev/null +++ b/packages/editor/src/renderer/assets.ts @@ -0,0 +1,79 @@ +import type { EditorState, EditorShapeRecord } from '@inkfinite/core'; +import type { RendererImageCache } from './resources.js'; + +/** Draw an embedded image, preserving its crop window when one is set. */ +export function drawImage( + context: CanvasRenderingContext2D, + state: EditorState, + shape: Extract, + imageCache: RendererImageCache, + onImageLoaded: () => void +) { + const asset = state.doc.assets?.[shape.props.assetId]; + const { w, h, crop, mask, caption } = shape.props; + context.globalAlpha *= shape.fillOpacity ?? 1; + if (!asset) { + context.fillStyle = '#e5e7eb'; + context.fillRect(0, 0, w, h); + return; + } + const image = imageCache.get(asset, onImageLoaded); + if (!image || !image.complete || image.naturalWidth === 0) { + context.fillStyle = '#e5e7eb'; + context.fillRect(0, 0, w, h); + context.strokeStyle = '#9ca3af'; + context.strokeRect(0, 0, w, h); + return; + } + const top = crop?.top ?? 0; + const right = crop?.right ?? 0; + const bottom = crop?.bottom ?? 0; + const left = crop?.left ?? 0; + const sourceX = image.naturalWidth * left; + const sourceY = image.naturalHeight * top; + const sourceWidth = image.naturalWidth * Math.max(0.001, 1 - left - right); + const sourceHeight = image.naturalHeight * Math.max(0.001, 1 - top - bottom); + context.save(); + if (mask?.kind === 'ellipse') { + context.beginPath(); + context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); + context.clip(); + } else if (mask?.kind === 'rounded') { + const radius = Math.min(mask.radius ?? 16, w / 2, h / 2); + context.beginPath(); + context.roundRect(0, 0, w, h, radius); + context.clip(); + } + context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, w, h); + context.restore(); + if (caption?.trim()) { + const captionHeight = Math.min(24, h); + context.fillStyle = 'rgba(0, 0, 0, 0.58)'; + context.fillRect(0, h - captionHeight, w, captionHeight); + context.fillStyle = '#ffffff'; + context.font = '12px sans-serif'; + context.textBaseline = 'middle'; + context.fillText(caption, 8, h - captionHeight / 2, Math.max(0, w - 16)); + } +} + +export function drawReference( + context: CanvasRenderingContext2D, + shape: Extract +) { + const { w, h, referenceType, value, label } = shape.props; + const accent = referenceType === 'url' ? '#2563eb' : referenceType === 'file' ? '#16a34a' : '#7c3aed'; + context.fillStyle = '#f8fafc'; + context.strokeStyle = accent; + context.lineWidth = 2; + context.beginPath(); + context.roundRect(0, 0, w, h, 8); + context.fill(); + context.stroke(); + context.fillStyle = accent; + context.font = '600 12px sans-serif'; + context.fillText(referenceType.toUpperCase(), 12, 20); + context.fillStyle = '#1f2937'; + context.font = '13px sans-serif'; + context.fillText(label || value, 12, 42, Math.max(0, w - 24)); +} diff --git a/packages/editor/src/renderer/canvas.ts b/packages/editor/src/renderer/canvas.ts new file mode 100644 index 0000000..fd18dea --- /dev/null +++ b/packages/editor/src/renderer/canvas.ts @@ -0,0 +1,160 @@ +import type { EditorShapeRecord, FilterEffect, PaintValue, PathGeometry } from '@inkfinite/core'; +import { shapeTransform } from '@inkfinite/core'; +type PaintBounds = + | { x: number; y: number; width: number; height: number } + | { min: { x: number; y: number }; max: { x: number; y: number } }; + +/** Resolve a paint value for Canvas drawing in local shape coordinates. */ +export function paintForCanvas( + context: CanvasRenderingContext2D, + value: PaintValue | undefined, + bounds: PaintBounds +): string | CanvasGradient | null { + if (typeof value === 'string') return value || null; + if (!value) return null; + if (value.kind === 'solid') return value.color || null; + const area = + 'min' in bounds + ? { + x: bounds.min.x, + y: bounds.min.y, + width: bounds.max.x - bounds.min.x, + height: bounds.max.y - bounds.min.y + } + : bounds; + const point = (x: number, y: number) => { + const localX = value.units === 'object_bounding_box' ? area.x + x * area.width : x; + const localY = value.units === 'object_bounding_box' ? area.y + y * area.height : y; + const transform = value.transform; + return { + x: transform.a * localX + transform.c * localY + transform.e, + y: transform.b * localX + transform.d * localY + transform.f + }; + }; + const stopColor = (color: string, opacity: number) => withOpacity(color, opacity); + if (value.kind === 'linear_gradient') { + const start = point(value.x1, value.y1); + const end = point(value.x2, value.y2); + const gradient = context.createLinearGradient(start.x, start.y, end.x, end.y); + for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); + return gradient; + } + const center = point(value.cx, value.cy); + const focus = point(value.fx, value.fy); + const radius = value.units === 'object_bounding_box' ? value.r * Math.min(area.width, area.height) : value.r; + const gradient = context.createRadialGradient(focus.x, focus.y, 0, center.x, center.y, Math.max(0, radius)); + for (const stop of value.stops) gradient.addColorStop(stop.offset, stopColor(stop.color, stop.opacity)); + return gradient; +} + +function withOpacity(color: string, opacity: number): string { + if (opacity >= 1) return color; + const hex = color.match(/^#([\da-f]{3,8})$/i)?.[1]; + if (hex) { + const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((part) => part + part).join('') : hex; + const rgb = expanded + .slice(0, 6) + .match(/[\da-f]{2}/gi) + ?.map((part) => parseInt(part, 16)); + if (rgb?.length === 3) + return `rgba(${rgb.join(', ')}, ${opacity * (expanded.length === 8 ? parseInt(expanded.slice(6), 16) / 255 : 1)})`; + } + const rgba = color.match(/^rgba?\(([^)]+)\)$/i); + if (rgba) { + const parts = rgba[1].split(',').map((part) => part.trim()); + if (parts.length >= 3) + return `rgba(${parts.slice(0, 3).join(', ')}, ${opacity * (parts[3] ? Number(parts[3]) : 1)})`; + } + return color; +} + +/** Apply the stored shape transform to a Canvas context. */ +export function applyShapeTransform(context: CanvasRenderingContext2D, shape: EditorShapeRecord): void { + const matrix = shapeTransform(shape); + if (shape.editorTransform && typeof context.transform === 'function') { + context.transform(matrix[0], matrix[1], matrix[3], matrix[4], matrix[6], matrix[7]); + return; + } + context.translate(shape.x, shape.y); + if (shape.rot !== 0) context.rotate(shape.rot); +} + +/** Add native path geometry to the current Canvas path. */ +export function drawNativePath(context: CanvasRenderingContext2D, geometry: PathGeometry): void { + context.beginPath(); + for (const subpath of geometry.subpaths) { + for (const segment of subpath.segments) { + switch (segment.type) { + case 'move': + context.moveTo(segment.to.x, segment.to.y); + break; + case 'line': + context.lineTo(segment.to.x, segment.to.y); + break; + case 'quadratic': + context.quadraticCurveTo(segment.control.x, segment.control.y, segment.to.x, segment.to.y); + break; + case 'cubic': + context.bezierCurveTo( + segment.control_1.x, + segment.control_1.y, + segment.control_2.x, + segment.control_2.y, + segment.to.x, + segment.to.y + ); + break; + } + } + if (subpath.closed) context.closePath(); + } +} + +/** Apply the supported clip, mask, and filter effects before shape drawing. */ +export function applyShapeEffects(context: CanvasRenderingContext2D, shape: EditorShapeRecord): void { + const props = shape.props as EditorShapeRecord['props'] & { + clipPath?: PathGeometry; + maskEffect?: { geometry: PathGeometry; opacity?: number }; + filter?: FilterEffect; + }; + if (props.filter) context.filter = filterToCanvas(props.filter); + if (props.clipPath) { + drawNativePath(context, props.clipPath); + context.clip(props.clipPath.fill_rule === 'evenodd' ? 'evenodd' : 'nonzero'); + } + if (props.maskEffect) { + context.globalAlpha *= props.maskEffect.opacity ?? 1; + drawNativePath(context, props.maskEffect.geometry); + context.clip(props.maskEffect.geometry.fill_rule === 'evenodd' ? 'evenodd' : 'nonzero'); + } +} + +/** Convert the shared supported filter subset to the Canvas filter syntax. */ +export function filterToCanvas(filter: FilterEffect): string { + return filter.primitives + .map((primitive) => { + switch (primitive.type) { + case 'blur': + return `blur(${Math.max(0, primitive.radius)}px)`; + case 'brightness': + return `brightness(${Math.max(0, primitive.amount)})`; + case 'contrast': + return `contrast(${Math.max(0, primitive.amount)})`; + case 'grayscale': + return `grayscale(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'hue_rotate': + return `hue-rotate(${primitive.degrees}deg)`; + case 'invert': + return `invert(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'saturate': + return `saturate(${Math.max(0, primitive.amount)})`; + case 'sepia': + return `sepia(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'opacity': + return `opacity(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'drop_shadow': + return `drop-shadow(${primitive.dx}px ${primitive.dy}px ${Math.max(0, primitive.radius)}px ${primitive.color})`; + } + }) + .join(' '); +} diff --git a/packages/editor/src/renderer/overlays.ts b/packages/editor/src/renderer/overlays.ts new file mode 100644 index 0000000..527ca89 --- /dev/null +++ b/packages/editor/src/renderer/overlays.ts @@ -0,0 +1,519 @@ +import type { + BindingIndex, + Camera, + CursorState, + EditorShapeRecord, + EditorState, + PathShape, + SnapGuide, + StrokeShape, + Vec2, + Viewport +} from '@inkfinite/core'; +import { + arrowBendHandleForShape, + arrowGeometryForShape, + arrowLabelPlacement, + arrowPathForShape, + arrowShaftGeometry, + getStrokeOutline, + localShapeBounds, + localToWorld, + pathAnchorHandleId, + pathAnchorPosition, + pathAnchorRefs, + pathControlHandleId, + pathControlHandles, + resolveArrowEndpoints, + shapeBounds, + shapeBoundsForState, + strokeWidthHandleId, + strokeWidthHandles, + textPathAnchorForShape, + textPathLayoutForShape +} from '@inkfinite/core'; +import { applyShapeTransform, drawNativePath } from './canvas.js'; +import type { HandleRenderState, PointerVisualState, SnapSettings } from './types.js'; + +const DEFAULT_GRID_SIZE = 25; + +export function drawSnapGuides( + context: CanvasRenderingContext2D, + camera: Camera, + viewport: Viewport, + snapSettings?: SnapSettings, + cursorState?: CursorState, + pointerState?: PointerVisualState +) { + if (!snapSettings?.snapEnabled || !pointerState?.isPointerDown) { + return; + } + + const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; + const guideWorld = pointerState.snappedWorld ?? cursorState?.cursorWorld; + if (!guideWorld) { + return; + } + + const snappedX = pointerState.snappedWorld + ? pointerState.snappedWorld.x + : Math.round(guideWorld.x / gridSize) * gridSize; + const snappedY = pointerState.snappedWorld + ? pointerState.snappedWorld.y + : Math.round(guideWorld.y / gridSize) * gridSize; + + const guides = pointerState.snapGuides ?? []; + if (guides.length === 0 && !snapSettings.gridEnabled) return; + if (guides.length > 0) { + const halfWidth = viewport.width / (2 * camera.zoom); + const halfHeight = viewport.height / (2 * camera.zoom); + context.save(); + context.setLineDash([4 / camera.zoom, 4 / camera.zoom]); + context.lineWidth = 1 / camera.zoom; + context.strokeStyle = 'rgba(236, 72, 153, 0.82)'; + for (const guide of guides) { + const start = guide.start ?? (guide.axis === 'x' ? camera.y - halfHeight : camera.x - halfWidth); + const end = guide.end ?? (guide.axis === 'x' ? camera.y + halfHeight : camera.x + halfWidth); + context.beginPath(); + if (guide.axis === 'x') { + context.moveTo(guide.position, start); + context.lineTo(guide.position, end); + } else { + context.moveTo(start, guide.position); + context.lineTo(end, guide.position); + } + context.stroke(); + } + context.restore(); + return; + } + + const halfWidth = viewport.width / (2 * camera.zoom); + const halfHeight = viewport.height / (2 * camera.zoom); + const minX = camera.x - halfWidth; + const maxX = camera.x + halfWidth; + const minY = camera.y - halfHeight; + const maxY = camera.y + halfHeight; + + context.save(); + const dashLength = 4 / camera.zoom; + context.setLineDash([dashLength, dashLength]); + context.lineWidth = 1 / camera.zoom; + context.strokeStyle = 'rgba(59, 130, 246, 0.6)'; + + context.beginPath(); + context.moveTo(minX, snappedY); + context.lineTo(maxX, snappedY); + context.stroke(); + + context.beginPath(); + context.moveTo(snappedX, minY); + context.lineTo(snappedX, maxY); + context.stroke(); + + context.setLineDash([]); + context.fillStyle = 'rgba(59, 130, 246, 0.6)'; + context.beginPath(); + context.arc(snappedX, snappedY, 4 / camera.zoom, 0, Math.PI * 2); + context.fill(); + + context.restore(); +} + +/** + * Draw binding preview indicator when dragging arrow endpoints + */ +export function drawBindingPreview(context: CanvasRenderingContext2D, state: EditorState) { + if (!state.ui.bindingPreview) return; + + const targetShape = state.doc.shapes[state.ui.bindingPreview.targetShapeId]; + if (!targetShape) return; + + const bounds = shapeBounds(targetShape); + + context.save(); + context.strokeStyle = 'rgba(59, 130, 246, 0.8)'; + context.lineWidth = 3 / state.camera.zoom; + context.setLineDash([8 / state.camera.zoom, 4 / state.camera.zoom]); + + const padding = 4; + context.strokeRect( + bounds.min.x - padding, + bounds.min.y - padding, + bounds.max.x - bounds.min.x + padding * 2, + bounds.max.y - bounds.min.y + padding * 2 + ); + + context.setLineDash([]); + context.restore(); +} + +/** + * Draw selection outlines for selected shapes + */ +const SELECTION_COLOR = '#34d399'; + +export function drawSelection( + context: CanvasRenderingContext2D, + state: EditorState, + shapes: EditorShapeRecord[], + handleState?: HandleRenderState, + bindingsBySource?: BindingIndex, + theme: 'light' | 'dark' = 'light' +) { + const selectedIds = new Set(state.ui.selectionIds); + const singleSelectionId = state.ui.selectionIds.length === 1 ? state.ui.selectionIds[0] : null; + const hovered = state.ui.hoveredShapeId ? state.doc.shapes[state.ui.hoveredShapeId] : undefined; + if (hovered && !selectedIds.has(hovered.id)) { + const bounds = shapeBoundsForState(state, hovered); + context.save(); + context.setLineDash([5 / state.camera.zoom, 4 / state.camera.zoom]); + context.strokeStyle = 'rgba(37, 99, 235, 0.65)'; + context.lineWidth = 1.5 / state.camera.zoom; + context.strokeRect(bounds.min.x, bounds.min.y, bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y); + context.restore(); + } + + for (const shape of shapes) { + if (!selectedIds.has(shape.id)) continue; + + context.save(); + const attachedText = shape.type === 'text' && shape.props.textPath && textPathLayoutForShape(state, shape); + if (!attachedText) applyShapeTransform(context, shape); + + const strokeSelectionBounds = () => { + switch (shape.type) { + case 'rect': + case 'ellipse': { + const { w, h } = shape.props; + context.strokeRect(0, 0, w, h); + break; + } + case 'line': { + const { a, b } = shape.props; + const minX = Math.min(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxX = Math.max(a.x, b.x); + const maxY = Math.max(a.y, b.y); + const padding = 5; + context.strokeRect( + minX - padding, + minY - padding, + maxX - minX + padding * 2, + maxY - minY + padding * 2 + ); + break; + } + case 'arrow': { + const geometry = arrowGeometryForShape(state, shape, bindingsBySource); + if (geometry) { + drawNativePath(context, arrowShaftGeometry(geometry.path, shape.props.style)); + context.stroke(); + } else { + const points = arrowPathForShape(state, shape, bindingsBySource); + if (points.length > 0) { + context.beginPath(); + context.moveTo(points[0].x, points[0].y); + for (const point of points.slice(1)) context.lineTo(point.x, point.y); + context.stroke(); + } + } + break; + } + case 'path': { + const bounds = localShapeBounds(shape); + const padding = 5; + context.strokeRect( + bounds.min.x - padding, + bounds.min.y - padding, + bounds.max.x - bounds.min.x + padding * 2, + bounds.max.y - bounds.min.y + padding * 2 + ); + break; + } + case 'text': { + if (attachedText) { + const bounds = shapeBoundsForState(state, shape); + context.strokeRect( + bounds.min.x, + bounds.min.y, + bounds.max.x - bounds.min.x, + bounds.max.y - bounds.min.y + ); + break; + } + const { fontSize, fontFamily, text, w } = shape.props; + context.font = `${fontSize}px ${fontFamily}`; + const width = w ?? context.measureText(text).width; + context.strokeRect(0, 0, width, fontSize * 1.2); + break; + } + case 'markdown': { + const { w, h, fontSize } = shape.props; + context.strokeRect(0, 0, w, h ?? fontSize * 10); + break; + } + case 'container': + case 'reference': { + const bounds = localShapeBounds(shape); + context.strokeRect( + bounds.min.x, + bounds.min.y, + bounds.max.x - bounds.min.x, + bounds.max.y - bounds.min.y + ); + break; + } + case 'stroke': { + const outline = shape.props.points.length >= 2 ? getStrokeOutline(shape) : []; + if (outline.length === 0) break; + let minX = outline[0].x; + let maxX = outline[0].x; + let minY = outline[0].y; + let maxY = outline[0].y; + for (const point of outline) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + context.strokeRect(minX, minY, maxX - minX, maxY - minY); + break; + } + } + }; + + context.setLineDash([7 / state.camera.zoom, 5 / state.camera.zoom]); + context.strokeStyle = SELECTION_COLOR; + context.lineWidth = 2.5 / state.camera.zoom; + strokeSelectionBounds(); + + context.restore(); + + if (singleSelectionId === shape.id) { + drawHandles(context, state, shape, handleState, bindingsBySource, theme); + if (state.ui.toolId === 'direct-select' && state.ui.pathSelection?.pathId === shape.id) { + if (shape.type === 'path') drawPathEditingHandles(context, state, shape, handleState); + if (shape.type === 'stroke') drawStrokeEditingHandles(context, state, shape, handleState); + } + } + } +} + +type HandleVisual = { id: string; position: Vec2; connectorFrom?: Vec2 }; +const ROTATE_HANDLE_OFFSET = 40; +const TEXT_HANDLE_OFFSET = 7; + +function drawHandles( + context: CanvasRenderingContext2D, + state: EditorState, + shape: EditorShapeRecord, + handleState?: HandleRenderState, + bindingsBySource?: BindingIndex, + theme: 'light' | 'dark' = 'light' +) { + if (!handleState) { + return; + } + const handles = getHandlesForShape(state, shape, bindingsBySource); + if (handles.length === 0) { + return; + } + + for (const handle of handles) { + if (handle.connectorFrom) { + context.save(); + context.strokeStyle = 'rgba(52, 211, 153, 0.72)'; + context.lineWidth = 1 / state.camera.zoom; + context.beginPath(); + context.moveTo(handle.connectorFrom.x, handle.connectorFrom.y); + context.lineTo(handle.position.x, handle.position.y); + context.stroke(); + context.restore(); + } + + context.save(); + const isActive = handleState.active === handle.id; + const isHover = handleState.hover === handle.id; + const fill = isActive || isHover ? SELECTION_COLOR : theme === 'dark' ? '#171928' : '#f0f3f4'; + const size = (handle.id === 'rotate' ? 7 : 6) / state.camera.zoom; + + context.translate(handle.position.x, handle.position.y); + context.lineWidth = 3 / state.camera.zoom; + context.strokeStyle = SELECTION_COLOR; + context.fillStyle = fill; + context.beginPath(); + context.arc(0, 0, size, 0, Math.PI * 2); + context.fill(); + context.stroke(); + + context.restore(); + } +} + +function drawPathEditingHandles( + context: CanvasRenderingContext2D, + state: EditorState, + shape: PathShape, + handleState?: HandleRenderState +): void { + const selectedAnchors = new Set( + (state.ui.pathSelection?.anchors ?? []).map((anchor) => `${anchor.subpathIndex}:${anchor.segmentIndex}`) + ); + const scale = state.camera.zoom; + const anchorSize = 5 / scale; + const controlSize = 4 / scale; + + context.save(); + applyShapeTransform(context, shape); + context.lineWidth = 1 / scale; + context.setLineDash([]); + + for (const handle of pathControlHandles(shape)) { + const handleId = pathControlHandleId(handle.ref); + context.strokeStyle = 'rgba(37, 99, 235, 0.6)'; + context.beginPath(); + context.moveTo(handle.anchor.x, handle.anchor.y); + context.lineTo(handle.position.x, handle.position.y); + context.stroke(); + const active = handleState?.active === handleId; + const hover = handleState?.hover === handleId; + context.fillStyle = active ? '#2563eb' : hover ? '#dbeafe' : '#ffffff'; + context.strokeStyle = active || hover ? '#1d4ed8' : '#2563eb'; + context.beginPath(); + context.arc(handle.position.x, handle.position.y, controlSize, 0, Math.PI * 2); + context.fill(); + context.stroke(); + } + + for (const anchor of pathAnchorRefs(shape)) { + const position = pathAnchorPosition(shape, anchor); + if (!position) continue; + const anchorId = pathAnchorHandleId(anchor); + const key = `${anchor.subpathIndex}:${anchor.segmentIndex}`; + const selected = selectedAnchors.has(key); + const hover = handleState?.hover === anchorId; + context.fillStyle = selected || handleState?.active === anchorId ? '#2563eb' : hover ? '#dbeafe' : '#ffffff'; + context.strokeStyle = hover ? '#1e40af' : '#1d4ed8'; + context.lineWidth = 1.5 / scale; + context.beginPath(); + context.rect(position.x - anchorSize, position.y - anchorSize, anchorSize * 2, anchorSize * 2); + context.fill(); + context.stroke(); + } + context.restore(); +} + +function drawStrokeEditingHandles( + context: CanvasRenderingContext2D, + state: EditorState, + shape: StrokeShape, + handleState?: HandleRenderState +): void { + const selected = new Set(state.ui.pathSelection?.widthPoints ?? []); + const scale = state.camera.zoom; + context.save(); + applyShapeTransform(context, shape); + context.lineWidth = 1 / scale; + context.setLineDash([]); + for (const handle of strokeWidthHandles(shape)) { + const id = strokeWidthHandleId(handle.index); + const active = handleState?.active === id; + const hover = handleState?.hover === id; + context.strokeStyle = 'rgba(37, 99, 235, 0.6)'; + context.beginPath(); + context.moveTo(handle.center.x, handle.center.y); + context.lineTo(handle.position.x, handle.position.y); + context.stroke(); + context.fillStyle = selected.has(handle.index) || active ? '#2563eb' : hover ? '#dbeafe' : '#ffffff'; + context.strokeStyle = active || hover ? '#1d4ed8' : '#2563eb'; + context.beginPath(); + context.arc(handle.position.x, handle.position.y, 5 / scale, 0, Math.PI * 2); + context.fill(); + context.stroke(); + } + context.restore(); +} + +function getHandlesForShape( + state: EditorState, + shape: EditorShapeRecord, + bindingsBySource?: BindingIndex +): HandleVisual[] { + const handles: HandleVisual[] = []; + if (shape.type === 'text' && shape.props.textPath) { + const position = textPathAnchorForShape(state, shape); + if (position) handles.push({ id: 'text-path-offset', position }); + return handles; + } + if ( + shape.type === 'rect' || + shape.type === 'ellipse' || + shape.type === 'text' || + shape.type === 'markdown' || + shape.type === 'container' + ) { + const bounds = localShapeBounds(shape); + const minX = bounds.min.x; + const maxX = bounds.max.x; + const minY = bounds.min.y; + const maxY = bounds.max.y; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const offset = shape.type === 'text' ? TEXT_HANDLE_OFFSET / state.camera.zoom : 0; + const world = (point: Vec2) => localToWorld(shape, point); + handles.push( + { id: 'nw', position: world({ x: minX - offset, y: minY - offset }) }, + { id: 'n', position: world({ x: centerX, y: minY - offset }) }, + { id: 'ne', position: world({ x: maxX + offset, y: minY - offset }) }, + { id: 'e', position: world({ x: maxX + offset, y: centerY }) }, + { id: 'se', position: world({ x: maxX + offset, y: maxY + offset }) }, + { id: 's', position: world({ x: centerX, y: maxY + offset }) }, + { id: 'sw', position: world({ x: minX - offset, y: maxY + offset }) }, + { id: 'w', position: world({ x: minX - offset, y: centerY }) }, + { + id: 'rotate', + position: world({ x: centerX, y: minY - ROTATE_HANDLE_OFFSET }), + connectorFrom: world({ x: centerX, y: minY - offset }) + } + ); + return handles; + } + + if (shape.type === 'line') { + const start = localToWorld(shape, shape.props.a); + const end = localToWorld(shape, shape.props.b); + handles.push({ id: 'line-start', position: start }, { id: 'line-end', position: end }); + return handles; + } + + if (shape.type === 'arrow') { + const resolved = resolveArrowEndpoints(state, shape.id, bindingsBySource); + const arrowGeometry = arrowGeometryForShape(state, shape, bindingsBySource); + if (resolved && arrowGeometry && shape.props.points && shape.props.points.length >= 2) { + handles.push({ id: 'line-start', position: resolved.a }); + + for (let i = 1; i < shape.props.points.length - 1; i++) { + const point = shape.props.points[i]; + const worldPos = localToWorld(shape, point); + handles.push({ id: `arrow-point-${i}`, position: worldPos }); + } + + const bendHandle = arrowBendHandleForShape(state, shape, bindingsBySource); + if (bendHandle) handles.push({ id: 'arrow-bend', ...bendHandle }); + + handles.push({ id: 'line-end', position: resolved.b }); + + if (shape.props.label) { + const placement = arrowLabelPlacement(arrowGeometry.path, shape.props.label); + if (placement) { + const worldLabelPos = localToWorld(shape, placement.point); + handles.push({ id: 'arrow-label', position: worldLabelPos }); + } + } + } + return handles; + } + + return handles; +} diff --git a/packages/editor/src/renderer/resources.ts b/packages/editor/src/renderer/resources.ts new file mode 100644 index 0000000..01b6e25 --- /dev/null +++ b/packages/editor/src/renderer/resources.ts @@ -0,0 +1,71 @@ +type RendererAsset = { digest: string; mediaType: string; bytes: number[] }; + +/** Small least-recently-used cache used by a renderer instance. */ +export class LruCache { + readonly #entries = new Map(); + + constructor(private readonly capacity: number) {} + + get(key: Key): Value | undefined { + const value = this.#entries.get(key); + if (value === undefined) return undefined; + this.#entries.delete(key); + this.#entries.set(key, value); + return value; + } + + set(key: Key, value: Value): void { + this.#entries.delete(key); + this.#entries.set(key, value); + if (this.#entries.size <= this.capacity) return; + const oldestKey = this.#entries.keys().next().value; + if (oldestKey !== undefined) this.#entries.delete(oldestKey); + } + + clear(): void { + this.#entries.clear(); + } +} + +/** Text and image resources owned by one renderer lifecycle. */ +export class RendererResources { + readonly textLayoutCache = new LruCache(512); + readonly textMetricCache = new LruCache(2_048); + readonly markdownLayoutCache = new LruCache(256); + readonly images = new RendererImageCache(); + + /** Release cached values when the renderer is disposed. */ + dispose(): void { + this.textLayoutCache.clear(); + this.textMetricCache.clear(); + this.markdownLayoutCache.clear(); + this.images.clear(); + } +} + +/** Image cache with an explicit renderer lifetime and async redraw callback. */ +export class RendererImageCache { + readonly #images = new Map(); + + get(asset: RendererAsset, onLoaded: () => void): HTMLImageElement | undefined { + let image = this.#images.get(asset.digest); + if (!image && typeof Image !== 'undefined') { + image = new Image(); + image.onload = onLoaded; + image.src = `data:${asset.mediaType};base64,${bytesToBase64(asset.bytes)}`; + this.#images.set(asset.digest, image); + } + return image; + } + + clear(): void { + for (const image of this.#images.values()) image.onload = null; + this.#images.clear(); + } +} + +function bytesToBase64(bytes: number[]): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return typeof btoa === 'function' ? btoa(binary) : ''; +} diff --git a/packages/editor/src/renderer/scene.ts b/packages/editor/src/renderer/scene.ts new file mode 100644 index 0000000..3451aec --- /dev/null +++ b/packages/editor/src/renderer/scene.ts @@ -0,0 +1,156 @@ +import type { Camera, EditorBindingRecord, EditorShapeRecord, EditorState, Viewport } from '@inkfinite/core'; +import { + arrowPathForShape, + getLayersOnCurrentPage, + getShapesOnCurrentPage, + localToWorld, + shapeBoundsForState +} from '@inkfinite/core'; +import { drawShape } from './shapes.js'; +import { drawBindingPreview, drawSelection, drawSnapGuides } from './overlays.js'; +import type { RendererResources } from './resources.js'; +import type { HandleRenderState, PointerVisualState, SnapSettings } from './types.js'; + +/** Render the world scene, then the editor overlays, in their fixed z-order. */ +export function drawScene( + context: CanvasRenderingContext2D, + state: EditorState, + viewport: Viewport, + snapSettings: SnapSettings | undefined, + cursorState: import('@inkfinite/core').CursorState | undefined, + pointerState: PointerVisualState | undefined, + handleState: HandleRenderState, + theme: 'light' | 'dark', + resources: RendererResources, + onImageLoaded: () => void +): void { + context.save(); + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); + + context.save(); + applyCameraTransform(context, state.camera, viewport); + drawGrid(context, state.camera, viewport, snapSettings, theme); + + const shapes = getShapesOnCurrentPage(state); + const bindingsBySource = new Map(); + for (const binding of Object.values(state.doc.bindings)) { + const bindings = bindingsBySource.get(binding.fromShapeId); + if (bindings) bindings.push(binding); + else bindingsBySource.set(binding.fromShapeId, [binding]); + } + const visibleBounds = getExpandedViewportBounds(state.camera, viewport); + const layers = getLayersOnCurrentPage(state); + const renderShape = (shape: EditorShapeRecord) => { + if (!isShapeVisible(state, shape, visibleBounds, bindingsBySource)) return; + drawShape(context, state, shape, bindingsBySource, theme, resources, onImageLoaded); + }; + + if (layers.length === 0) { + for (const shape of shapes) renderShape(shape); + } else { + for (const layer of layers) { + if (!layer.visible) continue; + context.save(); + context.globalAlpha *= layer.opacity; + for (const shapeId of layer.shapeIds) { + const shape = state.doc.shapes[shapeId]; + if (shape) renderShape(shape); + } + context.restore(); + } + } + + drawSelection(context, state, shapes, handleState, bindingsBySource, theme); + drawBindingPreview(context, state); + drawSnapGuides(context, state.camera, viewport, snapSettings, cursorState, pointerState); + context.restore(); +} + +type VisibleBounds = { minX: number; minY: number; maxX: number; maxY: number }; + +function getExpandedViewportBounds(camera: Camera, viewport: Viewport): VisibleBounds { + const padding = 48 / camera.zoom; + const halfWidth = viewport.width / (2 * camera.zoom); + const halfHeight = viewport.height / (2 * camera.zoom); + return { + minX: camera.x - halfWidth - padding, + minY: camera.y - halfHeight - padding, + maxX: camera.x + halfWidth + padding, + maxY: camera.y + halfHeight + padding + }; +} + +function isShapeVisible( + state: EditorState, + shape: EditorShapeRecord, + viewport: VisibleBounds, + bindingsBySource: Map +): boolean { + if (shape.type === 'arrow') { + const points = arrowPathForShape(state, shape, bindingsBySource); + if (points.length >= 2) { + const worldPoints = points.map((point) => localToWorld(shape, point)); + const minX = Math.min(...worldPoints.map((point) => point.x)); + const minY = Math.min(...worldPoints.map((point) => point.y)); + const maxX = Math.max(...worldPoints.map((point) => point.x)); + const maxY = Math.max(...worldPoints.map((point) => point.y)); + return maxX >= viewport.minX && minX <= viewport.maxX && maxY >= viewport.minY && minY <= viewport.maxY; + } + } + + const bounds = shapeBoundsForState(state, shape); + return ( + bounds.max.x >= viewport.minX && + bounds.min.x <= viewport.maxX && + bounds.max.y >= viewport.minY && + bounds.min.y <= viewport.maxY + ); +} + +/** Apply the camera transform shared by all scene and overlay drawing. */ +function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport): void { + context.translate(viewport.width / 2, viewport.height / 2); + context.scale(camera.zoom, camera.zoom); + context.translate(-camera.x, -camera.y); +} + +const DEFAULT_GRID_SIZE = 25; + +/** Draw the snapping grid in world space. */ +function drawGrid( + context: CanvasRenderingContext2D, + camera: Camera, + viewport: Viewport, + snapSettings?: SnapSettings, + theme: 'light' | 'dark' = 'light' +): void { + if (snapSettings && !snapSettings.gridEnabled) return; + const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; + const stepMultiplier = Math.max(1, Math.ceil(10 / (gridSize * camera.zoom))); + const visibleGridSize = gridSize * stepMultiplier; + const topLeft = { + x: camera.x - viewport.width / (2 * camera.zoom), + y: camera.y - viewport.height / (2 * camera.zoom) + }; + const bottomRight = { + x: camera.x + viewport.width / (2 * camera.zoom), + y: camera.y + viewport.height / (2 * camera.zoom) + }; + const startX = Math.floor(topLeft.x / visibleGridSize) * visibleGridSize; + const endX = Math.ceil(bottomRight.x / visibleGridSize) * visibleGridSize; + const startY = Math.floor(topLeft.y / visibleGridSize) * visibleGridSize; + const endY = Math.ceil(bottomRight.y / visibleGridSize) * visibleGridSize; + const dotRadius = 1.25 / camera.zoom; + + context.fillStyle = theme === 'dark' ? 'rgba(167, 180, 188, 0.38)' : 'rgba(73, 80, 99, 0.32)'; + context.beginPath(); + for (let x = startX; x <= endX; x += visibleGridSize) { + for (let y = startY; y <= endY; y += visibleGridSize) { + context.moveTo(x + dotRadius, y); + context.arc(x, y, dotRadius, 0, Math.PI * 2); + } + } + context.fill(); +} diff --git a/packages/editor/src/renderer/shapes.ts b/packages/editor/src/renderer/shapes.ts new file mode 100644 index 0000000..9f4931f --- /dev/null +++ b/packages/editor/src/renderer/shapes.ts @@ -0,0 +1,381 @@ +import type { + ArrowShape, + BindingIndex, + EditorState, + EditorShapeRecord, + EllipseShape, + LineShape, + PathShape, + RectShape, + StrokeShape +} from '@inkfinite/core'; +import { + arrowGeometryForShape, + arrowHeadGeometry, + arrowPathForShape, + arrowShaftGeometry, + getStrokeOutline, + pathGeometryBounds, + textPathLayoutForShape +} from '@inkfinite/core'; +import { applyShapeEffects, applyShapeTransform, drawNativePath, paintForCanvas } from './canvas.js'; +import { drawImage, drawReference } from './assets.js'; +import { drawArrowLabel, drawMarkdown, drawText, drawTextOnPath } from './text.js'; +import type { RendererResources } from './resources.js'; + +/** Render one shape through the single exhaustive shape-kind dispatch point. */ +export function drawShape( + context: CanvasRenderingContext2D, + state: EditorState, + shape: EditorShapeRecord, + bindingsBySource: BindingIndex, + theme: 'light' | 'dark', + resources: RendererResources, + onImageLoaded: () => void +) { + context.save(); + context.globalAlpha *= shape.opacity ?? 1; + + const attachedText = + shape.type === 'text' && shape.props.textPath + ? (() => { + context.font = `${shape.props.fontSize}px ${shape.props.fontFamily}`; + return textPathLayoutForShape(state, shape, (value) => context.measureText(value).width); + })() + : null; + if (!attachedText) { + applyShapeTransform(context, shape); + applyShapeEffects(context, shape); + } + + switch (shape.type) { + case 'rect': { + drawRect(context, shape); + break; + } + case 'ellipse': { + drawEllipse(context, shape); + break; + } + case 'line': { + drawLine(context, shape); + break; + } + case 'arrow': { + drawArrow(context, state, shape, bindingsBySource); + break; + } + case 'text': { + if (attachedText) drawTextOnPath(context, shape, attachedText.path, attachedText.layout); + else drawText(context, shape, resources); + break; + } + case 'markdown': { + drawMarkdown(context, shape, theme, resources); + break; + } + case 'image': { + drawImage(context, state, shape, resources.images, onImageLoaded); + break; + } + case 'reference': { + drawReference(context, shape); + break; + } + case 'stroke': { + drawStroke(context, shape); + break; + } + case 'path': { + drawPath(context, shape); + break; + } + case 'container': { + drawContainer(context, shape); + break; + } + default: + return assertNeverShape(shape); + } + + context.restore(); +} + +/** + * Draw a rectangle shape + */ +function drawRect(context: CanvasRenderingContext2D, shape: RectShape) { + const { w, h, fill, stroke, radius } = shape.props; + const shapeAlpha = context.globalAlpha; + + context.beginPath(); + if (radius > 0) { + const r = Math.min(radius, w / 2, h / 2); + context.moveTo(r, 0); + context.lineTo(w - r, 0); + context.arcTo(w, 0, w, r, r); + context.lineTo(w, h - r); + context.arcTo(w, h, w - r, h, r); + context.lineTo(r, h); + context.arcTo(0, h, 0, h - r, r); + context.lineTo(0, r); + context.arcTo(0, 0, r, 0, r); + context.closePath(); + } else { + context.rect(0, 0, w, h); + } + + if (fill) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + const fillStyle = paintForCanvas(context, fill, { x: 0, y: 0, width: w, height: h }); + if (fillStyle) { + context.fillStyle = fillStyle; + context.fill(); + } + } + + if (stroke) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + const strokeStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }); + if (strokeStyle) { + context.strokeStyle = strokeStyle; + context.lineWidth = 2; + context.stroke(); + } + } +} + +/** + * Draw an ellipse shape + */ +function drawEllipse(context: CanvasRenderingContext2D, shape: EllipseShape) { + const { w, h, fill, stroke } = shape.props; + const shapeAlpha = context.globalAlpha; + + context.beginPath(); + context.ellipse(w / 2, h / 2, w / 2, h / 2, 0, 0, Math.PI * 2); + + if (fill) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + const fillStyle = paintForCanvas(context, fill, { x: 0, y: 0, width: w, height: h }); + if (fillStyle) { + context.fillStyle = fillStyle; + context.fill(); + } + } + + if (stroke) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + const strokeStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }); + if (strokeStyle) { + context.strokeStyle = strokeStyle; + context.lineWidth = 2; + context.stroke(); + } + } +} + +/** + * Draw a line shape + */ +function drawLine(context: CanvasRenderingContext2D, shape: LineShape) { + const { a, b, stroke, width } = shape.props; + + context.beginPath(); + context.moveTo(a.x, a.y); + context.lineTo(b.x, b.y); + + context.globalAlpha *= shape.strokeOpacity ?? 1; + const strokeStyle = paintForCanvas(context, stroke, { + x: Math.min(a.x, b.x), + y: Math.min(a.y, b.y), + width: Math.abs(b.x - a.x), + height: Math.abs(b.y - a.y) + }); + if (strokeStyle) { + context.strokeStyle = strokeStyle; + context.lineWidth = width; + context.stroke(); + } +} + +function drawContainer(context: CanvasRenderingContext2D, shape: Extract) { + const { w = 0, h = 0, title, fill, stroke, radius = 0 } = shape.props; + const shapeAlpha = context.globalAlpha; + context.beginPath(); + if (radius > 0) { + const r = Math.min(radius, w / 2, h / 2); + context.moveTo(r, 0); + context.lineTo(w - r, 0); + context.arcTo(w, 0, w, r, r); + context.lineTo(w, h - r); + context.arcTo(w, h, w - r, h, r); + context.lineTo(r, h); + context.arcTo(0, h, 0, h - r, r); + context.lineTo(0, r); + context.arcTo(0, 0, r, 0, r); + context.closePath(); + } else { + context.rect(0, 0, w, h); + } + if (fill) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + const fillStyle = paintForCanvas(context, fill, { x: 0, y: 0, width: w, height: h }); + if (fillStyle) { + context.fillStyle = fillStyle; + context.fill(); + } + } + if (stroke) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + const strokeStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }); + if (strokeStyle) { + context.strokeStyle = strokeStyle; + context.lineWidth = 1.5; + context.stroke(); + } + } + if (title) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + context.fillStyle = paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }) ?? '#69717d'; + context.font = '600 14px sans-serif'; + context.textBaseline = 'top'; + context.fillText(title, 8, 6); + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + context.strokeStyle = + paintForCanvas(context, stroke, { x: 0, y: 0, width: w, height: h }) ?? 'rgba(37, 99, 235, 0.45)'; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(0, 26); + context.lineTo(w, 26); + context.stroke(); + } +} + +/** Draw a native path shape using the shared Canvas path helper. */ +function drawPath(context: CanvasRenderingContext2D, shape: PathShape): void { + const { subpaths, fill_rule: fillRule, fill, stroke, stroke_width: strokeWidth } = shape.props; + const shapeAlpha = context.globalAlpha; + drawNativePath(context, { subpaths, fill_rule: fillRule }); + if (fill && fill !== 'none' && fill !== 'transparent') { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + const fillStyle = paintForCanvas(context, fill, pathGeometryBounds(shape.props)); + if (fillStyle) { + context.fillStyle = fillStyle; + context.fill(fillRule); + } + } + if (stroke && stroke !== 'none' && stroke !== 'transparent') { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + const strokeStyle = paintForCanvas(context, stroke, pathGeometryBounds(shape.props)); + if (strokeStyle) { + context.strokeStyle = strokeStyle; + context.lineWidth = Math.max(0, strokeWidth ?? 2); + context.stroke(); + } + } +} + +/** + * Draw an arrow shape + */ +function drawArrow( + context: CanvasRenderingContext2D, + state: EditorState, + shape: ArrowShape, + bindingsBySource?: BindingIndex +) { + const style = shape.props.style; + const shapeAlpha = context.globalAlpha; + + const geometry = arrowGeometryForShape(state, shape, bindingsBySource); + if (!geometry) return; + + const shaft = arrowShaftGeometry(geometry.path, style); + drawNativePath(context, shaft); + + const strokePaint = paintForCanvas(context, style.stroke, pathGeometryBounds(geometry.path)); + if (!strokePaint) return; + context.strokeStyle = strokePaint; + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + context.lineWidth = style.width; + if (style.dash) context.setLineDash(style.dash); + context.stroke(); + if (style.dash) context.setLineDash([]); + + const drawHead = (atStart: boolean) => { + const head = arrowHeadGeometry(geometry.path, atStart); + if (!head) return; + const headStyle = atStart ? style.headStartStyle : style.headEndStyle; + context.beginPath(); + context.moveTo(head.tip.x, head.tip.y); + if (headStyle === 'triangle') { + context.lineTo(head.left.x, head.left.y); + context.lineTo(head.right.x, head.right.y); + context.closePath(); + const headFill = paintForCanvas(context, style.stroke, pathGeometryBounds(geometry.path)); + if (headFill) { + context.fillStyle = headFill; + context.fill(); + } + } else { + context.lineTo(head.left.x, head.left.y); + context.moveTo(head.tip.x, head.tip.y); + context.lineTo(head.right.x, head.right.y); + } + const headStroke = paintForCanvas(context, style.stroke, pathGeometryBounds(geometry.path)); + if (headStroke) { + context.strokeStyle = headStroke; + context.lineWidth = style.width; + context.stroke(); + } + }; + + if (style.headEnd !== false) drawHead(false); + if (style.headStart) drawHead(true); + + const label = shape.props.label; + if (label) { + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + drawArrowLabel(context, state, geometry.path, label); + } +} + +/** + * Draw a stroke shape (freehand drawing) + */ +function drawStroke(context: CanvasRenderingContext2D, shape: StrokeShape) { + const { points, style } = shape.props; + + if (points.length < 2) { + return; + } + + const outline = getStrokeOutline(shape); + + if (outline.length === 0) { + return; + } + + context.globalAlpha *= shape.strokeOpacity ?? style.opacity; + const outlineBounds = { + x: Math.min(...outline.map((point) => point.x)), + y: Math.min(...outline.map((point) => point.y)), + width: Math.max(...outline.map((point) => point.x)) - Math.min(...outline.map((point) => point.x)), + height: Math.max(...outline.map((point) => point.y)) - Math.min(...outline.map((point) => point.y)) + }; + context.fillStyle = paintForCanvas(context, style.color, outlineBounds) ?? '#000000'; + context.beginPath(); + context.moveTo(outline[0].x, outline[0].y); + + for (let i = 1; i < outline.length; i++) { + context.lineTo(outline[i].x, outline[i].y); + } + + context.closePath(); + context.fill(); +} + +function assertNeverShape(shape: never): never { + throw new Error(`Unsupported shape type: ${String(shape)}`); +} diff --git a/packages/editor/src/renderer/text.ts b/packages/editor/src/renderer/text.ts new file mode 100644 index 0000000..5e9a44f --- /dev/null +++ b/packages/editor/src/renderer/text.ts @@ -0,0 +1,397 @@ +import type { + ArrowLabel, + EditorState, + MarkdownShape, + PathGeometry, + PathShape, + TextPathLayout, + TextShape +} from '@inkfinite/core'; +import { arrowLabelPlacement, shapeTransform } from '@inkfinite/core'; +import { paintForCanvas } from './canvas.js'; +import type { RendererResources } from './resources.js'; +import { LruCache } from './resources.js'; + +/** + * Draw an arrow label + */ +export function drawArrowLabel( + context: CanvasRenderingContext2D, + state: EditorState, + geometry: PathGeometry, + label: ArrowLabel +) { + if (!label.text) return; + + const placement = arrowLabelPlacement(geometry, label); + if (!placement) return; + const labelPos = placement.point; + + context.save(); + context.font = '14px sans-serif'; + context.fillStyle = '#000'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + const metrics = context.measureText(label.text); + const padding = 4; + const bgWidth = metrics.width + padding * 2; + const bgHeight = 18; + + context.fillStyle = 'rgba(255, 255, 255, 0.9)'; + context.fillRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight / 2, bgWidth, bgHeight); + context.strokeStyle = '#ccc'; + context.lineWidth = 1 / state.camera.zoom; + context.strokeRect(labelPos.x - bgWidth / 2, labelPos.y - bgHeight / 2, bgWidth, bgHeight); + + context.fillStyle = '#000'; + context.fillText(label.text, labelPos.x, labelPos.y); + context.restore(); +} + +/** + * Draw a text shape + */ +export function drawText(context: CanvasRenderingContext2D, shape: TextShape, resources: RendererResources) { + const { text, fontSize, fontFamily, color, w } = shape.props; + + context.globalAlpha *= shape.fillOpacity ?? 1; + context.font = `${fontSize}px ${fontFamily}`; + context.fillStyle = + paintForCanvas(context, color, { x: 0, y: 0, width: w ?? fontSize * 10, height: fontSize * 1.2 }) ?? '#000000'; + context.textBaseline = 'top'; + + if (w === undefined) { + context.fillText(text, 0, 0); + } else { + const lines = wrapText(context, text, w, resources.textLayoutCache, resources.textMetricCache); + for (const [index, line] of lines.entries()) { + context.fillText(line, 0, index * fontSize * 1.2); + } + } +} + +/** Draw attached text in the supporting path's local coordinate system. */ +export function drawTextOnPath( + context: CanvasRenderingContext2D, + shape: TextShape, + path: PathShape, + layout: TextPathLayout +): void { + const matrix = shapeTransform(path); + context.save(); + context.globalAlpha *= shape.fillOpacity ?? 1; + context.transform(matrix[0], matrix[1], matrix[3], matrix[4], matrix[6], matrix[7]); + context.font = `${shape.props.fontSize}px ${shape.props.fontFamily}`; + context.fillStyle = + paintForCanvas(context, shape.props.color, { + x: layout.bounds.min.x, + y: layout.bounds.min.y, + width: layout.bounds.max.x - layout.bounds.min.x, + height: layout.bounds.max.y - layout.bounds.min.y + }) ?? '#000000'; + context.textAlign = 'center'; + context.textBaseline = 'alphabetic'; + for (const glyph of layout.glyphs) { + context.save(); + context.translate(glyph.point.x, glyph.point.y); + context.rotate(glyph.angle); + context.fillText(glyph.character, 0, 0); + context.restore(); + } + context.restore(); +} + +/** + * Parse and render markdown to canvas + * + * Renders markdown with basic formatting: + * - Headings (h1-h6) with appropriate sizes + * - Bold (**text** or __text__) + * - Italic (*text* or _text_) + * - Code (`code`) + * - Paragraphs with line wrapping + * - Lists (ordered and unordered) + * - Code blocks (```) + */ +export type MarkdownLine = { source: string; kind: 'code' | 'text'; fontSize: number; weight: string; prefix: string }; + +function prepareMarkdownLines(source: string, baseFontSize: number): MarkdownLine[] { + const sourceLines = source.split('\n'); + const lines: MarkdownLine[] = []; + + for (let index = 0; index < sourceLines.length; index++) { + let line = sourceLines[index]; + if (line.startsWith('```')) { + const codeLines: string[] = []; + index++; + while (index < sourceLines.length && !sourceLines[index].startsWith('```')) { + codeLines.push(sourceLines[index]); + index++; + } + lines.push({ + source: codeLines.join('\n'), + kind: 'code', + fontSize: baseFontSize, + weight: 'normal', + prefix: '' + }); + continue; + } + + let fontSize = baseFontSize; + let weight = 'normal'; + let prefix = ''; + const heading = line.match(/^(#{1,6})\s(.*)$/); + const orderedItem = line.match(/^(\d+)\.\s(.*)$/); + if (heading) { + fontSize = baseFontSize * (2 - heading[1].length * 0.15); + weight = 'bold'; + line = heading[2]; + } else if (/^[-*+]\s/.test(line)) { + prefix = '• '; + line = line.replace(/^[-*+]\s/, ''); + } else if (orderedItem) { + prefix = `${orderedItem[1]}. `; + line = orderedItem[2]; + } + lines.push({ source: line, kind: 'text', fontSize, weight, prefix }); + } + + return lines; +} + +export function drawMarkdown( + context: CanvasRenderingContext2D, + shape: MarkdownShape, + theme: 'light' | 'dark' = 'light', + resources: RendererResources +) { + const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; + + const width = w; + const height = h ?? fontSize * 10; + const shapeAlpha = context.globalAlpha; + + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + context.fillStyle = paintForCanvas(context, bg, { x: 0, y: 0, width, height }) ?? '#ffffff'; + context.fillRect(0, 0, width, height); + + if (border) { + context.globalAlpha = shapeAlpha * (shape.strokeOpacity ?? 1); + context.strokeStyle = paintForCanvas(context, border, { x: 0, y: 0, width, height }) ?? '#000000'; + context.lineWidth = 1; + context.strokeRect(0, 0, width, height); + } + + context.globalAlpha = shapeAlpha * (shape.fillOpacity ?? 1); + context.fillStyle = paintForCanvas(context, color, { x: 0, y: 0, width, height }) ?? '#000000'; + context.textBaseline = 'top'; + + const padding = 8; + let yOffset = padding; + const lineHeight = fontSize * 1.4; + + const layoutKey = `${md}\u0000${w}\u0000${fontSize}\u0000${fontFamily}\u0000${theme}`; + let preparedLines = resources.markdownLayoutCache.get(layoutKey); + if (!preparedLines) { + preparedLines = prepareMarkdownLines(md, fontSize); + resources.markdownLayoutCache.set(layoutKey, preparedLines); + } + + for (let lineIndex = 0; lineIndex < preparedLines.length; lineIndex++) { + const prepared = preparedLines[lineIndex]; + let line = prepared.source; + + if (yOffset + lineHeight > height - padding) break; + + let currentFontSize = prepared.fontSize; + let currentStyle = 'normal'; + let currentWeight = 'normal'; + let prefix = prepared.prefix; + + if (prepared.kind === 'code') { + context.fillStyle = theme === 'dark' ? '#2e3440' : '#f4f4f4'; + const codeBlockLines = line.split('\n'); + + const codeBlockHeight = codeBlockLines.length * lineHeight + padding * 2; + if (yOffset + codeBlockHeight <= height - padding) { + context.fillRect(padding, yOffset, width - padding * 2, codeBlockHeight); + + context.fillStyle = theme === 'dark' ? '#e5e9f0' : '#333'; + context.font = `normal normal ${fontSize}px monospace`; + + for (const [index, codeLine] of codeBlockLines.entries()) { + context.fillText(codeLine, padding + 4, yOffset + padding + index * lineHeight); + } + + yOffset += codeBlockHeight + padding; + } + + context.fillStyle = paintForCanvas(context, color, { x: 0, y: 0, width, height }) ?? '#000000'; + context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; + continue; + } + + currentWeight = prepared.weight; + + line = prefix + line; + + line = line.replace(/`([^`]+)`/g, '$1'); + + context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; + + const wrappedLines = wrapText( + context, + line, + width - padding * 2, + resources.textLayoutCache, + resources.textMetricCache + ); + + for (const wrappedLine of wrappedLines) { + if (yOffset + currentFontSize * 1.4 > height - padding) break; + + const styledLine = wrappedLine; + let xOffset = padding; + + const segments = parseInlineStyles(styledLine); + + for (const segment of segments) { + const { text: segmentText, bold, italic, code } = segment; + + if (code) { + context.fillStyle = theme === 'dark' ? '#2e3440' : '#f4f4f4'; + const metrics = context.measureText(segmentText); + context.fillRect(xOffset, yOffset, metrics.width + 4, currentFontSize * 1.2); + + context.fillStyle = theme === 'dark' ? '#e5e9f0' : '#333'; + context.font = `normal normal ${currentFontSize * 0.9}px monospace`; + context.fillText(segmentText, xOffset + 2, yOffset); + xOffset += metrics.width + 4; + context.fillStyle = paintForCanvas(context, color, { x: 0, y: 0, width, height }) ?? '#000000'; + context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; + } else { + const weight = bold ? 'bold' : currentWeight; + const style = italic ? 'italic' : currentStyle; + context.font = `${weight} ${style} ${currentFontSize}px ${fontFamily}`; + context.fillText(segmentText, xOffset, yOffset); + const metrics = context.measureText(segmentText); + xOffset += metrics.width; + context.font = `${currentWeight} ${currentStyle} ${currentFontSize}px ${fontFamily}`; + } + } + + yOffset += currentFontSize * 1.4; + } + } +} + +/** + * Parse inline markdown styles (bold, italic, code) into segments + */ +function parseInlineStyles(text: string): Array<{ text: string; bold: boolean; italic: boolean; code: boolean }> { + const segments: Array<{ text: string; bold: boolean; italic: boolean; code: boolean }> = []; + + const codeRegex = /`([^`]+)`/g; + const parts = []; + let lastIndex = 0; + let match; + + while ((match = codeRegex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ text: text.slice(lastIndex, match.index), code: false }); + } + parts.push({ text: match[1], code: true }); + lastIndex = codeRegex.lastIndex; + } + + if (lastIndex < text.length) { + parts.push({ text: text.slice(lastIndex), code: false }); + } + + for (const part of parts) { + if (part.code) { + segments.push({ text: part.text, bold: false, italic: false, code: true }); + } else { + const boldItalicRegex = /(\*\*\*|___)([^*_]+)(\*\*\*|___)|(\*\*|__)([^*_]+)(\*\*|__)|(\*|_)([^*_]+)(\*|_)/g; + let lastPartIndex = 0; + let partMatch; + + while ((partMatch = boldItalicRegex.exec(part.text)) !== null) { + if (partMatch.index > lastPartIndex) { + segments.push({ + text: part.text.slice(lastPartIndex, partMatch.index), + bold: false, + italic: false, + code: false + }); + } + + if (partMatch[1]) { + segments.push({ text: partMatch[2], bold: true, italic: true, code: false }); + } else if (partMatch[4]) { + segments.push({ text: partMatch[5], bold: true, italic: false, code: false }); + } else if (partMatch[7]) { + segments.push({ text: partMatch[8], bold: false, italic: true, code: false }); + } + + lastPartIndex = boldItalicRegex.lastIndex; + } + + if (lastPartIndex < part.text.length) { + segments.push({ text: part.text.slice(lastPartIndex), bold: false, italic: false, code: false }); + } + + if (segments.length === 0 || lastPartIndex === 0) { + if (segments.length === 0) { + segments.push({ text: part.text, bold: false, italic: false, code: false }); + } + } + } + } + + if (segments.length === 0) { + segments.push({ text, bold: false, italic: false, code: false }); + } + + return segments; +} + +/** + * Wrap text to fit within a given width + */ +function wrapText( + context: CanvasRenderingContext2D, + text: string, + maxWidth: number, + layoutCache = new LruCache(512), + metricCache = new LruCache(2_048) +): string[] { + const cacheKey = `${context.font}\u0000${maxWidth}\u0000${text}`; + const cached = layoutCache.get(cacheKey); + if (cached) return cached; + const lines: string[] = []; + for (const sourceLine of text.split('\n')) { + let currentLine = ''; + for (const word of sourceLine.split(' ')) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const widthKey = `${context.font}\u0000${testLine}`; + let measuredWidth = metricCache.get(widthKey); + if (measuredWidth === undefined) { + measuredWidth = context.measureText(testLine).width; + metricCache.set(widthKey, measuredWidth); + } + + if (measuredWidth > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + lines.push(currentLine); + } + + layoutCache.set(cacheKey, lines); + return lines; +} diff --git a/packages/editor/src/renderer/types.ts b/packages/editor/src/renderer/types.ts new file mode 100644 index 0000000..ed3113e --- /dev/null +++ b/packages/editor/src/renderer/types.ts @@ -0,0 +1,52 @@ +import type { CursorState, EditorState, SnapGuide, Store, Vec2, Viewport } from '@inkfinite/core'; + +/** Grid and snapping settings consumed by the Canvas renderer. */ +export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; + +/** Ephemeral pointer information used by snap guides and overlays. */ +export type PointerVisualState = { isPointerDown: boolean; snappedWorld?: Vec2 | null; snapGuides?: SnapGuide[] }; + +/** Current hover and active state for editor handles. */ +export type HandleRenderState = { hover: string | null; active: string | null } | null | undefined; + +/** Platform-neutral providers used by the Canvas renderer. */ +export type RendererOptions = { + snapProvider?: { get(): SnapSettings }; + cursorProvider?: { get(): CursorState }; + pointerStateProvider?: { get(): PointerVisualState }; + handleProvider?: { get(): HandleRenderState }; + themeProvider?: { get(): 'light' | 'dark' }; +}; + +/** Lifecycle controls for a Canvas renderer instance. */ +export interface Renderer { + /** Stop rendering and release subscriptions and renderer-owned resources. */ + dispose(): void; + + /** Schedule a redraw on the next animation frame. */ + markDirty(): void; +} + +/** Inputs required to render one scene frame. */ +export type SceneRenderOptions = { + viewport: Viewport; + snapSettings?: SnapSettings; + cursorState?: CursorState; + pointerState?: PointerVisualState; + handleState?: HandleRenderState; + theme: 'light' | 'dark'; + onImageLoaded: () => void; +}; + +/** State and drawing context shared by shape renderers. */ +export type ShapeRenderState = { + state: EditorState; + context: CanvasRenderingContext2D; + bindingsBySource: Map; +}; + +/** Canvas viewport dimensions in CSS pixels. */ +export type CanvasViewport = Viewport; + +/** Store type retained here to make renderer lifecycle dependencies explicit. */ +export type RendererStore = Store; diff --git a/packages/editor/src/runtime.ts b/packages/editor/src/runtime.ts index 9494205..b74539d 100644 --- a/packages/editor/src/runtime.ts +++ b/packages/editor/src/runtime.ts @@ -1,26 +1,18 @@ import { type Action, - EditorBindingRecord, Camera, - duplicateAndConnectSelection, - type CommandKind, - createId, EditorState, getSelectionScopeShapes, - groupShapes, - reorderShapes, - reorderShapesToEdge, - routeAction, - setShapesLocked, - translateShapes, - ungroupShapes, - EditorShapeRecord, hitTestPoint, + routeAction, selectionTarget, + type CommandKind, type PathTopologyEdit, type Store, type Tool } from '@inkfinite/core'; +import { dispatchHostRequest, type EditorHostRequests } from './host.js'; +import { resolveKeyboardShortcut } from './shortcuts.js'; /** Grid settings consumed by the editor runtime. */ export type SnapSettings = { @@ -48,7 +40,7 @@ export type RuntimeTransactionDraft = { }; /** Dependencies supplied by a UI adapter. */ -export type EditorRuntimeOptions = { +export type EditorRuntimeOptions = EditorHostRequests & { store: Store; tools: Map; selectionTool: SelectionTool; @@ -69,6 +61,8 @@ export type EditorRuntimeOptions = { onCopyRequested?: () => void; onCutRequested?: () => void; onPasteRequested?: () => void; + /** Optional grouped host boundary; legacy callback fields remain supported. */ + host?: EditorHostRequests; onHandleHover?: (handle: string | null) => void; onInteractionChanged?: () => void; onSnappedWorldChanged?: (world: { x: number; y: number }) => void; @@ -83,6 +77,7 @@ export type EditorRuntimeOptions = { */ export class EditorRuntime { private readonly options: EditorRuntimeOptions; + private readonly hostRequests: EditorHostRequests; private gestureStart: EditorState | null = null; private pointerDown = false; private spaceHeld = false; @@ -124,6 +119,7 @@ export class EditorRuntime { constructor(options: EditorRuntimeOptions) { this.options = options; + this.hostRequests = { ...options, ...options.host }; } /** Returns the local interaction state needed by cursor and overlay adapters. */ @@ -197,17 +193,9 @@ export class EditorRuntime { } const before = store.getState(); - const shortcut = applyKeyboardShortcut(before, routedAction, { - onBrowseRequested: this.options.onBrowseRequested, - onShortcutsRequested: this.options.onShortcutsRequested, - onCommandPaletteRequested: this.options.onCommandPaletteRequested, - onUndoRequested: this.options.onUndoRequested, - onRedoRequested: this.options.onRedoRequested, - onCopyRequested: this.options.onCopyRequested, - onCutRequested: this.options.onCutRequested, - onPasteRequested: this.options.onPasteRequested - }); - const after = shortcut ?? routeAction(before, routedAction, this.options.tools); + const shortcut = resolveKeyboardShortcut(before, routedAction); + if (shortcut.request) dispatchHostRequest(shortcut.request, this.hostRequests); + const after = shortcut.state ?? routeAction(before, routedAction, this.options.tools); if (!statesEqual(before, after)) { const kind = commandKind(before, after); @@ -296,219 +284,6 @@ function snapAction(action: Action, snap: SnapSettings): Action { }; } -function applyKeyboardShortcut( - state: EditorState, - action: Action, - handlers: { - onBrowseRequested?: () => void; - onShortcutsRequested?: () => void; - onCommandPaletteRequested?: () => void; - onUndoRequested?: () => void; - onRedoRequested?: () => void; - onCopyRequested?: () => void; - onCutRequested?: () => void; - onPasteRequested?: () => void; - } -): EditorState | null { - if (action.type !== 'key-down') return null; - const primary = action.modifiers.meta || action.modifiers.ctrl; - if (action.key === '?' || (action.key === '/' && action.modifiers.shift)) { - handlers.onShortcutsRequested?.(); - return null; - } - if (primary && ['k', 'K'].includes(action.key)) { - handlers.onCommandPaletteRequested?.(); - return null; - } - if (primary && ['z', 'Z'].includes(action.key)) { - if (action.modifiers.shift) handlers.onRedoRequested?.(); - else handlers.onUndoRequested?.(); - return null; - } - if (primary && ['y', 'Y'].includes(action.key)) { - handlers.onRedoRequested?.(); - return null; - } - if (primary && ['b', 'B'].includes(action.key)) { - handlers.onBrowseRequested?.(); - return null; - } - if (primary && ['c', 'C'].includes(action.key)) { - handlers.onCopyRequested?.(); - return null; - } - if (primary && ['x', 'X'].includes(action.key)) { - handlers.onCutRequested?.(); - return null; - } - if (primary && ['v', 'V'].includes(action.key)) { - handlers.onPasteRequested?.(); - return null; - } - if (primary && ['a', 'A'].includes(action.key)) { - const selectionIds = getSelectionScopeShapes(state).map((shape) => shape.id); - return selectionIds.length === 0 || selectionIds.every((id) => state.ui.selectionIds.includes(id)) - ? null - : { ...state, ui: { ...state.ui, selectionIds } }; - } - if (state.ui.selectionIds.length === 0) return null; - - if (action.key.startsWith('Arrow')) { - const step = action.modifiers.shift ? 10 : 1; - const delta = arrowDelta(action.key, step); - if (delta) { - const next = translateShapes(state, state.ui.selectionIds, delta); - return next === state ? null : next; - } - } - if (primary && action.modifiers.alt && ['d', 'D'].includes(action.key)) { - return duplicateAndConnectSelection(state); - } - if (primary && ['d', 'D'].includes(action.key)) return duplicateSelection(state); - if (primary && ['g', 'G'].includes(action.key)) { - return action.modifiers.shift - ? nullableState(ungroupShapes(state, state.ui.selectionIds), state) - : nullableState(groupShapes(state, state.ui.selectionIds), state); - } - if (primary && action.modifiers.shift && ['l', 'L'].includes(action.key)) { - const locked = state.ui.selectionIds.every((id) => state.doc.shapes[id]?.locked); - return nullableState(setShapesLocked(state, state.ui.selectionIds, !locked), state); - } - if (primary && action.key === ']') { - return action.modifiers.shift - ? nullableState(reorderShapesToEdge(state, state.ui.selectionIds, 'front'), state) - : reorderSelection(state, 'forward'); - } - if (primary && action.key === '[') { - return action.modifiers.shift - ? nullableState(reorderShapesToEdge(state, state.ui.selectionIds, 'back'), state) - : reorderSelection(state, 'backward'); - } - return null; -} - -function nullableState(next: EditorState, previous: EditorState): EditorState | null { - return next === previous ? null : next; -} - -function arrowDelta(key: string, step: number): { x: number; y: number } | null { - switch (key) { - case 'ArrowLeft': - return { x: -step, y: 0 }; - case 'ArrowRight': - return { x: step, y: 0 }; - case 'ArrowUp': - return { x: 0, y: -step }; - case 'ArrowDown': - return { x: 0, y: step }; - default: - return null; - } -} - -function duplicateSelection(state: EditorState): EditorState | null { - const selectedIds = new Set(state.ui.selectionIds); - const roots = state.ui.selectionIds.filter((id) => !hasSelectedAncestor(state, id, selectedIds)); - if (roots.length === 0) return null; - const included = Object.values(state.doc.shapes).filter( - (shape) => roots.includes(shape.id) || roots.some((root) => hasAncestor(shape, root, state)) - ); - const mapping = new Map(included.map((shape) => [shape.id, createId('shape')])); - const shapes = { ...state.doc.shapes }; - for (const source of included) { - const copy = EditorShapeRecord.clone(source); - const id = mapping.get(source.id)!; - const parentId = source.groupId ? mapping.get(source.groupId) : undefined; - const copied = { - ...copy, - id, - x: copy.x + 12, - y: copy.y + 12, - editorTransform: copy.editorTransform - ? { ...copy.editorTransform, e: copy.editorTransform.e + 12, f: copy.editorTransform.f + 12 } - : undefined, - ...(parentId ? { groupId: parentId } : { groupId: undefined }) - }; - shapes[id] = copied; - if (copied.type === 'text' && copied.props.textPath) { - const pathId = mapping.get(copied.props.textPath.pathId); - if (pathId) copied.props = { ...copied.props, textPath: { ...copied.props.textPath, pathId } }; - } - } - const pages = { ...state.doc.pages }; - const layers = state.doc.layers ? { ...state.doc.layers } : undefined; - const copiedIds = included.map((shape) => mapping.get(shape.id)!); - const rootCopies = roots.map((id) => mapping.get(id)).filter((id): id is string => Boolean(id)); - for (const page of Object.values(pages)) { - const added = copiedIds.filter((id) => shapes[id]?.pageId === page.id); - if (added.length > 0) pages[page.id] = { ...page, shapeIds: [...page.shapeIds, ...added] }; - } - if (layers) { - for (const layer of Object.values(layers)) { - const added = copiedIds.filter((id) => shapes[id]?.layerId === layer.id); - if (added.length > 0) layers[layer.id] = { ...layer, shapeIds: [...layer.shapeIds, ...added] }; - } - } - const bindings = { ...state.doc.bindings }; - for (const binding of Object.values(state.doc.bindings)) { - const fromShapeId = mapping.get(binding.fromShapeId); - if (!fromShapeId) continue; - const id = createId('binding'); - const toShapeId = mapping.get(binding.toShapeId) ?? binding.toShapeId; - bindings[id] = { ...EditorBindingRecord.clone(binding), id, fromShapeId, toShapeId }; - } - for (const source of included) { - const id = mapping.get(source.id); - const copy = id ? shapes[id] : undefined; - if (!id || !copy || copy.type !== 'arrow' || source.type !== 'arrow') continue; - const copiedBindings = Object.values(bindings).filter((binding) => binding.fromShapeId === id); - const handleBinding = (handle: 'start' | 'end') => { - const originalBindingId = source.props[handle].bindingId; - const original = originalBindingId ? state.doc.bindings[originalBindingId] : undefined; - const copied = copiedBindings.find( - (binding) => - original && - binding.toShapeId === (mapping.get(original.toShapeId) ?? original.toShapeId) && - binding.handle === handle - ); - return copied ? { kind: 'bound' as const, bindingId: copied.id } : { kind: 'free' as const }; - }; - shapes[id] = { ...copy, props: { ...copy.props, start: handleBinding('start'), end: handleBinding('end') } }; - } - return { - ...state, - doc: { ...state.doc, pages, shapes, bindings, ...(layers ? { layers } : {}) }, - ui: { ...state.ui, selectionIds: rootCopies } - }; -} - -function reorderSelection(state: EditorState, direction: 'forward' | 'backward'): EditorState | null { - const next = reorderShapes(state, state.ui.selectionIds, direction); - return next === state ? null : next; -} - -function hasSelectedAncestor(state: EditorState, id: string, selected: ReadonlySet): boolean { - let parentId = state.doc.shapes[id]?.groupId; - while (parentId) { - if (selected.has(parentId)) return true; - parentId = state.doc.shapes[parentId]?.groupId; - } - return false; -} - -function hasAncestor( - shape: import('@inkfinite/core').EditorShapeRecord, - ancestorId: string, - state: EditorState -): boolean { - let parentId = shape.groupId; - while (parentId) { - if (parentId === ancestorId) return true; - parentId = state.doc.shapes[parentId]?.groupId; - } - return false; -} - function describeAction(action: Action, kind: CommandKind): string { if (action.type === 'key-down') { if (action.key.startsWith('Arrow')) return 'Nudge'; diff --git a/packages/editor/src/shortcuts.ts b/packages/editor/src/shortcuts.ts new file mode 100644 index 0000000..646afbc --- /dev/null +++ b/packages/editor/src/shortcuts.ts @@ -0,0 +1,140 @@ +import type { Action, EditorState, Modifiers } from '@inkfinite/core'; +import { + duplicateAndConnectSelection, + duplicateSelection, + getSelectionScopeShapes, + groupShapes, + reorderShapes, + reorderShapesToEdge, + setShapesLocked, + translateShapes, + ungroupShapes +} from '@inkfinite/core'; +import type { EditorHostRequest } from './host.js'; + +/** Platform used when deciding whether a primary modifier is Cmd or Ctrl. */ +export type PrimaryModifierPlatform = 'mac' | 'other'; + +/** Pure result of resolving one normalized keyboard action. */ +export type KeyboardShortcutResult = { state: EditorState | null; request?: EditorHostRequest }; + +/** Resolve editor keyboard commands without dispatching browser or host effects. */ +export function resolveKeyboardShortcut(state: EditorState, action: Action): KeyboardShortcutResult { + if (action.type !== 'key-down') return { state: null }; + const primary = action.modifiers.meta || action.modifiers.ctrl; + if (action.key === '?' || (action.key === '/' && action.modifiers.shift)) { + return { state: null, request: { type: 'shortcuts' } }; + } + if (primary && action.key.toLowerCase() === 'k') { + return { state: null, request: { type: 'command-palette' } }; + } + if (primary && action.key.toLowerCase() === 'z') { + return { state: null, request: { type: action.modifiers.shift ? 'redo' : 'undo' } }; + } + if (primary && action.key.toLowerCase() === 'y') { + return { state: null, request: { type: 'redo' } }; + } + if (primary && action.key.toLowerCase() === 'b') { + return { state: null, request: { type: 'browse' } }; + } + if (primary && action.key.toLowerCase() === 'c') { + return { state: null, request: { type: 'copy' } }; + } + if (primary && action.key.toLowerCase() === 'x') { + return { state: null, request: { type: 'cut' } }; + } + if (primary && action.key.toLowerCase() === 'v') { + return { state: null, request: { type: 'paste' } }; + } + if (primary && action.key.toLowerCase() === 'a') { + const selectionIds = getSelectionScopeShapes(state).map((shape) => shape.id); + return { + state: + selectionIds.length === 0 || selectionIds.every((id) => state.ui.selectionIds.includes(id)) + ? null + : { ...state, ui: { ...state.ui, selectionIds } } + }; + } + if (state.ui.selectionIds.length === 0) return { state: null }; + + if (action.key.startsWith('Arrow')) { + const delta = arrowDelta(action.key, action.modifiers.shift ? 10 : 1); + if (delta) return { state: nullableState(translateShapes(state, state.ui.selectionIds, delta), state) }; + } + if (primary && action.modifiers.alt && action.key.toLowerCase() === 'd') { + return { state: duplicateAndConnectSelection(state) }; + } + if (primary && action.key.toLowerCase() === 'd') { + return { state: duplicateSelection(state) }; + } + if (primary && action.key.toLowerCase() === 'g') { + return { + state: action.modifiers.shift + ? nullableState(ungroupShapes(state, state.ui.selectionIds), state) + : nullableState(groupShapes(state, state.ui.selectionIds), state) + }; + } + if (primary && action.modifiers.shift && action.key.toLowerCase() === 'l') { + const locked = state.ui.selectionIds.every((id) => state.doc.shapes[id]?.locked); + return { state: nullableState(setShapesLocked(state, state.ui.selectionIds, !locked), state) }; + } + if (primary && action.key === ']') { + return { + state: action.modifiers.shift + ? nullableState(reorderShapesToEdge(state, state.ui.selectionIds, 'front'), state) + : nullableState(reorderShapes(state, state.ui.selectionIds, 'forward'), state) + }; + } + if (primary && action.key === '[') { + return { + state: action.modifiers.shift + ? nullableState(reorderShapesToEdge(state, state.ui.selectionIds, 'back'), state) + : nullableState(reorderShapes(state, state.ui.selectionIds, 'backward'), state) + }; + } + return { state: null }; +} + +/** Decide whether the normalized key should not reach the browser. */ +export function shouldPreventKeyboardDefault( + key: string, + code: string, + modifiers: Modifiers, + platform: PrimaryModifierPlatform +): boolean { + if (key === ' ' || key.startsWith('Arrow')) return true; + if (key === 'Backspace' || key === 'Delete' || key === 'Tab') return true; + if (key === '+' || key === '=' || key === '-' || key === '_' || key === '0') return true; + if (modifiers.shift && (code === 'Digit1' || code === 'Digit2')) return true; + if (isPrimaryModifier(modifiers, platform) && ['z', 'Z', 'y', 'Y'].includes(key)) return true; + if ( + isPrimaryModifier(modifiers, platform) && + ('abgldcxvzy'.includes(key.toLowerCase()) || key === '[' || key === ']') + ) { + return true; + } + return key === '?' || (key === '/' && modifiers.shift); +} + +function isPrimaryModifier(modifiers: Modifiers, platform: PrimaryModifierPlatform): boolean { + return platform === 'mac' ? modifiers.meta : modifiers.ctrl; +} + +function arrowDelta(key: string, step: number): { x: number; y: number } | null { + switch (key) { + case 'ArrowLeft': + return { x: -step, y: 0 }; + case 'ArrowRight': + return { x: step, y: 0 }; + case 'ArrowUp': + return { x: 0, y: -step }; + case 'ArrowDown': + return { x: 0, y: step }; + default: + return null; + } +} + +function nullableState(next: EditorState, previous: EditorState): EditorState | null { + return next === previous ? null : next; +} diff --git a/packages/editor/tests/shortcuts.test.ts b/packages/editor/tests/shortcuts.test.ts new file mode 100644 index 0000000..bfb7b04 --- /dev/null +++ b/packages/editor/tests/shortcuts.test.ts @@ -0,0 +1,54 @@ +import { Action, EditorShapeRecord, EditorState } from '@inkfinite/core'; +import { describe, expect, it, vi } from 'vitest'; +import { dispatchHostRequest } from '../src/host'; +import { resolveKeyboardShortcut, shouldPreventKeyboardDefault } from '../src/shortcuts'; + +function selectedState(): EditorState { + const state = EditorState.create(); + state.doc.pages.page = { id: 'page', name: 'Page', shapeIds: ['shape'] }; + state.doc.shapes.shape = EditorShapeRecord.createRect( + 'page', + 0, + 0, + { w: 10, h: 10, fill: '#000', stroke: '#000', radius: 0 }, + 'shape' + ); + state.ui.currentPageId = 'page'; + state.ui.selectionIds = ['shape']; + return state; +} + +describe('keyboard shortcut resolution', () => { + it('resolves document edits without dispatching host effects', () => { + const result = resolveKeyboardShortcut( + selectedState(), + Action.keyDown('ArrowRight', 'ArrowRight', { ctrl: false, shift: false, alt: false, meta: true }) + ); + + expect(result.request).toBeUndefined(); + expect(result.state?.doc.shapes.shape?.x).toBe(1); + }); + + it('returns a host request for application-owned actions', () => { + const result = resolveKeyboardShortcut( + EditorState.create(), + Action.keyDown('b', 'KeyB', { ctrl: false, shift: false, alt: false, meta: true }) + ); + + expect(result).toEqual({ state: null, request: { type: 'browse' } }); + }); + + it('dispatches host requests through the browser boundary', () => { + const handlers = { onUndoRequested: vi.fn(), onPasteRequested: vi.fn() }; + dispatchHostRequest({ type: 'undo' }, handlers); + dispatchHostRequest({ type: 'paste' }, handlers); + expect(handlers.onUndoRequested).toHaveBeenCalledOnce(); + expect(handlers.onPasteRequested).toHaveBeenCalledOnce(); + }); + + it('keeps keyboard default prevention independent from DOM events', () => { + const modifiers = { ctrl: true, shift: false, alt: false, meta: false }; + expect(shouldPreventKeyboardDefault('z', 'KeyZ', modifiers, 'other')).toBe(true); + expect(shouldPreventKeyboardDefault('o', 'KeyO', modifiers, 'other')).toBe(false); + }); +});