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
13 changes: 9 additions & 4 deletions apps/desktop/e2e/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,16 +423,22 @@ test.describe("Editor view", () => {
} finally {
await page.keyboard.up("Alt");
}
await canvas.click({ position: down, modifiers: ["Meta"] });
await editor.waitForIdle();
await page.keyboard.down("Meta");
try {
await expect(canvas).toHaveCSS("cursor", /cursor@32-bend\.svg/);
for (const sidebar of [editorSidebar(page), glyphProperties(page)]) {
await sidebar.hover({ position: { x: 10, y: 10 } });
await expect(sidebar).not.toHaveCSS("cursor", /cursors\//);
}
} finally {
await page.keyboard.up("Meta");
}
await canvas.click({ position: down, modifiers: ["Meta"] });
await editor.waitForIdle();
await canvas.click({ position: { x: 10, y: 10 } });
await editor.waitForIdle();

try {
await canvas.hover({ position: down });
await page.mouse.down();
for (const sidebar of [editorSidebar(page), glyphProperties(page)]) {
Expand All @@ -449,7 +455,6 @@ test.describe("Editor view", () => {
} finally {
await page.keyboard.press("Escape");
await page.mouse.up();
await page.keyboard.up("Meta");
}
await expect(canvas).toHaveCSS("cursor", /cursor@32\.svg/);
});
Expand Down Expand Up @@ -521,7 +526,7 @@ test.describe("Editor view", () => {
expect(point.y).toBeCloseTo(preview.controls[index].y, 6);
});
await expect.poll(previewHandles).toBeNull();
await expect(canvas).toHaveCSS("cursor", /cursor@32-bend\.svg/);
await expect(canvas).toHaveCSS("cursor", /cursor@32\.svg/);
} finally {
await page.keyboard.up("Meta");
}
Expand Down
81 changes: 56 additions & 25 deletions apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,38 +721,69 @@ describe("Select tool", () => {
});
});

