@moloodev/editor-sdk 0.0.0-stage → 0.1.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
@@ -0,0 +1,59 @@
1
+ import { LOCAL_ORIGIN, type Doc } from "./doc";
2
+ import { Y } from "./yjs";
3
+
4
+ const managers = new WeakMap<Doc, Y.UndoManager>();
5
+
6
+ export type DocUndo = {
7
+ push: (command?: unknown) => void;
8
+ undo: () => Promise<boolean>;
9
+ redo: () => Promise<boolean>;
10
+ canUndo: () => boolean;
11
+ canRedo: () => boolean;
12
+ clear: () => void;
13
+ };
14
+
15
+ /** Keep the next local change from merging into the current undo item. */
16
+ export function stopCapturing(doc: Doc): void {
17
+ managers.get(doc)?.stopCapturing();
18
+ }
19
+
20
+ export function createUndo(doc: Doc): DocUndo {
21
+ // Hydrated root types are lazy until read. Scope the concrete map before the
22
+ // first edit so opening undo before an editor projection still captures it.
23
+ doc.getMap("root");
24
+ const manager = new Y.UndoManager(doc.getMap("__scope"), {
25
+ trackedOrigins: new Set([LOCAL_ORIGIN, null]),
26
+ });
27
+ managers.set(doc, manager);
28
+
29
+ const syncScope = () => {
30
+ doc.share.forEach((type) => {
31
+ manager.addToScope(type as never);
32
+ });
33
+ };
34
+ syncScope();
35
+ doc.on("update", syncScope);
36
+
37
+ return {
38
+ push() {},
39
+ async undo() {
40
+ if (manager.undoStack.length === 0) return false;
41
+ manager.undo();
42
+ return true;
43
+ },
44
+ async redo() {
45
+ if (manager.redoStack.length === 0) return false;
46
+ manager.redo();
47
+ return true;
48
+ },
49
+ canUndo() {
50
+ return manager.undoStack.length > 0;
51
+ },
52
+ canRedo() {
53
+ return manager.redoStack.length > 0;
54
+ },
55
+ clear() {
56
+ manager.clear();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,42 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const yjsEsm = join(dirname(require.resolve("yjs/package.json")), "dist/yjs.mjs");
6
+
7
+ /**
8
+ * Server environments must `import("yjs")` through Node so SSR and Nitro
9
+ * share one evaluation. A file alias beats this and loads a second copy.
10
+ */
11
+ export const yjsNodeExternals = ["yjs", "y-protocols", "lib0"];
12
+
13
+ type EnvironmentResolve = {
14
+ alias?: Record<string, string>;
15
+ dedupe?: string[];
16
+ external?: string[];
17
+ };
18
+
19
+ /**
20
+ * One Yjs instance per realm. Client: one ESM file. Server: Node's module
21
+ * cache, shared by every Vite environment in the Nitro worker.
22
+ */
23
+ export function dedupeYjs(): {
24
+ name: string;
25
+ enforce: "post";
26
+ configEnvironment: (name: string) => { resolve: EnvironmentResolve };
27
+ } {
28
+ return {
29
+ name: "moloo-dedupe-yjs",
30
+ enforce: "post",
31
+ configEnvironment(name) {
32
+ if (name === "client") {
33
+ return {
34
+ resolve: { alias: { yjs: yjsEsm }, dedupe: ["yjs"] },
35
+ };
36
+ }
37
+ return {
38
+ resolve: { external: yjsNodeExternals, dedupe: ["yjs"] },
39
+ };
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,16 @@
1
+ /** Sole module that imports `yjs`. Do not re-export this file from the package root. */
2
+ import * as Y from "yjs";
3
+ import { Awareness } from "y-protocols/awareness";
4
+ import * as syncProtocol from "y-protocols/sync";
5
+ import * as awarenessProtocol from "y-protocols/awareness";
6
+ import * as decoding from "lib0/decoding";
7
+ import * as encoding from "lib0/encoding";
8
+
9
+ export {
10
+ Y,
11
+ Awareness,
12
+ syncProtocol,
13
+ awarenessProtocol,
14
+ decoding,
15
+ encoding,
16
+ };
@@ -0,0 +1,160 @@
1
+ import type { ChromeManifest } from "./protocol";
2
+
3
+ export const resetLayoutCommandId = "editor.resetLayout";
4
+
5
+ export const EDITOR_COMMON_CONTRIBUTES = {
6
+ commands: [
7
+ {
8
+ id: "editor.new",
9
+ title: "새로 만들기...",
10
+ group: "파일",
11
+ },
12
+ {
13
+ id: "editor.open",
14
+ title: "열기...",
15
+ group: "파일",
16
+ },
17
+ {
18
+ id: "editor.import",
19
+ title: "가져오기...",
20
+ group: "파일",
21
+ when: "editorCanImport && editorMode == 'edit' && editorHasAsset",
22
+ },
23
+ {
24
+ id: "editor.download",
25
+ title: "다운로드...",
26
+ group: "파일",
27
+ when: "editorCanDownload && editorHasAsset",
28
+ },
29
+ {
30
+ id: "editor.undo",
31
+ title: "실행 취소",
32
+ group: "편집",
33
+ when: "editorMode == 'edit' && editorCanUndo",
34
+ },
35
+ {
36
+ id: "editor.redo",
37
+ title: "다시 실행",
38
+ group: "편집",
39
+ when: "editorMode == 'edit' && editorCanRedo",
40
+ },
41
+ {
42
+ id: "editor.cut",
43
+ title: "잘라내기",
44
+ group: "편집",
45
+ when: "editorMode == 'edit'",
46
+ },
47
+ {
48
+ id: "editor.copy",
49
+ title: "복사하기",
50
+ group: "편집",
51
+ },
52
+ {
53
+ id: "editor.paste",
54
+ title: "붙여넣기",
55
+ group: "편집",
56
+ when: "editorMode == 'edit'",
57
+ },
58
+ {
59
+ id: "editor.delete",
60
+ title: "삭제하기",
61
+ group: "편집",
62
+ when: "editorMode == 'edit'",
63
+ },
64
+ {
65
+ id: "editor.preferences",
66
+ title: "에디터 설정...",
67
+ group: "앱",
68
+ },
69
+ {
70
+ id: resetLayoutCommandId,
71
+ title: "레이아웃 초기화",
72
+ group: "보기",
73
+ },
74
+ ],
75
+ menus: [
76
+ {
77
+ path: ["file", "new"],
78
+ commandId: "editor.new",
79
+ group: "1_lifecycle",
80
+ order: 10,
81
+ },
82
+ {
83
+ path: ["file", "open"],
84
+ commandId: "editor.open",
85
+ group: "1_lifecycle",
86
+ order: 20,
87
+ },
88
+ {
89
+ path: ["file", "import"],
90
+ commandId: "editor.import",
91
+ group: "2_io",
92
+ order: 10,
93
+ when: "editorCanImport",
94
+ },
95
+ {
96
+ path: ["file", "download"],
97
+ commandId: "editor.download",
98
+ group: "2_io",
99
+ order: 20,
100
+ when: "editorCanDownload",
101
+ },
102
+ {
103
+ path: ["edit", "undo"],
104
+ commandId: "editor.undo",
105
+ group: "1_history",
106
+ order: 10,
107
+ },
108
+ {
109
+ path: ["edit", "redo"],
110
+ commandId: "editor.redo",
111
+ group: "1_history",
112
+ order: 20,
113
+ },
114
+ {
115
+ path: ["edit", "cut"],
116
+ commandId: "editor.cut",
117
+ group: "2_clipboard",
118
+ order: 10,
119
+ shortcut: "Mod+X",
120
+ },
121
+ {
122
+ path: ["edit", "copy"],
123
+ commandId: "editor.copy",
124
+ group: "2_clipboard",
125
+ order: 20,
126
+ shortcut: "Mod+C",
127
+ },
128
+ {
129
+ path: ["edit", "paste"],
130
+ commandId: "editor.paste",
131
+ group: "2_clipboard",
132
+ order: 30,
133
+ shortcut: "Mod+V",
134
+ },
135
+ {
136
+ path: ["edit", "delete"],
137
+ commandId: "editor.delete",
138
+ group: "2_clipboard",
139
+ order: 40,
140
+ shortcut: "Backspace",
141
+ },
142
+ {
143
+ path: ["edit", "preferences"],
144
+ commandId: "editor.preferences",
145
+ group: "9_settings",
146
+ order: 20,
147
+ },
148
+ {
149
+ path: ["view", "resetLayout"],
150
+ commandId: resetLayoutCommandId,
151
+ group: "z_reset",
152
+ order: 10,
153
+ },
154
+ ],
155
+ keybindings: [
156
+ { commandId: "editor.undo", key: "Mod+Z" },
157
+ { commandId: "editor.redo", key: "Mod+Shift+Z" },
158
+ { commandId: "editor.delete", key: "Backspace" },
159
+ ],
160
+ } satisfies NonNullable<ChromeManifest["contributes"]>;
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * DNS label used for both halves of `publisher.editor`.
5
+ * 1–63 characters, lowercase letters, digits, and hyphens.
6
+ * No leading or trailing hyphen. Positions 3–4 must not be `--` (punycode).
7
+ */
8
+ const editorLabelSchema = z
9
+ .string()
10
+ .regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/)
11
+ .refine((label) => label.length < 4 || label[2] !== "-" || label[3] !== "-");
12
+
13
+ export const reservedPublishers: readonly string[] = [
14
+ "www",
15
+ "api",
16
+ "app",
17
+ "admin",
18
+ "static",
19
+ "cdn",
20
+ "preview",
21
+ "dev",
22
+ "localhost",
23
+ "local",
24
+ "mail",
25
+ "moloo",
26
+ ];
27
+
28
+ export type EditorId = {
29
+ publisher: string;
30
+ editor: string;
31
+ };
32
+
33
+ export function isEditorLabel(label: string): boolean {
34
+ return editorLabelSchema.safeParse(label).success;
35
+ }
36
+
37
+ /** `firstParty` is the only caller that may use the `moloo` publisher. */
38
+ export function isPublisherName(publisher: string, firstParty: boolean): boolean {
39
+ if (!isEditorLabel(publisher)) return false;
40
+ if (publisher === "moloo") return firstParty;
41
+ return !reservedPublishers.includes(publisher);
42
+ }
43
+
44
+ export function parseEditorId(
45
+ id: string,
46
+ firstParty: boolean,
47
+ ): EditorId | undefined {
48
+ const dot = id.indexOf(".");
49
+ if (dot < 1 || dot !== id.lastIndexOf(".")) return undefined;
50
+ const publisher = id.slice(0, dot);
51
+ const editor = id.slice(dot + 1);
52
+ if (!isPublisherName(publisher, firstParty) || !isEditorLabel(editor)) {
53
+ return undefined;
54
+ }
55
+ return { publisher, editor };
56
+ }
@@ -0,0 +1,9 @@
1
+ export const editorFrameAttributes: {
2
+ sandbox: "allow-scripts";
3
+ referrerPolicy: "no-referrer";
4
+ loading: "eager";
5
+ } = {
6
+ sandbox: "allow-scripts",
7
+ referrerPolicy: "no-referrer",
8
+ loading: "eager",
9
+ };
package/src/index.ts ADDED
@@ -0,0 +1,229 @@
1
+ import type {
2
+ BundleConfirm,
3
+ Composition,
4
+ Publication,
5
+ } from "@moloodev/platform";
6
+ import type { PrintDocument } from "./print";
7
+ import type { ChromeManifest } from "./chrome-manifest";
8
+
9
+ export type Json =
10
+ | null
11
+ | boolean
12
+ | number
13
+ | string
14
+ | Json[]
15
+ | { [key: string]: Json };
16
+
17
+ export type EditorAsset = {
18
+ id: string;
19
+ name: string;
20
+ type: string;
21
+ storageMode?: "json" | "text" | "blob";
22
+ contentType?: string | null;
23
+ schema?: string | null;
24
+ path: string;
25
+ epoch: number;
26
+ headRevision: number;
27
+ updatedAt: string;
28
+ contentBytes: number;
29
+ locked: boolean;
30
+ lockHeld: boolean;
31
+ trashed: boolean;
32
+ };
33
+
34
+ export type EditorAccess = {
35
+ canManage: boolean;
36
+ };
37
+
38
+ export type EditorSessionAsset = {
39
+ id: string;
40
+ type: string;
41
+ name: string;
42
+ storageMode?: "json" | "text" | "blob";
43
+ contentType?: string | null;
44
+ schema?: string | null;
45
+ };
46
+
47
+ export type EditorSession = {
48
+ asset: EditorSessionAsset | null;
49
+ mode: "idle" | "loading" | "read" | "edit";
50
+ data: { [key: string]: Json } | null;
51
+ syncStatus: "live" | "pending" | "offline" | "error";
52
+ };
53
+
54
+ export type EditorRevision = {
55
+ id: string;
56
+ assetId: string;
57
+ revision: number;
58
+ message: string;
59
+ contentBytes: number;
60
+ createdAt: string;
61
+ };
62
+
63
+ type Empty = Record<string, never>;
64
+
65
+ export type EditorLayoutValues = {
66
+ panels: {
67
+ [elementKey: string]: { visible: boolean; size?: number | `${number}%` };
68
+ };
69
+ collapsed?: {
70
+ [sectionId: string]: boolean;
71
+ };
72
+ };
73
+
74
+ export type EditorOperation = {
75
+ "asset.rename": {
76
+ body: { name: string };
77
+ success: { name: string };
78
+ };
79
+ "layout.load": {
80
+ body: Empty;
81
+ success: { layout: EditorLayoutValues | null };
82
+ };
83
+ "layout.save": {
84
+ body: { layout: EditorLayoutValues };
85
+ success: Empty;
86
+ };
87
+ "asset.read": {
88
+ body: { id: string };
89
+ /** Snapshot; fileUrl is a bounded base64 data URL for blobs, otherwise null. */
90
+ success: { asset: EditorAsset; payload: { [key: string]: Json }; fileUrl: string | null };
91
+ };
92
+ "asset.list": {
93
+ body: { type?: string };
94
+ success: EditorAsset[];
95
+ };
96
+ "asset.listRevisions": {
97
+ body: { id: string };
98
+ success: EditorRevision[];
99
+ };
100
+ "asset.getComposition": {
101
+ body: { id: string };
102
+ success: Composition;
103
+ };
104
+ "asset.getPublication": {
105
+ body: { id: string };
106
+ success: Publication;
107
+ };
108
+ "asset.confirmBundle": {
109
+ body: { id: string; message: string; reviewed: string };
110
+ success: BundleConfirm;
111
+ };
112
+ "asset.putPublication": {
113
+ body: { id: string; publication: Publication };
114
+ success: Publication;
115
+ };
116
+ "host.pick": {
117
+ body: {
118
+ acceptedTypes?: string[];
119
+ excludeTypes?: string[];
120
+ excludeIds?: string[];
121
+ };
122
+ success:
123
+ | { cancelled: false; assets: EditorAsset[] }
124
+ | { cancelled: true };
125
+ };
126
+ "host.openAsset": {
127
+ body: { id: string; type: string };
128
+ success: Empty;
129
+ };
130
+ "host.openInfo": {
131
+ body: { id: string };
132
+ success: Empty;
133
+ };
134
+ "host.start": {
135
+ body: Empty;
136
+ success: Empty;
137
+ };
138
+ "host.openBrowser": {
139
+ body: { excludeIds?: readonly string[] };
140
+ success: Empty;
141
+ };
142
+ "host.clipboardWrite": {
143
+ body: { text: string };
144
+ success: Empty;
145
+ };
146
+ "host.download": {
147
+ body:
148
+ | {
149
+ filename: string;
150
+ mimeType: string;
151
+ content: { kind: "text"; text: string } | { kind: "base64"; data: string };
152
+ }
153
+ | { url: string };
154
+ success: Empty;
155
+ };
156
+ "host.openPopup": {
157
+ body: { url: string };
158
+ success: Empty;
159
+ };
160
+ "host.render": {
161
+ body: {
162
+ format: "pdf";
163
+ document: PrintDocument;
164
+ name: string;
165
+ };
166
+ success: { url: string };
167
+ };
168
+ "doc.append": {
169
+ body: { assetId: string; update: string };
170
+ success: Empty;
171
+ };
172
+ "chrome.submit": {
173
+ body: ChromeManifest;
174
+ success: Empty;
175
+ };
176
+ };
177
+
178
+ export type EditorRequestType = keyof EditorOperation;
179
+
180
+ export type EditorRequest = {
181
+ [Type in EditorRequestType]: {
182
+ v: 1;
183
+ id: string;
184
+ type: Type;
185
+ body: EditorOperation[Type]["body"];
186
+ };
187
+ }[EditorRequestType];
188
+
189
+ export type EditorSuccess<Type extends EditorRequestType = EditorRequestType> =
190
+ {
191
+ [T in Type]: {
192
+ v: 1;
193
+ id: string;
194
+ ok: true;
195
+ body: EditorOperation[T]["success"];
196
+ };
197
+ }[Type];
198
+
199
+ export type EditorFailure = {
200
+ v: 1;
201
+ id: string;
202
+ ok: false;
203
+ code: string;
204
+ };
205
+
206
+ export type EditorResponse<Type extends EditorRequestType = EditorRequestType> =
207
+ | EditorSuccess<Type>
208
+ | EditorFailure;
209
+
210
+ export type EditorEvent =
211
+ | { v: 1; type: "file.import"; body: { name: string; data: string } }
212
+ | { v: 1; type: "session"; body: EditorSession }
213
+ | { v: 1; type: "access"; body: EditorAccess }
214
+ | { v: 1; type: "theme"; body: { theme: "dark" | "light" } }
215
+ | { v: 1; type: "doc.update"; body: { assetId: string; update: string } }
216
+ | { v: 1; type: "host.createRequested" }
217
+ | { v: 1; type: "command.invoke"; body: { commandId: string } }
218
+ | { v: 1; type: "command.shortcut"; body: { key: string; commandId: string } }
219
+ | {
220
+ v: 1;
221
+ type: "command.state";
222
+ body: {
223
+ id: string;
224
+ visible: boolean;
225
+ disabled: boolean;
226
+ pressed: boolean;
227
+ label: string;
228
+ };
229
+ };
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ import type { Json } from "./index";
3
+
4
+ export const jsonSchema: z.ZodType<Json> = z.lazy(() =>
5
+ z.union([
6
+ z.null(),
7
+ z.boolean(),
8
+ z.number(),
9
+ z.string(),
10
+ z.array(jsonSchema),
11
+ z.record(z.string(), jsonSchema),
12
+ ]),
13
+ );
@@ -0,0 +1,60 @@
1
+ import type { Spec } from "@json-render/core";
2
+ import type { LayoutElement } from "./types";
3
+
4
+ const STATE_PROPS = ["disabled", "pressed", "label"] as const;
5
+
6
+ function commandState(path: string) {
7
+ return { $state: path };
8
+ }
9
+
10
+ function elementsOf(spec: Spec): Record<string, LayoutElement> {
11
+ return spec.elements as Record<string, LayoutElement>;
12
+ }
13
+
14
+ /** Fill compact ToolbarButton entries with executeCommand + $state bindings. */
15
+ export function bindToolbarSpec(spec: Spec): Spec {
16
+ const next: Spec = {
17
+ ...spec,
18
+ elements: { ...spec.elements },
19
+ };
20
+ const elements = elementsOf(next);
21
+
22
+ for (const [key, element] of Object.entries(elements)) {
23
+ if (element.type !== "ToolbarButton") continue;
24
+ const commandId = String(element.props.commandId ?? "");
25
+ if (!commandId) continue;
26
+
27
+ const bound: LayoutElement = {
28
+ ...element,
29
+ props: { ...element.props },
30
+ };
31
+ const on = { ...(element.on ?? {}) };
32
+ if (on.press == null) {
33
+ bound.on = {
34
+ ...on,
35
+ press: { action: "executeCommand", params: { commandId } },
36
+ };
37
+ }
38
+ if (bound.visible == null) {
39
+ bound.visible = commandState(`/commands/${commandId}/visible`);
40
+ }
41
+ for (const prop of STATE_PROPS) {
42
+ if (bound.props[prop] == null) {
43
+ bound.props[prop] = commandState(`/commands/${commandId}/${prop}`);
44
+ }
45
+ }
46
+ elements[key] = bound;
47
+ }
48
+
49
+ for (const [key, element] of Object.entries(elements)) {
50
+ if (element.type !== "Toolbar" || element.visible != null) continue;
51
+ const conditions = (element.children ?? []).flatMap((childKey) => {
52
+ const child = elements[childKey];
53
+ return child?.type === "ToolbarButton" ? [child.visible ?? true] : [];
54
+ });
55
+ if (conditions.length === 0) continue;
56
+ elements[key] = { ...element, visible: { $or: conditions } };
57
+ }
58
+
59
+ return next;
60
+ }