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
6 changes: 6 additions & 0 deletions apps/desktop/src/main/commands/Commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ const glyphCommands: Command[] = [
enabled: (ctx) => ctx.document.hasWorkspace(),
run: (ctx) => ctx.renderer.run("glyph.addComponent"),
},
{
id: "glyph.decomposeComponents",
label: "Decompose Components",
enabled: (ctx) => ctx.document.hasWorkspace(),
run: (ctx) => ctx.renderer.run("glyph.decomposeComponents"),
},
{
id: "glyph.makeFirstPoint",
label: "Make First Point",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/menu/ApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export class ApplicationMenu {
#glyphItems(): MenuItemConstructorOptions[] {
return [
this.#commandItem("glyph.addComponent"),
this.#commandItem("glyph.decomposeComponents"),
{ type: "separator" },
this.#commandItem("glyph.reverseSelectedContour"),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export const CanvasContextMenu = ({ children }: CanvasContextMenuProps) => {
<ContextMenuItem onClick={async () => runCommand("glyph.addComponent")}>
Add component…
</ContextMenuItem>
{editor.canDecomposeSelection() && (
<ContextMenuItem onClick={async () => runCommand("glyph.decomposeComponents")}>
Decompose Components
</ContextMenuItem>
)}
<ContextMenuItem onClick={async () => runCommand("glyph.reverseSelectedContour")}>
Reverse Contour
</ContextMenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export async function runRendererCommand(editor: Editor, id: EditorCommandId): P
return changed;
}

case "glyph.decomposeComponents":
return editor.decomposeSelection();

case "glyph.reverseSelectedContour": {
const contourIds = new Set<ContourId>();

Expand Down Expand Up @@ -186,6 +189,7 @@ async function runFocusedTextEditCommand(id: EditorCommandId): Promise<boolean |
case "edit.deselect":
case "view.zoomIn":
case "view.zoomOut":
case "glyph.decomposeComponents":
case "glyph.reverseSelectedContour":
case "glyph.makeFirstPoint":
return null;
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/renderer/src/lib/editor/Deletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,20 @@ describe("deletion refuses absent or unsupported selections without edits", () =
expect(layer().state).toEqual(before);
});

it("deletes selected anchors as one undoable edit", async () => {
const top = layer().addAnchor("top", { x: 0, y: 100 });
const bottom = layer().addAnchor("bottom", { x: 0, y: 0 });
await editor.settle();
editor.selection.select([top]);

expect(await editor.deleteSelection()).toBe(true);
expect(layer().anchors.map(({ id }) => id)).toEqual([bottom]);
expect(editor.selection.ids).toEqual([]);

await editor.undo();
expect(layer().anchors.map(({ id }) => id)).toEqual([top, bottom]);
});

it("retains anchors when they are mixed into a point selection", async () => {
const [id] = await editor.drawOpenContour([{ x: 0, y: 0 }]);
const anchor = layer().addAnchor("top", { x: 0, y: 100 });
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/renderer/src/lib/model/ComponentEditing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { Point } from "@shift/glyph-state";
import type { GlyphName } from "@shift/types";
import { externalAxisLocationFromRecord } from "@shift/editor/variation";
import { runRendererCommand } from "@/lib/commands/rendererCommands";
import { TestEditor } from "@/testing/TestEditor";

describe("component references become removable or editable local contours", () => {
Expand Down Expand Up @@ -145,6 +146,42 @@ describe("component references become removable or editable local contours", ()
});
});

describe("Decompose Components replaces selected components with outlines", () => {
it("decomposes the selection through the command as one undoable edit", async () => {
const editor = new TestEditor();
await editor.startSession("root", null);
await editor.addGlyph("base", null);
const base = editor.font.recordForName("base" as GlyphName)!;
const baseLayer = (await editor.font.loadGlyph(base.id)).layerForSource(
editor.font.defaultSource.id,
)!;
const contourId = baseLayer.addContour();
baseLayer.addPoint(contourId, Point.onCurve({ x: 40, y: 25 }));
const componentId = await editor.addComponent(base.id);

expect(editor.canDecomposeSelection()).toBe(true);
expect(await runRendererCommand(editor, "glyph.decomposeComponents")).toBe(true);
const layer = editor.requireGlyphLayer();
expect(layer.components).toEqual([]);
expect(layer.allPoints.map(({ x, y }) => ({ x, y }))).toEqual([{ x: 40, y: 25 }]);

await editor.undo();
expect(layer.components.map(({ id }) => id)).toEqual([componentId]);
expect(layer.allPoints).toEqual([]);
});

it("does nothing when the selection is not components", async () => {
const editor = new TestEditor();
await editor.startSession();
const [pointId] = await editor.drawOpenContour([{ x: 0, y: 0 }]);
editor.selection.select([pointId]);

expect(editor.canDecomposeSelection()).toBe(false);
expect(await runRendererCommand(editor, "glyph.decomposeComponents")).toBe(false);
expect(editor.requireGlyphLayer().allPoints).toHaveLength(1);
});
});

describe("components of glyphs not yet loaded this session", () => {
const outputRoots: string[] = [];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { PointId } from "@shift/types";
import { Point } from "@shift/glyph-state";
import type { ComponentId, GlyphName, PointId } from "@shift/types";
import { TestEditor } from "@/testing/TestEditor";

describe("Select arrow keys nudge selected points", () => {
Expand Down Expand Up @@ -75,3 +76,37 @@ describe("Select arrow keys nudge selected points", () => {
expect([editor.pointPosition(firstId), editor.pointPosition(secondId)]).toEqual(nudged);
});
});

describe("Select arrow keys nudge selected components", () => {
let editor: TestEditor;
let componentId: ComponentId;

const translation = () => {
const component = editor.requireGlyphLayer().components.find(({ id }) => id === componentId);
return { x: component?.transform.translateX, y: component?.transform.translateY };
};

beforeEach(async () => {
editor = new TestEditor();
await editor.startSession("root", null);
await editor.addGlyph("base", null);
const base = editor.font.recordForName("base" as GlyphName)!;
const baseLayer = (await editor.font.loadGlyph(base.id)).layerForSource(
editor.font.defaultSource.id,
)!;
const contourId = baseLayer.addContour();
baseLayer.addPoint(contourId, Point.onCurve({ x: 0, y: 0 }));
baseLayer.addPoint(contourId, Point.onCurve({ x: 100, y: 100 }));
componentId = (await editor.addComponent(base.id))!;
editor.selectTool("select");
});

it("moves the component by the increment as one undoable edit", async () => {
await editor.pressKey("ArrowRight", { shiftKey: true });
await editor.pressKey("ArrowUp");
expect(translation()).toEqual({ x: 10, y: 1 });

await editor.undo();
expect(translation()).toEqual({ x: 10, y: 0 });
});
});
2 changes: 2 additions & 0 deletions apps/desktop/src/shared/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type CommandId =
| "edit.selectAll"
| "edit.deselect"
| "glyph.addComponent"
| "glyph.decomposeComponents"
| "glyph.reverseSelectedContour"
| "glyph.makeFirstPoint"
| "window.showHome"
Expand Down Expand Up @@ -111,5 +112,6 @@ export type EditorCommandId =
| "edit.deselect"
| "view.zoomIn"
| "view.zoomOut"
| "glyph.decomposeComponents"
| "glyph.reverseSelectedContour"
| "glyph.makeFirstPoint";
75 changes: 60 additions & 15 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1808,26 +1808,29 @@ export class Editor {
}

public async deleteSelection(mode: DeleteMode = "fit"): Promise<boolean> {
const componentTargets = this.#componentTargets(this.selection.ids);
if (componentTargets) {
this.transaction("Delete Components", () => {
componentTargets.layer.removeComponents(componentTargets.componentIds);
for (const target of componentTargets.additionalLayers) {
target.layer.removeComponents(target.componentIds);
const componentsRemoved = await this.#editSelectedComponents(
"Delete Components",
(layer, ids) => layer.removeComponents(ids),
);
if (componentsRemoved) return true;

const selection = this.positionSelection(this.selection.ids);
if (!selection) return false;

const pointIds = selection.targets.points ?? [];
const anchorIds = selection.targets.anchors ?? [];
if (pointIds.length === 0 && anchorIds.length > 0) {
this.transaction("Delete Anchors", () => {
selection.layer.removeAnchors(anchorIds);
for (const target of selection.additionalLayers) {
target.layer.removeAnchors(target.targets.anchors ?? []);
}
});

this.selection.clear();
this.hover.clear();
await this.font.editCoordinator.settled();
await this.#clearSelectionAfterEdit();
return true;
}

const selection = this.positionSelection(this.selection.ids);
const pointIds = selection?.targets.points ?? [];
if (!selection || pointIds.length === 0 || (selection.targets.anchors?.length ?? 0) > 0) {
return false;
}
if (pointIds.length === 0 || anchorIds.length > 0) return false;

const deleted = this.history.capture("Delete", () => {
if (!selection.layer.deletePoints(pointIds, mode)) return false;
Expand All @@ -1842,6 +1845,48 @@ export class Editor {
return true;
}

/** Whether the selection is entirely components that {@link decomposeSelection} can decompose. */
public canDecomposeSelection(): boolean {
return this.#componentTargets(this.selection.ids) !== null;
}

/**
* Replaces the selected components with their base glyphs' outlines.
*
* @remarks
* Applies to every source selected for editing that has matching components,
* as one undo step, then clears the selection.
*
* @returns `true` when components were decomposed; `false` when the selection
* is not entirely components on the active source.
*/
public async decomposeSelection(): Promise<boolean> {
return this.#editSelectedComponents("Decompose Components", (layer, ids) =>
layer.decomposeComponents(ids),
);
}

async #editSelectedComponents(
label: string,
edit: (layer: GlyphLayer, componentIds: readonly ComponentId[]) => void,
): Promise<boolean> {
const targets = this.#componentTargets(this.selection.ids);
if (!targets) return false;

this.transaction(label, () => {
edit(targets.layer, targets.componentIds);
for (const target of targets.additionalLayers) edit(target.layer, target.componentIds);
});
await this.#clearSelectionAfterEdit();
return true;
}

async #clearSelectionAfterEdit(): Promise<void> {
this.selection.clear();
this.hover.clear();
await this.font.editCoordinator.settled();
}

/**
* Reads the system clipboard and inserts supported content.
*
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/lib/editor/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ Paste selects inserted objects and activates Select before awaiting the workspac

### Deletion

`Editor.deleteSelection(mode)` normalizes point, segment, and contour selection against the active authored layer and delegates to `GlyphLayer.deletePoints`. Delete and Backspace use `"fit"`; Shift selects `"gap"`. Native-menu Delete uses the same default reconnection behavior. Reconnection never raises the original span's highest degree: lines stay lines, quadratic/line mixtures stay quadratic, and spans containing cubics are fitted as cubics. Selecting a segment is equivalent to selecting all of its points. Unsupported, missing, or mixed-anchor selections refuse without mutation. Successful deletion clears selection and hover and awaits the workspace echo; geometry removal and selection clearing are one unified undo step.
`Editor.deleteSelection(mode)` normalizes point, segment, and contour selection against the active authored layer and delegates to `GlyphLayer.deletePoints`. Delete and Backspace use `"fit"`; Shift selects `"gap"`. Native-menu Delete uses the same default reconnection behavior. Reconnection never raises the original span's highest degree: lines stay lines, quadratic/line mixtures stay quadratic, and spans containing cubics are fitted as cubics. Selecting a segment is equivalent to selecting all of its points. Component-only selections remove the matched components in every selected source; anchor-only selections remove the matched anchors in every selected source. Unsupported, missing, or mixed point-and-anchor selections refuse without mutation.

`Editor.decomposeSelection()` accepts the same component-only selections, replaces the matched components in every selected source with their base outlines as one undo step, and clears selection. Successful deletion clears selection and hover and awaits the workspace echo; geometry removal and selection clearing are one unified undo step.

### Hit testing

Expand Down
56 changes: 32 additions & 24 deletions packages/editor/src/lib/tools/select/behaviors/Nudge.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Mat, type Point2D } from "@shift/geo";

import type { ToolContext } from "../../core/Behavior";
import type { KeyDownEvent } from "../../core/GestureDetector";
import type { SelectBehavior, SelectState } from "../types";
Expand All @@ -9,36 +11,24 @@ export class Nudge implements SelectBehavior {
onKeyDown(state: SelectState, ctx: ToolContext<SelectState>, event: KeyDownEvent): boolean {
if (state.type !== "ready") return false;

const delta = nudgeDelta(event);
if (!delta) return false;

const components = ctx.editor.componentTransformSelection(ctx.editor.selection.ids);
if (components) {
const edit = components.layer.beginComponentTransformEdit(components);
edit.preview(() => Mat.Translate(delta.x, delta.y));
edit.commit("Move components");
return true;
}

const selection = ctx.editor.positionSelection(ctx.editor.selection.ids);
if (!selection) return false;

const pointIds = selection.targets.points ?? [];
const anchorIds = selection.targets.anchors ?? [];
if (pointIds.length === 0 && anchorIds.length === 0) return false;

const nudgeValue =
NUDGES_VALUES[nudgeMagnitude({ accel: event.accelKey, shift: event.shiftKey })];

let dx = 0;
let dy = 0;

switch (event.key) {
case "ArrowLeft":
dx = -nudgeValue;
break;
case "ArrowRight":
dx = nudgeValue;
break;
case "ArrowUp":
dy = nudgeValue;
break;
case "ArrowDown":
dy = -nudgeValue;
break;
default:
return false;
}

const [onlyPointId] = pointIds;
const slidesAlone = event.altKey && pointIds.length === 1 && anchorIds.length === 0;
const slide = slidesAlone && onlyPointId ? pointSlide(selection.layer, onlyPointId) : null;
Expand All @@ -50,8 +40,26 @@ export class Nudge implements SelectBehavior {
if (pointIds.length > 0 && handlesFollow) {
edit.constrainedBy(PointRuleConstraint.forSelection(selection.layer.geometry, pointIds));
}
edit.preview({ x: dx, y: dy });
edit.preview(delta);
edit.commit();
return true;
}
}

function nudgeDelta(event: KeyDownEvent): Point2D | null {
const nudgeValue =
NUDGES_VALUES[nudgeMagnitude({ accel: event.accelKey, shift: event.shiftKey })];

switch (event.key) {
case "ArrowLeft":
return { x: -nudgeValue, y: 0 };
case "ArrowRight":
return { x: nudgeValue, y: 0 };
case "ArrowUp":
return { x: 0, y: nudgeValue };
case "ArrowDown":
return { x: 0, y: -nudgeValue };
default:
return null;
}
}
4 changes: 3 additions & 1 deletion packages/sdk/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class Editor {
//
// (undocumented)
get camera(): Camera;
canDecomposeSelection(): boolean;
collapseEditingSources(): boolean;
// Warning: (ae-forgotten-export) The symbol "SelectableId" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "ComponentTransformSelection" needs to be exported by the entry point index.d.ts
Expand Down Expand Up @@ -75,6 +76,7 @@ export class Editor {
get debugOverlays(): DebugOverlays;
// (undocumented)
get debugOverlaysCell(): Signal<DebugOverlays>;
decomposeSelection(): Promise<boolean>;
// Warning: (ae-forgotten-export) The symbol "DeleteMode" needs to be exported by the entry point index.d.ts
//
// (undocumented)
Expand Down Expand Up @@ -664,7 +666,7 @@ export function useSignalState<T>(signal: Signal<T>, options?: UseSignalOptions)

// Warnings were encountered during analysis:
//
// dist/Editor-C4VGA6MS.d.ts:3725:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts
// dist/Editor-BO84Q4oC.d.ts:3725:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)

Expand Down
Loading