diff --git a/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts b/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts new file mode 100644 index 00000000..c5ecee6a --- /dev/null +++ b/apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { NativeBridge } from "./NativeBridge"; +import { createBridge } from "@/testing/engine"; + +describe("NativeBridge session lifecycle", () => { + let bridge: NativeBridge; + + beforeEach(() => { + bridge = createBridge(); + }); + + it("has no session and a null $glyph before any start", () => { + expect(bridge.hasSession()).toBe(false); + expect(bridge.$glyph.peek()).toBe(null); + }); + + it("startEditSession opens a session and populates $glyph", () => { + bridge.startEditSession("A"); + + expect(bridge.hasSession()).toBe(true); + expect(bridge.$glyph.peek()).not.toBe(null); + expect(bridge.getEditingGlyphName()).toBe("A"); + }); + + it("endEditSession clears the session and nulls $glyph", () => { + bridge.startEditSession("A"); + bridge.endEditSession(); + + expect(bridge.hasSession()).toBe(false); + expect(bridge.$glyph.peek()).toBe(null); + }); + + it("starting the same glyph again is a no-op — $glyph reference is preserved", () => { + bridge.startEditSession("A"); + const first = bridge.$glyph.peek(); + + bridge.startEditSession("A"); + const second = bridge.$glyph.peek(); + + expect(second).toBe(first); + }); + + it("switching to a different glyph replaces the Glyph instance", () => { + bridge.startEditSession("A"); + const first = bridge.$glyph.peek(); + + bridge.startEditSession("B"); + const second = bridge.$glyph.peek(); + + expect(bridge.getEditingGlyphName()).toBe("B"); + expect(second).not.toBe(first); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts new file mode 100644 index 00000000..d81fb6b4 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TestEditor } from "@/testing/TestEditor"; + +describe("Clipboard (via Editor)", () => { + let editor: TestEditor; + + beforeEach(() => { + editor = new TestEditor(); + editor.startSession(); + editor.selectTool("pen"); + + // Draw a small rectangle: 4 points. + editor.click(100, 100); + editor.click(200, 100); + editor.click(200, 200); + editor.click(100, 200); + }); + + it("copy on empty selection returns false", async () => { + editor.selection.clear(); + + const ok = await editor.copy(); + + expect(ok).toBe(false); + expect(editor.clipboardBuffer).toBe(""); + }); + + it("copy writes a shift/glyph-data payload to the clipboard", async () => { + editor.selectAll(); + + const ok = await editor.copy(); + + expect(ok).toBe(true); + const payload = JSON.parse(editor.clipboardBuffer); + expect(payload.format).toBe("shift/glyph-data"); + expect(payload.content.contours).toHaveLength(1); + expect(payload.content.contours[0].points).toHaveLength(4); + }); + + it("copy + paste duplicates the selected contour", async () => { + editor.selectAll(); + const pointsBefore = editor.pointCount; + + await editor.copy(); + await editor.paste(); + + expect(editor.pointCount).toBe(pointsBefore * 2); + }); + + it("cut removes the selected points from the glyph", async () => { + editor.selectAll(); + expect(editor.pointCount).toBeGreaterThan(0); + + await editor.cut(); + + expect(editor.pointCount).toBe(0); + }); + + it("paste with an empty clipboard is a no-op", async () => { + editor.selection.clear(); + const pointsBefore = editor.pointCount; + + await editor.paste(); + + expect(editor.pointCount).toBe(pointsBefore); + }); + + it("repeated pastes compound the offset", async () => { + editor.selectAll(); + await editor.copy(); + await editor.paste(); + await editor.paste(); + + const contours = editor.currentGlyph?.contours ?? []; + expect(contours).toHaveLength(3); + + // Each paste translates the original by DEFAULT_PASTE_OFFSET (20) * + // pasteIndex. Sort by minX so the assertion is independent of the + // contour array's insertion order. + const sortedMinX = contours + .map((c) => Math.min(...c.points.map((p) => p.x))) + .sort((a, b) => a - b); + + expect(sortedMinX[1]! - sortedMinX[0]!).toBe(20); + expect(sortedMinX[2]! - sortedMinX[0]!).toBe(40); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts index 2c888e3d..07c3ec90 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts @@ -10,6 +10,7 @@ import { Polygon } from "@shift/geo"; import { Validate } from "@shift/validation"; import { ValidateClipboard } from "@shift/validation"; import type { + SystemClipboard, ClipboardContent, ClipboardImporter, ClipboardPayload, @@ -35,6 +36,13 @@ export interface ClipboardDeps { readonly glyph: Signal; readonly selection: Selection; readonly commands: CommandHistory; + readonly clipboard: SystemClipboard; +} + +interface ClipboardState { + content: ClipboardContent | null; + bounds: Rect2D | null; + timestamp: number; } /** @@ -44,7 +52,7 @@ export interface ClipboardDeps { export class Clipboard { readonly #deps: ClipboardDeps; readonly #importers: ClipboardImporter[] = []; - #internalState: { content: ClipboardContent | null; bounds: Rect2D | null; timestamp: number } = { + #internalState: ClipboardState = { content: null, bounds: null, timestamp: 0, @@ -108,14 +116,13 @@ export class Clipboard { this.#pasteCount = 0; try { - if (!window.electronAPI) return false; const payload: ClipboardPayload = { version: 1, format: "shift/glyph-data", content, metadata: { bounds, timestamp: Date.now(), ...(sourceGlyph ? { sourceGlyph } : {}) }, }; - window.electronAPI.clipboardWriteText(JSON.stringify(payload)); + this.#deps.clipboard.writeText(JSON.stringify(payload)); return true; } catch { return false; @@ -124,8 +131,7 @@ export class Clipboard { async #read(): Promise<{ content: ClipboardContent | null }> { try { - if (!window.electronAPI) return this.#internalState; - const text = window.electronAPI.clipboardReadText(); + const text = this.#deps.clipboard.readText(); const native = tryDeserialize(text); if (native) return { content: native }; diff --git a/apps/desktop/src/renderer/src/lib/clipboard/electronSystemClipboard.ts b/apps/desktop/src/renderer/src/lib/clipboard/electronSystemClipboard.ts new file mode 100644 index 00000000..e4b729ae --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/clipboard/electronSystemClipboard.ts @@ -0,0 +1,18 @@ +import type { SystemClipboard } from "./types"; + +/** + * Production {@link SystemClipboard} backed by Electron's preload-exposed + * clipboard IPC (`window.electronAPI.clipboard*`). Throws if `electronAPI` + * is missing so misconfiguration surfaces loudly instead of silently + * dropping clipboard ops. + */ +export const electronSystemClipboard: SystemClipboard = { + writeText(text: string): void { + if (!window.electronAPI) throw new Error("electronAPI is not available"); + window.electronAPI.clipboardWriteText(text); + }, + readText(): string { + if (!window.electronAPI) throw new Error("electronAPI is not available"); + return window.electronAPI.clipboardReadText(); + }, +}; diff --git a/apps/desktop/src/renderer/src/lib/clipboard/index.ts b/apps/desktop/src/renderer/src/lib/clipboard/index.ts index e8c7c3f6..4b4c6c43 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/index.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/index.ts @@ -1,6 +1,8 @@ export { Clipboard, resolveClipboardContent, type ClipboardDeps } from "./Clipboard"; export { SvgImporter } from "./importers/SvgImporter"; +export { electronSystemClipboard } from "./electronSystemClipboard"; export type { + SystemClipboard, ClipboardContent, ClipboardImporter, ClipboardPayload, diff --git a/apps/desktop/src/renderer/src/lib/clipboard/types.ts b/apps/desktop/src/renderer/src/lib/clipboard/types.ts index 2b0c9701..a6fd6cdd 100644 --- a/apps/desktop/src/renderer/src/lib/clipboard/types.ts +++ b/apps/desktop/src/renderer/src/lib/clipboard/types.ts @@ -43,6 +43,16 @@ export interface ClipboardImporter { import(text: string): ClipboardContent | null; } +/** + * The OS-level clipboard — the boundary between the {@link Clipboard} + * orchestrator and Electron's `clipboard` module (via preload). Production + * wiring uses {@link electronSystemClipboard}; tests inject an in-memory fake. + */ +export interface SystemClipboard { + writeText(text: string): void; + readText(): string; +} + /** Current in-memory clipboard state held by the clipboard service. */ export interface ClipboardState { content: ClipboardContent | null; diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index aca709e5..21beff61 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -50,7 +50,7 @@ import { type Signal, type WritableSignal, } from "../reactive/signal"; -import { Clipboard, resolveClipboardContent } from "../clipboard"; +import { Clipboard, resolveClipboardContent, type SystemClipboard } from "../clipboard"; import { cursorToCSS } from "../styles/cursor"; import { DEFAULT_THEME } from "./rendering/Theme"; import { hitTestBoundingBox, isBoundingBoxVisibleAtZoom } from "./hit/boundingBox"; @@ -110,6 +110,11 @@ import { StateRegistry, type ShiftState, type ShiftStateOptions } from "@/lib/st import type { LineSegment } from "@/types/segments"; import type { GlyphDraft } from "@/types/draft"; +interface EditorOptions { + bridge: NativeBridge; + clipboard: SystemClipboard; +} + /** * Central orchestrator for the glyph editing surface. * @@ -197,11 +202,14 @@ export class Editor { * reactive effects that schedule canvas redraws when state changes. * */ - constructor(options: { bridge: NativeBridge }) { + constructor(options: EditorOptions) { this.#viewport = new ViewportManager(); + this.#bridge = options.bridge; + this.font = new Font(this.#bridge); this.#$glyph = computed(() => this.#bridge.$glyph.value as Glyph | null); + this.#$segmentIndex = computed(() => { const glyph = this.#$glyph.value; if (!glyph) return new Map(); @@ -211,6 +219,7 @@ export class Editor { } return segmentsById; }); + this.#commandHistory = new CommandHistory(this.#$glyph); this.#previewMode = signal(false); @@ -280,6 +289,7 @@ export class Editor { glyph: this.#$glyph, selection: this.selection, commands: this.#commandHistory, + clipboard: options.clipboard, }); this.#textRunController = new TextRunController(); this.#textRunController.setFont(this.font); diff --git a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts index 820de7b9..1e91795e 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { ToolManager } from "./ToolManager"; import { TestEditor } from "@/testing"; import type { Modifiers } from "./GestureDetector"; @@ -146,25 +146,23 @@ describe("ToolManager", () => { expect(toolManager.activeToolId).toBe("pen"); }); - it("should call onActivate callback when requesting temporary tool", () => { + it("runs the onActivate callback when requesting a temporary tool", () => { toolManager.activate("pen"); - const onActivate = { fn: () => {} }; - const spy = vi.spyOn(onActivate, "fn"); + let activated = false; - editor.requestTemporaryTool("hand", { onActivate: onActivate.fn }); + editor.requestTemporaryTool("hand", { onActivate: () => (activated = true) }); - expect(spy).toHaveBeenCalled(); + expect(activated).toBe(true); }); - it("should call onReturn callback when returning from temporary tool", () => { + it("runs the onReturn callback when returning from a temporary tool", () => { toolManager.activate("pen"); - const onReturn = { fn: () => {} }; - const spy = vi.spyOn(onReturn, "fn"); + let returned = false; - editor.requestTemporaryTool("hand", { onReturn: onReturn.fn }); + editor.requestTemporaryTool("hand", { onReturn: () => (returned = true) }); editor.returnFromTemporaryTool(); - expect(spy).toHaveBeenCalled(); + expect(returned).toBe(true); }); }); @@ -184,67 +182,15 @@ describe("ToolManager", () => { }); it("drag (down, move, up) drives tool with dragStart, drag, dragEnd and does not emit click", () => { - const originalRAF = globalThis.requestAnimationFrame; - vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { - cb(); - return 0; - }); - try { - toolManager.activate("hand"); - toolManager.handlePointerDown({ x: 100, y: 100 }, modifiers); - toolManager.handlePointerMove({ x: 120, y: 100 }, modifiers); - toolManager.handlePointerUp({ x: 120, y: 100 }); - - expect(toolManager.isDragging).toBe(false); - const lastState = editor.getActiveToolState() as { type?: string }; - expect(lastState?.type).toBe("ready"); - } finally { - vi.stubGlobal("requestAnimationFrame", originalRAF); - } - }); - - it("deduplicates pointer move when screen point unchanged (no force)", () => { - const originalRAF = globalThis.requestAnimationFrame; - vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { - cb(); - return 0; - }); - try { - toolManager.activate("select"); - const handleEventSpy = vi.spyOn(toolManager.activeTool!, "handleEvent"); - - toolManager.handlePointerMove({ x: 100, y: 100 }, modifiers); - toolManager.handlePointerMove({ x: 100, y: 100 }, modifiers); - - const pointerMoveCalls = handleEventSpy.mock.calls.filter( - (c) => c[0] && (c[0] as { type?: string }).type === "pointerMove", - ); - expect(pointerMoveCalls).toHaveLength(1); - } finally { - vi.stubGlobal("requestAnimationFrame", originalRAF); - } - }); + toolManager.activate("hand"); + toolManager.handlePointerDown({ x: 100, y: 100 }, modifiers); + toolManager.handlePointerMove({ x: 120, y: 100 }, modifiers); + toolManager.flushPointerMoves(); + toolManager.handlePointerUp({ x: 120, y: 100 }); - it("processes pointer move when screen point unchanged if force: true (e.g. wheel pan)", () => { - const originalRAF = globalThis.requestAnimationFrame; - vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { - cb(); - return 0; - }); - try { - toolManager.activate("select"); - const handleEventSpy = vi.spyOn(toolManager.activeTool!, "handleEvent"); - - toolManager.handlePointerMove({ x: 100, y: 100 }, modifiers); - toolManager.handlePointerMove({ x: 100, y: 100 }, modifiers, { force: true }); - - const pointerMoveCalls = handleEventSpy.mock.calls.filter( - (c) => c[0] && (c[0] as { type?: string }).type === "pointerMove", - ); - expect(pointerMoveCalls).toHaveLength(2); - } finally { - vi.stubGlobal("requestAnimationFrame", originalRAF); - } + expect(toolManager.isDragging).toBe(false); + const lastState = editor.getActiveToolState() as { type?: string }; + expect(lastState?.type).toBe("ready"); }); }); @@ -259,21 +205,13 @@ describe("ToolManager", () => { }); it("updates currentModifiers on flushPointerMove", () => { - const originalRAF = globalThis.requestAnimationFrame; - vi.stubGlobal("requestAnimationFrame", (cb: () => void) => { - cb(); - return 0; - }); - try { - toolManager.activate("select"); - const mods: Modifiers = { shiftKey: true, altKey: true, metaKey: false }; + toolManager.activate("select"); + const mods: Modifiers = { shiftKey: true, altKey: true, metaKey: false }; - toolManager.handlePointerMove({ x: 10, y: 10 }, mods); + toolManager.handlePointerMove({ x: 10, y: 10 }, mods); + toolManager.flushPointerMoves(); - expect(editor.currentModifiers).toEqual(mods); - } finally { - vi.stubGlobal("requestAnimationFrame", originalRAF); - } + expect(editor.currentModifiers).toEqual(mods); }); it("updates currentModifiers on handleKeyDown", () => { diff --git a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts index c9e3c39b..c886c7ed 100644 --- a/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts +++ b/apps/desktop/src/renderer/src/lib/tools/core/ToolManager.ts @@ -134,6 +134,22 @@ export class ToolManager implements ToolSwitchHandler { } } + /** + * Drain any pending pointer-move synchronously. + * + * `handlePointerMove` normally coalesces calls via `requestAnimationFrame`. + * Tests and automation that need the full move pipeline (gesture → tool + * event → state signal → cursor effect → hover update) to complete before + * the next action call this to bypass rAF. + */ + flushPointerMoves(): void { + if (this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + } + this.flushPointerMove(); + } + private flushPointerMove(): void { this.frameId = null; diff --git a/apps/desktop/src/renderer/src/lib/tools/hand/Hand.outcome.test.ts b/apps/desktop/src/renderer/src/lib/tools/hand/Hand.outcome.test.ts deleted file mode 100644 index 1ce8dd61..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/hand/Hand.outcome.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { ToolEvent } from "../core/GestureDetector"; -import { Hand } from "./Hand"; -import { makeTestCoordinates, TestEditor } from "@/testing"; - -const p = { x: 0, y: 0 }; -const coordsP = makeTestCoordinates(p); - -function makeDragStart(): ToolEvent { - return { - type: "dragStart", - point: p, - coords: coordsP, - screenPoint: p, - shiftKey: false, - altKey: false, - metaKey: false, - }; -} -function makeDrag(screenDelta: { x: number; y: number }): ToolEvent { - return { - type: "drag", - point: p, - coords: coordsP, - screenPoint: p, - origin: p, - screenOrigin: p, - delta: p, - screenDelta, - shiftKey: false, - altKey: false, - metaKey: false, - }; -} -function makeDragEnd(): ToolEvent { - return { - type: "dragEnd", - point: p, - coords: coordsP, - screenPoint: p, - origin: p, - screenOrigin: p, - }; -} -function makeDragCancel(): ToolEvent { - return { type: "dragCancel" }; -} - -describe("Hand outcome", () => { - let hand: Hand; - - beforeEach(() => { - const editor = new TestEditor(); - hand = new Hand(editor); - }); - - it("ready + dragStart -> dragging with screenStart and startPan from editor.pan", () => { - const ready = { type: "ready" as const }; - const result = hand.transition(ready, makeDragStart()); - - expect(result.type).toBe("dragging"); - if (result.type === "dragging") { - expect(result.screenStart).toEqual(p); - expect(result.startPan).toBeDefined(); - expect(typeof result.startPan.x).toBe("number"); - expect(typeof result.startPan.y).toBe("number"); - } - }); - - it("dragging + drag -> same state (pan/redraw side effect only)", () => { - const dragging = { type: "dragging" as const, screenStart: p, startPan: { x: 10, y: 20 } }; - const result = hand.transition(dragging, makeDrag({ x: 5, y: 5 })); - - expect(result.type).toBe("dragging"); - if (result.type === "dragging") { - expect(result.screenStart).toEqual(dragging.screenStart); - expect(result.startPan).toEqual(dragging.startPan); - } - }); - - it("dragging + dragEnd -> ready", () => { - const dragging = { type: "dragging" as const, screenStart: p, startPan: p }; - const result = hand.transition(dragging, makeDragEnd()); - - expect(result.type).toBe("ready"); - }); - - it("dragging + dragCancel -> ready", () => { - const dragging = { type: "dragging" as const, screenStart: p, startPan: p }; - const result = hand.transition(dragging, makeDragCancel()); - - expect(result.type).toBe("ready"); - }); - - it("idle + any event -> idle", () => { - const idle = { type: "idle" as const }; - expect(hand.transition(idle, makeDragStart()).type).toBe("idle"); - expect(hand.transition(idle, makeDragEnd()).type).toBe("idle"); - }); - - it("ready + pointerMove -> ready (no state change)", () => { - const ready = { type: "ready" as const }; - const move: ToolEvent = { - type: "pointerMove", - point: { x: 1, y: 1 }, - coords: makeTestCoordinates({ x: 1, y: 1 }), - }; - const result = hand.transition(ready, move); - - expect(result.type).toBe("ready"); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/hand/Hand.test.ts b/apps/desktop/src/renderer/src/lib/tools/hand/Hand.test.ts new file mode 100644 index 00000000..b288b2ec --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/hand/Hand.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TestEditor } from "@/testing/TestEditor"; + +describe("Hand tool", () => { + let editor: TestEditor; + + beforeEach(() => { + editor = new TestEditor(); + editor.startSession(); + editor.selectTool("hand"); + }); + + it("drag pans the viewport by the screen delta", () => { + const startPan = editor.pan; + + editor.pointerDown(0, 0); + editor.pointerMove(50, 30); // crosses drag threshold, emits dragStart + editor.pointerMove(120, 80); // cumulative screenDelta = (120, 80) + editor.pointerUp(120, 80); + + expect(editor.pan.x).toBe(startPan.x + 120); + expect(editor.pan.y).toBe(startPan.y + 80); + }); + + it("escape mid-drag returns the tool to ready without further panning", () => { + editor.pointerDown(0, 0); + editor.pointerMove(50, 0); // start dragging + editor.pointerMove(100, 0); + const panMidDrag = editor.pan; + + editor.escape(); + + const state = editor.getActiveToolState(); + expect(state.type).toBe("ready"); + + // After cancel, further moves without a new pointerDown must not pan. + editor.pointerMove(200, 0); + expect(editor.pan).toEqual(panMidDrag); + }); + + it("pointer hover in ready state does not pan", () => { + const startPan = editor.pan; + + editor.pointerMove(50, 50); + editor.pointerMove(100, 100); + + expect(editor.pan).toEqual(startPan); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.outcome.test.ts b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.outcome.test.ts deleted file mode 100644 index 8f1eb2e6..00000000 --- a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.outcome.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { ToolEvent } from "../core/GestureDetector"; -import { Shape } from "./Shape"; -import { makeTestCoordinates, TestEditor } from "@/testing"; - -const p = { x: 0, y: 0 }; -const q = { x: 100, y: 50 }; - -function makeDragStart(point: { x: number; y: number } = p): ToolEvent { - return { - type: "dragStart", - point, - coords: makeTestCoordinates(point), - screenPoint: point, - shiftKey: false, - altKey: false, - metaKey: false, - }; -} -function makeDrag(point: { x: number; y: number }): ToolEvent { - return { - type: "drag", - point, - coords: makeTestCoordinates(point), - screenPoint: point, - origin: p, - screenOrigin: p, - delta: { x: point.x - p.x, y: point.y - p.y }, - screenDelta: { x: 0, y: 0 }, - shiftKey: false, - altKey: false, - metaKey: false, - }; -} -function makeDragEnd(point: { x: number; y: number } = q): ToolEvent { - return { - type: "dragEnd", - point, - coords: makeTestCoordinates(point), - screenPoint: point, - origin: p, - screenOrigin: p, - }; -} -function makeDragCancel(): ToolEvent { - return { type: "dragCancel" }; -} - -describe("Shape outcome", () => { - let shape: Shape; - - beforeEach(() => { - const editor = new TestEditor(); - shape = new Shape(editor); - }); - - it("ready + dragStart -> dragging with startPos and currentPos", () => { - const ready = { type: "ready" as const }; - const result = shape.transition(ready, makeDragStart(q)); - - expect(result.type).toBe("dragging"); - if (result.type === "dragging") { - expect(result.startPos).toEqual(q); - expect(result.currentPos).toEqual(q); - } - }); - - it("dragging + drag -> dragging with updated currentPos", () => { - const dragging = { - type: "dragging" as const, - startPos: p, - currentPos: p, - }; - const result = shape.transition(dragging, makeDrag(q)); - - expect(result.type).toBe("dragging"); - if (result.type === "dragging") { - expect(result.startPos).toEqual(p); - expect(result.currentPos).toEqual(q); - } - }); - - it("dragging + dragEnd -> ready", () => { - const dragging = { - type: "dragging" as const, - startPos: p, - currentPos: q, - }; - const result = shape.transition(dragging, makeDragEnd()); - - expect(result.type).toBe("ready"); - }); - - it("dragging + dragCancel -> ready", () => { - const dragging = { - type: "dragging" as const, - startPos: p, - currentPos: q, - }; - const result = shape.transition(dragging, makeDragCancel()); - - expect(result.type).toBe("ready"); - }); - - it("idle + any event -> idle", () => { - const idle = { type: "idle" as const }; - expect(shape.transition(idle, makeDragStart()).type).toBe("idle"); - expect(shape.transition(idle, makeDragEnd()).type).toBe("idle"); - }); - - it("ready + pointerMove -> ready (no state change)", () => { - const ready = { type: "ready" as const }; - const move: ToolEvent = { - type: "pointerMove", - point: q, - coords: makeTestCoordinates(q), - }; - const result = shape.transition(ready, move); - - expect(result.type).toBe("ready"); - }); -}); diff --git a/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts new file mode 100644 index 00000000..3d94b9ce --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/tools/shape/Shape.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TestEditor } from "@/testing/TestEditor"; + +describe("Shape tool", () => { + let editor: TestEditor; + + beforeEach(() => { + editor = new TestEditor(); + editor.startSession(); + editor.selectTool("shape"); + }); + + it("drag then release commits a closed 4-point rectangle contour", () => { + const glyph = editor.currentGlyph; + if (!glyph) return; + const contoursBefore = glyph.contours.length; + + editor.pointerDown(10, 10); + editor.pointerMove(50, 30); // crosses drag threshold + editor.pointerMove(110, 90); + editor.pointerUp(110, 90); + + const contours = glyph.contours; + expect(contours.length).toBe(contoursBefore + 1); + + const created = contours[contours.length - 1]!; + expect(created.points.length).toBe(4); + expect(created.closed).toBe(true); + }); + + it("escape mid-drag discards the preview without committing a contour", () => { + const glyph = editor.currentGlyph; + if (!glyph) return; + const contoursBefore = glyph.contours.length; + + editor.pointerDown(10, 10); + editor.pointerMove(50, 30); + editor.pointerMove(110, 90); + editor.escape(); + + expect(glyph.contours.length).toBe(contoursBefore); + const state = editor.getActiveToolState(); + expect(state.type).toBe("ready"); + }); + + it("drag smaller than the 3-unit minimum does not commit", () => { + const glyph = editor.currentGlyph; + if (!glyph) return; + const contoursBefore = glyph.contours.length; + + editor.pointerDown(10, 10); + editor.pointerMove(14, 14); // past drag threshold but resulting rect is 4x4 in screen coords + editor.pointerMove(12, 12); // shrink below minimum width/height + editor.pointerUp(12, 12); + + expect(glyph.contours.length).toBe(contoursBefore); + }); +}); diff --git a/apps/desktop/src/renderer/src/store/store.ts b/apps/desktop/src/renderer/src/store/store.ts index 2ed46e84..addeafac 100644 --- a/apps/desktop/src/renderer/src/store/store.ts +++ b/apps/desktop/src/renderer/src/store/store.ts @@ -1,5 +1,6 @@ import { Editor } from "@/lib/editor/Editor"; import { NativeBridge } from "@/bridge/NativeBridge"; +import { electronSystemClipboard } from "@/lib/clipboard"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { create } from "zustand"; import type { StoreApi } from "zustand"; @@ -23,7 +24,10 @@ function getFileNameFromPath(path: string | null): string | null { } const createStore = (set: StoreApi["setState"]): AppState => { - const editor = new Editor({ bridge: new NativeBridge() }); + const editor = new Editor({ + bridge: new NativeBridge(), + clipboard: electronSystemClipboard, + }); registerBuiltInTools(editor); // Set select tool as ready on startup diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.test.ts b/apps/desktop/src/renderer/src/testing/TestEditor.test.ts new file mode 100644 index 00000000..7547d386 --- /dev/null +++ b/apps/desktop/src/renderer/src/testing/TestEditor.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TestEditor } from "./TestEditor"; + +describe("TestEditor", () => { + let editor: TestEditor; + + beforeEach(() => { + editor = new TestEditor(); + editor.startSession(); + }); + + describe("pointerMove", () => { + it("drives the tool pipeline synchronously via the flush seam", () => { + editor.selectTool("pen"); + + // Two distinct moves must both register synchronously — a single-flush + // rAF implementation would coalesce them and only the latest would land. + editor.pointerMove(100, 100); + const first = editor.getActiveToolState().mousePos; + + editor.pointerMove(200, 200); + const second = editor.getActiveToolState().mousePos; + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(second).not.toEqual(first); + }); + }); +}); diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index a95dd5fe..6318eada 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -16,15 +16,39 @@ import { Editor } from "@/lib/editor/Editor"; import type { ToolName } from "@/lib/tools/core"; import { registerBuiltInTools } from "@/lib/tools/tools"; import { createBridge } from "./engine"; +import type { SystemClipboard } from "@/lib/clipboard"; const DEFAULT_MODIFIERS = { shiftKey: false, altKey: false, metaKey: false }; +/** + * In-memory {@link SystemClipboard} for tests. The buffer is directly + * readable via {@link TestEditor.clipboardBuffer} so tests can assert on + * what the Editor wrote without needing a round-trip. + */ +class InMemorySystemClipboard implements SystemClipboard { + buffer = ""; + writeText(text: string): void { + this.buffer = text; + } + readText(): string { + return this.buffer; + } +} + export class TestEditor extends Editor { + readonly #clipboard: InMemorySystemClipboard; + constructor() { - super({ bridge: createBridge() }); + const clipboard = new InMemorySystemClipboard(); + super({ bridge: createBridge(), clipboard }); + this.#clipboard = clipboard; registerBuiltInTools(this); } + get clipboardBuffer(): string { + return this.#clipboard.buffer; + } + startSession(glyphName = "A"): this { this.open(glyphName); return this; @@ -48,6 +72,7 @@ export class TestEditor extends Editor { { ...DEFAULT_MODIFIERS, ...options }, { force: true }, ); + this.toolManager.flushPointerMoves(); return this; } diff --git a/packages/font/src/Glyph.ts b/packages/font/src/Glyph.ts index 97d2d46b..a565371f 100644 --- a/packages/font/src/Glyph.ts +++ b/packages/font/src/Glyph.ts @@ -8,7 +8,6 @@ * @module */ import type { Point, Contour, Glyph, PointId, ContourId, Point2D } from "@shift/types"; -import { Vec2 } from "@shift/geo"; /** * A point together with the contour it belongs to and its index within that @@ -73,11 +72,20 @@ export const Glyphs = { /** * Find the first point within `radius` of `pos` (linear scan). * @returns The matching point, or `null` if none is close enough. + * + * Hot path — called on every pointer-move from the cursor computed. + * Iterates contour points inline (no `points()` generator allocation) + * and uses squared-distance comparison (no `Math.sqrt`). */ getPointAt(glyph: Glyph, pos: Point2D, radius: number): Point | null { - for (const { point } of Glyphs.points(glyph)) { - if (Vec2.dist(point, pos) < radius) { - return point; + const r2 = radius * radius; + const px = pos.x; + const py = pos.y; + for (const contour of glyph.contours) { + for (const point of contour.points) { + const dx = point.x - px; + const dy = point.y - py; + if (dx * dx + dy * dy < r2) return point; } } return null;