it("bends a cubic segment with meta-drag", async () => {
editor.selectTool("pen");
await editor.clickGlyphLocal(100, 200);
await editor.clickGlyphLocal(190, 230);
describe("dragging a cubic segment", () => {
let layer: GlyphLayer;
let bendPoint: Point2D;
let controlStartId: PointId;
let controlEndId: PointId;

const layer = editor.requireGlyphLayer();
const segment = layer.contours[0]?.segments()[0];
if (!segment) throw new Error("Expected line segment");
expect(layer.upgradeLineToCubic(segment.id)).toBe(true);
await editor.settle();
beforeEach(async () => {
editor.selectTool("pen");
await editor.clickGlyphLocal(100, 200);
await editor.clickGlyphLocal(190, 230);

const cubic = layer.contours[0]?.segments()[0]?.asCubic();
if (!cubic) throw new Error("Expected cubic segment");
layer = editor.requireGlyphLayer();
const segment = layer.contours[0]?.segments()[0];
if (!segment) throw new Error("Expected line segment");
expect(layer.upgradeLineToCubic(segment.id)).toBe(true);
await editor.settle();

const beforeControlStart = editor.pointPosition(cubic.controlStart.id);
const beforeControlEnd = editor.pointPosition(cubic.controlEnd.id);
const bendPoint = layer.contours[0]?.segments()[0]?.pointAt(0.5);
if (!bendPoint) throw new Error("Expected cubic bend point");
const cubic = layer.contours[0]?.segments()[0]?.asCubic();
const point = layer.contours[0]?.segments()[0]?.pointAt(0.5);
if (!cubic || !point) throw new Error("Expected cubic segment");
controlStartId = cubic.controlStart.id;
controlEndId = cubic.controlEnd.id;
bendPoint = point;

editor.selectTool("select");
await editor.dragScene({
down: bendPoint,
start: { x: bendPoint.x + 4, y: bendPoint.y },
end: { x: bendPoint.x + 4, y: bendPoint.y + 40 },
options: { metaKey: true },
editor.selectTool("select");
editor.selection.clear();
});

function dragDown() {
return editor.dragScene({
down: bendPoint,
start: { x: bendPoint.x + 4, y: bendPoint.y },
end: { x: bendPoint.x + 4, y: bendPoint.y + 40 },
});
}

it("bends an unselected segment without moving its anchors", async () => {
const [start, , , end] = layer.contours[0]?.points ?? [];
if (!start || !end) throw new Error("Expected segment anchors");
const beforeControlStart = editor.pointPosition(controlStartId);
const beforeControlEnd = editor.pointPosition(controlEndId);

await dragDown();

expect(editor.pointPosition(controlStartId).y).toBeGreaterThan(beforeControlStart.y);
expect(editor.pointPosition(controlEndId).y).toBeGreaterThan(beforeControlEnd.y);
expect(editor.pointPosition(start.id)).toEqual({ x: start.x, y: start.y });
expect(editor.pointPosition(end.id)).toEqual({ x: end.x, y: end.y });
});

const afterControlStart = editor.pointPosition(cubic.controlStart.id);
const afterControlEnd = editor.pointPosition(cubic.controlEnd.id);
it("translates a segment selected by clicking it first", async () => {
const before = layer.contours[0]?.points ?? [];
await editor.clickGlyphLocal(bendPoint.x, bendPoint.y);

expect(afterControlStart.y).toBeGreaterThan(beforeControlStart.y);
expect(afterControlEnd.y).toBeGreaterThan(beforeControlEnd.y);
const drag = await dragDown();

for (const point of before) {
expect(editor.pointPosition(point.id)).toEqual({
x: point.x + drag.delta.x,
y: point.y + drag.delta.y,
});
}
});
});

it("toggles a point smooth with double-click", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe("Select translates segment selections in 90-degree directions", () => {
await editor.settle();
const cubic = layer.contours[0]!.segments()[0]!.asCubic()!;
const before = cubic.controlStart.y;
await editor.clickGlyphLocal(200, 100);
await editor.dragScene({
down: { x: 200, y: 100 },
start: { x: 260, y: 180 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,15 +615,12 @@ describe("Select curve bending preserves edit lifecycle", () => {
editor.selectTool("select");
});

it("shows the bend cursor when Meta is pressed over a cubic, and clears it on release", () => {
it("keeps the normal cursor on Cmd-hover over a cubic", () => {
const down = editor.projectSceneToScreen(bendPoint);
editor.pointerMove(down.x, down.y);
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "default" });

editor.keyDown("Meta", { metaKey: true });
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "bend" });

editor.pointerMove(down.x, down.y);
editor.keyDown("Meta");
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "default" });
});

Expand All @@ -638,17 +635,17 @@ describe("Select curve bending preserves edit lifecycle", () => {

it("does not offer bending on a point or empty canvas", () => {
const down = editor.projectSceneToScreen({ x: 100, y: 200 });
editor.pointerMove(down.x, down.y, { metaKey: true });
editor.pointerMove(down.x, down.y);
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "default" });

editor.pointerMove(down.x + 300, down.y + 300, { metaKey: true });
editor.pointerMove(down.x + 300, down.y + 300);
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "default" });
});

it("keeps the bend cursor during a drag even after Meta is released", async () => {
it("shows the bend cursor for the whole bend drag", async () => {
const down = editor.projectSceneToScreen(bendPoint);
editor.pointerDown(down.x, down.y, { metaKey: true });
editor.pointerMove(down.x + 4, down.y, { metaKey: true });
editor.pointerDown(down.x, down.y);
editor.pointerMove(down.x + 4, down.y);
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "bend" });

editor.pointerMove(down.x + 4, down.y + 40);
Expand All @@ -665,9 +662,9 @@ describe("Select curve bending preserves edit lifecycle", () => {
const start = editor.projectSceneToScreen({ x: bendPoint.x + 4, y: bendPoint.y });
const end = editor.projectSceneToScreen({ x: bendPoint.x + 4, y: bendPoint.y + 40 });

editor.pointerDown(down.x, down.y, { metaKey: true });
editor.pointerMove(start.x, start.y, { metaKey: true });
editor.pointerMove(end.x, end.y, { metaKey: true });
editor.pointerDown(down.x, down.y);
editor.pointerMove(start.x, start.y);
editor.pointerMove(end.x, end.y);
expect(editor.pointPosition(controlOneId)).not.toEqual(oneBefore);
expect(editor.toolManager.activeTool?.cursorCell.value).toEqual({ type: "bend" });
editor.escape();
Expand All @@ -682,7 +679,7 @@ describe("Select curve bending preserves edit lifecycle", () => {
down: bendPoint,
start: { x: bendPoint.x + 4, y: bendPoint.y },
end: { x: bendPoint.x, y: bendPoint.y + 40 },
options: { metaKey: true, shiftKey: true },
options: { shiftKey: true },
});

const handleOne = Vec2.sub(editor.pointPosition(controlOneId), { x: 100, y: 200 });
Expand All @@ -700,12 +697,12 @@ describe("Select curve bending preserves edit lifecycle", () => {
const end = editor.projectSceneToScreen({ x: bendPoint.x, y: bendPoint.y + 40 });
const handleOne = () => Vec2.sub(editor.pointPosition(controlOneId), { x: 100, y: 200 });

editor.pointerDown(down.x, down.y, { metaKey: true });
editor.pointerMove(start.x, start.y, { metaKey: true });
editor.pointerMove(end.x, end.y, { metaKey: true, shiftKey: true });
editor.pointerDown(down.x, down.y);
editor.pointerMove(start.x, start.y);
editor.pointerMove(end.x, end.y, { shiftKey: true });
expect(handleOne().y).toBeCloseTo(handleOne().x);

editor.pointerMove(end.x, end.y + 1, { metaKey: true });
editor.pointerMove(end.x, end.y + 1);
expect(handleOne().y).not.toBeCloseTo(handleOne().x);
});

Expand All @@ -714,7 +711,6 @@ describe("Select curve bending preserves edit lifecycle", () => {
down: bendPoint,
start: { x: bendPoint.x + 4, y: bendPoint.y },
end: { x: bendPoint.x + 20, y: bendPoint.y + 40 },
options: { metaKey: true },
});
const anchorStart = { x: 100, y: 200 };
const anchorEnd = { x: 200, y: 200 };
Expand All @@ -727,7 +723,7 @@ describe("Select curve bending preserves edit lifecycle", () => {
down: bentPoint,
start: { x: bentPoint.x, y: bentPoint.y + 4 },
end: { x: bentPoint.x - 10, y: bentPoint.y + 30 },
options: { metaKey: true, altKey: true },
options: { altKey: true },
});

const handleOne = Vec2.sub(editor.pointPosition(controlOneId), anchorStart);
Expand All @@ -746,7 +742,6 @@ describe("Select curve bending preserves edit lifecycle", () => {
down: bendPoint,
start: { x: bendPoint.x + 4, y: bendPoint.y },
end: { x: bendPoint.x + 4, y: bendPoint.y + 40 },
options: { metaKey: true },
});
const bent = [editor.pointPosition(controlOneId), editor.pointPosition(controlTwoId)];

Expand Down Expand Up @@ -781,7 +776,7 @@ describe("Select Shift-bending snaps handles to 45掳 steps", () => {
down: { x: 200, y: 200 },
start: { x: 204, y: 196 },
end: { x: 260, y: 140 },
options: { metaKey: true, shiftKey: true },
options: { shiftKey: true },
});

expect(editor.pointPosition(cubic.controlStart.id)).toMatchObject({ x: 260, y: 100 });
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/lib/tools/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ All three receive a `Canvas` instance.

### Line upgrade preview

In Select's `ready` state, Cmd-hovering an active authored line shows the bend cursor and two solid grey circles at one-third and two-thirds of the segment. `SelectUpgradePreview.props()` tracks tool state, modifiers, pointer presence, hover, active source, and current geometry, deriving scene-space handle positions without mutating anything. `drawOverlay` delegates to this canvas item, which uses `UPGRADE_PREVIEW_STYLE` for a fixed screen-pixel radius. Releasing Cmd, leaving the segment or canvas, or starting a gesture removes the preview. Cmd-click upgrades the line into a shape-preserving cubic with handles at those positions; Alt-click no longer upgrades. Existing cubics retain Cmd-drag bending and do not show upgrade previews. Upgrading the closing line appends its handles in traversal order, preserving the original on-curve contour start.
In Select's `ready` state, Cmd-hovering an active authored line shows the bend cursor and two solid grey circles at one-third and two-thirds of the segment. `SelectUpgradePreview.props()` tracks tool state, modifiers, pointer presence, hover, active source, and current geometry, deriving scene-space handle positions without mutating anything. `drawOverlay` delegates to this canvas item, which uses `UPGRADE_PREVIEW_STYLE` for a fixed screen-pixel radius. Releasing Cmd, leaving the segment or canvas, or starting a gesture removes the preview. Cmd-click upgrades the line into a shape-preserving cubic with handles at those positions; Alt-click no longer upgrades. Existing cubics do not show upgrade previews. Dragging an unselected cubic bends it (Shift snaps handles to 45掳, Alt keeps handle directions); dragging a selected segment translates it, so moving a curve takes a click to select it first. Upgrading the closing line appends its handles in traversal order, preserving the original on-curve contour start.

### Cursor

Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/lib/tools/select/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class Select extends BaseTool<SelectState, Select> {
if (
layer &&
layer.sourceId === this.editor.activeSourceIdCell.value &&
(segment?.type === "line" || segment?.type === "cubic")
segment?.type === "line"
) {
return { type: "bend" };
}
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/lib/tools/select/behaviors/BendCurve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export class BendCurve implements SelectBehavior {
#hasChanges = false;

onDragStart(state: SelectState, ctx: ToolContext<SelectState>, event: DragStartEvent): boolean {
if (state.type !== "ready" || !event.metaKey) return false;
if (state.type !== "ready") return false;
if (event.target.kind !== "segment") return false;
// Dragging a selected segment translates it instead.
if (ctx.editor.selection.isSelected(event.target.id)) return false;

const object = ctx.editor.object(event.target.id);
if (!objectIsKindOf(object, "segment")) return false;
Expand Down
Loading