@moloodev/editor-sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +62 -3
  2. package/src/channel.ts +287 -0
  3. package/src/chrome-manifest.ts +111 -0
  4. package/src/commands/clipboard/router.ts +99 -0
  5. package/src/commands/clipboard/types.ts +20 -0
  6. package/src/commands/history/stack.ts +53 -0
  7. package/src/commands/icons/catalog.ts +172 -0
  8. package/src/commands/index.ts +100 -0
  9. package/src/commands/keys/display.ts +16 -0
  10. package/src/commands/keys/normalize.ts +55 -0
  11. package/src/commands/keys/reserved.ts +62 -0
  12. package/src/commands/keys/validate.ts +77 -0
  13. package/src/commands/manifest/configuration.ts +59 -0
  14. package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
  15. package/src/commands/manifest/editor-document.ts +501 -0
  16. package/src/commands/manifest/ids.ts +2 -0
  17. package/src/commands/manifest/merge.ts +215 -0
  18. package/src/commands/manifest/types.ts +113 -0
  19. package/src/commands/manifest/validate-manifest.ts +45 -0
  20. package/src/commands/react/context.tsx +249 -0
  21. package/src/commands/react/hotkey-defaults.ts +11 -0
  22. package/src/commands/react/scope-context.tsx +31 -0
  23. package/src/commands/react/scope-provider.tsx +123 -0
  24. package/src/commands/react/store.ts +182 -0
  25. package/src/commands/registry/create.ts +55 -0
  26. package/src/commands/registry/types.ts +12 -0
  27. package/src/commands/when/evaluate.ts +80 -0
  28. package/src/commands/when/index.ts +19 -0
  29. package/src/commands/when/parse.ts +276 -0
  30. package/src/commands/when/types.ts +24 -0
  31. package/src/commands/when/validate.ts +20 -0
  32. package/src/crdt/awareness.ts +29 -0
  33. package/src/crdt/bytes.ts +26 -0
  34. package/src/crdt/connect.ts +285 -0
  35. package/src/crdt/diff.ts +86 -0
  36. package/src/crdt/doc.ts +67 -0
  37. package/src/crdt/front-matter.ts +45 -0
  38. package/src/crdt/index.ts +76 -0
  39. package/src/crdt/persist.ts +40 -0
  40. package/src/crdt/protocol.ts +119 -0
  41. package/src/crdt/shared.ts +104 -0
  42. package/src/crdt/text-body.ts +125 -0
  43. package/src/crdt/undo.ts +59 -0
  44. package/src/crdt/vite-plugin.ts +42 -0
  45. package/src/crdt/yjs.ts +16 -0
  46. package/src/editor-common.ts +160 -0
  47. package/src/editor-id.ts +56 -0
  48. package/src/frame-attributes.ts +9 -0
  49. package/src/index.ts +229 -0
  50. package/src/json-schema.ts +13 -0
  51. package/src/layout/bind-toolbar.ts +60 -0
  52. package/src/layout/catalog.ts +82 -0
  53. package/src/layout/chrome/command-affordances.ts +47 -0
  54. package/src/layout/chrome/command-layout-state.ts +42 -0
  55. package/src/layout/chrome/editor-menu.tsx +358 -0
  56. package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
  57. package/src/layout/chrome/menu-styles.ts +3 -0
  58. package/src/layout/chrome/mid-menu-nodes.ts +27 -0
  59. package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
  60. package/src/layout/chrome/visible-menu-leaves.ts +15 -0
  61. package/src/layout/content-error-notice.tsx +31 -0
  62. package/src/layout/define-editor-layout.ts +60 -0
  63. package/src/layout/define-editor-layout.types.ts +57 -0
  64. package/src/layout/doc-undo-bridge.tsx +22 -0
  65. package/src/layout/dock-zones.tsx +61 -0
  66. package/src/layout/drop.ts +39 -0
  67. package/src/layout/editor-layout.tsx +287 -0
  68. package/src/layout/editor-loading.tsx +23 -0
  69. package/src/layout/frame-layout-binding.tsx +74 -0
  70. package/src/layout/frame.ts +23 -0
  71. package/src/layout/hide.ts +103 -0
  72. package/src/layout/index.ts +14 -0
  73. package/src/layout/layout-binding.tsx +42 -0
  74. package/src/layout/layout-storage-key.ts +6 -0
  75. package/src/layout/layout-values.ts +48 -0
  76. package/src/layout/mutate.ts +486 -0
  77. package/src/layout/nesting-context.tsx +20 -0
  78. package/src/layout/panel-header-action.tsx +29 -0
  79. package/src/layout/panel-header.tsx +168 -0
  80. package/src/layout/panel-hide-menu.tsx +6 -0
  81. package/src/layout/panel-menu.tsx +85 -0
  82. package/src/layout/panel-section.tsx +114 -0
  83. package/src/layout/panel-size.ts +94 -0
  84. package/src/layout/panel-title.tsx +5 -0
  85. package/src/layout/registry.tsx +333 -0
  86. package/src/layout/restore-panel.ts +26 -0
  87. package/src/layout/slots-context.tsx +189 -0
  88. package/src/layout/tab-button.tsx +60 -0
  89. package/src/layout/toolbar-scroll.ts +22 -0
  90. package/src/layout/toolbar.tsx +168 -0
  91. package/src/layout/types.ts +41 -0
  92. package/src/layout/use-collapsed-sections.ts +50 -0
  93. package/src/layout/validate-layout-spec.ts +143 -0
  94. package/src/message-samples.ts +377 -0
  95. package/src/origin-headers.ts +57 -0
  96. package/src/print/index.ts +10 -0
  97. package/src/print/inspect.ts +16 -0
  98. package/src/print/schema.ts +122 -0
  99. package/src/protocol-entry.ts +19 -0
  100. package/src/protocol.ts +471 -0
  101. package/src/render-result.ts +25 -0
  102. package/src/view/editor-chrome.ts +28 -0
  103. package/src/view/hooks.ts +260 -0
  104. package/src/view/mount-editor-view.ts +36 -0
  105. package/src/view/port-binding.ts +127 -0
  106. package/src/view/port-commands.ts +36 -0
  107. package/src/view/port.ts +35 -0
  108. package/src/view/theme-class.ts +11 -0
  109. package/src/view.ts +313 -0
  110. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@moloodev/editor-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/view.ts",
