From 877cd71641ba5b8d8dd5c8f0310889d9d1ea311a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 13:29:22 +0100 Subject: [PATCH 1/2] feat: bend curves by dragging and move segments once selected Dragging an unselected cubic bends it without a modifier; a segment translates only after it is selected. Cmd no longer affects bending and keeps only the line upgrade preview and Cmd-click upgrade. --- apps/desktop/e2e/editor.spec.ts | 11 ++- .../src/lib/tools/select/Select.test.ts | 81 +++++++++++++------ .../tools/select/SelectSegmentSnap.test.ts | 1 + .../lib/tools/select/SelectTransform.test.ts | 39 ++++----- packages/editor/src/lib/tools/docs/DOCS.md | 2 +- .../editor/src/lib/tools/select/Select.ts | 2 +- .../lib/tools/select/behaviors/BendCurve.ts | 4 +- 7 files changed, 87 insertions(+), 53 deletions(-) diff --git a/apps/desktop/e2e/editor.spec.ts b/apps/desktop/e2e/editor.spec.ts index fb7b37d54..7c262a96 100644 --- a/apps/desktop/e2e/editor.spec.ts +++ b/apps/desktop/e2e/editor.spec.ts @@ -423,8 +423,6 @@ 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/); @@ -432,7 +430,15 @@ test.describe("Editor view", () => { 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)]) { @@ -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/); }); diff --git a/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts b/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts index 95d123d9..d4eca8bc 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/Select.test.ts @@ -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 () => { diff --git a/apps/desktop/src/renderer/src/lib/tools/select/SelectSegmentSnap.test.ts b/apps/desktop/src/renderer/src/lib/tools/select/SelectSegmentSnap.test.ts index 5d150b31..ce187fea 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/SelectSegmentSnap.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/SelectSegmentSnap.test.ts @@ -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 }, diff --git a/apps/desktop/src/renderer/src/lib/tools/select/SelectTransform.test.ts b/apps/desktop/src/renderer/src/lib/tools/select/SelectTransform.test.ts index afd7e082..0f771aca 100644 --- a/apps/desktop/src/renderer/src/lib/tools/select/SelectTransform.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/select/SelectTransform.test.ts @@ -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" }); }); @@ -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); @@ -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(); @@ -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 }); @@ -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); }); @@ -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 }; @@ -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); @@ -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)]; @@ -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 }); diff --git a/packages/editor/src/lib/tools/docs/DOCS.md b/packages/editor/src/lib/tools/docs/DOCS.md index eea06422..e9de28c8 100644 --- a/packages/editor/src/lib/tools/docs/DOCS.md +++ b/packages/editor/src/lib/tools/docs/DOCS.md @@ -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 diff --git a/packages/editor/src/lib/tools/select/Select.ts b/packages/editor/src/lib/tools/select/Select.ts index 7db42113..57c2e146 100644 --- a/packages/editor/src/lib/tools/select/Select.ts +++ b/packages/editor/src/lib/tools/select/Select.ts @@ -95,7 +95,7 @@ export class Select extends BaseTool { if ( layer && layer.sourceId === this.editor.activeSourceIdCell.value && - (segment?.type === "line" || segment?.type === "cubic") + segment?.type === "line" ) { return { type: "bend" }; } diff --git a/packages/editor/src/lib/tools/select/behaviors/BendCurve.ts b/packages/editor/src/lib/tools/select/behaviors/BendCurve.ts index 0a251fc7..41d8906a 100644 --- a/packages/editor/src/lib/tools/select/behaviors/BendCurve.ts +++ b/packages/editor/src/lib/tools/select/behaviors/BendCurve.ts @@ -11,8 +11,10 @@ export class BendCurve implements SelectBehavior { #hasChanges = false; onDragStart(state: SelectState, ctx: ToolContext, 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; From b0239d9e33fcce078f4000a8025485c0b0047d44 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 30 Sep 2026 13:41:23 +0100 Subject: [PATCH 2/2] test(e2e): expect the default cursor over a freshly upgraded cubic --- apps/desktop/e2e/editor.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/e2e/editor.spec.ts b/apps/desktop/e2e/editor.spec.ts index 7c262a96..d7b1adab 100644 --- a/apps/desktop/e2e/editor.spec.ts +++ b/apps/desktop/e2e/editor.spec.ts @@ -526,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"); }