}
+ 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";