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 (
-
- );
-}
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