7
+ "./view": "./src/view.ts",
8
+ "./protocol": "./src/protocol-entry.ts",
9
+ "./render-result": "./src/render-result.ts",
10
+ "./origin-headers": "./src/origin-headers.ts",
11
+ "./layout": "./src/layout/index.ts",
12
+ "./layout/frame": "./src/layout/frame.ts",
13
+ "./commands": "./src/commands/index.ts",
14
+ "./commands/editor-document": "./src/commands/manifest/editor-document.ts",
15
+ "./commands/when": "./src/commands/when/validate.ts",
16
+ "./crdt": "./src/crdt/index.ts",
17
+ "./crdt/vite": "./src/crdt/vite-plugin.ts",
18
+ "./print": "./src/print/index.ts"
19
+ },
20
+ "dependencies": {
21
+ "@dnd-kit/react": "^0.5.0",
22
+ "@json-render/core": "^0.20.0",
23
+ "@json-render/react": "^0.20.0",
24
+ "@tabler/icons-react": "^3.46.0",
25
+ "@tanstack/react-hotkeys": "^0.10.0",
26
+ "lib0": "^0.2.117",
27
+ "y-indexeddb": "^9.0.12",
28
+ "y-protocols": "^1.0.7",
29
+ "yjs": "^13.6.32",
30
+ "zod": "^4.5.4",
31
+ "@moloodev/platform": "0.2.0",
32
+ "@moloodev/ui": "0.2.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^19.0.0",
36
+ "react-dom": "^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@playwright/test": "1.56.1",
40
+ "@types/jsdom": "30.0.0",
41
+ "@types/node": "^24.13.3",
42
+ "@types/react": "^19.2.18",
43
+ "@types/react-dom": "^19.2.7",
44
+ "esbuild": "^0.28.2",
45
+ "jsdom": "30.1.1",
46
+ "react": "^19.2.8",
47
+ "react-dom": "^19.2.8",
48
+ "tsx": "^4.23.13",
49
+ "typescript": "^7.0.2",
50
+ "@moloodev/typescript-config": "0.2.0"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "files": [
56
+ "src",
57
+ "!src/**/*.test.ts",
58
+ "!src/**/*.test.tsx"
59
+ ],
60
+ "scripts": {
61
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.origin-headers.json",
62
+ "test": "tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx' && pnpm test:browser",
63
+ "test:browser": "tsx --test 'browser/**/*.test.ts'"
64
+ }
6
65
  }
