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
53 changes: 53 additions & 0 deletions apps/desktop/src/renderer/src/bridge/NativeBridge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
87 changes: 87 additions & 0 deletions apps/desktop/src/renderer/src/lib/clipboard/Clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
16 changes: 11 additions & 5 deletions apps/desktop/src/renderer/src/lib/clipboard/Clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,6 +36,13 @@ export interface ClipboardDeps {
readonly glyph: Signal<Glyph | null>;
readonly selection: Selection;
readonly commands: CommandHistory;
readonly clipboard: SystemClipboard;
}

interface ClipboardState {
content: ClipboardContent | null;
bounds: Rect2D | null;
timestamp: number;
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
};
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/lib/clipboard/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/src/lib/clipboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/renderer/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<Glyph | null>(() => this.#bridge.$glyph.value as Glyph | null);

this.#$segmentIndex = computed(() => {
const glyph = this.#$glyph.value;
if (!glyph) return new Map();
Expand All @@ -211,6 +219,7 @@ export class Editor {
}
return segmentsById;
});

this.#commandHistory = new CommandHistory(this.#$glyph);

this.#previewMode = signal(false);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading