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/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 f52dc2f2..242570f1 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,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 (!glyphIdParam) { + if (!glyphId) { editor.scene.clear(); - return undefined; + return; } - const glyphId = asGlyphId(glyphIdParam); - if (!editor.font.glyph(glyphId)) { - editor.scene.clear(); - return undefined; - } - - editor.scene.clear(); - const itemId = editor.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); - editor.scene.setGeometryItems([itemId]); - - return () => editor.scene.clear(); - }, [editor, glyphIdParam]); + 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/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..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(() => { - const { pointIds, anchorIds } = editor.selection.stateCell.value; - const nextPoints = pointIds.size > 0; - const nextAnchors = 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/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/ScaleSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx index 0b97ac13..043e0435 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx @@ -1,21 +1,39 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { SidebarSection } from "./SidebarSection"; import { TransformGrid } from "./TransformGrid"; import { EditableSidebarInput, type EditableSidebarInputHandle } from "./EditableSidebarInput"; import { useTransformOrigin } from "@/context/TransformOriginContext"; import { useEditor } from "@/workspace/WorkspaceContext"; +import { useActiveSourceId } from "@/hooks/useActiveSourceId"; import { anchorToPoint } from "@/lib/transform/anchor"; +import { useSignalState } from "@/lib/signals"; import { Bounds } from "@shift/geo"; import ScaleIcon from "@/assets/sidebar-right/scale.svg"; import { useSelectionBounds } from "@/hooks/useSelectionBounds"; +import { isPointId } from "@shift/types"; export const ScaleSection = () => { const editor = useEditor(); + const sourceId = useActiveSourceId(); + const scene = useSignalState(editor.scene.cell); + const selection = useSignalState(editor.selection.stateCell); const { anchor, setAnchor } = useTransformOrigin(); const selectionBounds = useSelectionBounds(); const widthRef = useRef(null); const heightRef = useRef(null); + const layer = useMemo(() => { + if (!sourceId) return null; + + const glyphNodes = scene.nodes.filter((node) => node.kind === "glyph"); + if (glyphNodes.length !== 1) return null; + + const [node] = glyphNodes; + if (!node) return null; + + return editor.font.layer(node.glyphId, sourceId); + }, [editor, scene, sourceId]); + const selectedPointIds = useMemo(() => selection.ids.filter(isPointId), [selection]); useEffect(() => { if (!widthRef.current || !heightRef.current) return; @@ -30,6 +48,7 @@ export const ScaleSection = () => { const handleSizeChange = useCallback( (dimension: "width" | "height", value: number) => { + if (!layer) return; if (!selectionBounds) return; const current = @@ -38,18 +57,19 @@ export const ScaleSection = () => { const factor = value / current; const anchorPoint = anchorToPoint(anchor, selectionBounds); - editor.scaleSelection(factor, factor, anchorPoint); + layer.scale(selectedPointIds, factor, factor, anchorPoint); }, - [anchor, editor, selectionBounds], + [anchor, layer, selectedPointIds, selectionBounds], ); const handleScaleChange = useCallback( (scale: number) => { + if (!layer) return; if (!selectionBounds) return; const anchorPoint = anchorToPoint(anchor, selectionBounds); - editor.scaleSelection(scale, scale, anchorPoint); + layer.scale(selectedPointIds, scale, scale, anchorPoint); }, - [anchor, editor, selectionBounds], + [anchor, layer, selectedPointIds, selectionBounds], ); return ( 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..5a1f1f3e 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 @@ -4,6 +4,7 @@ import { EditableSidebarInput, type EditableSidebarInputHandle } from "./Editabl import { IconButton } from "./IconButton"; import { useTransformOrigin } from "@/context/TransformOriginContext"; import { useEditor } from "@/workspace/WorkspaceContext"; +import { useActiveSourceId } from "@/hooks/useActiveSourceId"; import { anchorToPoint } from "@/lib/transform/anchor"; import { useSignalState } from "@/lib/signals"; import { useSelectionBounds } from "@/hooks/useSelectionBounds"; @@ -22,6 +23,7 @@ import DistributeHorizontalIcon from "@/assets/sidebar-right/distribute-h.svg"; import DistributeVerticalIcon from "@/assets/sidebar-right/distribute-v.svg"; import { AlignmentType, DistributeType } from "@/lib/transform/types"; +import { isPointId } from "@shift/types"; const AlignButtonsRow = React.memo(function AlignButtonsRow({ onAlign, @@ -91,16 +93,30 @@ const DistributeButtonsRow = React.memo(function DistributeButtonsRow({ export const TransformSection = () => { const editor = useEditor(); + const sourceId = useActiveSourceId(); + const scene = useSignalState(editor.scene.cell); const { anchor } = useTransformOrigin(); - const selectedPointIds = useSignalState(editor.selection.stateCell).pointIds; + const selection = useSignalState(editor.selection.stateCell); + const selectedPointIds = useMemo(() => selection.ids.filter(isPointId), [selection]); const selectionBounds = useSelectionBounds(); const [rotation, setRotation] = useState(0); const xRef = useRef(null); const yRef = useRef(null); + const layer = useMemo(() => { + if (!sourceId) return null; + + const glyphNodes = scene.nodes.filter((node) => node.kind === "glyph"); + if (glyphNodes.length !== 1) return null; + + const [node] = glyphNodes; + if (!node) return null; + + return editor.font.layer(node.glyphId, sourceId); + }, [editor, scene, sourceId]); useEffect(() => { - if (selectedPointIds.size === 0) { + if (selectedPointIds.length === 0) { xRef.current?.setValue(0); yRef.current?.setValue(0); return; @@ -112,20 +128,24 @@ export const TransformSection = () => { yRef.current?.setValue(Math.round(selectionBounds.min.y)); }, [selectedPointIds, selectionBounds]); - const canDistribute = selectedPointIds.size >= 3; + const canDistribute = selectedPointIds.length >= 3; const handleAlign = useCallback( (alignment: AlignmentType) => { - editor.alignSelection(alignment); + if (!layer) return; + + layer.align(selectedPointIds, alignment); }, - [editor], + [layer, selectedPointIds], ); const handleDistribute = useCallback( (type: DistributeType) => { - editor.distributeSelection(type); + if (!layer) return; + + layer.distribute(selectedPointIds, type); }, - [editor], + [layer, selectedPointIds], ); const origin = useMemo( @@ -134,32 +154,42 @@ export const TransformSection = () => { ); const handleRotate90 = () => { - editor.rotate90CW(); + if (!layer || !origin) return; + + layer.rotate(selectedPointIds, -Math.PI / 2, origin); }; const handleRotate = (angle: number) => { + if (!layer || !origin) return; + const wrapped = angle % 360; const radians = (wrapped * Math.PI) / 180; - editor.rotateSelection(radians, origin); + layer.rotate(selectedPointIds, radians, origin); setRotation(wrapped); }; const handleFlipH = () => { - editor.reflectSelection("vertical", origin); + if (!layer || !origin) return; + + layer.reflect(selectedPointIds, "vertical", origin); }; const handleFlipV = () => { - editor.reflectSelection("horizontal", origin); + if (!layer || !origin) return; + + layer.reflect(selectedPointIds, "horizontal", origin); }; const handlePositionChange = useCallback( (axis: "x" | "y", value: number) => { + if (!layer) return; if (!selectionBounds) return; + const anchorPoint = anchorToPoint(anchor, selectionBounds); const target = axis === "x" ? { x: value, y: anchorPoint.y } : { x: anchorPoint.x, y: value }; - editor.moveSelectionTo(target, anchorPoint); + layer.moveSelectionTo([...selectedPointIds], target, anchorPoint); }, - [anchor, editor, selectionBounds], + [anchor, layer, selectedPointIds, selectionBounds], ); return ( 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 (
{ const axes = useAxes(); const [location, setDesignLocation] = useDesignLocation(); - if (axes.length === 0) return

No axes defined

; + if (axes.length === 0) return

No axes defined

; const onAxisChange = (axis: Axis, value: number) => { const nextLocation = withAxisValue(location, axis, value); diff --git a/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx b/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx index bf1ab9ca..b8aa27c8 100644 --- a/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx +++ b/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx @@ -94,7 +94,7 @@ export const CreateSourceDialog = ({ open, onOpenChange }: CreateSourceDialogPro
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..8e77aafb 100644 --- a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts +++ b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts @@ -1,21 +1,16 @@ -import type { Bounds } from "@shift/geo"; +import { Bounds, type Bounds as BoundsType } from "@shift/geo"; import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; /** - * Current selection bounds (axis-aligned, point-based), live-updating. + * Current selection bounds. * - * 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. + * @returns null when the current selection has no bounded objects. */ -export function useSelectionBounds(): Bounds | null { +export function useSelectionBounds(): BoundsType | null { const editor = useEditor(); - return useSignalState(editor.selection.boundsCell, { schedule: "frame" }); + const rect = useSignalState(editor.selectionBoundsCell, { schedule: "frame" }); + if (!rect) return null; + + return Bounds.fromXYWH(rect.x, rect.y, rect.width, rect.height); } 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..94b48730 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts @@ -1,99 +1,84 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { TestEditor } from "@/testing/TestEditor"; - -// Restored from the WS6 behavioral inventory (git show ef037c6e^), rebuilt on -// the workspace stack with explicit settles around the echo round trip. -describe("Clipboard (via Editor)", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - - // Draw a small rectangle: 4 points. - editor.click(100, 100); - await editor.settle(); - editor.click(200, 100); - await editor.settle(); - editor.click(200, 200); - await editor.settle(); - editor.click(100, 200); - await editor.settle(); - }); - - it("copy on empty selection returns false", async () => { - editor.selection.clear(); +import { Clipboard } from "./Clipboard"; +import type { ShiftContent, SystemClipboard } from "./types"; + +class MemoryClipboard implements SystemClipboard { + text = ""; + + writeText(text: string): void { + this.text = text; + } + + readText(): string { + return this.text; + } +} + +const content = (): ShiftContent => ({ + contours: [ + { + closed: true, + points: [ + { x: 0, y: 0, pointType: "onCurve", smooth: false }, + { x: 100, y: 0, pointType: "onCurve", smooth: false }, + ], + }, + ], +}); - const ok = await editor.copy(); +describe("Clipboard", () => { + let system: MemoryClipboard; + let clipboard: Clipboard; - expect(ok).toBe(false); - expect(editor.clipboardBuffer).toBe(""); + beforeEach(() => { + system = new MemoryClipboard(); + clipboard = new Clipboard(system); }); - it("copy writes a shift/glyph-data payload to the clipboard", async () => { - editor.selectAll(); + it("writes Shift content as a versioned clipboard payload", async () => { + const written = await clipboard.write(content()); - const ok = await editor.copy(); + expect(written).toBe(true); - expect(ok).toBe(true); - const payload = JSON.parse(editor.clipboardBuffer); + const payload = JSON.parse(system.text); expect(payload.format).toBe("shift/glyph-data"); + expect(payload.version).toBe(1); expect(payload.content.contours).toHaveLength(1); - expect(payload.content.contours[0].points).toHaveLength(4); + expect(payload.metadata.sourceApp).toBe("shift"); }); - it("copy + paste duplicates the selected contour", async () => { - editor.selectAll(); - const pointsBefore = editor.pointCount; + it("reads Shift clipboard payloads as glyph content", async () => { + await clipboard.write(content()); + + const result = await clipboard.read(); - await editor.copy(); - await editor.paste(); - await editor.settle(); + expect(result.kind).toBe("content"); + if (result.kind !== "content") return; - expect(editor.pointCount).toBe(pointsBefore * 2); + expect(result.source).toBe("shift"); + expect(result.content.contours[0]?.points).toHaveLength(2); }); - it("cut removes the selected points from the glyph", async () => { - editor.selectAll(); - expect(editor.pointCount).toBeGreaterThan(0); + it("returns unsupported for non-empty text that no importer accepts", async () => { + system.writeText("plain text"); - await editor.cut(); - await editor.settle(); + const result = await clipboard.read(); - expect(editor.pointCount).toBe(0); + expect(result.kind).toBe("unsupported"); }); - it("paste with an empty clipboard is a no-op", async () => { - editor.selection.clear(); - const pointsBefore = editor.pointCount; + it("returns empty for an empty system clipboard", async () => { + const result = await clipboard.read(); - await editor.paste(); - await editor.settle(); - - expect(editor.pointCount).toBe(pointsBefore); + expect(result.kind).toBe("empty"); }); - it("repeated pastes compound the offset", async () => { - editor.selectAll(); - await editor.copy(); - await editor.paste(); - await editor.paste(); - await editor.settle(); - - const contours = (editor.editingGlyphLayer?.contours ?? []).filter( - (contour) => !contour.isEmpty, - ); - expect(contours).toHaveLength(3); - - // Each paste translates the original by DEFAULT_PASTE_OFFSET (20) * - // pasteIndex. Sort by minX so the assertion is independent of the - // contour array's insertion order. - const sortedMinX = contours - .map((c) => Math.min(...c.points.map((p) => p.x))) - .sort((a, b) => a - b); - - expect(sortedMinX[1]! - sortedMinX[0]!).toBe(20); - expect(sortedMinX[2]! - sortedMinX[0]!).toBe(40); + it("compounds paste offsets until reset", () => { + expect(clipboard.nextPasteOffset()).toEqual({ x: 20, y: -20 }); + expect(clipboard.nextPasteOffset()).toEqual({ x: 40, y: -40 }); + + clipboard.resetPasteOffset(); + + expect(clipboard.nextPasteOffset()).toEqual({ x: 20, y: -20 }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts index a01848ee..2b04ae19 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts @@ -3,12 +3,12 @@ import { Polygon } from "@shift/geo"; import { ValidateClipboard } from "@shift/validation"; import type { SystemClipboard, - ClipboardContent, ClipboardImporter, ClipboardOffer, ClipboardPayload, ClipboardReadResult, ClipboardWriteMetadata, + ShiftContent, } from "./types"; import { SvgImporter } from "./importers/SvgImporter"; @@ -26,7 +26,7 @@ const EMPTY_BOUNDS: Rect2D = { }; interface ClipboardState { - content: ClipboardContent | null; + content: ShiftContent | null; bounds: Rect2D | null; timestamp: number; } @@ -51,7 +51,7 @@ export class Clipboard { this.#importers.push(new SvgImporter()); } - async write(content: ClipboardContent, metadata: ClipboardWriteMetadata = {}): Promise { + async write(content: ShiftContent, metadata: ClipboardWriteMetadata = {}): Promise { if (!content || content.contours.length === 0) return false; const bounds = Polygon.boundingRect(content.contours.flatMap((c) => c.points)) ?? EMPTY_BOUNDS; @@ -85,14 +85,14 @@ export class Clipboard { const offers = this.#offersFromText(text); const native = tryDeserialize(text); - if (native) return { kind: "glyph", content: native, source: "shift" }; + if (native) return { kind: "content", content: native, source: "shift" }; for (const importer of this.#importers) { const offer = importer.pick(offers); if (!offer) continue; const imported = await importer.import(offer); - if (imported) return { kind: "glyph", content: imported, source: importer.id }; + if (imported) return { kind: "content", content: imported, source: importer.id }; } if (text.trim().length > 0) { @@ -103,7 +103,7 @@ export class Clipboard { } } catch { if (this.#internalState.content) { - return { kind: "glyph", content: this.#internalState.content, source: "shift" }; + return { kind: "content", content: this.#internalState.content, source: "shift" }; } } @@ -126,11 +126,11 @@ export class Clipboard { } } -function tryDeserialize(text: string): ClipboardContent | null { +function tryDeserialize(text: string): ShiftContent | null { try { const payload = JSON.parse(text); if (payload.format !== "shift/glyph-data" || payload.version > 1) return null; - if (!ValidateClipboard.isClipboardContent(payload.content)) return null; + if (!ValidateClipboard.isShiftContent(payload.content)) return null; return payload.content; } catch { return null; diff --git a/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts b/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts index 29479d79..ef80b56d 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/ClipboardSelection.ts @@ -1,18 +1,25 @@ -import type { PointId } from "@shift/types"; +import { isPointId, type PointId } from "@shift/types"; import { Validate } from "@shift/validation"; -import type { SegmentId } from "@/types/indicator"; import type { Contour, Point } from "@shift/glyph-state"; -import type { ClipboardContent, ContourContent, PointContent } from "./types"; +import type { ContourContent, PointContent, ShiftContent } from "./types"; +import type { SelectableId } from "@/types"; export interface ClipboardContourSource { readonly contours: readonly Contour[]; } export interface ClipboardSelectionSource { - readonly pointIds: ReadonlySet; - readonly segmentIds: ReadonlySet; + readonly ids: readonly SelectableId[]; } +/** + * Builds glyph clipboard content from selected point identities. + * + * @remarks + * This class does not resolve semantic selections such as segments or nodes. + * Callers must expand those selections against the appropriate layer first and + * pass only concrete point ids here. + */ export class ClipboardSelection { readonly #pointIds: ReadonlySet; @@ -21,29 +28,36 @@ export class ClipboardSelection { } static fromSelection(selection: ClipboardSelectionSource): ClipboardSelection { - return ClipboardSelection.fromIds(selection.pointIds, selection.segmentIds); + return ClipboardSelection.fromPointIds(selection.ids.filter(isPointId)); } - static fromIds( - pointIds: ReadonlySet, - segmentIds: ReadonlySet, - ): ClipboardSelection { - const resolved = new Set(pointIds); - - for (const segmentId of segmentIds) { - const [id1, id2] = segmentId.split(":"); - if (id1) resolved.add(id1 as PointId); - if (id2) resolved.add(id2 as PointId); - } - - return new ClipboardSelection(resolved); + /** + * Captures the point ids that should be copied from a contour source. + * + * @param pointIds - Concrete point identities that belong to the contour + * source passed later to {@link contentFrom}. + * @returns a clipboard selection with its own copy of the id set. + */ + static fromPointIds(pointIds: Iterable): ClipboardSelection { + return new ClipboardSelection(new Set(pointIds)); } get pointIds(): readonly PointId[] { return [...this.#pointIds]; } - contentFrom(source: ClipboardContourSource): ClipboardContent | null { + /** + * Returns clipboard contour content resolved against the supplied contours. + * + * @remarks + * Partial selections are expanded to include neighboring off-curve points when + * needed to preserve valid curve fragments. The returned content is detached + * from the source contours and can be written to the system clipboard. + * + * @param source - Contours that own the selected point ids. + * @returns `null` when no selected points resolve to valid clipboard content. + */ + contentFrom(source: ClipboardContourSource): ShiftContent | null { if (this.#pointIds.size === 0) return null; const contours: ContourContent[] = []; diff --git a/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts b/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts index 44ed1f8c..3244890d 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/importers/SvgImporter.ts @@ -1,9 +1,9 @@ import type { - ClipboardContent, ClipboardImporter, ClipboardOffer, ContourContent, PointContent, + ShiftContent, } from "../types"; type PathCommand = { @@ -20,7 +20,7 @@ export class SvgImporter implements ClipboardImporter { ); } - import(offer: ClipboardOffer): ClipboardContent | null { + import(offer: ClipboardOffer): ShiftContent | null { if (offer.text === undefined) return null; return this.importText(offer.text); } @@ -29,7 +29,7 @@ export class SvgImporter implements ClipboardImporter { return this.#canImportText(text); } - importText(text: string): ClipboardContent | null { + importText(text: string): ShiftContent | null { const trimmed = text.trim(); const pathData = this.#extractPathData(trimmed); diff --git a/apps/desktop/src/renderer/src/lib/clipboard/index.ts b/apps/desktop/src/renderer/src/lib/clipboard/index.ts index f44aba1b..d105c6eb 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/index.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/index.ts @@ -8,7 +8,6 @@ export { SvgImporter } from "./importers/SvgImporter"; export { electronSystemClipboard } from "./electronSystemClipboard"; export type { SystemClipboard, - ClipboardContent, ClipboardImporter, ClipboardOffer, ClipboardPayload, @@ -16,6 +15,8 @@ export type { ClipboardSource, ClipboardWriteMetadata, ContourContent, + PasteOptions, PasteResult, PointContent, + ShiftContent, } from "./types"; diff --git a/apps/desktop/src/renderer/src/lib/clipboard/types.ts b/apps/desktop/src/renderer/src/lib/clipboard/types.ts index 0ab3d59d..94b472a8 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/types.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/types.ts @@ -3,7 +3,7 @@ import type { Rect2D } from "@shift/geo"; export type { PasteResult } from "@/types/bridge"; -/** A point's data without its identity, used for clipboard serialization. */ +/** A point's portable geometry without its source identity. */ export type PointContent = { x: number; y: number; @@ -11,14 +11,22 @@ export type PointContent = { smooth: boolean; }; -/** A single contour as stored in the clipboard. */ +/** A contour's portable geometry without its source identity. */ export type ContourContent = { points: PointContent[]; closed: boolean; }; -/** The geometry that the clipboard carries -- one or more contours. */ -export type ClipboardContent = { +/** + * Portable Shift content detached from live editor state. + * + * @remarks + * `ShiftContent` contains enough geometry to reconstruct editable contours in + * another layer, document, or clipboard round trip. It does not carry live + * glyph, layer, contour, point, node, or scene ids; those are minted by the + * destination when content is pasted or otherwise inserted. + */ +export type ShiftContent = { contours: ContourContent[]; }; @@ -30,7 +38,7 @@ export type ClipboardContent = { export type ClipboardPayload = { version: 1; format: "shift/glyph-data"; - content: ClipboardContent; + content: ShiftContent; metadata: { bounds: Rect2D; sourceGlyph?: string; @@ -54,7 +62,7 @@ export type ClipboardSource = "shift" | "svg" | "fontra" | "glyphs" | "image"; export type ClipboardReadResult = | { kind: "empty" } - | { kind: "glyph"; content: ClipboardContent; source: ClipboardSource } + | { kind: "content"; content: ShiftContent; source: ClipboardSource } | { kind: "unsupported"; offeredTypes: readonly string[]; reason?: string }; export interface ClipboardWriteMetadata { @@ -69,7 +77,7 @@ export interface ClipboardWriteMetadata { export interface ClipboardImporter { readonly id: ClipboardSource; pick(offers: readonly ClipboardOffer[]): ClipboardOffer | null; - import(offer: ClipboardOffer): ClipboardContent | null | Promise; + import(offer: ClipboardOffer): ShiftContent | null | Promise; } /** 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 deleted file mode 100644 index 5053daf0..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import type { PointId } from "@shift/types"; -import { TestEditor } from "@/testing/TestEditor"; -import { CutCommand, PasteCommand } from "./ClipboardCommands"; -import type { ClipboardContent } from "../../clipboard/types"; - -// Restored from the WS6 behavioral inventory (git show ef037c6e^); the old -// CommandHistory plumbing is gone, so commands run through CommandRunner and -// undo through the workspace ledger. -function createTestContent(points: Array<{ x: number; y: number }>): ClipboardContent { - return { - contours: [ - { - points: points.map((p) => ({ - x: p.x, - y: p.y, - pointType: "onCurve" as const, - smooth: false, - })), - closed: false, - }, - ], - }; -} - -describe("CutCommand", () => { - let editor: TestEditor; - let p1: PointId; - let p2: PointId; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.clickGlyphLocal(100, 100); - await editor.settle(); - editor.clickGlyphLocal(200, 200); - await editor.settle(); - [p1, p2] = editor.editingGlyphLayer!.allPoints.map((point) => point.id) as [PointId, PointId]; - }); - - const source = () => editor.editingGlyphLayer!; - - it("removes the cut points and keeps the rest", async () => { - editor.commands.run(new CutCommand([p1])); - await editor.settle(); - - expect(source().allPoints.length).toBe(1); - expect(source().point(p2)).toMatchObject({ x: 200, y: 200 }); - }); - - it("restores the cut points through ledger undo", async () => { - editor.commands.run(new CutCommand([p1])); - await editor.settle(); - expect(source().allPoints.length).toBe(1); - - await editor.undoAndSettle(); - expect(source().allPoints.length).toBe(2); - expect(source().point(p1)).toMatchObject({ x: 100, y: 100 }); - }); -}); - -describe("PasteCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - }); - - const source = () => editor.editingGlyphLayer!; - - it("creates points offset from the clipboard content", async () => { - const command = new PasteCommand(createTestContent([{ x: 100, y: 100 }]), { - offset: { x: 20, y: -20 }, - }); - - editor.commands.run(command); - await editor.settle(); - - expect(command.createdPointIds).toHaveLength(1); - expect(source().point(command.createdPointIds[0]!)).toMatchObject({ x: 120, y: 80 }); - }); - - it("creates one contour per clipboard contour", async () => { - const content: ClipboardContent = { - contours: [ - { points: [{ x: 0, y: 0, pointType: "onCurve", smooth: false }], closed: false }, - { points: [{ x: 100, y: 100, pointType: "onCurve", smooth: false }], closed: false }, - ], - }; - const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - - editor.commands.run(command); - await editor.settle(); - - expect(command.createdContourIds).toHaveLength(2); - expect(source().contours).toHaveLength(2); - }); - - it("removes all pasted geometry with one ledger undo", async () => { - editor.commands.run( - new PasteCommand(createTestContent([{ x: 100, y: 100 }]), { offset: { x: 0, y: 0 } }), - ); - await editor.settle(); - expect(source().allPoints.length).toBe(1); - - await editor.undoAndSettle(); - expect(source().allPoints.length).toBe(0); - expect(source().contours.length).toBe(0); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts b/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts deleted file mode 100644 index b3a269d6..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/ClipboardCommands.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Command, CommandContext } from "../core/Command"; -import type { PointId, ContourId } from "@shift/types"; -import { Point } from "@shift/glyph-state"; -import type { GlyphLayer } from "@/lib/model/Glyph"; -import type { ClipboardContent, PasteOptions } from "../../clipboard/types"; -import { Vec2 } from "@shift/geo"; - -/** - * Removes the specified points from the glyph as part of a cut operation. - * The clipboard write happens outside this command; CutCommand only handles - * the destructive removal. - */ -export class CutCommand implements Command { - readonly name = "Cut"; - - readonly #pointIds: PointId[]; - - constructor(pointIds: PointId[]) { - this.#pointIds = [...pointIds]; - } - - execute(ctx: CommandContext): void { - ctx.layer.removePoints(this.#pointIds); - } -} - -/** - * Pastes clipboard contours into the glyph at the given offset. Tracks - * created point and contour ids so callers can select the pasted geometry. - * Access results via {@link createdPointIds} and {@link createdContourIds}. - */ -export class PasteCommand implements Command { - readonly name = "Paste"; - - readonly #content: ClipboardContent; - readonly #options: PasteOptions; - #createdPointIds: PointId[] = []; - #createdContourIds: ContourId[] = []; - - constructor(content: ClipboardContent, options: PasteOptions) { - this.#content = content; - this.#options = options; - } - - execute(ctx: CommandContext): void { - const result = pasteContours(ctx.layer, this.#content, this.#options); - this.#createdPointIds = result.createdPointIds; - this.#createdContourIds = result.createdContourIds; - } - - get createdPointIds(): PointId[] { - return this.#createdPointIds; - } - - get createdContourIds(): ContourId[] { - return this.#createdContourIds; - } -} - -function pasteContours( - layer: GlyphLayer, - content: ClipboardContent, - options: PasteOptions, -): { createdPointIds: PointId[]; createdContourIds: ContourId[] } { - const createdPointIds: PointId[] = []; - const createdContourIds: ContourId[] = []; - - for (const contour of content.contours) { - const contourId = layer.addContour(); - createdContourIds.push(contourId); - - for (const point of contour.points) { - const newPos = Vec2.add(point, options.offset); - const pointId = layer.addPoint( - contourId, - Point.create(newPos, point.pointType, point.smooth), - ); - createdPointIds.push(pointId); - } - - if (contour.closed) { - layer.closeContour(contourId); - } - } - - return { createdPointIds, createdContourIds }; -} diff --git a/apps/desktop/src/renderer/src/lib/commands/clipboard/index.ts b/apps/desktop/src/renderer/src/lib/commands/clipboard/index.ts deleted file mode 100644 index c733f763..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/clipboard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CutCommand, PasteCommand } from "./ClipboardCommands"; diff --git a/apps/desktop/src/renderer/src/lib/commands/core/Command.ts b/apps/desktop/src/renderer/src/lib/commands/core/Command.ts deleted file mode 100644 index 839fbd04..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/core/Command.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Command Pattern Infrastructure - * - * Commands are verb scripts over the active glyph layer. They carry no undo - * machinery: every verb pushes intents that coalesce into one workspace - * ledger entry per tick, so a command IS one undo step. - */ - -import type { GlyphLayer } from "@/lib/model/Glyph"; - -/** - * Context available to commands during execution. - * Commands receive the active authored glyph layer directly. - */ -export interface CommandContext { - readonly layer: GlyphLayer; -} - -export interface Command { - readonly name: string; - execute(ctx: CommandContext): TResult; -} diff --git a/apps/desktop/src/renderer/src/lib/commands/core/CommandRunner.ts b/apps/desktop/src/renderer/src/lib/commands/core/CommandRunner.ts deleted file mode 100644 index 0c482210..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/core/CommandRunner.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Stateless command executor. - * - * Commands are verb scripts over the active glyph layer; they carry no - * undo machinery. Undo authority lives in the workspace ledger — every - * verb a command calls pushes intents that coalesce into one ledger entry - * per tick, so a command IS one undo step without any history bookkeeping. - */ -import type { Signal } from "@/lib/signals/signal"; -import type { GlyphLayer } from "@/lib/model/Glyph"; -import type { Command, CommandContext } from "./Command"; - -export class CommandRunner { - readonly #layerCell: Signal; - - constructor(layerCell: Signal) { - this.#layerCell = layerCell; - } - - run(command: Command): TResult { - return command.execute(this.#context()); - } - - #context(): CommandContext { - const layer = this.#layerCell.peek(); - if (!layer) { - throw new Error("Cannot execute command without an active glyph layer"); - } - - return { layer }; - } -} diff --git a/apps/desktop/src/renderer/src/lib/commands/core/index.ts b/apps/desktop/src/renderer/src/lib/commands/core/index.ts deleted file mode 100644 index 0c225b15..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/core/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { type Command, type CommandContext } from "./Command"; -export { CommandRunner } from "./CommandRunner"; diff --git a/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md deleted file mode 100644 index f2a4a6ec..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/docs/DOCS.md +++ /dev/null @@ -1,121 +0,0 @@ -# Commands - -Command pattern implementation providing undo/redo for all glyph editing operations. - -## Architecture Invariants - -- **Architecture Invariant:** Commands mutate the active authored glyph layer exclusively through `CommandContext.layer`. They never touch the native bridge directly. -- **Architecture Invariant:** Every command must be self-contained for undo. `execute` must capture enough state (original positions, snapshot, etc.) so that `undo` can fully reverse the operation without external help. -- **Architecture Invariant:** `CompositeCommand` undoes children in reverse order. Commands grouped via `beginBatch`/`endBatch` are auto-wrapped in a `CompositeCommand` at `endBatch` time. -- **Architecture Invariant:** `record` adds a command to the undo stack without calling `execute`. This is the correct path for incremental operations (e.g. drag) where mutations have already been applied live. **CRITICAL:** Using `execute` instead of `record` for already-applied mutations will double-apply them. -- **Architecture Invariant:** New commands that execute clears the redo stack. This is standard command-pattern semantics -- there is no branching history. -- **Architecture Invariant:** Batches cannot nest. Calling `beginBatch` while already batching throws. - -## Codemap - -``` -commands/ - core/ - Command.ts # Command interface, BaseCommand, CompositeCommand - CommandHistory.ts # Undo/redo stacks, batching, reactive signals - primitives/ - PointCommands.ts # AddPointCommand - BezierCommands.ts # CloseContourCommand, NudgePointsCommand, ReverseContourCommand, - # SplitSegmentCommand, UpgradeLineToCubicCommand - ApplyPositionPatchCommand.ts # Efficient sparse position patch replay (before/after lists) - SidebearingCommands.ts # SetXAdvanceCommand, SetLeftSidebearingCommand, SetRightSidebearingCommand - transform/ - TransformCommands.ts # RotatePointsCommand, ScalePointsCommand, ReflectPointsCommand, MoveSelectionToCommand - AlignmentCommands.ts # AlignPointsCommand, DistributePointsCommand - clipboard/ - ClipboardCommands.ts # CutCommand, PasteCommand -``` - -## Key Types - -- **`Command`** -- Interface: `name`, `execute(ctx)`, `undo(ctx)`, `redo(ctx)`. All commands implement this. -- **`CommandContext`** -- `{ readonly layer: GlyphLayer }`. Injected into every command method. Provides access to the active authored glyph layer. -- **`BaseCommand`** -- Abstract class implementing `Command`. Default `redo` calls `execute`; subclasses override when redo needs different logic (e.g. snapshot-based replay). -- **`CompositeCommand`** -- Groups multiple commands into one undo step. Executes children in order, undoes in reverse. -- **`CommandHistory`** -- Manages undo/redo stacks. Exposes `execute`, `record`, `undo`, `redo`, `clear`, batching (`beginBatch`/`endBatch`/`withBatch`), and reactive signals (`canUndo`, `canRedo`). -- **`CommandHistoryOptions`** -- `{ maxHistory?: number; onDirty?: () => void }`. `maxHistory` defaults to 100. -- **`ApplyPositionPatchCommand`** -- Stores before/after `GlyphLayerPositions`. Efficient path for point/anchor position-only operations. -- **`BaseTransformCommand`** -- Abstract template in `TransformCommands.ts`. Captures original positions on first execute; subclasses implement `transformPoints`. Used by `RotatePointsCommand`, `ScalePointsCommand`, `ReflectPointsCommand`, `MoveSelectionToCommand`. - -## How it works - -### Two-stack undo/redo - -`CommandHistory` maintains an undo stack and a redo stack. `execute(cmd)` runs the command and pushes it onto the undo stack, clearing the redo stack. `undo()` pops from undo, calls `cmd.undo(ctx)`, and pushes onto redo. `redo()` does the reverse. - -### execute vs record - -- **`execute(cmd)`** -- Calls `cmd.execute(ctx)`, then pushes to undo stack. Use for discrete one-shot operations (add point, nudge, transform). -- **`record(cmd)`** -- Pushes to undo stack without calling execute. Use when mutations have already been applied incrementally (e.g. dragging points). `GlyphLayerEditDraft.commit()` commits the final sparse patch to Rust, then records an `ApplyPositionPatchCommand`. - -### Batching - -Multiple commands can be grouped into a single undo step using `withBatch(name, fn)` (preferred) or the manual `beginBatch`/`endBatch` pair. Commands executed during a batch are collected and wrapped in a `CompositeCommand` at `endBatch`. If only one command was batched, it is pushed directly without wrapping. `cancelBatch` discards the batch state but does not roll back already-executed commands. - -### Command undo strategies - -Commands use one of three strategies depending on cost: - -1. **Delta-based** -- Store the delta, apply inverse on undo. Used by `NudgePointsCommand`. -2. **Position-capture** -- Store original positions, restore on undo. Used by `BaseTransformCommand` subclasses, `AlignPointsCommand`, `DistributePointsCommand`, `SplitSegmentCommand`. -3. **Layer-state-based** -- Store full `GlyphState` before/after. Used by `CutCommand` and `PasteCommand` for topology changes (adding/removing contours). - -`ApplyPositionPatchCommand` stores before/after position lists (cheaper than full snapshots) and replays them on undo/redo. - -### Reactive UI - -`canUndo` and `canRedo` are `ComputedSignal` derived from `undoCount`/`redoCount` writable signals. These update after every stack mutation, enabling reactive UI binding for undo/redo button state. `getUndoLabel()`/`getRedoLabel()` return the next command's `name` for menu display. - -### Dirty tracking - -`CommandHistoryOptions.onDirty` fires after every `execute` or `record` call, allowing the editor to mark the document as unsaved. - -## Workflow recipes - -### Adding a new command - -1. Create a class extending `BaseCommand` (or implementing `Command` directly). -2. Set `readonly name` to a human-readable label (shown in undo/redo menus). -3. Implement `execute(ctx)` -- perform the mutation via `ctx.layer.*` methods and capture any state needed for undo. -4. Implement `undo(ctx)` -- fully reverse the mutation. -5. Override `redo(ctx)` only if re-executing from scratch would fail (e.g. id drift after point removal). Default `redo` calls `execute`. -6. Export from the appropriate subdirectory's `index.ts` and from the top-level `commands/index.ts`. -7. Wire it in `Editor` or a tool -- call `commandHistory.execute(new YourCommand(...))` or `commandHistory.record(...)`. -8. Add tests covering execute, undo, and redo round-trips. - -### Adding a transform command - -1. Extend `BaseTransformCommand` instead of `BaseCommand`. -2. Implement `transformPoints(points)` -- receives original positions, returns transformed positions. -3. Position capture and undo/redo are handled by the base class automatically. - -## Gotchas - -- `SetLeftSidebearingCommand` moves all geometry (`translateLayer`) in addition to changing `xAdvance`. Undo must reverse both, and the order matters (restore advance first, then translate back). -- `SplitSegmentCommand.redo` resets internal state (`#insertedPointIds`, `#originalPositions`) before re-executing because point ids are engine-assigned and differ across executions. -- `PasteCommand` captures full layer state after first execute and restores it for redo, avoiding id-drift issues from creating new contours/points twice. -- `cancelBatch` does not undo already-executed commands -- it only discards the batch bookkeeping. Callers must handle rollback separately if needed. - -## Verification - -```bash -# Run command tests -npx vitest run --project renderer src/lib/commands/ - -# Run editor integration tests (exercises command wiring) -npx vitest run --project renderer src/lib/editor/ -``` - -## Related - -- `GlyphLayer` -- Active authored glyph layer; commands mutate through `ctx.layer` -- `Editor` -- Orchestrates command execution; owns the `CommandHistory` instance -- `Signal`, `ComputedSignal` -- Reactive primitives powering `canUndo`/`canRedo` -- `GlyphLayerPositions` -- Typed point/anchor position lists used by `ApplyPositionPatchCommand` -- `Transform` -- Pure math functions for rotate/scale/reflect, consumed by transform commands -- `Alignment` -- Pure math for align/distribute, consumed by `AlignPointsCommand`/`DistributePointsCommand` diff --git a/apps/desktop/src/renderer/src/lib/commands/index.ts b/apps/desktop/src/renderer/src/lib/commands/index.ts deleted file mode 100644 index 2b032501..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Core command infrastructure -export { type Command, type CommandContext, CommandRunner } from "./core"; - -// Primitive commands (point, bezier operations) -export { - DrawRectangleCommand, - ToggleSmoothCommand, - ReverseContourCommand, - NudgePointsCommand, - SplitSegmentCommand, - UpgradeLineToCubicCommand, - BooleanOperationCommand, - type BooleanOperation, - SetXAdvanceCommand, - SetLeftSidebearingCommand, - SetRightSidebearingCommand, -} from "./primitives"; - -// Clipboard commands -export { CutCommand, PasteCommand } from "./clipboard"; - -// Transform commands -export { RotatePointsCommand, ScalePointsCommand, ReflectPointsCommand } from "./transform"; 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 deleted file mode 100644 index a7d8f1d8..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import type { PointId } from "@shift/types"; -import { TestEditor } from "@/testing/TestEditor"; -import { - NudgePointsCommand, - ReverseContourCommand, - SplitSegmentCommand, - UpgradeLineToCubicCommand, -} from "./BezierCommands"; - -// Restored from the WS6 behavioral inventory (git show ef037c6e^), rebuilt on -// the workspace stack: geometry is drawn through editor verbs, commands run -// through CommandRunner, and undo goes through the workspace ledger. -describe("NudgePointsCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.clickGlyphLocal(10, 20); - await editor.settle(); - editor.clickGlyphLocal(30, 40); - await editor.settle(); - }); - - const source = () => editor.editingGlyphLayer!; - - it("moves points by the nudge delta", async () => { - const ids = source().allPoints.map((point) => point.id); - - editor.commands.run(new NudgePointsCommand(ids, 1, 0)); - await editor.settle(); - - expect(source().allPoints.map(({ x }) => x)).toEqual([11, 31]); - }); - - it("restores both points with one ledger undo", async () => { - const ids = source().allPoints.map((point) => point.id); - - editor.commands.run(new NudgePointsCommand(ids, 5, -10)); - await editor.settle(); - - await editor.undoAndSettle(); - expect(source().allPoints.map(({ x, y }) => ({ x, y }))).toEqual([ - { x: 10, y: 20 }, - { x: 30, y: 40 }, - ]); - }); - - it("does not change state with an empty point list", async () => { - editor.commands.run(new NudgePointsCommand([], 5, 5)); - await editor.settle(); - - expect(source().allPoints.map(({ x, y }) => ({ x, y }))).toEqual([ - { x: 10, y: 20 }, - { x: 30, y: 40 }, - ]); - }); -}); - -describe("ReverseContourCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.click(0, 0); - await editor.settle(); - editor.click(100, 0); - await editor.settle(); - editor.click(200, 0); - await editor.settle(); - }); - - const source = () => editor.editingGlyphLayer!; - - it("reverses the point order", async () => { - const contour = source().contours[0]!; - expect(contour.points.map(({ x }) => x)).toEqual([0, 100, 200]); - - editor.commands.run(new ReverseContourCommand(contour.id)); - await editor.settle(); - - expect(source().contours[0]!.points.map(({ x }) => x)).toEqual([200, 100, 0]); - }); - - it("restores the original winding through ledger undo", async () => { - editor.commands.run(new ReverseContourCommand(source().contours[0]!.id)); - await editor.settle(); - - await editor.undoAndSettle(); - expect(source().contours[0]!.points.map(({ x }) => x)).toEqual([0, 100, 200]); - }); -}); - -describe("SplitSegmentCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - }); - - const source = () => editor.editingGlyphLayer!; - - describe("line segment", () => { - beforeEach(async () => { - editor.clickGlyphLocal(0, 0); - await editor.settle(); - editor.clickGlyphLocal(100, 0); - await editor.settle(); - }); - - it("inserts a single on-curve point at t=0.5", async () => { - const segment = source().contours[0]!.segments()[0]!; - - const splitId = editor.commands.run(new SplitSegmentCommand(segment, 0.5)); - await editor.settle(); - - expect(source().allPoints.length).toBe(3); - expect(source().point(splitId)).toMatchObject({ x: 50, y: 0, pointType: "onCurve" }); - }); - - it("removes the inserted point with one ledger undo", async () => { - const segment = source().contours[0]!.segments()[0]!; - - editor.commands.run(new SplitSegmentCommand(segment, 0.5)); - await editor.settle(); - expect(source().allPoints.length).toBe(3); - - await editor.undoAndSettle(); - expect(source().allPoints.length).toBe(2); - }); - }); - - it("inserts the point at the parametric position for t=0.25", async () => { - editor.clickGlyphLocal(0, 0); - await editor.settle(); - editor.clickGlyphLocal(100, 100); - await editor.settle(); - - const segment = source().contours[0]!.segments()[0]!; - const splitId = editor.commands.run(new SplitSegmentCommand(segment, 0.25)); - await editor.settle(); - - expect(source().point(splitId)).toMatchObject({ x: 25, y: 25 }); - }); - - describe("cubic segment", () => { - let c1: PointId; - let c2: PointId; - - beforeEach(async () => { - const contourId = source().addContour(); - source().addOnCurvePoint(contourId, { x: 0, y: 0 }); - c1 = source().addOffCurvePoint(contourId, { x: 25, y: 100 }); - c2 = source().addOffCurvePoint(contourId, { x: 75, y: 100 }); - source().addOnCurvePoint(contourId, { x: 100, y: 0 }); - await editor.settle(); - }); - - it("inserts three points and a smooth on-curve at the split", async () => { - const segment = source().contours[0]!.segments()[0]!; - expect(segment.type).toBe("cubic"); - - const splitId = editor.commands.run(new SplitSegmentCommand(segment, 0.5)); - await editor.settle(); - - expect(source().allPoints.length).toBe(7); - expect(source().point(splitId)).toMatchObject({ pointType: "onCurve", smooth: true }); - }); - - it("restores both control positions with one ledger undo", async () => { - const segment = source().contours[0]!.segments()[0]!; - - editor.commands.run(new SplitSegmentCommand(segment, 0.5)); - await editor.settle(); - expect(source().allPoints.length).toBe(7); - - await editor.undoAndSettle(); - expect(source().allPoints.length).toBe(4); - expect(source().point(c1)).toMatchObject({ x: 25, y: 100 }); - expect(source().point(c2)).toMatchObject({ x: 75, y: 100 }); - }); - }); -}); - -describe("UpgradeLineToCubicCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.clickGlyphLocal(0, 0); - await editor.settle(); - editor.clickGlyphLocal(90, 30); - await editor.settle(); - }); - - const source = () => editor.editingGlyphLayer!; - - it("converts the line into a shape-preserving cubic", async () => { - const line = source().contours[0]!.segments()[0]!.asLine()!; - - editor.commands.run(new UpgradeLineToCubicCommand(line)); - await editor.settle(); - - const segment = source().contours[0]!.segments()[0]!; - expect(segment.type).toBe("cubic"); - expect(source().allPoints.length).toBe(4); - - const cubic = segment.asCubic()!; - expect(cubic.controlStart).toMatchObject({ x: 30, y: 10 }); - expect(cubic.controlEnd).toMatchObject({ x: 60, y: 20 }); - }); - - it("removes both controls with one ledger undo", async () => { - const line = source().contours[0]!.segments()[0]!.asLine()!; - - editor.commands.run(new UpgradeLineToCubicCommand(line)); - await editor.settle(); - expect(source().allPoints.length).toBe(4); - - await editor.undoAndSettle(); - expect(source().allPoints.length).toBe(2); - expect(source().contours[0]!.segments()[0]!.type).toBe("line"); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts deleted file mode 100644 index 5efdde10..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BezierCommands.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { PointId, ContourId } from "@shift/types"; -import type { Command, CommandContext } from "../core/Command"; -import type { CubicCurve, QuadraticCurve, Point2D } from "@shift/geo"; -import { Point, type LineSegmentPoints, type Segment } from "@shift/glyph-state"; - -/** - * Moves points by a fixed delta, intended for keyboard arrow-key nudging. - */ -export class NudgePointsCommand implements Command { - readonly name = "Nudge Points"; - - readonly #pointIds: PointId[]; - readonly #dx: number; - readonly #dy: number; - - constructor(pointIds: PointId[], dx: number, dy: number) { - this.#pointIds = [...pointIds]; - this.#dx = dx; - this.#dy = dy; - } - - execute(ctx: CommandContext): void { - if (this.#pointIds.length === 0) return; - - ctx.layer.movePoints(this.#pointIds, { x: this.#dx, y: this.#dy }); - } -} - -/** - * Reverses a contour's winding direction. This affects fill rule rendering - * (e.g. counter-shapes) and path direction conventions. - */ -export class ReverseContourCommand implements Command { - readonly name = "Reverse Contour"; - - readonly #contourId: ContourId; - - constructor(contourId: ContourId) { - this.#contourId = contourId; - } - - execute(ctx: CommandContext): void { - ctx.layer.reverseContour(this.#contourId); - } -} - -/** - * Splits a segment at parametric value t, inserting new points and adjusting - * control handles to preserve the curve's shape. Handles line, quadratic, and - * cubic segments using de Casteljau subdivision. Returns the id of the new - * on-curve split point. - */ -export class SplitSegmentCommand implements Command { - readonly name = "Split Segment"; - - readonly #segment: Segment; - readonly #t: number; - - #splitPointId: PointId | null = null; - - constructor(segment: Segment, t: number) { - this.#segment = segment; - this.#t = t; - } - - execute(ctx: CommandContext): PointId { - switch (this.#segment.type) { - case "line": - return this.#splitLine(ctx); - case "quad": - return this.#splitQuadratic(ctx); - case "cubic": - return this.#splitCubic(ctx); - } - } - - #splitLine(ctx: CommandContext): PointId { - const splitPoint = this.#segment.pointAt(this.#t); - - const anchor2Id = this.#segment.endId; - - this.#splitPointId = ctx.layer.insertPointBefore(anchor2Id, Point.onCurve(splitPoint)); - - return this.#splitPointId; - } - - #splitQuadratic(ctx: CommandContext): PointId { - const points = this.#segment.asQuad()!; - const [curveA, curveB] = this.#segment.splitAt(this.#t) as [QuadraticCurve, QuadraticCurve]; - - const cA = curveA.c; - const mid = curveA.p1; - const cB = curveB.c; - - const controlId = points.control.id; - const anchor2Id = points.end.id; - - this.#splitPointId = ctx.layer.insertPointBefore(anchor2Id, Point.smooth(mid)); - ctx.layer.insertPointBefore(anchor2Id, Point.offCurve(cB)); - - ctx.layer.movePointTo(controlId, cA); - - return this.#splitPointId; - } - - #splitCubic(ctx: CommandContext): PointId { - const points = this.#segment.asCubic()!; - const [curveA, curveB] = this.#segment.splitAt(this.#t) as [CubicCurve, CubicCurve]; - - const c0A = curveA.c0; - const c1A = curveA.c1; - const mid = curveA.p1; - const c0B = curveB.c0; - const c1B = curveB.c1; - - const control1Id = points.controlStart.id; - const control2Id = points.controlEnd.id; - - ctx.layer.insertPointBefore(control2Id, Point.offCurve(c1A)); - this.#splitPointId = ctx.layer.insertPointBefore(control2Id, Point.smooth(mid)); - ctx.layer.insertPointBefore(control2Id, Point.offCurve(c0B)); - - ctx.layer.movePointTo(control1Id, c0A); - ctx.layer.movePointTo(control2Id, c1B); - - return this.#splitPointId; - } - - get splitPointId(): PointId | null { - return this.#splitPointId; - } -} - -/** - * Converts a line segment into a cubic bezier by inserting two off-curve - * control points at the 1/3 and 2/3 positions. The resulting cubic traces - * the same path as the original line, enabling subsequent handle manipulation - * to introduce curvature. - */ -export class UpgradeLineToCubicCommand implements Command { - readonly name = "Upgrade Line to Cubic"; - - readonly #anchor2Id: PointId; - readonly #control1Pos: Point2D; - readonly #control2Pos: Point2D; - - constructor(segment: LineSegmentPoints) { - const p1 = segment.start; - const p2 = segment.end; - this.#anchor2Id = p2.id; - - this.#control1Pos = { - x: p1.x + (p2.x - p1.x) / 3, - y: p1.y + (p2.y - p1.y) / 3, - }; - this.#control2Pos = { - x: p1.x + ((p2.x - p1.x) * 2) / 3, - y: p1.y + ((p2.y - p1.y) * 2) / 3, - }; - } - - execute(ctx: CommandContext): void { - const control2Id = ctx.layer.insertPointBefore( - this.#anchor2Id, - Point.offCurve(this.#control2Pos), - ); - ctx.layer.insertPointBefore(control2Id, Point.offCurve(this.#control1Pos)); - } -} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts deleted file mode 100644 index 168dd242..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/BooleanOperationCommand.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ContourId } from "@shift/types"; -import type { Command, CommandContext } from "../core/Command"; - -export type BooleanOperation = "union" | "subtract" | "intersect" | "difference"; - -export class BooleanOperationCommand implements Command { - readonly name: string; - - readonly #contourIdA: ContourId; - readonly #contourIdB: ContourId; - readonly #operation: BooleanOperation; - - constructor(contourIdA: ContourId, contourIdB: ContourId, operation: BooleanOperation) { - this.name = `Boolean ${operation}`; - this.#contourIdA = contourIdA; - this.#contourIdB = contourIdB; - this.#operation = operation; - } - - execute(ctx: CommandContext): void { - ctx.layer.applyBooleanOp(this.#contourIdA, this.#contourIdB, this.#operation); - } -} 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 deleted file mode 100644 index 1d15f4fe..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { TestEditor } from "@/testing/TestEditor"; -import { ToggleSmoothCommand } from "./PointCommands"; - -// Restored from the WS6 behavioral inventory (git show ef037c6e^), rebuilt on -// the workspace stack: commands run through CommandRunner, undo through the -// workspace ledger. -describe("ToggleSmoothCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.click(100, 200); - await editor.settle(); - }); - - const source = () => editor.editingGlyphLayer!; - - it("toggles a corner point smooth", async () => { - const pointId = source().allPoints[0]!.id; - - editor.commands.run(new ToggleSmoothCommand(pointId)); - await editor.settle(); - - expect(source().point(pointId)?.smooth).toBe(true); - }); - - it("toggles back through the workspace ledger on undo", async () => { - const pointId = source().allPoints[0]!.id; - - editor.commands.run(new ToggleSmoothCommand(pointId)); - await editor.settle(); - expect(source().point(pointId)?.smooth).toBe(true); - - await editor.undoAndSettle(); - expect(source().point(pointId)?.smooth).toBe(false); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts deleted file mode 100644 index b1a3a525..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/PointCommands.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PointId } from "@shift/types"; -import type { Command, CommandContext } from "../core/Command"; - -export class ToggleSmoothCommand implements Command { - readonly name = "Toggle Smooth"; - - readonly #pointId: PointId; - - constructor(pointId: PointId) { - this.#pointId = pointId; - } - - execute(ctx: CommandContext): void { - ctx.layer.toggleSmooth(this.#pointId); - } -} 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 deleted file mode 100644 index 12e85999..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import type { Rect2D } from "@shift/geo"; -import { TestEditor } from "@/testing/TestEditor"; -import { DrawRectangleCommand } from "./ShapeCommands"; - -function rect(x: number, y: number, width: number, height: number): Rect2D { - return { x, y, width, height, left: x, top: y, right: x + width, bottom: y + height }; -} - -// Restored from the WS6 behavioral inventory (git show ef037c6e^). -describe("DrawRectangleCommand", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - }); - - const source = () => editor.editingGlyphLayer!; - - it("adds a closed four-point contour", async () => { - const contourId = editor.commands.run(new DrawRectangleCommand(rect(10, 20, 100, 50))); - await editor.settle(); - - const contour = source().contour(contourId); - expect(contour?.closed).toBe(true); - expect(contour?.points.map(({ x, y }) => ({ x, y }))).toEqual([ - { x: 10, y: 20 }, - { x: 110, y: 20 }, - { x: 110, y: 70 }, - { x: 10, y: 70 }, - ]); - }); - - it("coalesces contour, points, and close into one undo step", async () => { - editor.commands.run(new DrawRectangleCommand(rect(0, 0, 10, 10))); - await editor.settle(); - expect(source().contours.length).toBe(1); - - await editor.undoAndSettle(); - expect(source().contours.length).toBe(0); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts deleted file mode 100644 index 0845b514..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/ShapeCommands.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Vec2, type Rect2D } from "@shift/geo"; -import type { ContourId } from "@shift/types"; -import { Point } from "@shift/glyph-state"; -import type { Command, CommandContext } from "../core/Command"; - -export class DrawRectangleCommand implements Command { - readonly name = "Draw Rectangle"; - - readonly #rect: Rect2D; - - constructor(rect: Rect2D) { - this.#rect = rect; - } - - execute(ctx: CommandContext): ContourId { - const contourId = ctx.layer.addContour(); - - const origin = Vec2.create(this.#rect.x, this.#rect.y); - const topLeft = origin; - const topRight = Vec2.add(origin, Vec2.create(this.#rect.width, 0)); - const bottomRight = Vec2.add(origin, Vec2.create(this.#rect.width, this.#rect.height)); - const bottomLeft = Vec2.add(origin, Vec2.create(0, this.#rect.height)); - - ctx.layer.addPoint(contourId, Point.onCurve(topLeft)); - ctx.layer.addPoint(contourId, Point.onCurve(topRight)); - ctx.layer.addPoint(contourId, Point.onCurve(bottomRight)); - ctx.layer.addPoint(contourId, Point.onCurve(bottomLeft)); - - ctx.layer.closeContour(contourId); - - return contourId; - } -} 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 deleted file mode 100644 index 254f624b..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { TestEditor } from "@/testing/TestEditor"; -import { - SetLeftSidebearingCommand, - SetRightSidebearingCommand, - SetXAdvanceCommand, -} from "./SidebearingCommands"; - -// Restored from the WS6 behavioral inventory (git show ef037c6e^). -describe("sidebearing commands through the workspace", () => { - let editor: TestEditor; - let initialAdvance: number; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.clickGlyphLocal(100, 200); - await editor.settle(); - initialAdvance = editor.editingGlyphLayer!.xAdvance; - }); - - const source = () => editor.editingGlyphLayer!; - - describe("SetXAdvanceCommand", () => { - it("sets the advance width", async () => { - editor.commands.run(new SetXAdvanceCommand(530)); - await editor.settle(); - - expect(source().xAdvance).toBe(530); - }); - - it("restores the advance through ledger undo", async () => { - editor.commands.run(new SetXAdvanceCommand(530)); - await editor.settle(); - - await editor.undoAndSettle(); - expect(source().xAdvance).toBe(initialAdvance); - }); - }); - - describe("SetRightSidebearingCommand", () => { - it("sets the advance width", async () => { - editor.commands.run(new SetRightSidebearingCommand(530)); - await editor.settle(); - - expect(source().xAdvance).toBe(530); - }); - }); - - describe("SetLeftSidebearingCommand", () => { - it("translates geometry and sets the advance", async () => { - const pointId = source().allPoints[0]!.id; - - editor.commands.run(new SetLeftSidebearingCommand(520, 20)); - await editor.settle(); - - expect(source().xAdvance).toBe(520); - expect(source().point(pointId)).toMatchObject({ x: 120, y: 200 }); - }); - - it("reverts translation and advance with one ledger undo", async () => { - const pointId = source().allPoints[0]!.id; - - editor.commands.run(new SetLeftSidebearingCommand(520, 20)); - await editor.settle(); - - await editor.undoAndSettle(); - expect(source().xAdvance).toBe(initialAdvance); - expect(source().point(pointId)).toMatchObject({ x: 100, y: 200 }); - }); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts deleted file mode 100644 index 652baf1f..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/SidebearingCommands.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Command, CommandContext } from "../core"; - -export class SetXAdvanceCommand implements Command { - readonly name = "Set X Advance"; - - readonly #xAdvance: number; - - constructor(xAdvance: number) { - this.#xAdvance = xAdvance; - } - - execute(ctx: CommandContext): void { - ctx.layer.setXAdvance(this.#xAdvance); - } -} - -export class SetRightSidebearingCommand implements Command { - readonly name = "Set Right Sidebearing"; - - readonly #xAdvance: number; - - constructor(xAdvance: number) { - this.#xAdvance = xAdvance; - } - - execute(ctx: CommandContext): void { - ctx.layer.setXAdvance(this.#xAdvance); - } -} - -export class SetLeftSidebearingCommand implements Command { - readonly name = "Set Left Sidebearing"; - - readonly #xAdvance: number; - readonly #deltaX: number; - - constructor(xAdvance: number, deltaX: number) { - this.#xAdvance = xAdvance; - this.#deltaX = deltaX; - } - - execute(ctx: CommandContext): void { - ctx.layer.translateLayer(this.#deltaX, 0); - ctx.layer.setXAdvance(this.#xAdvance); - } -} diff --git a/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts b/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts deleted file mode 100644 index c25d94cf..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/primitives/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { ToggleSmoothCommand } from "./PointCommands"; -export { DrawRectangleCommand } from "./ShapeCommands"; -export { - ReverseContourCommand, - NudgePointsCommand, - SplitSegmentCommand, - UpgradeLineToCubicCommand, -} from "./BezierCommands"; -export { BooleanOperationCommand, type BooleanOperation } from "./BooleanOperationCommand"; -export { - SetXAdvanceCommand, - SetLeftSidebearingCommand, - SetRightSidebearingCommand, -} from "./SidebearingCommands"; diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts b/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts deleted file mode 100644 index b68715dd..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/transform/AlignmentCommands.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { PointId } from "@shift/types"; -import type { Command, CommandContext } from "../core/Command"; -import type { AlignmentType, DistributeType } from "@/types/transform"; - -/** - * Aligns selected points along one edge or center of the selection's own - * bounding box (not the glyph bounds). Supports left, right, top, bottom, - * horizontal center, and vertical center. - */ -export class AlignPointsCommand implements Command { - readonly name: string; - - readonly #pointIds: PointId[]; - readonly #alignment: AlignmentType; - - constructor(pointIds: PointId[], alignment: AlignmentType) { - this.#pointIds = [...pointIds]; - this.#alignment = alignment; - this.name = `Align ${alignment}`; - } - - execute(ctx: CommandContext): void { - if (this.#pointIds.length === 0) return; - - ctx.layer.align(this.#pointIds, this.#alignment); - } -} - -/** - * Evenly distributes selected points along the horizontal or vertical axis. - * Requires at least 3 points; the outermost points remain fixed while inner - * points are spaced equally between them. - */ -export class DistributePointsCommand implements Command { - readonly name: string; - - readonly #pointIds: PointId[]; - readonly #type: DistributeType; - - constructor(pointIds: PointId[], type: DistributeType) { - this.#pointIds = [...pointIds]; - this.#type = type; - this.name = `Distribute ${type}`; - } - - execute(ctx: CommandContext): void { - if (this.#pointIds.length < 3) return; - - ctx.layer.distribute(this.#pointIds, this.#type); - } -} 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 deleted file mode 100644 index 2d6c595d..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { TestEditor } from "@/testing/TestEditor"; -import { - MoveSelectionToCommand, - ReflectPointsCommand, - RotatePointsCommand, - ScalePointsCommand, -} from "./TransformCommands"; - -// Restored from the WS6 behavioral inventory (git show ef037c6e^), rebuilt on -// the workspace stack: transforms persist through the movePoints intent and -// undo through the workspace ledger. -describe("transform commands through the workspace", () => { - let editor: TestEditor; - - beforeEach(async () => { - editor = new TestEditor(); - await editor.startSession(); - editor.selectTool("pen"); - editor.clickGlyphLocal(0, 0); - await editor.settle(); - editor.clickGlyphLocal(100, 0); - await editor.settle(); - }); - - const source = () => editor.editingGlyphLayer!; - const pointIds = () => source().allPoints.map((point) => point.id); - const positions = () => source().allPoints.map(({ x, y }) => ({ x, y })); - - it("rotates points around an origin", async () => { - editor.commands.run(new RotatePointsCommand(pointIds(), Math.PI / 2, { x: 0, y: 0 })); - await editor.settle(); - - const [first, second] = positions(); - expect(first!.x).toBeCloseTo(0); - expect(first!.y).toBeCloseTo(0); - expect(second!.x).toBeCloseTo(0); - expect(second!.y).toBeCloseTo(100); - }); - - it("scales points relative to an origin", async () => { - editor.commands.run(new ScalePointsCommand(pointIds(), 2, 1, { x: 0, y: 0 })); - await editor.settle(); - - expect(positions()).toEqual([ - { x: 0, y: 0 }, - { x: 200, y: 0 }, - ]); - }); - - it("reflects points across a vertical axis through an origin", async () => { - editor.commands.run(new ReflectPointsCommand(pointIds(), "vertical", { x: 50, y: 0 })); - await editor.settle(); - - expect(positions()).toEqual([ - { x: 100, y: 0 }, - { x: 0, y: 0 }, - ]); - }); - - it("moves the selection so the anchor lands on the target", async () => { - editor.commands.run(new MoveSelectionToCommand(pointIds(), { x: 10, y: 5 }, { x: 0, y: 0 })); - await editor.settle(); - - expect(positions()).toEqual([ - { x: 10, y: 5 }, - { x: 110, y: 5 }, - ]); - }); - - it("restores all positions with one ledger undo", async () => { - editor.commands.run(new RotatePointsCommand(pointIds(), Math.PI / 4, { x: 50, y: 50 })); - await editor.settle(); - - await editor.undoAndSettle(); - expect(positions()).toEqual([ - { x: 0, y: 0 }, - { x: 100, y: 0 }, - ]); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts b/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts deleted file mode 100644 index 2f3a4909..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/transform/TransformCommands.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { PointId } from "@shift/types"; -import type { Point2D } from "@shift/geo"; -import type { Command, CommandContext } from "../core/Command"; -import type { ReflectAxis } from "@/types/transform"; - -/** - * Rotates selected points by a given angle (radians) around an origin. - * Used by the rotate handle on the bounding box and keyboard shortcuts. - */ -export class RotatePointsCommand implements Command { - readonly name = "Rotate Points"; - - readonly #pointIds: PointId[]; - readonly #angle: number; - readonly #origin: Point2D; - - constructor(pointIds: PointId[], angle: number, origin: Point2D) { - this.#pointIds = [...pointIds]; - this.#angle = angle; - this.#origin = origin; - } - - execute(ctx: CommandContext): void { - ctx.layer.rotate(this.#pointIds, this.#angle, this.#origin); - } -} - -/** - * Scales selected points by independent x/y factors relative to an origin. - * Drives bounding-box resize handles. - */ -export class ScalePointsCommand implements Command { - readonly name = "Scale Points"; - - readonly #pointIds: PointId[]; - readonly #sx: number; - readonly #sy: number; - readonly #origin: Point2D; - - constructor(pointIds: PointId[], sx: number, sy: number, origin: Point2D) { - this.#pointIds = [...pointIds]; - this.#sx = sx; - this.#sy = sy; - this.#origin = origin; - } - - execute(ctx: CommandContext): void { - ctx.layer.scale(this.#pointIds, this.#sx, this.#sy, this.#origin); - } -} - -/** - * Mirrors selected points across a horizontal or vertical axis through an - * origin. Used for flip-horizontal / flip-vertical menu actions. - */ -export class ReflectPointsCommand implements Command { - readonly name = "Reflect Points"; - - readonly #pointIds: PointId[]; - readonly #axis: ReflectAxis; - readonly #origin: Point2D; - - constructor(pointIds: PointId[], axis: ReflectAxis, origin: Point2D) { - this.#pointIds = [...pointIds]; - this.#axis = axis; - this.#origin = origin; - } - - execute(ctx: CommandContext): void { - ctx.layer.reflect(this.#pointIds, this.#axis, this.#origin); - } -} - -/** - * Translates selected points so that a given anchor position lands on a - * target position. Computes the delta internally. Used for move-to-position - * and alignment workflows where the destination is absolute. - */ -export class MoveSelectionToCommand implements Command { - readonly name = "Move Selection To"; - - readonly #pointIds: PointId[]; - readonly #target: Point2D; - readonly #anchor: Point2D; - - constructor(pointIds: PointId[], target: Point2D, anchor: Point2D) { - this.#pointIds = [...pointIds]; - this.#target = target; - this.#anchor = anchor; - } - - execute(ctx: CommandContext): void { - ctx.layer.moveSelectionTo(this.#pointIds, this.#target, this.#anchor); - } -} diff --git a/apps/desktop/src/renderer/src/lib/commands/transform/index.ts b/apps/desktop/src/renderer/src/lib/commands/transform/index.ts deleted file mode 100644 index 60de2dda..00000000 --- a/apps/desktop/src/renderer/src/lib/commands/transform/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - RotatePointsCommand, - ScalePointsCommand, - ReflectPointsCommand, - MoveSelectionToCommand, -} from "./TransformCommands"; - -export { AlignPointsCommand, DistributePointsCommand } from "./AlignmentCommands"; diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts index 615f7b6e..3c84b632 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.test.ts @@ -1,180 +1,71 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { mintNodeId } from "@shift/types"; import { TestEditor } from "@/testing/TestEditor"; -import { glyphTextItem, lineBreakTextItem } from "@/lib/text/layout"; -import { mintItemId } from "@shift/types"; -describe("Editor", () => { +describe("Editor scene bootstrap", () => { let editor: TestEditor; beforeEach(async () => { editor = new TestEditor(); await editor.startSession(); - await editor.addGlyph("S", 83); }); - describe("scene bootstrap", () => { - it("places the focused glyph as one scene item with geometry shown at the origin", () => { - const record = editor.font.recordForName("A")!; - const itemId = editor.scene.value.items[0]?.id ?? null; - const item = editor.scene.item(itemId); + it("places the opened glyph as one glyph node at the origin", () => { + const record = editor.font.recordForName("A")!; + const nodes = editor.scene.nodes(); - expect(editor.scene.value.items).toHaveLength(1); - expect(item).toMatchObject({ - kind: "glyph", - glyphId: record.id, - placement: { origin: { x: 0, y: 0 } }, - }); - expect(itemId && editor.scene.isGeometryShown(itemId)).toBe(true); - expect(itemId && editor.layerForItem(itemId)).not.toBeNull(); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ + kind: "glyph", + glyphId: record.id, + sourceId: editor.font.defaultSource.id, + position: { x: 0, y: 0 }, }); + }); - it("derives the preview glyph record from the geometry-shown scene item", () => { - const record = editor.font.recordForName("S")!; - - editor.scene.clear(); - const itemId = editor.scene.addGlyph({ - glyphId: record.id, - origin: { x: 0, y: 0 }, - }); - editor.scene.setGeometryItems([itemId]); + it("can place the same glyph id twice with distinct node ids", () => { + const record = editor.font.recordForName("A")!; + const left = mintNodeId(); + const right = mintNodeId(); - expect(editor.scene.value.items).toHaveLength(1); - expect(editor.scene.item(itemId)).toMatchObject({ + editor.scene.setNodes([ + { + id: left, + type: "node", kind: "glyph", + parentId: null, + index: "a0", glyphId: record.id, - placement: { origin: { x: 0, y: 0 } }, - }); - expect(editor.scene.isGeometryShown(itemId)).toBe(true); - expect(editor.previewGlyphRecordCell.peek()?.id).toBe(record.id); - expect(editor.layerForItem(itemId)).not.toBeNull(); - }); - - it("clearing the scene clears the derived preview glyph record", () => { - const record = editor.font.recordForName("S")!; - - editor.scene.clear(); - const itemId = editor.scene.addGlyph({ + sourceId: editor.font.defaultSource.id, + position: { x: 0, y: 0 }, + }, + { + id: right, + type: "node", + kind: "glyph", + parentId: null, + index: "a1", glyphId: record.id, - origin: { x: 0, y: 0 }, - }); - editor.scene.setGeometryItems([itemId]); - - expect(editor.previewGlyphRecordCell.peek()?.id).toBe(record.id); - - editor.scene.clear(); - - expect(editor.scene.value.items).toEqual([]); - expect(editor.previewGlyphRecordCell.peek()).toBeNull(); + sourceId: editor.font.defaultSource.id, + position: { x: 700, y: 0 }, + }, + ]); + + expect(editor.scene.node(left)?.glyphId).toBe(record.id); + expect(editor.scene.node(right)?.glyphId).toBe(record.id); + expect(editor.getPointInNodeSpace({ x: 710, y: 20 }, { x: 700, y: 0 })).toEqual({ + x: 10, + y: 20, }); - it("can place the same glyph id twice with distinct item ids", async () => { - const record = editor.font.recordForName("A")!; - const left = mintItemId(); - const right = mintItemId(); + const rightNode = editor.scene.node(right); + if (!rightNode) throw new Error("Expected right glyph node"); - editor.scene.set({ - items: [ - { - id: left, - kind: "glyph", - glyphId: record.id, - placement: { origin: { x: 0, y: 0 } }, - }, - { - id: right, - kind: "glyph", - glyphId: record.id, - placement: { origin: { x: 700, y: 0 } }, - }, - ], - geometryItems: [left], - }); - - expect(editor.scene.item(left)?.glyphId).toBe(record.id); - expect(editor.scene.item(right)?.glyphId).toBe(record.id); - expect(editor.scene.toScene(right, { x: 10, y: 20 })).toEqual({ - x: 710, - y: 20, - }); - expect(editor.scene.toLocal(right, { x: 710, y: 20 })).toEqual({ - x: 10, - y: 20, - }); - expect(editor.scene.isGeometryShown(left)).toBe(true); - expect(editor.scene.isGeometryShown(right)).toBe(false); - - editor.scene.moveItemBy(right, { x: 30, y: 5 }); - expect(editor.scene.item(right)?.placement.origin).toEqual({ - x: 730, - y: 5, - }); + editor.scene.updateNode({ + ...rightNode, + position: { x: rightNode.position.x + 30, y: rightNode.position.y + 5 }, }); - }); - // focusedGlyphVisible rule: - // No active text-run activity (buffer empty AND cursor not visible) - // → render the glyph normally. (grid → canvas open path) - // Active text run (typed something or Text tool active) - // → render the glyph only when in-place editing a slot. - describe("focused glyph visibility follows text focus", () => { - it("renders the glyph in initial state (no run, no cursor visible)", () => { - expect(editor.textRun.buffer.items).toHaveLength(0); - expect(editor.textRun.cursorVisible).toBe(false); - expect(editor.focusedGlyphVisible()).toBe(true); - }); - - it("does not render the glyph once the run has items and no slot edit", () => { - editor.selectTool("text"); - expect(editor.focusedGlyphVisible()).toBe(false); - }); - - it("renders the glyph again when in-place editing a slot", () => { - editor.selectTool("text"); - const item = glyphTextItem("S", 83); - editor.textRun.insert(item); - editor.setGlyphFocus({ runId: editor.textRun.id, itemId: item.id }); - expect(editor.focusedGlyphVisible()).toBe(true); - }); - - it("does not render the glyph with empty buffer but cursor visible (Text tool active)", () => { - editor.textRun.setCursorVisible(true); - expect(editor.focusedGlyphVisible()).toBe(false); - }); - }); - - describe("glyph focus placement", () => { - it("recomputes drawOffset from the focused item after inserting a linebreak before it", () => { - editor.selectTool("text"); - const s = glyphTextItem("S", 83); - editor.textRun.insert(s); - editor.setGlyphFocus({ runId: editor.textRun.id, itemId: s.id }); - const firstLineOrigin = editor.glyphPlacement?.focused.editOrigin; - - editor.textRun.buffer.placeCaret(1); - editor.textRun.insert(lineBreakTextItem()); - editor.selectTool("select"); - - const secondLineOrigin = editor.textRun.layoutCell.peek()?.editOriginForItem(s.id); - expect(editor.focusedGlyph?.anchor.itemId).toBe(s.id); - expect(editor.focusedGlyph?.glyph.name).toBe("S"); - expect(secondLineOrigin?.y).toBe(editor.textRun.layoutCell.peek()?.lines[1].y); - expect(editor.glyphPlacement?.focused.editOrigin).toEqual(secondLineOrigin); - expect(editor.drawOffset).toEqual(secondLineOrigin); - expect(editor.drawOffset).not.toEqual(firstLineOrigin); - }); - - it("clears derived placement when the focused item is deleted", () => { - editor.selectTool("text"); - const s = glyphTextItem("S", 83); - editor.textRun.insert(s); - editor.setGlyphFocus({ runId: editor.textRun.id, itemId: s.id }); - - editor.textRun.buffer.placeCaret(2); - editor.textRun.delete(); - - expect(editor.focusedGlyph).toBeNull(); - expect(editor.glyphPlacement).toBeNull(); - expect(editor.drawOffset).toEqual({ x: 0, y: 0 }); - }); + expect(editor.scene.node(right)?.position).toEqual({ x: 730, y: 5 }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 7785106d..5b1dbeaa 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -1,16 +1,20 @@ import type { CursorType, ToolRegistryItem } from "@/types/editor"; -import type { - PointId, - ContourId, - Source, - SourceId, - GlyphName, - GlyphRecord, - ItemId, +import { + isAnchorId, + isContourId, + isNodeId, + isPointId, + type AnchorId, + type PointId, + type ContourId, + type Source, + type SourceId, + type GlyphName, + type GlyphRecord, } from "@shift/types"; +import { isSegmentId, type SegmentId } from "@shift/glyph-state"; 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, @@ -18,36 +22,10 @@ import { } from "@/lib/variation/location"; 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 { Bounds, Vec2, type Bounds as BoundsType, type Point2D, type Rect2D } from "@shift/geo"; import { Camera } from "./managers"; import { - CommandRunner, - SetLeftSidebearingCommand, - SetRightSidebearingCommand, - SetXAdvanceCommand, - NudgePointsCommand, - ReverseContourCommand, - SplitSegmentCommand, - UpgradeLineToCubicCommand, - BooleanOperationCommand, - CutCommand, - PasteCommand, -} from "../commands"; -import { - RotatePointsCommand, - ScalePointsCommand, - ReflectPointsCommand, - MoveSelectionToCommand, - AlignPointsCommand, - DistributePointsCommand, - type ReflectAxis, - type AlignmentType, - type DistributeType, -} from "../transform"; -import { - batch, computed, effect, signal, @@ -57,14 +35,16 @@ import { } from "../signals/signal"; import { Clipboard, - ClipboardContent, ClipboardSelection, + type PasteOptions, + type ShiftContent, type SystemClipboard, } from "../clipboard"; import { cursorToCSS } from "../styles/cursor"; import { EdgePanManager } from "./managers"; import { Hover } from "./Hover"; import { Renderer } from "./rendering/Renderer"; +import { Scene } from "./Scene"; import type { Canvas2DSurface, MarkerCanvasSurface } from "./rendering/CanvasSurface"; import type { CameraTransform } from "./managers"; import type { FocusZone } from "@/types/focus"; @@ -72,29 +52,22 @@ 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 { ShiftStore } from "@/lib/store/ShiftStore"; +import { EditorGesture, EditorInput, EditorViewState } from "./EditorState"; +import type { PointerTarget } from "@/types/target"; +import type { SelectableId, ShiftEditorRecord, ShiftId, ShiftObject } from "@/types"; +import type { GlyphNode } from "@/types/node"; +import { AnchorObject, ContourObject, NodeObject, PointObject, SegmentObject } from "@/lib/objects"; interface EditorOptions { font: Font; @@ -119,8 +92,8 @@ interface EditorOptions { * 3. Call `setActiveTool()` to begin interaction. * 4. Call `destroy()` on teardown to dispose effects and the renderer. * - * Font state arrives through the injected `Font` projection; there is no - * load call on the editor. + * Font state arrives through the injected `Font` model; the editor does not + * expose a separate glyph loading API. * * @knipclassignore */ @@ -128,7 +101,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. */ @@ -145,6 +118,7 @@ export class Editor { readonly hover: Hover; readonly font: Font; readonly scene: Scene; + readonly #store: ShiftStore; /** * Rendering and camera infrastructure. @@ -165,29 +139,22 @@ export class Editor { shortcut?: string; } >; - #activeTool: WritableSignal; + #activeTool: Signal; #activeToolState: WritableSignal; #isEditing: Signal; + #selectionBounds: Signal; /** * Runtime services with lifecycle or side effects. * - * These mutate process/editor state: camera, command history, bridge IO, + * These mutate process/editor state: camera, bridge IO, * event dispatch, and registered tool state. They should stay separate from * immutable glyph geometry and from render-only state. */ #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; @@ -198,20 +165,9 @@ export class Editor { #textRuns: TextRuns; - #glyphFinderOpen: WritableSignal; - #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; @@ -227,42 +183,52 @@ export class Editor { this.#camera = new Camera(); this.font = options.font; - this.scene = new Scene(); - this.#designLocation = signal(emptyAxisLocation(), { + this.#store = new ShiftStore(); + this.scene = new Scene(this.#store); + + 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.#toolState = { app: new Map(), document: new Map(), }; - this.#glyphFinderOpen = signal(false, { name: "editor.glyphFinder.open" }); - - this.selection = new Selection(this.#glyph.layerEditing.glyphLayer); + this.selection = new Selection(this.#store); this.hover = new Hover(); + this.#selectionBounds = computed(() => this.selectionBounds(), { + name: "editor.selection.bounds", + }); this.#edgePan = new EdgePanManager(this); this.#toolMetadata = new Map(); - this.#activeTool = signal("select", { - name: "editor.tool.active", - }); this.#activeToolState = signal( { type: "idle" }, { name: "editor.tool.state", }, ); + + // TODO: why not make editor extend EventEmitter? this.#events = new EventEmitter(); this.#toolManager = new ToolManager(this); + this.#activeTool = computed(() => this.#toolManager.activeToolCell.value?.id ?? null, { + name: "editor.tool.active", + }); this.#isEditing = computed( () => this.#toolManager.activeToolCell.value?.isEditingCell.value ?? false, { name: "editor.isEditing" }, @@ -271,8 +237,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); @@ -326,16 +290,16 @@ export class Editor { return out; } - public get activeTool(): ToolName { + public get activeTool(): ToolName | null { return this.#activeTool.peek(); } - public get activeToolCell(): Signal { + public get activeToolCell(): Signal { return this.#activeTool; } // oxlint-disable-next-line shift/no-get-signal-value-method -- retained for upcoming tool refactor - public getActiveTool(): ToolName { + public getActiveTool(): ToolName | null { return this.#activeTool.peek(); } @@ -369,11 +333,11 @@ export class Editor { } public setActiveTool(toolName: ToolName): void { - const currentToolName = this.#activeTool.peek(); - if (currentToolName === toolName) return; + if (this.toolManager.primaryToolId === toolName && this.toolManager.activeToolId === toolName) { + return; + } this.toolManager.activate(toolName); - this.#activeTool.set(toolName); } public get toolManager(): ToolManager { @@ -400,15 +364,6 @@ export class Editor { this.input.setModifiers(modifiers); } - 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); - } - public get debugOverlays(): DebugOverlays { return this.#view.debugOverlaysCell.peek(); } @@ -421,60 +376,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,136 +407,223 @@ export class Editor { return this.font.createGlyph(name); } + public get designLocationCell(): Signal { + return this.#designLocation; + } + + public get activeSourceIdCell(): Signal { + return this.#activeSourceId; + } + + public get activeSourceId(): SourceId | null { + return this.#activeSourceId.peek(); + } + + 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. */ + public get designLocation(): AxisLocation { + return this.#designLocation.peek(); + } + /** - * Focus a glyph item and derive editor placement from current layout. + * Set the displayed designspace coordinate shared by editor views. + */ + public setDesignLocation(location: AxisLocation): void { + const next = cloneAxisLocation(location); + this.#designLocation.set(next); + this.#activeSourceId.set(this.#sourceIdAtLocation(next)); + } + + /** + * Resolves an editor-addressable id to the current live object. * - * GlyphAnchor { runId, itemId } - * │ - * ▼ - * TextRuns.resolveAnchor(anchor) - * │ - * ▼ - * source glyph geometry + drawOffset = focused.editOrigin + * @param id - Object identity to resolve in the current editor state. + * @returns The resolved object, or `null` when no object exists for the id. */ - public setGlyphFocus(anchor: GlyphAnchor): void { - const focused = this.#textRuns.resolveAnchor(anchor); - if (!focused) { - this.clearGlyphFocus(); - return; + public object(id: ShiftId): ShiftObject | null { + if (isNodeId(id)) { + const node = this.scene.node(id); + if (!node) return null; + + return new NodeObject(node); } - 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(); - }); - } + if (isPointId(id)) { + const layer = this.#layerForPoint(id); + if (!layer) return null; - public clearGlyphFocus(): void { - this.#text.glyphAnchor.set(null); - } + const node = this.#placedGlyphNodeForLayer(layer); + if (!node) return null; + + return new PointObject(id, layer, node); + } + + if (isAnchorId(id)) { + const layer = this.#layerForAnchor(id); + if (!layer) return null; + + const node = this.#placedGlyphNodeForLayer(layer); + if (!node) return null; + + return new AnchorObject(id, layer, node); + } + + if (isSegmentId(id)) { + const layer = this.#layerForSegment(id); + if (!layer) return null; + + const node = this.#placedGlyphNodeForLayer(layer); + if (!node) return null; - public get focusedGlyph(): FocusedGlyph | null { - return this.#text.focusedGlyph.peek(); + const pointIds = this.font.pointIdsForSegment(id); + if (!pointIds) return null; + + return new SegmentObject(id, pointIds, layer, node); + } + + if (isContourId(id)) { + const layer = this.#layerForContour(id); + if (!layer) return null; + + const node = this.#placedGlyphNodeForLayer(layer); + if (!node) return null; + + return new ContourObject(id, layer, node); + } + + return null; } - public get focusedGlyphCell(): Signal { - return this.#text.focusedGlyph; + #layerForPoint(pointId: PointId): GlyphLayer | null { + const layerId = this.font.layerIdForPoint(pointId); + if (!layerId) return null; + + const layer = this.font.layerById(layerId); + if (!layer?.point(pointId)) return null; + + return layer; } - public get glyphPlacement(): GlyphPlacement | null { - return this.#text.glyphPlacement.peek(); + #layerForAnchor(anchorId: AnchorId): GlyphLayer | null { + const layerId = this.font.layerIdForAnchor(anchorId); + if (!layerId) return null; + + const layer = this.font.layerById(layerId); + if (!layer?.anchor(anchorId)) return null; + + return layer; } - /** Clears scene glyph focus and the active contour selection. */ - public close(): void { - this.scene.clear(); - this.#glyph.sceneGlyph.activeContourId.set(null); + #layerForSegment(segmentId: SegmentId): GlyphLayer | null { + const layerId = this.font.layerIdForSegment(segmentId); + if (!layerId) return null; + + const layer = this.font.layerById(layerId); + if (!layer?.segment(segmentId)) return null; + + return layer; } - public get designLocationCell(): Signal { - return this.#designLocation; + #layerForContour(contourId: ContourId): GlyphLayer | null { + const layerId = this.font.layerIdForContour(contourId); + if (!layerId) return null; + + const layer = this.font.layerById(layerId); + if (!layer?.contour(contourId)) return null; + + return layer; } - /** Current designspace coordinate used for displayed glyph data. */ - public get designLocation(): AxisLocation { - return this.#designLocation.peek(); + #placedGlyphNodeForLayer(layer: GlyphLayer): GlyphNode | null { + for (const node of this.scene.nodesOfKind("glyph")) { + if (node.sourceId !== layer.sourceId) continue; + + const nodeLayer = this.font.layer(node.glyphId, node.sourceId); + if (nodeLayer?.id === layer.id) return node; + } + + return null; } /** - * Reactive ID of the designspace source selected for layer editing. + * Resolves editor-addressable ids to objects. * - * `null` means the current design location does not exactly match a source. + * @param ids - Object identities to resolve in selection or command order. + * @returns Resolved objects in input order. Unresolved ids are omitted. */ - public get layerSourceIdCell(): Signal { - return this.#glyph.layerEditing.sourceId; - } + public objects(ids: readonly ShiftId[]): readonly ShiftObject[] { + const objects: ShiftObject[] = []; - /** ID of the exact designspace source at the current location, or `null`. */ - public get layerSourceId(): SourceId | null { - return this.#glyph.layerEditing.sourceId.peek(); - } + for (const id of ids) { + const object = this.object(id); + if (object) objects.push(object); + } - /** Font source currently selected for layer editing, or `null` when unavailable. */ - public get layerSource(): Source | null { - return this.#glyph.layerEditing.selectedSource.peek(); + return objects; } /** - * Returns the authored glyph layer currently mutated by edit commands. + * Returns the current selection bounds in scene coordinates. * * @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. + * Selection stores IDs only. This method resolves those IDs against the + * current scene and font, asks each object for its live bounds, and returns a + * fresh axis-aligned rectangle enclosing the resolved objects. * - * @returns null when no authored glyph layer is available. + * @returns null when nothing is selected or no selected object has bounds. */ - 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; - } + public selectionBounds(): Rect2D | null { + let bounds: BoundsType | null = null; + + for (const id of this.selection.ids) { + const object = this.object(id); + if (!object) continue; + + const objectBounds = object.bounds(); + if (!objectBounds) continue; + + const next = Bounds.fromXYWH( + objectBounds.x, + objectBounds.y, + objectBounds.width, + objectBounds.height, + ); + bounds = bounds ? Bounds.union(bounds, next) : next; + } - /** Glyph instance shown by the editor preview at the current design location. */ - public get previewGlyphInstance(): GlyphInstance | null { - return this.#glyph.preview.instance.peek(); + return bounds ? Bounds.toRect(bounds) : null; } - /** - * Set the displayed designspace coordinate shared by editor views. - */ - public setDesignLocation(location: AxisLocation): void { - this.#designLocation.set(cloneAxisLocation(location)); + /** Reactive scene-space bounds for the current selection. */ + public get selectionBoundsCell(): Signal { + return this.#selectionBounds; } /** - * 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. + * refer to point IDs that layer operations 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; + const sourceId = this.activeSourceId; + if (!sourceId) return; + + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return; + + const [node] = glyphNodes; + if (!node) return; + + const layer = this.font.layer(node.glyphId, sourceId); + if (!layer) return; + + this.selection.select(layer.allPoints.map((point) => point.id)); } /** @@ -648,6 +636,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 +647,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 +673,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,38 +690,65 @@ 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; - - return this.font.editableLayerAt(item.glyphId, this.designLocation); - } + 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, + segmentId: hit.id, + pointIds: segment.pointIds, + }; + } + + if (hit.kind === "point") { + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point: nodePoint, + pointId: hit.id, + }; + } + + if (hit.kind === "anchor") { + return { + ...hit, + nodeId: node.id, + glyphId: node.glyphId, + point: nodePoint, + anchorId: hit.id, + }; + } + + break; + } + } + } - /** Stateless command executor; undo authority is the workspace ledger. */ - public get commands(): CommandRunner { - return this.#commands; + return { kind: "canvas", point }; } /** Subscribe to a lifecycle event. Returns an unsubscribe function. */ @@ -763,22 +774,6 @@ export class Editor { this.#zone = zone; } - public get glyphFinderOpen(): boolean { - return this.#glyphFinderOpen.peek(); - } - - public get glyphFinderOpenCell(): Signal { - return this.#glyphFinderOpen; - } - - public openGlyphFinder(): void { - this.#glyphFinderOpen.set(true); - } - - public closeGlyphFinder(): void { - this.#glyphFinderOpen.set(false); - } - public undo() { // One undo authority: the workspace ledger (state-pair replay). void this.font.editCoordinator.undo(); @@ -788,6 +783,17 @@ export class Editor { void this.font.editCoordinator.redo(); } + /** + * Groups synchronous workspace edits into one undoable operation. + * + * @param label - Human-readable operation name for diagnostics and future ledger labels. + * @param body - Synchronous edit body that calls model mutation APIs. + * @returns The value returned by `body`. + */ + public transaction(label: string, body: () => TResult): TResult { + return this.font.editCoordinator.transaction(label, body); + } + public setCameraRect(rect: Rect2D) { this.#camera.setRect(rect); } @@ -797,61 +803,70 @@ export class Editor { } public get xAdvance(): number { - return this.previewGlyphInstance?.xAdvance ?? 0; + const sourceId = this.activeSourceId; + if (!sourceId) return 0; + + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return 0; + + const [node] = glyphNodes; + if (!node) return 0; + + return this.font.layer(node.glyphId, sourceId)?.xAdvance ?? 0; } /** - * Sets the editable glyph layer's horizontal advance through command history. + * Sets the current glyph layer's horizontal advance. * * @param width - New advance width in UPM units. */ public setXAdvance(width: number): void { - if (!this.editingGlyphLayer) return; - const instance = this.previewGlyphInstance; - if (!instance) return; + const sourceId = this.activeSourceId; + if (!sourceId) return; + + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return; - if (instance.xAdvance === width) return; + const [node] = glyphNodes; + if (!node) return; - this.#commands.run(new SetXAdvanceCommand(width)); + this.font.layer(node.glyphId, sourceId)?.setXAdvance(width); } /** - * Sets the editable glyph layer's left sidebearing by translating its outline. + * Sets the current glyph layer's left sidebearing. * * @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 sourceId = this.activeSourceId; + if (!sourceId) return; - const bbox = instance.render.outline.bounds; - if (!bbox) return; + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return; - const delta = Math.round(value) - Math.round(bbox.min.x); - if (delta === 0) return; + const [node] = glyphNodes; + if (!node) return; - this.#commands.run(new SetLeftSidebearingCommand(instance.xAdvance + delta, delta)); + this.font.layer(node.glyphId, sourceId)?.setLeftSidebearing(value); } /** - * Sets the editable glyph layer's right sidebearing by changing its advance. + * Sets the current glyph layer's right sidebearing. * * @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 sourceId = this.activeSourceId; + if (!sourceId) return; - const bbox = instance.render.outline.bounds; - if (!bbox) return; + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return; - const currentRsb = instance.xAdvance - bbox.max.x; - const delta = Math.round(value) - Math.round(currentRsb); - if (delta === 0) return; + const [node] = glyphNodes; + if (!node) return; - this.#commands.run(new SetRightSidebearingCommand(instance.xAdvance + delta)); + this.font.layer(node.glyphId, sourceId)?.setRightSidebearing(value); } public updateMetricsFromFont(): void { @@ -888,16 +903,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 +934,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,217 +989,203 @@ 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; + /** + * Builds portable editor content from live object ids. + * + * @remarks + * The first content producer supports glyph point, segment, and contour + * objects that resolve to one authored layer. Segment and contour objects are + * expanded to concrete points before content is detached from live state. + * + * @param ids - Object identities to snapshot. + * @returns Detached content, or `null` when ids are empty, unsupported, + * unresolved, span multiple layers, or produce no portable geometry. + */ + public contentFrom(ids: readonly ShiftId[]): ShiftContent | null { + const selection = this.#pointSelectionFromIds(ids); + if (!selection) return null; - const glyph = this.#glyph.sceneGlyph.record.peek(); - if (!glyph) return false; + const content = ClipboardSelection.fromPointIds(selection.pointIds).contentFrom( + selection.layer, + ); + if (!content || content.contours.length === 0) return null; - return this.#clipboard.write(content, { sourceGlyph: glyph.name }); + return content; } - public async cut(): Promise { - const content = this.#selectedClipboardContent(); - if (!content || content.contours.length === 0) return false; + #pointSelectionFromIds( + ids: readonly ShiftId[], + ): { layer: GlyphLayer; pointIds: readonly PointId[] } | null { + const objects = this.objects(ids); + if (objects.length === 0 || objects.length !== ids.length) return null; - const glyph = this.#glyph.sceneGlyph.record.peek(); - if (!glyph) return false; + let layer: GlyphLayer | null = null; + const pointIds = new Set(); - const written = await this.#clipboard.write(content, { - sourceGlyph: glyph.name, - }); - if (!written) return false; + const useLayer = (next: GlyphLayer): boolean => { + if (layer && layer.id !== next.id) return false; - const pointIds = this.#selectedClipboardPointIds(); - this.#commands.run(new CutCommand(pointIds)); - this.selection.clear(); - - return true; - } - - public async paste(): Promise { - const result = await this.#clipboard.read(); - if (result.kind !== "glyph" || result.content.contours.length === 0) return; - - this.selection.clear(); - const command = new PasteCommand(result.content, { - offset: this.#clipboard.nextPasteOffset(), - }); - this.#commands.run(command); + layer = next; + return true; + }; - if (command.createdPointIds.length > 0) { - this.selection.select(command.createdPointIds.map((id) => ({ kind: "point", id }))); - } - } + for (const object of objects) { + switch (object.kind) { + case "point": + if (!useLayer(object.layer)) return null; - #selectionCenter(): Point2D | null { - const bounds = this.selection.bounds; - return bounds ? Bounds.center(bounds) : null; - } + pointIds.add(object.pointId); + break; - #selectedClipboardContent(): ClipboardContent | null { - const source = this.#glyph.layerEditing.glyphLayer.peek(); - if (!source) return null; + case "segment": + if (!useLayer(object.layer)) return null; - const selection = ClipboardSelection.fromSelection(this.selection); - if (selection.pointIds.length === 0) return null; + for (const pointId of object.pointIds) pointIds.add(pointId); + break; - return selection.contentFrom(source); - } + case "contour": { + if (!useLayer(object.layer)) return null; - #selectedClipboardPointIds(): PointId[] { - const selection = ClipboardSelection.fromSelection(this.selection); - if (selection.pointIds.length === 0) return []; + const contour = object.layer.contour(object.contourId); + if (!contour) return null; - return [...selection.pointIds]; - } + for (const point of contour.points) pointIds.add(point.id); + break; + } - /** @param angle - Rotation in radians. */ - public rotateSelection(angle: number, origin?: Point2D): void { - const pointIds = [...this.selection.pointIds]; - if (pointIds.length === 0) return; + case "anchor": + case "node": + return null; + } + } - const center = origin ?? this.#selectionCenter(); - if (!center) return; + if (!layer) return null; - const cmd = new RotatePointsCommand([...pointIds], angle, center); - this.#commands.run(cmd); + return { layer, pointIds: [...pointIds] }; } - public scaleSelection(sx: number, sy: number, origin?: Point2D): void { - const pointIds = [...this.selection.pointIds]; - if (pointIds.length === 0) return; + /** + * Inserts portable content into the current editor destination. + * + * @remarks + * The first insertion destination is conservative: exactly one glyph node in + * the scene plus an active source resolves to one authored glyph layer. + * + * @param content - Detached content produced by {@link contentFrom} or clipboard import. + * @param options - Placement options applied while minting destination objects. + * @returns Selection IDs for inserted objects, or `null` when no content can + * be inserted into the current destination. + */ + public insertContent( + content: ShiftContent, + options: PasteOptions = { offset: { x: 0, y: 0 } }, + ): readonly SelectableId[] | null { + const sourceId = this.activeSourceId; + if (!sourceId) return null; - const o = origin ?? this.#selectionCenter(); - if (!o) return; + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return null; - const cmd = new ScalePointsCommand([...pointIds], sx, sy, o); - this.#commands.run(cmd); - } + const [node] = glyphNodes; + if (!node) return null; - public reflectSelection(axis: ReflectAxis, origin?: Point2D): void { - const pointIds = [...this.selection.pointIds]; - if (pointIds.length === 0) return; + const layer = this.font.layer(node.glyphId, sourceId); + if (!layer) return null; - const center = origin ?? this.#selectionCenter(); - if (!center) return; + const inserted: SelectableId[] = []; - const cmd = new ReflectPointsCommand([...pointIds], axis, center); - this.#commands.run(cmd); - } + this.transaction("Insert content", () => { + for (const contour of content.contours) { + if (contour.points.length === 0) continue; - public rotate90CCW(): void { - this.rotateSelection(Math.PI / 2); - } + const contourId = layer.addContour(); - public rotate90CW(): void { - this.rotateSelection(-Math.PI / 2); - } + for (const point of contour.points) { + const pointId = layer.addPoint(contourId, { + ...point, + x: point.x + options.offset.x, + y: point.y + options.offset.y, + }); - public rotate180(): void { - this.rotateSelection(Math.PI); - } + inserted.push(pointId); + } - public flipHorizontal(): void { - this.reflectSelection("horizontal"); - } + if (contour.closed) { + layer.closeContour(contourId); + } + } + }); - public flipVertical(): void { - this.reflectSelection("vertical"); + return inserted.length === 0 ? null : inserted; } - public moveSelectionTo(target: Point2D, anchor: Point2D): void { - const pointIds = [...this.selection.pointIds]; - if (pointIds.length === 0) return; + /** + * Writes selected editor content to the system clipboard. + * + * @returns `true` when portable content was written, otherwise `false`. + */ + public async copy(): Promise { + const content = this.contentFrom(this.selection.ids); + if (!content) return false; - const cmd = new MoveSelectionToCommand([...pointIds], target, anchor); - this.#commands.run(cmd); + return this.#clipboard.write(content); } - public alignSelection(alignment: AlignmentType): void { - const pointIds = [...this.selection.pointIds]; - if (pointIds.length === 0) return; + public async cut(): Promise { + const selection = this.#pointSelectionFromIds(this.selection.ids); + if (!selection || selection.pointIds.length === 0) return false; - const cmd = new AlignPointsCommand([...pointIds], alignment); - this.#commands.run(cmd); - } + const content = ClipboardSelection.fromPointIds(selection.pointIds).contentFrom( + selection.layer, + ); + if (!content || content.contours.length === 0) return false; - public distributeSelection(type: DistributeType): void { - const pointIds = [...this.selection.pointIds]; - if (pointIds.length < 3) return; + const written = await this.#clipboard.write(content); + if (!written) return false; - const cmd = new DistributePointsCommand([...pointIds], type); - this.#commands.run(cmd); - } + this.transaction("Cut", () => { + selection.layer.removePoints(selection.pointIds); + }); + this.selection.clear(); - public get activeContourIdCell(): Signal { - return this.#glyph.sceneGlyph.activeContourId; + return true; } - public getActiveContourId(): ContourId | null { - const id = this.#glyph.sceneGlyph.activeContourId.peek(); - if (!id) return null; + public deleteSelection(): boolean { + const selection = this.#pointSelectionFromIds(this.selection.ids); + if (!selection || selection.pointIds.length === 0) return false; - return id; - } + this.transaction("Delete selection", () => { + selection.layer.removePoints(selection.pointIds); + }); + this.selection.clear(); - public setActiveContour(contourId: ContourId | null): void { - this.#glyph.sceneGlyph.activeContourId.set(contourId); + return true; } - public clearActiveContour(): void { - this.#glyph.sceneGlyph.activeContourId.set(null); - } + /** + * Reads the system clipboard and inserts supported content. + * + * @returns `true` when content was inserted, otherwise `false`. + */ + public async paste(): Promise { + const result = await this.#clipboard.read(); - public getActiveContour(): Contour | null { - const activeContourId = this.getActiveContourId(); - if (!activeContourId) return null; + switch (result.kind) { + case "content": { + const inserted = this.insertContent(result.content, { + offset: this.#clipboard.nextPasteOffset(), + }); + if (!inserted) return false; - return this.previewGlyphInstance?.geometry.contour(activeContourId) ?? null; - } + this.selection.select(inserted); + return true; + } - public continueContour(contourId: ContourId, fromStart: boolean, pointId: PointId): void { - this.#glyph.sceneGlyph.activeContourId.set(contourId); - if (fromStart) { - this.#commands.run(new ReverseContourCommand(contourId)); + case "empty": + case "unsupported": + return false; } - this.selection.select([{ kind: "point", id: pointId }]); - } - - public splitSegment(segment: Segment, t: number): PointId { - return this.#commands.run(new SplitSegmentCommand(segment, t)); - } - - public scalePoints(pointIds: readonly PointId[], sx: number, sy: number, anchor: Point2D): void { - if (pointIds.length === 0 || (sx === 1 && sy === 1)) return; - this.#commands.run(new ScalePointsCommand([...pointIds], sx, sy, anchor)); - } - - public rotatePoints(pointIds: readonly PointId[], angle: number, center: Point2D): void { - if (pointIds.length === 0 || angle === 0) return; - this.#commands.run(new RotatePointsCommand([...pointIds], angle, center)); - } - - public nudgePoints(pointIds: readonly PointId[], dx: number, dy: number): void { - if (pointIds.length === 0 || (dx === 0 && dy === 0)) return; - this.#commands.run(new NudgePointsCommand([...pointIds], dx, dy)); - } - - public upgradeLineToCubic(segment: LineSegmentPoints): void { - this.#commands.run(new UpgradeLineToCubicCommand(segment)); } public boolean( @@ -1215,25 +1193,29 @@ export class Editor { contourIdB: ContourId, operation: "union" | "subtract" | "intersect" | "difference", ): void { - this.#commands.run(new BooleanOperationCommand(contourIdA, contourIdB, operation)); + const sourceId = this.activeSourceId; + if (!sourceId) return; + + const glyphNodes = this.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return; + + const [node] = glyphNodes; + if (!node) return; + + const layer = this.font.layer(node.glyphId, sourceId); + if (!layer) return; + + layer.applyBooleanOp(contourIdA, contourIdB, operation); } public duplicateSelection(): PointId[] { - const content = this.#selectedClipboardContent(); + const content = this.contentFrom(this.selection.ids); if (!content || content.contours.length === 0) return []; - const command = new PasteCommand(content, { offset: { x: 0, y: 0 } }); - this.#commands.run(command); - return command.createdPointIds; - } - - public get drawOffset(): Point2D { - return this.#text.drawOffset.peek(); - } + const inserted = this.insertContent(content, { offset: { x: 0, y: 0 } }); + if (!inserted) return []; - /** @knipclassignore */ - public get drawOffsetCell(): Signal { - return this.#text.drawOffset; + return inserted.filter(isPointId); } public destroy() { 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..5bbf03ea 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Hover.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Hover.ts @@ -1,78 +1,59 @@ -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"; +import type { SelectableId } from "@/types"; -interface PointHover { - type: "point"; - pointId: PointId; -} - -interface AnchorHover { - type: "anchor"; - anchorId: AnchorId; -} - -interface SegmentHover { - type: "segment"; - segmentId: SegmentId; -} - -export type HoverState = PointHover | AnchorHover | SegmentHover | null; +export type HoverEntry = SelectableId; +export type HoverableId = SelectableId; /** - * 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; + }, + { 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(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); } } diff --git a/apps/desktop/src/renderer/src/lib/editor/Scene.ts b/apps/desktop/src/renderer/src/lib/editor/Scene.ts index b76abecd..0012071d 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Scene.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Scene.ts @@ -1,295 +1,129 @@ -import type { Point2D } from "@shift/geo"; -import { mintItemId, type GlyphId, type ItemId } from "@shift/types"; -import { signal, type Signal, type WritableSignal } from "@/lib/signals"; - -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; -} +import type { NodeId } from "@shift/types"; +import { computed, type Signal } from "@/lib/signals"; +import type { ShiftStore } from "@/lib/store/ShiftStore"; +import type { ShiftEditorRecord, ShiftNodeRecord } from "@/types"; +import type { ShiftNode } from "@/types/node"; 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. + * Exposes the editor's placed node graph. * - * @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 is the semantic API over node records in the flat editor store. It owns + * node-level queries and replacement rules; the store only owns record storage. */ export class Scene { - readonly #cell: WritableSignal; - - constructor() { - this.#cell = signal(emptyScene(), { name: "editor.scene" }); + readonly #store: ShiftStore; + readonly #cell: Signal; + readonly #nodesById: Signal>; + readonly #nodesByKind: Signal>; + + constructor(store: ShiftStore) { + this.#store = store; + this.#cell = computed( + () => { + const nodes: ShiftNodeRecord[] = []; + for (const record of this.#store.cell.value.values()) { + if (record.type === "node") nodes.push(record); + } + + return { nodes }; + }, + { name: "editor.scene" }, + ); + this.#nodesById = computed( + () => { + const nodes = new Map(); + for (const node of this.#cell.value.nodes) { + nodes.set(node.id, node); + } + return nodes; + }, + { name: "editor.scene.nodesById" }, + ); + this.#nodesByKind = computed( + () => { + const nodes = new Map(); + for (const node of this.#cell.value.nodes) { + const list = nodes.get(node.kind); + if (list) { + list.push(node); + } else { + nodes.set(node.kind, [node]); + } + } + return nodes; + }, + { name: "editor.scene.nodesByKind" }, + ); } - /** Reactive scene value for renderers and panels. */ get cell(): Signal { return this.#cell; } - /** 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; + nodes(): readonly ShiftNode[] { + return this.#cell.peek().nodes; } - /** Returns all placed glyph items as a read-only snapshot. */ - glyphItems(): readonly SceneGlyph[] { - return this.#cell.peek().items.filter(isSceneGlyph); - } + node(nodeId: NodeId | null): ShiftNode | null { + if (!nodeId) return null; - /** Returns item ids currently rendered with structured geometry. */ - geometryItemIds(): readonly ItemId[] { - return this.#cell.peek().geometryItems; + return this.#nodesById.peek().get(nodeId) ?? null; } - /** - * Resolves a placed scene item 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. - */ - item(itemId: ItemId | null): SceneItem | null { - if (!itemId) return null; - return this.#cell.peek().items.find((item) => item.id === itemId) ?? null; - } + nodeOfKind( + nodeId: NodeId | null, + kind: K, + ): Extract | null { + const node = this.node(nodeId); + if (node?.kind !== kind) 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 node as Extract; } - /** - * Replaces every scene item and geometry visibility state. - * - * @param scene - Complete scene description. Items are copied by value; glyph - * models and layers are not loaded or created. - */ - 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, - }); + nodesOfKind(kind: K): readonly Extract[] { + return (this.#nodesByKind.peek().get(kind) ?? []) as readonly Extract[]; } - /** Clears all scene items and geometry visibility state. */ - clear(): void { - const scene = this.#cell.peek(); - this.#cell.set({ ...scene, items: [], geometryItems: [] }); - } - - /** - * Adds one glyph item to the scene. - * - * @param input - Glyph identity and scene-space origin for the new item. - * @returns The minted item id for the placed glyph. - */ - addGlyph(input: AddGlyphInput): ItemId { - const id = mintItemId(); - const item: SceneGlyph = { - id, - kind: "glyph", - glyphId: input.glyphId, - placement: { origin: { ...input.origin } }, - }; - const scene = this.#cell.peek(); - this.#cell.set({ - ...scene, - items: [...scene.items, item], - }); - 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] }); - } + setNodes(nodes: readonly ShiftNode[]): void { + this.#deleteNodes(); - /** - * 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); + for (const node of nodes) { + this.#store.put(copyNode(node) as ShiftNodeRecord); } - this.#cell.set({ ...scene, geometryItems }); } - /** - * Returns whether structured geometry is shown for a scene item. - * - * @param itemId - Placement identity to inspect. - */ - 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 { - const scene = this.#cell.peek(); - this.#cell.set({ - ...scene, - items: scene.items.map((item) => - item.id === itemId ? copyItem({ ...item, placement: copyPlacement(placement) }) : item, - ), - }); - } + updateNode(node: ShiftNode): void { + if (!this.node(node.id)) return; - /** - * 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 } }); + this.#store.put(copyNode(node) as ShiftNodeRecord); } - /** - * Offsets a scene item's origin by a scene-space delta. - * - * @param itemId - Placement identity whose origin 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, - }); + deleteNode(nodeId: NodeId): void { + this.#store.delete(nodeId); } - /** - * 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, - ), - }); - } - - /** - * 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 }; + clear(): void { + this.#deleteNodes(); } - /** - * 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 }; + #deleteNodes(): void { + for (const node of this.nodes()) { + this.#store.delete(node.id); + } } } -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: [] }; -} diff --git a/apps/desktop/src/renderer/src/lib/editor/Selection.test.ts b/apps/desktop/src/renderer/src/lib/editor/Selection.test.ts index 151ad994..23bcbdbc 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Selection.test.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Selection.test.ts @@ -1,166 +1,64 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import type { NodeId, PointId } from "@shift/types"; +import { describe, expect, it } from "vitest"; +import { ShiftStore } from "@/lib/store/ShiftStore"; +import { currentSelectionId, type ShiftEditorRecord } from "@/types"; import { Selection } from "./Selection"; -import { signal } from "@/lib/signals/signal"; -import type { PointId } from "@shift/types"; -import type { GlyphLayer } from "@/lib/model/Glyph"; -import type { SegmentId } from "@shift/glyph-state"; +const asNodeId = (id: string): NodeId => id as NodeId; const asPointId = (id: string): PointId => id as PointId; -const asSegmentId = (id: string): SegmentId => id as SegmentId; describe("Selection", () => { - let selection: Selection; + it("starts empty", () => { + const selection = new Selection(new ShiftStore()); - beforeEach(() => { - selection = new Selection(signal(null)); + expect(selection.ids).toEqual([]); + expect(selection.hasSelection()).toBe(false); }); - describe("initialization", () => { - it("should initialize with empty point selection", () => { - expect(selection.stateCell.peek().pointIds.size).toBe(0); - }); + it("selects ordered unique ids", () => { + const store = new ShiftStore(); + const selection = new Selection(store); + const nodeId = asNodeId("node_one"); + const pointId = asPointId("point_abc"); - it("should initialize with empty segment selection", () => { - expect(selection.stateCell.peek().segmentIds.size).toBe(0); - }); + selection.select([nodeId, pointId, nodeId]); - it("should report no selection", () => { - expect(selection.hasSelection()).toBe(false); - }); + expect(selection.ids).toEqual([nodeId, pointId]); + expect(store.get(currentSelectionId)).toMatchObject({ ids: [nodeId, pointId] }); + expect(selection.hasSelection()).toBe(true); }); - describe("point selection", () => { - it("should select a single point", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - expect(selection.stateCell.peek().pointIds.has(asPointId("p1"))).toBe(true); - expect(selection.stateCell.peek().pointIds.size).toBe(1); - }); + it("adds, removes, and toggles ids", () => { + const selection = new Selection(new ShiftStore()); + const nodeId = asNodeId("node_one"); + const pointId = asPointId("point_abc"); - it("should select multiple points", () => { - selection.select([ - { kind: "point", id: asPointId("p1") }, - { kind: "point", id: asPointId("p2") }, - { kind: "point", id: asPointId("p3") }, - ]); - expect(selection.stateCell.peek().pointIds.size).toBe(3); - }); + selection.add(nodeId); + selection.add(pointId); + expect(selection.ids).toEqual([nodeId, pointId]); - it("should add point to selection", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - selection.add({ kind: "point", id: asPointId("p2") }); - expect(selection.stateCell.peek().pointIds.size).toBe(2); - }); + selection.toggle(nodeId); + expect(selection.ids).toEqual([pointId]); + expect(selection.has(nodeId)).toBe(false); - it("should remove point from selection", () => { - selection.select([ - { kind: "point", id: asPointId("p1") }, - { kind: "point", id: asPointId("p2") }, - ]); - selection.remove({ kind: "point", id: asPointId("p1") }); - expect(selection.stateCell.peek().pointIds.size).toBe(1); - expect(selection.stateCell.peek().pointIds.has(asPointId("p1"))).toBe(false); - }); + selection.toggle(nodeId); + expect(selection.ids).toEqual([pointId, nodeId]); - it("should toggle point selection (add)", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - selection.toggle({ kind: "point", id: asPointId("p2") }); - expect(selection.stateCell.peek().pointIds.size).toBe(2); - }); - - it("should toggle point selection (remove)", () => { - selection.select([ - { kind: "point", id: asPointId("p1") }, - { kind: "point", id: asPointId("p2") }, - ]); - selection.toggle({ kind: "point", id: asPointId("p1") }); - expect(selection.stateCell.peek().pointIds.size).toBe(1); - expect(selection.stateCell.peek().pointIds.has(asPointId("p1"))).toBe(false); - }); - - it("should check if point is selected", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - expect(selection.isSelected({ kind: "point", id: asPointId("p1") })).toBe(true); - expect(selection.isSelected({ kind: "point", id: asPointId("p2") })).toBe(false); - }); - - it("should replace selection when selecting new point", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - selection.select([{ kind: "point", id: asPointId("p2") }]); - expect(selection.stateCell.peek().pointIds.size).toBe(1); - expect(selection.stateCell.peek().pointIds.has(asPointId("p2"))).toBe(true); - }); + selection.remove(pointId); + expect(selection.ids).toEqual([nodeId]); }); - describe("segment selection", () => { - it("should select a single segment", () => { - selection.select([{ kind: "segment", id: asSegmentId("p1:p2") }]); - expect(selection.stateCell.peek().segmentIds.has(asSegmentId("p1:p2"))).toBe(true); - expect(selection.stateCell.peek().segmentIds.size).toBe(1); - }); - - it("should add segment to selection", () => { - selection.select([{ kind: "segment", id: asSegmentId("p1:p2") }]); - selection.add({ kind: "segment", id: asSegmentId("p2:p3") }); - expect(selection.stateCell.peek().segmentIds.size).toBe(2); - }); + it("clears selected ids and updates the signal", () => { + const store = new ShiftStore(); + const selection = new Selection(store); + const nodeId = asNodeId("node_one"); - it("should remove segment from selection", () => { - selection.select([ - { kind: "segment", id: asSegmentId("p1:p2") }, - { kind: "segment", id: asSegmentId("p2:p3") }, - ]); - selection.remove({ kind: "segment", id: asSegmentId("p1:p2") }); - expect(selection.stateCell.peek().segmentIds.size).toBe(1); - }); - - it("should toggle segment selection", () => { - selection.select([{ kind: "segment", id: asSegmentId("p1:p2") }]); - selection.toggle({ kind: "segment", id: asSegmentId("p2:p3") }); - expect(selection.stateCell.peek().segmentIds.size).toBe(2); - selection.toggle({ kind: "segment", id: asSegmentId("p1:p2") }); - expect(selection.stateCell.peek().segmentIds.size).toBe(1); - }); - - it("should check if segment is selected", () => { - selection.select([{ kind: "segment", id: asSegmentId("p1:p2") }]); - expect(selection.isSelected({ kind: "segment", id: asSegmentId("p1:p2") })).toBe(true); - expect(selection.isSelected({ kind: "segment", id: asSegmentId("p2:p3") })).toBe(false); - }); - }); - - describe("clear", () => { - it("should clear all selections", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - selection.add({ kind: "segment", id: asSegmentId("p1:p2") }); - selection.clear(); - expect(selection.stateCell.peek().pointIds.size).toBe(0); - expect(selection.stateCell.peek().segmentIds.size).toBe(0); - }); - }); - - describe("hasSelection", () => { - it("should return true when points are selected", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - expect(selection.hasSelection()).toBe(true); - }); - - it("should return true when segments are selected", () => { - selection.select([{ kind: "segment", id: asSegmentId("p1:p2") }]); - expect(selection.hasSelection()).toBe(true); - }); - - it("should return false after clearing", () => { - selection.select([{ kind: "point", id: asPointId("p1") }]); - selection.clear(); - expect(selection.hasSelection()).toBe(false); - }); - }); + selection.select([nodeId]); + expect(selection.stateCell.value.ids).toEqual([nodeId]); - describe("signals", () => { - it("should update signal value when selecting points", () => { - const sig = selection.stateCell; - selection.select([{ kind: "point", id: asPointId("p1") }]); - expect(sig.value.pointIds.has(asPointId("p1"))).toBe(true); - }); + selection.clear(); + expect(selection.stateCell.value.ids).toEqual([]); + expect(store.get(currentSelectionId)).toBeNull(); + expect(selection.hasSelection()).toBe(false); }); }); diff --git a/apps/desktop/src/renderer/src/lib/editor/Selection.ts b/apps/desktop/src/renderer/src/lib/editor/Selection.ts index 783a4a08..30a6f15d 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Selection.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Selection.ts @@ -1,241 +1,131 @@ -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, type Signal } from "@/lib/signals/signal"; +import type { ShiftStore } from "@/lib/store/ShiftStore"; +import { currentSelectionId, type SelectableId, type ShiftEditorRecord } from "@/types"; export interface SelectionState { - readonly pointIds: ReadonlySet; - readonly anchorIds: ReadonlySet; - readonly segmentIds: ReadonlySet; + readonly ids: readonly SelectableId[]; } -interface DerivedSelection { - readonly contourIds: ReadonlySet; - readonly bounds: BoundsType | null; -} - -const EMPTY_DERIVED: DerivedSelection = { - contourIds: new Set(), - bounds: null, -}; - function emptySelectionState(): SelectionState { - return { - pointIds: new Set(), - anchorIds: new Set(), - segmentIds: new Set(), - }; + return { ids: [] }; } /** - * Committed editor selection with computed contour queries. + * Stores selected editor object identities. * - * Selection owns selected IDs. Geometry owns object and parent lookups. + * @remarks + * Selection is intentionally a dumb ordered set. It does not know whether an id + * is a scene node, point, anchor, contour, or segment. + * Callers that need behavior must resolve ids through the editor/object layer. */ export class Selection { - readonly #glyphLayer: Signal; - readonly #state: WritableSignal; - readonly #derived: ComputedSignal; - readonly #bounds: ComputedSignal; + readonly #store: ShiftStore; + readonly #ids: Signal>; readonly stateCell: Signal; - readonly boundsCell: Signal; - - constructor(glyphLayer: Signal) { - this.#glyphLayer = glyphLayer; - this.#state = signal(emptySelectionState(), { - name: "editor.selection.state", - }); - this.#derived = computed( + constructor(store: ShiftStore) { + this.#store = store; + this.stateCell = 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(); + const record = this.#store.cell.value.get(currentSelectionId); + if (record?.type !== "selection") return emptySelectionState(); - 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 }; + return { ids: record.ids }; }, - { name: "editor.selection.derived" }, + { name: "editor.selection.state" }, ); - this.#bounds = computed(() => this.#derived.value.bounds, { - name: "editor.selection.bounds", + this.#ids = computed(() => new Set(this.stateCell.value.ids), { + name: "editor.selection.ids", }); - this.stateCell = this.#state; - this.boundsCell = this.#bounds; } get snapshot(): SelectionState { - return this.#state.peek(); + return this.stateCell.peek(); } - get pointIds(): ReadonlySet { - return this.#state.peek().pointIds; + /** + * Returns selected ids in selection order. + * + * @returns a fresh snapshot array; mutating it does not change selection. + */ + get ids(): readonly SelectableId[] { + return [...this.stateCell.peek().ids]; } - get anchorIds(): ReadonlySet { - return this.#state.peek().anchorIds; + has(id: SelectableId): boolean { + return this.#ids.peek().has(id); } - get segmentIds(): ReadonlySet { - return this.#state.peek().segmentIds; + isSelected(id: SelectableId): boolean { + return this.has(id); } - /** @knipclassignore - used by BooleanOps and upcoming callers */ - get contourIds(): ReadonlySet { - return this.#derived.peek().contourIds; + hasSelection(): boolean { + return this.stateCell.peek().ids.length > 0; } - get bounds(): BoundsType | null { - return this.#bounds.peek(); + select(ids: readonly SelectableId[]): void { + this.#write(uniqueIds(ids)); } - 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); - } - } + add(id: SelectableId): void { + if (this.has(id)) return; - hasSelection(): boolean { - const state = this.#state.peek(); - return state.pointIds.size > 0 || state.anchorIds.size > 0 || state.segmentIds.size > 0; + this.#write([...this.stateCell.peek().ids, id]); } - 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), - ]; - } + remove(id: SelectableId): void { + if (!this.has(id)) return; - /** 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; - } + const ids = this.stateCell.peek().ids.filter((selectedId) => selectedId !== id); + if (ids.length === 0) { + this.clear(); + return; } - this.#state.set({ pointIds, anchorIds, segmentIds }); + this.#write(ids); } - 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; - } + toggle(id: SelectableId): void { + if (this.has(id)) { + this.remove(id); + return; } + + this.add(id); } - 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; - } - } + clear(): void { + if (!this.hasSelection()) return; + + this.#store.delete(currentSelectionId); } - toggle(item: Selectable): void { - if (this.isSelected(item)) { - this.remove(item); - } else { - this.add(item); + #write(ids: readonly SelectableId[]): void { + if (ids.length === 0) { + this.clear(); + return; } + + this.#store.put({ + id: currentSelectionId, + type: "selection", + scope: "session", + ids, + }); } +} - clear(): void { - this.#state.set(emptySelectionState()); +function uniqueIds(ids: readonly SelectableId[]): readonly SelectableId[] { + const seen = new Set(); + const unique: SelectableId[] = []; + + for (const id of ids) { + if (seen.has(id)) continue; + + seen.add(id); + unique.push(id); } + + return unique; } 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..eae6bc5b 100644 --- a/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/editor/docs/DOCS.md @@ -53,14 +53,14 @@ editor/ ## Key Types -- **`Editor`** -- Facade class (~1750 lines). Owns `Selection`, `Hover`, `Camera`, `EdgePanManager`, `Renderer` (renderer), `ToolManager`, `CommandHistory`, `Clipboard`, `EventEmitter`. Passed directly to tools. +- **`Editor`** -- Facade class (~1750 lines). Owns `Selection`, `Hover`, `Camera`, `EdgePanManager`, `Renderer` (renderer), `ToolManager`, `Clipboard`, `EventEmitter`, and the workspace transaction facade. Passed directly to tools. - **`Camera`** -- Owns zoom/pan/UPM signals, computed affine matrices (`Mat`), and all coordinate projection methods (`projectScreenToScene`, `projectSceneToScreen`, `screenToUpmDistance`). - **`Renderer`** -- Manages four stacked canvas layers (background, scene, markers/WebGL, overlay), their `FrameHandler` instances, and the canvas item layers that draw each pass. - **`Canvas`** -- Thin wrapper around `CanvasRenderingContext2D` with `pxToUpm()` conversion and themed drawing primitives. Carries `CameraTransform` and `Theme`. - **`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 @@ -165,8 +165,8 @@ Glyph geometry exposes domain hit queries for points, anchors, and segments. Too ## Related -- `Font` -- State projection and glyph/layer lookup; `Editor` reads authored glyph layers through this boundary. -- `CommandHistory` -- Undo/redo stack; `Editor.#commandHistory` records all mutations. +- `Font` -- Font records and glyph/layer lookup; `Editor` reads authored glyph layers through this boundary. +- `WorkspaceEditCoordinator` -- undo/redo boundary for layer mutations and explicit editor transactions. - `ToolManager` -- Tool lifecycle and dispatch; `Editor.#toolManager`. Tools receive `Editor` to access all subsystems. - `Clipboard` -- Copy/cut/paste via `Editor.#clipboard`. - `Font` -- Font model access; `Editor.font` for metrics, glyph names, composites. 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..31779f79 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,40 @@ -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 { Anchors, ControlLines, DebugOverlays, Guides, Handles, Segments } from "./overlays"; import type { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; +import type { GlyphNode } from "@/types/node"; +import type { Editor } from "../Editor"; +import type { SegmentId } from "@shift/glyph-state"; -interface BackgroundGlyphFrame { - readonly item: SceneGlyph; - readonly advance: number; - readonly geometryShown: boolean; +class GlyphFrame { + readonly node: GlyphNode; + readonly instance: GlyphInstance; + + constructor(node: GlyphNode, instance: GlyphInstance) { + 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,21 +42,13 @@ 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 activeTool: string | null; readonly activeToolState: unknown; } @@ -99,27 +82,31 @@ export class BackgroundLayer extends CanvasItem { this.#editor.camera.trackViewportTransform(); this.#editor.activeToolCell.value; this.#editor.activeToolStateCell.value; + this.#editor.scene.cell.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.nodes()) { + switch (node.kind) { + case "glyph": { + const record = this.#editor.font.glyph(node.glyphId); + if (!record) break; + + const instance = this.#editor.font.instance( + node.glyphId, + this.#editor.designLocationCell, + ); + if (!instance) break; + + const frame = new GlyphFrame(node, instance); + 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 +117,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 +125,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 +135,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. */ @@ -229,19 +151,18 @@ export class SceneLayer extends CanvasItem { readonly #debugOverlays = new DebugOverlays(); readonly #controlLines = new ControlLines(); readonly #anchors = new Anchors(); + readonly #segments = new Segments(); 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. */ @@ -251,35 +172,38 @@ export class SceneLayer extends CanvasItem { protected props(): SceneLayerProps { this.#editor.camera.trackViewportTransform(); + + // TODO: should be track(thing) 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; + this.#editor.scene.cell.value; + + const glyphs: GlyphFrame[] = []; + for (const node of this.#editor.scene.nodes()) { + switch (node.kind) { + case "glyph": { + const instance = this.#editor.font.instance( + node.glyphId, + this.#editor.designLocationCell, + ); + if (!instance) break; + + const frame = new GlyphFrame(node, instance); + + 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 +216,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 +230,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 +242,18 @@ 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 hoveredObject = hover.entry ? this.#editor.object(hover.entry) : null; + const hoveredSegmentId = + hoveredObject?.kind === "segment" && hoveredObject.node.id === glyph.node.id + ? hoveredObject.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 +261,43 @@ export class SceneLayer extends CanvasItem { }); } - #drawTextRuns(canvas: Canvas): void { - this.#textLayer.draw(canvas); - } + #selectedSegmentIds(glyph: GlyphFrame): readonly SegmentId[] { + const segmentIds: SegmentId[] = []; - #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; + for (const object of this.#editor.objects(this.#editor.selection.ids)) { + if (object.kind !== "segment") continue; + if (object.node.id !== glyph.node.id) continue; + + segmentIds.push(object.segmentId); } - const renderModel = instance.render; + return segmentIds; + } + + #hoveredSegmentId(glyph: GlyphFrame): SegmentId | null { + const id = this.#editor.hover.id; + if (!id) return null; + + const object = this.#editor.object(id); + if (object?.kind !== "segment") return null; + if (object.node.id !== glyph.node.id) return null; + + return object.segmentId; + } + + #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.#segments.draw( + canvas, + glyph.instance.geometry, + this.#selectedSegmentIds(glyph), + this.#hoveredSegmentId(glyph), + ); + }); canvas.withTranslation(origin, () => { this.#controlLines.draw(canvas, renderModel.contours, (from, to) => { @@ -389,8 +317,8 @@ export class SceneLayer extends CanvasItem { this.#handles.draw( canvas, canvas.camera, - origin, - instance, + glyph.node, + glyph.instance, props.interaction.selection, props.interaction.hover, ); @@ -415,8 +343,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/Text.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts deleted file mode 100644 index 945d7e4e..00000000 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/Text.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * TextRunRenderer — stateless per-frame draw class for the active text run. - * - * Same shape as the indicator drawing classes (Anchors, BoundingBox, etc.): - * no fields, methods take `(canvas, ...)`. The frame loop subscribes to the - * relevant signals (textRun.layoutCell / caretCell / selectionRectsCell / interaction) - * and re-draws when they change. - * - * Coordinate space note: scene layers draw in glyph-local UPM space after - * `Canvas.withGlyphSpace()` applies the current `editor.drawOffset`. The - * TextLayout, by contrast, holds glyph positions in world/scene UPM space — - * same space that `event.point` arrives in via `coords.scene`. - * - * The renderer reverses the drawOffset translate once at the top, then - * draws everything using world coords directly — same coords the layout - * produced and the hit-test consumed. - * - * Draw order: - * 1. selection rects (under glyphs) - * 2. glyphs (fill + optional hover outline; the item being edited as a - * glyph is skipped — the editor draws that one separately at drawOffset) - * 3. caret (over glyphs) - */ -import type { Canvas } from "./Canvas"; -import type { Font } from "@/lib/model/Font"; -import { TextRun, type FocusedGlyph } from "@/lib/text/TextRun"; -import type { Point2D } from "@shift/geo"; -import type { Signal } from "@/lib/signals/signal"; -import type { AxisLocation } from "@/types/variation"; -import { OutlineRenderer } from "./Outline"; - -export class Text { - readonly #outlineRenderer = new OutlineRenderer(); - - draw( - canvas: Canvas, - run: TextRun, - font: Font, - designLocation: Signal, - drawOffset: Point2D, - focusedGlyph: FocusedGlyph | null, - ): void { - const layout = run.layoutCell.peek(); - if (!layout) return; - - const theme = canvas.theme.textRun; - - canvas.save(); - // Reverse the drawOffset translate so we draw in world UPM space. - canvas.translate(-drawOffset.x, -drawOffset.y); - - // Selection rects (under glyphs) - for (const rect of run.selectionRectsCell.peek()) { - canvas.fillRect(rect.x, rect.bottom, rect.width, rect.top - rect.bottom, theme.selectionFill); - } - - // Glyphs - const focusedItemId = focusedGlyph?.anchor.runId === run.id ? focusedGlyph.anchor.itemId : null; - const hoveredCluster = run.interaction.hoveredIndex; - - for (const line of layout.lines) { - let runBase = layout.origin.x; - for (const r of line.runs) { - for (const g of r.glyphs) { - // Skip the item being edited as a glyph — the editor draws that one - // at its drawOffset via the standard glyph render path. - if (focusedItemId && g.sourceItemIds.includes(focusedItemId)) { - continue; - } - - const instance = g.glyphId ? font.instance(g.glyphId, designLocation) : null; - if (!instance) continue; - - const outline = instance.render.outline; - - canvas.save(); - canvas.translate(runBase + g.origin.x + g.xOffset, line.y + g.origin.y + g.yOffset); - this.#outlineRenderer.draw(canvas, outline, { - fill: canvas.theme.glyph.fill, - }); - - if (g.cluster === hoveredCluster) { - this.#outlineRenderer.draw(canvas, outline, { - stroke: { - color: theme.hoverOutline, - widthPx: theme.hoverOutlineWidthPx, - }, - }); - } - canvas.restore(); - } - runBase += r.advance; - } - } - - // Caret (over glyphs) - if (run.cursorVisible) { - const caret = run.caretCell.peek(); - if (caret) { - const pos = caret.position(); - const top = pos.y + layout.metrics.ascender; - const bottom = pos.y + layout.metrics.descender; - canvas.line( - { x: pos.x, y: top }, - { x: pos.x, y: bottom }, - theme.cursorColor, - theme.cursorWidthPx, - ); - } - } - - canvas.restore(); - } -} 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..0b126f65 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 @@ -1,9 +1,9 @@ -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 type { GlyphNode } from "@/types/node"; import { MarkerLayer } from "@/lib/graphics/backends/MarkerLayer"; import { HandleItems } from "./handles/HandleItems"; import { MarkerHandleRenderer } from "./handles/MarkerHandleRenderer"; @@ -30,17 +30,17 @@ export class Handles { draw( canvas: Canvas, camera: CameraTransform, - drawOffset: Point2D, + node: GlyphNode, instance: GlyphInstance, - selection: SelectionState, - hover: HoverState, + selection: Selection, + hover: Hover, ): void { const list = this.#items.fromContours(instance.render.contours, { selection, hover, }); - if (this.#markers.draw(this.#markerLayer, list, camera, drawOffset)) return; + if (this.#markers.draw(this.#markerLayer, list, camera, node.position)) return; this.#canvas.draw(canvas, list.items); } diff --git a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Segments.ts b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Segments.ts index 74d85cb5..c239bd56 100644 --- a/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Segments.ts +++ b/apps/desktop/src/renderer/src/lib/editor/rendering/overlays/Segments.ts @@ -1,8 +1,31 @@ import type { Canvas } from "../Canvas"; -import type { Segment } from "@shift/glyph-state"; +import type { Segment, SegmentId } from "@shift/glyph-state"; +import type { GlyphInstanceGeometry } from "@/lib/model/Glyph"; export class Segments { - draw(canvas: Canvas, hovered: Segment | null, selected: readonly Segment[]): void { + readonly #selected: Segment[] = []; + + draw( + canvas: Canvas, + geometry: GlyphInstanceGeometry, + selectedSegmentIds: readonly SegmentId[], + hoveredSegmentId: SegmentId | null, + ): void { + this.#selected.length = 0; + + for (const segmentId of selectedSegmentIds) { + const segment = geometry.segment(segmentId); + if (segment) this.#selected.push(segment); + } + + this.#drawResolved( + canvas, + hoveredSegmentId ? geometry.segment(hoveredSegmentId) : null, + this.#selected, + ); + } + + #drawResolved(canvas: Canvas, hovered: Segment | null, selected: readonly Segment[]): void { if (!hovered && selected.length === 0) return; const theme = canvas.theme.segment; 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..20e0d63b 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 { SelectableId } from "@/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 { @@ -61,10 +61,10 @@ export class HandleItems { return new HandleDisplayList(this.#items); } - #state(pointId: PointId, source: HandleStateSource): HandleState { - if (source.selection.pointIds.has(pointId)) return "selected"; + #state(id: SelectableId, source: HandleStateSource): HandleState { + if (source.selection.has(id)) return "selected"; - if (source.hover?.type === "point" && source.hover.pointId === pointId) return "hovered"; + if (source.hover.has(id)) return "hovered"; return "idle"; } diff --git a/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.test.ts b/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.test.ts index 4ade983c..4a2ee39f 100644 --- a/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.test.ts +++ b/apps/desktop/src/renderer/src/lib/keyboard/KeyboardRouter.test.ts @@ -41,10 +41,6 @@ describe("KeyboardRouter", () => { beforeEach(async () => { editor = new TestEditor(); await editor.startSession(); - // setActiveTool short-circuits if the target matches the activeToolCell value, - // and activeToolCell defaults to "select" without actually activating a tool - // instance. Force activation directly so primaryTool is populated. - editor.toolManager.activate("select"); canvasActive = true; router = new KeyboardRouter(() => ({ @@ -196,6 +192,25 @@ describe("KeyboardRouter", () => { }); }); + describe("delete key", () => { + it("deletes the current canvas selection", async () => { + editor.selectTool("pen"); + editor.click(100, 100); + await editor.settle(); + editor.click(200, 100); + await editor.settle(); + editor.selectTool("select"); + editor.selectAll(); + + const handled = router.handleKeyDown(createKeyboardEvent({ key: "Delete", code: "Delete" })); + await editor.settle(); + + expect(handled).toBe(true); + expect(editor.pointCount).toBe(0); + expect(editor.selection.hasSelection()).toBe(false); + }); + }); + describe("temporary hand tool (space)", () => { it("activates the hand tool on space and returns to the previous tool on keyup", () => { const down = createKeyboardEvent({ key: " ", code: "Space" }); @@ -206,11 +221,9 @@ describe("KeyboardRouter", () => { // reflects the override while primaryToolId stays on the base tool. expect(editor.toolManager.activeToolId).toBe("hand"); expect(editor.toolManager.primaryToolId).toBe("select"); - expect(editor.proofMode).toBe(true); router.handleKeyUp(up); expect(editor.toolManager.activeToolId).toBe("select"); - expect(editor.proofMode).toBe(false); }); it("does not activate the hand tool on space while the text tool is active", () => { @@ -231,7 +244,7 @@ describe("KeyboardRouter", () => { const handled = router.handleKeyDown(e); expect(handled).toBe(false); - expect(editor.selection.pointIds.size).toBe(0); + expect(editor.selection.hasSelection()).toBe(false); }); }); }); 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..c7483990 100644 --- a/apps/desktop/src/renderer/src/lib/keyboard/keymaps.ts +++ b/apps/desktop/src/renderer/src/lib/keyboard/keymaps.ts @@ -136,10 +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(); - return true; - }, + run: (ctx) => ctx.editor.deleteSelection(), }, { id: "canvas.selectAll", @@ -150,15 +147,6 @@ export function createCanvasKeyDownBindings(handlers: KeymapHandlers): KeyBindin return true; }, }, - { - id: "canvas.glyphFinder", - preventDefault: true, - match: (event) => matchChord(event, { key: "f", primaryModifier: true }), - run: (ctx) => { - ctx.editor.openGlyphFinder(); - 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..701e387a 100644 --- a/apps/desktop/src/renderer/src/lib/keyboard/types.ts +++ b/apps/desktop/src/renderer/src/lib/keyboard/types.ts @@ -7,10 +7,10 @@ export interface KeyboardEditorActions { copy(): void; cut(): void; paste(): void; + deleteSelection(): boolean; undo(): void; redo(): void; selectAll(): void; - deleteSelectedPoints(): void; setActiveTool(toolName: ToolName): void; getToolShortcuts(): ToolShortcutEntry[]; requestTemporaryTool( @@ -18,9 +18,6 @@ export interface KeyboardEditorActions { options?: { onActivate?: () => void; onReturn?: () => void }, ): void; returnFromTemporaryTool(): void; - readonly proofMode: boolean; - setProofMode(enabled: boolean): void; - openGlyphFinder(): void; } export interface KeyboardToolManagerActions { @@ -30,7 +27,7 @@ export interface KeyboardToolManagerActions { export interface KeyContext { canvasActive: boolean; - activeTool: ToolName; + activeTool: ToolName | null; editor: KeyboardEditorActions; toolManager: KeyboardToolManagerActions; } 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..62c8a8fe 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -75,7 +75,7 @@ describe("Font projects the workspace snapshot", () => { expect(font.loadedCell.value).toBe(true); }); - it("resets to the unloaded projection when the workspace goes null", () => { + it("resets to fallback font values when the workspace goes null", () => { const store = new FontStore(SNAPSHOT); const font = new Font(store); @@ -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.layer(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] }, }, ]); @@ -202,24 +190,19 @@ describe("font-level intents make the font variable", () => { const committed = stack.font.glyph(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]); - 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); + expect(stack.font.layer(record.id, source.id)?.id).toBe(record.layers[0]?.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(await stack.font.loadGlyph(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,24 +268,21 @@ describe("font-level intents make the font variable", () => { }, ]); - const loaded = 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); + const glyph = await stack.font.loadGlyph(glyphId); + 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); + const location = axisLocationFromLocation(bold.location); + const instance = stack.font.instance(glyph.id, location); if (!instance) throw new Error("Expected glyph instance"); - expect(stack.font.editableLayerAt(glyphId, boldLocation)).toBeNull(); + expect(stack.font.editableLayerAt(glyph.id, location)).toBeNull(); expect(instance.xAdvance).toBe(0); expect(instance.geometry.allPoints).toEqual([]); }); - it("does not drop concurrent snapshot loads for different sources on one glyph", async () => { + it("loads every authored layer in one glyph snapshot", async () => { const stack = createWorkspaceStack(); await stack.createWorkspace(); const glyphId = mintGlyphId(); @@ -318,19 +291,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,27 +327,36 @@ describe("font-level intents make the font variable", () => { }, { kind: "createGlyphLayer", - createGlyphLayer: { - layerId: boldLayerId, - glyphId, - sourceId: boldSourceId, - }, + createGlyphLayer: { layerId: boldLayerId, glyphId, sourceId: boldSourceId }, }, ]); - const regularSource = stack.font.source(defaultSourceId); const boldSource = stack.font.source(boldSourceId); - if (!regularSource || !boldSource) throw new Error("Expected both sources"); + if (!boldSource) throw new Error("Expected bold source"); - const regularLoad = stack.font.loadGlyph(glyphId, { - sourceIds: [defaultSourceId], - }); - const boldLoad = stack.font.loadGlyph(glyphId, { - sourceIds: [boldSourceId], - }); - await Promise.all([regularLoad, boldLoad]); + await stack.font.loadGlyph(glyphId); - expect(stack.font.layer(glyphId, regularSource.id)?.id).toBe(defaultLayerId); + expect(stack.font.layer(glyphId, defaultSourceId)?.id).toBe(defaultLayerId); expect(stack.font.layer(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..b6c3f4f7 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -11,11 +11,15 @@ import type { SourceId, Unicode, AxisId, + AnchorId, + ContourId, LayerId, Location, + PointId, GlyphStructure, } from "@shift/types"; import { mintAxisId, mintGlyphId, mintLayerId, mintSourceId } from "@shift/types"; +import type { SegmentId } from "@shift/glyph-state"; import { computed, signal, track, type Signal } from "@/lib/signals/signal"; import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; import type { WorkspaceGlyphSnapshotRequest } from "@shared/workspace/protocol"; @@ -27,7 +31,7 @@ import { type GlyphLayer, } from "./Glyph"; import { GlyphOutline } from "./GlyphOutline"; -import type { FontStore, GlyphSnapshotStatus } from "./FontStore"; +import type { FontStore } from "./FontStore"; import type { GlyphLayerState } from "./GlyphLayerState"; import type { GlyphHandle } from "@shift/bridge"; import { @@ -42,16 +46,17 @@ import { defaultResources, GlyphInfo } from "@shift/glyph-info"; import { fallbackGlyphNameForUnicode } from "../utils/unicode"; import { interpolate, normalize } from "@/lib/interpolation/interpolate"; -export type GlyphLoadOptions = { - readonly sourceIds?: readonly SourceId[]; -}; - -type SnapshotRequest = { - glyphId: GlyphId; - sourceIds: SourceId[]; -}; +export interface GlyphGeometrySelection { + readonly points?: Iterable; + readonly anchors?: Iterable; + readonly contours?: Iterable; + readonly segments?: Iterable; +} -type InFlightKey = string & { readonly __inFlightKey: unique symbol }; +interface LayerDirectoryEntry { + readonly glyphId: GlyphId; + readonly layer: GlyphLayerRecord; +} const EMPTY_GLYPH_STRUCTURE: GlyphStructure = { contours: [], @@ -67,7 +72,7 @@ const EMPTY_GLYPH_STRUCTURE: GlyphStructure = { * source-of-truth font records separate from fallback glyph database knowledge: * methods named `record*`, `has*`, and dependency lookups only describe glyphs * committed in the font, while handle/name resolution methods may fall back to - * bundled glyph metadata so UI flows can address missing glyphs. + * bundled glyph metadata so UI flows can address glyphs before they are created. */ // One shared database for every directory rebuild: constructing GlyphInfo // indexes the full glyph dataset (~60k search docs) and must not run per @@ -89,6 +94,7 @@ class GlyphDirectory { readonly recordsById: ReadonlyMap = new Map(); readonly nameById: ReadonlyMap = new Map(); readonly nameByUnicode: ReadonlyMap = new Map(); + readonly layerById: ReadonlyMap = new Map(); readonly layerByGlyphAndSource: ReadonlyMap = new Map(); readonly componentBasesById: ReadonlyMap = new Map(); readonly dependentsById: ReadonlyMap> = new Map(); @@ -98,6 +104,7 @@ class GlyphDirectory { const recordsById = new Map(); const nameById = new Map(); const nameByUnicode = new Map(); + const layerById = new Map(); const layerByGlyphAndSource = new Map(); const componentBasesById = new Map(); const dependentsById = new Map>(); @@ -108,6 +115,7 @@ class GlyphDirectory { nameById.set(record.id, record.name); for (const layer of record.layers) { + layerById.set(layer.id, { glyphId: record.id, layer }); layerByGlyphAndSource.set(glyphLayerKey(record.id, layer.sourceId), layer); } @@ -133,6 +141,7 @@ class GlyphDirectory { this.recordsById = recordsById; this.nameById = nameById; this.nameByUnicode = nameByUnicode; + this.layerById = layerById; this.layerByGlyphAndSource = layerByGlyphAndSource; this.componentBasesById = componentBasesById; this.dependentsById = dependentsById; @@ -201,6 +210,11 @@ class GlyphDirectory { return this.layerByGlyphAndSource.get(glyphLayerKey(glyphId, sourceId)) ?? null; } + /** Returns the sparse authored layer and owning glyph for a layer id. */ + layerForId(layerId: LayerId): LayerDirectoryEntry | null { + return this.layerById.get(layerId) ?? null; + } + /** Returns the sparse authored layer for a glyph name and source. */ layerForGlyphNameAtSource(name: GlyphName, sourceId: SourceId): GlyphLayerRecord | null { const record = this.recordForName(name); @@ -321,14 +335,12 @@ export class Font { readonly #directoryCell: Signal; readonly #store: FontStore; readonly #editCoordinator: WorkspaceEditCoordinator | null; - readonly #glyphLoadsInFlight = new Map>(); - /** - * Projects the renderer's workspace snapshot into the font domain model. + * Builds a font model over renderer-local workspace state. * - * @param store - Renderer-local owner of committed records and loaded glyph snapshots. - * @param editCoordinator - Optional sync lane used by authored layer projections to submit - * committed edits to the utility workspace. + * @param store - Renderer-local owner of committed records and concrete glyph layer state. + * @param editCoordinator - Optional sync lane used by authored layer edits to submit + * committed changes to the utility workspace. */ constructor(store: FontStore, editCoordinator?: WorkspaceEditCoordinator) { this.#store = store; @@ -395,9 +407,77 @@ export class Font { return this.#glyphRecordsCell; } - /** Reactive glyph snapshot bookkeeping for model consumers that derive local glyph views. */ - get glyphSnapshotStatusCell(): Signal> { - return this.#store.snapshotStatusCell; + /** Returns the layer owning a point id, or null when unknown. */ + layerIdForPoint(pointId: PointId): LayerId | null { + return this.#store.layerIdForPoint(pointId); + } + + /** Returns the contour owning a point id, or null when unknown. */ + contourIdForPoint(pointId: PointId): ContourId | null { + return this.#store.contourIdForPoint(pointId); + } + + /** Returns the layer owning an anchor id, or null when unknown. */ + layerIdForAnchor(anchorId: AnchorId): LayerId | null { + return this.#store.layerIdForAnchor(anchorId); + } + + /** Returns the layer owning a contour id, or null when unknown. */ + layerIdForContour(contourId: ContourId): LayerId | null { + return this.#store.layerIdForContour(contourId); + } + + /** Returns the layer owning a segment id, or null when unknown. */ + layerIdForSegment(segmentId: SegmentId): LayerId | null { + return this.#store.layerIdForSegment(segmentId); + } + + /** Returns the contour owning a segment id, or null when unknown. */ + contourIdForSegment(segmentId: SegmentId): ContourId | null { + return this.#store.contourIdForSegment(segmentId); + } + + /** Returns the point ids that define a segment, or null when unknown. */ + pointIdsForSegment(segmentId: SegmentId): readonly PointId[] | null { + return this.#store.pointIdsForSegment(segmentId); + } + + /** + * Resolves geometry ids to the single authored layer that owns all of them. + * + * @remarks + * This is the edit-scoping primitive for identity-only selections: continuous + * edits such as dragging or copying must target exactly one authored layer. + * Ownership comes from current concrete layer state, so ids that are unknown + * to the font model resolve to `null`. + * + * @param ids - Geometry ids to resolve. Empty input resolves to `null`. + * @returns The sole owning authored layer, or `null` when any id is unknown + * or the ids span multiple layers. + */ + layerForGeometry(ids: GlyphGeometrySelection): GlyphLayer | null { + let owner: LayerId | null = null; + + const fold = (layerId: LayerId | null): boolean => { + if (!layerId || (owner !== null && owner !== layerId)) return false; + owner = layerId; + return true; + }; + + for (const pointId of ids.points ?? []) { + if (!fold(this.layerIdForPoint(pointId))) return null; + } + for (const anchorId of ids.anchors ?? []) { + if (!fold(this.layerIdForAnchor(anchorId))) return null; + } + for (const contourId of ids.contours ?? []) { + if (!fold(this.layerIdForContour(contourId))) return null; + } + for (const segmentId of ids.segments ?? []) { + if (!fold(this.layerIdForSegment(segmentId))) return null; + } + + return owner === null ? null : this.layerById(owner); } /** @knipclassignore */ @@ -502,7 +582,7 @@ export class Font { * otherwise the glyph database is used as a best-effort Unicode hint. * * @param name - Production glyph name to open, create, or query. - * @returns A glyph identity handle. The handle may refer to a missing glyph. + * @returns A glyph identity handle. The handle may refer to a glyph that is not in the font yet. */ glyphHandleForName(name: GlyphName): GlyphHandle { return this.#directoryCell.peek().glyphHandleForName(name); @@ -650,18 +730,31 @@ export class Font { * * @param glyphId - Stable glyph identity to resolve. * @param sourceId - Authored source identity to resolve. - * @returns The loaded authored layer, or `null` when the glyph/source pair is unavailable. + * @returns The authored layer, or `null` when the glyph/source pair is unavailable. */ layer(glyphId: GlyphId, sourceId: SourceId): GlyphLayer | null { return this.#glyphLayer(glyphId, sourceId); } + /** + * Returns the authored mutable layer for a stable layer id. + * + * @param layerId - Stable authored layer identity from selection or font records. + * @returns The authored layer, or `null` when the layer is unavailable in the current font. + */ + layerById(layerId: LayerId): GlyphLayer | null { + const entry = this.#directoryCell.peek().layerForId(layerId); + if (!entry) return null; + + return this.#glyphLayer(entry.glyphId, entry.layer.sourceId); + } + /** * Returns a reactive authored layer for glyph and source id cells. * * @param glyphId - Cell containing the glyph to resolve, or `null` when no glyph is selected. * @param sourceId - Cell containing the exact source to resolve, or `null` when interpolated. - * @returns A cell whose value is the loaded authored layer, or `null` when unavailable. + * @returns A cell whose value is the authored layer, or `null` when unavailable. */ layerCell( glyphId: Signal, @@ -670,7 +763,6 @@ export class Font { return computed( () => { track(this.#directoryCell); - track(this.#store.snapshotStatusCell); track(this.#sourcesCell); const id = glyphId.value; @@ -715,7 +807,6 @@ export class Font { return computed( () => { track(this.#directoryCell); - track(this.#store.snapshotStatusCell); track(this.#axesCell); track(this.#sourcesCell); @@ -746,7 +837,7 @@ export class Font { * Returns the local glyph model for a stable glyph id. * * @param glyphId - document glyph identity to resolve. - * @returns the id-keyed glyph model, or null when the glyph is missing or not loaded. + * @returns The id-keyed glyph model, or `null` when the glyph is not in the current font. */ #glyphModel(glyphId: GlyphId): Glyph | null { if (!this.loaded) return null; @@ -824,7 +915,6 @@ export class Font { () => { const currentLocation = location.value; track(this.#directoryCell); - track(this.#store.snapshotStatusCell); track(this.#axesCell); track(this.#sourcesCell); @@ -836,7 +926,6 @@ export class Font { () => { const currentLocation = location.value; track(this.#directoryCell); - track(this.#store.snapshotStatusCell); track(this.#axesCell); track(this.#sourcesCell); @@ -849,62 +938,52 @@ export class Font { } /** - * Reports whether every authored source for each glyph has local geometry. + * Reads one current-font glyph and returns its live model. * - * @param glyphIds - Stable glyph identities to inspect. - */ - areGlyphsLoaded(glyphIds: readonly GlyphId[]): boolean { - for (const glyphId of glyphIds) { - if (!this.glyph(glyphId)) return false; - for (const sourceId of this.#store.sourceIdsForGlyph(glyphId)) { - if (this.#store.needsGlyphSource(glyphId, sourceId)) return false; - } - } - return true; - } - - /** - * Loads one glyph's geometry. - * - * @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 model should be available. + * @returns The glyph model for `glyphId`. + * @throws {Error} when `glyphId` is not a current-font glyph or cannot be read. * @see {@link loadGlyphs} */ - async loadGlyph(glyphId: GlyphId, options: GlyphLoadOptions = {}): Promise { - return (await this.loadGlyphs([glyphId], options)).has(glyphId); + async loadGlyph(glyphId: GlyphId): Promise { + const glyph = (await this.loadGlyphs([glyphId]))[0]; + if (!glyph) throw new Error(`current-font glyph ${glyphId} could not be read`); + return glyph; } /** - * Requests local glyph geometry and returns the requested glyph ids that are available. + * Reads 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 reading the requested glyphs are read too, + * 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 models should be available. + * @returns Glyph models aligned with `glyphIds`, including duplicate requests. + * @throws {Error} when any requested glyph ID is not in the current font or cannot be read. */ - async loadGlyphs( - glyphIds: readonly GlyphId[], - options: GlyphLoadOptions = {}, - ): Promise> { - await this.#loadGlyphSnapshots(glyphIds, options); - - const loaded = new Set(); - for (const glyphId of uniqueGlyphIds(glyphIds)) { + async loadGlyphs(glyphIds: readonly GlyphId[]): 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.#readGlyphSnapshots(uniqueIds); + + 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} could not be read`); + } + glyphs.set(glyphId, glyph); } - return loaded; + + return glyphIds.map((glyphId) => glyphs.get(glyphId)!); } - async #loadGlyphSnapshots( - glyphIds: readonly GlyphId[], - options: GlyphLoadOptions = {}, - ): Promise { + async #readGlyphSnapshots(glyphIds: readonly GlyphId[]): Promise { if (!this.#editCoordinator || glyphIds.length === 0) return; await this.#editCoordinator.settled(); @@ -914,18 +993,10 @@ export class Font { while (queue.length > 0) { const batchGlyphIds = queue.splice(0); - const inFlight = this.#inFlightLoadsFor(batchGlyphIds, options); - if (inFlight.length > 0) { - await Promise.all(inFlight); - } - - const requests = this.#requestableGlyphs(batchGlyphIds, options); - if (requests.length > 0) { - await this.#loadGlyphRequests(requests); - } + await this.#readAndApplyGlyphRequests(batchGlyphIds.map((glyphId) => ({ glyphId }))); for (const glyphId of batchGlyphIds) { - for (const baseGlyphId of this.#store.loadedComponentBaseGlyphIds(glyphId)) { + for (const baseGlyphId of this.#store.componentBaseGlyphIdsInLayerState(glyphId)) { if (seen.has(baseGlyphId)) continue; seen.add(baseGlyphId); queue.push(baseGlyphId); @@ -934,88 +1005,12 @@ export class Font { } } - #requestableGlyphs( - glyphIds: readonly GlyphId[], - options: GlyphLoadOptions, - ): WorkspaceGlyphSnapshotRequest[] { - const result: SnapshotRequest[] = []; - const seen = new Set(); - for (const glyphId of glyphIds) { - if (seen.has(glyphId)) continue; - const sourceIds = this.#neededSourceIds(glyphId, options); - if (sourceIds.length === 0) continue; - seen.add(glyphId); - result.push({ glyphId, sourceIds }); - } - return result; - } - - #neededSourceIds(glyphId: GlyphId, options: GlyphLoadOptions): SourceId[] { - const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); - const needed: SourceId[] = []; - const seen = new Set(); - - for (const sourceId of sourceIds) { - if (seen.has(sourceId)) continue; - seen.add(sourceId); - if (this.#store.needsGlyphSource(glyphId, sourceId)) needed.push(sourceId); - } - - return needed; - } - - #inFlightLoadsFor(glyphIds: readonly GlyphId[], options: GlyphLoadOptions): Promise[] { - const promises: Promise[] = []; - const seen = new Set>(); - - for (const glyphId of uniqueGlyphIds(glyphIds)) { - const sourceIds = options.sourceIds ?? this.#store.sourceIdsForGlyph(glyphId); - for (const sourceId of sourceIds) { - const promise = this.#glyphLoadsInFlight.get(inFlightKey(glyphId, sourceId)); - if (!promise || seen.has(promise)) continue; - seen.add(promise); - promises.push(promise); - } - } - - return promises; - } - - async #loadGlyphRequests(requests: readonly WorkspaceGlyphSnapshotRequest[]): Promise { - const promise = this.#readAndApplyGlyphRequests(requests); - - for (const request of requests) { - for (const sourceId of request.sourceIds) { - this.#glyphLoadsInFlight.set(inFlightKey(request.glyphId, sourceId), promise); - } - } - - try { - await promise; - } finally { - for (const request of requests) { - for (const sourceId of request.sourceIds) { - const key = inFlightKey(request.glyphId, sourceId); - if (this.#glyphLoadsInFlight.get(key) === promise) { - this.#glyphLoadsInFlight.delete(key); - } - } - } - } - } - async #readAndApplyGlyphRequests( requests: readonly WorkspaceGlyphSnapshotRequest[], ): Promise { if (!this.#editCoordinator) return; - const load = this.#store.beginGlyphLoad(requests); - try { - this.#store.finishGlyphLoad(load, await this.#editCoordinator.readGlyphSnapshots(requests)); - } catch (error) { - this.#store.failGlyphLoad(load); - throw error; - } + this.#store.applyGlyphSnapshots(await this.#editCoordinator.readGlyphSnapshots(requests)); } /** @@ -1031,13 +1026,11 @@ export class Font { location, (glyphId) => { track(this.#directoryCell); - track(this.#store.snapshotStatusCell); return this.#glyphModel(glyphId); }, (glyphId, resolvedLocation) => { track(this.#directoryCell); - track(this.#store.snapshotStatusCell); track(this.#axesCell); track(this.#sourcesCell); @@ -1045,7 +1038,6 @@ export class Font { }, (glyphId, resolvedLocation) => { track(this.#directoryCell); - track(this.#store.snapshotStatusCell); track(this.#axesCell); track(this.#sourcesCell); @@ -1055,10 +1047,10 @@ export class Font { } /** - * Returns the store-owned layer state for loaded glyph geometry. + * Returns the store-owned state for an authored layer. * * @param layerId - stable authored layer identity to resolve. - * @returns the loaded layer state, or null when its glyph snapshot is not loaded. + * @returns The layer state, or `null` when it is not available in the current font model. */ layerState(layerId: LayerId): GlyphLayerState | null { return this.#store.layerState(layerId); @@ -1165,8 +1157,7 @@ export class Font { * Save and dirty semantics live in the utility workspace; this queue only * tracks renderer-submitted edits and serializes reads behind them. * - * @throws {Error} when constructed without a workspace (pure projection - * tests) — same not-wired contract as the legacy bridge getter. + * @throws {Error} when constructed without a workspace-backed edit lane. */ get editCoordinator(): WorkspaceEditCoordinator { if (!this.#editCoordinator) { @@ -1259,10 +1250,6 @@ function glyphLayerKey(glyphId: GlyphId, sourceId: SourceId): string { return `${glyphId}:${sourceId}`; } -function inFlightKey(glyphId: GlyphId, sourceId: SourceId): InFlightKey { - return `${glyphId}:${sourceId}` as InFlightKey; -} - function axisLocationSignal(location: AxisLocation | Signal): Signal { if (isSignal(location)) return location; diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts index a5295767..e068a188 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.test.ts @@ -1,33 +1,178 @@ import { describe, expect, it } from "vitest"; -import type { GlyphId, GlyphName, GlyphState, LayerId, SourceId, Unicode } from "@shift/types"; +import { + asAnchorId, + asContourId, + asPointId, + type AnchorId, + type ContourId, + type GlyphId, + type GlyphName, + type GlyphState, + type GlyphStructure, + type LayerId, + type PointId, + type SourceId, + type Unicode, +} from "@shift/types"; +import { segmentIdFor } from "@shift/glyph-state"; import type { WorkspaceGlyphSnapshot, WorkspaceSnapshot } from "@shared/workspace/protocol"; +import { Font } from "./Font"; import { FontStore } from "./FontStore"; const GLYPH_ID = "glyph_shared" as GlyphId; const SOURCE_ID = "source_regular" as SourceId; const LAYER_A_ID = "layer_a" as LayerId; const LAYER_B_ID = "layer_b" as LayerId; +const CONTOUR_ID = asContourId("contour_a"); +const NEXT_CONTOUR_ID = asContourId("contour_b"); +const POINT_1_ID = asPointId("point_1"); +const POINT_2_ID = asPointId("point_2"); +const POINT_3_ID = asPointId("point_3"); +const POINT_4_ID = asPointId("point_4"); +const NEXT_POINT_ID = asPointId("point_next"); +const ANCHOR_ID = asAnchorId("anchor_a"); +const NEXT_ANCHOR_ID = asAnchorId("anchor_b"); +const SEGMENT_ID = segmentIdFor(POINT_1_ID, POINT_4_ID); +const NEXT_SEGMENT_ID = segmentIdFor(NEXT_POINT_ID, POINT_4_ID); -describe("FontStore snapshot freshness", () => { - it("ignores stale snapshot responses after a workspace replacement", () => { +describe("FontStore glyph snapshot application", () => { + it("only materializes layer snapshots that match current font records", () => { const store = new FontStore(snapshot("document-a", LAYER_A_ID)); - const load = store.beginGlyphLoad([{ glyphId: GLYPH_ID, sourceIds: [SOURCE_ID] }]); - store.replaceWorkspace(snapshot("document-b", LAYER_B_ID)); - store.finishGlyphLoad(load, [glyphSnapshot(LAYER_A_ID)]); + store.applyGlyphSnapshots([glyphSnapshot(LAYER_B_ID)]); - expect(store.snapshotStatus(GLYPH_ID)).toBe("missing"); expect(store.layerState(LAYER_A_ID)).toBeNull(); expect(store.layerState(LAYER_B_ID)).toBeNull(); + + store.applyGlyphSnapshots([glyphSnapshot(LAYER_A_ID)]); + + expect(store.layerState(LAYER_A_ID)).not.toBeNull(); }); - it("marks snapshot request failures against the matching workspace generation", () => { + it("removes concrete layer state when the layer record is removed", () => { const store = new FontStore(snapshot("document-a", LAYER_A_ID)); - const load = store.beginGlyphLoad([{ glyphId: GLYPH_ID, sourceIds: [SOURCE_ID] }]); + store.applyGlyphSnapshots([glyphSnapshot(LAYER_A_ID, structure())]); - store.failGlyphLoad(load); + store.applyWorkspaceChange({ + glyphs: snapshot("document-a", LAYER_B_ID).glyphs, + layers: [], + dependents: [], + }); - expect(store.snapshotStatus(GLYPH_ID)).toBe("failed"); + expect(store.layerState(LAYER_A_ID)).toBeNull(); + expect(store.layerIdForPoint(POINT_1_ID)).toBeNull(); + }); +}); + +describe("FontStore glyph object ownership", () => { + it("indexes concrete layer structure ids by owner", () => { + const store = new FontStore(snapshot("document-a", LAYER_A_ID)); + expect(store.layerIdForPoint(POINT_1_ID)).toBeNull(); + + store.applyGlyphSnapshots([glyphSnapshot(LAYER_A_ID, structure())]); + + expect(store.layerIdForPoint(POINT_1_ID)).toBe(LAYER_A_ID); + expect(store.contourIdForPoint(POINT_1_ID)).toBe(CONTOUR_ID); + expect(store.layerIdForContour(CONTOUR_ID)).toBe(LAYER_A_ID); + expect(store.layerIdForAnchor(ANCHOR_ID)).toBe(LAYER_A_ID); + expect(store.layerIdForSegment(SEGMENT_ID)).toBe(LAYER_A_ID); + expect(store.contourIdForSegment(SEGMENT_ID)).toBe(CONTOUR_ID); + expect(store.pointIdsForSegment(SEGMENT_ID)).toEqual([ + POINT_1_ID, + POINT_2_ID, + POINT_3_ID, + POINT_4_ID, + ]); + }); + + it("rebuilds after a concrete layer structure replacement", () => { + const store = new FontStore(snapshot("document-a", LAYER_A_ID)); + store.applyGlyphSnapshots([glyphSnapshot(LAYER_A_ID, structure())]); + + const nextStructure = structure({ + contourId: NEXT_CONTOUR_ID, + pointIds: [NEXT_POINT_ID, POINT_2_ID, POINT_3_ID, POINT_4_ID], + anchorId: NEXT_ANCHOR_ID, + }); + store.applyWorkspaceChange({ + layers: [ + { + layerId: LAYER_A_ID, + structure: nextStructure, + values: valuesFor(nextStructure), + changed: { + pointIds: [NEXT_POINT_ID], + contourIds: [NEXT_CONTOUR_ID], + anchorIds: [NEXT_ANCHOR_ID], + guidelineIds: [], + componentIds: [], + }, + }, + ], + dependents: [], + }); + + expect(store.layerIdForPoint(POINT_1_ID)).toBeNull(); + expect(store.layerIdForContour(CONTOUR_ID)).toBeNull(); + expect(store.layerIdForAnchor(ANCHOR_ID)).toBeNull(); + expect(store.layerIdForSegment(SEGMENT_ID)).toBeNull(); + expect(store.layerIdForPoint(NEXT_POINT_ID)).toBe(LAYER_A_ID); + expect(store.contourIdForPoint(NEXT_POINT_ID)).toBe(NEXT_CONTOUR_ID); + expect(store.layerIdForContour(NEXT_CONTOUR_ID)).toBe(LAYER_A_ID); + expect(store.layerIdForAnchor(NEXT_ANCHOR_ID)).toBe(LAYER_A_ID); + expect(store.layerIdForSegment(NEXT_SEGMENT_ID)).toBe(LAYER_A_ID); + }); + + it("materializes added layer structure from the same workspace change", () => { + const store = new FontStore(snapshot("document-a", LAYER_A_ID)); + const nextStructure = structure({ + contourId: NEXT_CONTOUR_ID, + pointIds: [NEXT_POINT_ID, POINT_2_ID, POINT_3_ID, POINT_4_ID], + anchorId: NEXT_ANCHOR_ID, + }); + + store.applyWorkspaceChange({ + glyphs: snapshot("document-a", LAYER_B_ID).glyphs, + layers: [ + { + layerId: LAYER_B_ID, + structure: nextStructure, + values: valuesFor(nextStructure), + changed: { + pointIds: [NEXT_POINT_ID], + contourIds: [NEXT_CONTOUR_ID], + anchorIds: [NEXT_ANCHOR_ID], + guidelineIds: [], + componentIds: [], + }, + }, + ], + dependents: [], + }); + + expect(store.layerState(LAYER_B_ID)).not.toBeNull(); + expect(store.layerIdForPoint(NEXT_POINT_ID)).toBe(LAYER_B_ID); + expect(store.contourIdForPoint(NEXT_POINT_ID)).toBe(NEXT_CONTOUR_ID); + expect(store.layerIdForAnchor(NEXT_ANCHOR_ID)).toBe(LAYER_B_ID); + expect(store.layerIdForSegment(NEXT_SEGMENT_ID)).toBe(LAYER_B_ID); + }); + + it("forwards ownership queries through Font", () => { + const store = new FontStore(snapshot("document-a", LAYER_A_ID)); + const font = new Font(store); + store.applyGlyphSnapshots([glyphSnapshot(LAYER_A_ID, structure())]); + + expect(font.layerIdForPoint(POINT_1_ID)).toBe(LAYER_A_ID); + expect(font.contourIdForPoint(POINT_1_ID)).toBe(CONTOUR_ID); + expect(font.layerIdForAnchor(ANCHOR_ID)).toBe(LAYER_A_ID); + expect(font.layerIdForSegment(SEGMENT_ID)).toBe(LAYER_A_ID); + expect(font.contourIdForSegment(SEGMENT_ID)).toBe(CONTOUR_ID); + expect(font.pointIdsForSegment(SEGMENT_ID)).toEqual([ + POINT_1_ID, + POINT_2_ID, + POINT_3_ID, + POINT_4_ID, + ]); }); }); @@ -56,23 +201,68 @@ function snapshot(documentId: string, layerId: LayerId): WorkspaceSnapshot { }; } -function glyphSnapshot(layerId: LayerId): WorkspaceGlyphSnapshot { +function glyphSnapshot( + layerId: LayerId, + glyphStructure: GlyphStructure = emptyStructure(), +): WorkspaceGlyphSnapshot { return { glyphId: GLYPH_ID, layers: [ { glyphId: GLYPH_ID, sourceId: SOURCE_ID, - state: glyphState(layerId), + state: glyphState(layerId, glyphStructure), }, ], }; } -function glyphState(layerId: LayerId): GlyphState { +function glyphState(layerId: LayerId, glyphStructure: GlyphStructure): GlyphState { return { layerId, - structure: { contours: [], anchors: [], components: [] }, - values: new Float64Array([600]), + structure: glyphStructure, + values: valuesFor(glyphStructure), }; } + +function emptyStructure(): GlyphStructure { + return { contours: [], anchors: [], components: [] }; +} + +function structure({ + contourId = CONTOUR_ID, + pointIds = [POINT_1_ID, POINT_2_ID, POINT_3_ID, POINT_4_ID], + anchorId = ANCHOR_ID, +}: { + readonly contourId?: ContourId; + readonly pointIds?: readonly PointId[]; + readonly anchorId?: AnchorId; +} = {}): GlyphStructure { + return { + contours: [ + { + id: contourId, + points: [ + { id: pointIds[0]!, pointType: "onCurve", smooth: false }, + { id: pointIds[1]!, pointType: "offCurve", smooth: false }, + { id: pointIds[2]!, pointType: "offCurve", smooth: false }, + { id: pointIds[3]!, pointType: "onCurve", smooth: false }, + ], + closed: false, + }, + ], + anchors: [{ id: anchorId, x: 50, y: 100 }], + components: [], + }; +} + +function valuesFor(glyphStructure: GlyphStructure): Float64Array { + let length = 1; + for (const contour of glyphStructure.contours) length += contour.points.length * 2; + length += glyphStructure.anchors.length * 2; + length += glyphStructure.components.length * 9; + + const values = new Float64Array(length); + values[0] = 600; + return values; +} diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index ad4c3d1a..93bde877 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -1,62 +1,56 @@ import type { AppliedChange, + AnchorId, + ContourData, + ContourId, GlyphId, - GlyphLayerRecord, GlyphRecord, GlyphStructure, GlyphState, GlyphVariationData, LayerId, + PointData, + PointId, SourceId, } from "@shift/types"; +import { segmentIdFor, type SegmentId } from "@shift/glyph-state"; +import { Validate } from "@shift/validation"; import type { WorkspaceGlyphLayerSnapshot, - WorkspaceGlyphSnapshotRequest, WorkspaceGlyphSnapshot, WorkspaceSnapshot, } from "@shared/workspace/protocol"; import { batch, signal, type Signal, type WritableSignal } from "@/lib/signals/signal"; +import type { GlyphObjectIndex, GlyphObjectSegment } from "@/types"; import { GlyphLayerState } from "./GlyphLayerState"; import type { Glyph, GlyphLayer } from "./Glyph"; -export type GlyphSnapshotStatus = "missing" | "loading" | "loaded" | "stale" | "failed"; export type WorkspaceCommitState = "idle" | "queued" | "applying"; type GlyphSourceKey = string & { readonly __glyphSourceKey: unique symbol }; -const EMPTY_STATUS: ReadonlyMap = new Map(); - -export interface GlyphLoadBatch { - readonly generation: number; - readonly glyphIds: readonly GlyphId[]; -} - /** - * Renderer-local owner for font records, loaded glyph snapshots, model caches, - * and snapshot status. + * Renderer-local owner for font records, concrete glyph layer state, and model caches. * - * Model reads and snapshot freshness live directly on the store. Workspace - * mutation and async read ordering are owned by `WorkspaceEditCoordinator`; glyph-load - * request selection is owned by `Font`. + * Workspace mutation and async read ordering are owned by + * `WorkspaceEditCoordinator`. This store materializes returned glyph snapshots + * into layer state and indexes objects from that concrete state. */ export class FontStore { readonly #workspace: WritableSignal; - readonly #snapshotStatus: WritableSignal>; - readonly #layerStates = new Map(); + #glyphObjectIndex: GlyphObjectIndex = emptyGlyphObjectIndex(); + + readonly #layerStateCells = new Map>(); readonly #layerByGlyphSource = new Map(); readonly #glyphByLayer = new Map(); readonly #glyphById = new Map(); readonly #glyphModels = new Map(); readonly #glyphLayerModels = new Map(); - readonly #variationDataByGlyph = new Map(); - readonly #snapshotGeneration = new Map(); - - #generation = 0; + readonly #variationDataCells = new Map>(); constructor(workspace: WorkspaceSnapshot | null = null) { this.#workspace = signal(workspace, { name: "fontStore.workspace" }); - this.#snapshotStatus = signal(EMPTY_STATUS, { name: "fontStore.snapshotStatus" }); if (workspace) this.#indexWorkspace(workspace); } @@ -64,77 +58,61 @@ export class FontStore { return this.#workspace; } - get snapshotStatusCell(): Signal> { - return this.#snapshotStatus; + layerIdForPoint(pointId: PointId): LayerId | null { + return this.#glyphObjectIndex.layerIdByPointId.get(pointId) ?? null; } - replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { - batch(() => { - this.#generation += 1; - this.#workspace.set(snapshot); - this.#indexWorkspace(snapshot); - this.#layerStates.clear(); - this.#glyphModels.clear(); - this.#glyphLayerModels.clear(); - this.#variationDataByGlyph.clear(); - this.#snapshotGeneration.clear(); - this.#snapshotStatus.set(EMPTY_STATUS); - }); + contourIdForPoint(pointId: PointId): ContourId | null { + return this.#glyphObjectIndex.contourIdByPointId.get(pointId) ?? null; } - beginGlyphLoad(requests: readonly WorkspaceGlyphSnapshotRequest[]): GlyphLoadBatch { - const generation = this.#generation; - const next = new Map(this.#snapshotStatus.peek()); - const glyphIds = requests.map((request) => request.glyphId); - for (const glyphId of glyphIds) { - this.#snapshotGeneration.set(glyphId, generation); - next.set(glyphId, "loading"); - } - this.#snapshotStatus.set(next); - return { generation, glyphIds }; + layerIdForAnchor(anchorId: AnchorId): LayerId | null { + return this.#glyphObjectIndex.layerIdByAnchorId.get(anchorId) ?? null; + } + + layerIdForContour(contourId: ContourId): LayerId | null { + return this.#glyphObjectIndex.layerIdByContourId.get(contourId) ?? null; } - failGlyphLoad(batch: GlyphLoadBatch): void { - if (batch.generation !== this.#generation) return; + layerIdForSegment(segmentId: SegmentId): LayerId | null { + return this.#glyphObjectIndex.layerIdBySegmentId.get(segmentId) ?? null; + } - const next = new Map(this.#snapshotStatus.peek()); - for (const glyphId of batch.glyphIds) { - if (this.#snapshotGeneration.get(glyphId) === batch.generation) { - next.set(glyphId, "failed"); - } - } - this.#snapshotStatus.set(next); + contourIdForSegment(segmentId: SegmentId): ContourId | null { + return this.#glyphObjectIndex.contourIdBySegmentId.get(segmentId) ?? null; } - finishGlyphLoad(load: GlyphLoadBatch, snapshots: readonly WorkspaceGlyphSnapshot[]): void { - if (load.generation !== this.#generation) return; + pointIdsForSegment(segmentId: SegmentId): readonly PointId[] | null { + return this.#glyphObjectIndex.pointIdsBySegmentId.get(segmentId) ?? null; + } - const received = new Set(snapshots.map((snapshot) => snapshot.glyphId)); - const nextStatus = new Map(this.#snapshotStatus.peek()); + replaceWorkspace(snapshot: WorkspaceSnapshot | null): void { + batch(() => { + this.#workspace.set(snapshot); + this.#indexWorkspace(snapshot); + this.#clearLayerStates(); + this.#clearVariationData(); + this.#glyphModels.clear(); + this.#glyphLayerModels.clear(); + this.#rebuildGlyphObjectIndex(); + }); + } + applyGlyphSnapshots(snapshots: readonly WorkspaceGlyphSnapshot[]): void { batch(() => { + let layerChanged = false; + for (const snapshot of snapshots) { - if (this.#snapshotGeneration.get(snapshot.glyphId) !== load.generation) continue; + if (!this.#glyphById.has(snapshot.glyphId)) continue; - if (snapshot.variationData) { - this.#variationDataByGlyph.set(snapshot.glyphId, snapshot.variationData); - } else { - this.#variationDataByGlyph.delete(snapshot.glyphId); - } + this.#variationDataCell(snapshot.glyphId).set(snapshot.variationData ?? null); for (const layer of snapshot.layers) { - this.#applyLayerSnapshot(layer); + if (this.#applyLayerSnapshot(layer)) layerChanged = true; } - nextStatus.set(snapshot.glyphId, "loaded"); } - for (const glyphId of load.glyphIds) { - if (!received.has(glyphId) && this.#snapshotGeneration.get(glyphId) === load.generation) { - nextStatus.set(glyphId, "missing"); - } - } - - this.#snapshotStatus.set(nextStatus); + if (layerChanged) this.#rebuildGlyphObjectIndex(); }); } @@ -154,42 +132,50 @@ export class FontStore { : current; if (nextWorkspace !== current) { - this.#generation += 1; this.#workspace.set(nextWorkspace); this.#indexWorkspace(nextWorkspace); } - const staleGlyphIds = new Set(applied.dependents); - for (const layer of applied.layers) { - const glyphId = this.#glyphByLayer.get(layer.layerId); - if (glyphId) staleGlyphIds.add(glyphId); + let layerSetChanged = false; + if (nextWorkspace !== current) { + for (const [layerId, cell] of this.#layerStateCells) { + if (this.#glyphByLayer.has(layerId)) continue; - const state = this.#layerStates.get(layer.layerId); - if (!state) continue; + cell.set(null); + this.#layerStateCells.delete(layerId); + layerSetChanged = true; + } + + for (const [glyphId, cell] of this.#variationDataCells) { + if (this.#glyphById.has(glyphId)) continue; + + cell.set(null); + this.#variationDataCells.delete(glyphId); + } + } + + let structureChanged = false; + for (const layer of applied.layers) { + if (!this.#glyphByLayer.has(layer.layerId)) continue; if (layer.structure) { - state.replace({ + this.#replaceLayerState({ layerId: layer.layerId, structure: layer.structure, values: layer.values, }); + structureChanged = true; } else { - state.replaceValues(layer.values); + this.#peekLayerState(layer.layerId)?.replaceValues(layer.values); } } - if (applied.axes || applied.sources) { - for (const glyphId of this.#snapshotStatus.peek().keys()) { - staleGlyphIds.add(glyphId); - } - } - - this.#markLoadedSnapshotsStale(staleGlyphIds); + if (structureChanged || layerSetChanged) this.#rebuildGlyphObjectIndex(); }); } layerState(layerId: LayerId): GlyphLayerState | null { - return this.#layerStates.get(layerId) ?? null; + return this.#layerStateCell(layerId).peek(); } hasGlyph(glyphId: GlyphId): boolean { @@ -200,12 +186,8 @@ export class FontStore { return this.#glyphById.get(glyphId) ?? null; } - layerRecordForId(glyphId: GlyphId, sourceId: SourceId): GlyphLayerRecord | null { - return this.recordForId(glyphId)?.layers.find((layer) => layer.sourceId === sourceId) ?? null; - } - variationData(glyphId: GlyphId): GlyphVariationData | null { - return this.#variationDataByGlyph.get(glyphId) ?? null; + return this.#variationDataCell(glyphId).peek(); } glyphModel(glyphId: GlyphId, create: () => Glyph | null): Glyph | null { @@ -231,29 +213,7 @@ export class FontStore { return created; } - sourceIdsForGlyph(glyphId: GlyphId): readonly SourceId[] { - return this.recordForId(glyphId)?.layers.map((layer) => layer.sourceId) ?? []; - } - - snapshotStatus(glyphId: GlyphId): GlyphSnapshotStatus { - return this.#snapshotStatus.peek().get(glyphId) ?? "missing"; - } - - hasLayerSnapshot(glyphId: GlyphId, sourceId: SourceId): boolean { - return this.#layerStateForGlyphSource(glyphId, sourceId) !== null; - } - - hasLayerRecord(glyphId: GlyphId, sourceId: SourceId): boolean { - return this.#layerByGlyphSource.has(glyphSourceKey(glyphId, sourceId)); - } - - needsGlyphSource(glyphId: GlyphId, sourceId: SourceId): boolean { - if (!this.hasLayerRecord(glyphId, sourceId)) return false; - const status = this.snapshotStatus(glyphId); - return status === "stale" || status === "failed" || !this.hasLayerSnapshot(glyphId, sourceId); - } - - loadedComponentBaseGlyphIds(glyphId: GlyphId): readonly GlyphId[] { + componentBaseGlyphIdsInLayerState(glyphId: GlyphId): readonly GlyphId[] { const baseGlyphIds = new Set(); for (const state of this.#loadedLayerStatesForGlyph(glyphId)) { for (const baseGlyphId of componentBaseGlyphIds(state.structure)) { @@ -263,29 +223,26 @@ export class FontStore { return [...baseGlyphIds]; } - #layerStateForGlyphSource(glyphId: GlyphId, sourceId: SourceId): GlyphLayerState | null { - const layerId = this.#layerByGlyphSource.get(glyphSourceKey(glyphId, sourceId)); - return layerId ? (this.#layerStates.get(layerId) ?? null) : null; - } - - #applyLayerSnapshot(snapshot: WorkspaceGlyphLayerSnapshot): void { - this.#glyphByLayer.set(snapshot.state.layerId, snapshot.glyphId); - this.#layerByGlyphSource.set( + #applyLayerSnapshot(snapshot: WorkspaceGlyphLayerSnapshot): boolean { + const layerId = this.#layerByGlyphSource.get( glyphSourceKey(snapshot.glyphId, snapshot.sourceId), - snapshot.state.layerId, ); + if (layerId !== snapshot.state.layerId) return false; + this.#replaceLayerState(snapshot.state); + return true; } #replaceLayerState(state: GlyphState): GlyphLayerState { - const existing = this.#layerStates.get(state.layerId); + const cell = this.#layerStateCell(state.layerId); + const existing = cell.peek(); if (existing) { existing.replace(state); return existing; } const created = new GlyphLayerState(state); - this.#layerStates.set(state.layerId, created); + cell.set(created); return created; } @@ -295,21 +252,91 @@ export class FontStore { if (!record) return []; return record.layers - .map((layer) => this.#layerStates.get(layer.id)) - .filter((state): state is GlyphLayerState => state !== undefined); + .map((layer) => this.#peekLayerState(layer.id)) + .filter((state): state is GlyphLayerState => state !== null); + } + + #layerStateCell(layerId: LayerId): WritableSignal { + let cell = this.#layerStateCells.get(layerId); + if (!cell) { + cell = signal(null, { name: `fontStore.layerState.${layerId}` }); + this.#layerStateCells.set(layerId, cell); + } + return cell; + } + + #peekLayerState(layerId: LayerId): GlyphLayerState | null { + return this.#layerStateCells.get(layerId)?.peek() ?? null; + } + + #clearLayerStates(): void { + for (const cell of this.#layerStateCells.values()) cell.set(null); + this.#layerStateCells.clear(); + } + + #variationDataCell(glyphId: GlyphId): WritableSignal { + let cell = this.#variationDataCells.get(glyphId); + if (!cell) { + cell = signal(null, { name: `fontStore.variationData.${glyphId}` }); + this.#variationDataCells.set(glyphId, cell); + } + return cell; } - #markLoadedSnapshotsStale(glyphIds: Iterable): void { - const nextStatus = new Map(this.#snapshotStatus.peek()); - let changed = false; + #clearVariationData(): void { + for (const cell of this.#variationDataCells.values()) cell.set(null); + this.#variationDataCells.clear(); + } + + #rebuildGlyphObjectIndex(): void { + this.#glyphObjectIndex = this.#buildGlyphObjectIndex(); + } + + #buildGlyphObjectIndex(): GlyphObjectIndex { + const layerIdByPointId = new Map(); + const contourIdByPointId = new Map(); + const layerIdByContourId = new Map(); + const layerIdByAnchorId = new Map(); + const layerIdBySegmentId = new Map(); + const contourIdBySegmentId = new Map(); + const pointIdsBySegmentId = new Map(); + + for (const cell of this.#layerStateCells.values()) { + const state = cell.peek(); + if (!state) continue; + + const layerId = state.layerId; + const structure = state.structure; + + for (const contour of structure.contours) { + layerIdByContourId.set(contour.id, layerId); - for (const glyphId of glyphIds) { - if (nextStatus.get(glyphId) !== "loaded") continue; - nextStatus.set(glyphId, "stale"); - changed = true; + for (const point of contour.points) { + layerIdByPointId.set(point.id, layerId); + contourIdByPointId.set(point.id, contour.id); + } + + for (const segment of indexedSegments(contour)) { + layerIdBySegmentId.set(segment.id, layerId); + contourIdBySegmentId.set(segment.id, contour.id); + pointIdsBySegmentId.set(segment.id, segment.pointIds); + } + } + + for (const anchor of structure.anchors) { + layerIdByAnchorId.set(anchor.id, layerId); + } } - if (changed) this.#snapshotStatus.set(nextStatus); + return { + layerIdByPointId, + contourIdByPointId, + layerIdByContourId, + layerIdByAnchorId, + layerIdBySegmentId, + contourIdBySegmentId, + pointIdsBySegmentId, + }; } #indexWorkspace(snapshot: WorkspaceSnapshot | null): void { @@ -335,3 +362,87 @@ function glyphSourceKey(glyphId: GlyphId, sourceId: SourceId): GlyphSourceKey { function componentBaseGlyphIds(structure: GlyphStructure): readonly GlyphId[] { return structure.components.map((component) => component.baseGlyphId); } + +function emptyGlyphObjectIndex(): GlyphObjectIndex { + return { + layerIdByPointId: new Map(), + contourIdByPointId: new Map(), + layerIdByContourId: new Map(), + layerIdByAnchorId: new Map(), + layerIdBySegmentId: new Map(), + contourIdBySegmentId: new Map(), + pointIdsBySegmentId: new Map(), + }; +} + +function indexedSegments(contour: ContourData): GlyphObjectSegment[] { + const { points, closed } = contour; + if (points.length < 2) return []; + + const segments: GlyphObjectSegment[] = []; + let index = 0; + + const limit = closed ? points.length : points.length - 1; + + while (index < limit) { + const start = pointAt(points, closed, index); + const next = pointAt(points, closed, index + 1); + if (!start || !next) break; + + if (isOnCurve(start) && isOnCurve(next)) { + segments.push(indexedSegment(start, next, [start.id, next.id])); + index += 1; + continue; + } + + if (isOnCurve(start) && isOffCurve(next)) { + const maybeEnd = pointAt(points, closed, index + 2); + if (!maybeEnd) break; + + if (isOnCurve(maybeEnd)) { + segments.push(indexedSegment(start, maybeEnd, [start.id, next.id, maybeEnd.id])); + index += 2; + continue; + } + + if (isOffCurve(maybeEnd)) { + const end = pointAt(points, closed, index + 3); + if (!end) break; + + segments.push(indexedSegment(start, end, [start.id, next.id, maybeEnd.id, end.id])); + index += 3; + continue; + } + } + + index += 1; + } + + return segments; +} + +function indexedSegment( + start: PointData, + end: PointData, + pointIds: readonly PointId[], +): GlyphObjectSegment { + return { + id: segmentIdFor(start.id, end.id), + pointIds, + }; +} + +function pointAt(points: readonly PointData[], closed: boolean, index: number): PointData | null { + if (index < points.length) return points[index] ?? null; + if (!closed) return null; + + return points[index - points.length] ?? null; +} + +function isOnCurve(point: PointData): boolean { + return Validate.isOnCurve(point); +} + +function isOffCurve(point: PointData): boolean { + return Validate.isOffCurve(point); +} 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..7ba4f7a4 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 () => { @@ -146,7 +146,7 @@ describe("anchors edit through the workspace", () => { expect(layer.anchor(anchorId)).toMatchObject({ x: 300, y: 650 }); }); - it("mixed point and anchor commits coalesce into one undo step", async () => { + it("mixed point and anchor commits undo as one layer operation", async () => { const contourId = layer.addContour(); layer.addOnCurvePoint(contourId, { x: 0, y: 0 }); const anchorId = layer.addAnchor("top", { x: 250, y: 700 }); @@ -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,10 +227,10 @@ 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 () => { + it("commits a preview without double-applying local positions", async () => { const [, second] = await addTriangle(editor, layer); layer.previewPositionPatch([{ kind: "point", id: second!.id, x: 25, y: 75 }]); diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index a415308c..aa274f03 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -20,7 +20,14 @@ import type { AxisLocation } from "@/types/variation"; import { Transform } from "@/lib/transform/Transform"; import { Alignment } from "@/lib/transform/Alignment"; import type { AlignmentType, DistributeType, ReflectAxis } from "@/types/transform"; -import { Bounds, Vec2, type Bounds as BoundsType, type Point2D } from "@shift/geo"; +import { + Bounds, + Vec2, + type Bounds as BoundsType, + type CubicCurve, + type Point2D, + type QuadraticCurve, +} from "@shift/geo"; import { Anchor, Component, @@ -30,6 +37,7 @@ import { type GeometryAnchorHit, type GeometryPointHit, type GeometrySegmentHit, + type GlyphHit, Segment, type SegmentId, type GlyphPosition as GlyphLayerPosition, @@ -58,6 +66,7 @@ import { } from "./GlyphLayerState"; import type { Font } from "./Font"; import { LayerIntents } from "@/lib/workspace/LayerIntents"; +import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; export { GlyphGeometry, @@ -103,20 +112,24 @@ 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; } class GlyphEditSession { + readonly #editCoordinator: WorkspaceEditCoordinator; readonly #intents: LayerIntents; readonly #state: GlyphEditState; constructor(font: Font, layerId: LayerId, state: GlyphEditState) { + this.#editCoordinator = font.editCoordinator; this.#intents = new LayerIntents(font.editCoordinator, layerId); this.#state = state; } @@ -129,6 +142,10 @@ class GlyphEditSession { return this.#state.state; } + transaction(label: string, body: () => TResult): TResult { + return this.#editCoordinator.transaction(label, body); + } + setXAdvance(width: number): void { this.#intents.setXAdvance({ width }); } @@ -158,15 +175,22 @@ class GlyphEditSession { } } - // Mixed patches push two intents in the same tick; the edit coordinator coalesces - // them into one apply and therefore one undo step. - if (pointIds.length > 0) { - this.#intents.movePoints({ pointIds, coords: pointCoords }); - } + const commit = () => { + if (pointIds.length > 0) { + this.#intents.movePoints({ pointIds, coords: pointCoords }); + } + + if (anchorIds.length > 0) { + this.#intents.moveAnchors({ anchorIds, coords: anchorCoords }); + } + }; - if (anchorIds.length > 0) { - this.#intents.moveAnchors({ anchorIds, coords: anchorCoords }); + if (pointIds.length > 0 && anchorIds.length > 0) { + this.transaction("Move positions", commit); + return; } + + commit(); } translateLayer(dx: number, dy: number): void { @@ -405,6 +429,10 @@ export class GlyphLayer { return this.geometry.contour(contourId); } + segment(segmentId: SegmentId): Segment | null { + return this.geometry.segment(segmentId); + } + contourIdOfPoint(pointId: PointId): ContourId | null { return this.#edit.layerState.contourIdOfPoint(pointId); } @@ -439,10 +467,47 @@ export class GlyphLayer { this.#edit.setXAdvance(width); } + /** + * Sets this source's right sidebearing by changing horizontal advance. + * + * @param value - Desired distance from outline right edge to advance width. + */ + setRightSidebearing(value: number): void { + const bounds = this.bounds; + if (!bounds) return; + + const width = bounds.max.x + value; + if (width === this.xAdvance) return; + + this.setXAdvance(width); + } + + /** + * Sets this source's left sidebearing by translating outline geometry. + * + * @remarks + * The advance width changes by the same delta as the outline translation so + * the right sidebearing remains unchanged. Anchors are not translated. + * + * @param value - Desired outline left edge position. + */ + setLeftSidebearing(value: number): void { + const current = this.sidebearings.lsb; + if (current === null) return; + + const deltaX = value - current; + if (deltaX === 0) return; + + this.#edit.transaction("Set left sidebearing", () => { + this.translateLayer(deltaX, 0); + this.setXAdvance(this.xAdvance + deltaX); + }); + } + /** * Apply a sparse point/anchor position patch to Rust and local geometry. * - * Use this for one-shot edits and undo/redo of position commands. The bridge + * Use this for one-shot edits and undo/redo of position operations. The bridge * validates and commits the patch; TypeScript applies the same sparse patch * locally without reading back a full glyph values buffer. * @@ -578,6 +643,87 @@ export class GlyphLayer { this.#edit.reverseContour(contourId); } + /** + * Splits a current segment and preserves its shape. + * + * @param segmentId - Segment in this layer's current geometry to split. + * @param t - Parametric split position from 0 to 1. + * @returns The inserted on-curve point id, or `null` when the segment is unavailable. + */ + splitSegment(segmentId: SegmentId, t: number): PointId | null { + const segment = this.geometry.segment(segmentId); + if (!segment) return null; + + return this.#edit.transaction("Split segment", () => { + switch (segment.type) { + case "line": + return this.#splitLineSegment(segment, t); + case "quad": + return this.#splitQuadraticSegment(segment, t); + case "cubic": + return this.#splitCubicSegment(segment, t); + } + }); + } + + /** + * Converts a current line segment into a shape-preserving cubic segment. + * + * @param segmentId - Segment in this layer's current geometry to upgrade. + * @returns `true` when the segment was upgraded; `false` when it is missing or not a line. + */ + upgradeLineToCubic(segmentId: SegmentId): boolean { + const segment = this.geometry.segment(segmentId); + if (!segment || segment.type !== "line") return false; + + const points = segment.asLine(); + if (!points) return false; + + this.#edit.transaction("Upgrade line to cubic", () => { + const control1Pos = { + x: points.start.x + (points.end.x - points.start.x) / 3, + y: points.start.y + (points.end.y - points.start.y) / 3, + }; + const control2Pos = { + x: points.start.x + ((points.end.x - points.start.x) * 2) / 3, + y: points.start.y + ((points.end.y - points.start.y) * 2) / 3, + }; + + const control2Id = this.insertPointBefore(points.end.id, Point.offCurve(control2Pos)); + this.insertPointBefore(control2Id, Point.offCurve(control1Pos)); + }); + + return true; + } + + #splitLineSegment(segment: Segment, t: number): PointId { + return this.insertPointBefore(segment.endId, Point.onCurve(segment.pointAt(t))); + } + + #splitQuadraticSegment(segment: Segment, t: number): PointId { + const points = segment.asQuad()!; + const [curveA, curveB] = segment.splitAt(t) as [QuadraticCurve, QuadraticCurve]; + + const splitPointId = this.insertPointBefore(points.end.id, Point.smooth(curveA.p1)); + this.insertPointBefore(points.end.id, Point.offCurve(curveB.c)); + this.movePointTo(points.control.id, curveA.c); + + return splitPointId; + } + + #splitCubicSegment(segment: Segment, t: number): PointId { + const points = segment.asCubic()!; + const [curveA, curveB] = segment.splitAt(t) as [CubicCurve, CubicCurve]; + + this.insertPointBefore(points.controlEnd.id, Point.offCurve(curveA.c1)); + const splitPointId = this.insertPointBefore(points.controlEnd.id, Point.smooth(curveA.p1)); + this.insertPointBefore(points.controlEnd.id, Point.offCurve(curveB.c0)); + this.movePointTo(points.controlStart.id, curveA.c0); + this.movePointTo(points.controlEnd.id, curveB.c1); + + return splitPointId; + } + /** * Applies a boolean operation between two contours. * @@ -949,6 +1095,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 +1171,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 +1289,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 +1301,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 +1322,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/GlyphLayerGeometry.test.ts b/apps/desktop/src/renderer/src/lib/model/GlyphLayerGeometry.test.ts new file mode 100644 index 00000000..3fa7cb98 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/model/GlyphLayerGeometry.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import type { PointId } from "@shift/types"; +import { TestEditor } from "@/testing/TestEditor"; + +describe("GlyphLayer point movement", () => { + let editor: TestEditor; + + beforeEach(async () => { + editor = new TestEditor(); + await editor.startSession(); + editor.selectTool("pen"); + editor.clickGlyphLocal(10, 20); + await editor.settle(); + editor.clickGlyphLocal(30, 40); + await editor.settle(); + }); + + const layer = () => editor.glyphLayer!; + + it("moves points by a delta", async () => { + const ids = layer().allPoints.map((point) => point.id); + + layer().movePoints(ids, { x: 1, y: 0 }); + await editor.settle(); + + expect(layer().allPoints.map(({ x }) => x)).toEqual([11, 31]); + }); + + it("restores both points with one ledger undo", async () => { + const ids = layer().allPoints.map((point) => point.id); + + layer().movePoints(ids, { x: 5, y: -10 }); + await editor.settle(); + + await editor.undoAndSettle(); + expect(layer().allPoints.map(({ x, y }) => ({ x, y }))).toEqual([ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ]); + }); + + it("does not change state with an empty point list", async () => { + layer().movePoints([], { x: 5, y: 5 }); + await editor.settle(); + + expect(layer().allPoints.map(({ x, y }) => ({ x, y }))).toEqual([ + { x: 10, y: 20 }, + { x: 30, y: 40 }, + ]); + }); +}); + +describe("GlyphLayer.toggleSmooth", () => { + let editor: TestEditor; + + beforeEach(async () => { + editor = new TestEditor(); + await editor.startSession(); + editor.selectTool("pen"); + editor.click(100, 200); + await editor.settle(); + }); + + const layer = () => editor.glyphLayer!; + + it("toggles a corner point smooth", async () => { + const pointId = layer().allPoints[0]!.id; + + layer().toggleSmooth(pointId); + await editor.settle(); + + expect(layer().point(pointId)?.smooth).toBe(true); + }); + + it("toggles back through the workspace ledger on undo", async () => { + const pointId = layer().allPoints[0]!.id; + + layer().toggleSmooth(pointId); + await editor.settle(); + expect(layer().point(pointId)?.smooth).toBe(true); + + await editor.undoAndSettle(); + expect(layer().point(pointId)?.smooth).toBe(false); + }); +}); + +describe("GlyphLayer.reverseContour", () => { + let editor: TestEditor; + + beforeEach(async () => { + editor = new TestEditor(); + await editor.startSession(); + editor.selectTool("pen"); + editor.click(0, 0); + await editor.settle(); + editor.click(100, 0); + await editor.settle(); + editor.click(200, 0); + await editor.settle(); + }); + + const layer = () => editor.glyphLayer!; + + it("reverses the point order", async () => { + const contour = layer().contours[0]!; + expect(contour.points.map(({ x }) => x)).toEqual([0, 100, 200]); + + layer().reverseContour(contour.id); + await editor.settle(); + + expect(layer().contours[0]!.points.map(({ x }) => x)).toEqual([200, 100, 0]); + }); + + it("restores the original winding through ledger undo", async () => { + layer().reverseContour(layer().contours[0]!.id); + await editor.settle(); + + await editor.undoAndSettle(); + expect(layer().contours[0]!.points.map(({ x }) => x)).toEqual([0, 100, 200]); + }); +}); + +describe("GlyphLayer.splitSegment", () => { + let editor: TestEditor; + + beforeEach(async () => { + editor = new TestEditor(); + await editor.startSession(); + editor.selectTool("pen"); + }); + + const layer = () => editor.glyphLayer!; + + describe("line segment", () => { + beforeEach(async () => { + editor.clickGlyphLocal(0, 0); + await editor.settle(); + editor.clickGlyphLocal(100, 0); + await editor.settle(); + }); + + it("inserts a single on-curve point at t=0.5", async () => { + const segment = layer().contours[0]!.segments()[0]!; + + const splitId = layer().splitSegment(segment.id, 0.5); + await editor.settle(); + + expect(splitId).not.toBe(null); + expect(layer().allPoints.length).toBe(3); + expect(layer().point(splitId!)).toMatchObject({ x: 50, y: 0, pointType: "onCurve" }); + }); + + it("removes the inserted point with one ledger undo", async () => { + const segment = layer().contours[0]!.segments()[0]!; + + layer().splitSegment(segment.id, 0.5); + await editor.settle(); + expect(layer().allPoints.length).toBe(3); + + await editor.undoAndSettle(); + expect(layer().allPoints.length).toBe(2); + }); + }); + + it("inserts the point at the parametric position for t=0.25", async () => { + editor.clickGlyphLocal(0, 0); + await editor.settle(); + editor.clickGlyphLocal(100, 100); + await editor.settle(); + + const segment = layer().contours[0]!.segments()[0]!; + const splitId = layer().splitSegment(segment.id, 0.25); + await editor.settle(); + + expect(splitId).not.toBe(null); + expect(layer().point(splitId!)).toMatchObject({ x: 25, y: 25 }); + }); + + describe("cubic segment", () => { + let c1: PointId; + let c2: PointId; + + beforeEach(async () => { + const contourId = layer().addContour(); + layer().addOnCurvePoint(contourId, { x: 0, y: 0 }); + c1 = layer().addOffCurvePoint(contourId, { x: 25, y: 100 }); + c2 = layer().addOffCurvePoint(contourId, { x: 75, y: 100 }); + layer().addOnCurvePoint(contourId, { x: 100, y: 0 }); + await editor.settle(); + }); + + it("inserts three points and a smooth on-curve at the split", async () => { + const segment = layer().contours[0]!.segments()[0]!; + expect(segment.type).toBe("cubic"); + + const splitId = layer().splitSegment(segment.id, 0.5); + await editor.settle(); + + expect(splitId).not.toBe(null); + expect(layer().allPoints.length).toBe(7); + expect(layer().point(splitId!)).toMatchObject({ pointType: "onCurve", smooth: true }); + }); + + it("restores both control positions with one ledger undo", async () => { + const segment = layer().contours[0]!.segments()[0]!; + + layer().splitSegment(segment.id, 0.5); + await editor.settle(); + expect(layer().allPoints.length).toBe(7); + + await editor.undoAndSettle(); + expect(layer().allPoints.length).toBe(4); + expect(layer().point(c1)).toMatchObject({ x: 25, y: 100 }); + expect(layer().point(c2)).toMatchObject({ x: 75, y: 100 }); + }); + }); +}); + +describe("GlyphLayer.upgradeLineToCubic", () => { + let editor: TestEditor; + + beforeEach(async () => { + editor = new TestEditor(); + await editor.startSession(); + editor.selectTool("pen"); + editor.clickGlyphLocal(0, 0); + await editor.settle(); + editor.clickGlyphLocal(90, 30); + await editor.settle(); + }); + + const layer = () => editor.glyphLayer!; + + it("converts the line into a shape-preserving cubic", async () => { + const segment = layer().contours[0]!.segments()[0]!; + + expect(layer().upgradeLineToCubic(segment.id)).toBe(true); + await editor.settle(); + + const cubicSegment = layer().contours[0]!.segments()[0]!; + expect(cubicSegment.type).toBe("cubic"); + expect(layer().allPoints.length).toBe(4); + + const cubic = cubicSegment.asCubic()!; + expect(cubic.controlStart).toMatchObject({ x: 30, y: 10 }); + expect(cubic.controlEnd).toMatchObject({ x: 60, y: 20 }); + }); + + it("removes both controls with one ledger undo", async () => { + const segment = layer().contours[0]!.segments()[0]!; + + layer().upgradeLineToCubic(segment.id); + await editor.settle(); + expect(layer().allPoints.length).toBe(4); + + await editor.undoAndSettle(); + expect(layer().allPoints.length).toBe(2); + expect(layer().contours[0]!.segments()[0]!.type).toBe("line"); + }); +}); + +describe("GlyphLayer metrics", () => { + let editor: TestEditor; + let initialAdvance: number; + + beforeEach(async () => { + editor = new TestEditor(); + await editor.startSession(); + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.clickGlyphLocal(150, 200); + await editor.settle(); + initialAdvance = editor.glyphLayer!.xAdvance; + }); + + const layer = () => editor.glyphLayer!; + + describe("setXAdvance", () => { + it("sets the advance width", async () => { + layer().setXAdvance(530); + await editor.settle(); + + expect(layer().xAdvance).toBe(530); + }); + + it("restores the advance through ledger undo", async () => { + layer().setXAdvance(530); + await editor.settle(); + + await editor.undoAndSettle(); + expect(layer().xAdvance).toBe(initialAdvance); + }); + }); + + describe("setRightSidebearing", () => { + it("sets the right sidebearing by changing the advance width", async () => { + const bounds = layer().bounds!; + const nextRightSidebearing = layer().sidebearings.rsb! + 30; + + layer().setRightSidebearing(nextRightSidebearing); + await editor.settle(); + + expect(layer().xAdvance).toBe(bounds.max.x + nextRightSidebearing); + expect(layer().sidebearings.rsb).toBe(nextRightSidebearing); + }); + }); + + describe("setLeftSidebearing", () => { + it("translates geometry and preserves the right sidebearing", async () => { + const pointId = layer().allPoints[0]!.id; + const point = layer().point(pointId)!; + const nextLeftSidebearing = layer().sidebearings.lsb! + 20; + const initialRightSidebearing = layer().sidebearings.rsb; + + layer().setLeftSidebearing(nextLeftSidebearing); + await editor.settle(); + + expect(layer().xAdvance).toBe(initialAdvance + 20); + expect(layer().point(pointId)).toMatchObject({ x: point.x + 20, y: point.y }); + expect(layer().sidebearings.rsb).toBe(initialRightSidebearing); + }); + + it("reverts translation and advance with one ledger undo", async () => { + const pointId = layer().allPoints[0]!.id; + const point = layer().point(pointId)!; + const nextLeftSidebearing = layer().sidebearings.lsb! + 20; + + layer().setLeftSidebearing(nextLeftSidebearing); + await editor.settle(); + + await editor.undoAndSettle(); + expect(layer().xAdvance).toBe(initialAdvance); + expect(layer().point(pointId)).toMatchObject({ x: point.x, y: point.y }); + }); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/model/GlyphLayerState.ts b/apps/desktop/src/renderer/src/lib/model/GlyphLayerState.ts index 90e00d40..9744ca98 100644 --- a/apps/desktop/src/renderer/src/lib/model/GlyphLayerState.ts +++ b/apps/desktop/src/renderer/src/lib/model/GlyphLayerState.ts @@ -80,6 +80,10 @@ export class GlyphLayerState { return this.#structure; } + get layerId(): LayerId { + return this.#layerId; + } + get coordinateBuffers(): LayerCoordinateBuffers { return this.#coordinates.peek(); } 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..27089a40 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,16 +135,11 @@ async function variableFont(): Promise<{ } async function loadGlyph(stack: WorkspaceStack, glyphId: GlyphId) { - const loaded = await stack.font.loadGlyph(glyphId, { - sourceIds: [stack.font.defaultSource.id], - }); - if (!loaded) throw new Error("Expected default glyph layer to load"); + return stack.font.loadGlyph(glyphId); } async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: Source) { - await stack.font.loadGlyph(glyphId, { - sourceIds: [stack.font.defaultSource.id, source.id], - }); + await stack.font.loadGlyph(glyphId); const layer = stack.font.layer(glyphId, source.id); if (!layer) throw new Error("Expected glyph layer to load"); return layer; @@ -167,10 +148,11 @@ async function loadGlyphLayer(stack: WorkspaceStack, glyphId: GlyphId, source: S describe("variable editing across sources", () => { let stack: WorkspaceStack; let glyphId: GlyphId; + let boldLayerId: LayerId; let bold: Source; beforeEach(async () => { - ({ stack, glyphId, bold } = await variableFont()); + ({ stack, glyphId, boldLayerId, bold } = await variableFont()); }); it("opens an authored glyph layer at a non-default master", async () => { @@ -195,15 +177,15 @@ 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); + const instance = stack.font.instance(glyph.id, mid); if (!instance) throw new Error("Expected glyph instance"); - expect(stack.font.editableLayerAt(glyphId, mid)).toBeNull(); + expect(stack.font.editableLayerAt(glyph.id, mid)).toBeNull(); expect(instance.xAdvance).toBeCloseTo(300 + (500 - 300) * 0.5); const xs = instance.geometry.allPoints.map((point) => point.x); @@ -211,34 +193,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); + const instance = stack.font.instance(glyph.id, 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); - - expect(stack.font.editableLayerAt(glyphId, atBold)).not.toBeNull(); + expect(stack.font.editableLayerAt(glyph.id, atBold)?.id).toBe(boldLayerId); expect(instance.xAdvance).toBe(500); - expect(instance.render.outline.bounds?.max.x).toBe(200); }); }); diff --git a/apps/desktop/src/renderer/src/lib/objects/AnchorObject.ts b/apps/desktop/src/renderer/src/lib/objects/AnchorObject.ts new file mode 100644 index 00000000..bacfe67c --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/objects/AnchorObject.ts @@ -0,0 +1,55 @@ +import { Bounds, Vec2, type Rect2D } from "@shift/geo"; +import type { AnchorId } from "@shift/types"; +import { track } from "@/lib/signals"; +import type { GlyphLayer } from "@/lib/model/Glyph"; +import type { ShiftObjectOf } from "@/types"; +import type { GlyphNode } from "@/types/node"; + +/** + * Resolved editable glyph anchor in the current scene. + * + * @remarks + * The anchor is read from `layer` each time behavior runs. `node` supplies the + * scene placement for the authored glyph layer. + */ +export class AnchorObject implements ShiftObjectOf<"anchor"> { + readonly kind = "anchor"; + readonly id: AnchorId; + readonly layer: GlyphLayer; + readonly node: GlyphNode; + readonly #anchorId: AnchorId; + + /** + * Creates a placed anchor object from its canonical layer and scene node. + * + * @param anchorId - Stable anchor identity owned by `layer`. + * @param layer - Authored glyph layer that owns the anchor. + * @param node - Scene occurrence placing the layer on the canvas. + */ + constructor(anchorId: AnchorId, layer: GlyphLayer, node: GlyphNode) { + this.id = anchorId; + this.#anchorId = anchorId; + this.layer = layer; + this.node = node; + } + + /** Stable anchor identity owned by this object's layer. */ + get anchorId(): AnchorId { + return this.#anchorId; + } + + /** + * Returns zero-area scene-space bounds for the current anchor position. + * + * @returns null when the anchor no longer exists in the layer. + */ + bounds(): Rect2D | null { + track(this.layer.structureCell); + track(this.layer.coordinateBuffersChangedCell); + + const anchor = this.layer.anchor(this.anchorId); + if (!anchor) return null; + + return Bounds.toRect(Bounds.fromPoint(Vec2.add(this.node.position, anchor))); + } +} diff --git a/apps/desktop/src/renderer/src/lib/objects/ContourObject.ts b/apps/desktop/src/renderer/src/lib/objects/ContourObject.ts new file mode 100644 index 00000000..b4852d2a --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/objects/ContourObject.ts @@ -0,0 +1,61 @@ +import { Bounds, Vec2, type Rect2D } from "@shift/geo"; +import type { ContourId } from "@shift/types"; +import { track } from "@/lib/signals"; +import type { GlyphLayer } from "@/lib/model/Glyph"; +import type { ShiftObjectOf } from "@/types"; +import type { GlyphNode } from "@/types/node"; + +/** + * Resolved editable glyph contour in the current scene. + * + * @remarks + * The contour is looked up from the current layer geometry when behavior runs, + * so replacement structure is observed without storing contour objects here. + */ +export class ContourObject implements ShiftObjectOf<"contour"> { + readonly kind = "contour"; + readonly id: ContourId; + readonly layer: GlyphLayer; + readonly node: GlyphNode; + readonly #contourId: ContourId; + + /** + * Creates a placed contour object from its canonical layer and scene node. + * + * @param contourId - Stable contour identity owned by `layer`. + * @param layer - Authored glyph layer that owns the contour. + * @param node - Scene occurrence placing the layer on the canvas. + */ + constructor(contourId: ContourId, layer: GlyphLayer, node: GlyphNode) { + this.id = contourId; + this.#contourId = contourId; + this.layer = layer; + this.node = node; + } + + /** Stable contour identity owned by this object's layer. */ + get contourId(): ContourId { + return this.#contourId; + } + + /** + * Returns scene-space bounds for the current contour outline. + * + * @returns null when the contour no longer exists or has no drawable segments. + */ + bounds(): Rect2D | null { + track(this.layer.structureCell); + track(this.layer.coordinateBuffersChangedCell); + + const contour = this.layer.contour(this.contourId); + if (!contour) return null; + + const bounds = contour.bounds; + if (!bounds) return null; + + return Bounds.toRect({ + min: Vec2.add(this.node.position, bounds.min), + max: Vec2.add(this.node.position, bounds.max), + }); + } +} diff --git a/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts b/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts new file mode 100644 index 00000000..c31c5e38 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/objects/NodeObject.ts @@ -0,0 +1,36 @@ +import type { Rect2D } from "@shift/geo"; +import type { NodeId } from "@shift/types"; +import type { ShiftObjectOf } from "@/types"; +import type { ShiftNode } from "@/types/node"; + +/** + * Resolved scene node in the current editor scene. + * + * @remarks + * Node-specific bounds will eventually delegate to the node kind's object + * definition. Until those definitions exist, bare node bounds are absent. + */ +export class NodeObject implements ShiftObjectOf<"node"> { + readonly kind = "node"; + readonly id: NodeId; + readonly node: ShiftNode; + + /** + * Creates a scene node object. + * + * @param node - Placed scene node to expose as an object. + */ + constructor(node: ShiftNode) { + this.id = node.id; + this.node = node; + } + + /** + * Returns scene-space bounds for this node. + * + * @returns null until node-kind bounds definitions are available. + */ + bounds(): Rect2D | null { + return null; + } +} diff --git a/apps/desktop/src/renderer/src/lib/objects/PointObject.ts b/apps/desktop/src/renderer/src/lib/objects/PointObject.ts new file mode 100644 index 00000000..16174dbf --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/objects/PointObject.ts @@ -0,0 +1,56 @@ +import { Bounds, Vec2, type Rect2D } from "@shift/geo"; +import type { PointId } from "@shift/types"; +import { track } from "@/lib/signals"; +import type { GlyphLayer } from "@/lib/model/Glyph"; +import type { ShiftObjectOf } from "@/types"; +import type { GlyphNode } from "@/types/node"; + +/** + * Resolved editable glyph point in the current scene. + * + * @remarks + * The point is read from `layer` each time behavior runs. `node` supplies the + * scene placement for that glyph layer, so bounds are returned in scene space + * rather than glyph-local space. + */ +export class PointObject implements ShiftObjectOf<"point"> { + readonly kind = "point"; + readonly id: PointId; + readonly layer: GlyphLayer; + readonly node: GlyphNode; + readonly #pointId: PointId; + + /** + * Creates a placed point object from its canonical layer and scene node. + * + * @param pointId - Stable point identity owned by `layer`. + * @param layer - Authored glyph layer that owns the point. + * @param node - Scene occurrence placing the layer on the canvas. + */ + constructor(pointId: PointId, layer: GlyphLayer, node: GlyphNode) { + this.id = pointId; + this.#pointId = pointId; + this.layer = layer; + this.node = node; + } + + /** Stable point identity owned by this object's layer. */ + get pointId(): PointId { + return this.#pointId; + } + + /** + * Returns zero-area scene-space bounds for the current point position. + * + * @returns null when the point no longer exists in the layer. + */ + bounds(): Rect2D | null { + track(this.layer.structureCell); + track(this.layer.coordinateBuffersChangedCell); + + const point = this.layer.point(this.pointId); + if (!point) return null; + + return Bounds.toRect(Bounds.fromPoint(Vec2.add(this.node.position, point))); + } +} diff --git a/apps/desktop/src/renderer/src/lib/objects/SegmentObject.ts b/apps/desktop/src/renderer/src/lib/objects/SegmentObject.ts new file mode 100644 index 00000000..4a8785f1 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/objects/SegmentObject.ts @@ -0,0 +1,68 @@ +import { Bounds, Vec2, type Rect2D } from "@shift/geo"; +import type { PointId } from "@shift/types"; +import type { SegmentId } from "@shift/glyph-state"; +import { track } from "@/lib/signals"; +import type { GlyphLayer } from "@/lib/model/Glyph"; +import type { ShiftObjectOf } from "@/types"; +import type { GlyphNode } from "@/types/node"; + +/** + * Resolved editable glyph segment in the current scene. + * + * @remarks + * Segment IDs are derived from endpoint point IDs. `pointIds` preserves the + * source points associated with this segment so operations can expand segment + * selection without rewriting selection state. + */ +export class SegmentObject implements ShiftObjectOf<"segment"> { + readonly kind = "segment"; + readonly id: SegmentId; + readonly pointIds: readonly PointId[]; + readonly layer: GlyphLayer; + readonly node: GlyphNode; + readonly #segmentId: SegmentId; + + /** + * Creates a placed segment object from its canonical layer and scene node. + * + * @param segmentId - Derived segment identity owned by `layer`. + * @param pointIds - Point identities associated with the segment. + * @param layer - Authored glyph layer that owns the segment. + * @param node - Scene occurrence placing the layer on the canvas. + */ + constructor( + segmentId: SegmentId, + pointIds: readonly PointId[], + layer: GlyphLayer, + node: GlyphNode, + ) { + this.id = segmentId; + this.#segmentId = segmentId; + this.pointIds = pointIds; + this.layer = layer; + this.node = node; + } + + /** Stable segment identity derived from this object's endpoint points. */ + get segmentId(): SegmentId { + return this.#segmentId; + } + + /** + * Returns scene-space bounds for the current segment curve. + * + * @returns null when the segment no longer exists in the layer. + */ + bounds(): Rect2D | null { + track(this.layer.structureCell); + track(this.layer.coordinateBuffersChangedCell); + + const segment = this.layer.segment(this.segmentId); + if (!segment) return null; + + return Bounds.toRect({ + min: Vec2.add(this.node.position, segment.bounds.min), + max: Vec2.add(this.node.position, segment.bounds.max), + }); + } +} diff --git a/apps/desktop/src/renderer/src/lib/objects/index.ts b/apps/desktop/src/renderer/src/lib/objects/index.ts new file mode 100644 index 00000000..9309f769 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/objects/index.ts @@ -0,0 +1,5 @@ +export { AnchorObject } from "./AnchorObject"; +export { ContourObject } from "./ContourObject"; +export { NodeObject } from "./NodeObject"; +export { PointObject } from "./PointObject"; +export { SegmentObject } from "./SegmentObject"; diff --git a/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md index 7cf0150d..aaffda6b 100644 --- a/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/signals/docs/DOCS.md @@ -113,4 +113,4 @@ cd apps/desktop && npm test - `NativeBridge` -- uses a glyph identity cell with `equals: () => false` for identity changes - `useSignalState` -- React bridge hook (in this module) - `useSignalEffect` -- lifecycle-aware effect hook (in `@/hooks/useSignalEffect`) -- `CommandHistory` -- imports from reactive for undo/redo state signals +- `WorkspaceEditCoordinator` -- uses signals for settled and commit lifecycle state diff --git a/apps/desktop/src/renderer/src/lib/store/ShiftStore.ts b/apps/desktop/src/renderer/src/lib/store/ShiftStore.ts new file mode 100644 index 00000000..9568d1e3 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/store/ShiftStore.ts @@ -0,0 +1,51 @@ +import { signal, type Signal, type WritableSignal } from "@/lib/signals"; +import type { ShiftRecord } from "@/types"; + +export class ShiftStore { + readonly #cell: WritableSignal>; + + constructor(records: readonly R[] = []) { + this.#cell = signal>( + new Map(records.map((record) => [record.id, record])), + { + name: "editor.store", + }, + ); + } + + get cell(): Signal> { + return this.#cell; + } + + records(): readonly R[] { + return [...this.#cell.peek().values()]; + } + + put(record: R): void { + const current = this.#cell.peek(); + if (current.get(record.id) === record) return; + + const next = new Map(current); + next.set(record.id, record); + this.#cell.set(next); + } + + get(id: R["id"]): R | null { + return this.#cell.peek().get(id) ?? null; + } + + delete(id: R["id"]): void { + const current = this.#cell.peek(); + if (!current.has(id)) return; + + const next = new Map(current); + next.delete(id); + this.#cell.set(next); + } + + clear(): void { + if (this.#cell.peek().size === 0) return; + + this.#cell.set(new Map()); + } +} 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.test.ts b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.test.ts index f04c3d3b..2d2198b6 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/GestureDetector.test.ts @@ -6,6 +6,15 @@ function c(x: number, y: number) { return makeTestCoordinates({ x, y }); } +const NO_MODIFIERS = { shiftKey: false, altKey: false, metaKey: false }; +const NORMALIZED_NO_MODIFIERS = { + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + accelKey: false, +}; + describe("GestureDetector", () => { let detector: GestureDetector; @@ -15,52 +24,37 @@ describe("GestureDetector", () => { describe("click detection", () => { it("emits click when pointer up without significant movement", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + const events = detector.pointerUp(c(100, 100), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0)).toMatchObject({ type: "click", - point: { x: 100, y: 100 }, - shiftKey: false, - altKey: false, - metaKey: false, + coords: c(100, 100), + ...NORMALIZED_NO_MODIFIERS, }); - expect((expectAt(events, 0) as { coords: unknown }).coords).toEqual(c(100, 100)); }); it("preserves modifier keys in click event", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: true, altKey: true, metaKey: true }, - ); - const events = detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + const modifiers = { shiftKey: true, altKey: true, metaKey: true }; + + detector.pointerDown(c(100, 100), modifiers); + const events = detector.pointerUp(c(100, 100), modifiers); expect(expectAt(events, 0)).toMatchObject({ type: "click", shiftKey: true, altKey: true, metaKey: true, + ctrlKey: false, + accelKey: true, }); }); it("emits click even with small movement under threshold", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerMove( - c(101, 101), - { x: 101, y: 101 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(101, 101), { x: 101, y: 101 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerMove(c(101, 101), NO_MODIFIERS); + const events = detector.pointerUp(c(101, 101), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0).type).toBe("click"); @@ -69,41 +63,25 @@ describe("GestureDetector", () => { describe("double click detection", () => { it("emits doubleClick for two rapid clicks at same location", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerUp(c(100, 100), { x: 100, y: 100 }); - - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerUp(c(100, 100), NO_MODIFIERS); + + detector.pointerDown(c(100, 100), NO_MODIFIERS); + const events = detector.pointerUp(c(100, 100), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0)).toMatchObject({ type: "doubleClick", - point: { x: 100, y: 100 }, + coords: c(100, 100), }); }); it("emits click instead of doubleClick if too far apart", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerUp(c(100, 100), { x: 100, y: 100 }); - - detector.pointerDown( - c(110, 110), - { x: 110, y: 110 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(110, 110), { x: 110, y: 110 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerUp(c(100, 100), NO_MODIFIERS); + + detector.pointerDown(c(110, 110), NO_MODIFIERS); + const events = detector.pointerUp(c(110, 110), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0).type).toBe("click"); @@ -112,21 +90,13 @@ describe("GestureDetector", () => { it("emits click instead of doubleClick if too slow", async () => { vi.useFakeTimers(); - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerUp(c(100, 100), NO_MODIFIERS); vi.advanceTimersByTime(400); - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + const events = detector.pointerUp(c(100, 100), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0).type).toBe("click"); @@ -137,147 +107,92 @@ describe("GestureDetector", () => { describe("drag detection", () => { it("emits dragStart when movement exceeds threshold", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerMove( - c(110, 100), - { x: 110, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + const events = detector.pointerMove(c(110, 100), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0)).toMatchObject({ type: "dragStart", - point: { x: 100, y: 100 }, - screenPoint: { x: 100, y: 100 }, - shiftKey: false, - altKey: false, - metaKey: false, + coords: c(110, 100), + origin: c(100, 100), + delta: { screen: { x: 10, y: 0 }, scene: { x: 10, y: 0 } }, + ...NORMALIZED_NO_MODIFIERS, }); }); it("emits drag events after dragStart", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerMove( - c(110, 100), - { x: 110, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - - const events = detector.pointerMove( - c(120, 110), - { x: 120, y: 110 }, - { shiftKey: true, altKey: false, metaKey: false }, - ); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerMove(c(110, 100), NO_MODIFIERS); + + const events = detector.pointerMove(c(120, 110), { + shiftKey: true, + altKey: false, + metaKey: false, + }); expect(events).toHaveLength(1); expect(expectAt(events, 0)).toMatchObject({ type: "drag", - point: { x: 120, y: 110 }, - screenPoint: { x: 120, y: 110 }, - origin: { x: 100, y: 100 }, - screenOrigin: { x: 100, y: 100 }, - delta: { x: 20, y: 10 }, - screenDelta: { x: 20, y: 10 }, + coords: c(120, 110), + origin: c(100, 100), + delta: { screen: { x: 20, y: 10 }, scene: { x: 20, y: 10 } }, shiftKey: true, altKey: false, metaKey: false, + ctrlKey: false, + accelKey: false, }); }); it("emits dragEnd on pointer up after dragging", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerMove( - c(110, 100), - { x: 110, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(120, 110), { x: 120, y: 110 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerMove(c(110, 100), NO_MODIFIERS); + const events = detector.pointerUp(c(120, 110), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0)).toMatchObject({ type: "dragEnd", - point: { x: 120, y: 110 }, - screenPoint: { x: 120, y: 110 }, - origin: { x: 100, y: 100 }, - screenOrigin: { x: 100, y: 100 }, + coords: c(120, 110), + origin: c(100, 100), + delta: { screen: { x: 20, y: 10 }, scene: { x: 20, y: 10 } }, }); }); it("isDragging returns true during drag", () => { expect(detector.isDragging).toBe(false); - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); + detector.pointerDown(c(100, 100), NO_MODIFIERS); expect(detector.isDragging).toBe(false); - detector.pointerMove( - c(110, 100), - { x: 110, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); + detector.pointerMove(c(110, 100), NO_MODIFIERS); expect(detector.isDragging).toBe(true); - detector.pointerUp(c(110, 100), { x: 110, y: 100 }); + detector.pointerUp(c(110, 100), NO_MODIFIERS); expect(detector.isDragging).toBe(false); }); }); describe("reset", () => { it("cancels pending drag state", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerMove( - c(110, 100), - { x: 110, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerMove(c(110, 100), NO_MODIFIERS); detector.reset(); expect(detector.isDragging).toBe(false); - const events = detector.pointerMove( - c(120, 100), - { x: 120, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); + const events = detector.pointerMove(c(120, 100), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0).type).toBe("pointerMove"); }); it("resets double-click tracking", () => { - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + detector.pointerUp(c(100, 100), NO_MODIFIERS); detector.reset(); - detector.pointerDown( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); - const events = detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + detector.pointerDown(c(100, 100), NO_MODIFIERS); + const events = detector.pointerUp(c(100, 100), NO_MODIFIERS); expect(expectAt(events, 0).type).toBe("click"); }); @@ -285,21 +200,16 @@ describe("GestureDetector", () => { describe("edge cases", () => { it("returns pointerMove event when no button is down", () => { - const events = detector.pointerMove( - c(100, 100), - { x: 100, y: 100 }, - { shiftKey: false, altKey: false, metaKey: false }, - ); + const events = detector.pointerMove(c(100, 100), NO_MODIFIERS); expect(events).toHaveLength(1); expect(expectAt(events, 0)).toMatchObject({ type: "pointerMove", - point: { x: 100, y: 100 }, + coords: c(100, 100), }); - expect((expectAt(events, 0) as { coords: unknown }).coords).toEqual(c(100, 100)); }); it("returns empty array for pointerUp without pointerDown", () => { - const events = detector.pointerUp(c(100, 100), { x: 100, y: 100 }); + const events = detector.pointerUp(c(100, 100), NO_MODIFIERS); expect(events).toHaveLength(0); }); }); 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.test.ts b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts index 7b7c92c5..f7e9163c 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts @@ -240,6 +240,8 @@ describe("ToolManager", () => { shiftKey: false, altKey: true, metaKey: false, + ctrlKey: false, + accelKey: false, }); }); @@ -253,6 +255,8 @@ describe("ToolManager", () => { shiftKey: false, altKey: false, metaKey: false, + ctrlKey: false, + accelKey: false, }); }); }); 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..eead5d87 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,33 @@ 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 "drag": + case "dragEnd": + return { + ...event, + target: this.editor.getPointerTarget(event.coords.scene), + }; + case "dragStart": + return { + ...event, + target: this.editor.getPointerTarget(event.origin.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..ca28866c 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 @@ -179,7 +179,13 @@ export class MyBehavior implements Behavior { ```typescript onDragStart(state, ctx, event) { if (state.type !== "selected") return false; - this.#draft = ctx.editor.beginGlyphLayerEditDraft({ points: [...ctx.editor.selection.pointIds] }); + const edit = resolveSelectedGlyphEdit(ctx.editor); + if (!edit) return false; + + this.#draft = new GlyphLayerEditDraft(edit.layer, { + points: edit.pointIds, + anchors: edit.anchorIds, + }); ctx.setState({ type: "translating", startPos: event.point, totalDelta: { x: 0, y: 0 } }); return true; } @@ -233,6 +239,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..97d9dad4 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"); }); @@ -124,9 +124,8 @@ describe("Pen tool", () => { expect(editor.pointCount).toBe(1); }); - it("first click coalesces contour + point into a single undo step", async () => { - // The first pen click creates the contour AND the point in one tick — - // one apply, one undo step. + it("first click groups contour + point into a single undo step", async () => { + // The first pen click creates the contour and the point as one user operation. editor.click(100, 200); await editor.settle(); expect(editor.pointCount).toBe(1); @@ -134,7 +133,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..bc22debe 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.nodesOfKind("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..fef8c92f 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,18 @@ 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.peek(); + if (!context?.activeContourId) return null; + + const layer = this.#editor.font.layer(context.glyphNode.glyphId, context.glyphNode.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..ecbceefd 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 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 layer = pen.editor.font.layer(context.glyphNode.glyphId, context.glyphNode.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, pointId] = this.#pen.editor.transaction("Start contour", () => { + const contourId = this.#layer.addContour(); + const pointId = this.#layer.addOnCurvePoint(contourId, position); + return [contourId, pointId] as const; + }); + + 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,15 @@ 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.#layer.reverseContour(contourId); + } + this.#pen.editor.selection.select([pointId]); } splitSegment(segmentId: SegmentId, t: number): PointId | null { - const segment = this.#geometry.segment(segmentId); - if (!segment) return null; - return this.#editor.splitSegment(segment, t); + return this.#layer.splitSegment(segmentId, t); } commitAnchor(anchor: Anchor): PointId | null { @@ -91,11 +104,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 +116,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 +125,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 +157,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.test.ts b/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.test.ts index 7bfd7ed1..c94479d2 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { Bounds, type Point2D, type Rect2D } from "@shift/geo"; +import { type Point2D, type Rect2D } from "@shift/geo"; +import { asPointId } from "@shift/types"; import type { Editor } from "@/lib/editor/Editor"; import { signal } from "@/lib/signals"; import { SELECT_BOUNDING_BOX_STYLE, SelectBoundingBox } from "./BoundingBox"; @@ -17,29 +18,18 @@ const createRect = (x: number, y: number, width: number, height: number): Rect2D }); function createBoundingBox(rect: Rect2D) { - const project = (glyphLocal: Point2D) => ({ - screen: { x: glyphLocal.x, y: -glyphLocal.y }, - scene: glyphLocal, - glyphLocal, - }); + const projectSceneToScreen = (scene: Point2D) => ({ x: scene.x, y: -scene.y }); const editor = { selection: { - bounds: Bounds.fromXYWH(rect.x, rect.y, rect.width, rect.height), - boundsCell: signal(Bounds.fromXYWH(rect.x, rect.y, rect.width, rect.height)), stateCell: signal({ - pointIds: new Set(["p1", "p2"]), - anchorIds: new Set(), - segmentIds: new Set(), + ids: [asPointId("point_a"), asPointId("point_b")], }), - hasSelection: () => true, }, + selectionBoundsCell: signal(rect), camera: { trackViewportTransform: () => {}, }, - proofModeCell: signal(false), - handlesVisibleCell: signal(true), - focusedGlyphVisibleCell: signal(true), - fromGlyphLocal: project, + projectSceneToScreen, screenToUpmDistance: (pixels: number) => pixels, } as unknown as Editor; const select = { @@ -49,7 +39,11 @@ function createBoundingBox(rect: Rect2D) { const boundingBox = new SelectBoundingBox(select); - return (glyphLocal: Point2D) => boundingBox.hit(project(glyphLocal)); + return (scene: Point2D) => + boundingBox.hit({ + screen: projectSceneToScreen(scene), + scene, + }); } describe("SelectBoundingBox.hit", () => { 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..37c1a9ff 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/BoundingBox.ts @@ -1,4 +1,4 @@ -import { Bounds, Vec2, type Point2D, type Rect2D } from "@shift/geo"; +import { Vec2, type Point2D, type Rect2D } from "@shift/geo"; import type { Editor } from "@/lib/editor/Editor"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; import { CanvasItem } from "@/lib/editor/rendering/CanvasItem"; @@ -91,9 +91,9 @@ interface ExpandedHandleRect { } export interface SelectBoundingBoxProps { - readonly rect: Rect2D; + readonly sceneRect: Rect2D; readonly screenRect: Rect2D; - readonly upmHandles: HandlePositions; + readonly sceneHandles: HandlePositions; readonly screenHandles: HandlePositions; readonly hitRadiusPx: number; } @@ -112,28 +112,17 @@ export class SelectBoundingBox extends CanvasItem { 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; + const selectedCount = this.#editor.selection.stateCell.value.ids.length; if (selectedCount <= 1) return null; - const bounds = this.#editor.selection.boundsCell.value; - if (!bounds) return null; + const sceneRect = this.#editor.selectionBoundsCell.value; + if (!sceneRect) return null; this.#editor.camera.trackViewportTransform(); - const rect = Bounds.toRect(bounds); - const screenRect = this.#screenRect(rect); - const upmHandles = getHandlePositions( - rect, + const screenRect = this.#screenRect(sceneRect); + const sceneHandles = getHandlePositions( + sceneRect, this.#editor.screenToUpmDistance(SELECT_BOUNDING_BOX_STYLE.handle.offsetPx), this.#editor.screenToUpmDistance(SELECT_BOUNDING_BOX_STYLE.rotationZoneOffsetPx), ); @@ -145,16 +134,16 @@ export class SelectBoundingBox extends CanvasItem { ); return { - rect, + sceneRect, screenRect, - upmHandles, + sceneHandles, screenHandles, hitRadiusPx: SELECT_BOUNDING_BOX_STYLE.hitRadiusPx, }; } get rect(): Rect2D | null { - return this.propsSnapshot()?.rect ?? null; + return this.propsSnapshot()?.sceneRect ?? null; } get screenRect(): Rect2D | null { @@ -182,7 +171,7 @@ export class SelectBoundingBox extends CanvasItem { return { type: resizeResult.type, edge: resizeResult.edge, - rect: props.rect, + rect: props.sceneRect, cursor: edgeToCursor(resizeResult.edge), }; } @@ -197,8 +186,8 @@ export class SelectBoundingBox extends CanvasItem { return { type: rotationResult.type, corner: rotationResult.corner, - rect: props.rect, - center: rectCenter(props.rect), + rect: props.sceneRect, + center: rectCenter(props.sceneRect), cursor: this.cursorForRotationCorner(rotationResult.corner), }; } @@ -230,35 +219,19 @@ export class SelectBoundingBox extends CanvasItem { const props = this.propsCell.value; if (!props) return; - this.#drawRect(canvas, props.rect); - this.#drawHandles(canvas, props.upmHandles); + this.#drawRect(canvas, props.sceneRect); + this.#drawHandles(canvas, props.sceneHandles); } #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, - }; + return rectFromPoints( + [ + { x: rect.left, y: rect.top }, + { x: rect.right, y: rect.top }, + { x: rect.right, y: rect.bottom }, + { x: rect.left, y: rect.bottom }, + ].map((point) => this.#editor.projectSceneToScreen(point)), + ); } #drawRect(canvas: Canvas, rect: Rect2D): void { @@ -276,26 +249,6 @@ export class SelectBoundingBox extends CanvasItem { } } -function drawHandle( - canvas: Canvas, - center: Point2D, - style: SelectBoundingBoxStyle["handle"], -): void { - canvas.ctx.save(); - - const radius = canvas.pxToUpm(style.radiusPx); - const size = radius * 2; - const half = radius; - - canvas.ctx.lineWidth = canvas.pxToUpm(style.widthPx); - canvas.ctx.fillStyle = style.fill; - canvas.ctx.strokeStyle = style.stroke; - canvas.ctx.fillRect(center.x - half, center.y - half, size, size); - canvas.ctx.strokeRect(center.x - half, center.y - half, size, size); - - canvas.ctx.restore(); -} - function getExpandedHandleRect( rect: Rect2D, offset: number, @@ -381,6 +334,65 @@ function getHandlePositions( }; } +function rectFromPoints(points: readonly Point2D[]): Rect2D { + const first = points[0]; + if (!first) { + return { + x: 0, + y: 0, + width: 0, + height: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + } + + let left = first.x; + let right = first.x; + let top = first.y; + let bottom = first.y; + + for (const point of points.slice(1)) { + left = Math.min(left, point.x); + right = Math.max(right, point.x); + top = Math.min(top, point.y); + bottom = Math.max(bottom, point.y); + } + + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + left, + top, + right, + bottom, + }; +} + +function drawHandle( + canvas: Canvas, + center: Point2D, + style: SelectBoundingBoxStyle["handle"], +): void { + canvas.ctx.save(); + + const radius = canvas.pxToUpm(style.radiusPx); + const size = radius * 2; + const half = radius; + + canvas.ctx.lineWidth = canvas.pxToUpm(style.widthPx); + canvas.ctx.fillStyle = style.fill; + canvas.ctx.strokeStyle = style.stroke; + canvas.ctx.fillRect(center.x - half, center.y - half, size, size); + canvas.ctx.strokeRect(center.x - half, center.y - half, size, size); + + canvas.ctx.restore(); +} + 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 deleted file mode 100644 index a3db1375..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/select/Segments.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Segment } from "@shift/glyph-state"; -import type { GlyphInstanceGeometry } from "@/lib/model/Glyph"; -import type { Hover } from "@/lib/editor/Hover"; -import type { Selection } from "@/lib/editor/Selection"; -import type { Canvas } from "@/lib/editor/rendering/Canvas"; -import { Segments as SegmentOverlay } from "@/lib/editor/rendering/overlays"; - -export class SelectSegments { - readonly #overlay = new SegmentOverlay(); - readonly #selected: Segment[] = []; - - draw(canvas: Canvas, geometry: GlyphInstanceGeometry, selection: Selection, hover: Hover): void { - this.#selected.length = 0; - - for (const segmentId of selection.segmentIds) { - const segment = geometry.segment(segmentId); - if (segment) this.#selected.push(segment); - } - - this.#overlay.draw(canvas, this.#hoveredSegment(geometry, hover), this.#selected); - } - - #hoveredSegment(geometry: GlyphInstanceGeometry, hover: Hover): Segment | null { - const segmentId = hover.segmentId; - if (!segmentId) return null; - - return geometry.segment(segmentId); - } -} diff --git a/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts b/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts index 4a24e829..9a75c3fe 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { isPointId } from "@shift/types"; import { TestEditor } from "@/testing/TestEditor"; +import { SELECT_BOUNDING_BOX_STYLE } from "./BoundingBox"; // Restored from the WS6 behavioral inventory (git show ef037c6e^). describe("Select tool", () => { @@ -19,7 +21,7 @@ describe("Select tool", () => { editor.selectTool("select"); editor.click(100, 200); - expect(editor.selection.pointIds.size).toBeGreaterThan(0); + expect(editor.selection.ids.some(isPointId)).toBe(true); }); it("clears selection when clicking empty space", async () => { @@ -30,7 +32,333 @@ describe("Select tool", () => { editor.click(100, 200); editor.click(9999, 9999); - expect(editor.selection.pointIds.size).toBe(0); + expect(editor.selection.hasSelection()).toBe(false); + }); + + it("drags a selected point", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.selectTool("select"); + + editor.clickGlyphLocal(100, 200); + + const [pointId] = editor.selection.ids.filter(isPointId); + if (!pointId) throw new Error("Expected selected point"); + + const before = editor.pointPosition(pointId); + + const drag = editor.dragScene({ + down: before, + start: { x: before.x + 4, y: before.y }, + end: { x: before.x + 40, y: before.y + 30 }, + }); + await editor.settle(); + + const after = editor.pointPosition(pointId); + + expect(after.x).toBeCloseTo(before.x + drag.delta.x); + expect(after.y).toBeCloseTo(before.y + drag.delta.y); + }); + + it("drags an unselected point from the pointer-down handle", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.selectTool("select"); + + const layer = editor.requireGlyphLayer(); + const point = layer.contours[0]?.points[0]; + if (!point) throw new Error("Expected point"); + + const before = editor.pointPosition(point.id); + const drag = editor.dragScene({ + down: before, + start: { x: before.x + 80, y: before.y }, + end: { x: before.x + 110, y: before.y + 30 }, + }); + await editor.settle(); + + const after = editor.pointPosition(point.id); + + expect(editor.selection.has(point.id)).toBe(true); + expect(after.x).toBeCloseTo(before.x + drag.delta.x); + expect(after.y).toBeCloseTo(before.y + drag.delta.y); + }); + + it("drags the current selection from inside its bounding box", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 100); + await editor.settle(); + editor.clickGlyphLocal(200, 200); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const [first, second] = layer.contours[0]?.points ?? []; + if (!first || !second) throw new Error("Expected selected points"); + + editor.selection.select([first.id, second.id]); + editor.selectTool("select"); + + const beforeFirst = editor.pointPosition(first.id); + const beforeSecond = editor.pointPosition(second.id); + const drag = editor.dragScene({ + down: { x: 120, y: 180 }, + start: { x: 124, y: 180 }, + end: { x: 150, y: 220 }, + }); + await editor.settle(); + + const afterFirst = editor.pointPosition(first.id); + const afterSecond = editor.pointPosition(second.id); + + expect(afterFirst.x).toBeCloseTo(beforeFirst.x + drag.delta.x); + expect(afterFirst.y).toBeCloseTo(beforeFirst.y + drag.delta.y); + expect(afterSecond.x).toBeCloseTo(beforeSecond.x + drag.delta.x); + expect(afterSecond.y).toBeCloseTo(beforeSecond.y + drag.delta.y); + }); + + it("resizes the current selection from the pointer-down bounding-box handle", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 100); + await editor.settle(); + editor.clickGlyphLocal(200, 200); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const [first, second] = layer.contours[0]?.points ?? []; + if (!first || !second) throw new Error("Expected selected points"); + + editor.selection.select([first.id, second.id]); + editor.selectTool("select"); + + const bounds = editor.selectionBounds(); + if (!bounds) throw new Error("Expected selection bounds"); + + editor.dragScene({ + down: { x: bounds.right, y: bounds.bottom }, + start: { x: bounds.right + 60, y: bounds.bottom }, + end: { x: bounds.right + 50, y: bounds.bottom + 50 }, + }); + await editor.settle(); + + const firstAfter = editor.pointPosition(first.id); + const secondAfter = editor.pointPosition(second.id); + + expect(firstAfter.x).toBeCloseTo(100); + expect(firstAfter.y).toBeCloseTo(100); + expect(secondAfter.x).toBeCloseTo(250); + expect(secondAfter.y).toBeCloseTo(250); + }); + + it("rotates the current selection from the pointer-down bounding-box zone", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 100); + await editor.settle(); + editor.clickGlyphLocal(200, 200); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const [first, second] = layer.contours[0]?.points ?? []; + if (!first || !second) throw new Error("Expected selected points"); + + editor.selection.select([first.id, second.id]); + editor.selectTool("select"); + + const bounds = editor.selectionBounds(); + if (!bounds) throw new Error("Expected selection bounds"); + + const offset = SELECT_BOUNDING_BOX_STYLE.rotationZoneOffsetPx; + editor.dragScene({ + down: { x: bounds.right + offset, y: bounds.bottom + offset }, + start: { x: bounds.right + offset + 40, y: bounds.bottom + offset + 40 }, + end: { x: bounds.left - offset, y: bounds.bottom + offset }, + }); + await editor.settle(); + + const firstAfter = editor.pointPosition(first.id); + const secondAfter = editor.pointPosition(second.id); + + expect(firstAfter.x).toBeCloseTo(200); + expect(firstAfter.y).toBeCloseTo(100); + expect(secondAfter.x).toBeCloseTo(100); + expect(secondAfter.y).toBeCloseTo(200); + }); + + it("drags a segment by its endpoints", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.clickGlyphLocal(180, 200); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const [first, second] = layer.contours[0]?.points ?? []; + if (!first || !second) throw new Error("Expected line segment points"); + + const beforeFirst = editor.pointPosition(first.id); + const beforeSecond = editor.pointPosition(second.id); + const midpoint = { + x: (beforeFirst.x + beforeSecond.x) / 2, + y: (beforeFirst.y + beforeSecond.y) / 2, + }; + + editor.selectTool("select"); + const drag = editor.dragScene({ + down: midpoint, + start: { x: midpoint.x + 4, y: midpoint.y }, + end: { x: midpoint.x + 30, y: midpoint.y + 20 }, + }); + await editor.settle(); + + const afterFirst = editor.pointPosition(first.id); + const afterSecond = editor.pointPosition(second.id); + + expect(afterFirst.x).toBeCloseTo(beforeFirst.x + drag.delta.x); + expect(afterFirst.y).toBeCloseTo(beforeFirst.y + drag.delta.y); + expect(afterSecond.x).toBeCloseTo(beforeSecond.x + drag.delta.x); + expect(afterSecond.y).toBeCloseTo(beforeSecond.y + drag.delta.y); + }); + + it("duplicates the current selection at the same position", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 100); + await editor.settle(); + editor.clickGlyphLocal(200, 100); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const [first, second] = layer.contours[0]?.points ?? []; + if (!first || !second) throw new Error("Expected selected points"); + + editor.selection.select([first.id, second.id]); + editor.selectTool("select"); + + const duplicated = editor.duplicateSelection(); + await editor.settle(); + + expect(layer.allPoints).toHaveLength(4); + expect(duplicated).toHaveLength(2); + + const [duplicatedFirst, duplicatedSecond] = duplicated; + if (!duplicatedFirst || !duplicatedSecond) throw new Error("Expected duplicated points"); + + expect(editor.pointPosition(duplicatedFirst)).toEqual({ x: first.x, y: first.y }); + expect(editor.pointPosition(duplicatedSecond)).toEqual({ x: second.x, y: second.y }); + }); + + it("cuts the selected points to the clipboard", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 100); + await editor.settle(); + editor.clickGlyphLocal(200, 100); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const pointIds = layer.allPoints.map((point) => point.id); + editor.selection.select(pointIds); + + const cut = await editor.cut(); + await editor.settle(); + + expect(cut).toBe(true); + expect(layer.allPoints).toHaveLength(0); + expect(editor.selection.hasSelection()).toBe(false); + expect(editor.clipboardBuffer).toContain("shift/glyph-data"); + }); + + it("upgrades a line segment to a cubic with alt-click", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.clickGlyphLocal(190, 230); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + expect(layer.contours[0]?.segments()[0]?.type).toBe("line"); + + editor.selectTool("select"); + editor.clickGlyphLocal(130, 210, { altKey: true }); + await editor.settle(); + + expect(layer.contours[0]?.segments()[0]?.type).toBe("cubic"); + expect(layer.allPoints).toHaveLength(4); + }); + + it("bends a cubic segment with meta-drag", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.clickGlyphLocal(190, 230); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const segment = layer.contours[0]?.segments()[0]; + if (!segment) throw new Error("Expected line segment"); + expect(layer.upgradeLineToCubic(segment.id)).toBe(true); + await editor.settle(); + + const cubic = layer.contours[0]?.segments()[0]?.asCubic(); + if (!cubic) throw new Error("Expected cubic segment"); + + const beforeControlStart = editor.pointPosition(cubic.controlStart.id); + const beforeControlEnd = editor.pointPosition(cubic.controlEnd.id); + const bendPoint = layer.contours[0]?.segments()[0]?.pointAt(0.5); + if (!bendPoint) throw new Error("Expected cubic bend point"); + + editor.selectTool("select"); + editor.dragScene({ + down: bendPoint, + start: { x: bendPoint.x + 4, y: bendPoint.y }, + end: { x: bendPoint.x + 4, y: bendPoint.y + 40 }, + options: { metaKey: true }, + }); + await editor.settle(); + + const afterControlStart = editor.pointPosition(cubic.controlStart.id); + const afterControlEnd = editor.pointPosition(cubic.controlEnd.id); + + expect(afterControlStart.y).toBeGreaterThan(beforeControlStart.y); + expect(afterControlEnd.y).toBeGreaterThan(beforeControlEnd.y); + }); + + it("toggles a point smooth with double-click", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const point = layer.allPoints[0]; + if (!point) throw new Error("Expected point"); + + editor.selectTool("select"); + editor.clickGlyphLocal(point.x, point.y); + editor.clickGlyphLocal(point.x, point.y); + await editor.settle(); + + expect(layer.point(point.id)?.smooth).toBe(true); + }); + + it("marquee-selects points inside the brushed rectangle", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.clickGlyphLocal(180, 200); + await editor.settle(); + + const layer = editor.requireGlyphLayer(); + const [inside, outside] = layer.contours[0]?.points ?? []; + if (!inside || !outside) throw new Error("Expected line segment points"); + + editor.selectTool("select"); + editor.dragScene({ + down: { x: 80, y: 180 }, + start: { x: 84, y: 180 }, + end: { x: 130, y: 230 }, + }); + + expect(editor.selection.has(inside.id)).toBe(true); + expect(editor.selection.has(outside.id)).toBe(false); }); }); }); 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..7dd4f112 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Select.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Select.ts @@ -17,11 +17,9 @@ import { } from "./behaviors"; import { TextRunHover } from "./behaviors/TextRunHover"; import type { CursorType } from "@/types/editor"; -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,13 +27,11 @@ 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(), new SegmentDoubleClick(), new TextRunHover(), - new TextRunEdit(), new UpgradeSegment(), new Selection(), new Nudge(), @@ -62,7 +58,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 +88,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..0bf1cb49 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,37 +1,28 @@ 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"; +import { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; +import { objectIsKindOf } from "@/types"; export class BendCurve implements SelectBehavior { #draft: GlyphLayerEditDraft | null = null; #hasChanges = false; - onDragStart( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"dragStart">, - ): boolean { + onDragStart(state: SelectState, ctx: ToolContext, event: DragStartEvent): boolean { if (state.type !== "ready" || !event.metaKey) return false; + if (event.target.kind !== "segment") return false; - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; + const object = ctx.editor.object(event.target.id); + if (!objectIsKindOf(object, "segment")) 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(); + const segment = object.layer.segment(object.segmentId); + const cubic = segment?.asCubic(); if (!cubic) return false; const { controlStart, controlEnd } = cubic; - this.#draft = ctx.editor.beginGlyphLayerEditDraft({ + this.#draft = new GlyphLayerEditDraft(object.layer, { points: [controlStart.id, controlEnd.id], }); this.#hasChanges = false; @@ -39,9 +30,9 @@ export class BendCurve implements SelectBehavior { ctx.setState({ type: "bending", bend: { - t, - startPos: closestPoint, - segmentId, + t: event.target.t, + startPos: event.target.closestPoint, + segmentId: object.segmentId, controlOneId: controlStart.id, controlTwoId: controlEnd.id, initialControlOne: controlStart, @@ -51,12 +42,15 @@ export class BendCurve implements SelectBehavior { return true; } - 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 object = ctx.editor.object(state.bend.segmentId); + if (!objectIsKindOf(object, "segment")) return false; + + const point = ctx.editor.getPointInNodeSpace(event.coords.scene, object.node.position); + const delta = Vec2.sub(point, state.bend.startPos); const t = state.bend.t; const w1 = 3 * (1 - t) ** 2 * t; const w2 = 3 * (1 - t) * t ** 2; @@ -69,21 +63,20 @@ export class BendCurve implements SelectBehavior { const newCp1 = Vec2.add(initialControlOne, delta1); const newCp2 = Vec2.add(initialControlTwo, delta2); - const updates = [ + this.#draft.previewPositionPatch([ { - kind: "point" as const, + kind: "point", id: state.bend.controlOneId, x: newCp1.x, y: newCp1.y, }, { - kind: "point" as const, + kind: "point", id: state.bend.controlTwoId, x: newCp2.x, y: newCp2.y, }, - ]; - this.#draft.previewPositionPatch(updates); + ]); this.#hasChanges = true; 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..35a9b275 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,51 +1,46 @@ -import { Rect, type Rect2D } from "@shift/geo"; +import { Rect, Vec2, 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 { DragEndEvent, 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.origin.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 }))); + const rect = Rect.fromPoints(state.selection.startPos, event.coords.scene); + this.selectPointsInRect(rect, ctx); ctx.setState({ type: "brushing", - selection: { ...state.selection, currentPos: localPoint }, + selection: { ...state.selection, currentPos: event.coords.scene }, }); return true; } - onDragEnd(state: SelectState, ctx: ToolContext): boolean { + onDragEnd(state: SelectState, ctx: ToolContext, event: DragEndEvent): boolean { if (state.type !== "brushing") return false; - 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 }))); + const rect = Rect.fromPoints(state.selection.startPos, event.coords.scene); + this.selectPointsInRect(rect, ctx); ctx.setState({ type: "ready" }); return true; @@ -56,15 +51,29 @@ export class Marquee implements SelectBehavior { ctx.editor.selection.clear(); ctx.setState({ type: "ready" }); - return true; } private getPointsInRect(rect: Rect2D, ctx: ToolContext): Set { - const instance = ctx.editor.previewGlyphInstance; - if (!instance) return new Set(); + const pointIds = new Set(); + + for (const node of ctx.editor.scene.nodesOfKind("glyph")) { + const instance = ctx.editor.font.instance(node.glyphId, ctx.editor.designLocationCell); + if (!instance) continue; + + for (const point of instance.geometry.allPoints) { + const scenePoint = Vec2.add(point, node.position); + if (!Rect.containsPoint(rect, scenePoint)) continue; - const hitPoints = instance.geometry.allPoints.filter((p) => Rect.containsPoint(rect, p)); - return new Set(hitPoints.map((p) => p.id)); + pointIds.add(point.id); + } + } + + return pointIds; + } + + private selectPointsInRect(rect: Rect2D, ctx: ToolContext): void { + const pointIds = this.getPointsInRect(rect, ctx); + ctx.editor.selection.select([...pointIds]); } } 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..7c994f73 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,17 @@ 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"; +import { selectedGeometryEdit } from "./selectedGeometryEdit"; 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 edit = selectedGeometryEdit(ctx.editor); + if (!edit || edit.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; @@ -37,7 +34,7 @@ export class Nudge implements SelectBehavior { return false; } - ctx.editor.nudgePoints(pointIds, dx, dy); + edit.layer.movePoints(edit.pointIds, { x: dx, y: dy }); return true; } } 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..de80fff1 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,47 +1,48 @@ 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"; +import type { BoundingRectEdge } from "../cursor"; +import { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; +import { pointInSelectedNodeSpace, selectedGeometryEdit } from "./selectedGeometryEdit"; export class Resize implements SelectBehavior { #draft: GlyphLayerEditDraft | null = null; #origin: Point2D | null = null; + #nodePosition: 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 hit = ctx.tool.boundingBox.hit(event.origin); + if (hit?.type !== "resize") return false; - const bbHit = ctx.tool.boundingBox.hit(event.coords); - if (!bbHit) return false; + const edit = selectedGeometryEdit(ctx.editor); + if (!edit) return false; - if (bbHit.type !== "resize") return false; - const bounds = bbHit.rect; - - const edge = bbHit.edge; - const localPoint = event.coords.glyphLocal; + const bounds = rectInNodeSpace(hit.rect, edit.node.position); + const edge = hit.edge; + const startPos = pointInSelectedNodeSpace(event.origin.scene, edit); const anchorPoint = this.getAnchorPointForEdge(edge, bounds); - this.#draft = editor.beginGlyphLayerEditDraft({ - points: [...editor.selection.pointIds], - anchors: [...editor.selection.anchorIds], + this.#draft = new GlyphLayerEditDraft(edit.layer, { + points: edit.pointIds, + anchors: edit.anchorIds, }); this.#origin = anchorPoint; + this.#nodePosition = { ...edit.node.position }; ctx.setState({ type: "resizing", resize: { edge, - startPos: localPoint, - lastPos: localPoint, + startPos, + lastPos: startPos, initialBounds: bounds, anchorPoint, uniformScale: false, @@ -51,13 +52,9 @@ export class Resize implements SelectBehavior { return true; } - onDrag( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"drag">, - ): boolean { + onDrag(state: SelectState, ctx: ToolContext, event: DragEvent): boolean { if (state.type !== "resizing") return false; - if (!this.#draft || !this.#origin) return false; + if (!this.#draft || !this.#origin || !this.#nodePosition) return false; const next = this.nextResizingState(state, event); ctx.setState(next); @@ -94,13 +91,15 @@ export class Resize implements SelectBehavior { #cleanup(): void { this.#draft = null; this.#origin = null; + this.#nodePosition = null; } - private nextResizingState(state: SelectState, event: ToolEventOf<"drag">): SelectState { + private nextResizingState(state: SelectState, event: DragEvent): SelectState { if (state.type !== "resizing") return state; + if (!this.#nodePosition || !this.#origin) return state; const uniformScale = event.shiftKey; - const currentPos = event.coords.glyphLocal; + const currentPos = Vec2.sub(event.coords.scene, this.#nodePosition); const { sx, sy } = this.calculateScaleFactors( state.resize.edge, currentPos, @@ -109,7 +108,7 @@ export class Resize implements SelectBehavior { uniformScale, ); - this.#draft!.previewScale(sx, sy, this.#origin!); + this.#draft!.previewScale(sx, sy, this.#origin); return { type: "resizing", @@ -168,12 +167,8 @@ export class Resize implements SelectBehavior { 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 (affectsX) sx = newWidth / initialWidth; + if (affectsY) sy = newHeight / initialHeight; if (uniform && isCorner) { const uniformScale = Math.max(sx, sy); @@ -206,3 +201,19 @@ export class Resize implements SelectBehavior { return { sx, sy }; } } + +function rectInNodeSpace(rect: Rect2D, nodePosition: Point2D): Rect2D { + const x = rect.x - nodePosition.x; + const y = rect.y - nodePosition.y; + + return { + x, + y, + width: rect.width, + height: rect.height, + left: rect.left - nodePosition.x, + top: rect.top - nodePosition.y, + right: rect.right - nodePosition.x, + bottom: rect.bottom - nodePosition.y, + }; +} 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..c7320fd5 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,19 +1,21 @@ import { Vec2, 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 { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; import type { Select } from "../Select"; +import { pointInSelectedNodeSpace, selectedGeometryEdit } from "./selectedGeometryEdit"; export class Rotate implements SelectBehavior { #draft: GlyphLayerEditDraft | null = null; #origin: Point2D | null = null; + #nodePosition: Point2D | null = null; onDragStart( _state: SelectState, ctx: ToolContext, - event: ToolEventOf<"dragStart">, + event: DragStartEvent, ): boolean { if (!ctx.editor.selection.hasSelection()) return false; @@ -24,13 +26,9 @@ 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; + if (!this.#draft || !this.#origin || !this.#nodePosition) return false; const next = this.nextRotatingState(state, event); ctx.setState(next); @@ -74,19 +72,21 @@ export class Rotate implements SelectBehavior { #cleanup(): void { this.#draft = null; this.#origin = null; + this.#nodePosition = null; } private nextRotatingState( state: SelectState & { type: "rotating" }, - event: ToolEventOf<"drag">, + event: DragEvent, ): SelectState & { type: "rotating" } { - const currentPos = event.coords.glyphLocal; + if (!this.#nodePosition || !this.#origin) return state; + + const currentPos = Vec2.sub(event.coords.scene, this.#nodePosition); 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!); + this.#draft!.previewRotate(deltaAngle, this.#origin); return { type: "rotating", @@ -98,33 +98,24 @@ export class Rotate implements SelectBehavior { }; } - 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; + private tryStartRotate(event: DragStartEvent, editor: Editor, tool: Select): SelectState | null { + const hit = tool.boundingBox.hit(event.origin); + if (hit?.type !== "rotate") return null; - if (bbHit.type !== "rotate") return null; - - const corner = bbHit.corner; - - const localPoint = event.coords.glyphLocal; - - const center = bbHit.center; + const edit = selectedGeometryEdit(editor); + if (!edit) return null; + const corner = hit.corner; + const localPoint = pointInSelectedNodeSpace(event.origin.scene, edit); + const center = pointInSelectedNodeSpace(hit.center, edit); const startAngle = Vec2.angleTo(center, localPoint); - this.#draft = editor.beginGlyphLayerEditDraft({ - points: [...editor.selection.pointIds], - anchors: [...editor.selection.anchorIds], + this.#draft = new GlyphLayerEditDraft(edit.layer, { + points: edit.pointIds, + anchors: edit.anchorIds, }); - this.#origin = center; + this.#nodePosition = { ...edit.node.position }; return { type: "rotating", 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..87f0538a 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,33 @@ 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 node = ctx.editor.scene.node(event.target.nodeId); + if (node?.kind !== "glyph") 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(node.glyphId, node.sourceId); + 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([contourId, ...contour.points.map((point) => 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..5c75ed1d 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,15 +1,13 @@ 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; - if ( state.type === "brushing" || state.type === "translating" || @@ -21,39 +19,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 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(target.id); + return true; + } + + case "anchor": { + ctx.editor.hover.set(target.id); + return true; + } + + case "segment": { + ctx.editor.hover.set(target.id); + return true; + } } - - 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; - } - - ctx.editor.hover.clear(); - return false; } } 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..f1439f5e 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,46 +1,38 @@ -import type { Selectable } 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"; +import type { SelectableId } 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 ids: SelectableId[] | 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 }]; - } - - if (!items) { - const pointHit = geometry.hitPoint(pos, radius); - if (pointHit) { - items = [{ kind: "point", id: pointHit.pointId }]; + switch (target.kind) { + case "point": { + ids = [target.id]; + break; } - } - if (!items) { - const segmentHit = geometry.hitSegment(pos, radius); - const segment = segmentHit ? geometry.segment(segmentHit.segmentId) : null; + case "anchor": { + ids = [target.id]; + break; + } - if (segmentHit && segment) { - items = [ - { kind: "segment", id: segmentHit.segmentId }, - ...segment.pointIds.map((id) => ({ kind: "point" as const, id })), - ]; + case "segment": { + ids = [target.id]; + break; } + + case "canvas": + case "node": + break; } - if (!items) { + if (!ids) { if (event.shiftKey || !editor.selection.hasSelection()) return false; editor.selection.clear(); @@ -49,17 +41,17 @@ export class Selection implements SelectBehavior { } if (event.shiftKey) { - const selected = items.every((item) => editor.selection.isSelected(item)); + const selected = ids.every((id) => editor.selection.has(id)); - for (const item of items) { + for (const id of ids) { if (selected) { - editor.selection.remove(item); + editor.selection.remove(id); } else { - editor.selection.add(item); + editor.selection.add(id); } } } else { - editor.selection.select(items); + editor.selection.select(ids); } ctx.setState({ type: "ready" }); 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 deleted file mode 100644 index d6e80f9d..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/TextRunEdit.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ToolEventOf } from "../../core/GestureDetector"; -import type { ToolContext } from "../../core/Behavior"; -import type { SelectBehavior, SelectState } from "../types"; - -/** - * Double-click on a text run item to enter in-place editing for that glyph. - * - * Resolves the click to a stable text-item anchor and lets Editor derive the - * active glyph placement from the current layout. Linebreak items are not editable. - */ -export class TextRunEdit implements SelectBehavior { - onDoubleClick( - state: SelectState, - ctx: ToolContext, - event: ToolEventOf<"doubleClick">, - ): boolean { - 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; - } -} 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..3efca2ca 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,25 @@ +import { Validate } from "@shift/validation"; 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"; +import { objectIsKindOf } from "@/types"; export class ToggleSmooth implements SelectBehavior { onDoubleClick( state: SelectState, ctx: ToolContext, - event: ToolEventOf<"doubleClick">, + event: DoubleClickEvent, ): boolean { - if (state.type !== "ready" && ctx.editor.selection.hasSelection()) return false; - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; + if (state.type !== "ready") return false; + if (event.target.kind !== "point") return false; - const geometry = instance.geometry; - const hit = geometry.hitPoint(event.coords.glyphLocal, ctx.editor.hitRadius); - if (!hit) return false; + const object = ctx.editor.object(event.target.id); + if (!objectIsKindOf(object, "point")) return false; - const pointId = hit.pointId; - const point = geometry.point(hit.pointId); + const point = object.layer.point(object.pointId); if (!point || !Validate.isOnCurve(point)) return false; - ctx.editor.commands.run(new ToggleSmoothCommand(pointId)); + object.layer.toggleSmooth(object.pointId); return true; } } 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..5436b108 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,46 +1,40 @@ -import { Bounds, Vec2, type Point2D } from "@shift/geo"; +import { Rect, 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 { GlyphAnchorTarget, GlyphPointTarget, GlyphSegmentTarget } from "@/types/target"; +import type { ShiftId } from "@/types"; +import { selectedGeometryEdit } from "./selectedGeometryEdit"; import { constrainPreparedDrag, prepareConstrainedDrag, + type ConstrainDragGlyph, type PreparedConstrainDrag, } from "@shift/rules"; -import type { - GlyphInstanceGeometry, - GlyphLayerPositionTarget, - GlyphLayerPositions, -} from "@/lib/model/Glyph"; -import type { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; +import type { GlyphLayer, GlyphLayerPositionTarget, GlyphLayerPositions } from "@/lib/model/Glyph"; +import { GlyphLayerEditDraft } from "@/lib/editor/GlyphLayerEditDraft"; 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; + const operation = TranslateOperation.fromDragStart(ctx.editor, event); + if (!operation) return false; - target.applySelection(ctx.editor); - this.#drag = new TranslateDrag(ctx.editor, target, event.coords.glyphLocal); + operation.applySelection(ctx.editor); + this.#drag = new TranslateDrag(operation, 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; @@ -52,12 +46,15 @@ export class Translate implements SelectBehavior { onDragEnd(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "translating") return false; this.#drag?.commit(); + this.#drag = null; ctx.setState({ type: "ready" }); return true; } onDragCancel(state: SelectState, ctx: ToolContext): boolean { if (state.type !== "translating") return false; + this.#drag?.discard(); + this.#drag = null; ctx.setState({ type: "ready" }); return true; } @@ -69,8 +66,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); @@ -85,42 +82,52 @@ export class Translate implements SelectBehavior { } } -class TranslateTarget { +/** + * A resolved glyph-layer move: the subject point/anchor ids together with the + * single authored layer that owns all of them. + * + * Construction is where edit scoping happens. Builders resolve identity-only + * hit and selection ids through font ownership queries and refuse to produce + * an operation when the ids span layers, are absent from current layer state, + * or the resolved layer is not the one displayed at the current design location. Node + * translation is a separate future operation kind; node targets never + * produce one of these. + */ +class TranslateOperation { + readonly layer: GlyphLayer; readonly pointIds: readonly PointId[]; readonly anchorIds: readonly AnchorId[]; - readonly selection: readonly Selectable[] | null; + readonly selection: readonly ShiftId[] | null; readonly dragAnchor: GlyphLayerPositionTarget | null; private constructor( + layer: GlyphLayer, pointIds: readonly PointId[], anchorIds: readonly AnchorId[], - selection: readonly Selectable[] | null, + selection: readonly ShiftId[] | null, dragAnchor: GlyphLayerPositionTarget | null, ) { + this.layer = layer; this.pointIds = [...pointIds]; this.anchorIds = [...anchorIds]; this.selection = selection ? [...selection] : null; 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): TranslateOperation | null { + const target = event.target; + + switch (target.kind) { + case "point": + return TranslateOperation.fromPointTarget(editor, event, target); + case "anchor": + return TranslateOperation.fromAnchorTarget(editor, target); + case "segment": + return TranslateOperation.fromSegmentTarget(editor, event, target); + case "node": + case "canvas": + return TranslateOperation.fromInsideSelectionBounds(editor, event); + } } applySelection(editor: Editor): void { @@ -128,130 +135,130 @@ class TranslateTarget { editor.selection.select(this.selection); } - static fromAnchorHit(editor: Editor, hit: GeometryAnchorHit | null): TranslateTarget | null { - if (!hit) return null; + static fromPointTarget( + editor: Editor, + event: DragStartEvent, + target: GlyphPointTarget, + ): TranslateOperation | null { + if (event.altKey) { + return TranslateOperation.fromDuplicatedSelection(editor); + } - const selected = editor.selection.isSelected({ - kind: "anchor", - id: hit.anchorId, - }); + const selected = editor.selection.isSelected(target.id); if (selected) { - return TranslateTarget.fromSelection(editor, { - kind: "anchor", - id: hit.anchorId, + return TranslateOperation.fromSelection(editor, { + kind: "point", + id: target.id, }); } - return new TranslateTarget([], [hit.anchorId], [{ kind: "anchor", id: hit.anchorId }], { - kind: "anchor", - id: hit.anchorId, + return TranslateOperation.#resolve(editor, [target.id], [], [target.id], { + kind: "point", + id: target.id, }); } - static fromPointHit( - editor: Editor, - event: ToolEventOf<"dragStart">, - hit: GeometryPointHit | null, - ): TranslateTarget | null { - if (!hit) return null; - - if (event.altKey) { - return TranslateTarget.fromDuplicatedSelection(editor); - } - - const selected = editor.selection.isSelected({ - kind: "point", - id: hit.pointId, - }); + static fromAnchorTarget(editor: Editor, target: GlyphAnchorTarget): TranslateOperation | null { + const selected = editor.selection.isSelected(target.id); if (selected) { - return TranslateTarget.fromSelection(editor, { - kind: "point", - id: hit.pointId, + return TranslateOperation.fromSelection(editor, { + kind: "anchor", + id: target.id, }); } - return new TranslateTarget([hit.pointId], [], [{ kind: "point", id: hit.pointId }], { - kind: "point", - id: hit.pointId, + return TranslateOperation.#resolve(editor, [], [target.id], [target.id], { + kind: "anchor", + id: target.id, }); } - static fromSegmentHit( + static fromSegmentTarget( editor: Editor, - geometry: GlyphInstanceGeometry, - event: ToolEventOf<"dragStart">, - hit: GeometrySegmentHit | null, - ): TranslateTarget | null { - if (!hit) return null; - - const segment = geometry.segment(hit.segmentId); - if (!segment) return null; - - const segmentPointIds = segment.pointIds; + event: DragStartEvent, + target: GlyphSegmentTarget, + ): TranslateOperation | null { + const segmentPointIds = target.pointIds; if (segmentPointIds.length === 0) return null; if (event.altKey) { - return TranslateTarget.fromDuplicatedSelection(editor); + return TranslateOperation.fromDuplicatedSelection(editor); } - const selected = editor.selection.isSelected({ - kind: "segment", - id: hit.segmentId, - }); + const selected = editor.selection.isSelected(target.id); if (selected) { - return TranslateTarget.fromSelection(editor); + return TranslateOperation.fromSelection(editor); } - const pointIds = segmentPointIds.map((id) => ({ - kind: "point" as const, - id, - })); - return new TranslateTarget( + return TranslateOperation.#resolve( + editor, segmentPointIds, [], - [{ kind: "segment", id: hit.segmentId }, ...pointIds], - { kind: "point", id: segmentPointIds[0] }, + [target.id, ...segmentPointIds], + { 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); - } - - static fromDuplicatedSelection(editor: Editor): TranslateTarget | null { + static fromDuplicatedSelection(editor: Editor): TranslateOperation | null { const pointIds = editor.duplicateSelection(); if (pointIds.length === 0) return null; - return new TranslateTarget( - pointIds, - [], - pointIds.map((id) => ({ kind: "point" as const, id })), - { kind: "point", id: pointIds[0] }, - ); + return TranslateOperation.#resolve(editor, pointIds, [], pointIds, { + kind: "point", + id: pointIds[0]!, + }); } static fromSelection( editor: Editor, dragAnchor: GlyphLayerPositionTarget | null = null, - ): TranslateTarget { - return new TranslateTarget( - [...editor.selection.pointIds], - [...editor.selection.anchorIds], - null, - dragAnchor, - ); + ): TranslateOperation | null { + const edit = selectedGeometryEdit(editor); + if (!edit || !isDisplayedLayer(editor, edit.layer)) return null; + + return new TranslateOperation(edit.layer, edit.pointIds, edit.anchorIds, null, dragAnchor); + } + + static fromInsideSelectionBounds( + editor: Editor, + event: DragStartEvent, + ): TranslateOperation | null { + const bounds = editor.selectionBounds(); + if (!bounds) return null; + + if (!Rect.containsPoint(bounds, event.origin.scene)) return null; + + return TranslateOperation.fromSelection(editor); + } + + static #resolve( + editor: Editor, + pointIds: readonly PointId[], + anchorIds: readonly AnchorId[], + selection: readonly ShiftId[], + dragAnchor: GlyphLayerPositionTarget | null, + ): TranslateOperation | null { + const layer = editor.font.layerForGeometry({ points: pointIds, anchors: anchorIds }); + if (!layer || !isDisplayedLayer(editor, layer)) return null; + + return new TranslateOperation(layer, pointIds, anchorIds, selection, dragAnchor); } } +/** + * Interpolated views hit-test against geometry whose ids come from an + * authored layer's structure, so ownership alone would let a drag silently + * edit that layer while the user looks at an interpolation. A move may only + * start when the resolved layer is authored at the active source. + */ +function isDisplayedLayer(editor: Editor, layer: GlyphLayer): boolean { + const sourceId = editor.activeSourceId; + return sourceId !== null && layer.sourceId === sourceId; +} + function translatingState(startPos: Point2D): TranslatingState { return { type: "translating", @@ -264,24 +271,24 @@ function translatingState(startPos: Point2D): TranslatingState { } class TranslateDrag { - readonly #target: TranslateTarget; + readonly #operation: TranslateOperation; readonly #draft: GlyphLayerEditDraft; readonly #constraint: ConstrainedTranslate | null; readonly #pointerOffset: Point2D; readonly startPos: Point2D; - constructor(editor: Editor, target: TranslateTarget, pointerStart: Point2D) { - this.#target = target; - const instance = editor.previewGlyphInstance; + constructor(operation: TranslateOperation, pointerStart: Point2D) { + this.#operation = operation; - this.#draft = editor.beginGlyphLayerEditDraft({ - points: target.pointIds, - anchors: target.anchorIds, + this.#draft = new GlyphLayerEditDraft(operation.layer, { + points: operation.pointIds, + anchors: operation.anchorIds, }); - this.#constraint = instance - ? ConstrainedTranslate.fromGeometry(instance.geometry, target.pointIds) - : null; + this.#constraint = ConstrainedTranslate.fromGeometry( + operation.layer.geometry, + operation.pointIds, + ); this.startPos = pointerStart; this.#pointerOffset = Vec2.sub(pointerStart, this.startPos); @@ -298,7 +305,7 @@ class TranslateDrag { } this.#draft.previewPositionPatch( - this.#constraint.positionsFor(this.#draft.basePositions, this.#target, delta), + this.#constraint.positionsFor(this.#draft.basePositions, this.#operation.anchorIds, delta), ); } @@ -319,7 +326,7 @@ class ConstrainedTranslate { } static fromGeometry( - geometry: GlyphInstanceGeometry, + geometry: ConstrainDragGlyph, pointIds: readonly PointId[], ): ConstrainedTranslate | null { if (pointIds.length === 0) return null; @@ -330,7 +337,7 @@ class ConstrainedTranslate { positionsFor( base: GlyphLayerPositions, - target: TranslateTarget, + anchorIds: readonly AnchorId[], delta: Point2D, ): GlyphLayerPositions { const updates: GlyphLayerPositions[number][] = []; @@ -342,9 +349,9 @@ class ConstrainedTranslate { updates.push({ kind: "point", id: update.id, x: update.x, y: update.y }); } - const anchorIds = new Set(target.anchorIds); + const anchors = new Set(anchorIds); for (const position of base) { - if (position.kind !== "anchor" || !anchorIds.has(position.id)) continue; + if (position.kind !== "anchor" || !anchors.has(position.id)) continue; const next = Vec2.add(position, delta); updates.push({ ...position, x: next.x, y: next.y }); } 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..0d942485 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,16 @@ -import type { ToolEventOf } from "../../core/GestureDetector"; +import type { ClickEvent } from "../../core/GestureDetector"; import type { ToolContext } from "../../core/Behavior"; import type { SelectBehavior, SelectState } from "../types"; +import { objectIsKindOf } from "@/types"; export class UpgradeSegment implements SelectBehavior { - onClick(state: SelectState, ctx: ToolContext, event: ToolEventOf<"click">): boolean { + onClick(state: SelectState, ctx: ToolContext, event: ClickEvent): boolean { if (state.type !== "ready" || !event.altKey) return false; + if (event.target.kind !== "segment") return false; - const instance = ctx.editor.previewGlyphInstance; - if (!instance || !ctx.editor.editingGlyphLayer) return false; + const object = ctx.editor.object(event.target.id); + if (!objectIsKindOf(object, "segment")) 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 object.layer.upgradeLineToCubic(object.segmentId); } } diff --git a/apps/desktop/src/renderer/src/lib/tools/select/behaviors/selectedGeometryEdit.ts b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/selectedGeometryEdit.ts new file mode 100644 index 00000000..52b0899d --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/select/behaviors/selectedGeometryEdit.ts @@ -0,0 +1,79 @@ +import type { Point2D } from "@shift/geo"; +import type { AnchorId, PointId } from "@shift/types"; +import type { Editor } from "@/lib/editor/Editor"; +import type { GlyphLayer } from "@/lib/model/Glyph"; +import type { GlyphNode } from "@/types/node"; + +export interface SelectedGeometryEdit { + readonly layer: GlyphLayer; + readonly node: GlyphNode; + readonly pointIds: readonly PointId[]; + readonly anchorIds: readonly AnchorId[]; +} + +export function selectedGeometryEdit(editor: Editor): SelectedGeometryEdit | null { + const objects = editor.objects(editor.selection.ids); + if (objects.length !== editor.selection.ids.length) return null; + + let layer: GlyphLayer | null = null; + let node: GlyphNode | null = null; + const pointIds = new Set(); + const anchorIds = new Set(); + + const useGlyphContext = (nextLayer: GlyphLayer, nextNode: GlyphNode): boolean => { + if (nextLayer.sourceId !== editor.activeSourceId) return false; + if (layer && layer.id !== nextLayer.id) return false; + if (node && node.id !== nextNode.id) return false; + + layer = nextLayer; + node = nextNode; + return true; + }; + + for (const object of objects) { + switch (object.kind) { + case "point": + if (!useGlyphContext(object.layer, object.node)) return null; + pointIds.add(object.pointId); + break; + + case "anchor": + if (!useGlyphContext(object.layer, object.node)) return null; + anchorIds.add(object.anchorId); + break; + + case "segment": + if (!useGlyphContext(object.layer, object.node)) return null; + for (const pointId of object.pointIds) pointIds.add(pointId); + break; + + case "contour": { + if (!useGlyphContext(object.layer, object.node)) return null; + const contour = object.layer.contour(object.contourId); + if (!contour) return null; + + for (const point of contour.points) pointIds.add(point.id); + break; + } + + case "node": + return null; + } + } + + if (!layer || !node) return null; + + return { + layer, + node, + pointIds: [...pointIds], + anchorIds: [...anchorIds], + }; +} + +export function pointInSelectedNodeSpace(point: Point2D, edit: SelectedGeometryEdit): Point2D { + return { + x: point.x - edit.node.position.x, + y: point.y - edit.node.position.y, + }; +} 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/Shape.ts b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts index 675f8c3f..a1ffae35 100644 --- a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts +++ b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.ts @@ -1,9 +1,9 @@ -import type { Point2D, Rect2D } from "@shift/geo"; +import { Vec2, type Point2D, type Rect2D } from "@shift/geo"; +import { Point } from "@shift/glyph-state"; import { BaseTool, type ToolName, type ToolEvent, defineStateDiagram } from "../core"; import type { ShapeState } from "./types"; import { ShapeReadyBehavior, ShapeDraggingBehavior } from "./behaviors"; import type { Canvas } from "@/lib/editor/rendering/Canvas"; -import { DrawRectangleCommand } from "@/lib/commands/primitives"; import { CursorType } from "@/types/editor"; export class Shape extends BaseTool { @@ -30,7 +30,7 @@ export class Shape extends BaseTool { protected override onStateChange(prev: ShapeState, next: ShapeState, event: ToolEvent): void { if (prev.type === "dragging" && next.type === "ready") { if (event.type === "dragEnd") { - this.commitRectangle(prev); + this.commitShape(prev); } } } @@ -81,10 +81,32 @@ export class Shape extends BaseTool { }; } - private commitRectangle(state: { startPos: Point2D; currentPos: Point2D }): void { + private commitShape(state: { startPos: Point2D; currentPos: Point2D }): void { const rect = this.getRect(state); if (Math.abs(rect.width) < 3 || Math.abs(rect.height) < 3) return; - this.editor.commands.run(new DrawRectangleCommand(rect)); + const glyphNodes = this.editor.scene.nodesOfKind("glyph"); + if (glyphNodes.length !== 1) return; + + const [node] = glyphNodes; + if (!node) return; + + const layer = this.editor.font.layer(node.glyphId, node.sourceId); + if (!layer) return; + + this.editor.transaction("Draw rectangle", () => { + const contourId = layer.addContour(); + const origin = Vec2.create(rect.x, rect.y); + const topLeft = origin; + const topRight = Vec2.add(origin, Vec2.create(rect.width, 0)); + const bottomRight = Vec2.add(origin, Vec2.create(rect.width, rect.height)); + const bottomLeft = Vec2.add(origin, Vec2.create(0, rect.height)); + + layer.addPoint(contourId, Point.onCurve(topLeft)); + layer.addPoint(contourId, Point.onCurve(topRight)); + layer.addPoint(contourId, Point.onCurve(bottomRight)); + layer.addPoint(contourId, Point.onCurve(bottomLeft)); + layer.closeContour(contourId); + }); } } 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/transform/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md index 9ea7831b..44f03381 100644 --- a/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/transform/docs/DOCS.md @@ -4,7 +4,7 @@ Pure geometry transformation system for rotating, scaling, reflecting, aligning, ## Architecture Invariants -- **Architecture Invariant:** All functions in `Transform` are pure -- they return new arrays and never mutate input points. Commands are the only path that writes back to the glyph. +- **Architecture Invariant:** All functions in `Transform` are pure -- they return new arrays and never mutate input points. Glyph writes happen through `GlyphLayer` methods. - **Architecture Invariant:** Every geometric transform goes through `Transform.applyMatrix` internally. The three high-level functions (`rotatePoints`, `scalePoints`, `reflectPoints`) are thin wrappers that build a matrix and delegate. Custom transforms should follow the same pattern. - **Architecture Invariant: CRITICAL:** The composite matrix in `applyMatrix` must be assembled in the order `Translate(+origin) * Matrix * Translate(-origin)` (right-to-left application). Reversing the order silently produces wrong results around non-zero origins. - **Architecture Invariant:** `Alignment.distributePoints` requires at least 3 points. With fewer, it returns the input unchanged. The outermost points are always pinned; only interior points move. @@ -20,7 +20,7 @@ transform/ anchor.ts — Maps a 9-position anchor grid to a concrete Point2D on bounds zoomFromWheel.ts — Converts wheel deltaY into a zoom multiplier types.ts — Re-exports centralized types from @/types/transform - index.ts — Barrel; also re-exports command classes from commands/transform + index.ts — Barrel for pure transform helpers ``` ## Key Types @@ -42,12 +42,12 @@ transform/ The `matrices` namespace exposes the raw `Mat` builders (`Mat.Rotate`, `Mat.Scale`, etc.) for callers that need to compose custom transforms. -### Command layer +### Glyph layer writes -Undo/redo is handled by command classes in `commands/transform/`, re-exported through the barrel `index.ts`: - -- `RotatePointsCommand`, `ScalePointsCommand`, `ReflectPointsCommand`, `MoveSelectionToCommand` all extend `BaseTransformCommand`. The base class captures original positions on first execute; subclasses now call glyph-domain verbs (`glyph.rotate`, `glyph.scale`, etc.) instead of manually iterating point writes. -- `AlignPointsCommand` and `DistributePointsCommand` extend `BaseCommand` directly and call `Alignment.alignPoints` / `Alignment.distributePoints`. +`GlyphLayer` owns the mutating transform API: `rotate`, `scale`, `reflect`, +`moveSelectionTo`, `align`, and `distribute`. Those methods resolve point +positions, call the pure transform/alignment helpers, and commit one sparse +position patch through the workspace ledger. ### Alignment @@ -70,16 +70,15 @@ Undo/redo is handled by command classes in `commands/transform/`, re-exported th ### Add a new transform type 1. Add a pure function to `Transform` in `Transform.ts` that builds a matrix and calls `applyMatrix`. -2. Create a command class in `commands/transform/TransformCommands.ts` extending `BaseTransformCommand`; implement `transformPoints` to call the new function. -3. Re-export the command from `commands/transform/index.ts` and `transform/index.ts`. -4. Add tests in `Transform.test.ts` and `TransformCommands.test.ts`. +2. Add or update the corresponding `GlyphLayer` method if this transform should mutate glyph geometry. +3. Add pure tests in `Transform.test.ts` and layer behavior tests in `GlyphLayerGeometry.test.ts`. ### Add a new alignment mode 1. Add the new literal to `AlignmentType` in `@/types/transform`. 2. Add a `case` branch in `Alignment.alignPoints`. -3. Update `AlignPointsCommand` if it needs special bounds logic. -4. Add tests in `Alignment.test.ts`. +3. Update `GlyphLayer.align` if it needs special bounds logic. +4. Add tests in `Alignment.test.ts` and `GlyphLayerGeometry.test.ts`. ### Use a custom compound transform @@ -103,15 +102,14 @@ const result = Transform.applyMatrix(points, matrix, origin); # Unit tests for all transform files npx vitest run --reporter verbose src/renderer/src/lib/transform/ -# Command integration tests -npx vitest run --reporter verbose src/renderer/src/lib/commands/transform/ +# Layer integration tests +pnpm --filter @shift/desktop test -- src/renderer/src/lib/model/GlyphLayerGeometry.test.ts ``` ## Related -- `BaseTransformCommand`, `RotatePointsCommand`, `ScalePointsCommand`, `ReflectPointsCommand`, `MoveSelectionToCommand` -- command classes in `commands/transform` -- `AlignPointsCommand`, `DistributePointsCommand` -- alignment command classes in `commands/transform` +- `GlyphLayer` -- mutating transform API over authored glyph geometry - `Mat`, `MatModel`, `Bounds` -- matrix and bounds math from `@shift/geo` - `Segment` -- bezier segment utilities used by `getSegmentAwareBounds` -- `TransformGrid`, `TransformSection`, `ScaleSection` -- sidebar UI components that drive transforms via commands +- `TransformGrid`, `TransformSection`, `ScaleSection` -- sidebar UI components that drive transforms through `GlyphLayer` - `EditorView` -- consumes `zoomMultiplierFromWheel` for viewport zoom diff --git a/apps/desktop/src/renderer/src/lib/transform/index.ts b/apps/desktop/src/renderer/src/lib/transform/index.ts index 64f7bb2e..e39d77dc 100644 --- a/apps/desktop/src/renderer/src/lib/transform/index.ts +++ b/apps/desktop/src/renderer/src/lib/transform/index.ts @@ -9,14 +9,10 @@ * * @example * ```ts - * import { Transform, RotatePointsCommand } from '@/lib/transform'; + * import { Transform } from '@/lib/transform'; * * // Pure function usage (for preview/calculations) * const rotated = Transform.rotatePoints(points, Math.PI/2, center); - * - * // Command usage (for undo/redo) - * const cmd = new RotatePointsCommand(pointIds, Math.PI/2, center); - * commandHistory.execute(cmd); * ``` */ @@ -40,13 +36,3 @@ export { anchorToPoint } from "./anchor"; // Zoom-from-wheel (viewport zoom sensitivity) export { zoomMultiplierFromWheel, type ZoomFromWheelOptions } from "./zoomFromWheel"; - -// Commands for undo/redo (re-export from commands/transform) -export { - RotatePointsCommand, - ScalePointsCommand, - ReflectPointsCommand, - MoveSelectionToCommand, - AlignPointsCommand, - DistributePointsCommand, -} from "../commands/transform"; diff --git a/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts b/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts index 1fc8007b..9dde0900 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts @@ -24,10 +24,9 @@ type Payload = Omit; * * @remarks * This is the ONLY place wire envelopes (`{ kind, }`) are built; - * everything above it speaks typed calls. Each call enters the edit queue's - * coalescing window, so calls in the same tick become one apply and one - * undo step. No display strings ride the calls; undo labels are a concern - * for the ledger, not the renderer. + * everything above it speaks typed calls. Each call is one workspace operation + * unless the caller has opened a workspace transaction. No display strings + * ride the calls; undo labels are a concern for the ledger, not the renderer. */ export class LayerIntents { readonly #editCoordinator: WorkspaceEditCoordinator; diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts index b9d69d23..f7935dc6 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts @@ -16,7 +16,7 @@ import { signal } from "@/lib/signals/signal"; * * @remarks * `workspaceCell` is the renderer's latest workspace summary. `FontStore` - * owns the font-domain projection and glyph snapshot cache; this client only + * owns the renderer-local font model state; this client only * transports workspace calls and mirrors the summary for catch-up/recovery. */ export type WorkspaceClientOptions = { diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts index f9ec188e..94595455 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts @@ -60,10 +60,59 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => expect(client.documentStateCell.peek()).toMatchObject({ dirty: true }); }); - it("marks snapshot loads after queued workspace summary edits flush", async () => { - const { font, store, editCoordinator } = stack; + it("keeps separate pushes as separate undo entries", async () => { + const { store, editCoordinator } = stack; + + editCoordinator.push(createGlyph("A", 65)); + editCoordinator.push(createGlyph("B", 66)); + await editCoordinator.settled(); + + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(2); + + await editCoordinator.undo(); + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(1); + + await editCoordinator.undo(); + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(0); + }); + + it("groups transaction pushes into one undo entry", async () => { + const { store, editCoordinator } = stack; + + editCoordinator.transaction("Create glyph pair", () => { + editCoordinator.push(createGlyph("A", 65)); + editCoordinator.push(createGlyph("B", 66)); + }); + await editCoordinator.settled(); + + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(2); + + await editCoordinator.undo(); + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(0); + }); + + it("flattens nested transaction pushes into the outer undo entry", async () => { + const { store, editCoordinator } = stack; + + editCoordinator.transaction("Create outer pair", () => { + editCoordinator.push(createGlyph("A", 65)); + editCoordinator.transaction("Create nested glyph", () => { + editCoordinator.push(createGlyph("B", 66)); + }); + }); + await editCoordinator.settled(); + + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(2); + + await editCoordinator.undo(); + expect(store.workspaceCell.peek()?.glyphs).toHaveLength(0); + }); + + it("loads glyph layers after queued workspace summary edits flush", async () => { + const { font, editCoordinator } = stack; const glyphId = mintGlyphId(); const layerId = mintLayerId(); + const sourceId = font.defaultSource.id; await editCoordinator.apply([ { kind: "createGlyph", @@ -71,7 +120,7 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => }, { kind: "createGlyphLayer", - createGlyphLayer: { layerId, glyphId, sourceId: font.defaultSource.id }, + createGlyphLayer: { layerId, glyphId, sourceId }, }, ]); await font.loadGlyph(glyphId); @@ -93,6 +142,6 @@ describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => await font.loadGlyph(glyphId); expect(font.getAxes().map((axis) => axis.id)).toEqual([axisId]); - expect(store.snapshotStatus(glyphId)).toBe("loaded"); + expect(font.layer(glyphId, sourceId)).not.toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts index 6300b2ef..03f2af71 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -14,12 +14,12 @@ export type { WorkspaceCommitState } from "@/lib/model/FontStore"; * Tracks optimistic renderer edits until the utility workspace echoes them. * * @remarks - * Every editing verb pushes one intent; all intents in the same microtask - * coalesce into ONE `workspace.apply` — one SQLite transaction, one undo - * step. Echoes fold by substitution only (replace structure, replace - * values); the queue contains zero change-application or save semantics. - * Undo, redo, snapshot reads, and save are serialized through the same queue so - * none can overtake a pending flush. + * Every editing verb pushes one operation by default. Use + * {@link transaction} to group multiple intents into one `workspace.apply`, + * one SQLite transaction, and one undo step. Echoes fold by substitution only + * (replace structure, replace values); the queue contains zero + * change-application or save semantics. Undo, redo, snapshot reads, and save + * are serialized through the same queue so none can overtake a committed edit. * * Save ownership lives in the utility. The renderer issues save as one more op * on this queue (see {@link save}); because it shares the FIFO edit lane, the @@ -32,10 +32,9 @@ export class WorkspaceEditCoordinator { readonly #settledCell: WritableSignal; readonly #commitState: WritableSignal; - #flushQueued = false; #chain: Promise = Promise.resolve(); #busy = 0; - #pendingIntents: FontIntent[] = []; + #transaction: { readonly label: string; readonly intents: FontIntent[] } | null = null; constructor(workspace: WorkspaceClient, store: FontStore) { this.#workspace = workspace; @@ -66,24 +65,65 @@ export class WorkspaceEditCoordinator { return this.#commitState; } - /** Queues one intent; everything in the same microtask becomes one apply. */ + /** Commits one intent as its own operation unless a transaction is open. */ push(intent: FontIntent): void { - this.#pendingIntents.push(intent); - this.#settledCell.set(false); - if (this.#commitState.peek() === "idle") { - this.#commitState.set("queued"); + const transaction = this.#transaction; + if (transaction) { + transaction.intents.push(intent); + return; } - if (!this.#flushQueued) { - this.#flushQueued = true; - queueMicrotask(() => this.#enqueueFlush()); + this.#enqueueApply([intent]); + } + + /** + * Groups synchronous edit intents into one workspace operation. + * + * @remarks + * Calls to {@link push} inside `body` are buffered and committed as one apply + * after the outermost transaction returns. Transactions are synchronous; + * callers must complete all edits before returning. + * + * @param label - Human-readable operation name for diagnostics and future ledger labels. + * @param body - Synchronous edit body that may call layer mutation APIs. + * @returns The value returned by `body`. + * @throws {Error} when `body` returns a Promise. + */ + transaction(label: string, body: () => TResult): TResult { + const active = this.#transaction; + if (active) { + return this.#runTransactionBody(label, body); + } + + this.#transaction = { label, intents: [] }; + + try { + const result = this.#runTransactionBody(label, body); + const transaction = this.#transaction; + this.#transaction = null; + this.#enqueueApply(transaction.intents); + return result; + } catch (error) { + this.#transaction = null; + throw error; } } + #runTransactionBody(label: string, body: () => TResult): TResult { + const result = body(); + + if (result instanceof Promise) { + throw new Error(`workspace transaction "${label}" must be synchronous`); + } + + return result; + } + /** Resolves when every queued and in-flight operation has settled. */ async settled(): Promise { - while (this.#pendingIntents.length > 0 || this.#busy > 0) { - this.#enqueueFlush(); + this.#assertNoTransaction("settle workspace edits"); + + while (this.#busy > 0) { await this.#chain; } } @@ -139,16 +179,17 @@ export class WorkspaceEditCoordinator { } #withFlush(job: () => Promise): Promise { - this.#enqueueFlush(); + this.#assertNoTransaction("run a serialized workspace operation"); return this.#serialize(job); } - #enqueueFlush(): void { - this.#flushQueued = false; - if (this.#pendingIntents.length === 0) return; + #enqueueApply(intents: FontIntent[]): void { + if (intents.length === 0) return; - const intents = this.#pendingIntents; - this.#pendingIntents = []; + this.#settledCell.set(false); + if (this.#commitState.peek() === "idle") { + this.#commitState.set("queued"); + } void this.#serialize(async () => { try { @@ -176,12 +217,19 @@ export class WorkspaceEditCoordinator { #afterJob(): void { this.#busy -= 1; - if (this.#busy === 0 && this.#pendingIntents.length === 0) { + if (this.#busy === 0) { this.#settledCell.set(true); this.#commitState.set("idle"); } } + #assertNoTransaction(action: string): void { + const transaction = this.#transaction; + if (!transaction) return; + + throw new Error(`cannot ${action} while workspace transaction "${transaction.label}" is open`); + } + /** Recovery: discard loaded projections and reload the workspace summary from utility. */ async #resync(): Promise { this.#store.replaceWorkspace(await this.#workspace.snapshot()); 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..3c5689f2 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/ledger.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/ledger.test.ts @@ -3,9 +3,8 @@ import type { GlyphName } from "@shift/types"; import { TestEditor } from "@/testing/TestEditor"; /** - * The undo contracts that CommandHistory.test.ts protected before WS6, now - * owned by the workspace ledger: one settled tick = one entry, undo/redo - * replay in order, and a new edit truncates the redo branch. + * Workspace-owned undo semantics: one operation = one entry, undo/redo replay + * in order, and a new edit truncates the redo branch. */ describe("workspace ledger semantics (via TestEditor)", () => { let editor: TestEditor; @@ -16,9 +15,9 @@ 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 () => { + it("undoes settled operations in reverse order", async () => { editor.clickGlyphLocal(10, 10); await editor.settle(); editor.clickGlyphLocal(20, 20); @@ -78,12 +77,14 @@ describe("workspace ledger semantics (via TestEditor)", () => { expect(editor.font.recordForName("A" as GlyphName)).not.toBe(null); }); - it("coalesces every intent in one tick into a single entry", async () => { + it("groups transaction intents into a single entry", async () => { const initialAdvance = source().xAdvance; - const contourId = source().addContour(); - source().addOnCurvePoint(contourId, { x: 1, y: 2 }); - source().setXAdvance(640); + editor.transaction("Create contour and set advance", () => { + const contourId = source().addContour(); + source().addOnCurvePoint(contourId, { x: 1, y: 2 }); + source().setXAdvance(640); + }); await editor.settle(); expect(source().contours.length).toBe(1); expect(source().xAdvance).toBe(640); diff --git a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts index ff8182ff..79c2449f 100644 --- a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts +++ b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts @@ -81,8 +81,6 @@ describe("workspace apply round trip (channel + NAPI + SQLite)", () => { }); bench("replace-grade glyph state pull", async () => { - await stack.editCoordinator.readGlyphSnapshots([ - { glyphId, sourceIds: [stack.font.defaultSource.id] }, - ]); + await stack.editCoordinator.readGlyphSnapshots([{ glyphId }]); }); }); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.test.ts b/apps/desktop/src/renderer/src/testing/TestEditor.test.ts index 40c191fb..0ccc0fc8 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.test.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { asPointId, mintNodeId } from "@shift/types"; +import { objectIsKindOf } from "@/types"; import { TestEditor } from "./TestEditor"; describe("TestEditor", () => { @@ -9,6 +11,13 @@ describe("TestEditor", () => { await editor.startSession(); }); + describe("tool activation", () => { + it("starts with the select tool activated", () => { + expect(editor.getActiveTool()).toBe("select"); + expect(editor.toolManager.activeToolId).toBe("select"); + }); + }); + describe("pointerMove", () => { it("flushes pointer input synchronously", () => { editor.selectTool("pen"); @@ -27,4 +36,93 @@ describe("TestEditor", () => { expect(second).not.toEqual(first); }); }); + + describe("object resolution", () => { + it("resolves placed scene nodes", () => { + const node = editor.glyphNode; + expect(node).not.toBeNull(); + if (!node) return; + + const object = editor.object(node.id); + expect(objectIsKindOf(object, "node")).toBe(true); + if (!objectIsKindOf(object, "node")) return; + + expect(object.id).toBe(node.id); + expect(object.node).toEqual(node); + expect(object.bounds()).toBeNull(); + }); + + it("returns null for missing node ids", () => { + expect(editor.object(mintNodeId())).toBeNull(); + }); + + it("resolves placed glyph points", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + + const layer = editor.glyphLayer; + const node = editor.glyphNode; + const point = editor.openContour?.points[0]; + if (!layer || !node || !point) throw new Error("Expected placed glyph point"); + + const object = editor.object(point.id); + expect(objectIsKindOf(object, "point")).toBe(true); + if (!objectIsKindOf(object, "point")) return; + + expect(object.id).toBe(point.id); + expect(object.pointId).toBe(point.id); + expect(object.layer.id).toBe(layer.id); + expect(object.node).toEqual(node); + expect(object.bounds()).toEqual({ + x: 100, + y: 200, + width: 0, + height: 0, + left: 100, + top: 200, + right: 100, + bottom: 200, + }); + }); + + it("resolves placed glyph segments and contours", async () => { + editor.selectTool("pen"); + editor.clickGlyphLocal(100, 200); + await editor.settle(); + editor.clickGlyphLocal(180, 200); + await editor.settle(); + + const layer = editor.glyphLayer; + const node = editor.glyphNode; + const contour = editor.openContour; + const segment = contour?.segments()[0]; + if (!layer || !node || !contour || !segment) { + throw new Error("Expected placed glyph segment and contour"); + } + + const segmentObject = editor.object(segment.id); + expect(objectIsKindOf(segmentObject, "segment")).toBe(true); + if (!objectIsKindOf(segmentObject, "segment")) return; + + expect(segmentObject.id).toBe(segment.id); + expect(segmentObject.segmentId).toBe(segment.id); + expect(segmentObject.pointIds).toEqual(segment.pointIds); + expect(segmentObject.layer.id).toBe(layer.id); + expect(segmentObject.node).toEqual(node); + + const contourObject = editor.object(contour.id); + expect(objectIsKindOf(contourObject, "contour")).toBe(true); + if (!objectIsKindOf(contourObject, "contour")) return; + + expect(contourObject.id).toBe(contour.id); + expect(contourObject.contourId).toBe(contour.id); + expect(contourObject.layer.id).toBe(layer.id); + expect(contourObject.node).toEqual(node); + }); + + it("returns null for glyph-internal ids without a placed editable node", () => { + expect(editor.object(asPointId("point_missing"))).toBeNull(); + }); + }); }); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 51f79907..6d22d9e5 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -12,18 +12,24 @@ */ 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 type { Point2D } from "@shift/geo"; import { mintGlyphId, mintLayerId, + mintNodeId, type GlyphId, type GlyphName, type GlyphRecord, + type PointId, 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 }; @@ -53,6 +59,7 @@ export class TestEditor extends Editor { this.#stack = stack; this.#clipboard = clipboard; registerBuiltInTools(this); + this.setActiveTool("select"); } /** @@ -61,8 +68,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 +82,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 +107,22 @@ 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, { - sourceIds: [sourceId], - }); - if (!loaded) throw new Error("created glyph did not load"); - return record; + return this.font.loadGlyph(record.id); } #placeGlyph(glyphId: GlyphId): void { - this.scene.clear(); - const itemId = this.scene.addGlyph({ glyphId, origin: { x: 0, y: 0 } }); - this.scene.setGeometryItems([itemId]); + this.scene.setNodes([ + { + id: mintNodeId(), + type: "node", + kind: "glyph", + parentId: null, + index: "a0", + glyphId, + sourceId: this.font.defaultSource.id, + position: { x: 0, y: 0 }, + }, + ]); } /** Awaits every queued and in-flight apply; geometry reads confirmed truth after. */ @@ -133,13 +148,63 @@ 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); + } + + requireGlyphLayer(): GlyphLayer { + const layer = this.glyphLayer; + if (!layer) throw new Error("Expected glyph layer"); + + return layer; + } + + pointPosition(pointId: PointId): Point2D { + const point = this.requireGlyphLayer().point(pointId); + if (!point) throw new Error("Expected source point"); + + return { x: point.x, y: point.y }; + } + + get glyphNode(): GlyphNode | null { + return this.scene.nodesOfKind("glyph")[0] ?? 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 { const mods = { ...DEFAULT_MODIFIERS, ...options }; this.toolManager.handlePointerDown({ x, y }, mods); - this.toolManager.handlePointerUp({ x, y }); + this.toolManager.handlePointerUp({ x, y }, mods); return this; } @@ -149,10 +214,48 @@ 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); } + /** + * Drags through scene coordinates while preserving drag-start semantics. + * + * @param input - Scene-space pointer-down point, threshold-crossing first + * move, and final pointer position. + * @returns The scene-space drag points observed through the camera and the + * delta from `start` to `end`. + */ + dragScene(input: { + down: Point2D; + start: Point2D; + end: Point2D; + options?: Partial; + }): { down: Point2D; start: Point2D; end: Point2D; delta: Point2D } { + const downScreen = this.projectSceneToScreen(input.down); + const startScreen = this.projectSceneToScreen(input.start); + const endScreen = this.projectSceneToScreen(input.end); + + this.pointerDown(downScreen.x, downScreen.y, input.options); + this.pointerMove(startScreen.x, startScreen.y, input.options); + this.pointerMove(endScreen.x, endScreen.y, input.options); + this.pointerUp(endScreen.x, endScreen.y, input.options); + + const down = this.projectScreenToScene(downScreen); + const start = this.projectScreenToScene(startScreen); + const end = this.projectScreenToScene(endScreen); + + return { + down, + start, + end, + delta: { + x: end.x - start.x, + y: end.y - start.y, + }, + }; + } + pointerDown(x: number, y: number, options?: Partial): this { this.toolManager.handlePointerDown({ x, y }, { ...DEFAULT_MODIFIERS, ...options }); return this; @@ -168,8 +271,8 @@ export class TestEditor extends Editor { return this; } - pointerUp(x: number, y: number): this { - this.toolManager.handlePointerUp({ x, y }); + pointerUp(x: number, y: number, options?: Partial): this { + this.toolManager.handlePointerUp({ x, y }, { ...DEFAULT_MODIFIERS, ...options }); return this; } 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/index.ts b/apps/desktop/src/renderer/src/types/index.ts new file mode 100644 index 00000000..b3dae987 --- /dev/null +++ b/apps/desktop/src/renderer/src/types/index.ts @@ -0,0 +1,37 @@ +import type { AnchorId, ContourId, LayerId, PointId } from "@shift/types"; +import type { SegmentId } from "@shift/glyph-state"; + +export { currentSelectionId, objectIsKindOf } from "./object"; +export type { + SelectableId, + SelectionId, + ShiftId, + ShiftObject, + ShiftObjectBase, + ShiftObjectKind, + ShiftObjectKindMap, + ShiftObjectOf, +} from "./object"; +export type { + GlyphNodeRecord, + SelectionRecord, + ShiftEditorRecord, + ShiftNodeRecord, + ShiftRecord, + ShiftRecordId, +} from "./records"; + +export interface GlyphObjectSegment { + readonly id: SegmentId; + readonly pointIds: readonly PointId[]; +} + +export interface GlyphObjectIndex { + readonly layerIdByPointId: ReadonlyMap; + readonly contourIdByPointId: ReadonlyMap; + readonly layerIdByContourId: ReadonlyMap; + readonly layerIdByAnchorId: ReadonlyMap; + readonly layerIdBySegmentId: ReadonlyMap; + readonly contourIdBySegmentId: ReadonlyMap; + readonly pointIdsBySegmentId: ReadonlyMap; +} 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..5390b9eb --- /dev/null +++ b/apps/desktop/src/renderer/src/types/node.ts @@ -0,0 +1,51 @@ +import type { Point2D } from "@shift/geo"; +import type { GlyphId, NodeId, SourceId } from "@shift/types"; + +/** + * Describes a placed object in the editor scene. + * + * @remarks + * Nodes own scene identity and placement only. Canonical glyph data, image + * assets, text layout, and editing behavior live in the subsystem referenced by + * the node kind. + */ +export interface Node { + /** Stable scene identity for this placed occurrence. */ + id: NodeId; + + /** Record category for scene graph nodes. */ + type: "node"; + + /** Node kind used to resolve behavior and backing model data. */ + kind: string; + + /** Parent scene node, or null when this node is at the scene root. */ + parentId: NodeId | null; + + /** Sort key among siblings under the same parent. */ + index: string; + + /** Scene-space position of this placed occurrence. */ + position: Point2D; +} + +/** + * Places an authored glyph source in the editor scene. + * + * @remarks + * A glyph node is the scene occurrence for editable glyph geometry. It points + * at canonical font data by `glyphId` and `sourceId`; it does not own glyph + * outlines, layer geometry, or display state. + */ +export interface GlyphNode extends Node { + readonly kind: "glyph"; + + /** Glyph identity whose authored layer is shown by this scene node. */ + readonly glyphId: GlyphId; + + /** Source identity selecting the authored layer shown by this scene node. */ + readonly sourceId: SourceId; +} + +/** Represents every scene node kind known to this build. */ +export type ShiftNode = GlyphNode; diff --git a/apps/desktop/src/renderer/src/types/object.ts b/apps/desktop/src/renderer/src/types/object.ts new file mode 100644 index 00000000..fc649794 --- /dev/null +++ b/apps/desktop/src/renderer/src/types/object.ts @@ -0,0 +1,142 @@ +import type { Rect2D } from "@shift/geo"; +import type { SegmentId } from "@shift/glyph-state"; +import type { AnchorId, ContourId, NodeId, PointId } from "@shift/types"; +import type { GlyphLayer } from "@/lib/model/Glyph"; +import type { GlyphNode, ShiftNode } from "./node"; + +declare const SelectionIdBrand: unique symbol; + +export type SelectionId = string & { readonly [SelectionIdBrand]: typeof SelectionIdBrand }; + +export const currentSelectionId = "selection:current" as SelectionId; + +/** Identifies an editor-addressable scene node or authored glyph object. */ +export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId; + +/** Identifies objects that can be selected by the editor. */ +export type SelectableId = ShiftId; + +/** + * Defines the shared contract for a resolved editor object. + * + * @remarks + * Resolved objects combine a stable ID, a discriminating kind, and enough live + * ownership or placement context to answer object-level behavior without + * callers passing editor plumbing into each method. + * + * @template K - Kind string used for discriminated narrowing. + * @template I - Stable identity for this object. + */ +export interface ShiftObjectBase { + /** Stable identity used to address this object. */ + readonly id: I; + + /** Discriminant used to narrow this object to its concrete interface. */ + readonly kind: K; + + /** + * Returns this object's current scene-space bounds. + * + * @returns null when the object has no bounds or its backing model is unavailable. + */ + bounds(): Rect2D | null; +} + +/** + * Maps object kind strings to their resolved object interfaces. + * + * @remarks + * Built-in kinds live here. Compiled extensions can augment this interface so + * `objectIsKindOf(object, kind)` narrows plugin-defined object kinds too. + */ +export interface ShiftObjectKindMap { + /** + * Represents a placed scene node resolved from a node ID. + * + * @remarks + * The node is scene placement data. Kind-specific canonical data still belongs + * to the node's owning subsystem, such as the font for glyph nodes or future + * asset storage for image nodes. + */ + readonly node: ShiftObjectBase<"node", NodeId> & { + readonly node: ShiftNode; + }; + + /** + * Represents an editable glyph point resolved through its layer and scene node. + * + * @remarks + * The point's coordinates should be read from `layer` when behavior runs. This + * object carries `node` so glyph-local geometry can be interpreted in scene + * coordinates without restoring editor-global glyph state. + */ + readonly point: ShiftObjectBase<"point", PointId> & { + readonly node: GlyphNode; + readonly layer: GlyphLayer; + readonly pointId: PointId; + }; + + /** + * Represents a glyph anchor resolved through its layer and scene node. + * + * @remarks + * Anchors are glyph-internal objects, not scene nodes. The paired glyph node + * supplies placement when rendering indicators or computing scene-space bounds. + */ + readonly anchor: ShiftObjectBase<"anchor", AnchorId> & { + readonly node: GlyphNode; + readonly layer: GlyphLayer; + readonly anchorId: AnchorId; + }; + + /** + * Represents a glyph segment resolved through its layer and endpoint IDs. + * + * @remarks + * Segment IDs are derived geometry identities. `pointIds` records the endpoint + * ownership needed for operations that expand a selected segment to its points + * without changing selection semantics. + */ + readonly segment: ShiftObjectBase<"segment", SegmentId> & { + readonly node: GlyphNode; + readonly layer: GlyphLayer; + readonly segmentId: SegmentId; + readonly pointIds: readonly PointId[]; + }; + + /** + * Represents a glyph contour resolved through its layer and scene node. + * + * @remarks + * Contours are authored glyph structure. Bounds and content behavior should use + * the current layer structure rather than cached contour objects. + */ + readonly contour: ShiftObjectBase<"contour", ContourId> & { + readonly node: GlyphNode; + readonly layer: GlyphLayer; + readonly contourId: ContourId; + }; +} + +/** Names the resolved object kinds known to this build. */ +export type ShiftObjectKind = keyof ShiftObjectKindMap; + +/** Returns the resolved object contract for one kind. */ +export type ShiftObjectOf = ShiftObjectKindMap[K]; + +/** Represents any resolved editor object known to this build. */ +export type ShiftObject = ShiftObjectKindMap[ShiftObjectKind]; + +/** + * Narrows a resolved object to one registered kind. + * + * @param object - Candidate object; nullish values fail the guard. + * @param kind - Kind string to test against the object's discriminant. + * @returns true when the object exists and has the requested kind. + */ +export function objectIsKindOf( + object: ShiftObject | null | undefined, + kind: K, +): object is ShiftObjectOf { + return object?.kind === kind; +} diff --git a/apps/desktop/src/renderer/src/types/records.ts b/apps/desktop/src/renderer/src/types/records.ts new file mode 100644 index 00000000..85b3f416 --- /dev/null +++ b/apps/desktop/src/renderer/src/types/records.ts @@ -0,0 +1,24 @@ +import type { NodeId } from "@shift/types"; +import type { GlyphNode } from "./node"; +import type { SelectableId, SelectionId, ShiftId } from "./object"; + +export type ShiftRecordId = ShiftId | SelectionId; + +export interface ShiftRecord< + Type extends string = string, + Id extends ShiftRecordId = ShiftRecordId, +> { + readonly id: Id; + readonly type: Type; +} + +export type GlyphNodeRecord = GlyphNode & ShiftRecord<"node", NodeId>; + +export type ShiftNodeRecord = GlyphNodeRecord; + +export type SelectionRecord = ShiftRecord<"selection", SelectionId> & { + readonly scope: "session"; + readonly ids: readonly SelectableId[]; +}; + +export type ShiftEditorRecord = ShiftNodeRecord | SelectionRecord; 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..13216948 --- /dev/null +++ b/apps/desktop/src/renderer/src/types/target.ts @@ -0,0 +1,38 @@ +import type { GlyphAnchorHit, GlyphHit, GlyphPointHit, GlyphSegmentHit } from "@shift/glyph-state"; +import type { AnchorId, 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 & { + readonly pointId: PointId; +}; + +export type GlyphAnchorTarget = GlyphHitTarget & { + readonly anchorId: AnchorId; +}; + +export type GlyphSegmentTarget = GlyphHitTarget & { + readonly segmentId: GlyphSegmentHit["id"]; + 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/apps/desktop/src/renderer/src/views/Editor.tsx b/apps/desktop/src/renderer/src/views/Editor.tsx index 52f715a3..968eef39 100644 --- a/apps/desktop/src/renderer/src/views/Editor.tsx +++ b/apps/desktop/src/renderer/src/views/Editor.tsx @@ -1,54 +1,22 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@shift/ui"; import { Toolbar } from "@/components/chrome/Toolbar"; import { LeftSidebar } from "@/components/editor/LeftSidebar"; import { RightSidebar } from "@/components/editor/RightSidebar"; -import { GlyphFinder } from "@/components/editor/GlyphFinder"; import { Canvas } from "@/components/editor/Canvas"; import { useEditor } from "@/workspace/WorkspaceContext"; import { useFocusZone, ZoneContainer } from "@/context/FocusZoneContext"; -import { useSignalState } from "@/lib/signals"; import { KeyboardRouter } from "@/lib/keyboard"; -import type { Unicode } from "@shift/types"; - export const Editor = () => { const { glyphId } = useParams(); const editor = useEditor(); const { activeZone } = useFocusZone(); - const navigate = useNavigate(); - const glyphFinderOpen = useSignalState(editor.glyphFinderOpenCell); - - const handleGlyphFinderOpenChange = useCallback( - (open: boolean) => { - if (open) { - editor.openGlyphFinder(); - } else { - editor.closeGlyphFinder(); - } - }, - [editor], - ); - - const handleGlyphFinderSelect = useCallback( - (codepoint: number) => { - if (editor.toolManager.activeToolId === "text") { - editor.insertTextCodepoint(codepoint); - } else { - const name = editor.font.nameForUnicode(codepoint as Unicode); - const record = name ? editor.font.recordForName(name) : null; - if (record) navigate(`/editor/${encodeURIComponent(record.id)}`); - } - editor.closeGlyphFinder(); - }, - [editor, navigate], - ); - useEffect(() => { const toolManager = editor.toolManager; const keyboardRouter = new KeyboardRouter(() => ({ @@ -123,11 +91,6 @@ export const Editor = () => { -
); }; 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/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/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 84141a46..aec83e80 100644 --- a/packages/glyph-state/src/index.ts +++ b/packages/glyph-state/src/index.ts @@ -9,14 +9,22 @@ 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 { 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 b91dafab..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) @@ -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. @@ -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 ItemId. - * Useful for runtime validation in debug builds. - */ -export function isValidItemId(id: unknown): id is ItemId { - 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 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 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"; @@ -257,8 +227,8 @@ type MintedIdByPrefix = { component: ComponentId; guideline: GuidelineId; glyph: GlyphId; - item: ItemId; layer: LayerId; + node: NodeId; source: SourceId; }; @@ -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. @@ -312,16 +286,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..fac1b3ec 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,26 +23,26 @@ export { asComponentId, asGuidelineId, asGlyphId, - asItemId, asLayerId, + asNodeId, asSourceId, - isValidPointId, - isValidContourId, - isValidAnchorId, - isValidAxisId, - isValidComponentId, - isValidGuidelineId, - isValidGlyphId, - isValidItemId, - isValidLayerId, - isValidSourceId, + isPointId, + isContourId, + isAnchorId, + isAxisId, + isComponentId, + isGuidelineId, + isGlyphId, + isLayerId, + isNodeId, + isSourceId, mintContourId, mintAnchorId, mintAxisId, mintPointId, mintGlyphId, - mintItemId, mintLayerId, + mintNodeId, mintSourceId, } from "./ids"; 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;