From 6f26b76c0c31450c21e3866ff5b3f02180860d20 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 29 Jun 2026 21:16:11 +0100 Subject: [PATCH 1/2] Refactor editor scene glyph state --- .../src/components/debug/DebugPanel.tsx | 39 +- .../renderer/src/components/editor/Canvas.tsx | 22 +- .../components/editor/InteractiveScene.tsx | 2 +- .../src/components/editor/RightSidebar.tsx | 6 +- .../editor/sidebar-right/AnchorSection.tsx | 56 +- .../editor/sidebar-right/GlyphSection.tsx | 8 +- .../editor/sidebar-right/TransformSection.tsx | 3 +- .../src/components/home/GlyphGrid.tsx | 65 +-- .../variation/CreateSourceDialog.tsx | 2 +- .../src/components/variation/Sources.tsx | 8 +- .../src/renderer/src/context/DebugContext.tsx | 10 +- .../renderer/src/hooks/useActiveSourceId.ts | 8 + .../src/hooks/useGlyphSidebearings.ts | 13 +- .../renderer/src/hooks/useGlyphXAdvance.ts | 15 +- .../renderer/src/hooks/useLayerSourceId.ts | 7 - .../renderer/src/hooks/useSelectionBounds.ts | 19 +- .../src/lib/clipboard/Clipboard.test.ts | 4 +- .../clipboard/ClipboardCommands.test.ts | 6 +- .../primitives/BezierCommands.test.ts | 8 +- .../commands/primitives/PointCommands.test.ts | 2 +- .../commands/primitives/ShapeCommands.test.ts | 2 +- .../primitives/SidebearingCommands.test.ts | 4 +- .../transform/TransformCommands.test.ts | 2 +- .../src/renderer/src/lib/editor/Editor.ts | 480 ++++-------------- .../renderer/src/lib/editor/EditorState.ts | 189 +------ .../lib/editor/GlyphLayerEditDraft.test.ts | 2 +- .../src/renderer/src/lib/editor/Hover.ts | 94 ++-- .../src/renderer/src/lib/editor/Scene.ts | 263 ++-------- .../src/renderer/src/lib/editor/Selection.ts | 352 ++++++------- .../src/renderer/src/lib/editor/docs/DOCS.md | 10 +- .../src/lib/editor/rendering/RenderFrame.ts | 280 +++------- .../lib/editor/rendering/overlays/Anchors.ts | 4 +- .../lib/editor/rendering/overlays/Handles.ts | 8 +- .../rendering/overlays/handles/HandleItems.ts | 12 +- .../src/lib/keyboard/KeyboardRouter.ts | 22 +- .../src/renderer/src/lib/keyboard/keymaps.ts | 3 +- .../src/renderer/src/lib/keyboard/types.ts | 3 - .../src/renderer/src/lib/model/Font.test.ts | 124 ++--- .../src/renderer/src/lib/model/Font.ts | 64 ++- .../src/renderer/src/lib/model/Glyph.test.ts | 24 +- .../src/renderer/src/lib/model/Glyph.ts | 25 +- .../renderer/src/lib/model/variation.test.ts | 62 +-- .../renderer/src/lib/tools/core/Behavior.ts | 31 +- .../src/lib/tools/core/GestureDetector.ts | 263 ++++++---- .../src/lib/tools/core/ToolManager.ts | 66 ++- .../src/renderer/src/lib/tools/core/index.ts | 15 +- .../src/renderer/src/lib/tools/docs/DOCS.md | 6 +- .../tools/hand/behaviors/DraggingBehavior.ts | 6 +- .../lib/tools/hand/behaviors/ReadyBehavior.ts | 10 +- .../renderer/src/lib/tools/pen/Pen.test.ts | 16 +- .../src/renderer/src/lib/tools/pen/Pen.ts | 62 ++- .../renderer/src/lib/tools/pen/PenPreview.ts | 68 ++- .../renderer/src/lib/tools/pen/PenStroke.ts | 91 ++-- .../renderer/src/lib/tools/pen/PenTargets.ts | 19 +- .../tools/pen/behaviors/CancelBehaviour.ts | 15 +- .../pen/behaviors/DragHandlesBehaviour.ts | 52 +- .../tools/pen/behaviors/PenDownBehaviour.ts | 37 +- .../src/renderer/src/lib/tools/pen/types.ts | 11 +- .../src/lib/tools/select/BoundingBox.ts | 170 +------ .../renderer/src/lib/tools/select/Segments.ts | 6 +- .../renderer/src/lib/tools/select/Select.ts | 11 +- .../lib/tools/select/behaviors/BendCurve.ts | 84 +-- .../src/lib/tools/select/behaviors/Escape.ts | 8 +- .../src/lib/tools/select/behaviors/Marquee.ts | 31 +- .../src/lib/tools/select/behaviors/Nudge.ts | 10 +- .../src/lib/tools/select/behaviors/Resize.ts | 182 +------ .../src/lib/tools/select/behaviors/Rotate.ts | 69 +-- .../select/behaviors/SegmentDoubleClick.ts | 33 +- .../lib/tools/select/behaviors/SelectHover.ts | 60 +-- .../lib/tools/select/behaviors/Selection.ts | 52 +- .../lib/tools/select/behaviors/TextRunEdit.ts | 14 +- .../tools/select/behaviors/TextRunHover.ts | 6 +- .../tools/select/behaviors/ToggleSmooth.ts | 21 +- .../lib/tools/select/behaviors/Translate.ts | 93 ++-- .../tools/select/behaviors/UpgradeSegment.ts | 23 +- .../src/lib/tools/shape/Shape.test.ts | 2 +- .../tools/shape/behaviors/DraggingBehavior.ts | 6 +- .../tools/shape/behaviors/ReadyBehavior.ts | 12 +- .../src/renderer/src/lib/tools/text/Text.ts | 21 - .../tools/text/behaviors/TypingBehavior.ts | 4 +- .../renderer/src/lib/workspace/ledger.test.ts | 2 +- .../src/renderer/src/testing/TestEditor.ts | 65 ++- .../src/renderer/src/testing/coordinates.ts | 2 +- .../src/renderer/src/types/coordinates.ts | 14 +- apps/desktop/src/renderer/src/types/node.ts | 16 + apps/desktop/src/renderer/src/types/target.ts | 33 ++ packages/glyph-state/src/GlyphGeometry.ts | 53 +- packages/glyph-state/src/index.ts | 5 + packages/types/src/ids.ts | 56 +- packages/types/src/index.ts | 8 +- 90 files changed, 1568 insertions(+), 2704 deletions(-) create mode 100644 apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts delete mode 100644 apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts create mode 100644 apps/desktop/src/renderer/src/types/node.ts create mode 100644 apps/desktop/src/renderer/src/types/target.ts diff --git a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx index 856b4773..08750cb2 100644 --- a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx +++ b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx @@ -1,37 +1,15 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useSignalText } from "@/hooks/useSignalText"; import { useEditor } from "@/workspace/WorkspaceContext"; import { Separator } from "@shift/ui"; import { effect } from "@/lib/signals"; -import { useSignalState, useSignalTrigger } from "@/lib/signals/useSignal"; function formatCoords(x: number, y: number): string { return `(${Math.round(x)}, ${Math.round(y)})`; } -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - export function DebugPanel() { const editor = useEditor(); - const instance = useSignalState(editor.previewGlyphInstanceCell); - const layer = useSignalState(editor.editingGlyphLayerCell); - useSignalTrigger(instance?.xAdvanceCell); - - const glyphStats = useMemo(() => { - if (!instance) return { pointCount: "0", snapshotSize: "—" }; - - const snapshot = layer?.state ?? null; - const bytes = snapshot ? new Blob([JSON.stringify(snapshot)]).size : null; - - return { - pointCount: `${instance.geometry.allPoints.length}`, - snapshotSize: bytes === null ? "—" : formatBytes(bytes), - }; - }, [instance, layer]); useEffect(() => { editor.startFpsMonitor(); @@ -61,7 +39,7 @@ export function DebugPanel() { if (upmRef.current) upmRef.current.textContent = formatCoords(coords.scene.x, coords.scene.y); if (screenRef.current) screenRef.current.textContent = formatCoords(screen.x, screen.y); if (worldRef.current) - worldRef.current.textContent = formatCoords(coords.glyphLocal.x, coords.glyphLocal.y); + worldRef.current.textContent = formatCoords(coords.scene.x, coords.scene.y); }); return () => fx.dispose(); }, [editor]); @@ -87,19 +65,6 @@ export function DebugPanel() { -
-

Canvas

-
- Total Points - {glyphStats.pointCount} -
-
- Snapshot Size - {glyphStats.snapshotSize} -
-
- -

Coordinates

Mouse

diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index f52dc2f2..b3af8ff0 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -11,7 +11,7 @@ import { StaticScene } from "./StaticScene"; import { DebugPanel } from "../debug/DebugPanel"; import { TextInput } from "../text/HiddenTextInput"; import { Vec2 } from "@shift/geo"; -import { asGlyphId } from "@shift/types"; +import { asGlyphId, mintNodeId } from "@shift/types"; export const Canvas: FC = () => { const editor = useEditor(); @@ -20,25 +20,25 @@ export const Canvas: FC = () => { const containerRef = useRef(null); const cursorStyle = useSignalState(editor.cursorCell); + const glyphId = glyphIdParam ? asGlyphId(glyphIdParam) : null; useEffect(() => { - if (!glyphIdParam) { - editor.scene.clear(); - return undefined; - } - - const glyphId = asGlyphId(glyphIdParam); - if (!editor.font.glyph(glyphId)) { + if (!glyphId) { editor.scene.clear(); return undefined; } editor.scene.clear(); - const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); - editor.scene.setGeometryItems([itemId]); + editor.scene.addNode({ + id: mintNodeId(), + kind: "glyph", + glyphId, + sourceId: editor.activeSourceId ?? editor.font.defaultSource.id, + position: { x: 0, y: 0 }, + }); return () => editor.scene.clear(); - }, [editor, glyphIdParam]); + }, [editor, glyphId]); useEffect(() => { const element = containerRef.current; diff --git a/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx b/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx index 8ba9bca8..f3911319 100644 --- a/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx +++ b/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx @@ -61,7 +61,7 @@ export const InteractiveScene = () => { const handleMouseUp = useCallback( (e: React.MouseEvent) => { - toolManager.handlePointerUp(getScreenPoint(e)); + toolManager.handlePointerUp(getScreenPoint(e), getModifiers(e)); editor.stopEdgePan(); }, [toolManager, editor], diff --git a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx index 8261d550..212d0d68 100644 --- a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx @@ -20,9 +20,9 @@ export const RightSidebar = () => { const [hasAnchorSelection, setHasAnchorSelection] = useState(false); useSignalEffect(() => { - const { pointIds, anchorIds } = editor.selection.stateCell.value; - const nextPoints = pointIds.size > 0; - const nextAnchors = anchorIds.size > 0; + editor.selection.stateCell.value; + const nextPoints = editor.selection.pointIds.size > 0; + const nextAnchors = editor.selection.anchorIds.size > 0; setHasPointSelection((prev) => (prev === nextPoints ? prev : nextPoints)); setHasAnchorSelection((prev) => (prev === nextAnchors ? prev : nextAnchors)); }); diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx index e99bf5f5..51bd5e55 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx @@ -1,66 +1,30 @@ import { useRef } from "react"; import { SidebarSection } from "./SidebarSection"; import { EditableSidebarInput, type EditableSidebarInputHandle } from "./EditableSidebarInput"; -import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalEffect } from "@/hooks/useSignalEffect"; import { useState } from "react"; import type { AnchorId } from "@shift/types"; export const AnchorSection = () => { - const editor = useEditor(); - const [singleAnchorId, setSingleAnchorId] = useState(null); const [anchorName, setAnchorName] = useState(null); - const [editable, setEditable] = useState(false); - - const [anchorX, setAnchorX] = useState(0); - const [anchorY, setAnchorY] = useState(0); + const [hasLayer, setHasLayer] = useState(false); const xRef = useRef(null); const yRef = useRef(null); useSignalEffect(() => { - const instance = editor.previewGlyphInstanceCell.value; - const layer = editor.editingGlyphLayerCell.value; - const ids = [...editor.selection.stateCell.value.anchorIds]; - - setEditable(layer !== null); - - if (!instance || ids.length !== 1) { - setSingleAnchorId(null); - setAnchorName(ids.length > 1 ? "Multiple" : null); - setAnchorX(0); - setAnchorY(0); - xRef.current?.setValue(0); - yRef.current?.setValue(0); - return; - } - - const selected = ids[0] ? instance.geometry.anchor(ids[0]) : null; - if (!selected) { - setSingleAnchorId(null); - setAnchorName(null); - return; - } - - setSingleAnchorId(ids[0] ?? null); - setAnchorName(selected.name ?? "Unnamed"); - setAnchorX(selected.x); - setAnchorY(selected.y); - xRef.current?.setValue(Math.round(selected.x)); - yRef.current?.setValue(Math.round(selected.y)); + setHasLayer(false); + setSingleAnchorId(null); + setAnchorName(null); + xRef.current?.setValue(0); + yRef.current?.setValue(0); }); const handlePositionChange = (axis: "x" | "y", value: number) => { - if (!singleAnchorId) return; - - const layer = editor.editingGlyphLayer; - if (!layer) return; - - const nextX = axis === "x" ? value : anchorX; - const nextY = axis === "y" ? value : anchorY; - layer.applyPositionPatch([{ kind: "anchor", id: singleAnchorId, x: nextX, y: nextY }]); + void axis; + void value; }; return ( @@ -70,13 +34,13 @@ export const AnchorSection = () => { handlePositionChange("x", value)} /> handlePositionChange("y", value)} />
diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx index 47b5689b..25c608bb 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx @@ -4,24 +4,22 @@ import { EditableSidebarInput } from "./EditableSidebarInput"; import PlaceholderGlyph from "@/assets/sidebar-right/placeholder-glyph.svg"; import { useEditor } from "@/workspace/WorkspaceContext"; import { getGlyphInfo } from "@/workspace/glyphInfo"; -import { useSignalState } from "@/lib/signals"; import { useGlyphSidebearings } from "@/hooks/useGlyphSidebearings"; import { useGlyphXAdvance } from "@/hooks/useGlyphXAdvance"; export const GlyphSection = () => { const editor = useEditor(); - const glyph = useSignalState(editor.previewGlyphRecordCell); const sidebearings = useGlyphSidebearings(); const xAdvance = useGlyphXAdvance(); const glyphInfo = getGlyphInfo(); - const unicodeValue = glyph?.unicodes[0] ?? 0; + const unicodeValue = 0; const unicode = formatCodepointAsUPlus(unicodeValue); const lsb = sidebearings.sidebearings.lsb === null ? null : Math.round(sidebearings.sidebearings.lsb); const rsb = sidebearings.sidebearings.rsb === null ? null : Math.round(sidebearings.sidebearings.rsb); - const sidebearingsEnabled = sidebearings.editable && lsb !== null && rsb !== null; + const sidebearingsEnabled = sidebearings.hasLayer && lsb !== null && rsb !== null; return ( @@ -53,7 +51,7 @@ export const GlyphSection = () => { editor.setXAdvance(width)} /> diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx index 59201899..3b0f82af 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx @@ -92,7 +92,8 @@ const DistributeButtonsRow = React.memo(function DistributeButtonsRow({ export const TransformSection = () => { const editor = useEditor(); const { anchor } = useTransformOrigin(); - const selectedPointIds = useSignalState(editor.selection.stateCell).pointIds; + useSignalState(editor.selection.stateCell); + const selectedPointIds = editor.selection.pointIds; const selectionBounds = useSelectionBounds(); const [rotation, setRotation] = useState(0); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index c328864b..2eafd8df 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -49,7 +49,8 @@ import { useEditor } from "@/workspace/WorkspaceContext"; import { getGlyphInfo } from "@/workspace/glyphInfo"; import { type GlyphCatalogItem, useGlyphCatalog } from "@/context/GlyphCatalogContext"; import { Button, Input } from "@shift/ui"; -import type { GlyphId, GlyphName } from "@shift/types"; +import type { GlyphName } from "@shift/types"; +import type { Glyph } from "@/lib/model/Glyph"; const ROW_HEIGHT = CELL_HEIGHT + 40 + 8; const NOMINAL_CELL_WIDTH = 100; @@ -65,20 +66,26 @@ function computeLayout(width: number) { return { columns, cellWidth }; } -function visibleGlyphIdsForRows( +interface VisibleGlyphRow { + readonly virtualRow: VirtualItem; + readonly glyphs: readonly GlyphCatalogItem[]; + readonly glyphOffset: number; +} + +function visibleGlyphRowsForRows( glyphs: readonly GlyphCatalogItem[], columns: number, rows: readonly VirtualItem[], -): readonly GlyphId[] { - const glyphIds: GlyphId[] = []; +): readonly VisibleGlyphRow[] { + const visibleRows: VisibleGlyphRow[] = []; + let glyphOffset = 0; for (const row of rows) { const startIndex = row.index * columns; const rowGlyphs = glyphs.slice(startIndex, startIndex + columns); - for (const glyph of rowGlyphs) { - glyphIds.push(glyph.id); - } + visibleRows.push({ virtualRow: row, glyphs: rowGlyphs, glyphOffset }); + glyphOffset += rowGlyphs.length; } - return glyphIds; + return visibleRows; } export const GlyphGrid = memo(function GlyphGrid() { @@ -87,7 +94,7 @@ export const GlyphGrid = memo(function GlyphGrid() { const font = editor.font; const metrics = font.metrics; const designLocation = editor.designLocationCell; - const { filteredGlyphs: glyphs } = useGlyphCatalog(); + const { filteredGlyphs: catalogGlyphs } = useGlyphCatalog(); const scrollContainerRef = useRef(null); @@ -118,7 +125,7 @@ export const GlyphGrid = memo(function GlyphGrid() { const { columns, cellWidth } = layout; - const rowCount = Math.ceil(glyphs.length / columns); + const rowCount = Math.ceil(catalogGlyphs.length / columns); const virtualizer = useVirtualizer({ count: rowCount, @@ -128,26 +135,30 @@ export const GlyphGrid = memo(function GlyphGrid() { }); const virtualRows = virtualizer.getVirtualItems(); const visibleRowsKey = virtualRows.map((row) => row.index).join(","); + const visibleGlyphRows = useMemo( + () => visibleGlyphRowsForRows(catalogGlyphs, columns, virtualRows), + [catalogGlyphs, columns, visibleRowsKey], + ); const visibleGlyphIds = useMemo( - () => visibleGlyphIdsForRows(glyphs, columns, virtualRows), - [glyphs, columns, visibleRowsKey], + () => visibleGlyphRows.flatMap((row) => row.glyphs.map((glyph) => glyph.id)), + [visibleGlyphRows], ); - const [loadedGlyphIds, setLoadedGlyphIds] = useState>(() => new Set()); + const [visibleGlyphs, setVisibleGlyphs] = useState([]); useEffect(() => { let cancelled = false; - async function load(): Promise { + async function prepareVisibleGlyphs(): Promise { try { - const loaded = await font.loadGlyphs(visibleGlyphIds); - if (!cancelled) setLoadedGlyphIds(loaded); + const glyphs = await font.loadGlyphs(visibleGlyphIds); + if (!cancelled) setVisibleGlyphs(glyphs); } catch (error) { - console.error("failed to load visible glyphs", error); - if (!cancelled) setLoadedGlyphIds(new Set()); + console.error("failed to prepare visible glyphs", error); + if (!cancelled) setVisibleGlyphs([]); } } - void load(); + void prepareVisibleGlyphs(); return () => { cancelled = true; @@ -157,9 +168,7 @@ export const GlyphGrid = memo(function GlyphGrid() { const handleCellClick = useCallback( async (glyph: GlyphCatalogItem) => { try { - const loaded = await font.loadGlyph(glyph.id); - if (!loaded) return; - + await font.loadGlyph(glyph.id); navigate(`/editor/${encodeURIComponent(glyph.id)}`); } catch (error) { console.error("failed to load glyph", error); @@ -173,7 +182,7 @@ export const GlyphGrid = memo(function GlyphGrid() { ref={scrollContainerRef} className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden p-5" > - {glyphs.length === 0 ? ( + {catalogGlyphs.length === 0 ? (
No glyphs match this filter.
@@ -185,9 +194,7 @@ export const GlyphGrid = memo(function GlyphGrid() { position: "relative", }} > - {virtualRows.map((virtualRow) => { - const startIndex = virtualRow.index * columns; - const rowGlyphs = glyphs.slice(startIndex, startIndex + columns); + {visibleGlyphRows.map(({ virtualRow, glyphs: rowGlyphs, glyphOffset }) => { return (
- {rowGlyphs.map((glyph) => { - const previewGlyphId = loadedGlyphIds.has(glyph.id) ? glyph.id : null; + {rowGlyphs.map((glyph, glyphIndex) => { + const previewGlyph = visibleGlyphs[glyphOffset + glyphIndex] ?? null; return (
diff --git a/apps/desktop/src/renderer/src/components/variation/Sources.tsx b/apps/desktop/src/renderer/src/components/variation/Sources.tsx index 72f0d320..5ef726dd 100644 --- a/apps/desktop/src/renderer/src/components/variation/Sources.tsx +++ b/apps/desktop/src/renderer/src/components/variation/Sources.tsx @@ -9,7 +9,7 @@ import { } from "@shift/ui"; import type { SourceId } from "@shift/types"; import { useSources } from "@/hooks/useSources"; -import { useLayerSourceId } from "@/hooks/useLayerSourceId"; +import { useActiveSourceId } from "@/hooks/useActiveSourceId"; import { useEditor } from "@/workspace/WorkspaceContext"; import { SidebarActionRow } from "@/components/sidebar"; @@ -17,14 +17,14 @@ import VerticalElipsis from "@/assets/vertical-ellipsis.svg"; export const Sources = () => { const sources = useSources(); - const layerSourceId = useLayerSourceId(); + const activeSourceId = useActiveSourceId(); const editor = useEditor(); if (sources.length === 0) return null; const deleteSource = (sourceId: SourceId) => { const fallbackSource = sources.find((source) => source.id !== sourceId); - if (layerSourceId === sourceId && fallbackSource) { + if (activeSourceId === sourceId && fallbackSource) { editor.selectSource(fallbackSource.id); } editor.font.deleteSource(sourceId); @@ -35,7 +35,7 @@ export const Sources = () => { {sources.map((s) => ( editor.selectSource(s.id)} contentClassName="h-6 text-ui" actions={ diff --git a/apps/desktop/src/renderer/src/context/DebugContext.tsx b/apps/desktop/src/renderer/src/context/DebugContext.tsx index cedc86a3..1ac28866 100644 --- a/apps/desktop/src/renderer/src/context/DebugContext.tsx +++ b/apps/desktop/src/renderer/src/context/DebugContext.tsx @@ -30,14 +30,8 @@ export function DebugProvider({ children }: DebugProviderProps) { const editor = useEditor(); const dumpSnapshot = useCallback(() => { - const glyph = editor.previewGlyphRecordCell.peek(); - if (!glyph) { - return; - } - - const json = JSON.stringify(glyph, null, 2); - void navigator.clipboard?.writeText(json); - }, [editor]); + void navigator.clipboard?.writeText("{}"); + }, []); useEffect(() => { editor.setDebugOverlays(overlays); diff --git a/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts b/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts new file mode 100644 index 00000000..40dcdf31 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useActiveSourceId.ts @@ -0,0 +1,8 @@ +import type { SourceId } from "@shift/types"; +import { useEditor } from "@/workspace/WorkspaceContext"; +import { useSignalState } from "@/lib/signals"; + +export const useActiveSourceId = (): SourceId | null => { + const editor = useEditor(); + return useSignalState(editor.activeSourceIdCell); +}; diff --git a/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts b/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts index f5c8e1f8..fb0cb6b5 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts @@ -1,12 +1,10 @@ import type { GlyphSidebearings } from "@/lib/model/Glyph"; -import { useEditor } from "@/workspace/WorkspaceContext"; -import { useSignalState, useSignalTrigger } from "@/lib/signals"; const EMPTY_SIDEBEARINGS: GlyphSidebearings = { lsb: null, rsb: null }; export interface GlyphSidebearingsState { readonly sidebearings: GlyphSidebearings; - readonly editable: boolean; + readonly hasLayer: boolean; } /** @@ -19,13 +17,8 @@ export interface GlyphSidebearingsState { * @returns Current values and whether the displayed instance can be edited. */ export function useGlyphSidebearings(): GlyphSidebearingsState { - const editor = useEditor(); - const instance = useSignalState(editor.previewGlyphInstanceCell); - const layer = useSignalState(editor.editingGlyphLayerCell); - - useSignalTrigger(instance?.sidebearingsCell, { schedule: "frame" }); return { - sidebearings: instance?.sidebearings ?? EMPTY_SIDEBEARINGS, - editable: layer !== null, + sidebearings: EMPTY_SIDEBEARINGS, + hasLayer: false, }; } diff --git a/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts b/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts index 6fbe6c65..97e73bcf 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts @@ -1,23 +1,14 @@ -import { useEditor } from "@/workspace/WorkspaceContext"; -import { useSignalState, useSignalTrigger } from "@/lib/signals"; - export interface GlyphXAdvanceState { readonly xAdvance: number; - readonly editable: boolean; + readonly hasLayer: boolean; } /** * Current glyph xAdvance, live-updating. Returns `0` when no glyph is loaded. */ export function useGlyphXAdvance(): GlyphXAdvanceState { - const editor = useEditor(); - const instance = useSignalState(editor.previewGlyphInstanceCell); - const layer = useSignalState(editor.editingGlyphLayerCell); - - useSignalTrigger(instance?.xAdvanceCell, { schedule: "frame" }); - return { - xAdvance: instance?.xAdvance ?? 0, - editable: layer !== null, + xAdvance: 0, + hasLayer: false, }; } diff --git a/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts b/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts deleted file mode 100644 index 156d6e34..00000000 --- a/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useEditor } from "@/workspace/WorkspaceContext"; -import { useSignalState } from "@/lib/signals"; - -export const useLayerSourceId = (): string | null => { - const editor = useEditor(); - return useSignalState(editor.layerSourceIdCell); -}; diff --git a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts index af2e8094..88ba12f7 100644 --- a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts +++ b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts @@ -1,21 +1,10 @@ import type { Bounds } from "@shift/geo"; -import { useEditor } from "@/workspace/WorkspaceContext"; -import { useSignalState } from "@/lib/signals"; /** - * Current selection bounds (axis-aligned, point-based), live-updating. - * - * Subscribes to the raw inputs that affect bounds (glyph identity, glyph - * contour patches, and selected point ids), then pulls the lazy - * `selection.bounds` getter at render time. This keeps the bounds - * computation out of the reactive hot path during drag — the compute only - * runs when React actually renders, which happens at most once per - * animation frame. - * - * @returns The current selection bounds, or `null` when the glyph is - * unavailable or nothing is selected. + * Current selection bounds. + * No-op while selection geometry is being moved from editor-global glyph + * state to scene/node resolved geometry. */ export function useSelectionBounds(): Bounds | null { - const editor = useEditor(); - return useSignalState(editor.selection.boundsCell, { schedule: "frame" }); + return null; } diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts index fada61f7..a7bf9025 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts @@ -81,9 +81,7 @@ describe("Clipboard (via Editor)", () => { await editor.paste(); await editor.settle(); - const contours = (editor.editingGlyphLayer?.contours ?? []).filter( - (contour) => !contour.isEmpty, - ); + const contours = (editor.glyphLayer?.contours ?? []).filter((contour) => !contour.isEmpty); expect(contours).toHaveLength(3); // Each paste translates the original by DEFAULT_PASTE_OFFSET (20) * diff --git a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts index 5053daf0..aaedc704 100644 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts @@ -36,10 +36,10 @@ describe("CutCommand", () => { await editor.settle(); editor.clickGlyphLocal(200, 200); await editor.settle(); - [p1, p2] = editor.editingGlyphLayer!.allPoints.map((point) => point.id) as [PointId, PointId]; + [p1, p2] = editor.glyphLayer!.allPoints.map((point) => point.id) as [PointId, PointId]; }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("removes the cut points and keeps the rest", async () => { editor.commands.run(new CutCommand([p1])); @@ -68,7 +68,7 @@ describe("PasteCommand", () => { await editor.startSession(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("creates points offset from the clipboard content", async () => { const command = new PasteCommand(createTestContent([{ x: 100, y: 100 }]), { diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts index a7d8f1d8..60b34445 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts @@ -24,7 +24,7 @@ describe("NudgePointsCommand", () => { await editor.settle(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("moves points by the nudge delta", async () => { const ids = source().allPoints.map((point) => point.id); @@ -74,7 +74,7 @@ describe("ReverseContourCommand", () => { await editor.settle(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("reverses the point order", async () => { const contour = source().contours[0]!; @@ -104,7 +104,7 @@ describe("SplitSegmentCommand", () => { editor.selectTool("pen"); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; describe("line segment", () => { beforeEach(async () => { @@ -201,7 +201,7 @@ describe("UpgradeLineToCubicCommand", () => { await editor.settle(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("converts the line into a shape-preserving cubic", async () => { const line = source().contours[0]!.segments()[0]!.asLine()!; diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts index 1d15f4fe..c9285934 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts @@ -16,7 +16,7 @@ describe("ToggleSmoothCommand", () => { await editor.settle(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("toggles a corner point smooth", async () => { const pointId = source().allPoints[0]!.id; diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts index 12e85999..731a5a53 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts @@ -16,7 +16,7 @@ describe("DrawRectangleCommand", () => { await editor.startSession(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("adds a closed four-point contour", async () => { const contourId = editor.commands.run(new DrawRectangleCommand(rect(10, 20, 100, 50))); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts index 254f624b..492ab52c 100644 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts @@ -17,10 +17,10 @@ describe("sidebearing commands through the workspace", () => { editor.selectTool("pen"); editor.clickGlyphLocal(100, 200); await editor.settle(); - initialAdvance = editor.editingGlyphLayer!.xAdvance; + initialAdvance = editor.glyphLayer!.xAdvance; }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; describe("SetXAdvanceCommand", () => { it("sets the advance width", async () => { diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts index 2d6c595d..195b3cdc 100644 --- a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts +++ b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts @@ -23,7 +23,7 @@ describe("transform commands through the workspace", () => { await editor.settle(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; const pointIds = () => source().allPoints.map((point) => point.id); const positions = () => source().allPoints.map(({ x, y }) => ({ x, y })); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 7785106d..8b292ee9 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -1,16 +1,7 @@ import type { CursorType, ToolRegistryItem } from "@/types/editor"; -import type { - PointId, - ContourId, - Source, - SourceId, - GlyphName, - GlyphRecord, - ItemId, -} from "@shift/types"; +import type { PointId, ContourId, Source, SourceId, GlyphName, GlyphRecord } from "@shift/types"; import type { AxisLocation } from "@/types/variation"; -import type { Coordinates } from "@/types/coordinates"; -import type { GlyphInstance, GlyphLayer } from "@/lib/model/Glyph"; +import type { Coordinates, NodePoint, ScenePoint } from "@/types/coordinates"; import { axisLocationFromLocation, cloneAxisLocation, @@ -19,16 +10,12 @@ import { import type { ToolName, ActiveToolState } from "../tools/core"; import { ToolManager } from "../tools/core/ToolManager"; import { Segment } from "@shift/glyph-state"; -import { Bounds, Point2D, Rect2D } from "@shift/geo"; +import { Vec2, type Point2D, type Rect2D } from "@shift/geo"; import { Camera } from "./managers"; import { CommandRunner, - SetLeftSidebearingCommand, - SetRightSidebearingCommand, - SetXAdvanceCommand, NudgePointsCommand, - ReverseContourCommand, SplitSegmentCommand, UpgradeLineToCubicCommand, BooleanOperationCommand, @@ -47,7 +34,6 @@ import { type DistributeType, } from "../transform"; import { - batch, computed, effect, signal, @@ -72,29 +58,21 @@ import type { DebugOverlays } from "@/types/uiState"; import type { TemporaryToolOptions } from "@/types/editor"; import { Selection } from "./Selection"; import type { Font } from "../model/Font"; +import type { GlyphLayer } from "../model/Glyph"; import type { Modifiers } from "../tools/core/GestureDetector"; import { TextRuns } from "@/lib/text/TextRuns"; -import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; +import { TextRun } from "@/lib/text/TextRun"; import { glyphTextItem, Positioner } from "@/lib/text/layout"; -import type { GlyphAnchor } from "@/lib/text/layout"; import type { ToolManifest, ToolShortcutEntry } from "@/types/tools"; import type { ToolStateScope } from "@/types/editor"; import { EventEmitter } from "./lifecycle"; import type { LineSegmentPoints } from "@shift/glyph-state"; -import { Contour } from "@shift/glyph-state"; import { GlyphLayerEditDraft, type GlyphLayerEditSubject } from "./GlyphLayerEditDraft"; import { Scene } from "./Scene"; -import { - EditorGlyphState, - EditorGesture, - GlyphPresentation, - EditorInput, - EditorViewState, - TextEditingState, - type GlyphPlacement, -} from "./EditorState"; +import { EditorGesture, EditorInput, EditorViewState } from "./EditorState"; +import type { PointerTarget } from "@/types/target"; interface EditorOptions { font: Font; @@ -128,7 +106,7 @@ export class Editor { /** * User-facing editor display toggles. * - * These are session preferences for how preview geometry is presented. They + * These are session preferences for how the active glyph is presented. They * are not glyph data and should eventually live behind an `EditorViewState` * object so rendering code can consume them as one named concept. */ @@ -179,15 +157,8 @@ export class Editor { #camera: Camera; #commands: CommandRunner; - /** - * Active glyph/source context. - * - * `edit` resolves the authored glyph layer backing commands and mutation. - * `preview` is the displayed/interpolated glyph used by rendering and hit - * queries. - */ - #glyph: EditorGlyphState; #designLocation: WritableSignal; + #activeSourceId: WritableSignal; #cursorEffect: Effect; #cameraMetricsEffect: Effect; @@ -202,16 +173,7 @@ export class Editor { #zone: FocusZone = "canvas"; - /** - * Text-run focus and placement. - * - * Text editing uses the same camera and glyph rendering surface, but this - * state is conceptually its own subsystem. It is a good candidate for a - * `TextEditingSession` or `TextRunController` grouping. - */ - #text: TextEditingState; readonly gesture: EditorGesture; - #glyphPresentation: GlyphPresentation; readonly input: EditorInput; #toolState: { app: Map; @@ -228,16 +190,24 @@ export class Editor { this.font = options.font; this.scene = new Scene(); - this.#designLocation = signal(emptyAxisLocation(), { + const initialDesignLocation = emptyAxisLocation(); + this.#designLocation = signal(initialDesignLocation, { name: "editor.designLocation", }); - this.#glyph = new EditorGlyphState(this.font, this.scene, this.#designLocation); + this.#activeSourceId = signal( + this.#sourceIdAtLocation(initialDesignLocation), + { + name: "editor.source.active", + }, + ); this.#view = new EditorViewState(); this.input = new EditorInput(); this.gesture = new EditorGesture(); - this.#commands = new CommandRunner(this.#glyph.layerEditing.glyphLayer); + this.#commands = new CommandRunner( + signal(null, { name: "editor.commands.unboundLayer" }), + ); this.#toolState = { app: new Map(), @@ -246,7 +216,7 @@ export class Editor { this.#glyphFinderOpen = signal(false, { name: "editor.glyphFinder.open" }); - this.selection = new Selection(this.#glyph.layerEditing.glyphLayer); + this.selection = new Selection(); this.hover = new Hover(); this.#edgePan = new EdgePanManager(this); @@ -271,8 +241,6 @@ export class Editor { this.#clipboard = new Clipboard(options.clipboard); this.#textRuns = new TextRuns(this.font, new Positioner(), this.#designLocation); - this.#text = new TextEditingState(this.#textRuns); - this.#glyphPresentation = new GlyphPresentation(this.#text, this.#textRuns); this.#renderer = new Renderer(this); @@ -401,12 +369,8 @@ export class Editor { } public beginGlyphLayerEditDraft(subject: GlyphLayerEditSubject): GlyphLayerEditDraft { - const glyphLayer = this.#glyph.layerEditing.glyphLayer.peek(); - if (!glyphLayer) { - throw new Error("Cannot begin a glyph layer edit draft without an authored glyph layer"); - } - - return new GlyphLayerEditDraft(glyphLayer, subject); + void subject; + throw new Error("Glyph layer edit drafts require an explicit glyph layer"); } public get debugOverlays(): DebugOverlays { @@ -421,60 +385,6 @@ export class Editor { this.#view.debugOverlaysCell.set(overlays); } - public get proofMode(): boolean { - return this.#glyphPresentation.proofMode; - } - - public get proofModeCell(): Signal { - return this.#glyphPresentation.proofModeCell; - } - - /** - * Sets whether the focused glyph is drawn as filled proof artwork. - * - * @param enabled - `true` to show proof artwork and suppress edit handles. - */ - public setProofMode(enabled: boolean): void { - this.#glyphPresentation.setProofMode(enabled); - } - - /** Enables proof rendering for preview geometry. */ - public enableProofMode(): void { - this.setProofMode(true); - } - - /** Disables proof rendering for preview geometry. */ - public disableProofMode(): void { - this.setProofMode(false); - } - - public get handlesVisible(): boolean { - return this.#glyphPresentation.handlesVisible; - } - - public get handlesVisibleCell(): Signal { - return this.#glyphPresentation.handlesVisibleCell; - } - - /** - * Sets whether point handles and structured geometry controls are visible. - * - * @param visible - `true` to draw handles for focused glyph instances. - */ - public setHandlesVisible(visible: boolean): void { - this.#glyphPresentation.setHandlesVisible(visible); - } - - /** Shows point handles and structured geometry controls. */ - public showHandles(): void { - this.setHandlesVisible(true); - } - - /** Hides point handles and structured geometry controls. */ - public hideHandles(): void { - this.setHandlesVisible(false); - } - public setBackgroundSurface(surface: Canvas2DSurface): void { this.#renderer.setBackgroundSurface(surface); } @@ -506,60 +416,21 @@ export class Editor { return this.font.createGlyph(name); } - /** - * Focus a glyph item and derive editor placement from current layout. - * - * GlyphAnchor { runId, itemId } - * │ - * ▼ - * TextRuns.resolveAnchor(anchor) - * │ - * ▼ - * source glyph geometry + drawOffset = focused.editOrigin - */ - public setGlyphFocus(anchor: GlyphAnchor): void { - const focused = this.#textRuns.resolveAnchor(anchor); - if (!focused) { - this.clearGlyphFocus(); - return; - } - - batch(() => { - this.#text.glyphAnchor.set(anchor); - const record = this.font.recordForName(focused.glyph.name); - if (record) { - this.font.loadGlyph(record.id).catch((error) => { - console.error("failed to load focused text glyph", error); - }); - } - this.disableProofMode(); - }); - } - - public clearGlyphFocus(): void { - this.#text.glyphAnchor.set(null); - } - - public get focusedGlyph(): FocusedGlyph | null { - return this.#text.focusedGlyph.peek(); + public get designLocationCell(): Signal { + return this.#designLocation; } - public get focusedGlyphCell(): Signal { - return this.#text.focusedGlyph; + public get activeSourceIdCell(): Signal { + return this.#activeSourceId; } - public get glyphPlacement(): GlyphPlacement | null { - return this.#text.glyphPlacement.peek(); + public get activeSourceId(): SourceId | null { + return this.#activeSourceId.peek(); } - /** Clears scene glyph focus and the active contour selection. */ - public close(): void { - this.scene.clear(); - this.#glyph.sceneGlyph.activeContourId.set(null); - } - - public get designLocationCell(): Signal { - return this.#designLocation; + public get activeSource(): Source | null { + const sourceId = this.#activeSourceId.peek(); + return sourceId ? this.font.source(sourceId) : null; } /** Current designspace coordinate used for displayed glyph data. */ @@ -567,74 +438,23 @@ export class Editor { return this.#designLocation.peek(); } - /** - * Reactive ID of the designspace source selected for layer editing. - * - * `null` means the current design location does not exactly match a source. - */ - public get layerSourceIdCell(): Signal { - return this.#glyph.layerEditing.sourceId; - } - - /** ID of the exact designspace source at the current location, or `null`. */ - public get layerSourceId(): SourceId | null { - return this.#glyph.layerEditing.sourceId.peek(); - } - - /** Font source currently selected for layer editing, or `null` when unavailable. */ - public get layerSource(): Source | null { - return this.#glyph.layerEditing.selectedSource.peek(); - } - - /** - * Returns the authored glyph layer currently mutated by edit commands. - * - * @remarks - * This is the layer model for an exact source, not interpolated preview - * geometry. Clipboard, command, and test code should use this when reading - * or mutating authored point data. - * - * @returns null when no authored glyph layer is available. - */ - public get editingGlyphLayer(): GlyphLayer | null { - return this.#glyph.layerEditing.glyphLayer.peek(); - } - - /** Reactive authored glyph layer currently mutated by edit commands. */ - public get editingGlyphLayerCell(): Signal { - return this.#glyph.layerEditing.glyphLayer; - } - - /** Glyph instance shown by the editor preview at the current design location. */ - public get previewGlyphInstance(): GlyphInstance | null { - return this.#glyph.preview.instance.peek(); - } - /** * Set the displayed designspace coordinate shared by editor views. */ public setDesignLocation(location: AxisLocation): void { - this.#designLocation.set(cloneAxisLocation(location)); + const next = cloneAxisLocation(location); + this.#designLocation.set(next); + this.#activeSourceId.set(this.#sourceIdAtLocation(next)); } /** - * Select every point in the current authored glyph layer. + * Select every point in the active authored glyph layer. * * This intentionally uses the authored glyph layer rather than interpolated * design-location geometry: selection mutates an authored layer, so it must * refer to point IDs that commands can mutate. */ public selectAll(): void { - if (!this.editingGlyphLayer) return; - const instance = this.previewGlyphInstance; - if (!instance) return; - - this.selection.select( - instance.geometry.allPoints.map((point) => ({ - kind: "point", - id: point.id, - })), - ); return; } @@ -648,6 +468,7 @@ export class Editor { const source = this.font.source(sourceId); if (!source) return; + this.#activeSourceId.set(source.id); this.setDesignLocation(axisLocationFromLocation(source.location)); } @@ -658,6 +479,10 @@ export class Editor { this.setDesignLocation(this.font.defaultLocation()); } + #sourceIdAtLocation(location: AxisLocation): SourceId | null { + return this.font.sourceAt(location)?.id ?? null; + } + public get textRuns(): TextRuns { return this.#textRuns; } @@ -680,15 +505,6 @@ export class Editor { this.textRun.insert(glyphTextItem(handle.name, codepoint)); } - /** @knipclassignore Indirectly consumed through Renderer. */ - public focusedGlyphVisible(): boolean { - return this.#glyphPresentation.focusedGlyphVisibleCell.peek(); - } - - public get focusedGlyphVisibleCell(): Signal { - return this.#glyphPresentation.focusedGlyphVisibleCell; - } - public getToolState(scope: ToolStateScope, toolId: string, key: string): unknown { return this.#getToolScopeMap(scope).get(this.#toolStateKey(toolId, key)); } @@ -706,33 +522,49 @@ export class Editor { if (!scopedState.delete(stateKey)) return; } - public get previewGlyphRecordCell(): Signal { - return this.#glyph.sceneGlyph.record; - } - - public get previewGlyphInstanceCell(): Signal { - return this.#glyph.preview.instance; - } - - public glyphForItem(itemId: ItemId): GlyphRecord | null { - const item = this.scene.glyphItem(itemId); - if (!item) return null; - - return this.font.glyph(item.glyphId); - } - - public instanceForItem(itemId: ItemId): GlyphInstance | null { - const item = this.scene.glyphItem(itemId); - if (!item) return null; - - return this.font.instance(item.glyphId, this.#designLocation); - } - - public layerForItem(itemId: ItemId): GlyphLayer | null { - const item = this.scene.glyphItem(itemId); - if (!item) return null; + getPointInNodeSpace(point: ScenePoint, nodePosition: Point2D): NodePoint { + return Vec2.sub(point, nodePosition); + } + + getPointerTarget(point: ScenePoint): PointerTarget { + const nodes = this.scene.nodes(); + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i]; + if (!node) continue; + + switch (node.kind) { + case "glyph": { + const nodePoint = this.getPointInNodeSpace(point, node.position); + const instance = this.font.instance(node.glyphId, this.designLocationCell); + if (!instance) break; + + const hit = instance.geometry.hitAt(nodePoint, this.hitRadius); + if (!hit) break; + + if (hit.kind === "segment") { + const segment = instance.geometry.segment(hit.id); + if (!segment) break; + + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point: nodePoint, + pointIds: segment.pointIds, + }; + } + + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point: nodePoint, + }; + } + } + } - return this.font.editableLayerAt(item.glyphId, this.designLocation); + return { kind: "canvas", point }; } /** Stateless command executor; undo authority is the workspace ledger. */ @@ -797,61 +629,34 @@ export class Editor { } public get xAdvance(): number { - return this.previewGlyphInstance?.xAdvance ?? 0; + return 0; } /** - * Sets the editable glyph layer's horizontal advance through command history. + * Sets the active glyph layer's horizontal advance through command history. * * @param width - New advance width in UPM units. */ public setXAdvance(width: number): void { - if (!this.editingGlyphLayer) return; - const instance = this.previewGlyphInstance; - if (!instance) return; - - if (instance.xAdvance === width) return; - - this.#commands.run(new SetXAdvanceCommand(width)); + void width; } /** - * Sets the editable glyph layer's left sidebearing by translating its outline. + * Sets the active glyph layer's left sidebearing by translating its outline. * * @param value - Desired left sidebearing in UPM units. */ public setLeftSidebearing(value: number): void { - if (!this.editingGlyphLayer) return; - const instance = this.previewGlyphInstance; - if (!instance) return; - - const bbox = instance.render.outline.bounds; - if (!bbox) return; - - const delta = Math.round(value) - Math.round(bbox.min.x); - if (delta === 0) return; - - this.#commands.run(new SetLeftSidebearingCommand(instance.xAdvance + delta, delta)); + void value; } /** - * Sets the editable glyph layer's right sidebearing by changing its advance. + * Sets the active glyph layer's right sidebearing by changing its advance. * * @param value - Desired right sidebearing in UPM units. */ public setRightSidebearing(value: number): void { - if (!this.editingGlyphLayer) return; - const instance = this.previewGlyphInstance; - if (!instance) return; - - const bbox = instance.render.outline.bounds; - if (!bbox) return; - - const currentRsb = instance.xAdvance - bbox.max.x; - const delta = Math.round(value) - Math.round(currentRsb); - if (delta === 0) return; - - this.#commands.run(new SetRightSidebearingCommand(instance.xAdvance + delta)); + void value; } public updateMetricsFromFont(): void { @@ -888,16 +693,6 @@ export class Editor { return this.#camera.projectScreenToScene(screen.x, screen.y); } - public sceneToGlyphLocal(point: Point2D): Point2D { - const offset = this.drawOffset; - return { x: point.x - offset.x, y: point.y - offset.y }; - } - - public glyphLocalToScene(point: Point2D): Point2D { - const offset = this.drawOffset; - return { x: point.x + offset.x, y: point.y + offset.y }; - } - public get hitRadius(): number { return this.#camera.hitRadius; } @@ -929,20 +724,7 @@ export class Editor { public fromScreen(screen: Point2D): Coordinates { const scene = this.projectScreenToScene(screen); - const glyphLocal = this.sceneToGlyphLocal(scene); - return { screen, scene, glyphLocal }; - } - - public fromScene(scene: Point2D): Coordinates { - const screen = this.projectSceneToScreen(scene); - const glyphLocal = this.sceneToGlyphLocal(scene); - return { screen, scene, glyphLocal }; - } - - public fromGlyphLocal(glyphLocal: Point2D): Coordinates { - const scene = this.glyphLocalToScene(glyphLocal); - const screen = this.projectSceneToScreen(scene); - return { screen, scene, glyphLocal }; + return { screen, scene }; } public get pan(): Point2D { @@ -997,38 +779,18 @@ export class Editor { this.#renderer.fpsMonitor.stop(); } - /** Deletes currently selected points from the editable authored glyph layer. */ - public deleteSelectedPoints(): void { - const edit = this.editingGlyphLayer; - if (!edit) return; - - const selectedIds = [...this.selection.pointIds]; - if (selectedIds.length > 0) { - edit.removePoints(selectedIds); - this.selection.clear(); - } - } - public async copy(): Promise { const content = this.#selectedClipboardContent(); if (!content || content.contours.length === 0) return false; - const glyph = this.#glyph.sceneGlyph.record.peek(); - if (!glyph) return false; - - return this.#clipboard.write(content, { sourceGlyph: glyph.name }); + return this.#clipboard.write(content); } public async cut(): Promise { const content = this.#selectedClipboardContent(); if (!content || content.contours.length === 0) return false; - const glyph = this.#glyph.sceneGlyph.record.peek(); - if (!glyph) return false; - - const written = await this.#clipboard.write(content, { - sourceGlyph: glyph.name, - }); + const written = await this.#clipboard.write(content); if (!written) return false; const pointIds = this.#selectedClipboardPointIds(); @@ -1049,23 +811,16 @@ export class Editor { this.#commands.run(command); if (command.createdPointIds.length > 0) { - this.selection.select(command.createdPointIds.map((id) => ({ kind: "point", id }))); + this.selection.select(command.createdPointIds.map((pointId) => ({ kind: "point", pointId }))); } } #selectionCenter(): Point2D | null { - const bounds = this.selection.bounds; - return bounds ? Bounds.center(bounds) : null; + return null; } #selectedClipboardContent(): ClipboardContent | null { - const source = this.#glyph.layerEditing.glyphLayer.peek(); - if (!source) return null; - - const selection = ClipboardSelection.fromSelection(this.selection); - if (selection.pointIds.length === 0) return null; - - return selection.contentFrom(source); + return null; } #selectedClipboardPointIds(): PointId[] { @@ -1153,40 +908,6 @@ export class Editor { this.#commands.run(cmd); } - public get activeContourIdCell(): Signal { - return this.#glyph.sceneGlyph.activeContourId; - } - - public getActiveContourId(): ContourId | null { - const id = this.#glyph.sceneGlyph.activeContourId.peek(); - if (!id) return null; - - return id; - } - - public setActiveContour(contourId: ContourId | null): void { - this.#glyph.sceneGlyph.activeContourId.set(contourId); - } - - public clearActiveContour(): void { - this.#glyph.sceneGlyph.activeContourId.set(null); - } - - public getActiveContour(): Contour | null { - const activeContourId = this.getActiveContourId(); - if (!activeContourId) return null; - - return this.previewGlyphInstance?.geometry.contour(activeContourId) ?? null; - } - - public continueContour(contourId: ContourId, fromStart: boolean, pointId: PointId): void { - this.#glyph.sceneGlyph.activeContourId.set(contourId); - if (fromStart) { - this.#commands.run(new ReverseContourCommand(contourId)); - } - this.selection.select([{ kind: "point", id: pointId }]); - } - public splitSegment(segment: Segment, t: number): PointId { return this.#commands.run(new SplitSegmentCommand(segment, t)); } @@ -1227,15 +948,6 @@ export class Editor { return command.createdPointIds; } - public get drawOffset(): Point2D { - return this.#text.drawOffset.peek(); - } - - /** @knipclassignore */ - public get drawOffsetCell(): Signal { - return this.#text.drawOffset; - } - public destroy() { this.#events.emit("destroying"); this.#cursorEffect.dispose(); diff --git a/apps/desktop/src/renderer/src/lib/editor/EditorState.ts b/apps/desktop/src/renderer/src/lib/editor/EditorState.ts index 3c78e4a1..92e43f85 100644 --- a/apps/desktop/src/renderer/src/lib/editor/EditorState.ts +++ b/apps/desktop/src/renderer/src/lib/editor/EditorState.ts @@ -1,27 +1,14 @@ -import type { Point2D } from "@shift/geo"; -import type { ContourId, GlyphId, GlyphRecord, Source, SourceId } from "@shift/types"; import type { Coordinates } from "@/types/coordinates"; -import type { AxisLocation } from "@/types/variation"; import type { DebugOverlays } from "@/types/uiState"; import type { Modifiers } from "../tools/core/GestureDetector"; -import type { Font } from "../model/Font"; -import type { GlyphInstance, GlyphLayer } from "../model/Glyph"; -import type { Scene } from "./Scene"; -import type { FocusedGlyph } from "../text/TextRun"; -import type { GlyphAnchor } from "../text/layout"; -import type { TextRuns } from "../text/TextRuns"; -import { - computed, - signal, - type ComputedSignal, - type Signal, - type WritableSignal, -} from "../signals"; +import { signal, type Signal, type WritableSignal } from "../signals"; const DEFAULT_MODIFIERS: Modifiers = { shiftKey: false, altKey: false, metaKey: false, + ctrlKey: false, + accelKey: false, }; const DEFAULT_DEBUG_OVERLAYS: DebugOverlays = { @@ -31,12 +18,6 @@ const DEFAULT_DEBUG_OVERLAYS: DebugOverlays = { glyphBbox: false, }; -export interface GlyphPresentationState { - readonly proofMode: boolean; - readonly handlesVisible: boolean; - readonly focusedGlyphVisible: boolean; -} - export type EditorGesturePhase = "idle" | "pressed" | "dragging"; export interface EditorGestureSnapshot { @@ -82,48 +63,6 @@ export class EditorGesture { } } -export class GlyphPresentation { - readonly proofModeCell: WritableSignal; - readonly handlesVisibleCell: WritableSignal; - readonly focusedGlyphVisibleCell: ComputedSignal; - - constructor(text: TextEditingState, textRuns: TextRuns) { - this.proofModeCell = signal(false, { - name: "editor.glyph.presentation.proofMode", - }); - this.handlesVisibleCell = signal(true, { - name: "editor.glyph.presentation.handlesVisible", - }); - this.focusedGlyphVisibleCell = computed( - () => { - const run = textRuns.activeCell.value; - const focusedGlyph = text.focusedGlyph.value; - const hasTextActivity = - run.buffer.itemsCell.value.length > 0 || run.cursorVisibleCell.value; - - return !hasTextActivity || focusedGlyph?.anchor.runId === run.id; - }, - { name: "editor.glyph.presentation.focusedGlyphVisible" }, - ); - } - - get proofMode(): boolean { - return this.proofModeCell.peek(); - } - - setProofMode(enabled: boolean): void { - this.proofModeCell.set(enabled); - } - - get handlesVisible(): boolean { - return this.handlesVisibleCell.peek(); - } - - setHandlesVisible(visible: boolean): void { - this.handlesVisibleCell.set(visible); - } -} - export class EditorInput { readonly #pointer: WritableSignal; readonly #modifiers: WritableSignal; @@ -173,125 +112,3 @@ export class EditorViewState { this.cursorCell = signal("default", { name: "editor.view.cursor" }); } } - -/** Glyph preview state derived from placed scene items. */ -export class SceneGlyphPreviewState { - /** Glyph id for the first geometry-shown glyph item, or first placed glyph. */ - readonly glyphId: Signal; - - /** Identity record for the first geometry-shown glyph item, or first placed glyph. */ - readonly record: Signal; - - /** Contour receiving appended pen points, or `null` when no contour is active. */ - readonly activeContourId: WritableSignal; - - constructor(font: Font, scene: Scene) { - this.glyphId = computed( - () => { - const value = scene.cell.value; - const geometryItem = value.geometryItems - .map((itemId) => value.items.find((item) => item.id === itemId) ?? null) - .find((item) => item?.kind === "glyph"); - const fallbackItem = value.items.find((item) => item.kind === "glyph") ?? null; - const item = geometryItem ?? fallbackItem; - return item?.kind === "glyph" ? item.glyphId : null; - }, - { name: "editor.glyph.previewRecordId" }, - ); - this.record = font.glyphCell(this.glyphId); - this.activeContourId = signal(null, { - name: "editor.glyph.activeContourId", - }); - } -} - -/** - * Resolves the authored glyph layer for the current designspace source. - * - * The editable source follows the exact source at the current design location - * when one exists. - */ -export class GlyphLayerEditingState { - /** ID for the exact designspace source selected by the current design location. */ - readonly sourceId: Signal; - - /** Exact font source selected by the current design location. */ - readonly selectedSource: Signal; - - /** Authored glyph layer data for the selected source. */ - readonly glyphLayer: Signal; - - constructor(font: Font, glyphId: Signal, location: Signal) { - this.selectedSource = font.sourceAtCell(location); - this.sourceId = computed(() => this.selectedSource.value?.id ?? null, { - name: "editor.glyph.layerEditing.sourceId", - }); - - this.glyphLayer = font.layerCell(glyphId, this.sourceId); - } -} - -/** - * Provides read models for the displayed glyph at the current design location. - * - * The instance is the glyph resolved at the current design location. Query, - * render, and edit callers choose a surface from that one instance instead of - * choosing between layer geometry, interpolated geometry, and outline models. - */ -export class PreviewGlyphState { - /** Glyph resolved at the current design location. */ - readonly instance: Signal; - - constructor(font: Font, glyphId: Signal, location: Signal) { - this.instance = font.instanceCell(glyphId, location); - } -} -/** - * Groups glyph-session state by ownership. - * - * `layerEditing` owns authored glyph layer selection. `preview` owns the - * displayed glyph model at the current design location. - */ -export class EditorGlyphState { - readonly sceneGlyph: SceneGlyphPreviewState; - readonly layerEditing: GlyphLayerEditingState; - readonly preview: PreviewGlyphState; - - constructor(font: Font, scene: Scene, location: Signal) { - this.sceneGlyph = new SceneGlyphPreviewState(font, scene); - this.layerEditing = new GlyphLayerEditingState(font, this.sceneGlyph.glyphId, location); - this.preview = new PreviewGlyphState(font, this.sceneGlyph.glyphId, location); - } -} - -export interface GlyphPlacement { - focused: FocusedGlyph; - drawOffset: Point2D; -} - -export class TextEditingState { - readonly glyphAnchor: WritableSignal; - readonly focusedGlyph: ComputedSignal; - readonly glyphPlacement: ComputedSignal; - readonly drawOffset: ComputedSignal; - - constructor(textRuns: TextRuns) { - this.glyphAnchor = signal(null); - - this.focusedGlyph = computed(() => { - const anchor = this.glyphAnchor.value; - if (!anchor) return null; - return textRuns.resolveAnchor(anchor); - }); - - this.glyphPlacement = computed(() => { - const focused = this.focusedGlyph.value; - if (!focused) return null; - return { focused, drawOffset: focused.editOrigin }; - }); - - this.drawOffset = computed( - () => this.glyphPlacement.value?.drawOffset ?? { x: 0, y: 0 }, - ); - } -} diff --git a/apps/desktop/src/renderer/src/lib/editor/GlyphLayerEditDraft.test.ts b/apps/desktop/src/renderer/src/lib/editor/GlyphLayerEditDraft.test.ts index 65e45b46..74c2a44e 100644 --- a/apps/desktop/src/renderer/src/lib/editor/GlyphLayerEditDraft.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/GlyphLayerEditDraft.test.ts @@ -36,7 +36,7 @@ describe("glyph layer edit drafts preserve committed preview bases", () => { await editor.settle(); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("previews, commits, and undoes a layer edit through the real glyph layer", async () => { const point = source().allPoints[0]!; diff --git a/apps/desktop/src/renderer/src/lib/editor/Hover.ts b/apps/desktop/src/renderer/src/lib/editor/Hover.ts index 40a40a03..5b744eb1 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Hover.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Hover.ts @@ -1,78 +1,86 @@ import type { SegmentId } from "@shift/glyph-state"; import type { AnchorId, PointId } from "@shift/types"; -import { signal, type Signal, type WritableSignal } from "../signals"; +import { computed, signal, type Signal, type WritableSignal } from "../signals"; -interface PointHover { - type: "point"; - pointId: PointId; +export interface PointHover { + readonly kind: "point"; + readonly pointId: PointId; } -interface AnchorHover { - type: "anchor"; - anchorId: AnchorId; +export interface AnchorHover { + readonly kind: "anchor"; + readonly anchorId: AnchorId; } -interface SegmentHover { - type: "segment"; - segmentId: SegmentId; +export interface SegmentHover { + readonly kind: "segment"; + readonly segmentId: SegmentId; } -export type HoverState = PointHover | AnchorHover | SegmentHover | null; +export type HoverEntry = PointHover | AnchorHover | SegmentHover; +export type HoverableId = PointId | AnchorId | SegmentId; /** - * Runtime hover state for glyph-domain targets in the editor session. + * Stores scene-scoped hover identity. * - * `Hover` only records which glyph object is currently under the pointer. It - * does not perform hit testing, decide priority between target kinds, or hold - * tool-specific visual state such as bounding-box handles, pen endpoints, text - * carets, or marquee feedback. Tools update this state from pointer behavior - * after reading an explicit geometry surface. + * Hover is transient identity-only state. It owns no glyph models, glyph layers, + * geometry, or hit-test policy; tools replace it after resolving scene items. */ export class Hover { - readonly #target: WritableSignal; + readonly #entry: WritableSignal; + readonly #id: Signal; constructor() { - this.#target = signal(null, { name: "editor.hover.target" }); + this.#entry = signal(null, { name: "editor.hover" }); + this.#id = computed( + () => { + const entry = this.#entry.value; + return entry ? hoverId(entry) : null; + }, + { name: "editor.hover.id" }, + ); } - get targetCell(): Signal { - return this.#target; + get entryCell(): Signal { + return this.#entry; } - get target(): HoverState | null { - return this.#target.peek(); + get entry(): HoverEntry | null { + return this.#entry.peek(); } - get hasTarget(): boolean { - return this.#target.peek() !== null; + get id(): HoverableId | null { + return this.#id.peek(); } - get pointId(): PointId | null { - const target = this.#target.peek(); - return target?.type === "point" ? target.pointId : null; + get hasHover(): boolean { + return this.#id.peek() !== null; } - get anchorId(): AnchorId | null { - const target = this.#target.peek(); - return target?.type === "anchor" ? target.anchorId : null; + isHovered(entry: HoverEntry): boolean { + return this.has(hoverId(entry)); } - get segmentId(): SegmentId | null { - const target = this.#target.peek(); - return target?.type === "segment" ? target.segmentId : null; + has(id: HoverableId): boolean { + return this.#id.peek() === id; } - /** - * Replace the current glyph hover target. - * - * Passing `null` clears hover. The caller owns the policy that chose this - * target, including which geometry surface was queried and which hit kind won. - */ - set(target: HoverState | null): void { - this.#target.set(target); + set(entry: HoverEntry | null): void { + this.#entry.set(entry); } clear(): void { - this.#target.set(null); + this.#entry.set(null); + } +} + +function hoverId(entry: HoverEntry): HoverableId { + switch (entry.kind) { + case "point": + return entry.pointId; + case "anchor": + return entry.anchorId; + case "segment": + return entry.segmentId; } } diff --git a/apps/desktop/src/renderer/src/lib/editor/Scene.ts b/apps/desktop/src/renderer/src/lib/editor/Scene.ts index b76abecd..e5b718b8 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Scene.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Scene.ts @@ -1,42 +1,22 @@ import type { Point2D } from "@shift/geo"; -import { mintItemId, type GlyphId, type ItemId } from "@shift/types"; +import type { NodeId } from "@shift/types"; import { signal, type Signal, type WritableSignal } from "@/lib/signals"; +import type { ShiftNode } from "@/types/node"; -export interface ScenePlacement { - readonly origin: Point2D; -} - -export interface SceneGlyph { - readonly id: ItemId; - readonly kind: "glyph"; - readonly glyphId: GlyphId; - readonly placement: ScenePlacement; -} - -export type SceneItem = SceneGlyph; - -export interface AddGlyphInput { - readonly glyphId: GlyphId; - readonly origin: Point2D; +export interface SceneInput { + readonly nodes: readonly ShiftNode[]; } export interface SceneValue { - readonly items: readonly SceneItem[]; - readonly geometryItems: readonly ItemId[]; -} - -export interface SceneInput { - readonly items: readonly SceneItem[]; - readonly geometryItems?: readonly ItemId[]; + readonly nodes: readonly ShiftNode[]; } /** - * Owns the placed items visible in the editor scene. + * Owns the placed nodes visible in the editor scene. * * @remarks - * Scene stores placement identity and which item ids should show structured - * geometry. It does not store designspace location, resolve glyph models or - * glyph layers, or decide what authored data a command mutates. + * Scene stores canvas identity and position. It does not resolve glyph models, + * glyph instances, glyph layers, or decide what authored data a command mutates. */ export class Scene { readonly #cell: WritableSignal; @@ -45,251 +25,100 @@ export class Scene { this.#cell = signal(emptyScene(), { name: "editor.scene" }); } - /** Reactive scene value for renderers and panels. */ + /** Returns the reactive scene snapshot. */ get cell(): Signal { return this.#cell; } - /** Current scene snapshot. */ + /** Returns the current scene snapshot. */ get value(): SceneValue { return this.#cell.peek(); } - /** Returns all placed scene items as a read-only snapshot. */ - items(): readonly SceneItem[] { - return this.#cell.peek().items; - } - - /** Returns all placed glyph items as a read-only snapshot. */ - glyphItems(): readonly SceneGlyph[] { - return this.#cell.peek().items.filter(isSceneGlyph); - } - - /** Returns item ids currently rendered with structured geometry. */ - geometryItemIds(): readonly ItemId[] { - return this.#cell.peek().geometryItems; + /** Returns all placed nodes as a read-only snapshot. */ + nodes(): readonly ShiftNode[] { + return this.#cell.peek().nodes; } /** - * Resolves a placed scene item by id. + * Resolves a placed node by id. * - * @param itemId - Placement identity to resolve; `null` resolves to `null`. - * @returns The placed item, or `null` when the item is no longer in the scene. + * @param nodeId - Placement identity to resolve. + * @returns The placed node, or `null` when the node is not in the scene. */ - item(itemId: ItemId | null): SceneItem | null { - if (!itemId) return null; - return this.#cell.peek().items.find((item) => item.id === itemId) ?? null; - } + node(nodeId: NodeId | null): ShiftNode | null { + if (!nodeId) return null; - /** - * Resolves a placed glyph item by id. - * - * @param itemId - Placement identity to resolve; `null` resolves to `null`. - * @returns The placed glyph item, or `null` when the item is absent or not a glyph. - */ - glyphItem(itemId: ItemId | null): SceneGlyph | null { - const item = this.item(itemId); - return item?.kind === "glyph" ? item : null; + return this.#cell.peek().nodes.find((node) => node.id === nodeId) ?? null; } /** - * Replaces every scene item and geometry visibility state. + * Replaces the complete scene. * - * @param scene - Complete scene description. Items are copied by value; glyph - * models and layers are not loaded or created. + * @param scene - Complete scene description. Nodes are copied by value. */ set(scene: SceneInput): void { - const items = scene.items.map(copyItem); - const itemIds = new Set(items.map((item) => item.id)); - const geometryItems = (scene.geometryItems ?? []).filter((itemId) => itemIds.has(itemId)); this.#cell.set({ - items, - geometryItems, + nodes: scene.nodes.map(copyNode), }); } - /** Clears all scene items and geometry visibility state. */ + /** Clears all scene nodes. */ clear(): void { - const scene = this.#cell.peek(); - this.#cell.set({ ...scene, items: [], geometryItems: [] }); + this.#cell.set(emptyScene()); } /** - * Adds one glyph item to the scene. + * Adds one node to the scene. * - * @param input - Glyph identity and scene-space origin for the new item. - * @returns The minted item id for the placed glyph. + * @param node - Complete node identity and scene-space position to add. */ - addGlyph(input: AddGlyphInput): ItemId { - const id = mintItemId(); - const item: SceneGlyph = { - id, - kind: "glyph", - glyphId: input.glyphId, - placement: { origin: { ...input.origin } }, - }; + addNode(node: ShiftNode): void { const scene = this.#cell.peek(); this.#cell.set({ - ...scene, - items: [...scene.items, item], + nodes: [...scene.nodes, copyNode(node)], }); - return id; - } - - /** - * Shows structured geometry for a placed scene item. - * - * @param itemId - Placement identity whose geometry should be shown. - */ - showGeometry(itemId: ItemId): void { - if (!this.item(itemId) || this.isGeometryShown(itemId)) return; - const scene = this.#cell.peek(); - this.#cell.set({ ...scene, geometryItems: [...scene.geometryItems, itemId] }); - } - - /** - * Hides structured geometry for a placed scene item. - * - * @param itemId - Placement identity whose geometry should be hidden. - */ - hideGeometry(itemId: ItemId): void { - const scene = this.#cell.peek(); - this.#cell.set({ - ...scene, - geometryItems: scene.geometryItems.filter((existing) => existing !== itemId), - }); - } - - /** - * Replaces the complete set of items whose structured geometry is shown. - * - * @param itemIds - Placement identities to show with structured geometry. - */ - setGeometryItems(itemIds: readonly ItemId[]): void { - const scene = this.#cell.peek(); - const validIds = new Set(scene.items.map((item) => item.id)); - const geometryItems: ItemId[] = []; - for (const itemId of itemIds) { - if (!validIds.has(itemId) || geometryItems.includes(itemId)) continue; - geometryItems.push(itemId); - } - this.#cell.set({ ...scene, geometryItems }); } /** - * Returns whether structured geometry is shown for a scene item. + * Replaces a node's scene-space position. * - * @param itemId - Placement identity to inspect. + * @param nodeId - Placement identity whose position is updated. + * @param position - Destination position in scene coordinates. */ - isGeometryShown(itemId: ItemId): boolean { - return this.#cell.peek().geometryItems.includes(itemId); - } - - /** - * Replaces a scene item's placement. - * - * @param itemId - Placement identity whose placement is updated. - * @param placement - New placement in scene coordinates. - */ - setPlacement(itemId: ItemId, placement: ScenePlacement): void { + moveNodeTo(nodeId: NodeId, position: Point2D): void { const scene = this.#cell.peek(); this.#cell.set({ - ...scene, - items: scene.items.map((item) => - item.id === itemId ? copyItem({ ...item, placement: copyPlacement(placement) }) : item, + nodes: scene.nodes.map((node) => + node.id === nodeId ? copyNode({ ...node, position: { ...position } }) : node, ), }); } /** - * Sets a scene item's origin to an absolute scene-space position. - * - * @param itemId - Placement identity whose origin is updated. - * @param origin - Destination origin in scene coordinates. - */ - moveItemTo(itemId: ItemId, origin: Point2D): void { - const item = this.item(itemId); - if (!item) return; - this.setPlacement(itemId, { ...item.placement, origin: { ...origin } }); - } - - /** - * Offsets a scene item's origin by a scene-space delta. + * Offsets a node's scene-space position. * - * @param itemId - Placement identity whose origin is updated. + * @param nodeId - Placement identity whose position is updated. * @param delta - Relative movement in scene coordinates. */ - moveItemBy(itemId: ItemId, delta: Point2D): void { - const item = this.item(itemId); - if (!item) return; - this.moveItemTo(itemId, { - x: item.placement.origin.x + delta.x, - y: item.placement.origin.y + delta.y, - }); - } + moveNodeBy(nodeId: NodeId, delta: Point2D): void { + const node = this.node(nodeId); + if (!node) return; - /** - * Updates scene-only fields for a placed glyph item. - * - * @param itemId - Placement identity for the glyph item to update. - * @param patch - Replacement scene data; does not mutate authored glyph geometry. - */ - updateGlyph(itemId: ItemId, patch: Partial>): void { - const scene = this.#cell.peek(); - this.#cell.set({ - ...scene, - items: scene.items.map((item) => - item.id === itemId && item.kind === "glyph" - ? copyItem({ ...item, ...patch, kind: "glyph" }) - : item, - ), + this.moveNodeTo(nodeId, { + x: node.position.x + delta.x, + y: node.position.y + delta.y, }); } - - /** - * Converts a scene-space point into a placed item's local coordinate space. - * - * @param itemId - Placement identity that supplies the local origin. - * @param scenePoint - Point in scene coordinates. - * @returns Local point, or `null` when the item is not in the scene. - */ - toLocal(itemId: ItemId, scenePoint: Point2D): Point2D | null { - const origin = this.item(itemId)?.placement.origin; - if (!origin) return null; - return { x: scenePoint.x - origin.x, y: scenePoint.y - origin.y }; - } - - /** - * Converts a placed item's local point into scene-space coordinates. - * - * @param itemId - Placement identity that supplies the local origin. - * @param localPoint - Point in the placed item's local coordinates. - * @returns Scene-space point, or `null` when the item is not in the scene. - */ - toScene(itemId: ItemId, localPoint: Point2D): Point2D | null { - const origin = this.item(itemId)?.placement.origin; - if (!origin) return null; - return { x: localPoint.x + origin.x, y: localPoint.y + origin.y }; - } -} - -function isSceneGlyph(item: SceneItem): item is SceneGlyph { - return item.kind === "glyph"; -} - -function copyItem(item: T): T { - return { - ...item, - placement: copyPlacement(item.placement), - }; } -function copyPlacement(placement: ScenePlacement): ScenePlacement { +function copyNode(node: T): T { return { - origin: { ...placement.origin }, + ...node, + position: { ...node.position }, }; } function emptyScene(): SceneValue { - return { items: [], geometryItems: [] }; + return { nodes: [] }; } diff --git a/apps/desktop/src/renderer/src/lib/editor/Selection.ts b/apps/desktop/src/renderer/src/lib/editor/Selection.ts index 783a4a08..47f163fa 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Selection.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Selection.ts @@ -1,241 +1,221 @@ -import { Bounds, type Bounds as BoundsType } from "@shift/geo"; import type { AnchorId, ContourId, PointId } from "@shift/types"; import type { SegmentId } from "@shift/glyph-state"; -import type { GlyphLayer } from "@/lib/model/Glyph"; -import { - computed, - signal, - type ComputedSignal, - type Signal, - type WritableSignal, -} from "@/lib/signals/signal"; - -/** Discriminated reference to any selectable entity. */ -export type Selectable = - | { kind: "point"; id: PointId } - | { kind: "anchor"; id: AnchorId } - | { kind: "segment"; id: SegmentId }; +import { computed, signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; -export interface SelectionState { - readonly pointIds: ReadonlySet; - readonly anchorIds: ReadonlySet; - readonly segmentIds: ReadonlySet; +export interface PointSelection { + readonly kind: "point"; + readonly pointId: PointId; +} + +export interface AnchorSelection { + readonly kind: "anchor"; + readonly anchorId: AnchorId; +} + +export interface ContourSelection { + readonly kind: "contour"; + readonly contourId: ContourId; } -interface DerivedSelection { - readonly contourIds: ReadonlySet; - readonly bounds: BoundsType | null; +export interface SegmentSelection { + readonly kind: "segment"; + readonly segmentId: SegmentId; } -const EMPTY_DERIVED: DerivedSelection = { - contourIds: new Set(), - bounds: null, -}; +export type SelectionEntry = PointSelection | AnchorSelection | ContourSelection | SegmentSelection; + +export type SelectableId = PointId | AnchorId | ContourId | SegmentId; + +export interface SelectionState { + readonly entries: readonly SelectionEntry[]; +} function emptySelectionState(): SelectionState { - return { - pointIds: new Set(), - anchorIds: new Set(), - segmentIds: new Set(), - }; + return { entries: [] }; } /** - * Committed editor selection with computed contour queries. + * Stores scene-scoped editor selection. * - * Selection owns selected IDs. Geometry owns object and parent lookups. + * Selection is identity-only state. It owns no glyph models, glyph layers, or + * geometry-derived data; callers resolve entries against scene and font state. */ export class Selection { - readonly #glyphLayer: Signal; readonly #state: WritableSignal; - readonly #derived: ComputedSignal; - readonly #bounds: ComputedSignal; readonly stateCell: Signal; - readonly boundsCell: Signal; - constructor(glyphLayer: Signal) { - this.#glyphLayer = glyphLayer; + readonly #ids: Signal>; + + readonly #pointIds: Signal>; + readonly #anchorIds: Signal>; + readonly #contourIds: Signal>; + readonly #segmentIds: Signal>; + + constructor() { this.#state = signal(emptySelectionState(), { name: "editor.selection.state", }); - - this.#derived = computed( - () => { - const state = this.#state.value; - const pointIds = state.pointIds; - const glyphLayer = this.#glyphLayer.value; - - if (!glyphLayer) return EMPTY_DERIVED; - if (pointIds.size === 0) return EMPTY_DERIVED; - - glyphLayer.coordinateBuffersChangedCell.value; - - const contourIds = new Set(); - - for (const pointId of pointIds) { - const contourId = glyphLayer.contourIdOfPoint(pointId); - if (contourId) contourIds.add(contourId); - } - - let bounds: BoundsType | null = null; - if (this.segmentIds.size !== 0) { - for (const segment of this.segmentIds) { - const s = glyphLayer.geometry.segment(segment); - if (!s) continue; - bounds = Bounds.unionAll([bounds, s.bounds]); - } - - return { contourIds, bounds }; - } - - const points = glyphLayer.positionsFor( - [...pointIds].map((id) => ({ kind: "point" as const, id })), - ); - bounds = Bounds.fromPoints(points); - - return { contourIds, bounds }; - }, - { name: "editor.selection.derived" }, - ); - this.#bounds = computed(() => this.#derived.value.bounds, { - name: "editor.selection.bounds", - }); this.stateCell = this.#state; - this.boundsCell = this.#bounds; + this.#ids = computed(() => new Set(this.#state.value.entries.map(selectionId)), { + name: "editor.selection.ids", + }); + this.#pointIds = computed(() => pointSelectionIds(this.#state.value.entries), { + name: "editor.selection.pointIds", + }); + this.#anchorIds = computed(() => anchorSelectionIds(this.#state.value.entries), { + name: "editor.selection.anchorIds", + }); + this.#contourIds = computed(() => contourSelectionIds(this.#state.value.entries), { + name: "editor.selection.contourIds", + }); + this.#segmentIds = computed(() => segmentSelectionIds(this.#state.value.entries), { + name: "editor.selection.segmentIds", + }); } get snapshot(): SelectionState { return this.#state.peek(); } + get entries(): readonly SelectionEntry[] { + return this.#state.peek().entries; + } + + get points(): readonly PointSelection[] { + return this.entries.filter((entry): entry is PointSelection => entry.kind === "point"); + } + + get anchors(): readonly AnchorSelection[] { + return this.entries.filter((entry): entry is AnchorSelection => entry.kind === "anchor"); + } + + get contours(): readonly ContourSelection[] { + return this.entries.filter((entry): entry is ContourSelection => entry.kind === "contour"); + } + + get segments(): readonly SegmentSelection[] { + return this.entries.filter((entry): entry is SegmentSelection => entry.kind === "segment"); + } + get pointIds(): ReadonlySet { - return this.#state.peek().pointIds; + return this.#pointIds.peek(); } get anchorIds(): ReadonlySet { - return this.#state.peek().anchorIds; + return this.#anchorIds.peek(); } - get segmentIds(): ReadonlySet { - return this.#state.peek().segmentIds; + get contourIds(): ReadonlySet { + return this.#contourIds.peek(); } - /** @knipclassignore - used by BooleanOps and upcoming callers */ - get contourIds(): ReadonlySet { - return this.#derived.peek().contourIds; + get segmentIds(): ReadonlySet { + return this.#segmentIds.peek(); } - get bounds(): BoundsType | null { - return this.#bounds.peek(); + isSelected(entry: SelectionEntry): boolean { + return this.has(selectionId(entry)); } - isSelected(item: Selectable): boolean { - const state = this.#state.peek(); - switch (item.kind) { - case "point": - return state.pointIds.has(item.id); - case "anchor": - return state.anchorIds.has(item.id); - case "segment": - return state.segmentIds.has(item.id); - } + has(id: SelectableId): boolean { + return this.#ids.peek().has(id); } hasSelection(): boolean { - const state = this.#state.peek(); - return state.pointIds.size > 0 || state.anchorIds.size > 0 || state.segmentIds.size > 0; - } - - selected(): Selectable[] { - const state = this.#state.peek(); - - return [ - ...[...state.pointIds].map((id) => ({ kind: "point", id }) as const), - ...[...state.anchorIds].map((id) => ({ kind: "anchor", id }) as const), - ...[...state.segmentIds].map((id) => ({ kind: "segment", id }) as const), - ]; - } - - /** Replace entire selection with the given items. Clears everything first. */ - select(items: readonly Selectable[]): void { - const pointIds = new Set(); - const anchorIds = new Set(); - const segmentIds = new Set(); - - for (const item of items) { - switch (item.kind) { - case "point": - pointIds.add(item.id); - break; - case "anchor": - anchorIds.add(item.id); - break; - case "segment": - segmentIds.add(item.id); - break; - } - } + return this.entries.length > 0; + } - this.#state.set({ pointIds, anchorIds, segmentIds }); - } - - add(item: Selectable): void { - const state = this.#state.peek(); - switch (item.kind) { - case "point": { - const pointIds = new Set(state.pointIds); - pointIds.add(item.id); - this.#state.set({ ...state, pointIds }); - break; - } - case "anchor": { - const anchorIds = new Set(state.anchorIds); - anchorIds.add(item.id); - this.#state.set({ ...state, anchorIds }); - break; - } - case "segment": { - const segmentIds = new Set(state.segmentIds); - segmentIds.add(item.id); - this.#state.set({ ...state, segmentIds }); - break; - } - } + select(entries: readonly SelectionEntry[]): void { + this.#state.set({ entries: uniqueEntries(entries) }); } - remove(item: Selectable): void { - const state = this.#state.peek(); - switch (item.kind) { - case "point": { - const pointIds = new Set(state.pointIds); - pointIds.delete(item.id); - this.#state.set({ ...state, pointIds }); - break; - } - case "anchor": { - const anchorIds = new Set(state.anchorIds); - anchorIds.delete(item.id); - this.#state.set({ ...state, anchorIds }); - break; - } - case "segment": { - const segmentIds = new Set(state.segmentIds); - segmentIds.delete(item.id); - this.#state.set({ ...state, segmentIds }); - break; - } - } + add(entry: SelectionEntry): void { + if (this.isSelected(entry)) return; + + this.#state.set({ entries: [...this.entries, entry] }); + } + + remove(entry: SelectionEntry): void { + if (!this.isSelected(entry)) return; + + const id = selectionId(entry); + this.#state.set({ + entries: this.entries.filter((selected) => selectionId(selected) !== id), + }); } - toggle(item: Selectable): void { - if (this.isSelected(item)) { - this.remove(item); - } else { - this.add(item); + toggle(entry: SelectionEntry): void { + if (this.isSelected(entry)) { + this.remove(entry); + return; } + + this.add(entry); } clear(): void { + if (this.entries.length === 0) return; + this.#state.set(emptySelectionState()); } } + +function uniqueEntries(entries: readonly SelectionEntry[]): readonly SelectionEntry[] { + const seen = new Set(); + const unique: SelectionEntry[] = []; + + for (const entry of entries) { + const id = selectionId(entry); + if (seen.has(id)) continue; + + seen.add(id); + unique.push(entry); + } + + return unique; +} + +function selectionId(entry: SelectionEntry): SelectableId { + switch (entry.kind) { + case "point": + return entry.pointId; + case "anchor": + return entry.anchorId; + case "contour": + return entry.contourId; + case "segment": + return entry.segmentId; + } +} + +function pointSelectionIds(entries: readonly SelectionEntry[]): ReadonlySet { + const ids = new Set(); + for (const entry of entries) { + if (entry.kind === "point") ids.add(entry.pointId); + } + return ids; +} + +function anchorSelectionIds(entries: readonly SelectionEntry[]): ReadonlySet { + const ids = new Set(); + for (const entry of entries) { + if (entry.kind === "anchor") ids.add(entry.anchorId); + } + return ids; +} + +function contourSelectionIds(entries: readonly SelectionEntry[]): ReadonlySet { + const ids = new Set(); + for (const entry of entries) { + if (entry.kind === "contour") ids.add(entry.contourId); + } + return ids; +} + +function segmentSelectionIds(entries: readonly SelectionEntry[]): ReadonlySet { + const ids = new Set(); + for (const entry of entries) { + if (entry.kind === "segment") ids.add(entry.segmentId); + } + return ids; +} diff --git a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md index a7b6cd0f..6fed0707 100644 --- a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md @@ -60,7 +60,7 @@ editor/ - **`CameraTransform`** -- Value object: `{ zoom, panX, panY, centre, upmScale, logicalHeight, layoutHeight, padding, descender }`. Snapshot of viewport state passed to rendering code. - **`Selection`** -- Unified selection state for points, anchors, and segments. Computed `DerivedSelection` tracks selected contours and bounds. Exposes `stateCell` plus unwrapped ID getters. - **`Selectable`** -- Discriminated union: `{ kind: "point" | "anchor" | "segment", id }`. -- **`Coordinates`** -- Triple of `{ screen, scene, glyphLocal }` for a single position. Built via `Editor.fromScreen()` etc. +- **`Coordinates`** -- Pair of `{ screen, scene }` for a single pointer position. Node-local coordinates are derived after hit testing identifies the node being acted on. - **`GlyphLayerEditDraft`** -- Transactional interface for continuous layer manipulation: `previewPositionPatch()` / `previewTranslate()` / `previewRotate()` / `previewScale()` during drag, `commit(label)` or `discard()` at end. - **`Hover`** -- Tracks the currently hovered glyph-domain entity (point/anchor/segment). Tool-specific controls such as select bounding boxes stay with the owning tool. - **`Handles`** -- Handle renderer that tries the accelerated marker layer and falls back to CPU drawing internally. @@ -81,11 +81,11 @@ editor/ Screen (canvas pixels, Y-down) -> Camera.projectScreenToScene() [affine matrix inverse] Scene (UPM space, Y-up, viewport-relative) - -> Scene.toLocal(itemId, scenePoint) [subtract that item's placement origin] -Item-local (origin defined by the placed scene item) + -> node-local transform for the hit scene node +Node-local (origin defined by the placed node) ``` -Existing tools still receive `coords.glyphLocal` for the active glyph path. New scene-aware code should not treat that coordinate as global; use `Scene.toLocal(itemId, scenePoint)` after hit testing identifies the item. +Tools receive screen and scene coordinates from the pointer pipeline. Scene/node hit testing resolves any node-local coordinates needed by glyph editing tools. `Camera` computes the UPM-to-screen matrix as: baseline positioning + Y-flip + scale, composed with pan + zoom. The inverse is lazily computed. Both are `ComputedSignal` so any dependent computed/effect auto-invalidates. @@ -154,7 +154,7 @@ Glyph geometry exposes domain hit queries for points, anchors, and segments. Too - **Draft lifecycle** -- A `GlyphLayerEditDraft` must be committed or discarded. Calling `commit()` twice is safe (no-op), but forgetting both leaks the preview state. - **Hover mutual exclusion** -- `Hover` stores one glyph-domain target at a time. Tool-specific hover state should stay with the owning tool. - **Marker fallback** -- `Handles.draw()` tries the accelerated marker layer first. If WebGL is unavailable, it falls back to CPU canvas drawing internally. -- **Item-local coordinates** -- `glyphLocal` is transitional active-glyph state. Scene item-local conversion requires an `ItemId` through `Scene.toLocal()` / `Scene.toScene()`. +- **Node-local coordinates** -- Derived after hit testing identifies the scene node being acted on. Do not add editor-global glyph-local coordinates back to tool events. ## Verification diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts index b604fbc5..00666a62 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/RenderFrame.ts @@ -1,49 +1,46 @@ -import type { Point2D } from "@shift/geo"; import type { DebugOverlays as DebugOverlayState } from "@/types/uiState"; import type { GlyphInstance } from "@/lib/model/Glyph"; -import type { FocusedGlyph, TextRun } from "@/lib/text/TextRun"; -import type { SelectionState } from "@/lib/editor/Selection"; -import type { HoverState } from "@/lib/editor/Hover"; -import type { Editor } from "@/lib/editor/Editor"; -import type { GlyphPresentationState } from "@/lib/editor/EditorState"; -import type { SceneGlyph } from "@/lib/editor/Scene"; -import type { AxisLocation } from "@/types/variation"; -import type { GlyphRecord, Unicode } from "@shift/types"; -import { track, type Signal } from "@/lib/signals"; +import type { Selection } from "@/lib/editor/Selection"; +import type { Hover } from "@/lib/editor/Hover"; import { displayAdvance } from "@/lib/utils/unicode"; import { SCREEN_HIT_RADIUS } from "./constants"; import { CanvasItem } from "./CanvasItem"; import type { Canvas } from "./Canvas"; import { OutlineRenderer } from "./Outline"; -import { Text as TextRunDrawer } from "./Text"; import { Anchors, ControlLines, DebugOverlays, Guides, Handles } from "./overlays"; import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; +import type { GlyphNode } from "@/types/node"; +import type { Editor } from "../Editor"; + +class GlyphFrame { + readonly node: GlyphNode; + readonly instance: GlyphInstance; + + constructor(editor: Editor, node: GlyphNode) { + const instance = editor.font.instance(node.glyphId, editor.designLocationCell); + if (!instance) { + throw new Error( + `glyph node ${node.id} references glyph ${node.glyphId} outside the editor font`, + ); + } -interface BackgroundGlyphFrame { - readonly item: SceneGlyph; - readonly advance: number; - readonly geometryShown: boolean; + this.node = node; + this.instance = instance; + } } -interface GuideAdvanceInput { - readonly record: GlyphRecord; - readonly xAdvance: number; +interface BackgroundGlyphFrame { + readonly node: GlyphNode; + readonly advance: number; } export interface BackgroundLayerProps { readonly glyphs: readonly BackgroundGlyphFrame[]; } -interface SceneGlyphFrame { - readonly item: SceneGlyph; - readonly record: GlyphRecord | null; - readonly instance: GlyphInstance | null; - readonly geometryShown: boolean; -} - interface SceneInteractionProps { - readonly selection: SelectionState; - readonly hover: HoverState; + readonly selection: Selection; + readonly hover: Hover; } interface SceneViewProps { @@ -51,19 +48,11 @@ interface SceneViewProps { } export interface SceneLayerProps { - readonly glyphs: readonly SceneGlyphFrame[]; - readonly display: GlyphPresentationState; + readonly glyphs: readonly GlyphFrame[]; readonly interaction: SceneInteractionProps; readonly view: SceneViewProps; } -interface TextLayerProps { - readonly run: TextRun; - readonly designLocation: Signal; - readonly drawOffset: Point2D; - readonly focusedGlyph: FocusedGlyph | null; -} - export interface OverlayLayerProps { readonly activeTool: string; readonly activeToolState: unknown; @@ -100,26 +89,23 @@ export class BackgroundLayer extends CanvasItem { this.#editor.activeToolCell.value; this.#editor.activeToolStateCell.value; - const scene = this.#editor.scene.cell.value; const glyphs: BackgroundGlyphFrame[] = []; - - for (const item of scene.items) { - if (item.kind !== "glyph") continue; - const geometryShown = scene.geometryItems.includes(item.id); - if (!geometryShown) continue; - - const record = this.#editor.font.glyph(item.glyphId); - if (!record) continue; - - const instance = this.#editor.instanceForItem(item.id); - glyphs.push({ - item, - advance: guideAdvance({ - record, - xAdvance: instance?.xAdvanceCell.value ?? this.#editor.font.defaultXAdvance, - }), - geometryShown, - }); + for (const node of this.#editor.scene.cell.value.nodes) { + switch (node.kind) { + case "glyph": { + const record = this.#editor.font.glyph(node.glyphId); + if (!record) break; + + const frame = new GlyphFrame(this.#editor, node); + const unicode = record.unicodes[0] ?? null; + + glyphs.push({ + node: frame.node, + advance: displayAdvance(frame.instance.xAdvanceCell.value, record.name, unicode), + }); + break; + } + } } return { glyphs }; @@ -130,8 +116,7 @@ export class BackgroundLayer extends CanvasItem { if (!props) return; for (const glyph of props.glyphs) { - if (!glyph.geometryShown) continue; - canvas.withTranslation(glyph.item.placement.origin, () => { + canvas.withTranslation(glyph.node.position, () => { this.#guides.draw(canvas, this.#editor.font.metrics, glyph.advance); this.#editor.toolManager.drawBackground(canvas); }); @@ -139,69 +124,6 @@ export class BackgroundLayer extends CanvasItem { } } -function guideAdvance(input: GuideAdvanceInput): number { - return displayAdvance(input.xAdvance, input.record.name, primaryUnicode(input.record)); -} - -function primaryUnicode(record: GlyphRecord): Unicode | null { - return record.unicodes[0] ?? null; -} - -/** - * Draws the active text run in scene space. - * - * @remarks - * Text layout, caret, selection rectangles, hover, and active run selection - * are text-owned dependencies. Keeping them here makes text rendering wake the - * scene without hiding text-specific subscriptions in the broader glyph scene - * layer. - */ -export class TextLayer extends CanvasItem { - readonly #editor: Editor; - readonly #textRuns = new TextRunDrawer(); - - /** - * Creates the text layer for one editor. - * - * @param editor - Editor session whose active text run is rendered. - */ - constructor(editor: Editor) { - super(); - this.#editor = editor; - } - - protected props(): TextLayerProps { - const run = this.#editor.textRuns.activeCell.value; - - track(run.layoutCell); - track(run.selectionRectsCell); - track(run.cursorVisibleCell); - track(run.caretCell); - track(run.interaction.hoveredIndexCell); - - return { - run, - designLocation: this.#editor.designLocationCell, - drawOffset: { x: 0, y: 0 }, - focusedGlyph: this.#editor.focusedGlyphCell.value, - }; - } - - draw(canvas: Canvas): void { - const props = this.propsCell.value; - if (!props) return; - - this.#textRuns.draw( - canvas, - props.run, - this.#editor.font, - props.designLocation, - props.drawOffset, - props.focusedGlyph, - ); - } -} - /** * Draws the main scene. * @@ -212,14 +134,13 @@ export class TextLayer extends CanvasItem { * 1. glyph outlines * 2. debug overlays * 3. active tool scene drawing - * 4. text runs - * 5. glyph handles, anchors, and control lines + * 4. glyph handles, anchors, and control lines * * Tool visuals that should appear below point handles, such as a pen preview * line, belong in the active tool scene hook. Tool visuals that must appear * above handles belong in the overlay layer. * - * Scene props group glyph display state, interaction state, and view state so + * Scene props group glyph scene state, interaction state, and view state so * render dependencies stay inspectable. Control lines are drawn as part of the * handle pass because handles and their control tethers are one visual unit. */ @@ -230,18 +151,16 @@ export class SceneLayer extends CanvasItem { readonly #controlLines = new ControlLines(); readonly #anchors = new Anchors(); readonly #handles: Handles; - readonly #textLayer: TextLayer; /** * Creates the scene layer for one editor. * - * @param editor - Editor session whose preview glyph, selection, hover, and text runs are rendered. + * @param editor - Editor session whose scene glyphs, selection, and hover are rendered. */ constructor(editor: Editor) { super(); this.#editor = editor; this.#handles = new Handles(); - this.#textLayer = new TextLayer(editor); } /** Attach the marker layer used by accelerated handle drawing. */ @@ -253,33 +172,27 @@ export class SceneLayer extends CanvasItem { this.#editor.camera.trackViewportTransform(); this.#editor.activeToolCell.value; this.#editor.activeToolStateCell.value; - - const scene = this.#editor.scene.cell.value; - const glyphs: SceneGlyphFrame[] = []; - for (const item of scene.items) { - if (item.kind !== "glyph") continue; - - const instance = this.#editor.instanceForItem(item.id); - if (instance) instance.render.trackShape(); - - glyphs.push({ - item, - record: this.#editor.glyphForItem(item.id), - instance, - geometryShown: scene.geometryItems.includes(item.id), - }); + this.#editor.selection.stateCell.value; + this.#editor.hover.entryCell.value; + + const glyphs: GlyphFrame[] = []; + for (const node of this.#editor.scene.cell.value.nodes) { + switch (node.kind) { + case "glyph": { + const frame = new GlyphFrame(this.#editor, node); + + frame.instance.render.trackShape(); + glyphs.push(frame); + break; + } + } } return { glyphs, - display: { - proofMode: this.#editor.proofModeCell.value, - handlesVisible: this.#editor.handlesVisibleCell.value, - focusedGlyphVisible: this.#editor.focusedGlyphVisibleCell.value, - }, interaction: { - selection: this.#editor.selection.stateCell.value, - hover: this.#editor.hover.targetCell.value, + selection: this.#editor.selection, + hover: this.#editor.hover, }, view: { debugOverlays: this.#editor.debugOverlaysCell.value, @@ -292,15 +205,13 @@ export class SceneLayer extends CanvasItem { if (!props) return; for (const glyph of props.glyphs) { - this.#drawGlyphOutline(canvas, props, glyph); + this.#drawGlyphOutline(canvas, glyph); this.#drawDebugOverlays(canvas, props, glyph); - if (glyph.geometryShown) { - canvas.withTranslation(glyph.item.placement.origin, () => { - this.#editor.toolManager.drawScene(canvas); - }); - } + canvas.withTranslation(glyph.node.position, () => { + this.#editor.toolManager.drawScene(canvas); + }); } - this.#drawTextRuns(canvas); + let drewHandles = false; for (const glyph of props.glyphs) { drewHandles = this.#drawGlyphEditHandles(canvas, props, glyph) || drewHandles; @@ -308,22 +219,10 @@ export class SceneLayer extends CanvasItem { if (!drewHandles) this.#handles.clear(); } - #drawGlyphOutline(canvas: Canvas, props: SceneLayerProps, glyph: SceneGlyphFrame): void { - const { record, instance, geometryShown } = glyph; - const { display } = props; - if (!record || !instance) return; - if (geometryShown && !display.focusedGlyphVisible) return; - - canvas.withTranslation(glyph.item.placement.origin, () => { - if (!geometryShown) { - this.#outline.draw(canvas, instance.render.outline, { - fill: canvas.theme.glyph.fill, - }); - return; - } - - this.#outline.draw(canvas, instance.render.outline, { - fill: display.proofMode ? canvas.theme.glyph.fill : null, + #drawGlyphOutline(canvas: Canvas, glyph: GlyphFrame): void { + canvas.withTranslation(glyph.node.position, () => { + this.#outline.draw(canvas, glyph.instance.render.outline, { + fill: null, stroke: { color: canvas.theme.glyph.stroke, widthPx: canvas.theme.glyph.widthPx, @@ -332,17 +231,14 @@ export class SceneLayer extends CanvasItem { }); } - #drawDebugOverlays(canvas: Canvas, props: SceneLayerProps, glyph: SceneGlyphFrame): void { - const { instance, geometryShown } = glyph; - const { display } = props; - if (!geometryShown || !instance || display.proofMode || !display.focusedGlyphVisible) return; + #drawDebugOverlays(canvas: Canvas, props: SceneLayerProps, glyph: GlyphFrame): void { const { hover } = props.interaction; - const hoveredSegmentId = hover?.type === "segment" ? hover.segmentId : null; + const hoveredSegmentId = hover.entry?.kind === "segment" ? hover.entry.segmentId : null; - canvas.withTranslation(glyph.item.placement.origin, () => { + canvas.withTranslation(glyph.node.position, () => { this.#debugOverlays.draw( canvas, - instance.geometry, + glyph.instance.geometry, props.view.debugOverlays, hoveredSegmentId, canvas.pxToUpm(SCREEN_HIT_RADIUS), @@ -350,26 +246,10 @@ export class SceneLayer extends CanvasItem { }); } - #drawTextRuns(canvas: Canvas): void { - this.#textLayer.draw(canvas); - } - - #drawGlyphEditHandles(canvas: Canvas, props: SceneLayerProps, glyph: SceneGlyphFrame): boolean { - const { instance, geometryShown } = glyph; - const { display } = props; - if ( - !geometryShown || - display.proofMode || - !display.handlesVisible || - !display.focusedGlyphVisible || - !instance - ) { - return false; - } - - const renderModel = instance.render; + #drawGlyphEditHandles(canvas: Canvas, props: SceneLayerProps, glyph: GlyphFrame): boolean { + const renderModel = glyph.instance.render; const sceneBounds = this.#editor.camera.visibleSceneBounds(64); - const origin = glyph.item.placement.origin; + const origin = glyph.node.position; canvas.withTranslation(origin, () => { this.#controlLines.draw(canvas, renderModel.contours, (from, to) => { @@ -390,7 +270,7 @@ export class SceneLayer extends CanvasItem { canvas, canvas.camera, origin, - instance, + glyph.instance, props.interaction.selection, props.interaction.hover, ); @@ -415,8 +295,8 @@ export class SceneLayer extends CanvasItem { * drag previews, cursor markers, and modal tool overlays. * * Overlay props track transient interaction state such as pointer, selection, - * hover, active tool, and glyph display mode. The renderer supplies a canvas - * whose context is already in glyph-local UPM coordinates for the current frame. + * hover, and active tool. The renderer supplies a canvas whose context is + * already in scene coordinates for the current frame. */ export class OverlayLayer extends CanvasItem { readonly #editor: Editor; diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Anchors.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Anchors.ts index f0b0ddbe..34604c63 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Anchors.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Anchors.ts @@ -15,9 +15,9 @@ export class Anchors { } #anchorState(anchor: GlyphRenderAnchor, source: HandleStateSource): HandleState { - if (source.selection.anchorIds.has(anchor.id)) return "selected"; + if (source.selection.has(anchor.id)) return "selected"; - if (source.hover?.type === "anchor" && source.hover.anchorId === anchor.id) return "hovered"; + if (source.hover.has(anchor.id)) return "hovered"; return "idle"; } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts index 8aba9c95..4069dd72 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Handles.ts @@ -2,8 +2,8 @@ import type { Point2D } from "@shift/geo"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; import type { CameraTransform } from "@/lib/editor/managers/Camera"; import type { GlyphInstance } from "@/lib/model/Glyph"; -import type { HoverState } from "@/lib/editor/Hover"; -import type { SelectionState } from "@/lib/editor/Selection"; +import type { Hover } from "@/lib/editor/Hover"; +import type { Selection } from "@/lib/editor/Selection"; import { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; import { HandleItems } from "./handles/HandleItems"; import { MarkerHandleRenderer } from "./handles/MarkerHandleRenderer"; @@ -32,8 +32,8 @@ export class Handles { camera: CameraTransform, drawOffset: Point2D, instance: GlyphInstance, - selection: SelectionState, - hover: HoverState, + selection: Selection, + hover: Hover, ): void { const list = this.#items.fromContours(instance.render.contours, { selection, diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/HandleItems.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/HandleItems.ts index 8fe3a32a..2e1b14d2 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/HandleItems.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/handles/HandleItems.ts @@ -1,13 +1,13 @@ import type { PointId } from "@shift/types"; import type { HandleState } from "@/types/graphics"; -import type { HoverState } from "@/lib/editor/Hover"; -import type { SelectionState } from "@/lib/editor/Selection"; +import type { Hover } from "@/lib/editor/Hover"; +import type { Selection } from "@/lib/editor/Selection"; import type { GlyphRenderContour } from "@/lib/model/GlyphRenderModel"; import { PointHandleItem } from "./PointHandleItem"; export interface HandleStateSource { - readonly selection: SelectionState; - readonly hover: HoverState; + readonly selection: Selection; + readonly hover: Hover; } export class HandleDisplayList { @@ -62,9 +62,9 @@ export class HandleItems { } #state(pointId: PointId, source: HandleStateSource): HandleState { - if (source.selection.pointIds.has(pointId)) return "selected"; + if (source.selection.has(pointId)) return "selected"; - if (source.hover?.type === "point" && source.hover.pointId === pointId) return "hovered"; + if (source.hover.has(pointId)) return "hovered"; return "idle"; } diff --git a/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.ts b/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.ts index 19662766..17f097e1 100644 --- a/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.ts +++ b/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.ts @@ -26,7 +26,7 @@ export class KeyboardRouter { #textKeyDown: KeyBinding[]; #canvasKeyDown: KeyBinding[]; #globalKeyUp: KeyBinding[]; - #spaceProofModeBeforeTemporary: boolean | null = null; + #temporaryHandActive = false; constructor(getContext: () => KeyContext) { this.#getContext = getContext; @@ -103,32 +103,22 @@ export class KeyboardRouter { } #activateTemporaryHand(ctx: KeyContext): boolean { - if (this.#spaceProofModeBeforeTemporary !== null) { + if (this.#temporaryHandActive) { return true; } - const wasProofMode = ctx.editor.proofMode; - this.#spaceProofModeBeforeTemporary = wasProofMode; - ctx.editor.requestTemporaryTool("hand", { - onActivate: () => { - ctx.editor.setProofMode(true); - }, - onReturn: () => { - const restore = this.#spaceProofModeBeforeTemporary ?? wasProofMode; - ctx.editor.setProofMode(restore); - this.#spaceProofModeBeforeTemporary = null; - }, - }); + this.#temporaryHandActive = true; + ctx.editor.requestTemporaryTool("hand", {}); return true; } #releaseTemporaryHand(ctx: KeyContext): boolean { - if (this.#spaceProofModeBeforeTemporary === null) { + if (!this.#temporaryHandActive) { return false; } ctx.editor.returnFromTemporaryTool(); - this.#spaceProofModeBeforeTemporary = null; + this.#temporaryHandActive = false; return true; } } diff --git a/apps/desktop/src/renderer/src/lib/keyboard/keymaps.ts b/apps/desktop/src/renderer/src/lib/keyboard/keymaps.ts index 0df3c5ed..00cef24a 100644 --- a/apps/desktop/src/renderer/src/lib/keyboard/keymaps.ts +++ b/apps/desktop/src/renderer/src/lib/keyboard/keymaps.ts @@ -136,8 +136,7 @@ export function createCanvasKeyDownBindings(handlers: KeymapHandlers): KeyBindin preventDefault: true, when: (ctx) => ctx.activeTool !== "text", match: (event) => event.key === "Delete" || event.key === "Backspace", - run: (ctx) => { - ctx.editor.deleteSelectedPoints(); + run: () => { return true; }, }, diff --git a/apps/desktop/src/renderer/src/lib/keyboard/types.ts b/apps/desktop/src/renderer/src/lib/keyboard/types.ts index a703d0ca..93a671ea 100644 --- a/apps/desktop/src/renderer/src/lib/keyboard/types.ts +++ b/apps/desktop/src/renderer/src/lib/keyboard/types.ts @@ -10,7 +10,6 @@ export interface KeyboardEditorActions { undo(): void; redo(): void; selectAll(): void; - deleteSelectedPoints(): void; setActiveTool(toolName: ToolName): void; getToolShortcuts(): ToolShortcutEntry[]; requestTemporaryTool( @@ -18,8 +17,6 @@ export interface KeyboardEditorActions { options?: { onActivate?: () => void; onReturn?: () => void }, ): void; returnFromTemporaryTool(): void; - readonly proofMode: boolean; - setProofMode(enabled: boolean): void; openGlyphFinder(): void; } diff --git a/apps/desktop/src/renderer/src/lib/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index 6959c174..6b34dc93 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -64,15 +64,15 @@ describe("Font projects the workspace snapshot", () => { expect(font.sources.map((source) => source.name)).toEqual(["Regular"]); }); - it("loadedCell flips reactively when the snapshot changes", () => { + it("$loaded flips reactively when the snapshot changes", () => { const store = new FontStore(); const font = new Font(store); - expect(font.loadedCell.value).toBe(false); + expect(font.$loaded.value).toBe(false); store.replaceWorkspace(SNAPSHOT); - expect(font.loadedCell.value).toBe(true); + expect(font.$loaded.value).toBe(true); }); it("resets to the unloaded projection when the workspace goes null", () => { @@ -111,11 +111,7 @@ describe("font-level intents make the font variable", () => { await stack.editCoordinator.apply([ { kind: "createGlyph", - createGlyph: { - glyphId, - name: "A" as GlyphName, - unicodes: [65 as Unicode], - }, + createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, }, ]); expect(stack.font.isVariable()).toBe(false); @@ -145,9 +141,7 @@ describe("font-level intents make the font variable", () => { createSource: { sourceId: boldSourceId, name: "Bold", - location: { - values: { [weightAxisId]: 700 } as Record, - }, + location: { values: { [weightAxisId]: 700 } as Record }, }, }, ]); @@ -155,9 +149,7 @@ describe("font-level intents make the font variable", () => { expect(bold?.id).toBe(boldSourceId); expect(applied.sources?.find((source) => source.name === "Bold")?.id).toBe(boldSourceId); expect(applied.layers).toEqual([]); - expect(stack.font.glyph(glyphId)?.layers.find((layer) => layer.sourceId === boldSourceId)).toBe( - undefined, - ); + expect(stack.font.layerRecordForId(glyphId, boldSourceId)).toBeNull(); }); it("createGlyphLayer projects sparse glyph-layer membership", async () => { @@ -167,11 +159,7 @@ describe("font-level intents make the font variable", () => { await stack.editCoordinator.apply([ { kind: "createGlyph", - createGlyph: { - glyphId, - name: "A" as GlyphName, - unicodes: [65 as Unicode], - }, + createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, }, ]); @@ -185,7 +173,7 @@ describe("font-level intents make the font variable", () => { ]); expect(applied.glyphs?.[0]?.layers).toEqual([{ id: layerId, sourceId }]); - expect(stack.font.glyph(glyphId)?.layers).toEqual([{ id: layerId, sourceId }]); + expect(stack.font.layerRecordForId(glyphId, sourceId)).toEqual({ id: layerId, sourceId }); }); it("createGlyph authors a default layer for fresh glyphs", async () => { @@ -200,26 +188,21 @@ describe("font-level intents make the font variable", () => { await stack.editCoordinator.settled(); - const committed = stack.font.glyph(record.id); + const committed = stack.font.recordForId(record.id); expect(committed?.layers).toEqual(record.layers); - expect( - stack.font.glyph(record.id)?.layers.find((layer) => layer.sourceId === source.id), - ).toEqual(record.layers[0]); + expect(stack.font.layerRecordForId(record.id, source.id)).toEqual(record.layers[0]); - const loaded = await stack.font.loadGlyph(record.id, { - sourceIds: [source.id], - }); - expect(loaded).toBe(true); - expect(stack.font.layer(record.id, source.id)?.xAdvance).toBe(stack.font.defaultXAdvance); + const glyph = await stack.font.loadGlyph(record.id, { sourceIds: [source.id] }); + expect(glyph.xAdvance).toBe(stack.font.defaultXAdvance); }); - it("resolves updated glyph identity after record names change", async () => { + it("preserves glyph object identity while record names change", async () => { const stack = createWorkspaceStack(); await stack.createWorkspace(); const record = stack.font.createGlyph("A" as GlyphName); await stack.editCoordinator.settled(); - expect(await stack.font.loadGlyph(record.id)).toBe(true); + const glyph = await stack.font.loadGlyph(record.id); await stack.editCoordinator.apply([ { @@ -232,9 +215,9 @@ describe("font-level intents make the font variable", () => { }, ]); - expect(stack.font.instance(record.id, stack.font.defaultLocation())).not.toBeNull(); - expect(stack.font.glyph(record.id)?.name).toBe("A.alt"); - expect(stack.font.glyph(record.id)?.unicodes).toEqual([0xe001]); + expect(stack.font.glyphForId(record.id)).toBe(glyph); + expect(glyph.name).toBe("A.alt"); + expect(glyph.unicode).toBe(0xe001); }); it("exact sources without glyph layers have no live layer and do not render default geometry", async () => { @@ -245,11 +228,7 @@ describe("font-level intents make the font variable", () => { await stack.editCoordinator.apply([ { kind: "createGlyph", - createGlyph: { - glyphId, - name: "A" as GlyphName, - unicodes: [65 as Unicode], - }, + createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, }, { kind: "createGlyphLayer", @@ -259,10 +238,7 @@ describe("font-level intents make the font variable", () => { sourceId: stack.font.defaultSource.id, }, }, - { - kind: "setXAdvance", - setXAdvance: { layerId: defaultLayerId, width: 640 }, - }, + { kind: "setXAdvance", setXAdvance: { layerId: defaultLayerId, width: 640 } }, ]); const axisId = mintAxisId(); @@ -292,19 +268,17 @@ describe("font-level intents make the font variable", () => { }, ]); - const loaded = await stack.font.loadGlyph(glyphId, { + const glyph = await stack.font.loadGlyph(glyphId, { sourceIds: [stack.font.defaultSource.id], }); - expect(loaded).toBe(true); - expect(stack.font.layer(glyphId, stack.font.defaultSource.id)?.xAdvance).toBe(640); + expect(glyph.xAdvance).toBe(640); const bold = stack.font.source(sourceId); if (!bold) throw new Error("Expected created source"); - const boldLocation = axisLocationFromLocation(bold.location); - const instance = stack.font.instance(glyphId, boldLocation); - if (!instance) throw new Error("Expected glyph instance"); + const instance = glyph.instanceAt(axisLocationFromLocation(bold.location)); - expect(stack.font.editableLayerAt(glyphId, boldLocation)).toBeNull(); + expect(instance.layer).toBeNull(); + expect(instance.hasLayer).toBe(false); expect(instance.xAdvance).toBe(0); expect(instance.geometry.allPoints).toEqual([]); }); @@ -318,19 +292,11 @@ describe("font-level intents make the font variable", () => { await stack.editCoordinator.apply([ { kind: "createGlyph", - createGlyph: { - glyphId, - name: "A" as GlyphName, - unicodes: [65 as Unicode], - }, + createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, }, { kind: "createGlyphLayer", - createGlyphLayer: { - layerId: defaultLayerId, - glyphId, - sourceId: defaultSourceId, - }, + createGlyphLayer: { layerId: defaultLayerId, glyphId, sourceId: defaultSourceId }, }, ]); @@ -362,11 +328,7 @@ describe("font-level intents make the font variable", () => { }, { kind: "createGlyphLayer", - createGlyphLayer: { - layerId: boldLayerId, - glyphId, - sourceId: boldSourceId, - }, + createGlyphLayer: { layerId: boldLayerId, glyphId, sourceId: boldSourceId }, }, ]); @@ -374,15 +336,31 @@ describe("font-level intents make the font variable", () => { const boldSource = stack.font.source(boldSourceId); if (!regularSource || !boldSource) throw new Error("Expected both sources"); - const regularLoad = stack.font.loadGlyph(glyphId, { - sourceIds: [defaultSourceId], - }); - const boldLoad = stack.font.loadGlyph(glyphId, { - sourceIds: [boldSourceId], - }); + const regularLoad = stack.font.loadGlyph(glyphId, { sourceIds: [defaultSourceId] }); + const boldLoad = stack.font.loadGlyph(glyphId, { sourceIds: [boldSourceId] }); await Promise.all([regularLoad, boldLoad]); - expect(stack.font.layer(glyphId, regularSource.id)?.id).toBe(defaultLayerId); - expect(stack.font.layer(glyphId, boldSource.id)?.id).toBe(boldLayerId); + expect(stack.font.glyphLayerForId(glyphId, regularSource.id)?.id).toBe(defaultLayerId); + expect(stack.font.glyphLayerForId(glyphId, boldSource.id)?.id).toBe(boldLayerId); + }); + + it("rejects glyph loads for ids outside the current font", async () => { + const stack = createWorkspaceStack(); + await stack.createWorkspace(); + + await expect(stack.font.loadGlyph(mintGlyphId())).rejects.toThrow("is not in the current font"); + }); + + it("loads glyph batches in request order", async () => { + const stack = createWorkspaceStack(); + await stack.createWorkspace(); + + const a = stack.font.createGlyph("A" as GlyphName); + const b = stack.font.createGlyph("B" as GlyphName); + await stack.editCoordinator.settled(); + + const glyphs = await stack.font.loadGlyphs([b.id, a.id, b.id]); + + expect(glyphs.map((glyph) => glyph.id)).toEqual([b.id, a.id, b.id]); }); }); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 7cbb8050..f9022684 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -864,41 +864,54 @@ export class Font { } /** - * Loads one glyph's geometry. + * Loads one current-font glyph and returns its live model. * - * @param glyphId - Stable glyph identity whose local geometry should be available. - * @param options - Optional source scope; omitted means every authored layer for the glyph. - * @returns `true` when the glyph exists and is locally available after the load. + * @param glyphId - Current-font glyph identity whose local model should be available. + * @param options - Optional additional source scope; the default source is always loaded. + * @returns The loaded glyph model for `glyphId`. + * @throws {Error} when `glyphId` is not a current-font glyph or its model cannot be loaded. * @see {@link loadGlyphs} */ - async loadGlyph(glyphId: GlyphId, options: GlyphLoadOptions = {}): Promise { - return (await this.loadGlyphs([glyphId], options)).has(glyphId); + async loadGlyph(glyphId: GlyphId, options: GlyphLoadOptions = {}): Promise { + const glyph = (await this.loadGlyphs([glyphId], options))[0]; + if (!glyph) throw new Error(`current-font glyph ${glyphId} did not load`); + return glyph; } /** - * Requests local glyph geometry and returns the requested glyph ids that are available. + * Loads current-font glyphs and returns their live models in request order. * * @remarks - * Component bases discovered while reading snapshots are requested as a side - * effect, but the returned set is keyed only by the glyph IDs requested by the - * caller. + * Component bases discovered while loading are hydrated as a side effect, but + * the returned list contains only the glyph IDs requested by the caller. * - * @param glyphIds - Stable glyph identities whose local geometry should be available. - * @param options - Optional source scope; omitted means every authored layer for each glyph. - * @returns A fresh set containing requested glyph ids that exist in the current font. + * @param glyphIds - Current-font glyph identities whose local models should be available. + * @param options - Optional additional source scope; default sources are always loaded. + * @returns Loaded glyph models aligned with `glyphIds`, including duplicate requests. + * @throws {Error} when any requested glyph ID is not in the current font or cannot load. */ async loadGlyphs( glyphIds: readonly GlyphId[], options: GlyphLoadOptions = {}, - ): Promise> { - await this.#loadGlyphSnapshots(glyphIds, options); + ): Promise { + const uniqueIds = uniqueGlyphIds(glyphIds); + for (const glyphId of uniqueIds) { + if (!this.#store.recordForId(glyphId)) { + throw new Error(`glyph ${glyphId} is not in the current font`); + } + } + await this.#loadGlyphSnapshots(uniqueIds, options); - const loaded = new Set(); - for (const glyphId of uniqueGlyphIds(glyphIds)) { + const glyphs = new Map(); + for (const glyphId of uniqueIds) { const glyph = this.#glyphModel(glyphId); - if (glyph) loaded.add(glyphId); + if (!glyph) { + throw new Error(`current-font glyph ${glyphId} did not load`); + } + glyphs.set(glyphId, glyph); } - return loaded; + + return glyphIds.map((glyphId) => glyphs.get(glyphId)!); } async #loadGlyphSnapshots( @@ -951,7 +964,7 @@ export class Font { } #neededSourceIds(glyphId: GlyphId, options: GlyphLoadOptions): SourceId[] { - const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); + const sourceIds = this.#sourceIdsForGlyphLoad(glyphId, options); const needed: SourceId[] = []; const seen = new Set(); @@ -969,7 +982,7 @@ export class Font { const seen = new Set>(); for (const glyphId of uniqueGlyphIds(glyphIds)) { - const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); + const sourceIds = this.#sourceIdsForGlyphLoad(glyphId, options); for (const sourceId of sourceIds) { const promise = this.#glyphLoadsInFlight.get(inFlightKey(glyphId, sourceId)); if (!promise || seen.has(promise)) continue; @@ -981,6 +994,11 @@ export class Font { return promises; } + #sourceIdsForGlyphLoad(glyphId: GlyphId, options: GlyphLoadOptions): readonly SourceId[] { + const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); + return uniqueSourceIds([...sourceIds, this.defaultSource.id]); + } + async #loadGlyphRequests(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { const promise = this.#readAndApplyGlyphRequests(requests); @@ -1280,3 +1298,7 @@ function isSignal(value: T | Signal): value is Signal { function uniqueGlyphIds(glyphIds: readonly GlyphId[]): GlyphId[] { return [...new Set(glyphIds)]; } + +function uniqueSourceIds(sourceIds: readonly SourceId[]): SourceId[] { + return [...new Set(sourceIds)]; +} diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts index 4452f377..65b53ee9 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts @@ -65,10 +65,10 @@ describe("Glyph", () => { beforeEach(async () => { editor = new TestEditor(); await editor.startSession(); - layer = editor.editingGlyphLayer!; - const previewRecord = editor.previewGlyphRecordCell.peek(); - if (!previewRecord) throw new Error("Expected preview glyph record"); - record = previewRecord; + layer = editor.glyphLayer!; + const glyphRecord = editor.glyphRecord; + if (!glyphRecord) throw new Error("Expected scene glyph record"); + record = glyphRecord; }); it("loads identity and state from the workspace", () => { @@ -90,7 +90,7 @@ describe("Glyph", () => { it("updates positions synchronously and keeps them after the echo folds", async () => { const [first] = await addTriangle(editor, layer); - const instance = editor.previewGlyphInstance; + const instance = editor.sceneGlyphInstance; if (!instance) throw new Error("Expected glyph instance"); layer.applyPositionPatch([{ kind: "point", id: first!.id, x: 25, y: 75 }]); @@ -123,7 +123,7 @@ describe("anchors edit through the workspace", () => { beforeEach(async () => { editor = new TestEditor(); await editor.startSession(); - layer = editor.editingGlyphLayer!; + layer = editor.glyphLayer!; }); it("addAnchor echoes a named anchor into confirmed geometry", async () => { @@ -198,10 +198,10 @@ describe("glyph layers keep public geometry coherent across position edits", () beforeEach(async () => { editor = new TestEditor(); await editor.startSession(); - layer = editor.editingGlyphLayer!; - const previewRecord = editor.previewGlyphRecordCell.peek(); - if (!previewRecord) throw new Error("Expected preview glyph record"); - record = previewRecord; + layer = editor.glyphLayer!; + const glyphRecord = editor.glyphRecord; + if (!glyphRecord) throw new Error("Expected scene glyph record"); + record = glyphRecord; }); it("previews point patches through every public layer geometry view", async () => { @@ -216,7 +216,7 @@ describe("glyph layers keep public geometry coherent across position edits", () x: 25, y: 75, }); - expect(layer.bounds).toEqual(editor.previewGlyphInstance?.geometry.bounds); + expect(layer.bounds).toEqual(editor.sceneGlyphInstance?.geometry.bounds); }); it("applies committed point patches to the source and owning glyph geometry", async () => { @@ -227,7 +227,7 @@ describe("glyph layers keep public geometry coherent across position edits", () expect(sourcePosition(layer, second!.id)).toEqual({ x: 25, y: 75 }); expect(pointPosition(layer, second!.id)).toEqual({ x: 25, y: 75 }); - expect(editor.previewGlyphInstance?.geometry.point(second!.id)).toMatchObject({ x: 25, y: 75 }); + expect(editor.sceneGlyphInstance?.geometry.point(second!.id)).toMatchObject({ x: 25, y: 75 }); }); it("commits a preview without stale geometry or double-applying local positions", async () => { diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index a415308c..117c14a8 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -30,6 +30,7 @@ import { type GeometryAnchorHit, type GeometryPointHit, type GeometrySegmentHit, + type GlyphHit, Segment, type SegmentId, type GlyphPosition as GlyphLayerPosition, @@ -103,12 +104,14 @@ export interface GlyphInstanceGeometry { readonly bounds: BoundsType | null; readonly contours: readonly Contour[]; readonly allPoints: readonly Point[]; + contour(contourId: ContourId): Contour | null; point(pointId: PointId): Point | null; anchor(anchorId: AnchorId): Anchor | null; segment(segmentId: SegmentId): Segment | null; hitPoint(pos: Point2D, radius: number): GeometryPointHit | null; hitAnchor(pos: Point2D, radius: number): GeometryAnchorHit | null; + hitAt(pos: Point2D, radius: number): GlyphHit | null; hitSegment(pos: Point2D, radius: number): GeometrySegmentHit | null; } @@ -949,6 +952,10 @@ class InstanceGeometry implements GlyphInstanceGeometry { return this.#resolved.peek().hitAnchor(pos, radius); } + hitAt(pos: Point2D, radius: number): GlyphHit | null { + return this.#resolved.peek().hitAt(pos, radius); + } + hitSegment(pos: Point2D, radius: number): GeometrySegmentHit | null { return this.#resolved.peek().hitSegment(pos, radius); } @@ -1021,6 +1028,10 @@ class SnapshotGeometryCache implements GlyphInstanceGeometry { return this.#geometry.hitAnchor(pos, radius); } + hitAt(pos: Point2D, radius: number): GlyphHit | null { + return this.#geometry.hitAt(pos, radius); + } + hitSegment(pos: Point2D, radius: number): GeometrySegmentHit | null { return this.#geometry.hitSegment(pos, radius); } @@ -1135,7 +1146,7 @@ class SourceGeometryCache implements GlyphInstanceGeometry { for (const point of contour.pointsCell.peek()) { const hit = Point.hit(point, pos, radius); if (hit && (!best || hit.distance < best.distance)) { - best = { type: "point", pointId: point.id, distance: hit.distance }; + best = { kind: "point", id: point.id, distance: hit.distance }; } } } @@ -1147,12 +1158,18 @@ class SourceGeometryCache implements GlyphInstanceGeometry { for (const anchor of this.#anchors.all) { const hit = anchor.hit(pos, radius); if (hit && (!best || hit.distance < best.distance)) { - best = { type: "anchor", anchorId: anchor.id, distance: hit.distance }; + best = { kind: "anchor", id: anchor.id, distance: hit.distance }; } } return best; } + hitAt(pos: Point2D, radius: number): GlyphHit | null { + return ( + this.hitAnchor(pos, radius) ?? this.hitPoint(pos, radius) ?? this.hitSegment(pos, radius) + ); + } + hitSegment(pos: Point2D, radius: number): GeometrySegmentHit | null { let best: GeometrySegmentHit | null = null; @@ -1162,8 +1179,8 @@ class SourceGeometryCache implements GlyphInstanceGeometry { if (hit && (!best || hit.distance < best.distance)) { best = { - type: "segment", - segmentId: segment.id, + kind: "segment", + id: segment.id, t: hit.t, closestPoint: hit.closestPoint, distance: hit.distance, diff --git a/apps/desktop/src/renderer/src/lib/model/variation.test.ts b/apps/desktop/src/renderer/src/lib/model/variation.test.ts index 421f4eaf..18f767da 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -47,10 +47,7 @@ async function drawSquare(stack: WorkspaceStack, layerId: LayerId, width: number })), }, }, - { - kind: "setContourClosed", - setContourClosed: { layerId, contourId, closed: true }, - }, + { kind: "setContourClosed", setContourClosed: { layerId, contourId, closed: true } }, ]); } @@ -69,11 +66,7 @@ async function variableFont(): Promise<{ const created = await stack.editCoordinator.apply([ { kind: "createGlyph", - createGlyph: { - glyphId, - name: "A" as GlyphName, - unicodes: [65 as Unicode], - }, + createGlyph: { glyphId, name: "A" as GlyphName, unicodes: [65 as Unicode] }, }, { kind: "createGlyphLayer", @@ -123,11 +116,7 @@ async function variableFont(): Promise<{ await stack.editCoordinator.apply([ { kind: "createGlyphLayer", - createGlyphLayer: { - layerId: boldLayerId, - glyphId, - sourceId: boldSourceId, - }, + createGlyphLayer: { layerId: boldLayerId, glyphId, sourceId: boldSourceId }, }, ]); @@ -136,10 +125,7 @@ async function variableFont(): Promise<{ await drawSquare(stack, regularLayerId, 100); await drawSquare(stack, boldLayerId, 200); await stack.editCoordinator.apply([ - { - kind: "setXAdvance", - setXAdvance: { layerId: regularLayerId, width: 300 }, - }, + { kind: "setXAdvance", setXAdvance: { layerId: regularLayerId, width: 300 } }, ]); await stack.editCoordinator.apply([ { kind: "setXAdvance", setXAdvance: { layerId: boldLayerId, width: 500 } }, @@ -149,17 +135,17 @@ async function variableFont(): Promise<{ } async function loadGlyph(stack: WorkspaceStack, glyphId: GlyphId) { - const loaded = await stack.font.loadGlyph(glyphId, { + const glyph = await stack.font.loadGlyph(glyphId, { sourceIds: [stack.font.defaultSource.id], }); - if (!loaded) throw new Error("Expected default glyph layer to load"); + return glyph; } async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: Source) { await stack.font.loadGlyph(glyphId, { sourceIds: [stack.font.defaultSource.id, source.id], }); - const layer = stack.font.layer(glyphId, source.id); + const layer = stack.font.glyphLayerForId(glyphId, source.id); if (!layer) throw new Error("Expected glyph layer to load"); return layer; } @@ -195,15 +181,14 @@ describe("variable editing across sources", () => { }); it("interpolates geometry and metrics between masters", async () => { - await loadGlyph(stack, glyphId); + const glyph = await loadGlyph(stack, glyphId); const axis = stack.font.getAxes()[0]!; // wght 550 is halfway between the masters at 400 and 700. const mid = withAxisValue(defaultAxisLocation(stack.font.getAxes()), axis, 550); - const instance = stack.font.instance(glyphId, mid); - if (!instance) throw new Error("Expected glyph instance"); + const instance = glyph.instanceAt(mid); - expect(stack.font.editableLayerAt(glyphId, mid)).toBeNull(); + expect(instance.hasLayer).toBe(false); expect(instance.xAdvance).toBeCloseTo(300 + (500 - 300) * 0.5); const xs = instance.geometry.allPoints.map((point) => point.x); @@ -211,34 +196,15 @@ describe("variable editing across sources", () => { }); it("resolves live layer geometry at exact master locations", async () => { - await loadGlyph(stack, glyphId); + const glyph = await loadGlyph(stack, glyphId); await loadGlyphLayer(stack, glyphId, bold); const axis = stack.font.getAxes()[0]!; const atBold = withAxisValue(defaultAxisLocation(stack.font.getAxes()), axis, 700); - const instance = stack.font.instance(glyphId, atBold); - if (!instance) throw new Error("Expected glyph instance"); - - expect(stack.font.editableLayerAt(glyphId, atBold)).not.toBeNull(); - expect(instance.xAdvance).toBe(500); - }); - - it("refreshes an existing instance when its exact source layer loads", async () => { - await loadGlyph(stack, glyphId); - - const axis = stack.font.getAxes()[0]!; - const atBold = withAxisValue(defaultAxisLocation(stack.font.getAxes()), axis, 700); - const instance = stack.font.instance(glyphId, atBold); - if (!instance) throw new Error("Expected glyph instance"); - - expect(stack.font.editableLayerAt(glyphId, atBold)).toBeNull(); - expect(instance.xAdvance).toBe(0); - expect(instance.render.outline.bounds).toBeNull(); - - await loadGlyphLayer(stack, glyphId, bold); + const instance = glyph.instanceAt(atBold); - expect(stack.font.editableLayerAt(glyphId, atBold)).not.toBeNull(); + expect(instance.hasLayer).toBe(true); + expect(instance.hasLayer).toBe(true); expect(instance.xAdvance).toBe(500); - expect(instance.render.outline.bounds?.max.x).toBe(200); }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts b/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts index 9c369429..43da3744 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/Behavior.ts @@ -14,7 +14,18 @@ * @module */ import type { Editor } from "@/lib/editor/Editor"; -import type { ToolEvent, ToolEventOf } from "./GestureDetector"; +import type { + ClickEvent, + DoubleClickEvent, + DragCancelEvent, + DragEndEvent, + DragEvent, + DragStartEvent, + KeyDownEvent, + KeyUpEvent, + PointerMoveEvent, + ToolEvent, +} from "./GestureDetector"; export interface ToolContext { readonly editor: Editor; @@ -34,15 +45,15 @@ export interface ToolContext { */ export interface Behavior { // New explicit event handlers - onPointerMove?(state: S, ctx: ToolContext, event: ToolEventOf<"pointerMove">): boolean; - onClick?(state: S, ctx: ToolContext, event: ToolEventOf<"click">): boolean; - onDoubleClick?(state: S, ctx: ToolContext, event: ToolEventOf<"doubleClick">): boolean; - onDragStart?(state: S, ctx: ToolContext, event: ToolEventOf<"dragStart">): boolean; - onDrag?(state: S, ctx: ToolContext, event: ToolEventOf<"drag">): boolean; - onDragEnd?(state: S, ctx: ToolContext, event: ToolEventOf<"dragEnd">): boolean; - onDragCancel?(state: S, ctx: ToolContext, event: ToolEventOf<"dragCancel">): boolean; - onKeyDown?(state: S, ctx: ToolContext, event: ToolEventOf<"keyDown">): boolean; - onKeyUp?(state: S, ctx: ToolContext, event: ToolEventOf<"keyUp">): boolean; + onPointerMove?(state: S, ctx: ToolContext, event: PointerMoveEvent): boolean; + onClick?(state: S, ctx: ToolContext, event: ClickEvent): boolean; + onDoubleClick?(state: S, ctx: ToolContext, event: DoubleClickEvent): boolean; + onDragStart?(state: S, ctx: ToolContext, event: DragStartEvent): boolean; + onDrag?(state: S, ctx: ToolContext, event: DragEvent): boolean; + onDragEnd?(state: S, ctx: ToolContext, event: DragEndEvent): boolean; + onDragCancel?(state: S, ctx: ToolContext, event: DragCancelEvent): boolean; + onKeyDown?(state: S, ctx: ToolContext, event: KeyDownEvent): boolean; + onKeyUp?(state: S, ctx: ToolContext, event: KeyUpEvent): boolean; onStateExit?(prev: S, next: S, ctx: ToolContext, event: ToolEvent): void; onStateEnter?(prev: S, next: S, ctx: ToolContext, event: ToolEvent): void; } diff --git a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts index b9842869..9e411d63 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.ts @@ -11,8 +11,9 @@ * * @module */ -import type { Point2D } from "@shift/geo"; +import { Vec2, type Point2D } from "@shift/geo"; import type { Coordinates } from "@/types/coordinates"; +import type { PointerTarget } from "@/types/target"; /** Well-known key names that tools handle directly. */ export type ToolKey = "Escape" | "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" | "Backspace"; @@ -20,61 +21,105 @@ export type ToolKey = "Escape" | "ArrowLeft" | "ArrowRight" | "ArrowUp" | "Arrow /** * Discriminated union of all events a tool can receive. * - * Pointer events include `coords` (screen + scene + glyphLocal) so tools use - * `event.coords` for hit-test and layout. `point` is kept as `coords.scene`. - * Drag events include cumulative `delta`/`screenDelta` from the drag origin. + * Pointer events include `coords` so tools use the captured event coordinate + * snapshot for hit-test and layout. Drag events include `origin` and `delta` + * with both screen and scene coordinates. */ +export interface ModifierKeys { + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly accelKey: boolean; +} + +interface PointerGestureInfo extends ModifierKeys { + readonly coords: Coordinates; +} + +export interface PointerDelta { + readonly screen: Point2D; + readonly scene: Point2D; +} + +type PointerMoveGestureEvent = PointerGestureInfo & { + readonly type: "pointerMove"; +}; + +type ClickGestureEvent = PointerGestureInfo & { + readonly type: "click"; +}; + +type DoubleClickGestureEvent = PointerGestureInfo & { + readonly type: "doubleClick"; +}; + +type DragStartGestureEvent = PointerGestureInfo & { + readonly type: "dragStart"; + readonly origin: Coordinates; + readonly delta: PointerDelta; +}; + +type DragGestureEvent = PointerGestureInfo & { + readonly type: "drag"; + readonly origin: Coordinates; + readonly delta: PointerDelta; +}; + +type DragEndGestureEvent = PointerGestureInfo & { + readonly type: "dragEnd"; + readonly origin: Coordinates; + readonly delta: PointerDelta; +}; + +export type DragCancelEvent = { type: "dragCancel" }; + +export type KeyDownEvent = ModifierKeys & { + readonly type: "keyDown"; + readonly key: ToolKey | (string & {}); +}; + +export type KeyUpEvent = ModifierKeys & { + readonly type: "keyUp"; + readonly key: ToolKey | (string & {}); +}; + +export type SelectionChangedEvent = { type: "selectionChanged" }; + +export type GestureEvent = + | PointerMoveGestureEvent + | ClickGestureEvent + | DoubleClickGestureEvent + | DragStartGestureEvent + | DragGestureEvent + | DragEndGestureEvent + | DragCancelEvent + | KeyDownEvent + | KeyUpEvent + | SelectionChangedEvent; + +type WithTarget = TEvent & { + readonly target: PointerTarget; +}; + +export type PointerMoveEvent = WithTarget; +export type ClickEvent = WithTarget; +export type DoubleClickEvent = WithTarget; +export type DragStartEvent = WithTarget; +export type DragEvent = WithTarget; +export type DragEndEvent = WithTarget; + export type ToolEvent = - | { type: "pointerMove"; point: Point2D; coords: Coordinates } - | { - type: "click"; - point: Point2D; - coords: Coordinates; - shiftKey: boolean; - altKey: boolean; - metaKey?: boolean; - } - | { type: "doubleClick"; point: Point2D; coords: Coordinates; metaKey?: boolean } - | { - type: "dragStart"; - point: Point2D; - coords: Coordinates; - screenPoint: Point2D; - shiftKey: boolean; - altKey: boolean; - metaKey?: boolean; - } - | { - type: "drag"; - point: Point2D; - coords: Coordinates; - screenPoint: Point2D; - origin: Point2D; - screenOrigin: Point2D; - delta: Point2D; - screenDelta: Point2D; - shiftKey: boolean; - altKey: boolean; - metaKey?: boolean; - } - | { - type: "dragEnd"; - point: Point2D; - coords: Coordinates; - screenPoint: Point2D; - origin: Point2D; - screenOrigin: Point2D; - } - | { type: "dragCancel" } - | { - type: "keyDown"; - key: ToolKey | (string & {}); - shiftKey: boolean; - altKey: boolean; - metaKey: boolean; - } - | { type: "keyUp"; key: ToolKey | (string & {}) } - | { type: "selectionChanged" }; + | PointerMoveEvent + | ClickEvent + | DoubleClickEvent + | DragStartEvent + | DragEvent + | DragEndEvent + | DragCancelEvent + | KeyDownEvent + | KeyUpEvent + | SelectionChangedEvent; /** * Modifier key state captured at the moment of a pointer or key event. @@ -85,6 +130,21 @@ export interface Modifiers { shiftKey: boolean; altKey: boolean; metaKey?: boolean; + ctrlKey?: boolean; + accelKey?: boolean; +} + +export function normalizeModifiers(modifiers: Modifiers): ModifierKeys { + const metaKey = modifiers.metaKey ?? false; + const ctrlKey = modifiers.ctrlKey ?? false; + + return { + shiftKey: modifiers.shiftKey, + altKey: modifiers.altKey, + metaKey, + ctrlKey, + accelKey: modifiers.accelKey ?? (metaKey || ctrlKey), + }; } /** @@ -100,8 +160,6 @@ export interface GestureDetectorConfig { doubleClickDistance?: number; } -export type ToolEventOf = Extract; - const DEFAULT_CONFIG: Required = { dragThreshold: 3, doubleClickTime: 300, @@ -117,10 +175,8 @@ const DEFAULT_CONFIG: Required = { * the drag threshold, and times double-clicks. */ export class GestureDetector { - private downPoint: Point2D | null = null; private downCoords: Coordinates | null = null; - private downScreenPoint: Point2D | null = null; - private downModifiers: Modifiers | null = null; + private downModifiers: ModifierKeys | null = null; private dragging = false; private lastClickTime = 0; private lastClickPoint: Point2D | null = null; @@ -140,11 +196,9 @@ export class GestureDetector { return this.dragging; } - pointerDown(coords: Coordinates, screenPoint: Point2D, modifiers: Modifiers): void { - this.downPoint = coords.scene; + pointerDown(coords: Coordinates, modifiers: Modifiers): void { this.downCoords = coords; - this.downScreenPoint = screenPoint; - this.downModifiers = modifiers; + this.downModifiers = normalizeModifiers(modifiers); this.dragging = false; } @@ -152,28 +206,28 @@ export class GestureDetector { * Process a pointer move. Returns `pointerMove` if not pressed, `dragStart` * on threshold crossing, or `drag` while dragging. */ - pointerMove(coords: Coordinates, screenPoint: Point2D, modifiers: Modifiers): ToolEvent[] { - if (!this.downPoint || !this.downCoords || !this.downScreenPoint || !this.downModifiers) { - return [{ type: "pointerMove", point: coords.scene, coords }]; + pointerMove(coords: Coordinates, modifiers: Modifiers): GestureEvent[] { + const modifierKeys = normalizeModifiers(modifiers); + + if (!this.downCoords || !this.downModifiers) { + return [{ type: "pointerMove", coords, ...modifierKeys }]; } - // TODO: make this use Vec library const distance = Math.hypot( - screenPoint.x - this.downScreenPoint.x, - screenPoint.y - this.downScreenPoint.y, + coords.screen.x - this.downCoords.screen.x, + coords.screen.y - this.downCoords.screen.y, ); + const delta = pointerDelta(coords, this.downCoords); if (!this.dragging && distance > this.dragThreshold) { this.dragging = true; return [ { type: "dragStart", - point: this.downPoint, - coords: this.downCoords, - screenPoint: this.downScreenPoint, - shiftKey: this.downModifiers.shiftKey, - altKey: this.downModifiers.altKey, - metaKey: this.downModifiers.metaKey ?? false, + coords, + origin: this.downCoords, + delta, + ...modifierKeys, }, ]; } @@ -182,24 +236,10 @@ export class GestureDetector { return [ { type: "drag", - point: coords.scene, coords, - screenPoint, - origin: this.downPoint, - screenOrigin: this.downScreenPoint, - // TODO: Vec - delta: { - x: coords.scene.x - this.downPoint.x, - y: coords.scene.y - this.downPoint.y, - }, - // TODO: Vec - screenDelta: { - x: screenPoint.x - this.downScreenPoint.x, - y: screenPoint.y - this.downScreenPoint.y, - }, - shiftKey: modifiers.shiftKey, - altKey: modifiers.altKey, - metaKey: modifiers.metaKey ?? false, + origin: this.downCoords, + delta, + ...modifierKeys, }, ]; } @@ -211,28 +251,26 @@ export class GestureDetector { * Process a pointer release. Returns `dragEnd` if dragging, `doubleClick` * if within timing/distance thresholds, or `click` otherwise. */ - pointerUp(coords: Coordinates, screenPoint: Point2D): ToolEvent[] { - if (!this.downPoint || !this.downCoords || !this.downScreenPoint || !this.downModifiers) - return []; + pointerUp(coords: Coordinates, modifiers: Modifiers): GestureEvent[] { + if (!this.downCoords || !this.downModifiers) return []; - const events: ToolEvent[] = []; - const point = coords.scene; + const modifierKeys = normalizeModifiers(modifiers); + const events: GestureEvent[] = []; + const delta = pointerDelta(coords, this.downCoords); if (this.dragging) { events.push({ type: "dragEnd", - point, coords, - screenPoint, - origin: this.downPoint, - screenOrigin: this.downScreenPoint, + origin: this.downCoords, + delta, + ...modifierKeys, }); } else { const now = Date.now(); const timeSinceLastClick = now - this.lastClickTime; const distFromLastClick = this.lastClickPoint - ? //TODO: VEC - Math.hypot(point.x - this.lastClickPoint.x, point.y - this.lastClickPoint.y) + ? Math.hypot(coords.scene.x - this.lastClickPoint.x, coords.scene.y - this.lastClickPoint.y) : Infinity; if ( @@ -241,23 +279,19 @@ export class GestureDetector { ) { events.push({ type: "doubleClick", - point, coords, - metaKey: this.downModifiers.metaKey ?? false, + ...modifierKeys, }); this.lastClickTime = 0; this.lastClickPoint = null; } else { events.push({ type: "click", - point, coords, - shiftKey: this.downModifiers.shiftKey, - altKey: this.downModifiers.altKey, - metaKey: this.downModifiers.metaKey ?? false, + ...modifierKeys, }); this.lastClickTime = now; - this.lastClickPoint = point; + this.lastClickPoint = coords.scene; } } @@ -272,10 +306,15 @@ export class GestureDetector { } private resetPointerState(): void { - this.downPoint = null; this.downCoords = null; - this.downScreenPoint = null; this.downModifiers = null; this.dragging = false; } } + +function pointerDelta(coords: Coordinates, origin: Coordinates): PointerDelta { + return { + screen: Vec2.sub(coords.screen, origin.screen), + scene: Vec2.sub(coords.scene, origin.scene), + }; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts index 5c367515..bc02e1d7 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts @@ -2,7 +2,13 @@ import type { Point2D } from "@shift/geo"; import type { Editor } from "@/lib/editor/Editor"; import type { ToolSwitchHandler, TemporaryToolOptions } from "@/types/editor"; import type { ToolName } from "./createContext"; -import { GestureDetector, type ToolEvent, type Modifiers } from "./GestureDetector"; +import { + GestureDetector, + normalizeModifiers, + type GestureEvent, + type ToolEvent, + type Modifiers, +} from "./GestureDetector"; import type { BaseTool } from "./BaseTool"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; import type { ToolManifest } from "./ToolManifest"; @@ -10,6 +16,14 @@ import { signal, type Signal, type WritableSignal } from "@/lib/signals"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ToolInstance = BaseTool; +const DEFAULT_MODIFIERS: Modifiers = { + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + accelKey: false, +}; + export class ToolManager implements ToolSwitchHandler { private registry = new Map(); private primaryTool: ToolInstance | null = null; @@ -121,7 +135,7 @@ export class ToolManager implements ToolSwitchHandler { this.editor.input.setModifiers(modifiers); this.editor.input.setPointer(coords); this.editor.gesture.setPressed(); - this.gesture.pointerDown(coords, screenPoint, modifiers); + this.gesture.pointerDown(coords, modifiers); } handlePointerMove( @@ -179,25 +193,28 @@ export class ToolManager implements ToolSwitchHandler { this.editor.input.setModifiers(modifiers); this.editor.input.setPointer(coords); - const events = this.gesture.pointerMove(coords, screenPoint, modifiers); + const events = this.gesture.pointerMove(coords, modifiers); if (this.gesture.isDragging) this.editor.gesture.setDragging(); this.dispatchEvents(events); } - handlePointerUp(screenPoint: Point2D): void { + handlePointerUp(screenPoint: Point2D, modifiers: Modifiers = DEFAULT_MODIFIERS): void { const coords = this.editor.fromScreen(screenPoint); + this.editor.input.setModifiers(modifiers); this.editor.input.setPointer(coords); - const events = this.gesture.pointerUp(coords, screenPoint); + const events = this.gesture.pointerUp(coords, modifiers); this.dispatchEvents(events); this.editor.gesture.reset(); } handleKeyDown(e: KeyboardEvent): boolean { - this.editor.input.setModifiers({ + const modifiers = normalizeModifiers({ shiftKey: e.shiftKey, altKey: e.altKey, - metaKey: e.metaKey || e.ctrlKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, }); + this.editor.input.setModifiers(modifiers); if (e.key === "Escape" && this.gesture.isDragging) { this.gesture.reset(); @@ -210,24 +227,25 @@ export class ToolManager implements ToolSwitchHandler { this.activeTool?.handleEvent({ type: "keyDown", key: e.key, - shiftKey: e.shiftKey, - altKey: e.altKey, - metaKey: e.metaKey || e.ctrlKey, + ...modifiers, }) ?? false ); } handleKeyUp(e: KeyboardEvent): boolean { - this.editor.input.setModifiers({ + const modifiers = normalizeModifiers({ shiftKey: e.shiftKey, altKey: e.altKey, - metaKey: e.metaKey || e.ctrlKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, }); + this.editor.input.setModifiers(modifiers); return ( this.activeTool?.handleEvent({ type: "keyUp", key: e.key, + ...modifiers, }) ?? false ); } @@ -244,9 +262,29 @@ export class ToolManager implements ToolSwitchHandler { if (this.activeTool?.drawOverlay) this.activeTool.drawOverlay(canvas); } - private dispatchEvents(events: ToolEvent[]): void { + private dispatchEvents(events: GestureEvent[]): void { for (const event of events) { - this.activeTool?.handleEvent(event); + this.activeTool?.handleEvent(this.withPointerTarget(event)); + } + } + + private withPointerTarget(event: GestureEvent): ToolEvent { + switch (event.type) { + case "pointerMove": + case "click": + case "doubleClick": + case "dragStart": + case "drag": + case "dragEnd": + return { + ...event, + target: this.editor.getPointerTarget(event.coords.scene), + }; + case "dragCancel": + case "keyDown": + case "keyUp": + case "selectionChanged": + return event; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/core/index.ts b/apps/desktop/src/renderer/src/lib/tools/core/index.ts index 385d7c9f..a7462e2f 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/index.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/index.ts @@ -1,10 +1,21 @@ export { GestureDetector, + type ClickEvent, + type DoubleClickEvent, + type DragCancelEvent, + type DragEndEvent, + type DragEvent, + type DragStartEvent, + type GestureEvent, type GestureDetectorConfig, + type Modifiers, + type ModifierKeys, + type PointerDelta, + type PointerMoveEvent, + type KeyDownEvent, + type KeyUpEvent, type ToolEvent, - type ToolEventOf, type ToolKey, - type Modifiers, } from "./GestureDetector"; export { BaseTool, type ToolState } from "./BaseTool"; export { ToolManager } from "./ToolManager"; diff --git a/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md index d9dc5fc7..79b76799 100644 --- a/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md @@ -18,7 +18,7 @@ State machine-based tool system for the Shift font editor: translates pointer/ke - **Architecture Invariant:** For drag operations that mutate the glyph (translate, resize, rotate, bend), use the glyph layer edit draft pattern: `editor.beginGlyphLayerEditDraft(subject)` on drag start, `draft.previewPositionPatch()` or another `draft.preview*()` method on each drag event, `draft.commit(label)` on drag end, `draft.discard()` on cancel. **CRITICAL**: forgetting `commit` or `discard` leaks the draft and leaves the glyph in preview state. -- **Architecture Invariant:** `ToolEvent` pointer events carry a `coords: Coordinates` bundle (`screen`, `scene`, `glyphLocal`). Use `event.coords` for hit-testing and layout; `event.point` is an alias for `coords.scene`. **CRITICAL**: using raw `event.point` when glyph-local coordinates are needed (e.g. pen cursor position) produces wrong results when draw offset is non-zero. +- **Architecture Invariant:** `ToolEvent` pointer events carry a `coords: Coordinates` bundle (`screen`, `scene`). Use `event.coords.scene` for scene-space hit-testing and resolve node-local coordinates from the hit target when a tool needs them. ## Codemap @@ -54,7 +54,7 @@ tools/ - `StateDiagram` — `{ states, initial, transitions }`. Declarative spec for compliance testing. - `ToolName` — `string` (not a fixed union; extensible for plugins). - `ToolState` — `{ type: string }` base interface for all tool state unions. -- `Coordinates` — `{ screen, scene, glyphLocal }` bundle on pointer events. +- `Coordinates` — `{ screen, scene }` bundle on pointer events. - `Modifiers` — `{ shiftKey, altKey, metaKey? }`. ## How it works @@ -233,6 +233,6 @@ onDragCancel(state, ctx) { - `Editor` — provides all services tools access via `this.editor` (hit-testing, selection, hover, commands, viewport, glyph). - `Canvas` — rendering target passed to `drawOverlay` / `drawScene` / `drawBackground`. - `GlyphLayerEditDraft` — preview-and-commit pattern for drag mutations (translate, resize, rotate, bend). -- `Coordinates` — `{ screen, scene, glyphLocal }` coordinate bundle on pointer events. +- `Coordinates` — `{ screen, scene }` coordinate bundle on pointer events. - `TextRunController` — text input controller used by Text tool. - `KeyboardRouter` — binds tool shortcuts registered via `getToolShortcuts`. diff --git a/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/DraggingBehavior.ts b/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/DraggingBehavior.ts index c776faff..51d63f8c 100644 --- a/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/DraggingBehavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/DraggingBehavior.ts @@ -1,12 +1,12 @@ import { createBehavior, type ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent } from "../../core/GestureDetector"; import type { HandState } from "../types"; import { Vec2 } from "@shift/geo"; export const HandDraggingBehavior = createBehavior({ - onDrag(state: HandState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { + onDrag(state: HandState, ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "dragging") return false; - const newPan = Vec2.add(state.startPan, event.screenDelta); + const newPan = Vec2.add(state.startPan, event.delta.screen); ctx.editor.setPan(newPan); return true; }, diff --git a/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/ReadyBehavior.ts b/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/ReadyBehavior.ts index 888958cc..b6f8d603 100644 --- a/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/ReadyBehavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/hand/behaviors/ReadyBehavior.ts @@ -1,18 +1,14 @@ import { createBehavior, type ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragStartEvent } from "../../core/GestureDetector"; import type { HandState } from "../types"; export const HandReadyBehavior = createBehavior({ - onDragStart( - state: HandState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { + onDragStart(state: HandState, ctx: ToolContext, event: DragStartEvent): boolean { if (state.type !== "ready") return false; const startPan = ctx.editor.pan; ctx.setState({ type: "dragging", - screenStart: event.screenPoint, + screenStart: event.coords.screen, startPan, }); return true; diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts index bde1aff6..d8a13e2b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.test.ts @@ -21,7 +21,7 @@ describe("Pen tool", () => { editor.click(100, 200); await editor.settle(); - const contour = editor.getActiveContour(); + const contour = editor.openContour; expect(contour?.points.length).toBe(1); }); }); @@ -33,7 +33,7 @@ describe("Pen tool", () => { editor.click(300, 200); await editor.settle(); - const segment = editor.getActiveContour()?.segments()[0]; + const segment = editor.openContour?.segments()[0]; expect(segment?.type).toBe("line"); }); @@ -46,7 +46,7 @@ describe("Pen tool", () => { editor.click(500, 200); await editor.settle(); - const contour = editor.getActiveContour(); + const contour = editor.openContour; expect(contour?.segments().length).toBe(2); expect(contour?.segments()[0]?.type).toBe("line"); @@ -64,10 +64,10 @@ describe("Pen tool", () => { editor.click(100, 200); // back on the first point await editor.settle(); - const contour = editor.editingGlyphLayer?.geometry.contours[0]; + const contour = editor.glyphContours[0]; expect(contour?.closed).toBe(true); expect(contour?.points.length).toBe(3); - expect(editor.getActiveContour()).toBeNull(); + expect(editor.openContour).toBeNull(); }); it("two consecutive curve drags create two cubic segments joined by a smooth point", async () => { @@ -88,7 +88,7 @@ describe("Pen tool", () => { editor.pointerUp(580, 180); await editor.settle(); - const contour = editor.getActiveContour(); + const contour = editor.openContour; expect(contour?.segments().map((segment) => segment.type)).toEqual(["cubic", "cubic"]); const junction = contour?.segments()[0]?.asCubic()?.end; @@ -105,7 +105,7 @@ describe("Pen tool", () => { editor.pointerUp(200, -200); await editor.settle(); - const contour = editor.getActiveContour(); + const contour = editor.openContour; expect(contour?.segments().length).toBe(1); expect(contour?.segments()[0]?.type).toBe("cubic"); }); @@ -134,7 +134,7 @@ describe("Pen tool", () => { await editor.undoAndSettle(); expect(editor.pointCount).toBe(0); - expect(editor.editingGlyphLayer?.geometry.contours.length).toBe(0); + expect(editor.glyphContours.length).toBe(0); }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts index 61a09def..f0482178 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/Pen.ts @@ -1,31 +1,68 @@ import { BaseTool, type ToolName } from "../core"; -import type { PenState } from "./types"; +import type { PenContext, PenState } from "./types"; import { PenDownBehaviour, HandleBehavior, EscapeBehavior } from "./behaviors"; import type { CursorType } from "@/types/editor"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; +import type { Editor } from "@/lib/editor/Editor"; import { PenTargets } from "./PenTargets"; import { PenPreview } from "./PenPreview"; +import type { ContourId } from "@shift/types"; +import { signal, type Signal, type WritableSignal } from "@/lib/signals"; +import { PenStroke } from "./PenStroke"; export type { PenState }; -export class Pen extends BaseTool { +export class Pen extends BaseTool { readonly id: ToolName = "pen"; + readonly #ctx: WritableSignal; #penPreview: PenPreview = new PenPreview(this); readonly behaviors = [new EscapeBehavior(), new PenDownBehaviour(), new HandleBehavior()]; + constructor(editor: Editor) { + super(editor); + this.#ctx = signal(null, { + name: "tool.pen.context", + }); + } + + get context(): PenContext | null { + return this.#ctx.peek(); + } + + get contextCell(): Signal { + return this.#ctx; + } + + clearContext(): void { + this.#ctx.set(null); + } + + setActiveContour(contourId: ContourId | null): void { + const context = this.#ctx.peek(); + if (!context) return; + + this.#ctx.set({ ...context, activeContourId: contourId }); + } + + clearActiveContour(): void { + this.setActiveContour(null); + } + override getCursor(state: PenState): CursorType { if (state.type !== "ready") return { type: "pen" }; - const targets = PenTargets.active(this.editor); - if (!targets) return { type: "pen" }; + const stroke = PenStroke.active(this); + if (!stroke) return { type: "pen" }; const pos = this.editor.input.pointerCell.value; if (!pos) return { type: "pen" }; - const target = targets.at(pos.glyphLocal, this.editor.hitRadius); - const activeContour = this.editor.getActiveContour(); + const nodePoint = this.editor.getPointInNodeSpace(pos.scene, stroke.node.position); + const targets = PenTargets.forGeometry(stroke.layer.geometry); + const target = targets.at(nodePoint, this.editor.hitRadius); + const activeContour = stroke.activeContour; switch (target.type) { case "terminal": { @@ -55,11 +92,22 @@ export class Pen extends BaseTool { override activate(): void { this.setState({ type: "ready" }); - this.editor.clearActiveContour(); + + const glyphNodes = this.editor.scene.nodes().filter((node) => node.kind === "glyph"); + if (glyphNodes.length !== 1) return; + + const [node] = glyphNodes; + if (!node) return; + + this.#ctx.set({ + glyphNode: node, + activeContourId: null, + }); } override deactivate(): void { this.setState({ type: "idle" }); + this.clearContext(); } override drawOverlay(canvas: Canvas): void { diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/PenPreview.ts b/apps/desktop/src/renderer/src/lib/tools/pen/PenPreview.ts index 1a527f59..a8ce2172 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/PenPreview.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/PenPreview.ts @@ -1,16 +1,18 @@ -import { Canvas } from "@/lib/editor/rendering/Canvas"; +import type { Canvas } from "@/lib/editor/rendering/Canvas"; import { CanvasItem } from "@/lib/editor/rendering/CanvasItem"; -import { Pen, PenState } from "./Pen"; -import { Point2D, Vec2 } from "@shift/geo"; -import { Editor } from "@/lib/editor/Editor"; -import { Coordinates } from "@/types/coordinates"; -import { ContourId } from "@shift/types"; +import type { Pen, PenState } from "./Pen"; +import { Vec2, type Point2D } from "@shift/geo"; +import type { Editor } from "@/lib/editor/Editor"; +import type { Coordinates } from "@/types/coordinates"; +import { track } from "@/lib/signals"; export interface PenPreviewProps { state: PenState; pointer: Coordinates | null; - activeContourId: ContourId | null; + nodePosition: Point2D | null; + lastOnCurvePoint: Point2D | null; } + export class PenPreview extends CanvasItem { readonly #pen: Pen; readonly #editor: Editor; @@ -23,43 +25,49 @@ export class PenPreview extends CanvasItem { } protected props(): PenPreviewProps { - this.#pen.stateCell.value; - return { state: this.#pen.stateCell.value, pointer: this.#editor.input.pointerCell.value, - activeContourId: this.#editor.activeContourIdCell.value, + nodePosition: this.#pen.contextCell.value?.glyphNode.position ?? null, + lastOnCurvePoint: this.#lastOnCurvePoint(), }; } draw(canvas: Canvas): void { - const props = this.props(); + const props = this.propsCell.value; + if (!props) return; + const pos = props.pointer; if (!pos) return; if (props.state.type === "ready") { - const lastPoint = this.#getLastOnCurvePoint(); - if (lastPoint) { + const lastPoint = props.lastOnCurvePoint; + const nodePosition = props.nodePosition; + if (lastPoint && nodePosition) { canvas.line( - lastPoint, - pos.glyphLocal, + Vec2.add(nodePosition, lastPoint), + pos.scene, canvas.theme.preview.color, canvas.theme.preview.widthPx, ); } const { fill, stroke, size, widthPx } = canvas.theme.penReady; - canvas.filledStrokeCircle(pos.glyphLocal, size, fill, stroke, widthPx); + canvas.filledStrokeCircle(pos.scene, size, fill, stroke, widthPx); } if (props.state.type === "dragging") { + const nodePosition = props.nodePosition; + if (!nodePosition) return; + const { anchor, mousePos } = props.state; - const effectivePos = mousePos; - const mirrorPos = Vec2.mirror(effectivePos, anchor.position); + const anchorPos = Vec2.add(nodePosition, anchor.position); + const effectivePos = Vec2.add(nodePosition, mousePos); + const mirrorPos = Vec2.add(nodePosition, Vec2.mirror(mousePos, anchor.position)); const { stroke, widthPx } = canvas.theme.glyph; - canvas.line(effectivePos, anchor.position, stroke, widthPx); - canvas.line(anchor.position, mirrorPos, stroke, widthPx); + canvas.line(effectivePos, anchorPos, stroke, widthPx); + canvas.line(anchorPos, mirrorPos, stroke, widthPx); // Draw control handle previews const controlStyle = canvas.theme.handle.control.idle; @@ -80,11 +88,21 @@ export class PenPreview extends CanvasItem { } } - #getLastOnCurvePoint(): Point2D | null { - const contour = this.#editor.getActiveContour(); - if (!contour || contour.isEmpty || contour.closed) { - return null; - } + #lastOnCurvePoint(): Point2D | null { + const context = this.#pen.contextCell.value; + if (!context?.activeContourId) return null; + + const sourceId = this.#editor.activeSourceIdCell.value; + if (!sourceId) return null; + + const layer = this.#editor.font.layer(context.glyphNode.glyphId, sourceId); + if (!layer) return null; + + track(layer.structureCell); + track(layer.coordinateBuffersChangedCell); + + const contour = layer.contour(context.activeContourId); + if (!contour || contour.isEmpty || contour.closed) return null; const lastOnCurve = contour.lastOnCurvePoint; if (!lastOnCurve) return null; diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/PenStroke.ts b/apps/desktop/src/renderer/src/lib/tools/pen/PenStroke.ts index 776a5985..8e733d31 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/PenStroke.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/PenStroke.ts @@ -1,61 +1,72 @@ import { Vec2, type Point2D } from "@shift/geo"; import { Validate } from "@shift/validation"; import type { ContourId, PointId } from "@shift/types"; -import type { Editor } from "@/lib/editor/Editor"; -import type { GlyphLayer, GlyphInstanceGeometry, GlyphLayerPositions } from "@/lib/model/Glyph"; +import type { GlyphLayer, GlyphLayerPositions } from "@/lib/model/Glyph"; import { Point, type Contour, type SegmentId } from "@shift/glyph-state"; import { Anchor, Handles } from "./types"; +import type { Pen } from "./Pen"; +import { ReverseContourCommand } from "@/lib/commands"; +import type { GlyphNode } from "@/types/node"; export class PenStroke { #pendingHandles: GlyphLayerPositions | null = null; - readonly #editor: Editor; - readonly #geometry: GlyphInstanceGeometry; - readonly #edit: GlyphLayer; - - private constructor(editor: Editor, geometry: GlyphInstanceGeometry, edit: GlyphLayer) { - this.#editor = editor; - this.#geometry = geometry; - this.#edit = edit; + readonly #pen: Pen; + readonly #node: GlyphNode; + readonly #layer: GlyphLayer; + + private constructor(pen: Pen, node: GlyphNode, layer: GlyphLayer) { + this.#pen = pen; + this.#node = node; + this.#layer = layer; } - static active(editor: Editor): PenStroke | null { - const instance = editor.previewGlyphInstance; - const edit = editor.editingGlyphLayer; - if (!instance || !edit) return null; + static active(pen: Pen): PenStroke | null { + const context = pen.context; + if (!context) return null; - return new PenStroke(editor, instance.geometry, edit); + const sourceId = pen.editor.activeSourceId; + if (!sourceId) return null; + + const layer = pen.editor.font.layer(context.glyphNode.glyphId, sourceId); + if (!layer) return null; + + return new PenStroke(pen, context.glyphNode, layer); } - get activeContour(): Contour | null { - const contourId = this.#editor.getActiveContourId(); - if (!contourId) return null; + get node(): GlyphNode { + return this.#node; + } - return this.#geometry.contour(contourId); + get layer(): GlyphLayer { + return this.#layer; } - get geometry(): GlyphInstanceGeometry { - return this.#geometry; + get activeContour(): Contour | null { + const contourId = this.#pen.context?.activeContourId; + if (!contourId) return null; + + return this.#layer.contour(contourId); } startContour(position: Point2D): PointId { - const contourId = this.#edit.addContour(); - const pointId = this.#edit.addOnCurvePoint(contourId, position); - this.#editor.setActiveContour(contourId); + const contourId = this.#layer.addContour(); + const pointId = this.#layer.addOnCurvePoint(contourId, position); + this.#pen.setActiveContour(contourId); return pointId; } appendOnCurve(position: Point2D): PointId | null { const contour = this.activeContour; if (!contour) return null; - return this.#edit.addOnCurvePoint(contour.id, position); + return this.#layer.addOnCurvePoint(contour.id, position); } closeActiveContour(): boolean { const contour = this.activeContour; if (!contour) return false; - this.#edit.closeContour(contour.id); - this.#editor.clearActiveContour(); + this.#layer.closeContour(contour.id); + this.#pen.clearActiveContour(); return true; } @@ -65,13 +76,17 @@ export class PenStroke { } continueContour(contourId: ContourId, side: "start" | "end", pointId: PointId): void { - this.#editor.continueContour(contourId, side === "start", pointId); + this.#pen.setActiveContour(contourId); + if (side === "start") { + this.#pen.editor.commands.run(new ReverseContourCommand(contourId)); + } + this.#pen.editor.selection.select([{ kind: "point", pointId }]); } splitSegment(segmentId: SegmentId, t: number): PointId | null { - const segment = this.#geometry.segment(segmentId); + const segment = this.#layer.geometry.segment(segmentId); if (!segment) return null; - return this.#editor.splitSegment(segment, t); + return this.#pen.editor.splitSegment(segment, t); } commitAnchor(anchor: Anchor): PointId | null { @@ -91,11 +106,11 @@ export class PenStroke { const prevOnCurve = contour.lastOnCurvePoint; const isFirstPoint = contour.isEmpty; - const anchorId = this.#edit.addSmoothPoint(contour.id, position); + const anchorId = this.#layer.addSmoothPoint(contour.id, position); anchor.pointId = anchorId; if (isFirstPoint) { - const cpOutId = this.#edit.addOffCurvePoint(contour.id, handlePos); + const cpOutId = this.#layer.addOffCurvePoint(contour.id, handlePos); return { cpOut: cpOutId }; } @@ -103,8 +118,8 @@ export class PenStroke { if (prevIsOffCurve) { const cpInPos = Vec2.mirror(handlePos, position); - const cpInId = this.#edit.insertPointBefore(anchorId, Point.offCurve(cpInPos)); - const cpOutId = this.#edit.addOffCurvePoint(contour.id, handlePos); + const cpInId = this.#layer.insertPointBefore(anchorId, Point.offCurve(cpInPos)); + const cpOutId = this.#layer.addOffCurvePoint(contour.id, handlePos); return { cpIn: cpInId, cpOut: cpOutId }; } @@ -112,11 +127,11 @@ export class PenStroke { if (prevOnCurve) { const cp1Pos = Vec2.lerp(prevOnCurve, position, 1 / 3); - this.#edit.insertPointBefore(anchorId, Point.offCurve(cp1Pos)); + this.#layer.insertPointBefore(anchorId, Point.offCurve(cp1Pos)); } const cpInPos = Vec2.mirror(handlePos, position); - const cpInId = this.#edit.insertPointBefore(anchorId, Point.offCurve(cpInPos)); + const cpInId = this.#layer.insertPointBefore(anchorId, Point.offCurve(cpInPos)); return { cpIn: cpInId }; } @@ -144,14 +159,14 @@ export class PenStroke { // Live drag: local preview only; durability happens once at gesture end. this.#pendingHandles = positions; - this.#edit.previewPositionPatch(positions); + this.#layer.previewPositionPatch(positions); } /** Commits the last previewed handle positions as one durable move. */ commitHandles(): void { if (!this.#pendingHandles) return; - this.#edit.commitPositionPatch(this.#pendingHandles); + this.#layer.commitPositionPatch(this.#pendingHandles); this.#pendingHandles = null; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/PenTargets.ts b/apps/desktop/src/renderer/src/lib/tools/pen/PenTargets.ts index 39740874..9566b0ee 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/PenTargets.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/PenTargets.ts @@ -1,9 +1,7 @@ import type { Point2D } from "@shift/geo"; import { Point, type SegmentId } from "@shift/glyph-state"; import type { ContourId, PointId } from "@shift/types"; -import type { Editor } from "@/lib/editor/Editor"; -import type { GlyphInstanceGeometry } from "@/lib/model/Glyph"; -import { PenStroke } from "./PenStroke"; +import type { GlyphGeometry } from "@/lib/model/Glyph"; export type PenTarget = | { @@ -20,19 +18,14 @@ export type PenTarget = | { readonly type: "empty" }; export class PenTargets { - readonly #geometry: GlyphInstanceGeometry; + readonly #geometry: GlyphGeometry; - private constructor(geometry: GlyphInstanceGeometry) { + private constructor(geometry: GlyphGeometry) { this.#geometry = geometry; } - static active(editor: Editor): PenTargets | null { - const stroke = PenStroke.active(editor); - return stroke ? PenTargets.forStroke(stroke) : null; - } - - static forStroke(stroke: PenStroke): PenTargets { - return new PenTargets(stroke.geometry); + static forGeometry(geometry: GlyphGeometry): PenTargets { + return new PenTargets(geometry); } at(pos: Point2D, radius: number): PenTarget { @@ -43,7 +36,7 @@ export class PenTargets { if (segment) { return { type: "segment", - segmentId: segment.segmentId, + segmentId: segment.id, t: segment.t, }; } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts index 803b1050..93e56be0 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/CancelBehaviour.ts @@ -1,23 +1,24 @@ import type { ToolContext } from "../../core/Behavior"; -import type { Editor } from "@/lib/editor/Editor"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { KeyDownEvent } from "../../core/GestureDetector"; import type { PenState, PenBehavior } from "../types"; +import { PenStroke } from "../PenStroke"; +import type { Pen } from "../Pen"; export class EscapeBehavior implements PenBehavior { - onKeyDown(state: PenState, ctx: ToolContext, event: ToolEventOf<"keyDown">): boolean { + onKeyDown(state: PenState, ctx: ToolContext, event: KeyDownEvent): boolean { if (state.type !== "ready") return false; if (event.key !== "Escape") return false; - if (this.hasActiveDrawingContour(ctx.editor)) { - ctx.editor.clearActiveContour(); + if (this.hasActiveDrawingContour(ctx)) { + ctx.tool.clearActiveContour(); return true; } return false; } - private hasActiveDrawingContour(editor: Editor): boolean { - const contour = editor.getActiveContour(); + private hasActiveDrawingContour(ctx: ToolContext): boolean { + const contour = PenStroke.active(ctx.tool)?.activeContour ?? null; if (!contour) return false; return !contour.closed && !contour.isEmpty; } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts index 4f8d86f8..aa5b1f82 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/DragHandlesBehaviour.ts @@ -1,59 +1,59 @@ import type { Point2D } from "@shift/geo"; import { Vec2 } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; -import type { Editor } from "@/lib/editor/Editor"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent, KeyDownEvent } from "../../core/GestureDetector"; import type { PenState, PenBehavior, Anchor, Handles } from "../types"; +import type { Pen } from "../Pen"; import { PenStroke } from "../PenStroke"; const DRAG_THRESHOLD = 3; export class HandleBehavior implements PenBehavior { - onDrag(state: PenState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { + onDrag(state: PenState, ctx: ToolContext, event: DragEvent): boolean { if (state.type === "anchored") { - const next = this.#nextAnchoredState(state, event, ctx.editor); + const next = this.#nextAnchoredState(state, event, ctx.tool); if (next) ctx.setState(next); return true; } if (state.type === "dragging") { - ctx.setState(this.#nextDraggingState(state, event, ctx.editor)); + ctx.setState(this.#nextDraggingState(state, event, ctx.tool)); return true; } return false; } - onDragEnd(state: PenState, ctx: ToolContext): boolean { + onDragEnd(state: PenState, ctx: ToolContext): boolean { if (state.type !== "anchored" && state.type !== "dragging") return false; if (state.type === "anchored" && !state.anchor.pointId) { - PenStroke.active(ctx.editor)?.commitAnchor(state.anchor); + PenStroke.active(ctx.tool)?.commitAnchor(state.anchor); } if (state.type === "dragging") { - PenStroke.active(ctx.editor)?.commitHandles(); + PenStroke.active(ctx.tool)?.commitHandles(); } ctx.setState({ type: "ready" }); return true; } - onDragCancel(state: PenState, ctx: ToolContext): boolean { + onDragCancel(state: PenState, ctx: ToolContext): boolean { if (state.type !== "anchored" && state.type !== "dragging") return false; // Matches prior observable behavior: handle moves were durable per // move, so cancel never reverted them. True revert-on-escape can come // with the overlay rework. if (state.type === "dragging") { - PenStroke.active(ctx.editor)?.commitHandles(); + PenStroke.active(ctx.tool)?.commitHandles(); } ctx.setState({ type: "ready" }); return true; } - onKeyDown(state: PenState, ctx: ToolContext, event: ToolEventOf<"keyDown">): boolean { + onKeyDown(state: PenState, ctx: ToolContext, event: KeyDownEvent): boolean { if (event.key !== "Escape") return false; if (state.type !== "anchored" && state.type !== "dragging") return false; @@ -63,13 +63,16 @@ export class HandleBehavior implements PenBehavior { #nextAnchoredState( state: PenState & { type: "anchored" }, - event: ToolEventOf<"drag">, - editor: Editor, + event: DragEvent, + pen: Pen, ): (PenState & { type: "dragging" }) | null { - const localPoint = event.coords.glyphLocal; + const stroke = PenStroke.active(pen); + if (!stroke) return null; + + const localPoint = pen.editor.getPointInNodeSpace(event.coords.scene, stroke.node.position); if (Vec2.dist(state.anchor.position, localPoint) <= DRAG_THRESHOLD) return null; - const handles = this.#createHandles(state.anchor, localPoint, editor); + const handles = this.#createHandles(state.anchor, localPoint, stroke); return { type: "dragging", @@ -81,12 +84,15 @@ export class HandleBehavior implements PenBehavior { #nextDraggingState( state: PenState & { type: "dragging" }, - event: ToolEventOf<"drag">, - editor: Editor, + event: DragEvent, + pen: Pen, ): PenState & { type: "dragging" } { - const localPoint = event.coords.glyphLocal; + const stroke = PenStroke.active(pen); + if (!stroke) return state; + + const localPoint = pen.editor.getPointInNodeSpace(event.coords.scene, stroke.node.position); - this.#updateHandles(state.anchor, state.handles, localPoint, editor); + this.#updateHandles(state.anchor, state.handles, localPoint, stroke); return { ...state, @@ -94,11 +100,11 @@ export class HandleBehavior implements PenBehavior { }; } - #createHandles(anchor: Anchor, handlePos: Point2D, editor: Editor): Handles { - return PenStroke.active(editor)?.createHandles(anchor, handlePos) ?? {}; + #createHandles(anchor: Anchor, handlePos: Point2D, stroke: PenStroke): Handles { + return stroke.createHandles(anchor, handlePos); } - #updateHandles(anchor: Anchor, handles: Handles, handlePos: Point2D, editor: Editor): void { - PenStroke.active(editor)?.moveHandles(anchor, handles, handlePos); + #updateHandles(anchor: Anchor, handles: Handles, handlePos: Point2D, stroke: PenStroke): void { + stroke.moveHandles(anchor, handles, handlePos); } } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts index 1182072d..260e7ca1 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/behaviors/PenDownBehaviour.ts @@ -1,24 +1,25 @@ import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { ClickEvent, DragStartEvent } from "../../core/GestureDetector"; import { PenStroke } from "../PenStroke"; import { PenTargets } from "../PenTargets"; import type { PenState, PenBehavior } from "../types"; +import type { Pen } from "../Pen"; export class PenDownBehaviour implements PenBehavior { - onClick(state: PenState, ctx: ToolContext, event: ToolEventOf<"click">): boolean { + onClick(state: PenState, ctx: ToolContext, event: ClickEvent): boolean { if (state.type !== "ready") return false; const editor = ctx.editor; - const stroke = PenStroke.active(editor); + const stroke = PenStroke.active(ctx.tool); if (!stroke) return false; - const localPoint = event.coords.glyphLocal; - const targets = PenTargets.forStroke(stroke); - const target = targets.at(localPoint, editor.hitRadius); + const nodePoint = editor.getPointInNodeSpace(event.coords.scene, stroke.node.position); + const targets = PenTargets.forGeometry(stroke.layer.geometry); + const target = targets.at(nodePoint, editor.hitRadius); editor.selection.clear(); - const isActive = editor.getActiveContour() !== null; + const isActive = stroke.activeContour !== null; switch (target.type) { case "terminal": @@ -38,9 +39,9 @@ export class PenDownBehaviour implements PenBehavior { case "empty": if (stroke.activeContour) { - stroke.appendOnCurve(localPoint); + stroke.appendOnCurve(nodePoint); } else { - stroke.startContour(localPoint); + stroke.startContour(nodePoint); } ctx.setState({ type: "ready" }); @@ -48,26 +49,24 @@ export class PenDownBehaviour implements PenBehavior { } } - onDragStart( - state: PenState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { + onDragStart(state: PenState, ctx: ToolContext, event: DragStartEvent): boolean { if (state.type !== "ready") return false; const editor = ctx.editor; - const targets = PenTargets.active(editor); - if (!targets) return false; + const stroke = PenStroke.active(ctx.tool); + if (!stroke) return false; - const target = targets.at(event.coords.glyphLocal, editor.hitRadius); + const nodePoint = editor.getPointInNodeSpace(event.coords.scene, stroke.node.position); + const targets = PenTargets.forGeometry(stroke.layer.geometry); + const target = targets.at(nodePoint, editor.hitRadius); if (target.type === "segment") return false; - const activeContour = editor.getActiveContour(); + const activeContour = stroke.activeContour; if (!activeContour) return false; ctx.setState({ type: "anchored", - anchor: { position: event.coords.glyphLocal }, + anchor: { position: nodePoint }, }); return true; } diff --git a/apps/desktop/src/renderer/src/lib/tools/pen/types.ts b/apps/desktop/src/renderer/src/lib/tools/pen/types.ts index e67eb28e..ca0ac7f3 100644 --- a/apps/desktop/src/renderer/src/lib/tools/pen/types.ts +++ b/apps/desktop/src/renderer/src/lib/tools/pen/types.ts @@ -1,6 +1,8 @@ import type { Point2D } from "@shift/geo"; -import type { PointId } from "@shift/types"; +import type { ContourId, PointId } from "@shift/types"; import type { Behavior } from "../core/Behavior"; +import type { Pen } from "./Pen"; +import type { GlyphNode } from "@/types/node"; export interface Anchor { position: Point2D; @@ -23,4 +25,9 @@ export type PenState = mousePos: Point2D; }; -export type PenBehavior = Behavior; +export type PenBehavior = Behavior; + +export interface PenContext { + glyphNode: GlyphNode; + activeContourId: ContourId | null; +} diff --git a/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts b/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts index 369c505a..dcf63141 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts @@ -1,5 +1,4 @@ -import { Bounds, Vec2, type Point2D, type Rect2D } from "@shift/geo"; -import type { Editor } from "@/lib/editor/Editor"; +import { Vec2, type Point2D, type Rect2D } from "@shift/geo"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; import { CanvasItem } from "@/lib/editor/rendering/CanvasItem"; import type { Coordinates } from "@/types/coordinates"; @@ -7,8 +6,6 @@ import type { CursorType } from "@/types/editor"; import { edgeToCursor, type BoundingRectEdge } from "./cursor"; import type { Select } from "./Select"; -type YAxisDirection = "up" | "down"; - export type CornerHandle = "top-left" | "top-right" | "bottom-left" | "bottom-right"; export type BoundingBoxHitResult = @@ -83,13 +80,6 @@ interface HandlePositions { }; } -interface ExpandedHandleRect { - left: number; - right: number; - top: number; - bottom: number; -} - export interface SelectBoundingBoxProps { readonly rect: Rect2D; readonly screenRect: Rect2D; @@ -100,57 +90,15 @@ export interface SelectBoundingBoxProps { export class SelectBoundingBox extends CanvasItem { readonly #select: Select; - readonly #editor: Editor; constructor(select: Select) { super(); this.#select = select; - this.#editor = select.editor; } protected props(): SelectBoundingBoxProps | null { - const state = this.#select.stateCell.value; - if (state.type === "brushing") return null; - - if ( - !this.#editor.handlesVisibleCell.value || - this.#editor.proofModeCell.value || - !this.#editor.focusedGlyphVisibleCell.value - ) { - return null; - } - - const selection = this.#editor.selection.stateCell.value; - const selectedCount = - selection.pointIds.size + selection.anchorIds.size + selection.segmentIds.size; - if (selectedCount <= 1) return null; - - const bounds = this.#editor.selection.boundsCell.value; - if (!bounds) return null; - - this.#editor.camera.trackViewportTransform(); - - const rect = Bounds.toRect(bounds); - const screenRect = this.#screenRect(rect); - const upmHandles = getHandlePositions( - rect, - this.#editor.screenToUpmDistance(SELECT_BOUNDING_BOX_STYLE.handle.offsetPx), - this.#editor.screenToUpmDistance(SELECT_BOUNDING_BOX_STYLE.rotationZoneOffsetPx), - ); - const screenHandles = getHandlePositions( - screenRect, - SELECT_BOUNDING_BOX_STYLE.handle.offsetPx, - SELECT_BOUNDING_BOX_STYLE.rotationZoneOffsetPx, - "down", - ); - - return { - rect, - screenRect, - upmHandles, - screenHandles, - hitRadiusPx: SELECT_BOUNDING_BOX_STYLE.hitRadiusPx, - }; + void this.#select; + return null; } get rect(): Rect2D | null { @@ -234,33 +182,6 @@ export class SelectBoundingBox extends CanvasItem { this.#drawHandles(canvas, props.upmHandles); } - #screenRect(rect: Rect2D): Rect2D { - const topLeft = this.#editor.fromGlyphLocal({ - x: rect.left, - y: rect.bottom, - }).screen; - const bottomRight = this.#editor.fromGlyphLocal({ - x: rect.right, - y: rect.top, - }).screen; - - const left = Math.min(topLeft.x, bottomRight.x); - const right = Math.max(topLeft.x, bottomRight.x); - const top = Math.min(topLeft.y, bottomRight.y); - const bottom = Math.max(topLeft.y, bottomRight.y); - - return { - x: left, - y: top, - width: right - left, - height: bottom - top, - left, - top, - right, - bottom, - }; - } - #drawRect(canvas: Canvas, rect: Rect2D): void { const { stroke, widthPx, dashPx } = SELECT_BOUNDING_BOX_STYLE; canvas.strokeRect(rect.x, rect.y, rect.width, rect.height, stroke, widthPx, dashPx); @@ -296,91 +217,6 @@ function drawHandle( canvas.ctx.restore(); } -function getExpandedHandleRect( - rect: Rect2D, - offset: number, - yAxisDirection: YAxisDirection, -): ExpandedHandleRect { - const left = rect.left - offset; - const right = rect.right + offset; - - if (yAxisDirection === "up") { - return { - left, - right, - top: rect.bottom + offset, - bottom: rect.top - offset, - }; - } - - return { - left, - right, - top: rect.top - offset, - bottom: rect.bottom + offset, - }; -} - -function getHandlePositions( - rect: Rect2D, - handleOffset: number, - rotationZoneOffset: number, - yAxisDirection: YAxisDirection = "up", -): HandlePositions { - const alignmentRect = getExpandedHandleRect(rect, handleOffset, yAxisDirection); - const rotationRect = getExpandedHandleRect(rect, rotationZoneOffset, yAxisDirection); - const centerX = (alignmentRect.left + alignmentRect.right) / 2; - const centerY = (alignmentRect.top + alignmentRect.bottom) / 2; - - return { - corners: { - topLeft: { - x: alignmentRect.left, - y: alignmentRect.top, - }, - topRight: { - x: alignmentRect.right, - y: alignmentRect.top, - }, - bottomLeft: { - x: alignmentRect.left, - y: alignmentRect.bottom, - }, - bottomRight: { - x: alignmentRect.right, - y: alignmentRect.bottom, - }, - }, - midpoints: { - top: { x: centerX, y: alignmentRect.top }, - bottom: { - x: centerX, - y: alignmentRect.bottom, - }, - left: { x: alignmentRect.left, y: centerY }, - right: { x: alignmentRect.right, y: centerY }, - }, - rotationZones: { - topLeft: { - x: rotationRect.left, - y: rotationRect.top, - }, - topRight: { - x: rotationRect.right, - y: rotationRect.top, - }, - bottomLeft: { - x: rotationRect.left, - y: rotationRect.bottom, - }, - bottomRight: { - x: rotationRect.right, - y: rotationRect.bottom, - }, - }, - }; -} - function hitTestRotationZones( pos: Point2D, rotationZones: HandlePositions["rotationZones"], diff --git a/apps/desktop/src/renderer/src/lib/tools/select/Segments.ts b/apps/desktop/src/renderer/src/lib/tools/select/Segments.ts index a3db1375..2874059a 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Segments.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Segments.ts @@ -21,9 +21,9 @@ export class SelectSegments { } #hoveredSegment(geometry: GlyphInstanceGeometry, hover: Hover): Segment | null { - const segmentId = hover.segmentId; - if (!segmentId) return null; + const entry = hover.entry; + if (entry?.kind !== "segment") return null; - return geometry.segment(segmentId); + return geometry.segment(entry.segmentId); } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts index af52ffe0..46666260 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts @@ -21,7 +21,6 @@ import { TextRunEdit } from "./behaviors/TextRunEdit"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; import { SelectBoundingBox } from "./BoundingBox"; import { SelectMarquee } from "./Marquee"; -import { SelectSegments } from "./Segments"; export type { BoundingRectEdge, SelectState }; @@ -29,7 +28,6 @@ export class Select extends BaseTool { readonly id: ToolName = "select"; readonly boundingBox = new SelectBoundingBox(this); readonly marquee = new SelectMarquee(this); - readonly #segments = new SelectSegments(); readonly behaviors: SelectBehavior[] = [ new ToggleSmooth(), @@ -62,7 +60,7 @@ export class Select extends BaseTool { } const modifiers = this.editor.input.modifiersCell.value; - const hover = this.editor.hover.targetCell.value; + const hover = this.editor.hover.entryCell.value; if (modifiers.altKey && hover) { return { type: "copy" }; } @@ -92,12 +90,7 @@ export class Select extends BaseTool { } override drawScene(canvas: Canvas): void { - if (this.editor.proofMode || !this.editor.focusedGlyphVisible()) return; - - const instance = this.editor.previewGlyphInstance; - if (!instance) return; - - this.#segments.draw(canvas, instance.geometry, this.editor.selection, this.editor.hover); + void canvas; } override drawOverlay(canvas: Canvas): void { diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts index 0088c3eb..475f2f6d 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/BendCurve.ts @@ -1,6 +1,5 @@ -import { Vec2 } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; import type { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; @@ -8,83 +7,16 @@ export class BendCurve implements SelectBehavior { #draft: GlyphLayerEditDraft | null = null; #hasChanges = false; - onDragStart( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { - if (state.type !== "ready" || !event.metaKey) return false; - - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; - - const geometry = instance.geometry; - const hit = geometry.hitSegment(event.coords.glyphLocal, ctx.editor.hitRadius); - if (!hit) return false; - - const { t, closestPoint, segmentId } = hit; - const segment = geometry.segment(segmentId); - if (!segment) return false; - - const cubic = segment.asCubic(); - if (!cubic) return false; - - const { controlStart, controlEnd } = cubic; - - this.#draft = ctx.editor.beginGlyphLayerEditDraft({ - points: [controlStart.id, controlEnd.id], - }); - this.#hasChanges = false; - - ctx.setState({ - type: "bending", - bend: { - t, - startPos: closestPoint, - segmentId, - controlOneId: controlStart.id, - controlTwoId: controlEnd.id, - initialControlOne: controlStart, - initialControlTwo: controlEnd, - }, - }); - return true; + onDragStart(state: SelectState, ctx: ToolContext, event: DragStartEvent): boolean { + void ctx; + void event; + if (state.type !== "ready" || !event.accelKey) return false; + return false; } - onDrag(state: SelectState, _ctx: ToolContext, event: ToolEventOf<"drag">): boolean { + onDrag(state: SelectState, _ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "bending") return false; - if (!this.#draft) return false; - - const { glyphLocal } = event.coords; - const delta = Vec2.sub(glyphLocal, state.bend.startPos); - const t = state.bend.t; - const w1 = 3 * (1 - t) ** 2 * t; - const w2 = 3 * (1 - t) * t ** 2; - const denom = w1 * w1 + w2 * w2; - if (Math.abs(denom) < 1e-12) return true; - - const delta1 = Vec2.scale(delta, w1 / denom); - const delta2 = Vec2.scale(delta, w2 / denom); - const { initialControlOne, initialControlTwo } = state.bend; - const newCp1 = Vec2.add(initialControlOne, delta1); - const newCp2 = Vec2.add(initialControlTwo, delta2); - - const updates = [ - { - kind: "point" as const, - id: state.bend.controlOneId, - x: newCp1.x, - y: newCp1.y, - }, - { - kind: "point" as const, - id: state.bend.controlTwoId, - x: newCp2.x, - y: newCp2.y, - }, - ]; - this.#draft.previewPositionPatch(updates); - this.#hasChanges = true; + void event; return true; } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Escape.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Escape.ts index 1ccfb3b4..2e6cc967 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Escape.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Escape.ts @@ -1,13 +1,9 @@ import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { KeyDownEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; export class Escape implements SelectBehavior { - onKeyDown( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"keyDown">, - ): boolean { + onKeyDown(state: SelectState, ctx: ToolContext, event: KeyDownEvent): boolean { if (event.key !== "Escape") return false; if (ctx.editor.selection.hasSelection()) { diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts index 4a3272e8..7da24da5 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Marquee.ts @@ -1,41 +1,38 @@ import { Rect, type Rect2D } from "@shift/geo"; import type { PointId } from "@shift/types"; import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; export class Marquee implements SelectBehavior { - onDragStart( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { + onDragStart(state: SelectState, ctx: ToolContext, event: DragStartEvent): boolean { if (state.type !== "ready") return false; - const localPoint = event.coords.glyphLocal; if (ctx.editor.selection.hasSelection()) { ctx.editor.selection.clear(); } ctx.setState({ type: "brushing", - selection: { startPos: localPoint, currentPos: localPoint }, + selection: { + startPos: event.coords.scene, + currentPos: event.coords.scene, + }, }); return true; } - onDrag(state: SelectState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { + onDrag(state: SelectState, ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "brushing") return false; - const localPoint = event.coords.glyphLocal; const rect = Rect.fromPoints(state.selection.startPos, state.selection.currentPos); const pointIds = this.getPointsInRect(rect, ctx); - ctx.editor.selection.select([...pointIds].map((id) => ({ kind: "point", id }))); + ctx.editor.selection.select([...pointIds].map((pointId) => ({ kind: "point", pointId }))); ctx.setState({ type: "brushing", - selection: { ...state.selection, currentPos: localPoint }, + selection: { ...state.selection, currentPos: event.coords.scene }, }); return true; } @@ -45,7 +42,7 @@ export class Marquee implements SelectBehavior { const rect = Rect.fromPoints(state.selection.startPos, state.selection.currentPos); const pointIds = this.getPointsInRect(rect, ctx); - ctx.editor.selection.select([...pointIds].map((id) => ({ kind: "point", id }))); + ctx.editor.selection.select([...pointIds].map((pointId) => ({ kind: "point", pointId }))); ctx.setState({ type: "ready" }); return true; @@ -61,10 +58,8 @@ export class Marquee implements SelectBehavior { } private getPointsInRect(rect: Rect2D, ctx: ToolContext): Set { - const instance = ctx.editor.previewGlyphInstance; - if (!instance) return new Set(); - - const hitPoints = instance.geometry.allPoints.filter((p) => Rect.containsPoint(rect, p)); - return new Set(hitPoints.map((p) => p.id)); + void rect; + void ctx; + return new Set(); } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Nudge.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Nudge.ts index 88e79b6c..188c6996 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Nudge.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Nudge.ts @@ -1,20 +1,16 @@ import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { KeyDownEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; import { NUDGES_VALUES, type NudgeMagnitude } from "@/types/nudge"; export class Nudge implements SelectBehavior { - onKeyDown( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"keyDown">, - ): boolean { + onKeyDown(state: SelectState, ctx: ToolContext, event: KeyDownEvent): boolean { if (state.type !== "ready") return false; const pointIds = [...ctx.editor.selection.pointIds]; if (pointIds.length === 0) return false; - const modifier: NudgeMagnitude = event.metaKey ? "large" : event.shiftKey ? "medium" : "small"; + const modifier: NudgeMagnitude = event.accelKey ? "large" : event.shiftKey ? "medium" : "small"; const nudgeValue = NUDGES_VALUES[modifier]; let dx = 0; diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts index 02ac8f83..72a1311a 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Resize.ts @@ -1,75 +1,31 @@ -import { Vec2, type Point2D, type Rect2D } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import type { BoundingRectEdge } from "../cursor"; -import type { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; import type { Select } from "../Select"; export class Resize implements SelectBehavior { - #draft: GlyphLayerEditDraft | null = null; - #origin: Point2D | null = null; - onDragStart( _state: SelectState, ctx: ToolContext, - event: ToolEventOf<"dragStart">, + event: DragStartEvent, ): boolean { if (!ctx.editor.selection.hasSelection()) return false; - const editor = ctx.editor; - if (!editor.previewGlyphInstance || !editor.editingGlyphLayer) return false; - - const bbHit = ctx.tool.boundingBox.hit(event.coords); - if (!bbHit) return false; - - if (bbHit.type !== "resize") return false; - const bounds = bbHit.rect; - - const edge = bbHit.edge; - const localPoint = event.coords.glyphLocal; - const anchorPoint = this.getAnchorPointForEdge(edge, bounds); - - this.#draft = editor.beginGlyphLayerEditDraft({ - points: [...editor.selection.pointIds], - anchors: [...editor.selection.anchorIds], - }); - this.#origin = anchorPoint; - - ctx.setState({ - type: "resizing", - resize: { - edge, - startPos: localPoint, - lastPos: localPoint, - initialBounds: bounds, - anchorPoint, - uniformScale: false, - }, - }); - - return true; + void ctx; + void event; + return false; } - onDrag( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"drag">, - ): boolean { - if (state.type !== "resizing") return false; - if (!this.#draft || !this.#origin) return false; - - const next = this.nextResizingState(state, event); - ctx.setState(next); - return true; + onDrag(state: SelectState, ctx: ToolContext, event: DragEvent): boolean { + void state; + void ctx; + void event; + return false; } onDragEnd(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "resizing") return false; - this.#draft?.commit(); - this.#cleanup(); - ctx.setState({ type: "ready" }); return true; } @@ -77,9 +33,6 @@ export class Resize implements SelectBehavior { onDragCancel(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "resizing") return false; - this.#draft?.discard(); - this.#cleanup(); - ctx.setState({ type: "ready" }); return true; } @@ -90,119 +43,4 @@ export class Resize implements SelectBehavior { editor.hover.clear(); } } - - #cleanup(): void { - this.#draft = null; - this.#origin = null; - } - - private nextResizingState(state: SelectState, event: ToolEventOf<"drag">): SelectState { - if (state.type !== "resizing") return state; - - const uniformScale = event.shiftKey; - const currentPos = event.coords.glyphLocal; - const { sx, sy } = this.calculateScaleFactors( - state.resize.edge, - currentPos, - state.resize.anchorPoint, - state.resize.initialBounds, - uniformScale, - ); - - this.#draft!.previewScale(sx, sy, this.#origin!); - - return { - type: "resizing", - resize: { - ...state.resize, - lastPos: currentPos, - uniformScale, - }, - }; - } - - private getAnchorPointForEdge(edge: Exclude, rect: Rect2D): Point2D { - const center = Vec2.midpoint({ x: rect.left, y: rect.top }, { x: rect.right, y: rect.bottom }); - - switch (edge) { - case "top-left": - return { x: rect.right, y: rect.top }; - case "top-right": - return { x: rect.left, y: rect.top }; - case "bottom-left": - return { x: rect.right, y: rect.bottom }; - case "bottom-right": - return { x: rect.left, y: rect.bottom }; - case "left": - return { x: rect.right, y: center.y }; - case "right": - return { x: rect.left, y: center.y }; - case "top": - return { x: center.x, y: rect.top }; - case "bottom": - return { x: center.x, y: rect.bottom }; - } - } - - private calculateScaleFactors( - edge: Exclude, - currentPos: Point2D, - anchorPoint: Point2D, - initialBounds: Rect2D, - uniform: boolean, - ): { sx: number; sy: number } { - const initialWidth = initialBounds.right - initialBounds.left; - const initialHeight = initialBounds.bottom - initialBounds.top; - - if (initialWidth === 0 || initialHeight === 0) { - return { sx: 1, sy: 1 }; - } - - const newWidth = Math.abs(currentPos.x - anchorPoint.x); - const newHeight = Math.abs(currentPos.y - anchorPoint.y); - - let sx = 1; - let sy = 1; - - const isCorner = edge.includes("-"); - const affectsX = edge === "left" || edge === "right" || isCorner; - const affectsY = edge === "top" || edge === "bottom" || isCorner; - - if (affectsX) { - sx = newWidth / initialWidth; - } - if (affectsY) { - sy = newHeight / initialHeight; - } - - if (uniform && isCorner) { - const uniformScale = Math.max(sx, sy); - sx = uniformScale; - sy = uniformScale; - } - - let flipX = false; - let flipY = false; - - if (edge === "left" || edge === "top-left" || edge === "bottom-left") { - flipX = currentPos.x > anchorPoint.x; - } else if (edge === "right" || edge === "top-right" || edge === "bottom-right") { - flipX = currentPos.x < anchorPoint.x; - } - - if (edge === "top-left" || edge === "top-right") { - flipY = currentPos.y < anchorPoint.y; - } else if (edge === "bottom-left" || edge === "bottom-right") { - flipY = currentPos.y > anchorPoint.y; - } else if (edge === "top") { - flipY = currentPos.y < anchorPoint.y; - } else if (edge === "bottom") { - flipY = currentPos.y > anchorPoint.y; - } - - if (flipX) sx = -sx; - if (flipY) sy = -sy; - - return { sx, sy }; - } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts index 4f785bab..e5c96af3 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Rotate.ts @@ -1,7 +1,7 @@ -import { Vec2, type Point2D } from "@shift/geo"; +import type { Point2D } from "@shift/geo"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; import type { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; import type { Select } from "../Select"; @@ -13,7 +13,7 @@ export class Rotate implements SelectBehavior { onDragStart( _state: SelectState, ctx: ToolContext, - event: ToolEventOf<"dragStart">, + event: DragStartEvent, ): boolean { if (!ctx.editor.selection.hasSelection()) return false; @@ -24,11 +24,7 @@ export class Rotate implements SelectBehavior { return true; } - onDrag( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"drag">, - ): boolean { + onDrag(state: SelectState, ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "rotating") return false; if (!this.#draft || !this.#origin) return false; @@ -78,64 +74,21 @@ export class Rotate implements SelectBehavior { private nextRotatingState( state: SelectState & { type: "rotating" }, - event: ToolEventOf<"drag">, + event: DragEvent, ): SelectState & { type: "rotating" } { - const currentPos = event.coords.glyphLocal; - const rawAngle = Vec2.angleTo(state.rotate.center, currentPos); - const deltaAngle = rawAngle - state.rotate.startAngle; - - const currentAngle = state.rotate.startAngle + deltaAngle; - - this.#draft!.previewRotate(deltaAngle, this.#origin!); - + void event; return { type: "rotating", rotate: { ...state.rotate, - lastPos: currentPos, - currentAngle, }, }; } - private tryStartRotate( - event: ToolEventOf<"dragStart">, - editor: Editor, - tool: Select, - ): SelectState | null { - const instance = editor.previewGlyphInstance; - if (!instance || !editor.editingGlyphLayer) return null; - - const bbHit = tool.boundingBox.hit(event.coords); - if (!bbHit) return null; - - if (bbHit.type !== "rotate") return null; - - const corner = bbHit.corner; - - const localPoint = event.coords.glyphLocal; - - const center = bbHit.center; - - const startAngle = Vec2.angleTo(center, localPoint); - - this.#draft = editor.beginGlyphLayerEditDraft({ - points: [...editor.selection.pointIds], - anchors: [...editor.selection.anchorIds], - }); - - this.#origin = center; - - return { - type: "rotating", - rotate: { - corner, - startPos: localPoint, - lastPos: localPoint, - center, - startAngle, - currentAngle: startAngle, - }, - }; + private tryStartRotate(event: DragStartEvent, editor: Editor, tool: Select): SelectState | null { + void event; + void editor; + void tool; + return null; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SegmentDoubleClick.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SegmentDoubleClick.ts index 98dee434..cde02f25 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SegmentDoubleClick.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SegmentDoubleClick.ts @@ -1,26 +1,39 @@ import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DoubleClickEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; export class SegmentDoubleClick implements SelectBehavior { onDoubleClick( state: SelectState, ctx: ToolContext, - event: ToolEventOf<"doubleClick">, + event: DoubleClickEvent, ): boolean { if (state.type !== "ready") return false; + if (event.target.kind != "segment") return false; - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; + const activeSourceId = ctx.editor.activeSourceId; + if (!activeSourceId) return false; - const geometry = instance.geometry; - const segmentHit = geometry.hitSegment(event.coords.glyphLocal, ctx.editor.hitRadius); - if (!segmentHit) return false; + const layer = ctx.editor.font.layer(event.target.glyphId, activeSourceId); + if (!layer) return false; - const segment = geometry.segment(segmentHit.segmentId); - if (!segment) return false; + const firstPoint = event.target.pointIds[0]; + if (!firstPoint) return false; - ctx.editor.selectAll(); + const contourId = layer.contourIdOfPoint(firstPoint); + if (!contourId) return false; + + const contour = layer.contour(contourId); + if (!contour) return false; + + ctx.editor.selection.clear(); + ctx.editor.selection.select([ + { kind: "contour", contourId }, + ...contour.points.map((point) => ({ + kind: "point" as const, + pointId: point.id, + })), + ]); return true; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SelectHover.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SelectHover.ts index b6c7c1d3..70de69ed 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SelectHover.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/SelectHover.ts @@ -1,12 +1,12 @@ import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { PointerMoveEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; export class SelectHover implements SelectBehavior { onPointerMove( state: SelectState, ctx: ToolContext, - event: ToolEventOf<"pointerMove">, + event: PointerMoveEvent, ): boolean { if (state.type === "idle") return false; @@ -21,36 +21,32 @@ export class SelectHover implements SelectBehavior { return false; } - const instance = ctx.editor.previewGlyphInstance; - if (!instance) { - ctx.editor.hover.clear(); - return false; - } - - const geometry = instance.geometry; - - const pos = event.coords.glyphLocal; - const radius = ctx.editor.hitRadius; - - const anchorHit = geometry.hitAnchor(pos, radius); - if (anchorHit) { - ctx.editor.hover.set({ type: "anchor", anchorId: anchorHit.anchorId }); - return false; - } - - const pointHit = geometry.hitPoint(pos, radius); - if (pointHit) { - ctx.editor.hover.set({ type: "point", pointId: pointHit.pointId }); - return false; - } - - const segmentHit = geometry.hitSegment(pos, radius); - if (segmentHit) { - ctx.editor.hover.set({ - type: "segment", - segmentId: segmentHit.segmentId, - }); - return false; + const target = event.target; + switch (target.kind) { + case "canvas": { + ctx.editor.hover.clear(); + return false; + } + + case "node": { + ctx.editor.hover.clear(); + return false; + } + + case "point": { + ctx.editor.hover.set({ kind: "point", pointId: target.id }); + return true; + } + + case "anchor": { + ctx.editor.hover.set({ kind: "anchor", anchorId: target.id }); + return true; + } + + case "segment": { + ctx.editor.hover.set({ kind: "segment", segmentId: target.id }); + return true; + } } ctx.editor.hover.clear(); diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Selection.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Selection.ts index 9e8a2f08..9ddcbc79 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Selection.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Selection.ts @@ -1,43 +1,41 @@ -import type { Selectable } from "@/lib/editor/Selection"; +import type { SelectionEntry } from "@/lib/editor/Selection"; import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { ClickEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; export class Selection implements SelectBehavior { - onClick(state: SelectState, ctx: ToolContext, event: ToolEventOf<"click">): boolean { + onClick(state: SelectState, ctx: ToolContext, event: ClickEvent): boolean { if (state.type !== "ready" && ctx.editor.selection.hasSelection()) return false; const editor = ctx.editor; - const instance = editor.previewGlyphInstance; - if (!instance) return false; + let items: SelectionEntry[] | null = null; + const target = event.target; - const geometry = instance.geometry; - const pos = event.coords.glyphLocal; - const radius = editor.hitRadius; - let items: Selectable[] | null = null; - - const anchorHit = geometry.hitAnchor(pos, radius); - if (anchorHit) { - items = [{ kind: "anchor", id: anchorHit.anchorId }]; - } + switch (target.kind) { + case "point": { + items = [{ kind: "point", pointId: target.id }]; + break; + } - if (!items) { - const pointHit = geometry.hitPoint(pos, radius); - if (pointHit) { - items = [{ kind: "point", id: pointHit.pointId }]; + case "anchor": { + items = [{ kind: "anchor", anchorId: target.id }]; + break; } - } - if (!items) { - const segmentHit = geometry.hitSegment(pos, radius); - const segment = segmentHit ? geometry.segment(segmentHit.segmentId) : null; + case "segment": { + const pointIds = target.pointIds; + const segmentPointSelection = pointIds.map((pointId) => ({ + kind: "point" as const, + pointId, + })); - if (segmentHit && segment) { - items = [ - { kind: "segment", id: segmentHit.segmentId }, - ...segment.pointIds.map((id) => ({ kind: "point" as const, id })), - ]; + items = [{ kind: "segment", segmentId: target.id }, ...segmentPointSelection]; + break; } + + case "canvas": + case "node": + break; } if (!items) { diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts index d6e80f9d..8dbdba1a 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts @@ -1,4 +1,4 @@ -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DoubleClickEvent } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; @@ -12,15 +12,11 @@ export class TextRunEdit implements SelectBehavior { onDoubleClick( state: SelectState, ctx: ToolContext, - event: ToolEventOf<"doubleClick">, + event: DoubleClickEvent, ): boolean { + void ctx; + void event; if (state.type !== "ready") return false; - - const run = ctx.editor.textRun; - const anchor = run.anchorAtPoint(event.point, ctx.editor.hitRadius); - if (!anchor) return false; - ctx.editor.setGlyphFocus(anchor); - - return true; + return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts index e46eec2e..8eaafc56 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunHover.ts @@ -1,4 +1,4 @@ -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { PointerMoveEvent } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; @@ -12,7 +12,7 @@ export class TextRunHover implements SelectBehavior { onPointerMove( state: SelectState, ctx: ToolContext, - event: ToolEventOf<"pointerMove">, + event: PointerMoveEvent, ): boolean { if (state.type !== "ready") return false; @@ -23,7 +23,7 @@ export class TextRunHover implements SelectBehavior { return false; } - const hit = layout.hitTest(event.point, ctx.editor.hitRadius); + const hit = layout.hitTest(event.coords.scene, ctx.editor.hitRadius); run.interaction.setHovered(hit?.cluster ?? null); return false; diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts index 7ba78765..03e7cf3b 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/ToggleSmooth.ts @@ -1,28 +1,15 @@ import type { ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DoubleClickEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import { Validate } from "@shift/validation"; -import { ToggleSmoothCommand } from "@/lib/commands/primitives"; export class ToggleSmooth implements SelectBehavior { onDoubleClick( state: SelectState, ctx: ToolContext, - event: ToolEventOf<"doubleClick">, + event: DoubleClickEvent, ): boolean { + void event; if (state.type !== "ready" && ctx.editor.selection.hasSelection()) return false; - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; - - const geometry = instance.geometry; - const hit = geometry.hitPoint(event.coords.glyphLocal, ctx.editor.hitRadius); - if (!hit) return false; - - const pointId = hit.pointId; - const point = geometry.point(hit.pointId); - if (!point || !Validate.isOnCurve(point)) return false; - - ctx.editor.commands.run(new ToggleSmoothCommand(pointId)); - return true; + return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts index d9a1678c..ce99dcb8 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/Translate.ts @@ -1,11 +1,11 @@ -import { Bounds, Vec2, type Point2D } from "@shift/geo"; +import { Vec2, type Point2D } from "@shift/geo"; import type { AnchorId, PointId } from "@shift/types"; import type { GeometryAnchorHit, GeometryPointHit, GeometrySegmentHit } from "@shift/glyph-state"; import type { ToolContext } from "../../core/Behavior"; import type { Editor } from "@/lib/editor/Editor"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import type { Selectable } from "@/lib/editor/Selection"; +import type { SelectionEntry } from "@/lib/editor/Selection"; import { constrainPreparedDrag, @@ -24,23 +24,19 @@ type TranslatingState = Extract; export class Translate implements SelectBehavior { #drag: TranslateDrag | null = null; - onDragStart( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { + onDragStart(state: SelectState, ctx: ToolContext, event: DragStartEvent): boolean { if (state.type !== "idle" && state.type !== "ready") return false; const target = TranslateTarget.fromDragStart(ctx.editor, event); if (!target) return false; target.applySelection(ctx.editor); - this.#drag = new TranslateDrag(ctx.editor, target, event.coords.glyphLocal); + this.#drag = new TranslateDrag(ctx.editor, target, event.coords.scene); ctx.setState(translatingState(this.#drag.startPos)); return true; } - onDrag(state: SelectState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { + onDrag(state: SelectState, ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "translating") return false; if (!this.#drag) return false; @@ -69,8 +65,8 @@ export class Translate implements SelectBehavior { } } - #nextTranslatingState(state: TranslatingState, event: ToolEventOf<"drag">): TranslatingState { - const currentPos = this.#drag!.positionForPointer(event.coords.glyphLocal); + #nextTranslatingState(state: TranslatingState, event: DragEvent): TranslatingState { + const currentPos = this.#drag!.positionForPointer(event.coords.scene); const totalDelta = Vec2.sub(currentPos, state.translate.startPos); this.#drag!.preview(totalDelta); @@ -88,13 +84,13 @@ export class Translate implements SelectBehavior { class TranslateTarget { readonly pointIds: readonly PointId[]; readonly anchorIds: readonly AnchorId[]; - readonly selection: readonly Selectable[] | null; + readonly selection: readonly SelectionEntry[] | null; readonly dragAnchor: GlyphLayerPositionTarget | null; private constructor( pointIds: readonly PointId[], anchorIds: readonly AnchorId[], - selection: readonly Selectable[] | null, + selection: readonly SelectionEntry[] | null, dragAnchor: GlyphLayerPositionTarget | null, ) { this.pointIds = [...pointIds]; @@ -103,24 +99,10 @@ class TranslateTarget { this.dragAnchor = dragAnchor; } - static fromDragStart(editor: Editor, event: ToolEventOf<"dragStart">): TranslateTarget | null { - const instance = editor.previewGlyphInstance; - if (!instance || !editor.editingGlyphLayer) return null; - - const geometry = instance.geometry; - const pos = event.coords.glyphLocal; - const radius = editor.hitRadius; - - const anchorHit = geometry.hitAnchor(pos, radius); - const pointHit = geometry.hitPoint(pos, radius); - const segmentHit = geometry.hitSegment(pos, radius); - - return ( - TranslateTarget.fromAnchorHit(editor, anchorHit) ?? - TranslateTarget.fromPointHit(editor, event, pointHit) ?? - TranslateTarget.fromSegmentHit(editor, geometry, event, segmentHit) ?? - TranslateTarget.fromInsideSelectionBounds(editor, pos) - ); + static fromDragStart(editor: Editor, event: DragStartEvent): TranslateTarget | null { + void editor; + void event; + return null; } applySelection(editor: Editor): void { @@ -133,25 +115,25 @@ class TranslateTarget { const selected = editor.selection.isSelected({ kind: "anchor", - id: hit.anchorId, + anchorId: hit.id, }); if (selected) { return TranslateTarget.fromSelection(editor, { kind: "anchor", - id: hit.anchorId, + id: hit.id, }); } - return new TranslateTarget([], [hit.anchorId], [{ kind: "anchor", id: hit.anchorId }], { + return new TranslateTarget([], [hit.id], [{ kind: "anchor", anchorId: hit.id }], { kind: "anchor", - id: hit.anchorId, + id: hit.id, }); } static fromPointHit( editor: Editor, - event: ToolEventOf<"dragStart">, + event: DragStartEvent, hit: GeometryPointHit | null, ): TranslateTarget | null { if (!hit) return null; @@ -162,31 +144,31 @@ class TranslateTarget { const selected = editor.selection.isSelected({ kind: "point", - id: hit.pointId, + pointId: hit.id, }); if (selected) { return TranslateTarget.fromSelection(editor, { kind: "point", - id: hit.pointId, + id: hit.id, }); } - return new TranslateTarget([hit.pointId], [], [{ kind: "point", id: hit.pointId }], { + return new TranslateTarget([hit.id], [], [{ kind: "point", pointId: hit.id }], { kind: "point", - id: hit.pointId, + id: hit.id, }); } static fromSegmentHit( editor: Editor, geometry: GlyphInstanceGeometry, - event: ToolEventOf<"dragStart">, + event: DragStartEvent, hit: GeometrySegmentHit | null, ): TranslateTarget | null { if (!hit) return null; - const segment = geometry.segment(hit.segmentId); + const segment = geometry.segment(hit.id); if (!segment) return null; const segmentPointIds = segment.pointIds; @@ -198,33 +180,29 @@ class TranslateTarget { const selected = editor.selection.isSelected({ kind: "segment", - id: hit.segmentId, + segmentId: hit.id, }); if (selected) { return TranslateTarget.fromSelection(editor); } - const pointIds = segmentPointIds.map((id) => ({ + const pointIds = segmentPointIds.map((pointId) => ({ kind: "point" as const, - id, + pointId, })); return new TranslateTarget( segmentPointIds, [], - [{ kind: "segment", id: hit.segmentId }, ...pointIds], + [{ kind: "segment", segmentId: hit.id }, ...pointIds], { kind: "point", id: segmentPointIds[0] }, ); } static fromInsideSelectionBounds(editor: Editor, pos: Point2D): TranslateTarget | null { - const bounds = editor.selection.bounds; - if (!bounds) return null; - - const inBounds = Bounds.containsPoint(bounds, pos); - if (!inBounds) return null; - - return TranslateTarget.fromSelection(editor); + void editor; + void pos; + return null; } static fromDuplicatedSelection(editor: Editor): TranslateTarget | null { @@ -234,7 +212,7 @@ class TranslateTarget { return new TranslateTarget( pointIds, [], - pointIds.map((id) => ({ kind: "point" as const, id })), + pointIds.map((pointId) => ({ kind: "point" as const, pointId })), { kind: "point", id: pointIds[0] }, ); } @@ -272,16 +250,13 @@ class TranslateDrag { constructor(editor: Editor, target: TranslateTarget, pointerStart: Point2D) { this.#target = target; - const instance = editor.previewGlyphInstance; this.#draft = editor.beginGlyphLayerEditDraft({ points: target.pointIds, anchors: target.anchorIds, }); - this.#constraint = instance - ? ConstrainedTranslate.fromGeometry(instance.geometry, target.pointIds) - : null; + this.#constraint = null; this.startPos = pointerStart; this.#pointerOffset = Vec2.sub(pointerStart, this.startPos); diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/UpgradeSegment.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/UpgradeSegment.ts index 24952303..b583b786 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/UpgradeSegment.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/UpgradeSegment.ts @@ -1,25 +1,12 @@ -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { ClickEvent } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; export class UpgradeSegment implements SelectBehavior { - onClick(state: SelectState, ctx: ToolContext, event: ToolEventOf<"click">): boolean { + onClick(state: SelectState, ctx: ToolContext, event: ClickEvent): boolean { + void ctx; + void event; if (state.type !== "ready" || !event.altKey) return false; - - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; - - const geometry = instance.geometry; - const hit = geometry.hitSegment(event.coords.glyphLocal, ctx.editor.hitRadius); - if (!hit) return false; - - const segment = geometry.segment(hit.segmentId); - if (!segment) return false; - - const line = segment.asLine(); - if (!line) return false; - - ctx.editor.upgradeLineToCubic(line); - return true; + return false; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts index b76e245b..a0acd340 100644 --- a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts @@ -12,7 +12,7 @@ describe("Shape tool", () => { editor.selectTool("shape"); }); - const contours = () => editor.editingGlyphLayer?.geometry.contours ?? []; + const contours = () => editor.glyphLayer?.geometry.contours ?? []; it("drag then release commits a closed 4-point rectangle contour", async () => { const contoursBefore = contours().length; diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/DraggingBehavior.ts b/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/DraggingBehavior.ts index 88476011..f0eec8f1 100644 --- a/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/DraggingBehavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/DraggingBehavior.ts @@ -1,11 +1,11 @@ import { createBehavior, type ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragEvent } from "../../core/GestureDetector"; import type { ShapeState } from "../types"; export const ShapeDraggingBehavior = createBehavior({ - onDrag(state: ShapeState, ctx: ToolContext, event: ToolEventOf<"drag">): boolean { + onDrag(state: ShapeState, ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "dragging") return false; - ctx.setState({ ...state, currentPos: event.point }); + ctx.setState({ ...state, currentPos: event.coords.scene }); return true; }, diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/ReadyBehavior.ts b/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/ReadyBehavior.ts index 8cec7d2a..e1cac0a7 100644 --- a/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/ReadyBehavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/shape/behaviors/ReadyBehavior.ts @@ -1,19 +1,15 @@ import { createBehavior, type ToolContext } from "../../core/Behavior"; -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { DragStartEvent } from "../../core/GestureDetector"; import type { ShapeState } from "../types"; export const ShapeReadyBehavior = createBehavior({ - onDragStart( - state: ShapeState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { + onDragStart(state: ShapeState, ctx: ToolContext, event: DragStartEvent): boolean { if (state.type !== "ready") return false; ctx.setState({ type: "dragging", - startPos: event.point, - currentPos: event.point, + startPos: event.coords.scene, + currentPos: event.coords.scene, }); return true; diff --git a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts index afc6266c..20aa8c69 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/Text.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/Text.ts @@ -2,7 +2,6 @@ import { BaseTool, type ToolName } from "../core/BaseTool"; import { TypingBehavior } from "./behaviors/TypingBehavior"; import type { TextBehavior, TextState } from "./types"; import type { CursorType } from "@/types/editor"; -import { glyphTextItem } from "@/lib/text/layout"; export class TextTool extends BaseTool { readonly id: ToolName = "text"; @@ -18,33 +17,13 @@ export class TextTool extends BaseTool { } override activate(): void { - const owner = this.editor.previewGlyphRecordCell.peek(); - if (!owner) { - this.state = { type: "typing" }; - return; - } - - const ownerName = owner.name; - const ownerUnicode = owner.unicodes[0] ?? null; - this.editor.font.loadGlyph(owner.id).catch((error) => { - console.error("failed to load text owner glyph", error); - }); - - const run = this.editor.textRuns.switchTo(ownerName); - run.seed(glyphTextItem(ownerName, ownerUnicode), this.editor.drawOffset.x); - run.interaction.suspend(); - run.setCursorVisible(true); - this.state = { type: "typing" }; - this.editor.enableProofMode(); } override deactivate(): void { const run = this.editor.textRun; run.setCursorVisible(false); run.interaction.resume(); - - this.editor.disableProofMode(); this.state = { type: "idle" }; } } diff --git a/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts b/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts index c8b6734a..f37b9210 100644 --- a/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts +++ b/apps/desktop/src/renderer/src/lib/tools/text/behaviors/TypingBehavior.ts @@ -1,4 +1,4 @@ -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { KeyDownEvent } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { TextBehavior, TextState } from "../types"; @@ -7,7 +7,7 @@ import type { TextBehavior, TextState } from "../types"; * text tool is active). */ export class TypingBehavior implements TextBehavior { - onKeyDown(state: TextState, ctx: ToolContext, event: ToolEventOf<"keyDown">): boolean { + onKeyDown(state: TextState, ctx: ToolContext, event: KeyDownEvent): boolean { if (state.type !== "typing") return false; if (event.key === "Escape") { ctx.editor.setActiveTool("select"); diff --git a/apps/desktop/src/renderer/src/lib/workspace/ledger.test.ts b/apps/desktop/src/renderer/src/lib/workspace/ledger.test.ts index 39f8826b..1b04216c 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/ledger.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/ledger.test.ts @@ -16,7 +16,7 @@ describe("workspace ledger semantics (via TestEditor)", () => { editor.selectTool("pen"); }); - const source = () => editor.editingGlyphLayer!; + const source = () => editor.glyphLayer!; it("undoes settled ticks in reverse order", async () => { editor.clickGlyphLocal(10, 10); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 51f79907..1d4b1c9b 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -12,18 +12,22 @@ */ import { Editor } from "@/lib/editor/Editor"; +import type { Glyph, GlyphInstance, GlyphLayer } from "@/lib/model/Glyph"; import type { ToolName } from "@/lib/tools/core"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { mintGlyphId, mintLayerId, + mintNodeId, type GlyphId, type GlyphName, type GlyphRecord, type Unicode, } from "@shift/types"; +import type { Contour } from "@shift/glyph-state"; import type { SystemClipboard } from "@/lib/clipboard"; import { createWorkspaceStack, type WorkspaceStack } from "./workspaceStack"; +import type { GlyphNode } from "@/types/node"; const DEFAULT_MODIFIERS = { shiftKey: false, altKey: false, metaKey: false }; @@ -61,8 +65,11 @@ export class TestEditor extends Editor { */ async startSession(name = "A", unicode: number | null = 65): Promise { await this.#stack.createWorkspace(); + this.selectSource(this.font.defaultSource.id); - const record = await this.#createAndOpenGlyph(name, unicode); + const glyph = await this.#createAndOpenGlyph(name, unicode); + const record = this.font.recordForName(glyph.handle.name); + if (!record) throw new Error("created glyph did not appear in the font directory"); this.#placeGlyph(record.id); return this; } @@ -72,7 +79,7 @@ export class TestEditor extends Editor { await this.#createAndOpenGlyph(name, unicode); } - async #createAndOpenGlyph(name: string, unicode: number | null): Promise { + async #createAndOpenGlyph(name: string, unicode: number | null): Promise { const glyphId = mintGlyphId(); const sourceId = this.font.defaultSource.id; const applied = await this.#stack.editCoordinator.apply([ @@ -97,17 +104,21 @@ export class TestEditor extends Editor { const record = applied.glyphs?.find((glyph) => glyph.name === name); if (!record) throw new Error("createGlyph did not echo the new record"); - const loaded = await this.font.loadGlyph(record.id, { + const glyph = await this.font.loadGlyph(record.id, { sourceIds: [sourceId], }); - if (!loaded) throw new Error("created glyph did not load"); - return record; + return glyph; } #placeGlyph(glyphId: GlyphId): void { this.scene.clear(); - const itemId = this.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); - this.scene.setGeometryItems([itemId]); + this.scene.addNode({ + id: mintNodeId(), + kind: "glyph", + glyphId, + sourceId: this.font.defaultSource.id, + position: { x: 0, y: 0 }, + }); } /** Awaits every queued and in-flight apply; geometry reads confirmed truth after. */ @@ -133,7 +144,43 @@ export class TestEditor extends Editor { } get pointCount(): number { - return this.editingGlyphLayer?.allPoints.length ?? 0; + return this.glyphLayer?.pointCount ?? 0; + } + + get glyphLayer(): GlyphLayer | null { + const sourceId = this.activeSourceId; + if (!sourceId) return null; + + const node = this.glyphNode; + if (!node) return null; + + return this.font.layer(node.glyphId, sourceId); + } + + get glyphNode(): GlyphNode | null { + return this.scene.nodes().find((node) => node.kind === "glyph") ?? null; + } + + get sceneGlyphInstance(): GlyphInstance | null { + const node = this.glyphNode; + if (!node) return null; + + return this.font.instance(node.glyphId, this.designLocationCell); + } + + get glyphRecord(): GlyphRecord | null { + const node = this.glyphNode; + if (!node) return null; + + return this.font.glyph(node.glyphId); + } + + get glyphContours(): readonly Contour[] { + return this.glyphLayer?.contours ?? []; + } + + get openContour(): Contour | null { + return this.glyphContours.find((contour) => !contour.closed) ?? null; } click(x: number, y: number, options?: Partial): this { @@ -149,7 +196,7 @@ export class TestEditor extends Editor { * screen coordinates, which the viewport y-flips. */ clickGlyphLocal(x: number, y: number, options?: Partial): this { - const { screen } = this.fromGlyphLocal({ x, y }); + const screen = this.projectSceneToScreen({ x, y }); return this.click(screen.x, screen.y, options); } diff --git a/apps/desktop/src/renderer/src/testing/coordinates.ts b/apps/desktop/src/renderer/src/testing/coordinates.ts index 81d3a555..44b1ac81 100644 --- a/apps/desktop/src/renderer/src/testing/coordinates.ts +++ b/apps/desktop/src/renderer/src/testing/coordinates.ts @@ -3,5 +3,5 @@ import type { Coordinates } from "@/types/coordinates"; /** For tests: build Coordinates with the same point in all three spaces. */ export function makeTestCoordinates(point: Point2D): Coordinates { - return { screen: { ...point }, scene: { ...point }, glyphLocal: { ...point } }; + return { screen: { ...point }, scene: { ...point } }; } diff --git a/apps/desktop/src/renderer/src/types/coordinates.ts b/apps/desktop/src/renderer/src/types/coordinates.ts index 5e848be2..4891d3e5 100644 --- a/apps/desktop/src/renderer/src/types/coordinates.ts +++ b/apps/desktop/src/renderer/src/types/coordinates.ts @@ -1,14 +1,14 @@ import type { Point2D } from "@shift/geo"; +export type ScreenPoint = Point2D; +export type ScenePoint = Point2D; +export type NodePoint = Point2D; + /** - * A point in the active tool coordinate spaces. + * A point in the active coordinate spaces. * - * `glyphLocal` is the current active glyph-local coordinate used by existing - * tools. It is not enough to identify a local coordinate for an arbitrary - * placed scene item; item-local conversion requires an `ItemId`. */ export interface Coordinates { - readonly screen: Point2D; - readonly scene: Point2D; - readonly glyphLocal: Point2D; + readonly screen: ScreenPoint; + readonly scene: ScenePoint; } diff --git a/apps/desktop/src/renderer/src/types/node.ts b/apps/desktop/src/renderer/src/types/node.ts new file mode 100644 index 00000000..7a217458 --- /dev/null +++ b/apps/desktop/src/renderer/src/types/node.ts @@ -0,0 +1,16 @@ +import type { Point2D } from "@shift/geo"; +import type { GlyphId, NodeId, SourceId } from "@shift/types"; + +export interface Node { + id: NodeId; + kind: string; + position: Point2D; +} + +export interface GlyphNode extends Node { + readonly kind: "glyph"; + readonly glyphId: GlyphId; + readonly sourceId: SourceId; +} + +export type ShiftNode = GlyphNode; diff --git a/apps/desktop/src/renderer/src/types/target.ts b/apps/desktop/src/renderer/src/types/target.ts new file mode 100644 index 00000000..4b356734 --- /dev/null +++ b/apps/desktop/src/renderer/src/types/target.ts @@ -0,0 +1,33 @@ +import type { GlyphAnchorHit, GlyphHit, GlyphPointHit, GlyphSegmentHit } from "@shift/glyph-state"; +import type { GlyphId, NodeId, PointId } from "@shift/types"; +import type { NodePoint, ScenePoint } from "./coordinates"; +import type { ShiftNode } from "./node"; + +type GlyphHitTarget = Hit & { + readonly nodeId: NodeId; + readonly glyphId: GlyphId; + readonly point: NodePoint; +}; + +export type GlyphPointTarget = GlyphHitTarget; + +export type GlyphAnchorTarget = GlyphHitTarget; + +export type GlyphSegmentTarget = GlyphHitTarget & { + readonly pointIds: readonly PointId[]; +}; + +export type GlyphEditTarget = GlyphPointTarget | GlyphAnchorTarget | GlyphSegmentTarget; + +export interface NodeTarget { + readonly kind: "node"; + readonly node: ShiftNode; + readonly point: NodePoint; +} + +export interface CanvasTarget { + readonly kind: "canvas"; + readonly point: ScenePoint; +} + +export type PointerTarget = CanvasTarget | NodeTarget | GlyphEditTarget; diff --git a/packages/glyph-state/src/GlyphGeometry.ts b/packages/glyph-state/src/GlyphGeometry.ts index 64c35f24..983e57ce 100644 --- a/packages/glyph-state/src/GlyphGeometry.ts +++ b/packages/glyph-state/src/GlyphGeometry.ts @@ -42,23 +42,36 @@ export type GlyphPosition = export type GlyphPositions = readonly GlyphPosition[]; -export interface GeometryPointHit extends PointHit { - readonly type: "point"; - readonly pointId: PointId; +type GlyphHitIdByKind = { + readonly point: PointId; + readonly anchor: AnchorId; + readonly segment: SegmentId; +}; + +export type GlyphHitKind = keyof GlyphHitIdByKind; + +interface GlyphHitBase { + readonly kind: K; + readonly id: GlyphHitIdByKind[K]; + readonly distance: number; } -export interface GeometryAnchorHit extends AnchorHit { - readonly type: "anchor"; - readonly anchorId: AnchorId; -} +export type GeometryPointHit = GlyphHitBase<"point"> & PointHit; -export interface GeometrySegmentHit extends SegmentHit { - readonly type: "segment"; - readonly segmentId: SegmentId; -} +export type GeometryAnchorHit = GlyphHitBase<"anchor"> & AnchorHit; + +export type GeometrySegmentHit = GlyphHitBase<"segment"> & SegmentHit; export type GeometryHit = GeometryPointHit | GeometryAnchorHit | GeometrySegmentHit; +export type GlyphPointHit = GeometryPointHit; + +export type GlyphAnchorHit = GeometryAnchorHit; + +export type GlyphSegmentHit = GeometrySegmentHit; + +export type GlyphHit = GeometryHit; + /** * Immutable geometry view over a glyph structure and value buffer. * @@ -155,8 +168,8 @@ export class GlyphGeometry { const hit = Point.hit(point, pos, radius); if (hit && (!best || hit.distance < best.distance)) { best = { - type: "point", - pointId: point.id, + kind: "point", + id: point.id, distance: hit.distance, }; } @@ -172,8 +185,8 @@ export class GlyphGeometry { const hit = anchor.hit(pos, radius); if (hit && (!best || hit.distance < best.distance)) { best = { - type: "anchor", - anchorId: anchor.id, + kind: "anchor", + id: anchor.id, distance: hit.distance, }; } @@ -182,6 +195,12 @@ export class GlyphGeometry { return best; } + hitAt(pos: Point2D, radius: number): GeometryHit | null { + return ( + this.hitAnchor(pos, radius) ?? this.hitPoint(pos, radius) ?? this.hitSegment(pos, radius) + ); + } + hitSegment(pos: Point2D, radius: number): GeometrySegmentHit | null { let best: GeometrySegmentHit | null = null; @@ -189,8 +208,8 @@ export class GlyphGeometry { const hit = segment.hit(pos, radius); if (hit && (!best || hit.distance < best.distance)) { best = { - type: "segment", - segmentId: segment.id, + kind: "segment", + id: segment.id, t: hit.t, closestPoint: hit.closestPoint, distance: hit.distance, diff --git a/packages/glyph-state/src/index.ts b/packages/glyph-state/src/index.ts index 84141a46..729cfd73 100644 --- a/packages/glyph-state/src/index.ts +++ b/packages/glyph-state/src/index.ts @@ -9,9 +9,14 @@ export { type GeometryHit, type GeometryPointHit, type GeometrySegmentHit, + type GlyphAnchorHit, + type GlyphHit, + type GlyphHitKind, + type GlyphPointHit, type GlyphPosition, type GlyphPositions, type GlyphPositionTarget, + type GlyphSegmentHit, type GlyphSidebearings, } from "./GlyphGeometry"; export { diff --git a/packages/types/src/ids.ts b/packages/types/src/ids.ts index b91dafab..94764065 100644 --- a/packages/types/src/ids.ts +++ b/packages/types/src/ids.ts @@ -16,8 +16,8 @@ declare const AxisIdBrand: unique symbol; declare const ComponentIdBrand: unique symbol; declare const GuidelineIdBrand: unique symbol; declare const GlyphIdBrand: unique symbol; -declare const ItemIdBrand: unique symbol; declare const LayerIdBrand: unique symbol; +declare const NodeIdBrand: unique symbol; declare const SourceIdBrand: unique symbol; /** @@ -64,21 +64,21 @@ export type GuidelineId = string & { readonly [GuidelineIdBrand]: typeof Guideli */ export type GlyphId = string & { readonly [GlyphIdBrand]: typeof GlyphIdBrand }; -/** - * A scene item identifier minted by the renderer. - * - * Item ids identify a placed object on an editor scene. They are placement - * identity only; commands that mutate authored glyph geometry must resolve the - * glyph layer separately from document glyph identity and designspace location. - */ -export type ItemId = string & { readonly [ItemIdBrand]: typeof ItemIdBrand }; - /** * A layer identifier from Rust. * Branded string type - can't be confused with other IDs or plain strings. */ export type LayerId = string & { readonly [LayerIdBrand]: typeof LayerIdBrand }; +/** + * A scene node identifier minted by the renderer. + * + * Node ids identify placed editor nodes. They are placement identity only; + * commands that mutate authored glyph geometry must resolve the glyph layer + * separately from document glyph identity and designspace location. + */ +export type NodeId = string & { readonly [NodeIdBrand]: typeof NodeIdBrand }; + /** * A source identifier from Rust. * Branded string type - can't be confused with other IDs or plain strings. @@ -141,14 +141,6 @@ export function asGlyphId(id: string): GlyphId { return id as GlyphId; } -/** - * Convert a scene item ID string to a typed ItemId. - * Use this only for persisted or test-provided scene item identities. - */ -export function asItemId(id: string): ItemId { - return id as ItemId; -} - /** * Convert a string ID from Rust to a typed LayerId. * Use this when receiving IDs from Rust snapshots. @@ -157,6 +149,14 @@ export function asLayerId(id: string): LayerId { return id as LayerId; } +/** + * Convert a scene node ID string to a typed NodeId. + * Use this only for persisted or test-provided scene node identities. + */ +export function asNodeId(id: string): NodeId { + return id as NodeId; +} + /** * Convert a string ID from Rust to a typed SourceId. * Use this when receiving IDs from Rust snapshots. @@ -222,18 +222,18 @@ export function isValidGlyphId(id: unknown): id is GlyphId { } /** - * Type guard to check if a value is a valid ItemId. + * Type guard to check if a value is a valid LayerId. * Useful for runtime validation in debug builds. */ -export function isValidItemId(id: unknown): id is ItemId { +export function isValidLayerId(id: unknown): id is LayerId { return typeof id === "string" && id.length > 0; } /** - * Type guard to check if a value is a valid LayerId. + * Type guard to check if a value is a valid NodeId. * Useful for runtime validation in debug builds. */ -export function isValidLayerId(id: unknown): id is LayerId { +export function isValidNodeId(id: unknown): id is NodeId { return typeof id === "string" && id.length > 0; } @@ -257,8 +257,8 @@ type MintedIdByPrefix = { component: ComponentId; guideline: GuidelineId; glyph: GlyphId; - item: ItemId; layer: LayerId; + node: NodeId; source: SourceId; }; @@ -312,16 +312,16 @@ export function mintGlyphId(): GlyphId { return mintPrefixedId("glyph"); } -/** Mints a new scene item id. See {@link ItemId}. */ -export function mintItemId(): ItemId { - return mintPrefixedId("item"); -} - /** Mints a new layer id. See {@link mintPointId}. */ export function mintLayerId(): LayerId { return mintPrefixedId("layer"); } +/** Mints a new scene node id. See {@link NodeId}. */ +export function mintNodeId(): NodeId { + return mintPrefixedId("node"); +} + /** Mints a new source id. See {@link mintPointId}. */ export function mintSourceId(): SourceId { return mintPrefixedId("source"); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 72dff2d8..f5e1468d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,8 +11,8 @@ export type { ComponentId, GuidelineId, GlyphId, - ItemId, LayerId, + NodeId, SourceId, } from "./ids"; export { @@ -23,8 +23,8 @@ export { asComponentId, asGuidelineId, asGlyphId, - asItemId, asLayerId, + asNodeId, asSourceId, isValidPointId, isValidContourId, @@ -33,16 +33,16 @@ export { isValidComponentId, isValidGuidelineId, isValidGlyphId, - isValidItemId, isValidLayerId, + isValidNodeId, isValidSourceId, mintContourId, mintAnchorId, mintAxisId, mintPointId, mintGlyphId, - mintItemId, mintLayerId, + mintNodeId, mintSourceId, } from "./ids"; From 44a812a90064c56e13b431a3190a6376751e5fcb Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 5 Jul 2026 09:09:14 +0100 Subject: [PATCH 2/2] Remove global glyph state --- .../src/components/editor/BooleanOps.tsx | 8 +- .../renderer/src/components/editor/Canvas.tsx | 27 +- .../src/components/editor/GlyphFinder.tsx | 188 ----- .../src/components/editor/RightSidebar.tsx | 16 +- .../src/components/editor/ToolsPane.tsx | 2 +- .../editor/sidebar-right/ScaleSection.tsx | 30 +- .../editor/sidebar-right/TransformSection.tsx | 57 +- .../src/components/variation/AxesPanel.tsx | 2 +- .../renderer/src/hooks/useSelectionBounds.ts | 16 +- .../src/lib/clipboard/Clipboard.test.ts | 133 ++-- .../renderer/src/lib/clipboard/Clipboard.ts | 16 +- .../src/lib/clipboard/ClipboardSelection.ts | 54 +- .../lib/clipboard/importers/SvgImporter.ts | 6 +- .../src/renderer/src/lib/clipboard/index.ts | 3 +- .../src/renderer/src/lib/clipboard/types.ts | 22 +- .../clipboard/ClipboardCommands.test.ts | 112 --- .../commands/clipboard/ClipboardCommands.ts | 87 --- .../src/lib/commands/clipboard/index.ts | 1 - .../renderer/src/lib/commands/core/Command.ts | 22 - .../src/lib/commands/core/CommandRunner.ts | 32 - .../renderer/src/lib/commands/core/index.ts | 2 - .../renderer/src/lib/commands/docs/DOCS.md | 121 ---- .../src/renderer/src/lib/commands/index.ts | 23 - .../primitives/BezierCommands.test.ts | 232 ------ .../lib/commands/primitives/BezierCommands.ts | 169 ----- .../primitives/BooleanOperationCommand.ts | 23 - .../commands/primitives/PointCommands.test.ts | 40 -- .../lib/commands/primitives/PointCommands.ts | 16 - .../commands/primitives/ShapeCommands.test.ts | 43 -- .../lib/commands/primitives/ShapeCommands.ts | 33 - .../primitives/SidebearingCommands.test.ts | 73 -- .../primitives/SidebearingCommands.ts | 46 -- .../src/lib/commands/primitives/index.ts | 14 - .../commands/transform/AlignmentCommands.ts | 51 -- .../transform/TransformCommands.test.ts | 81 --- .../commands/transform/TransformCommands.ts | 95 --- .../src/lib/commands/transform/index.ts | 8 - .../renderer/src/lib/editor/Editor.test.ts | 205 ++---- .../src/renderer/src/lib/editor/Editor.ts | 678 ++++++++++++------ .../src/renderer/src/lib/editor/Hover.ts | 37 +- .../src/renderer/src/lib/editor/Scene.ts | 157 ++-- .../renderer/src/lib/editor/Selection.test.ts | 186 ++--- .../src/renderer/src/lib/editor/Selection.ts | 236 ++---- .../src/renderer/src/lib/editor/docs/DOCS.md | 6 +- .../src/lib/editor/rendering/RenderFrame.ts | 80 ++- .../renderer/src/lib/editor/rendering/Text.ts | 114 --- .../lib/editor/rendering/overlays/Handles.ts | 6 +- .../lib/editor/rendering/overlays/Segments.ts | 27 +- .../rendering/overlays/handles/HandleItems.ts | 8 +- .../src/lib/keyboard/KeyboardRouter.test.ts | 27 +- .../src/renderer/src/lib/keyboard/keymaps.ts | 13 +- .../src/renderer/src/lib/keyboard/types.ts | 4 +- .../src/renderer/src/lib/model/Font.test.ts | 44 +- .../src/renderer/src/lib/model/Font.ts | 315 ++++---- .../renderer/src/lib/model/FontStore.test.ts | 222 +++++- .../src/renderer/src/lib/model/FontStore.ts | 391 ++++++---- .../src/renderer/src/lib/model/Glyph.test.ts | 4 +- .../src/renderer/src/lib/model/Glyph.ts | 161 ++++- .../src/lib/model/GlyphLayerGeometry.test.ts | 337 +++++++++ .../renderer/src/lib/model/GlyphLayerState.ts | 4 + .../renderer/src/lib/model/variation.test.ts | 25 +- .../renderer/src/lib/objects/AnchorObject.ts | 55 ++ .../renderer/src/lib/objects/ContourObject.ts | 61 ++ .../renderer/src/lib/objects/NodeObject.ts | 36 + .../renderer/src/lib/objects/PointObject.ts | 56 ++ .../renderer/src/lib/objects/SegmentObject.ts | 68 ++ .../src/renderer/src/lib/objects/index.ts | 5 + .../src/renderer/src/lib/signals/docs/DOCS.md | 2 +- .../src/renderer/src/lib/store/ShiftStore.ts | 51 ++ .../lib/tools/core/GestureDetector.test.ts | 240 ++----- .../src/lib/tools/core/ToolManager.test.ts | 4 + .../src/lib/tools/core/ToolManager.ts | 6 +- .../src/renderer/src/lib/tools/docs/DOCS.md | 8 +- .../renderer/src/lib/tools/pen/Pen.test.ts | 5 +- .../src/renderer/src/lib/tools/pen/Pen.ts | 2 +- .../renderer/src/lib/tools/pen/PenPreview.ts | 7 +- .../renderer/src/lib/tools/pen/PenStroke.ts | 22 +- .../src/lib/tools/select/BoundingBox.test.ts | 28 +- .../src/lib/tools/select/BoundingBox.ts | 196 ++++- .../renderer/src/lib/tools/select/Segments.ts | 29 - .../src/lib/tools/select/Select.test.ts | 332 ++++++++- .../renderer/src/lib/tools/select/Select.ts | 2 - .../lib/tools/select/behaviors/BendCurve.ts | 75 +- .../src/lib/tools/select/behaviors/Marquee.ts | 42 +- .../src/lib/tools/select/behaviors/Nudge.ts | 7 +- .../src/lib/tools/select/behaviors/Resize.ts | 187 ++++- .../src/lib/tools/select/behaviors/Rotate.ts | 54 +- .../select/behaviors/SegmentDoubleClick.ts | 14 +- .../lib/tools/select/behaviors/SelectHover.ts | 11 +- .../lib/tools/select/behaviors/Selection.ts | 28 +- .../lib/tools/select/behaviors/TextRunEdit.ts | 22 - .../tools/select/behaviors/ToggleSmooth.ts | 16 +- .../lib/tools/select/behaviors/Translate.ts | 248 ++++--- .../tools/select/behaviors/UpgradeSegment.ts | 10 +- .../select/behaviors/selectedGeometryEdit.ts | 79 ++ .../src/renderer/src/lib/tools/shape/Shape.ts | 32 +- .../renderer/src/lib/transform/docs/DOCS.md | 32 +- .../src/renderer/src/lib/transform/index.ts | 16 +- .../src/lib/workspace/LayerIntents.ts | 7 +- .../src/lib/workspace/WorkspaceClient.ts | 2 +- .../WorkspaceEditCoordinator.test.ts | 57 +- .../lib/workspace/WorkspaceEditCoordinator.ts | 98 ++- .../renderer/src/lib/workspace/ledger.test.ts | 17 +- .../renderer/src/perf/workspaceApply.bench.ts | 4 +- .../renderer/src/testing/TestEditor.test.ts | 98 +++ .../src/renderer/src/testing/TestEditor.ts | 88 ++- apps/desktop/src/renderer/src/types/index.ts | 37 + apps/desktop/src/renderer/src/types/node.ts | 35 + apps/desktop/src/renderer/src/types/object.ts | 142 ++++ .../desktop/src/renderer/src/types/records.ts | 24 + apps/desktop/src/renderer/src/types/target.ts | 11 +- .../desktop/src/renderer/src/views/Editor.tsx | 41 +- apps/desktop/src/shared/workspace/protocol.ts | 1 - .../utility/workspace/WorkspaceHost.test.ts | 4 +- crates/shift-bridge/__test__/index.spec.mjs | 4 +- crates/shift-bridge/index.d.ts | 1 - crates/shift-bridge/src/bridge.rs | 18 +- crates/shift-wire/src/bridges/napi/mod.rs | 9 +- crates/shift-wire/src/lib.rs | 1 - docs/architecture/index.md | 15 +- packages/glyph-state/src/Segment.test.ts | 45 ++ packages/glyph-state/src/Segment.ts | 35 +- packages/glyph-state/src/index.ts | 3 + packages/types/src/bridge/generated.ts | 1 - packages/types/src/ids.ts | 102 +-- packages/types/src/index.ts | 20 +- packages/validation/docs/DOCS.md | 6 +- .../validation/src/ValidateClipboard.test.ts | 26 +- packages/validation/src/ValidateClipboard.ts | 4 +- 129 files changed, 4514 insertions(+), 3721 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx delete mode 100644 apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/clipboard/index.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/core/Command.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/core/CommandRunner.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/core/index.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md delete mode 100644 apps/desktop/src/renderer/src/lib/commands/index.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/primitives/index.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts delete mode 100644 apps/desktop/src/renderer/src/lib/commands/transform/index.ts delete mode 100644 apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts create mode 100644 apps/desktop/src/renderer/src/lib/model/GlyphLayerGeometry.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/objects/AnchorObject.ts create mode 100644 apps/desktop/src/renderer/src/lib/objects/ContourObject.ts create mode 100644 apps/desktop/src/renderer/src/lib/objects/NodeObject.ts create mode 100644 apps/desktop/src/renderer/src/lib/objects/PointObject.ts create mode 100644 apps/desktop/src/renderer/src/lib/objects/SegmentObject.ts create mode 100644 apps/desktop/src/renderer/src/lib/objects/index.ts create mode 100644 apps/desktop/src/renderer/src/lib/store/ShiftStore.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/select/Segments.ts delete mode 100644 apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts create mode 100644 apps/desktop/src/renderer/src/lib/tools/select/behaviors/selectedGeometryEdit.ts create mode 100644 apps/desktop/src/renderer/src/types/index.ts create mode 100644 apps/desktop/src/renderer/src/types/object.ts create mode 100644 apps/desktop/src/renderer/src/types/records.ts create mode 100644 packages/glyph-state/src/Segment.test.ts diff --git a/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx b/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx index 495d20f2..c42f6343 100644 --- a/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx +++ b/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx @@ -5,13 +5,17 @@ import UnionIcon from "@/assets/sidebar-right/union.svg"; import IntersectIcon from "@/assets/sidebar-right/intersect.svg"; import SubtractIcon from "@/assets/sidebar-right/subtract.svg"; import { useEditor } from "@/workspace/WorkspaceContext"; +import { useSignalState } from "@/lib/signals"; +import { isContourId } from "@shift/types"; export const BooleanOps = () => { const editor = useEditor(); - const selectedContourIds = editor.selection.contourIds; + const selection = useSignalState(editor.selection.stateCell); + const selectedContourIds = selection.ids.filter(isContourId); - if (selectedContourIds.size < 2) return null; + if (selectedContourIds.length < 2) return null; const [contourIdA, contourIdB] = selectedContourIds; + if (!contourIdA || !contourIdB) return null; return ( diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index b3af8ff0..242570f1 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -20,25 +20,28 @@ export const Canvas: FC = () => { const containerRef = useRef(null); const cursorStyle = useSignalState(editor.cursorCell); + const activeSourceId = useSignalState(editor.activeSourceIdCell); const glyphId = glyphIdParam ? asGlyphId(glyphIdParam) : null; useEffect(() => { if (!glyphId) { editor.scene.clear(); - return undefined; + return; } - editor.scene.clear(); - editor.scene.addNode({ - id: mintNodeId(), - kind: "glyph", - glyphId, - sourceId: editor.activeSourceId ?? editor.font.defaultSource.id, - position: { x: 0, y: 0 }, - }); - - return () => editor.scene.clear(); - }, [editor, glyphId]); + editor.scene.setNodes([ + { + id: mintNodeId(), + type: "node", + kind: "glyph", + parentId: null, + index: "a0", + glyphId, + sourceId: activeSourceId ?? editor.font.defaultSource.id, + position: { x: 0, y: 0 }, + }, + ]); + }, [activeSourceId, editor, glyphId]); useEffect(() => { const element = containerRef.current; diff --git a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx deleted file mode 100644 index 0cf7d830..00000000 --- a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Dialog, - DialogBackdrop, - DialogPortal, - DialogPopup, - Input, - Search, - Separator, -} from "@shift/ui"; -import { formatCodepointAsUPlus } from "@/lib/utils/unicode"; -import type { SearchResult } from "@shift/glyph-info"; -import { useFocusZone } from "@/context/FocusZoneContext"; -import { getGlyphInfo } from "@/workspace/glyphInfo"; - -interface GlyphFinderProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSelect: (codepoint: number) => void; -} - -function glyphChar(codepoint: number): string { - try { - return String.fromCodePoint(codepoint); - } catch { - return ""; - } -} - -export function GlyphFinder({ open, onOpenChange, onSelect }: GlyphFinderProps) { - const { lockToZone, unlock } = useFocusZone(); - - const [query, setQuery] = useState(""); - - const [results, setResults] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - - const inputRef = useRef(null); - const listRef = useRef(null); - - useEffect(() => { - if (open) { - lockToZone("modal"); - setQuery(""); - setResults([]); - setSelectedIndex(0); - return () => unlock(); - } - unlock(); - return undefined; - }, [open, lockToZone, unlock]); - - const handleSearch = useCallback((value: string) => { - setQuery(value); - if (value.trim() === "") { - setResults([]); - setSelectedIndex(0); - return; - } - const hits = getGlyphInfo().search(value, 50); - setResults(hits); - setSelectedIndex(0); - }, []); - - const stopPropagation = useCallback((e: React.KeyboardEvent) => { - e.nativeEvent.stopImmediatePropagation(); - }, []); - - const commitSelection = useCallback( - (codepoint: number) => { - onSelect(codepoint); - }, - [onSelect], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - e.nativeEvent.stopImmediatePropagation(); - switch (e.key) { - case "ArrowDown": { - e.preventDefault(); - setSelectedIndex((prev) => { - const next = Math.min(prev + 1, results.length - 1); - scrollItemIntoView(next); - return next; - }); - break; - } - case "ArrowUp": { - e.preventDefault(); - setSelectedIndex((prev) => { - const next = Math.max(prev - 1, 0); - scrollItemIntoView(next); - return next; - }); - break; - } - case "Enter": { - e.preventDefault(); - if (results.length > 0 && selectedIndex < results.length) { - commitSelection(results[selectedIndex].codepoint); - } - break; - } - } - }, - [results, selectedIndex, commitSelection], - ); - - const scrollItemIntoView = (index: number) => { - requestAnimationFrame(() => { - const list = listRef.current; - if (!list) return; - const item = list.children[index] as HTMLElement | undefined; - item?.scrollIntoView({ block: "nearest" }); - }); - }; - - const selectedColour = (index: number) => - index === selectedIndex ? "bg-accent/10 text-accent" : "text-primary hover:bg-muted/10"; - - return ( - - - - -
- handleSearch(e.target.value)} - placeholder="Search " - className="h-8 pl-8 bg-transparent text-sm focus:ring-0 focus:ring-transparent focus:outline-none" - icon={} - iconPosition="left" - /> - {results.length > 0 && ( - <> - -
- {results.map((result, index) => ( -
{ - e.preventDefault(); - e.nativeEvent.stopImmediatePropagation(); - commitSelection(result.codepoint); - }} - onMouseEnter={() => setSelectedIndex(index)} - > -
- - {glyphChar(result.codepoint)} - -
- - {result.glyphName ?? "—"} - - - {formatCodepointAsUPlus(result.codepoint)} - -
-
-
- ))} -
- - )} - {query.trim() !== "" && results.length === 0 && ( -

No results found.

- )} -
-
-
-
- ); -} diff --git a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx index 212d0d68..21974281 100644 --- a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx @@ -1,11 +1,10 @@ -import { useState } from "react"; import { Separator } from "@shift/ui"; +import { isAnchorId, isPointId } from "@shift/types"; import { TransformSection } from "./sidebar-right/TransformSection"; import { ScaleSection } from "./sidebar-right/ScaleSection"; import { TransformOriginProvider } from "@/context/TransformOriginContext"; import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; -import { useSignalEffect } from "@/hooks/useSignalEffect"; import { GlyphSection } from "./sidebar-right/GlyphSection"; import { AnchorSection } from "./sidebar-right/AnchorSection"; import { BooleanOps } from "./BooleanOps"; @@ -13,19 +12,12 @@ import { BooleanOps } from "./BooleanOps"; export const RightSidebar = () => { const editor = useEditor(); const zoom = useSignalState(editor.zoomCell); + const selection = useSignalState(editor.selection.stateCell); const zoomPercent = Math.round(zoom * 100); const { familyName } = editor.font.metadata; - const [hasPointSelection, setHasPointSelection] = useState(false); - const [hasAnchorSelection, setHasAnchorSelection] = useState(false); - - useSignalEffect(() => { - editor.selection.stateCell.value; - const nextPoints = editor.selection.pointIds.size > 0; - const nextAnchors = editor.selection.anchorIds.size > 0; - setHasPointSelection((prev) => (prev === nextPoints ? prev : nextPoints)); - setHasAnchorSelection((prev) => (prev === nextAnchors ? prev : nextAnchors)); - }); + const hasPointSelection = selection.ids.some(isPointId); + const hasAnchorSelection = selection.ids.some(isAnchorId); return (
); }; diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index f418850c..153c23e0 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -32,7 +32,6 @@ export type WorkspaceGlyphLayerSnapshot = { export type WorkspaceGlyphSnapshotRequest = { glyphId: GlyphId; - sourceIds: SourceId[]; }; export type WorkspaceGlyphSnapshot = { diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index 0efe467b..ddd36e25 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -628,7 +628,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { if (!glyphId) throw new Error("createGlyph did not echo glyph id"); const snapshots = await sync.call("workspace.glyphSnapshots", { - requests: [{ glyphId, sourceIds: [snapshot.sources[0].id] }], + requests: [{ glyphId }], }); expect(snapshots).toHaveLength(1); expect(snapshots[0].glyphId).toBe(glyphId); @@ -639,7 +639,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { const missing = mintGlyphId(); await expect( sync.call("workspace.glyphSnapshots", { - requests: [{ glyphId: missing, sourceIds: [snapshot.sources[0].id] }], + requests: [{ glyphId: missing }], }), ).resolves.toEqual([]); }); diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index ba029f48..21f4b23c 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -78,9 +78,7 @@ describe("Bridge", () => { function glyphState(name) { const glyph = bridge.getGlyphs().find((record) => record.name === name); - const snapshots = bridge.getGlyphSnapshots([ - { glyphId: glyph.id, sourceIds: [defaultSourceId()] }, - ]); + const snapshots = bridge.getGlyphSnapshots([{ glyphId: glyph.id }]); return snapshots[0]?.layers[0]?.state; } diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index 56eae32b..b9f2be24 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -335,7 +335,6 @@ export interface NapiGlyphSnapshot { export interface NapiGlyphSnapshotRequest { glyphId: GlyphId - sourceIds: SourceId[] } export interface NapiGlyphState { diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 046ef11b..9becfdfa 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -485,7 +485,6 @@ impl Bridge { for request in requests { let request = GlyphSnapshotRequest::from(request); let glyph_id = request.glyph_id; - let source_ids = request.source_ids; let Some(glyph) = font.glyph(glyph_id.clone()) else { continue; }; @@ -494,9 +493,10 @@ impl Bridge { .variation_build_for_glyph(glyph)? .and_then(|(_, build)| build.variation_data); - let layers = source_ids - .into_iter() - .filter_map(|source_id| glyph.layer_for_source(source_id)) + let layers = glyph + .layers() + .values() + .map(|layer| layer.as_ref()) .map(|layer| GlyphLayerSnapshot { glyph_id: glyph_id.clone(), source_id: layer.source_id(), @@ -1504,14 +1504,18 @@ mod tests { let snapshots = bridge .get_glyph_snapshots(vec![NapiGlyphSnapshotRequest { glyph_id: glyph_id.to_string(), - source_ids: vec![source_id.to_string()], }]) .unwrap(); snapshots .into_iter() .next() - .and_then(|snapshot| snapshot.layers.into_iter().next()) + .and_then(|snapshot| { + snapshot + .layers + .into_iter() + .find(|layer| layer.source_id == source_id) + }) .map(|layer| layer.state) } @@ -2284,12 +2288,10 @@ mod tests { fn get_glyph_snapshots_returns_none_for_missing_glyph() { let bridge = bridge_with_workspace(); let missing_glyph_id = shift_font::GlyphId::new().to_string(); - let source_id = default_source_id(&bridge); let snapshots = bridge .get_glyph_snapshots(vec![NapiGlyphSnapshotRequest { glyph_id: missing_glyph_id, - source_ids: vec![source_id], }]) .unwrap(); diff --git a/crates/shift-wire/src/bridges/napi/mod.rs b/crates/shift-wire/src/bridges/napi/mod.rs index c9177cae..bca76d34 100644 --- a/crates/shift-wire/src/bridges/napi/mod.rs +++ b/crates/shift-wire/src/bridges/napi/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use napi::bindgen_prelude::Float64Array; use napi_derive::napi; -use shift_font::{GlyphId, PointType as IrPointType, SourceId}; +use shift_font::{GlyphId, PointType as IrPointType}; use crate::{ AnchorData, Axis, AxisTent, ComponentData, ContourData, FontMetadata, FontMetrics, @@ -245,19 +245,12 @@ impl From for NapiGlyphLayerSnapshot { pub struct NapiGlyphSnapshotRequest { #[napi(ts_type = "GlyphId")] pub glyph_id: String, - #[napi(ts_type = "SourceId[]")] - pub source_ids: Vec, } impl From for GlyphSnapshotRequest { fn from(request: NapiGlyphSnapshotRequest) -> Self { Self { glyph_id: GlyphId::from_raw(request.glyph_id), - source_ids: request - .source_ids - .into_iter() - .map(SourceId::from_raw) - .collect(), } } } diff --git a/crates/shift-wire/src/lib.rs b/crates/shift-wire/src/lib.rs index e66f8436..ad605c30 100644 --- a/crates/shift-wire/src/lib.rs +++ b/crates/shift-wire/src/lib.rs @@ -239,7 +239,6 @@ pub struct GlyphLayerSnapshot { #[serde(rename_all = "camelCase")] pub struct GlyphSnapshotRequest { pub glyph_id: GlyphId, - pub source_ids: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/docs/architecture/index.md b/docs/architecture/index.md index efba0d05..c33756ee 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -13,13 +13,13 @@ Central routing table for Shift's distributed documentation. Before creating new ### Rust crates -| Path pattern | Canonical doc | Purpose | -| --------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------- | -| `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | -| `crates/shift-font/**` | [`crates/shift-font/docs/DOCS.md`](../../crates/shift-font/docs/DOCS.md) | First-class Rust font object model and editing behavior | -| `crates/shift-source/**` | [`crates/shift-source/docs/DOCS.md`](../../crates/shift-source/docs/DOCS.md) | User-authored `.shift` source package layout | -| `crates/shift-workspace/**` | [`crates/shift-workspace/docs/DOCS.md`](../../crates/shift-workspace/docs/DOCS.md) | Open font workspace runtime over source, store, and font | -| `crates/shift-bridge/**` | [`crates/shift-bridge/docs/DOCS.md`](../../crates/shift-bridge/docs/DOCS.md) | NAPI bridge exposing Rust to Node.js/Electron | +| Path pattern | Canonical doc | Purpose | +| --------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | +| `crates/shift-font/**` | [`crates/shift-font/docs/DOCS.md`](../../crates/shift-font/docs/DOCS.md) | First-class Rust font object model and editing behavior | +| `crates/shift-source/**` | [`crates/shift-source/docs/DOCS.md`](../../crates/shift-source/docs/DOCS.md) | User-authored `.shift` source package layout | +| `crates/shift-workspace/**` | [`crates/shift-workspace/docs/DOCS.md`](../../crates/shift-workspace/docs/DOCS.md) | Open font workspace runtime over source, store, and font | +| `crates/shift-bridge/**` | [`crates/shift-bridge/docs/DOCS.md`](../../crates/shift-bridge/docs/DOCS.md) | NAPI bridge exposing Rust to Node.js/Electron | ### Desktop app — Electron shell @@ -38,7 +38,6 @@ Central routing table for Shift's distributed documentation. Before creating new | `apps/desktop/src/renderer/src/lib/tools/**` | [`apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/tools/docs/DOCS.md) | State machine-based tool system (BaseTool, behaviors, actions) | | `apps/desktop/src/renderer/src/lib/graphics/**` | [`apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md) | Rendering abstraction with Canvas 2D backend and path caching | | `apps/desktop/src/renderer/src/lib/transform/**` | [`apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md) | Geometry transforms: rotate, scale, reflect selected points | -| `apps/desktop/src/renderer/src/lib/commands/**` | [`apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md) | Command pattern with undo/redo for all editing operations | | `apps/desktop/src/renderer/src/lib/signals/**` | [`apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md`](../../apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md) | Fine-grained reactivity: dependency tracking and efficient updates | ### Packages diff --git a/packages/glyph-state/src/Segment.test.ts b/packages/glyph-state/src/Segment.test.ts new file mode 100644 index 00000000..2569db2b --- /dev/null +++ b/packages/glyph-state/src/Segment.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { asContourId, asPointId } from "@shift/types"; +import { Contour } from "./Contour"; +import { isSegmentId, parseSegmentId, segmentIdFor } from "./Segment"; + +describe("segment ids", () => { + it("derive from endpoint point ids with a runtime-discriminable prefix", () => { + const startPointId = asPointId("point_start"); + const endPointId = asPointId("point_end"); + const segmentId = segmentIdFor(startPointId, endPointId); + + expect(segmentId).toBe("segment:point_start:point_end"); + expect(isSegmentId(segmentId)).toBe(true); + expect(parseSegmentId(segmentId)).toEqual({ startPointId, endPointId }); + }); + + it("rejects unprefixed or incomplete segment ids", () => { + expect(isSegmentId("point_start:point_end")).toBe(false); + expect(parseSegmentId("point_start:point_end")).toBeNull(); + expect(parseSegmentId("segment:point_start")).toBeNull(); + expect(parseSegmentId("segment::point_end")).toBeNull(); + expect(parseSegmentId("segment:point_start:")).toBeNull(); + }); + + it("uses prefixed ids for parsed contour segments", () => { + const contour = new Contour( + { + id: asContourId("contour_1"), + closed: false, + points: [ + { id: asPointId("point_1"), pointType: "onCurve", smooth: false }, + { id: asPointId("point_2"), pointType: "onCurve", smooth: false }, + { id: asPointId("point_3"), pointType: "onCurve", smooth: false }, + ], + }, + new Float64Array([500, 0, 0, 100, 0, 200, 0]), + 1, + ); + + expect(contour.segments().map((segment) => segment.id)).toEqual([ + "segment:point_1:point_2", + "segment:point_2:point_3", + ]); + }); +}); diff --git a/packages/glyph-state/src/Segment.ts b/packages/glyph-state/src/Segment.ts index 58ee7cfe..8a2aea46 100644 --- a/packages/glyph-state/src/Segment.ts +++ b/packages/glyph-state/src/Segment.ts @@ -9,10 +9,42 @@ export type SegmentId = string & { readonly [SegmentIdBrand]: typeof SegmentIdBrand; }; +const SEGMENT_ID_PREFIX = "segment:"; + export function asSegmentId(id: string): SegmentId { return id as SegmentId; } +/** Returns the derived segment id for a segment's endpoint point ids. */ +export function segmentIdFor(startPointId: PointId, endPointId: PointId): SegmentId { + return asSegmentId(`${SEGMENT_ID_PREFIX}${startPointId}:${endPointId}`); +} + +/** + * Parses a runtime-discriminable segment id into endpoint point ids. + * + * @param segmentId - Candidate segment id using the `segment:start:end` format. + * @returns null when the value is not a prefixed segment id. + */ +export function parseSegmentId( + segmentId: string, +): { startPointId: PointId; endPointId: PointId } | null { + if (!segmentId.startsWith(SEGMENT_ID_PREFIX)) return null; + + const body = segmentId.slice(SEGMENT_ID_PREFIX.length); + const separatorIndex = body.indexOf(":"); + if (separatorIndex <= 0 || separatorIndex === body.length - 1) return null; + + const startPointId = body.slice(0, separatorIndex) as PointId; + const endPointId = body.slice(separatorIndex + 1) as PointId; + return { startPointId, endPointId }; +} + +/** Returns whether a value is a runtime-discriminable segment id. */ +export function isSegmentId(id: unknown): id is SegmentId { + return typeof id === "string" && parseSegmentId(id) !== null; +} + export type LineSegment = { type: "line"; points: { @@ -185,8 +217,7 @@ export class Segment { get id(): SegmentId { if (this.#id === null) { - const id = asSegmentId(`${this.startId}:${this.endId}`); - this.#id = id; + this.#id = segmentIdFor(this.startId, this.endId); } return this.#id; } diff --git a/packages/glyph-state/src/index.ts b/packages/glyph-state/src/index.ts index 729cfd73..aec83e80 100644 --- a/packages/glyph-state/src/index.ts +++ b/packages/glyph-state/src/index.ts @@ -22,6 +22,9 @@ export { export { Segment, asSegmentId, + isSegmentId, + parseSegmentId, + segmentIdFor, type SegmentId, type SegmentHit, type SegmentType, diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index d790cd04..8b73d4a9 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -340,7 +340,6 @@ export interface GlyphSnapshot { export interface GlyphSnapshotRequest { glyphId: GlyphId - sourceIds: SourceId[] } export interface GlyphState { diff --git a/packages/types/src/ids.ts b/packages/types/src/ids.ts index 94764065..26d1a2c4 100644 --- a/packages/types/src/ids.ts +++ b/packages/types/src/ids.ts @@ -2,10 +2,10 @@ * Branded ID types for type-safe identification of font entities. * * These types ensure compile-time safety when working with IDs across the - * TS/Rust boundary. Ids are prefixed strings (`point_`). The renderer - * MINTS ids for entities it creates (client-minted ids: verbs return - * identity synchronously; Rust validates and honors them); all other ids - * come from Rust. + * TS/Rust boundary. Most ids are prefixed strings (`point_`). The + * renderer MINTS ids for entities it creates (client-minted ids: verbs return + * identity synchronously; Rust validates and honors them); all other ids come + * from Rust. */ // Branded type symbols (never exported, just for type branding) @@ -165,84 +165,54 @@ export function asSourceId(id: string): SourceId { return id as SourceId; } -/** - * Type guard to check if a value is a valid PointId. - * Useful for runtime validation in debug builds. - */ -export function isValidPointId(id: unknown): id is PointId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable point id. */ +export function isPointId(id: unknown): id is PointId { + return hasIdPrefix(id, "point"); } -/** - * Type guard to check if a value is a valid ContourId. - * Useful for runtime validation in debug builds. - */ -export function isValidContourId(id: unknown): id is ContourId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable contour id. */ +export function isContourId(id: unknown): id is ContourId { + return hasIdPrefix(id, "contour"); } -/** - * Type guard to check if a value is a valid AnchorId. - * Useful for runtime validation in debug builds. - */ -export function isValidAnchorId(id: unknown): id is AnchorId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable anchor id. */ +export function isAnchorId(id: unknown): id is AnchorId { + return hasIdPrefix(id, "anchor"); } -/** - * Type guard to check if a value is a valid AxisId. - * Useful for runtime validation in debug builds. - */ -export function isValidAxisId(id: unknown): id is AxisId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable axis id. */ +export function isAxisId(id: unknown): id is AxisId { + return hasIdPrefix(id, "axis"); } -/** - * Type guard to check if a value is a valid ComponentId. - * Useful for runtime validation in debug builds. - */ -export function isValidComponentId(id: unknown): id is ComponentId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable component id. */ +export function isComponentId(id: unknown): id is ComponentId { + return hasIdPrefix(id, "component"); } -/** - * Type guard to check if a value is a valid GuidelineId. - * Useful for runtime validation in debug builds. - */ -export function isValidGuidelineId(id: unknown): id is GuidelineId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable guideline id. */ +export function isGuidelineId(id: unknown): id is GuidelineId { + return hasIdPrefix(id, "guideline"); } -/** - * Type guard to check if a value is a valid GlyphId. - * Useful for runtime validation in debug builds. - */ -export function isValidGlyphId(id: unknown): id is GlyphId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable glyph id. */ +export function isGlyphId(id: unknown): id is GlyphId { + return hasIdPrefix(id, "glyph"); } -/** - * Type guard to check if a value is a valid LayerId. - * Useful for runtime validation in debug builds. - */ -export function isValidLayerId(id: unknown): id is LayerId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable layer id. */ +export function isLayerId(id: unknown): id is LayerId { + return hasIdPrefix(id, "layer"); } -/** - * Type guard to check if a value is a valid NodeId. - * Useful for runtime validation in debug builds. - */ -export function isValidNodeId(id: unknown): id is NodeId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable scene node id. */ +export function isNodeId(id: unknown): id is NodeId { + return hasIdPrefix(id, "node"); } -/** - * Type guard to check if a value is a valid SourceId. - * Useful for runtime validation in debug builds. - */ -export function isValidSourceId(id: unknown): id is SourceId { - return typeof id === "string" && id.length > 0; +/** Returns whether a value is a runtime-discriminable source id. */ +export function isSourceId(id: unknown): id is SourceId { + return hasIdPrefix(id, "source"); } const SHORT_ID_ALPHABET = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; @@ -284,6 +254,10 @@ function mintPrefixedId( return `${prefix}_${mintShortIdSuffix()}` as MintedIdByPrefix[Prefix]; } +function hasIdPrefix(id: unknown, prefix: keyof MintedIdByPrefix): boolean { + return typeof id === "string" && id.startsWith(`${prefix}_`); +} + /** * Mints a new point id. Client-minted ids let editing verbs return identity * synchronously; Rust honors them and rejects duplicates. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f5e1468d..fac1b3ec 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -26,16 +26,16 @@ export { asLayerId, asNodeId, asSourceId, - isValidPointId, - isValidContourId, - isValidAnchorId, - isValidAxisId, - isValidComponentId, - isValidGuidelineId, - isValidGlyphId, - isValidLayerId, - isValidNodeId, - isValidSourceId, + isPointId, + isContourId, + isAnchorId, + isAxisId, + isComponentId, + isGuidelineId, + isGlyphId, + isLayerId, + isNodeId, + isSourceId, mintContourId, mintAnchorId, mintAxisId, diff --git a/packages/validation/docs/DOCS.md b/packages/validation/docs/DOCS.md index b37900ff..acdbd8bd 100644 --- a/packages/validation/docs/DOCS.md +++ b/packages/validation/docs/DOCS.md @@ -26,7 +26,7 @@ validation/src/ - `ValidationErrorCode` -- union of all failure codes: `EMPTY_SEQUENCE`, `MUST_START_WITH_ON_CURVE`, `MUST_END_WITH_ON_CURVE`, `TOO_MANY_CONSECUTIVE_OFF_CURVE`, `ORPHAN_OFF_CURVE`, `INCOMPLETE_SEGMENT`, `INVALID_CLIPBOARD_CONTENT`. - `PointLike` -- minimal `{ pointType: PointType }` interface accepted by all point-sequence validators. Full `Point` objects, snapshots, and test stubs all satisfy it. - `Validate` -- namespace object with point predicates (`isOnCurve`, `isOffCurve`), pattern matchers (`matchesLinePattern`, `matchesQuadPattern`, `matchesCubicPattern`), sequence validators (`sequence`, `canFormSegments`), boolean shortcuts (`isValidSequence`, `canFormValidSegments`, `hasValidAnchor`), and result constructors (`ok`, `fail`, `error`). -- `ValidateClipboard` -- namespace object with `isClipboardContent` (validates contour array shape) and `isClipboardPayload` (validates full `shift/glyph-data` envelope with format, version, metadata, content). +- `ValidateClipboard` -- namespace object with `isShiftContent` (validates portable contour content) and `isClipboardPayload` (validates full `shift/glyph-data` envelope with format, version, metadata, content). ## How it works @@ -40,7 +40,7 @@ Boolean shortcuts (`isValidSequence`, `canFormValidSegments`, `hasValidAnchor`) ### Clipboard validation -`ValidateClipboard.isClipboardContent` validates the contour/point structure of clipboard data. `isClipboardPayload` checks the full envelope (format string `"shift/glyph-data"`, version number, metadata with timestamp). Used by `Clipboard` when parsing pasted content. +`ValidateClipboard.isShiftContent` validates the contour/point structure of portable Shift content. `isClipboardPayload` checks the full envelope (format string `"shift/glyph-data"`, version number, metadata with timestamp). Used by `Clipboard` when parsing pasted content. ## Workflow recipes @@ -68,6 +68,6 @@ cd packages/validation && npx tsc --noEmit ## Related -- `Clipboard` -- uses `Validate.hasValidAnchor` for copy eligibility and `ValidateClipboard.isClipboardContent` for paste parsing +- `Clipboard` -- uses `Validate.hasValidAnchor` for copy eligibility and `ValidateClipboard.isShiftContent` for paste parsing - `Segments` / `Segment` -- uses `Validate.isOnCurve` / `Validate.isOffCurve` for segment decomposition - `PointType` from `@shift/types` -- the underlying union (`"onCurve" | "offCurve"`) that `PointLike` wraps diff --git a/packages/validation/src/ValidateClipboard.test.ts b/packages/validation/src/ValidateClipboard.test.ts index 6de1a6de..8a135480 100644 --- a/packages/validation/src/ValidateClipboard.test.ts +++ b/packages/validation/src/ValidateClipboard.test.ts @@ -28,63 +28,63 @@ const validPayload = () => ({ }); describe("ValidateClipboard", () => { - describe("isClipboardContent", () => { + describe("isShiftContent", () => { it("accepts valid content with multiple contours", () => { const content = { contours: [validContour(), validContour()] }; - expect(ValidateClipboard.isClipboardContent(content)).toBe(true); + expect(ValidateClipboard.isShiftContent(content)).toBe(true); }); it("accepts empty contours array", () => { - expect(ValidateClipboard.isClipboardContent({ contours: [] })).toBe(true); + expect(ValidateClipboard.isShiftContent({ contours: [] })).toBe(true); }); it("rejects non-object", () => { - expect(ValidateClipboard.isClipboardContent(null)).toBe(false); - expect(ValidateClipboard.isClipboardContent("string")).toBe(false); - expect(ValidateClipboard.isClipboardContent(42)).toBe(false); + expect(ValidateClipboard.isShiftContent(null)).toBe(false); + expect(ValidateClipboard.isShiftContent("string")).toBe(false); + expect(ValidateClipboard.isShiftContent(42)).toBe(false); }); it("rejects missing contours", () => { - expect(ValidateClipboard.isClipboardContent({})).toBe(false); + expect(ValidateClipboard.isShiftContent({})).toBe(false); }); it("rejects non-array contours", () => { - expect(ValidateClipboard.isClipboardContent({ contours: "bad" })).toBe(false); + expect(ValidateClipboard.isShiftContent({ contours: "bad" })).toBe(false); }); it("rejects contour with invalid point", () => { const content = { contours: [{ points: [{ x: 0, y: 0 }], closed: true }], }; - expect(ValidateClipboard.isClipboardContent(content)).toBe(false); + expect(ValidateClipboard.isShiftContent(content)).toBe(false); }); it("rejects contour with missing closed field", () => { const content = { contours: [{ points: [validPoint()] }], }; - expect(ValidateClipboard.isClipboardContent(content)).toBe(false); + expect(ValidateClipboard.isShiftContent(content)).toBe(false); }); it("rejects contour with non-array points", () => { const content = { contours: [{ points: "bad", closed: true }], }; - expect(ValidateClipboard.isClipboardContent(content)).toBe(false); + expect(ValidateClipboard.isShiftContent(content)).toBe(false); }); it("rejects point with non-finite coordinates", () => { const content = { contours: [{ points: [{ ...validPoint(), x: Infinity }], closed: true }], }; - expect(ValidateClipboard.isClipboardContent(content)).toBe(false); + expect(ValidateClipboard.isShiftContent(content)).toBe(false); }); it("rejects point with invalid pointType", () => { const content = { contours: [{ points: [{ ...validPoint(), pointType: "cubic" }], closed: true }], }; - expect(ValidateClipboard.isClipboardContent(content)).toBe(false); + expect(ValidateClipboard.isShiftContent(content)).toBe(false); }); }); diff --git a/packages/validation/src/ValidateClipboard.ts b/packages/validation/src/ValidateClipboard.ts index 7082525d..73c417af 100644 --- a/packages/validation/src/ValidateClipboard.ts +++ b/packages/validation/src/ValidateClipboard.ts @@ -23,7 +23,7 @@ function isValidContour(v: unknown): boolean { } export const ValidateClipboard = { - isClipboardContent(v: unknown): boolean { + isShiftContent(v: unknown): boolean { if (!isRecord(v)) return false; if (!Array.isArray(v.contours)) return false; return v.contours.every((c: unknown) => isValidContour(c)); @@ -35,6 +35,6 @@ export const ValidateClipboard = { if (typeof v.version !== "number") return false; if (!isRecord(v.metadata)) return false; if (typeof v.metadata.timestamp !== "number") return false; - return ValidateClipboard.isClipboardContent(v.content); + return ValidateClipboard.isShiftContent(v.content); }, } as const;