@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.
- package/package.json +62 -3
- package/src/channel.ts +287 -0
- package/src/chrome-manifest.ts +111 -0
- package/src/commands/clipboard/router.ts +99 -0
- package/src/commands/clipboard/types.ts +20 -0
- package/src/commands/history/stack.ts +53 -0
- package/src/commands/icons/catalog.ts +172 -0
- package/src/commands/index.ts +100 -0
- package/src/commands/keys/display.ts +16 -0
- package/src/commands/keys/normalize.ts +55 -0
- package/src/commands/keys/reserved.ts +62 -0
- package/src/commands/keys/validate.ts +77 -0
- package/src/commands/manifest/configuration.ts +59 -0
- package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
- package/src/commands/manifest/editor-document.ts +501 -0
- package/src/commands/manifest/ids.ts +2 -0
- package/src/commands/manifest/merge.ts +215 -0
- package/src/commands/manifest/types.ts +113 -0
- package/src/commands/manifest/validate-manifest.ts +45 -0
- package/src/commands/react/context.tsx +249 -0
- package/src/commands/react/hotkey-defaults.ts +11 -0
- package/src/commands/react/scope-context.tsx +31 -0
- package/src/commands/react/scope-provider.tsx +123 -0
- package/src/commands/react/store.ts +182 -0
- package/src/commands/registry/create.ts +55 -0
- package/src/commands/registry/types.ts +12 -0
- package/src/commands/when/evaluate.ts +80 -0
- package/src/commands/when/index.ts +19 -0
- package/src/commands/when/parse.ts +276 -0
- package/src/commands/when/types.ts +24 -0
- package/src/commands/when/validate.ts +20 -0
- package/src/crdt/awareness.ts +29 -0
- package/src/crdt/bytes.ts +26 -0
- package/src/crdt/connect.ts +285 -0
- package/src/crdt/diff.ts +86 -0
- package/src/crdt/doc.ts +67 -0
- package/src/crdt/front-matter.ts +45 -0
- package/src/crdt/index.ts +76 -0
- package/src/crdt/persist.ts +40 -0
- package/src/crdt/protocol.ts +119 -0
- package/src/crdt/shared.ts +104 -0
- package/src/crdt/text-body.ts +125 -0
- package/src/crdt/undo.ts +59 -0
- package/src/crdt/vite-plugin.ts +42 -0
- package/src/crdt/yjs.ts +16 -0
- package/src/editor-common.ts +160 -0
- package/src/editor-id.ts +56 -0
- package/src/frame-attributes.ts +9 -0
- package/src/index.ts +229 -0
- package/src/json-schema.ts +13 -0
- package/src/layout/bind-toolbar.ts +60 -0
- package/src/layout/catalog.ts +82 -0
- package/src/layout/chrome/command-affordances.ts +47 -0
- package/src/layout/chrome/command-layout-state.ts +42 -0
- package/src/layout/chrome/editor-menu.tsx +358 -0
- package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
- package/src/layout/chrome/menu-styles.ts +3 -0
- package/src/layout/chrome/mid-menu-nodes.ts +27 -0
- package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
- package/src/layout/chrome/visible-menu-leaves.ts +15 -0
- package/src/layout/content-error-notice.tsx +31 -0
- package/src/layout/define-editor-layout.ts +60 -0
- package/src/layout/define-editor-layout.types.ts +57 -0
- package/src/layout/doc-undo-bridge.tsx +22 -0
- package/src/layout/dock-zones.tsx +61 -0
- package/src/layout/drop.ts +39 -0
- package/src/layout/editor-layout.tsx +287 -0
- package/src/layout/editor-loading.tsx +23 -0
- package/src/layout/frame-layout-binding.tsx +74 -0
- package/src/layout/frame.ts +23 -0
- package/src/layout/hide.ts +103 -0
- package/src/layout/index.ts +14 -0
- package/src/layout/layout-binding.tsx +42 -0
- package/src/layout/layout-storage-key.ts +6 -0
- package/src/layout/layout-values.ts +48 -0
- package/src/layout/mutate.ts +486 -0
- package/src/layout/nesting-context.tsx +20 -0
- package/src/layout/panel-header-action.tsx +29 -0
- package/src/layout/panel-header.tsx +168 -0
- package/src/layout/panel-hide-menu.tsx +6 -0
- package/src/layout/panel-menu.tsx +85 -0
- package/src/layout/panel-section.tsx +114 -0
- package/src/layout/panel-size.ts +94 -0
- package/src/layout/panel-title.tsx +5 -0
- package/src/layout/registry.tsx +333 -0
- package/src/layout/restore-panel.ts +26 -0
- package/src/layout/slots-context.tsx +189 -0
- package/src/layout/tab-button.tsx +60 -0
- package/src/layout/toolbar-scroll.ts +22 -0
- package/src/layout/toolbar.tsx +168 -0
- package/src/layout/types.ts +41 -0
- package/src/layout/use-collapsed-sections.ts +50 -0
- package/src/layout/validate-layout-spec.ts +143 -0
- package/src/message-samples.ts +377 -0
- package/src/origin-headers.ts +57 -0
- package/src/print/index.ts +10 -0
- package/src/print/inspect.ts +16 -0
- package/src/print/schema.ts +122 -0
- package/src/protocol-entry.ts +19 -0
- package/src/protocol.ts +471 -0
- package/src/render-result.ts +25 -0
- package/src/view/editor-chrome.ts +28 -0
- package/src/view/hooks.ts +260 -0
- package/src/view/mount-editor-view.ts +36 -0
- package/src/view/port-binding.ts +127 -0
- package/src/view/port-commands.ts +36 -0
- package/src/view/port.ts +35 -0
- package/src/view/theme-class.ts +11 -0
- package/src/view.ts +313 -0
- package/README.md +0 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { claimKeybindings, validateKeybindings } from "../keys/validate";
|
|
2
|
+
import type {
|
|
3
|
+
ContributionManifest,
|
|
4
|
+
ManifestActivation,
|
|
5
|
+
MenuContribution,
|
|
6
|
+
MenuNode,
|
|
7
|
+
MergedContributions,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
function resolveActivation(
|
|
11
|
+
manifest: ContributionManifest,
|
|
12
|
+
): ManifestActivation {
|
|
13
|
+
return manifest.activation ?? "always";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve which manifests apply given the active scope.
|
|
18
|
+
* - `"always"` (default): always
|
|
19
|
+
* - `"any"`: when some scope is active
|
|
20
|
+
* - `"exact"`: when activeScopeId === manifest.id
|
|
21
|
+
*/
|
|
22
|
+
export function selectManifests(
|
|
23
|
+
manifests: readonly ContributionManifest[],
|
|
24
|
+
activeScopeId: string | null,
|
|
25
|
+
): ContributionManifest[] {
|
|
26
|
+
return manifests.filter((manifest) => {
|
|
27
|
+
const activation = resolveActivation(manifest);
|
|
28
|
+
if (activation === "always") return true;
|
|
29
|
+
if (activation === "any") return activeScopeId != null;
|
|
30
|
+
return activeScopeId != null && manifest.id === activeScopeId;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Default leaf group — scope-specific appends land after common blocks. */
|
|
35
|
+
export const DEFAULT_MENU_GROUP = "9_default";
|
|
36
|
+
|
|
37
|
+
function compareMenuSiblings(a: MenuNode, b: MenuNode): number {
|
|
38
|
+
const groupCmp = (a.group ?? DEFAULT_MENU_GROUP).localeCompare(
|
|
39
|
+
b.group ?? DEFAULT_MENU_GROUP,
|
|
40
|
+
);
|
|
41
|
+
if (groupCmp !== 0) return groupCmp;
|
|
42
|
+
return a.order - b.order || a.label.localeCompare(b.label);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildMenuTree(
|
|
46
|
+
menus: MenuContribution[],
|
|
47
|
+
titleByCommand: Map<string, string>,
|
|
48
|
+
): MenuNode[] {
|
|
49
|
+
type Mutable = {
|
|
50
|
+
id: string;
|
|
51
|
+
segment: string;
|
|
52
|
+
label: string;
|
|
53
|
+
order: number;
|
|
54
|
+
group?: string;
|
|
55
|
+
shortcut?: string;
|
|
56
|
+
commandId?: string;
|
|
57
|
+
when?: string;
|
|
58
|
+
children: Map<string, Mutable>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const roots = new Map<string, Mutable>();
|
|
62
|
+
|
|
63
|
+
const sorted = [...menus].sort((a, b) => {
|
|
64
|
+
const pathCmp = a.path.join("\0").localeCompare(b.path.join("\0"));
|
|
65
|
+
if (pathCmp !== 0) return pathCmp;
|
|
66
|
+
const groupCmp = (a.group ?? DEFAULT_MENU_GROUP).localeCompare(
|
|
67
|
+
b.group ?? DEFAULT_MENU_GROUP,
|
|
68
|
+
);
|
|
69
|
+
if (groupCmp !== 0) return groupCmp;
|
|
70
|
+
return (a.order ?? 0) - (b.order ?? 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const item of sorted) {
|
|
74
|
+
if (item.path.length === 0) {
|
|
75
|
+
throw new Error(`Menu for "${item.commandId}" has empty path`);
|
|
76
|
+
}
|
|
77
|
+
let level = roots;
|
|
78
|
+
for (let i = 0; i < item.path.length; i++) {
|
|
79
|
+
const segment = item.path[i]!;
|
|
80
|
+
const id = item.path.slice(0, i + 1).join("/");
|
|
81
|
+
let current = level.get(segment);
|
|
82
|
+
if (!current) {
|
|
83
|
+
current = {
|
|
84
|
+
id,
|
|
85
|
+
segment,
|
|
86
|
+
label: segment,
|
|
87
|
+
order: item.order ?? 0,
|
|
88
|
+
children: new Map(),
|
|
89
|
+
};
|
|
90
|
+
level.set(segment, current);
|
|
91
|
+
}
|
|
92
|
+
const isLeaf = i === item.path.length - 1;
|
|
93
|
+
if (isLeaf) {
|
|
94
|
+
if (current.commandId && current.commandId !== item.commandId) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Menu path "${id}" already bound to "${current.commandId}"`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
current.commandId = item.commandId;
|
|
100
|
+
current.group = item.group ?? DEFAULT_MENU_GROUP;
|
|
101
|
+
current.shortcut = item.shortcut;
|
|
102
|
+
current.when = item.when;
|
|
103
|
+
current.label =
|
|
104
|
+
item.label ?? titleByCommand.get(item.commandId) ?? segment;
|
|
105
|
+
if (item.order != null) current.order = item.order;
|
|
106
|
+
} else if (item.order != null && current.children.size === 0) {
|
|
107
|
+
current.order = Math.min(current.order, item.order);
|
|
108
|
+
}
|
|
109
|
+
level = current.children;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SEGMENT_META: Record<string, { label: string; order: number }> = {
|
|
114
|
+
file: { label: "파일", order: 10 },
|
|
115
|
+
edit: { label: "편집", order: 30 },
|
|
116
|
+
insert: { label: "삽입", order: 35 },
|
|
117
|
+
format: { label: "포맷", order: 35 },
|
|
118
|
+
asset: { label: "애셋", order: 36 },
|
|
119
|
+
view: { label: "보기", order: 40 },
|
|
120
|
+
canvas: { label: "캔버스", order: 50 },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function toNodes(map: Map<string, Mutable>): MenuNode[] {
|
|
124
|
+
return [...map.values()]
|
|
125
|
+
.map((n) => {
|
|
126
|
+
const meta = SEGMENT_META[n.segment];
|
|
127
|
+
const children = toNodes(n.children);
|
|
128
|
+
const label =
|
|
129
|
+
children.length > 0
|
|
130
|
+
? (meta?.label ?? n.label)
|
|
131
|
+
: n.label;
|
|
132
|
+
const order = meta?.order ?? n.order;
|
|
133
|
+
return {
|
|
134
|
+
id: n.id,
|
|
135
|
+
segment: n.segment,
|
|
136
|
+
label,
|
|
137
|
+
order,
|
|
138
|
+
group: children.length > 0 ? undefined : n.group,
|
|
139
|
+
shortcut: children.length > 0 ? undefined : n.shortcut,
|
|
140
|
+
when: children.length > 0 ? undefined : n.when,
|
|
141
|
+
commandId: children.length > 0 ? undefined : n.commandId,
|
|
142
|
+
children,
|
|
143
|
+
};
|
|
144
|
+
})
|
|
145
|
+
.sort(compareMenuSiblings);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return toNodes(roots);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Merge selected manifests. Duplicate command ids or key overrides throw.
|
|
153
|
+
*/
|
|
154
|
+
export function mergeManifests(
|
|
155
|
+
manifests: readonly ContributionManifest[],
|
|
156
|
+
): MergedContributions {
|
|
157
|
+
const commands: MergedContributions["commands"] = [];
|
|
158
|
+
const menus: MenuContribution[] = [];
|
|
159
|
+
const keybindings: MergedContributions["keybindings"] = [];
|
|
160
|
+
const seenCommands = new Map<string, string>();
|
|
161
|
+
const claimedKeys = new Map<string, string>();
|
|
162
|
+
|
|
163
|
+
for (const manifest of manifests) {
|
|
164
|
+
for (const command of manifest.commands ?? []) {
|
|
165
|
+
const prior = seenCommands.get(command.id);
|
|
166
|
+
if (prior) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Command "${command.id}" redeclared by "${manifest.id}" (already from "${prior}")`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
seenCommands.set(command.id, manifest.id);
|
|
172
|
+
commands.push({
|
|
173
|
+
id: command.id,
|
|
174
|
+
title: command.title,
|
|
175
|
+
group: command.group,
|
|
176
|
+
when: command.when,
|
|
177
|
+
sourceId: manifest.id,
|
|
178
|
+
activation: resolveActivation(manifest),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const menu of manifest.menus ?? []) {
|
|
183
|
+
menus.push(menu);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const bindings = manifest.keybindings ?? [];
|
|
187
|
+
const result = validateKeybindings(bindings, claimedKeys);
|
|
188
|
+
if (!result.ok) {
|
|
189
|
+
const first = result.conflicts[0]!;
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Keybinding conflict in "${manifest.id}": ${first.key} → ${first.commandId} (${first.reason})`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
claimKeybindings(bindings, claimedKeys);
|
|
195
|
+
keybindings.push(...bindings);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const titleByCommand = new Map(
|
|
199
|
+
commands.map((command) => [command.id, command.title]),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
commands,
|
|
204
|
+
menus,
|
|
205
|
+
keybindings,
|
|
206
|
+
menuTree: buildMenuTree(menus, titleByCommand),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function mergeActiveContributions(
|
|
211
|
+
manifests: readonly ContributionManifest[],
|
|
212
|
+
activeScopeId: string | null,
|
|
213
|
+
): MergedContributions {
|
|
214
|
+
return mergeManifests(selectManifests(manifests, activeScopeId));
|
|
215
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { CommandId } from "../registry/types";
|
|
2
|
+
|
|
3
|
+
export type MenuContribution = {
|
|
4
|
+
/**
|
|
5
|
+
* Full menu path. Intermediate segments are submenus; the last segment is
|
|
6
|
+
* the item. Hosts append under existing paths (e.g. `["edit","group"]`)
|
|
7
|
+
* or add top-level menus (e.g. `["canvas","frame"]`).
|
|
8
|
+
*/
|
|
9
|
+
path: string[];
|
|
10
|
+
commandId: CommandId;
|
|
11
|
+
order?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Sibling group id (VS Code-style). Sorted lexicographically; hosts insert
|
|
14
|
+
* a separator when the group changes. Prefer prefixes like `1_history`,
|
|
15
|
+
* `2_clipboard`, `9_default`.
|
|
16
|
+
*/
|
|
17
|
+
group?: string;
|
|
18
|
+
/** Optional display label; defaults to command title when rendering. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Display-only chord (e.g. `Mod+C`). Shown in menus/palette hints but not
|
|
22
|
+
* registered as a keybinding — use for native clipboard keys etc.
|
|
23
|
+
*/
|
|
24
|
+
shortcut?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Visibility when-clause. Hosts hide the leaf when false.
|
|
27
|
+
* Command `when` still gates enablement (disabled).
|
|
28
|
+
*/
|
|
29
|
+
when?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type KeybindingContribution = {
|
|
33
|
+
commandId: CommandId;
|
|
34
|
+
/** TanStack / Mod-style chord, e.g. `Mod+Z`. */
|
|
35
|
+
key: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ConfigurationProperty = {
|
|
39
|
+
type: "boolean" | "number" | "string";
|
|
40
|
+
default: boolean | number | string;
|
|
41
|
+
title: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
enum?: string[];
|
|
44
|
+
minimum?: number;
|
|
45
|
+
maximum?: number;
|
|
46
|
+
order?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ConfigurationGroup = {
|
|
50
|
+
id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
order?: number;
|
|
53
|
+
properties: Record<string, ConfigurationProperty>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Omitted → `"always"`. */
|
|
57
|
+
export type ManifestActivation =
|
|
58
|
+
| "always" // include regardless of scope
|
|
59
|
+
| "any" // include when some scope is active
|
|
60
|
+
| "exact"; // include when activeScopeId === manifest.id
|
|
61
|
+
|
|
62
|
+
export type ContributionManifest = {
|
|
63
|
+
id: string;
|
|
64
|
+
activation?: ManifestActivation;
|
|
65
|
+
commands?: {
|
|
66
|
+
id: CommandId;
|
|
67
|
+
title: string;
|
|
68
|
+
group?: string;
|
|
69
|
+
/** When-clause; omitted = always available. */
|
|
70
|
+
when?: string;
|
|
71
|
+
}[];
|
|
72
|
+
menus?: MenuContribution[];
|
|
73
|
+
keybindings?: KeybindingContribution[];
|
|
74
|
+
/** VS Code–style settings schema contributions. */
|
|
75
|
+
configuration?: { groups: ConfigurationGroup[] };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type MergedConfiguration = {
|
|
79
|
+
groups: ConfigurationGroup[];
|
|
80
|
+
defaults: Record<string, boolean | number | string>;
|
|
81
|
+
properties: Map<string, ConfigurationProperty>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type MenuNode = {
|
|
85
|
+
id: string;
|
|
86
|
+
/** Segment key (last path part). */
|
|
87
|
+
segment: string;
|
|
88
|
+
label: string;
|
|
89
|
+
order: number;
|
|
90
|
+
/** Present on leaves; used for separator breaks among siblings. */
|
|
91
|
+
group?: string;
|
|
92
|
+
commandId?: CommandId;
|
|
93
|
+
/** Display-only shortcut; see MenuContribution.shortcut. */
|
|
94
|
+
shortcut?: string;
|
|
95
|
+
/** Visibility when-clause; see MenuContribution.when. */
|
|
96
|
+
when?: string;
|
|
97
|
+
children: MenuNode[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type MergedContributions = {
|
|
101
|
+
commands: {
|
|
102
|
+
id: CommandId;
|
|
103
|
+
title: string;
|
|
104
|
+
group?: string;
|
|
105
|
+
when?: string;
|
|
106
|
+
sourceId: string;
|
|
107
|
+
/** Activation of the contributing manifest (for palette / scope ordering). */
|
|
108
|
+
activation: ManifestActivation;
|
|
109
|
+
}[];
|
|
110
|
+
menus: MenuContribution[];
|
|
111
|
+
keybindings: KeybindingContribution[];
|
|
112
|
+
menuTree: MenuNode[];
|
|
113
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ContributionManifest } from "./types";
|
|
2
|
+
import { contributionManifestSchema } from "./contribution-manifest-schema";
|
|
3
|
+
import { validateWhenClause } from "../when/validate";
|
|
4
|
+
|
|
5
|
+
function formatZodIssues(error: {
|
|
6
|
+
issues: { path: PropertyKey[]; message: string }[];
|
|
7
|
+
}): string {
|
|
8
|
+
return error.issues
|
|
9
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
10
|
+
.join("; ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Shape validation for contribution manifests. Throws on failure. */
|
|
14
|
+
export function validateManifest(input: unknown): ContributionManifest {
|
|
15
|
+
const result = contributionManifestSchema.safeParse(input);
|
|
16
|
+
if (!result.success) {
|
|
17
|
+
throw new Error(`Invalid contribution manifest: ${formatZodIssues(result.error)}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const data = result.data;
|
|
21
|
+
const whenErrors: string[] = [];
|
|
22
|
+
for (const [index, command] of (data.commands ?? []).entries()) {
|
|
23
|
+
if (command.when == null) continue;
|
|
24
|
+
const issues = validateWhenClause(command.when);
|
|
25
|
+
for (const issue of issues) {
|
|
26
|
+
whenErrors.push(
|
|
27
|
+
`commands.${index}.when: ${issue.message} (offset ${issue.offset})`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const [index, menu] of (data.menus ?? []).entries()) {
|
|
32
|
+
if (menu.when == null) continue;
|
|
33
|
+
const issues = validateWhenClause(menu.when);
|
|
34
|
+
for (const issue of issues) {
|
|
35
|
+
whenErrors.push(
|
|
36
|
+
`menus.${index}.when: ${issue.message} (offset ${issue.offset})`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (whenErrors.length > 0) {
|
|
41
|
+
throw new Error(`Invalid contribution manifest: ${whenErrors.join("; ")}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { HotkeysProvider, useHotkey } from "@tanstack/react-hotkeys";
|
|
10
|
+
import { createClipboardRouter } from "../clipboard/router";
|
|
11
|
+
import { mergeActiveConfiguration } from "../manifest/configuration";
|
|
12
|
+
import { mergeActiveContributions } from "../manifest/merge";
|
|
13
|
+
import type {
|
|
14
|
+
ContributionManifest,
|
|
15
|
+
MergedConfiguration,
|
|
16
|
+
MergedContributions,
|
|
17
|
+
} from "../manifest/types";
|
|
18
|
+
import type { CommandHandler, CommandId } from "../registry/types";
|
|
19
|
+
import { normalizeKey } from "../keys/normalize";
|
|
20
|
+
import { isWhenEnabled } from "../when";
|
|
21
|
+
import type { ContextKeys } from "../when/types";
|
|
22
|
+
import { commandsHotkeyDefaults } from "./hotkey-defaults";
|
|
23
|
+
import {
|
|
24
|
+
CommandsStoreContext,
|
|
25
|
+
createCommandsStore,
|
|
26
|
+
useCommandsStore,
|
|
27
|
+
type CommandsStore,
|
|
28
|
+
} from "./store";
|
|
29
|
+
|
|
30
|
+
function useStoreVersion(store: CommandsStore): number {
|
|
31
|
+
return useSyncExternalStore(
|
|
32
|
+
store.subscribe,
|
|
33
|
+
store.getSnapshot,
|
|
34
|
+
store.getSnapshot,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CommandsProviderProps = {
|
|
39
|
+
manifests?: ContributionManifest[];
|
|
40
|
+
onKeybinding?: (binding: { key: string; commandId: CommandId }) => void;
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function CommandsProvider({
|
|
45
|
+
manifests: initialManifests = [],
|
|
46
|
+
onKeybinding,
|
|
47
|
+
children,
|
|
48
|
+
}: CommandsProviderProps) {
|
|
49
|
+
const storeRef = useRef<CommandsStore | null>(null);
|
|
50
|
+
if (!storeRef.current) {
|
|
51
|
+
storeRef.current = createCommandsStore(initialManifests);
|
|
52
|
+
}
|
|
53
|
+
const store = storeRef.current;
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
store.setManifests(initialManifests);
|
|
57
|
+
}, [store, initialManifests]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const router = createClipboardRouter(() => store.getClipboardAdapter());
|
|
61
|
+
return router.attach(document);
|
|
62
|
+
}, [store]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<CommandsStoreContext.Provider value={store}>
|
|
66
|
+
<HotkeysProvider defaultOptions={commandsHotkeyDefaults}>
|
|
67
|
+
<CommandKeybindingsBridge onKeybinding={onKeybinding} />
|
|
68
|
+
{children}
|
|
69
|
+
</HotkeysProvider>
|
|
70
|
+
</CommandsStoreContext.Provider>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function CommandKeybindingsBridge({ onKeybinding }: {
|
|
75
|
+
onKeybinding: CommandsProviderProps["onKeybinding"];
|
|
76
|
+
}) {
|
|
77
|
+
const merged = useMergedContributions();
|
|
78
|
+
const { execute } = useCommands();
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{merged.keybindings.map((binding) => (
|
|
83
|
+
<Keybinding
|
|
84
|
+
key={`${binding.commandId}:${binding.key}`}
|
|
85
|
+
chord={binding.key}
|
|
86
|
+
commandId={binding.commandId}
|
|
87
|
+
execute={execute}
|
|
88
|
+
onKeybinding={onKeybinding}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function Keybinding({
|
|
96
|
+
chord,
|
|
97
|
+
commandId,
|
|
98
|
+
execute,
|
|
99
|
+
onKeybinding,
|
|
100
|
+
}: {
|
|
101
|
+
chord: string;
|
|
102
|
+
commandId: CommandId;
|
|
103
|
+
execute: (id: CommandId) => Promise<void>;
|
|
104
|
+
onKeybinding: CommandsProviderProps["onKeybinding"];
|
|
105
|
+
}) {
|
|
106
|
+
const registration = keybindingRegistration({ key: chord, commandId }, execute, onKeybinding);
|
|
107
|
+
|
|
108
|
+
useHotkey(
|
|
109
|
+
// Manifest chords are validated at merge time; TanStack types a finite set.
|
|
110
|
+
chord as Parameters<typeof useHotkey>[0],
|
|
111
|
+
registration.action,
|
|
112
|
+
registration.options,
|
|
113
|
+
);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function executeKeybinding(
|
|
118
|
+
binding: { key: string; commandId: CommandId },
|
|
119
|
+
execute: (id: CommandId) => Promise<void>,
|
|
120
|
+
onKeybinding?: CommandsProviderProps["onKeybinding"],
|
|
121
|
+
): void {
|
|
122
|
+
if (onKeybinding) onKeybinding(binding);
|
|
123
|
+
else void execute(binding.commandId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function keybindingRegistration(
|
|
127
|
+
binding: { key: string; commandId: CommandId },
|
|
128
|
+
execute: (id: CommandId) => Promise<void>,
|
|
129
|
+
onKeybinding?: CommandsProviderProps["onKeybinding"],
|
|
130
|
+
) {
|
|
131
|
+
const key = normalizeKey(binding.key);
|
|
132
|
+
return {
|
|
133
|
+
key: binding.key,
|
|
134
|
+
action: () => executeKeybinding(binding, execute, onKeybinding),
|
|
135
|
+
options: {
|
|
136
|
+
preventDefault: true,
|
|
137
|
+
ignoreInputs: key === "delete" || key === "backspace",
|
|
138
|
+
meta: { name: binding.commandId, description: binding.commandId },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function useMergedManifestList(): ContributionManifest[] {
|
|
144
|
+
const store = useCommandsStore();
|
|
145
|
+
useStoreVersion(store);
|
|
146
|
+
return useMemo(() => {
|
|
147
|
+
const scope = store.scopeManifest;
|
|
148
|
+
if (!scope) return store.manifests;
|
|
149
|
+
const rest = store.manifests.filter((item) => item.id !== scope.id);
|
|
150
|
+
return [...rest, scope];
|
|
151
|
+
}, [store, store.manifests, store.scopeManifest, store.getSnapshot()]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function useMergedContributions(): MergedContributions {
|
|
155
|
+
const store = useCommandsStore();
|
|
156
|
+
useStoreVersion(store);
|
|
157
|
+
const manifests = useMergedManifestList();
|
|
158
|
+
return useMemo(
|
|
159
|
+
() => mergeActiveContributions(manifests, store.activeScopeId),
|
|
160
|
+
[manifests, store.activeScopeId],
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function useMergedConfiguration(): MergedConfiguration {
|
|
165
|
+
const store = useCommandsStore();
|
|
166
|
+
useStoreVersion(store);
|
|
167
|
+
const manifests = useMergedManifestList();
|
|
168
|
+
return useMemo(
|
|
169
|
+
() => mergeActiveConfiguration(manifests, store.activeScopeId),
|
|
170
|
+
[manifests, store.activeScopeId],
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function useCommands() {
|
|
175
|
+
const store = useCommandsStore();
|
|
176
|
+
useStoreVersion(store);
|
|
177
|
+
|
|
178
|
+
const merged = useMergedContributions();
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
for (const command of merged.commands) {
|
|
182
|
+
store.registry.define({
|
|
183
|
+
id: command.id,
|
|
184
|
+
title: command.title,
|
|
185
|
+
group: command.group,
|
|
186
|
+
sourceId: command.sourceId,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}, [merged.commands, store.registry]);
|
|
190
|
+
|
|
191
|
+
const commandsById = useMemo(() => {
|
|
192
|
+
const map = new Map(merged.commands.map((command) => [command.id, command]));
|
|
193
|
+
return map;
|
|
194
|
+
}, [merged.commands]);
|
|
195
|
+
|
|
196
|
+
const isAvailable = useCallback(
|
|
197
|
+
(id: CommandId) => {
|
|
198
|
+
const command = commandsById.get(id);
|
|
199
|
+
return isWhenEnabled(command?.when, store.getContext());
|
|
200
|
+
},
|
|
201
|
+
[commandsById, store],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const execute = useCallback(
|
|
205
|
+
async (id: CommandId, args?: unknown) => {
|
|
206
|
+
if (!isAvailable(id)) return;
|
|
207
|
+
await store.registry.execute(id, args);
|
|
208
|
+
},
|
|
209
|
+
[isAvailable, store.registry],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const registerHandler = useCallback(
|
|
213
|
+
(id: CommandId, handler: CommandHandler) => {
|
|
214
|
+
return store.registry.registerHandler(id, handler);
|
|
215
|
+
},
|
|
216
|
+
[store.registry],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const setContext = useCallback(
|
|
220
|
+
(partial: ContextKeys) => {
|
|
221
|
+
store.setContext(partial);
|
|
222
|
+
},
|
|
223
|
+
[store],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const clearContext = useCallback(
|
|
227
|
+
(keys?: readonly string[]) => {
|
|
228
|
+
store.clearContext(keys);
|
|
229
|
+
},
|
|
230
|
+
[store],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
commands: merged.commands,
|
|
235
|
+
menus: merged.menus,
|
|
236
|
+
menuTree: merged.menuTree,
|
|
237
|
+
keybindings: merged.keybindings,
|
|
238
|
+
activeScopeId: store.activeScopeId,
|
|
239
|
+
execute,
|
|
240
|
+
registerHandler,
|
|
241
|
+
getUndoStack: store.getUndoStack,
|
|
242
|
+
replaceUndoStack: store.replaceUndoStack,
|
|
243
|
+
setClipboardAdapter: store.setClipboardAdapter,
|
|
244
|
+
getContext: store.getContext,
|
|
245
|
+
setContext,
|
|
246
|
+
clearContext,
|
|
247
|
+
isAvailable,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HotkeysProviderProps } from "@tanstack/react-hotkeys";
|
|
2
|
+
|
|
3
|
+
/** Shared defaults for moloo product shells. */
|
|
4
|
+
export const commandsHotkeyDefaults: NonNullable<
|
|
5
|
+
HotkeysProviderProps["defaultOptions"]
|
|
6
|
+
> = {
|
|
7
|
+
hotkey: {
|
|
8
|
+
preventDefault: true,
|
|
9
|
+
ignoreInputs: false,
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import type { ClipboardAdapter } from "../clipboard/types";
|
|
3
|
+
import type { UndoStack } from "../history/stack";
|
|
4
|
+
import type { ContributionManifest } from "../manifest/types";
|
|
5
|
+
import type { CommandHandler, CommandId } from "../registry/types";
|
|
6
|
+
|
|
7
|
+
export type ScopeContextValue = {
|
|
8
|
+
scopeId: string;
|
|
9
|
+
manifest: ContributionManifest;
|
|
10
|
+
active: boolean;
|
|
11
|
+
stack: UndoStack;
|
|
12
|
+
execute: (id: CommandId, args?: unknown) => Promise<void>;
|
|
13
|
+
registerHandler: (id: CommandId, handler: CommandHandler) => () => void;
|
|
14
|
+
hasHandler: (id: CommandId) => boolean;
|
|
15
|
+
setClipboardAdapter: (adapter: ClipboardAdapter | null) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const ScopeContext = createContext<ScopeContextValue | null>(null);
|
|
19
|
+
|
|
20
|
+
export function useScope(): ScopeContextValue {
|
|
21
|
+
const scope = useContext(ScopeContext);
|
|
22
|
+
if (!scope) {
|
|
23
|
+
throw new Error("useScope requires <ScopeProvider>");
|
|
24
|
+
}
|
|
25
|
+
return scope;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns null outside a scope (e.g. settings host above the page). */
|
|
29
|
+
export function useOptionalScope(): ScopeContextValue | null {
|
|
30
|
+
return useContext(ScopeContext);
|
|
31
|
+
}
|