Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/__screenshots__/editor.spec.ts/objects-sidebar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-select.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down Expand Up @@ -193,6 +201,7 @@ export const ObjectsPanel = () => {
rows={rows}
visibleIds={visibleIds}
selectedIds={selectedIds}
coveredIds={coveredIds}
collapsedObjectIds={collapsedObjectIds}
setObjectOpen={setObjectOpen}
selectObject={selectObject}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<ObjectContextMenu
Expand Down Expand Up @@ -63,6 +76,25 @@ export const ObjectRow = ({
</span>
</div>
}
actions={
item.direction &&
reversibleContourId && (
<Tooltip>
<TooltipTrigger>
<SidebarActionButton
label={`Reverse ${item.label}`}
tabIndex={-1}
className={isSelected ? "!opacity-100" : undefined}
onClick={() => reverseContours(editor, [reversibleContourId])}
>
<DirectionIcon direction={item.direction} />
</SidebarActionButton>
</TooltipTrigger>
<TooltipContent>Reverse contour</TooltipContent>
</Tooltip>
)
}
className="pr-2"
isSelected={isSelected}
joinsPrevious={joinsPrevious}
joinsNext={joinsNext}
Expand All @@ -78,6 +110,16 @@ export const ObjectRow = ({
);
};

const DirectionIcon = ({ direction }: { direction: ContourDirection }) => (
<span aria-hidden className="flex text-secondary">
{direction === "clockwise" ? (
<RotateCw className="h-3.5 w-3.5" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
</span>
);

function itemIcon(icon: ObjectTreeIcon, iconPath?: string) {
const className = "h-3 w-3 text-icon-subtle [&_path]:stroke-current";

Expand Down Expand Up @@ -109,6 +151,8 @@ function itemIcon(icon: ObjectTreeIcon, iconPath?: string) {
return <ContourIcon aria-hidden className={className} />;
case "curve":
return <CurvePointIcon aria-hidden className={className} />;
case "first":
return <FirstPointIcon aria-hidden className="h-3 w-3 text-icon-subtle" />;
case "handle":
return <HandlePointIcon aria-hidden className={className} />;
case "line":
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,6 +12,7 @@ export function VirtualObjectRows({
rows,
visibleIds,
selectedIds,
coveredIds,
collapsedObjectIds,
setObjectOpen,
selectObject,
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -154,6 +161,7 @@ export function VirtualObjectRows({

if (!next) return;
event.preventDefault();
setKeyboardNavigating(true);
onNavigate(next);
}}
>
Expand All @@ -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 (
<div
Expand All @@ -177,14 +186,17 @@ export function VirtualObjectRows({
aria-expanded={
row.item.children.length > 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",
)}
>
<ObjectRow
row={row}
isCollapsed={collapsedObjectIds.has(row.item.id)}
isSelected={isSelected}
joinsPrevious={isSelected && previousId !== undefined && selectedIds.has(previousId)}
joinsNext={isSelected && nextId !== undefined && selectedIds.has(nextId)}
isSelected={isCovered}
joinsPrevious={isCovered && previousId !== undefined && coveredIds.has(previousId)}
joinsNext={isCovered && nextId !== undefined && coveredIds.has(nextId)}
onOpenChange={setObjectOpen}
selectObject={selectObject}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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: [],
};
}),
Expand Down Expand Up @@ -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";

Expand Down
41 changes: 25 additions & 16 deletions apps/desktop/src/renderer/src/lib/commands/rendererCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContourId>): 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.
*
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
Loading
Loading