diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png index 184bb6c2..dcfb323a 100644 Binary files a/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png and b/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png differ diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar-selection.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar-selection.png index ff7e7073..86ea6c03 100644 Binary files a/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar-selection.png and b/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar-selection.png differ diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar.png index 26a425bb..5ca5b64c 100644 Binary files a/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar.png and b/apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar.png differ diff --git a/apps/desktop/e2e/__screenshots__/theme.spec.ts/editor-shift-dark.png b/apps/desktop/e2e/__screenshots__/theme.spec.ts/editor-shift-dark.png index 4ce20b0e..b911d35f 100644 Binary files a/apps/desktop/e2e/__screenshots__/theme.spec.ts/editor-shift-dark.png and b/apps/desktop/e2e/__screenshots__/theme.spec.ts/editor-shift-dark.png differ diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/Ellipse-committed.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/Ellipse-committed.png index 5d3e60d6..f1e30551 100644 Binary files a/apps/desktop/e2e/__screenshots__/tools.spec.ts/Ellipse-committed.png and b/apps/desktop/e2e/__screenshots__/tools.spec.ts/Ellipse-committed.png differ diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/Rectangle-committed.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/Rectangle-committed.png index debbe966..d887b95a 100644 Binary files a/apps/desktop/e2e/__screenshots__/tools.spec.ts/Rectangle-committed.png and b/apps/desktop/e2e/__screenshots__/tools.spec.ts/Rectangle-committed.png differ diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-select.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-select.png index 83133e6a..63163073 100644 Binary files a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-select.png and b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-select.png differ diff --git a/apps/desktop/src/renderer/src/assets/sidebar-left/first-point.svg b/apps/desktop/src/renderer/src/assets/sidebar-left/first-point.svg new file mode 100644 index 00000000..4721ed65 --- /dev/null +++ b/apps/desktop/src/renderer/src/assets/sidebar-left/first-point.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx b/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx index 3c8aedce..1f7b6fdb 100644 --- a/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx @@ -84,6 +84,14 @@ export const ObjectsPanel = () => { return result; }, [objectTree]); + const coveredIds = useMemo(() => { + const result = new Set(selectedIds); + for (const [childId, parentId] of parentByObjectId) { + if (selectedIds.has(parentId)) result.add(childId); + } + return result; + }, [parentByObjectId, selectedIds]); + const { selectItem: selectObject } = useListSelection(visibleObjectIds, selection.ids, (ids) => { editor.history.capture("Select object", () => editor.selection.select(ids)); }); @@ -193,6 +201,7 @@ export const ObjectsPanel = () => { rows={rows} visibleIds={visibleIds} selectedIds={selectedIds} + coveredIds={coveredIds} collapsedObjectIds={collapsedObjectIds} setObjectOpen={setObjectOpen} selectObject={selectObject} diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/ObjectRow.tsx b/apps/desktop/src/renderer/src/components/editor/object-tree/ObjectRow.tsx index 0888e475..cb3fcb7f 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/ObjectRow.tsx +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/ObjectRow.tsx @@ -1,12 +1,24 @@ -import { Collapsible, CollapsibleChevron, CollapsibleTrigger } from "@shift/ui"; -import { SidebarActionRow } from "@/components/sidebar"; +import { + Collapsible, + CollapsibleChevron, + CollapsibleTrigger, + RotateCcw, + RotateCw, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@shift/ui"; +import { SidebarActionButton, SidebarActionRow } from "@/components/sidebar"; +import { isContourId } from "@shift/types"; import { listSelectionMode } from "@/hooks/useListSelection"; -import type { ObjectRowProps, ObjectTreeIcon } from "@/types/objectTree"; +import { reverseContours } from "@/lib/commands/rendererCommands"; +import type { ContourDirection, ObjectRowProps, ObjectTreeIcon } from "@/types/objectTree"; import { useEditor } from "@/workspace/WorkspaceContext"; import AnchorIcon from "@/assets/sidebar-left/anchor.svg"; import ComponentIcon from "@/assets/sidebar-left/component.svg"; import ContourIcon from "@/assets/sidebar-left/contour.svg"; import CurvePointIcon from "@/assets/sidebar-left/curve-point.svg"; +import FirstPointIcon from "@/assets/sidebar-left/first-point.svg"; import HandlePointIcon from "@/assets/sidebar-left/handle-point.svg"; import LinePointIcon from "@/assets/sidebar-left/line-point.svg"; import { ObjectContextMenu } from "./ObjectContextMenu"; @@ -23,6 +35,7 @@ export const ObjectRow = ({ const editor = useEditor(); const { depth, item } = row; const hasChildren = item.children.length > 0; + const reversibleContourId = item.direction && isContourId(item.id) ? item.id : null; return ( } + actions={ + item.direction && + reversibleContourId && ( + + + reverseContours(editor, [reversibleContourId])} + > + + + + Reverse contour + + ) + } + className="pr-2" isSelected={isSelected} joinsPrevious={joinsPrevious} joinsNext={joinsNext} @@ -78,6 +110,16 @@ export const ObjectRow = ({ ); }; +const DirectionIcon = ({ direction }: { direction: ContourDirection }) => ( + + {direction === "clockwise" ? ( + + ) : ( + + )} + +); + function itemIcon(icon: ObjectTreeIcon, iconPath?: string) { const className = "h-3 w-3 text-icon-subtle [&_path]:stroke-current"; @@ -109,6 +151,8 @@ function itemIcon(icon: ObjectTreeIcon, iconPath?: string) { return ; case "curve": return ; + case "first": + return ; case "handle": return ; case "line": diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx b/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx index d104438e..eaf592a1 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx @@ -1,4 +1,5 @@ -import { useLayoutEffect } from "react"; +import { cn } from "@shift/ui"; +import { useLayoutEffect, useState } from "react"; import type { SelectableId } from "@shift/editor/types"; import type { VirtualObjectRowsProps } from "@/types/objectTree"; import { ObjectRow } from "./ObjectRow"; @@ -11,6 +12,7 @@ export function VirtualObjectRows({ rows, visibleIds, selectedIds, + coveredIds, collapsedObjectIds, setObjectOpen, selectObject, @@ -43,6 +45,9 @@ export function VirtualObjectRows({ const after = afterCount > 0 ? afterCount * OBJECT_ROW_STEP - ROW_GAP : 0; const focusedIndex = focusedObjectId ? visibleIds.indexOf(focusedObjectId) : -1; const mountedFocus = focusedIndex >= visibleRowRange.start && focusedIndex < visibleRowRange.end; + // The tree's own focus ring is suppressed; the focused row shows one only while + // navigating by keyboard, so modifier keys after a click don't flash a ring. + const [isKeyboardNavigating, setKeyboardNavigating] = useState(false); useLayoutEffect(() => { if (!pendingFocusId) return; @@ -68,7 +73,9 @@ export function VirtualObjectRows({ aria-multiselectable="true" aria-activedescendant={mountedFocus ? `object-tree-${focusedObjectId}` : undefined} tabIndex={0} - className="flex flex-col gap-1" + className="flex flex-col gap-1 outline-none" + onPointerDown={() => setKeyboardNavigating(false)} + onBlur={() => setKeyboardNavigating(false)} onFocus={() => { if (mountedFocus) return; @@ -154,6 +161,7 @@ export function VirtualObjectRows({ if (!next) return; event.preventDefault(); + setKeyboardNavigating(true); onNavigate(next); }} > @@ -163,6 +171,7 @@ export function VirtualObjectRows({ const previousId = visibleIds[rowIndex - 1]; const nextId = visibleIds[rowIndex + 1]; const isSelected = selectedIds.has(row.item.id); + const isCovered = coveredIds.has(row.item.id); return (
0 ? !collapsedObjectIds.has(row.item.id) : undefined } - className="h-7" + className={cn( + "h-7 rounded", + isKeyboardNavigating && row.item.id === focusedObjectId && "ring-2 ring-primary/50", + )} > diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.test.ts b/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.test.ts index 96b170f0..4404253e 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.test.ts +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.test.ts @@ -24,6 +24,27 @@ const geometry = new GlyphGeometry( new Float64Array([500, 0, 0, 10, 0, 20, 0, 25, 10, 30, 0]), ); +function squareGeometry(closed: boolean, coordinates: readonly number[]): GlyphGeometry { + return new GlyphGeometry( + { + contours: [ + { + id: asContourId("square"), + closed, + points: ["a", "b", "c", "d"].map((id) => ({ + id: asPointId(id), + pointType: "onCurve" as const, + smooth: false, + })), + }, + ], + anchors: [], + components: [], + }, + new Float64Array([500, ...coordinates]), + ); +} + describe("object tree contour descriptions", () => { it("labels the first point and describes remaining point geometry", () => { const contour = createObjectTree(geometry)[0]?.items[0]; @@ -37,6 +58,34 @@ describe("object tree contour descriptions", () => { ]); }); + it("marks the first point with its own icon", () => { + const contour = createObjectTree(geometry)[0]?.items[0]; + + expect(contour?.children.map((point) => point.icon)).toEqual([ + "first", + "line", + "curve", + "handle", + "curve", + ]); + }); + + it("reports the winding of closed contours in y-up font space", () => { + const upFirst = [0, 0, 0, 10, 10, 10, 10, 0]; + const rightFirst = [0, 0, 10, 0, 10, 10, 0, 10]; + + expect(createObjectTree(squareGeometry(true, upFirst))[0]?.items[0]?.direction).toBe( + "clockwise", + ); + expect(createObjectTree(squareGeometry(true, rightFirst))[0]?.items[0]?.direction).toBe( + "counterclockwise", + ); + }); + + it("gives open contours no direction", () => { + expect(createObjectTree(geometry)[0]?.items[0]?.direction).toBeUndefined(); + }); + it("fits contour path geometry into the icon view box", () => { const contour = geometry.contours[0]; if (!contour) throw new Error("Expected contour fixture"); diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.ts b/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.ts index 79426d9b..807f5751 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.ts +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/createObjectTree.ts @@ -1,8 +1,8 @@ -import { Mat } from "@shift/geo"; +import { Mat, Polygon } from "@shift/geo"; import type { Contour, GlyphGeometry, Point } from "@shift/glyph-state"; import { ContourPath } from "@shift/editor/rendering"; import { Validate } from "@shift/validation"; -import type { ObjectTree } from "@/types/objectTree"; +import type { ContourDirection, ObjectTree } from "@/types/objectTree"; const CONTOUR_ICON_SIZE = 14; const CONTOUR_ICON_PADDING = 1; @@ -20,32 +20,21 @@ export function createObjectTree(geometry: GlyphGeometry): ObjectTree { kind: "contour", icon: "contour", iconPath: createContourIconPath(contour), + direction: contourDirection(contour), label: `Contour ${contourIndex + 1}`, children: contour.points.map((point, pointIndex) => { - const icon = pointIcon(contour, point); - let label = "First"; - - if (pointIndex !== 0) { - pointCounts[icon] += 1; - - switch (icon) { - case "curve": - label = `Curve ${pointCounts.curve}`; - break; - case "handle": - label = `Handle ${pointCounts.handle}`; - break; - case "line": - label = `Line ${pointCounts.line}`; - break; - } + if (pointIndex === 0) { + return { id: point.id, kind: "point", icon: "first", label: "First", children: [] }; } + const icon = pointIcon(contour, point); + pointCounts[icon] += 1; + return { id: point.id, kind: "point", icon, - label, + label: pointLabel(icon, pointCounts[icon]), children: [], }; }), @@ -97,6 +86,22 @@ export function createContourIconPath(contour: Contour): string | undefined { return ContourPath.fromContour(contour, transform).svgPath; } +function contourDirection(contour: Contour): ContourDirection | undefined { + if (!contour.closed) return undefined; + return Polygon.isClockwise(contour.points) ? "clockwise" : "counterclockwise"; +} + +function pointLabel(icon: "curve" | "handle" | "line", count: number): string { + switch (icon) { + case "curve": + return `Curve ${count}`; + case "handle": + return `Handle ${count}`; + case "line": + return `Line ${count}`; + } +} + function pointIcon(contour: Contour, point: Point) { if (Validate.isOffCurve(point)) return "handle"; diff --git a/apps/desktop/src/renderer/src/lib/commands/rendererCommands.ts b/apps/desktop/src/renderer/src/lib/commands/rendererCommands.ts index 07f75b1e..5d9644f1 100644 --- a/apps/desktop/src/renderer/src/lib/commands/rendererCommands.ts +++ b/apps/desktop/src/renderer/src/lib/commands/rendererCommands.ts @@ -32,6 +32,30 @@ export function canMakeFirstPoint(editor: Editor): boolean { ); } +/** + * Reverses the given contours as one undoable step, without touching selection. + * + * @returns false when none of the ids resolve to contours on the active source's layer. + */ +export function reverseContours(editor: Editor, contourIds: Iterable): boolean { + const contours = [...contourIds] + .map((contourId) => editor.object(contourId)) + .filter((object) => objectIsKindOf(object, "contour")); + + if (contours.length === 0) return false; + + const layer = editor.layerForGeometry({ + contours: contours.map((contour) => contour.contourId), + }); + if (!layer || layer.sourceId !== editor.activeSourceId) return false; + + editor.transaction("Reverse Contours", () => { + for (const contour of contours) layer.reverseContour(contour.contourId); + }); + + return true; +} + /** * Executes a renderer-owned app command against one editor. * @@ -136,22 +160,7 @@ export async function runRendererCommand(editor: Editor, id: EditorCommandId): P } } - const contours = [...contourIds] - .map((contourId) => editor.object(contourId)) - .filter((object) => objectIsKindOf(object, "contour")); - - if (contours.length === 0) return false; - - const layer = editor.layerForGeometry({ - contours: contours.map((contour) => contour.contourId), - }); - if (!layer || layer.sourceId !== editor.activeSourceId) return false; - - editor.transaction("Reverse Contours", () => { - for (const contour of contours) layer.reverseContour(contour.contourId); - }); - - return true; + return reverseContours(editor, contourIds); } } } diff --git a/apps/desktop/src/renderer/src/lib/nodes/GlyphHandleStates.test.ts b/apps/desktop/src/renderer/src/lib/nodes/GlyphHandleStates.test.ts index cafa05f0..e5143552 100644 --- a/apps/desktop/src/renderer/src/lib/nodes/GlyphHandleStates.test.ts +++ b/apps/desktop/src/renderer/src/lib/nodes/GlyphHandleStates.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { PointId } from "@shift/types"; +import { objectIsKindOf } from "@shift/editor/types"; import { externalAxisLocationFromRecord } from "@shift/editor/variation"; import { TestEditor } from "@/testing/TestEditor"; @@ -35,6 +36,15 @@ describe("glyph handles keep source styling apart from interpolated locations", ]); }); + it("draws every point of a selected contour as selected", () => { + const point = editor.object(pointIds[0]!); + if (!objectIsKindOf(point, "point")) throw new Error("Expected point object"); + + editor.selection.select([point.contourId]); + + expect([...handleStates().values()]).toEqual(["selected", "selected", "selected"]); + }); + it("keeps drawing every handle with interpolated styling between sources", async () => { editor.selection.select([pointIds[1]!]); const axisId = editor.font.createAxis({ diff --git a/apps/desktop/src/renderer/src/types/objectTree.ts b/apps/desktop/src/renderer/src/types/objectTree.ts index 8f8653d0..91134803 100644 --- a/apps/desktop/src/renderer/src/types/objectTree.ts +++ b/apps/desktop/src/renderer/src/types/objectTree.ts @@ -1,11 +1,21 @@ import type { ListSelectionMode, SelectableId } from "@shift/editor/types"; -export type ObjectTreeIcon = "anchor" | "component" | "contour" | "curve" | "handle" | "line"; +export type ObjectTreeIcon = + | "anchor" + | "component" + | "contour" + | "curve" + | "first" + | "handle" + | "line"; +export type ContourDirection = "clockwise" | "counterclockwise"; export type ObjectTreeItemKind = "anchor" | "component" | "contour" | "point"; export type ObjectTreeSectionId = "anchors" | "components" | "contours"; export interface ObjectTreeItem { readonly children: readonly ObjectTreeItem[]; + /** Winding of a closed contour in font units (y-up); absent for open contours. */ + readonly direction?: ContourDirection; readonly icon: ObjectTreeIcon; readonly iconPath?: string; readonly id: SelectableId; @@ -34,6 +44,8 @@ export interface VirtualObjectRowsProps { readonly rows: readonly VisibleObjectRow[]; readonly visibleIds: readonly SelectableId[]; readonly selectedIds: ReadonlySet; + /** Selected ids plus the children of selected parents. */ + readonly coveredIds: ReadonlySet; readonly collapsedObjectIds: ReadonlySet; readonly setObjectOpen: (id: SelectableId, open: boolean) => void; readonly selectObject: ObjectTreeSelectionHandler; diff --git a/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts b/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts index 3176c189..baee65fb 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts @@ -32,8 +32,10 @@ export class HandleItems { ): HandleDisplayList { return this.#fromShapes( contours, - (contourIndex, pointIndex) => - this.#state(contours[contourIndex]!.points[pointIndex]!.id, source), + (contourIndex, pointIndex) => { + const contour = contours[contourIndex]!; + return this.#state(contour.points[pointIndex]!.id, contour.id, source); + }, isVisible, visibleBounds, ); @@ -86,10 +88,11 @@ export class HandleItems { return new HandleDisplayList(this.#items); } - #state(id: SelectableId, source: HandleStateSource): HandleState { + #state(id: SelectableId, contourId: ContourId, source: HandleStateSource): HandleState { if (source.interpolated) return "interpolated"; - if (source.selection.has(id)) return "selected"; + // A selected contour covers its points, so they draw as selected too. + if (source.selection.has(id) || source.selection.has(contourId)) return "selected"; if (source.hover.has(id)) return "hovered"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2a6f0317..1b7c8f5a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -187,4 +187,14 @@ export { type PortalContainerProviderProps, } from "./components/portal"; export { cn } from "./lib/utils"; -export { ArrowRight, Check, ChevronDown, LayoutGrid, List, Search, X } from "lucide-react"; +export { + ArrowRight, + Check, + ChevronDown, + LayoutGrid, + List, + RotateCcw, + RotateCw, + Search, + X, +} from "lucide-react";