package/src/channel.ts ADDED
@@ -0,0 +1,287 @@
1
+ import type {
2
+ EditorEvent,
3
+ EditorFailure,
4
+ EditorOperation,
5
+ EditorRequest,
6
+ EditorRequestType,
7
+ EditorResponse,
8
+ EditorSuccess,
9
+ } from "./index";
10
+ import {
11
+ parseEditorEvent,
12
+ parseEditorRequest,
13
+ parseEditorResponseFor,
14
+ parseHandshakeReply,
15
+ parseSessionOpen,
16
+ } from "./protocol";
17
+
18
+ /** One deadline for every paired request and for the view handshake. host.pick shares it. */
19
+ export const EDITOR_REQUEST_TIMEOUT_MS = 30_000;
20
+
21
+ export type EditorPort = {
22
+ postMessage(data: unknown): void;
23
+ start(): void;
24
+ close(): void;
25
+ addEventListener(
26
+ type: "message",
27
+ listener: (event: { readonly data: unknown }) => void,
28
+ ): void;
29
+ removeEventListener(
30
+ type: "message",
31
+ listener: (event: { readonly data: unknown }) => void,
32
+ ): void;
33
+ };
34
+
35
+ export type HandshakeResult =
36
+ | { ok: true }
37
+ | { ok: false; code: "version" | "schema" | "rejected" | "timeout" };
38
+
39
+ export type EditorRequestHandler = (
40
+ request: EditorRequest,
41
+ ) => Promise<
42
+ | { ok: true; body: EditorSuccess["body"] }
43
+ | { ok: false; code: string }
44
+ >;
45
+
46
+ export type EditorChannel = {
47
+ readonly handshake: Promise<HandshakeResult>;
48
+ request<Type extends EditorRequestType>(
49
+ type: Type,
50
+ body: EditorOperation[Type]["body"],
51
+ ): Promise<EditorResponse<Type>>;
52
+ emit(event: EditorEvent): boolean;
53
+ close(): void;
54
+ };
55
+
56
+ type Role = "host" | "view";
57
+ type Phase = "wait" | "open" | "closed";
58
+ type CloseCode = "version" | "schema" | "rejected" | "timeout" | "closed";
59
+
60
+ type Pending = {
61
+ timer: ReturnType<typeof setTimeout>;
62
+ accept(value: unknown): void;
63
+ cancel(): void;
64
+ };
65
+
66
+ function failure(id: string, code: string): EditorFailure {
67
+ return { v: 1, id, ok: false, code };
68
+ }
69
+
70
+ function handlerFailure(code: string): { ok: false; code: string } {
71
+ return { ok: false, code };
72
+ }
73
+
74
+ function field(value: unknown, key: string): unknown {
75
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
76
+ return undefined;
77
+ }
78
+ if (!Object.hasOwn(value, key)) return undefined;
79
+ return Reflect.get(value, key);
80
+ }
81
+
82
+ export function createEditorChannel(options: {
83
+ role: Role;
84
+ editorId: string;
85
+ port: EditorPort;
86
+ onRequest?: EditorRequestHandler;
87
+ onEvent?: (event: EditorEvent) => void;
88
+ }): EditorChannel {
89
+ const { role, editorId, port, onRequest, onEvent } = options;
90
+ let phase: Phase = "wait";
91
+ let nextId = 0;
92
+ const pending = new Map<string, Pending>();
93
+ let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
94
+ let handshakeSettled = false;
95
+ let settleHandshake: (result: HandshakeResult) => void = () => {};
96
+ const handshake = new Promise<HandshakeResult>((resolve) => {
97
+ settleHandshake = (result) => {
98
+ if (handshakeSettled) return;
99
+ handshakeSettled = true;
100
+ resolve(result);
101
+ };
102
+ });
103
+
104
+ function post(data: unknown) {
105
+ if (phase === "closed") return;
106
+ port.postMessage(data);
107
+ }
108
+
109
+ function disarmHandshakeTimer() {
110
+ if (handshakeTimer === undefined) return;
111
+ clearTimeout(handshakeTimer);
112
+ handshakeTimer = undefined;
113
+ }
114
+
115
+ function closeWith(code: CloseCode) {
116
+ if (phase === "closed") return;
117
+ phase = "closed";
118
+ disarmHandshakeTimer();
119
+ port.removeEventListener("message", onMessage);
120
+ port.close();
121
+ const waiting = [...pending.values()];
122
+ for (const entry of waiting) clearTimeout(entry.timer);
123
+ pending.clear();
124
+ for (const entry of waiting) entry.cancel();
125
+ if (code === "closed") {
126
+ settleHandshake({ ok: false, code: "rejected" });
127
+ return;
128
+ }
129
+ settleHandshake({ ok: false, code });
130
+ }
131
+
132
+ function rejectOpen(code: "version" | "schema" | "rejected") {
133
+ post({ v: 1, ok: false, code });
134
+ closeWith(code);
135
+ }
136
+
137
+ function take(id: string): Pending | undefined {
138
+ const entry = pending.get(id);
139
+ if (!entry) return undefined;
140
+ pending.delete(id);
141
+ clearTimeout(entry.timer);
142
+ return entry;
143
+ }
144
+
145
+ async function replyTo(request: EditorRequest) {
146
+ let result: Awaited<ReturnType<EditorRequestHandler>>;
147
+ try {
148
+ result = onRequest ? await onRequest(request) : handlerFailure("schema");
149
+ } catch {
150
+ result = handlerFailure("schema");
151
+ }
152
+ if (phase === "closed") return;
153
+ const wire = result.ok
154
+ ? { v: 1, id: request.id, ok: true, body: result.body }
155
+ : failure(request.id, result.code);
156
+ const parsed = parseEditorResponseFor(request.type, wire);
157
+ if (!parsed.ok) {
158
+ post(failure(request.id, parsed.code));
159
+ return;
160
+ }
161
+ post(parsed.message);
162
+ }
163
+
164
+ function takeReply(value: unknown) {
165
+ const reply = parseHandshakeReply(value);
166
+ if (!reply.ok) {
167
+ closeWith(reply.code);
168
+ return;
169
+ }
170
+ if (!reply.message.ok) {
171
+ closeWith(reply.message.code);
172
+ return;
173
+ }
174
+ disarmHandshakeTimer();
175
+ phase = "open";
176
+ settleHandshake({ ok: true });
177
+ }
178
+
179
+ function onMessage(event: { readonly data: unknown }) {
180
+ if (phase === "closed") return;
181
+ const value = event.data;
182
+ if (role === "view" && phase === "wait") {
183
+ takeReply(value);
184
+ return;
185
+ }
186
+ if (field(value, "type") === "session.open") {
187
+ if (phase === "open") {
188
+ rejectOpen("rejected");
189
+ return;
190
+ }
191
+ const opened = parseSessionOpen(value);
192
+ if (!opened.ok) {
193
+ rejectOpen(opened.code);
194
+ return;
195
+ }
196
+ if (opened.message.body.editorId !== editorId) {
197
+ rejectOpen("rejected");
198
+ return;
199
+ }
200
+ post({ v: 1, ok: true });
201
+ phase = "open";
202
+ settleHandshake({ ok: true });
203
+ return;
204
+ }
205
+ if (phase === "wait") return;
206
+ const id = field(value, "id");
207
+ if (typeof id === "string") {
208
+ const entry = take(id);
209
+ if (entry) {
210
+ entry.accept(value);
211
+ return;
212
+ }
213
+ }
214
+ if (typeof field(value, "ok") === "boolean") return;
215
+ const request = parseEditorRequest(value);
216
+ if (request.ok) {
217
+ void replyTo(request.message);
218
+ return;
219
+ }
220
+ if (request.code === "version" && typeof id === "string") {
221
+ post(failure(id, "version"));
222
+ return;
223
+ }
224
+ const incoming = parseEditorEvent(value);
225
+ if (incoming.ok) {
226
+ onEvent?.(incoming.message);
227
+ return;
228
+ }
229
+ if (typeof id === "string") post(failure(id, incoming.code));
230
+ }
231
+
232
+ function request<Type extends EditorRequestType>(
233
+ type: Type,
234
+ body: EditorOperation[Type]["body"],
235
+ ): Promise<EditorResponse<Type>> {
236
+ nextId += 1;
237
+ const id = `${role}:${nextId}`;
238
+ if (phase !== "open") return Promise.resolve(failure(id, "closed"));
239
+ return new Promise((resolve) => {
240
+ const timer = setTimeout(() => {
241
+ if (!pending.delete(id)) return;
242
+ resolve(failure(id, "timeout"));
243
+ }, EDITOR_REQUEST_TIMEOUT_MS);
244
+ pending.set(id, {
245
+ timer,
246
+ accept(value: unknown) {
247
+ const parsed = parseEditorResponseFor(type, value);
248
+ if (!parsed.ok) {
249
+ resolve(failure(id, parsed.code));
250
+ return;
251
+ }
252
+ resolve(parsed.message);
253
+ },
254
+ cancel() {
255
+ resolve(failure(id, "closed"));
256
+ },
257
+ });
258
+ post({ v: 1, id, type, body });
259
+ });
260
+ }
261
+
262
+ function emit(event: EditorEvent): boolean {
263
+ if (phase !== "open") return false;
264
+ const parsed = parseEditorEvent(event);
265
+ if (!parsed.ok) return false;
266
+ post(parsed.message);
267
+ return true;
268
+ }
269
+
270
+ port.addEventListener("message", onMessage);
271
+ if (role === "view") {
272
+ handshakeTimer = setTimeout(() => {
273
+ closeWith("timeout");
274
+ }, EDITOR_REQUEST_TIMEOUT_MS);
275
+ post({ v: 1, type: "session.open", body: { editorId } });
276
+ }
277
+ port.start();
278
+
279
+ return {
280
+ handshake,
281
+ request,
282
+ emit,
283
+ close() {
284
+ closeWith("closed");
285
+ },
286
+ };
287
+ }
@@ -0,0 +1,111 @@
1
+ import { validateWhenClause } from "./commands/when/validate";
2
+ import { z } from "zod";
3
+ import type { Json } from "./index";
4
+ import { jsonSchema } from "./json-schema";
5
+
6
+ /** One path segment. KindIcon imports `@tabler/icons-react/dist/esm/icons/${name}.mjs`. */
7
+ const iconNameSchema = z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/);
8
+
9
+ const HOST_COMMANDS = new Set([
10
+ "editor.new",
11
+ "editor.open",
12
+ "editor.preferences",
13
+ ]);
14
+
15
+ const whenClause = z
16
+ .string()
17
+ .min(1)
18
+ .refine((clause) => validateWhenClause(clause).length === 0);
19
+
20
+ const commandSchema = z.strictObject({
21
+ id: z.string().min(1),
22
+ title: z.string(),
23
+ group: z.string().exactOptional(),
24
+ when: whenClause.exactOptional(),
25
+ });
26
+
27
+ const menuSchema = z.strictObject({
28
+ path: z.array(z.string()).min(1),
29
+ commandId: z.string().min(1),
30
+ order: z.number().exactOptional(),
31
+ group: z.string().exactOptional(),
32
+ label: z.string().exactOptional(),
33
+ shortcut: z.string().exactOptional(),
34
+ when: whenClause.exactOptional(),
35
+ });
36
+
37
+ const keybindingSchema = z.strictObject({
38
+ commandId: z.string().min(1),
39
+ key: z.string().min(1),
40
+ });
41
+
42
+ function jsonRecord(value: Json): { [key: string]: Json } | undefined {
43
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
44
+ return value;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export const chromeManifestSchema = z
50
+ .strictObject({
51
+ contributes: z
52
+ .strictObject({
53
+ commands: z.array(commandSchema).exactOptional(),
54
+ menus: z.array(menuSchema).exactOptional(),
55
+ keybindings: z.array(keybindingSchema).exactOptional(),
56
+ })
57
+ .exactOptional(),
58
+ layout: z.strictObject({
59
+ root: z.string(),
60
+ elements: z.record(z.string(), jsonSchema),
61
+ }),
62
+ titles: z.record(z.string(), z.string()),
63
+ panelHeaders: z
64
+ .record(
65
+ z.string().min(1),
66
+ z.strictObject({
67
+ commandId: z.string().min(1),
68
+ icon: iconNameSchema,
69
+ }),
70
+ )
71
+ .exactOptional(),
72
+ })
73
+ .superRefine((manifest, ctx) => {
74
+ const declared = new Set(
75
+ manifest.contributes?.commands?.map((command) => command.id) ?? [],
76
+ );
77
+ const used = [
78
+ ...(manifest.contributes?.menus?.map((menu) => menu.commandId) ?? []),
79
+ ...(manifest.contributes?.keybindings?.map(
80
+ (binding) => binding.commandId,
81
+ ) ?? []),
82
+ ];
83
+ for (const [contentId, header] of Object.entries(manifest.panelHeaders ?? {})) {
84
+ used.push(header.commandId);
85
+ if (HOST_COMMANDS.has(header.commandId)) {
86
+ ctx.addIssue({
87
+ code: "custom",
88
+ message: "host command",
89
+ path: ["panelHeaders", contentId, "commandId"],
90
+ });
91
+ }
92
+ }
93
+ for (const element of Object.values(manifest.layout.elements)) {
94
+ const row = jsonRecord(element);
95
+ if (row?.type !== "ToolbarButton") continue;
96
+ const props = row.props === undefined ? undefined : jsonRecord(row.props);
97
+ if (props == null) continue;
98
+ const icon = props.icon;
99
+ if (icon != null && !iconNameSchema.safeParse(icon).success) {
100
+ ctx.addIssue({ code: "custom", message: "icon" });
101
+ }
102
+ if (typeof props.commandId === "string") used.push(props.commandId);
103
+ }
104
+ for (const id of used) {
105
+ if (!declared.has(id)) {
106
+ ctx.addIssue({ code: "custom", message: "command" });
107
+ }
108
+ }
109
+ });
110
+
111
+ export type ChromeManifest = z.infer<typeof chromeManifestSchema>;
@@ -0,0 +1,99 @@
1
+ import type { ClipboardAdapter, ClipboardPayload } from "./types";
2
+
3
+ function isEditableTarget(target: EventTarget | null): boolean {
4
+ if (!target || typeof target !== "object") return false;
5
+ const el = target as {
6
+ isContentEditable?: boolean;
7
+ tagName?: string;
8
+ };
9
+ if (el.isContentEditable) return true;
10
+ const tag = el.tagName;
11
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
12
+ }
13
+
14
+ /**
15
+ * Route native copy/cut/paste:
16
+ * - text / editable targets → let the browser handle
17
+ * - object selection → custom MIME via clipboardData
18
+ */
19
+ export function createClipboardRouter(getAdapter: () => ClipboardAdapter | null) {
20
+ function onCopy(event: ClipboardEvent): void {
21
+ if (isEditableTarget(event.target)) return;
22
+ const adapter = getAdapter();
23
+ if (!adapter) return;
24
+ const selection = adapter.getSelection();
25
+ if (selection.kind !== "object") return;
26
+ event.preventDefault();
27
+ writePayload(event, selection.payload);
28
+ }
29
+
30
+ function onCut(event: ClipboardEvent): void {
31
+ if (isEditableTarget(event.target)) return;
32
+ const adapter = getAdapter();
33
+ if (!adapter) return;
34
+ const selection = adapter.getSelection();
35
+ if (selection.kind !== "object") return;
36
+ event.preventDefault();
37
+ writePayload(event, selection.payload);
38
+ void adapter.deleteSelection?.();
39
+ }
40
+
41
+ function onPaste(event: ClipboardEvent): void {
42
+ if (isEditableTarget(event.target)) return;
43
+ const adapter = getAdapter();
44
+ if (!adapter?.pasteObject) return;
45
+ const payload = readObjectPayload(event, adapter);
46
+ if (!payload) return;
47
+ event.preventDefault();
48
+ void adapter.pasteObject(payload);
49
+ }
50
+
51
+ function attach(target: GlobalEventHandlers = document): () => void {
52
+ target.addEventListener("copy", onCopy as EventListener);
53
+ target.addEventListener("cut", onCut as EventListener);
54
+ target.addEventListener("paste", onPaste as EventListener);
55
+ return () => {
56
+ target.removeEventListener("copy", onCopy as EventListener);
57
+ target.removeEventListener("cut", onCut as EventListener);
58
+ target.removeEventListener("paste", onPaste as EventListener);
59
+ };
60
+ }
61
+
62
+ return { onCopy, onCut, onPaste, attach };
63
+ }
64
+
65
+ function writePayload(event: ClipboardEvent, payload: ClipboardPayload): void {
66
+ const { clipboardData } = event;
67
+ if (!clipboardData) return;
68
+ clipboardData.setData(payload.mimeType, payload.data);
69
+ if (payload.text != null) {
70
+ clipboardData.setData("text/plain", payload.text);
71
+ }
72
+ }
73
+
74
+ function readObjectPayload(
75
+ event: ClipboardEvent,
76
+ adapter: ClipboardAdapter,
77
+ ): ClipboardPayload | null {
78
+ const { clipboardData } = event;
79
+ if (!clipboardData) return null;
80
+
81
+ // Prefer a MIME the adapter just exposed via current selection type,
82
+ // otherwise scan application/x-moloo-* types.
83
+ const types = [...clipboardData.types];
84
+ const molooType = types.find((type) => type.startsWith("application/x-moloo-"));
85
+ if (!molooType) return null;
86
+
87
+ const data = clipboardData.getData(molooType);
88
+ if (!data) return null;
89
+
90
+ // Ensure adapter still wants object paste (has pasteObject).
91
+ void adapter;
92
+ return {
93
+ mimeType: molooType,
94
+ data,
95
+ text: clipboardData.getData("text/plain") || undefined,
96
+ };
97
+ }
98
+
99
+ export type ClipboardRouter = ReturnType<typeof createClipboardRouter>;
@@ -0,0 +1,20 @@
1
+ export type ClipboardPayload = {
2
+ /** Custom MIME type, e.g. `application/x-moloo-canvas`. */
3
+ mimeType: string;
4
+ data: string;
5
+ /** Optional plain-text fallback for foreign paste targets. */
6
+ text?: string;
7
+ };
8
+
9
+ export type ClipboardSelection =
10
+ | { kind: "text" }
11
+ | { kind: "object"; payload: ClipboardPayload }
12
+ | { kind: "none" };
13
+
14
+ export type ClipboardAdapter = {
15
+ getSelection(): ClipboardSelection;
16
+ /** Apply an object payload (and optional text) from a paste event. */
17
+ pasteObject?(payload: ClipboardPayload): void | Promise<void>;
18
+ /** Delete current object selection (used by cut). */
19
+ deleteSelection?(): void | Promise<void>;
20
+ };
@@ -0,0 +1,53 @@
1
+ export type UndoCommand = {
2
+ id?: string;
3
+ undo: () => void | Promise<void>;
4
+ redo: () => void | Promise<void>;
5
+ };
6
+
7
+ export type UndoStack = {
8
+ push(command: UndoCommand): void;
9
+ undo(): Promise<boolean>;
10
+ redo(): Promise<boolean>;
11
+ canUndo(): boolean;
12
+ canRedo(): boolean;
13
+ clear(): void;
14
+ };
15
+
16
+ export function createUndoStack(limit = 100): UndoStack {
17
+ const past: UndoCommand[] = [];
18
+ const future: UndoCommand[] = [];
19
+
20
+ return {
21
+ push(command) {
22
+ past.push(command);
23
+ if (past.length > limit) {
24
+ past.shift();
25
+ }
26
+ future.length = 0;
27
+ },
28
+ async undo() {
29
+ const command = past.pop();
30
+ if (!command) return false;
31
+ await command.undo();
32
+ future.push(command);
33
+ return true;
34
+ },
35
+ async redo() {
36
+ const command = future.pop();
37
+ if (!command) return false;
38
+ await command.redo();
39
+ past.push(command);
40
+ return true;
41
+ },
42
+ canUndo() {
43
+ return past.length > 0;
44
+ },
45
+ canRedo() {
46
+ return future.length > 0;
47
+ },
48
+ clear() {
49
+ past.length = 0;
50
+ future.length = 0;
51
+ },
52
+ };
53
+ }