@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,82 @@
1
+ import { schema } from "@json-render/react/schema";
2
+ import { z } from "zod";
3
+
4
+ const sizeValue = z.union([z.number(), z.string()]);
5
+
6
+ /**
7
+ * Layout catalog on the React spec schema.
8
+ *
9
+ * `visible` is an optional element field (not a prop). `false` is collapsed;
10
+ * omit or `true` is shown. VisibilityProvider already honors it.
11
+ *
12
+ * Keep per-element props checks in `validateLayoutSpec` until `propsOf`
13
+ * discriminates correctly for multi-component catalogs.
14
+ */
15
+ export const catalog = schema.createCatalog({
16
+ components: {
17
+ PanelGroup: {
18
+ props: z.object({
19
+ id: z.string(),
20
+ layout: z.enum(["split", "tabs"]),
21
+ orientation: z.enum(["horizontal", "vertical"]).nullable().optional(),
22
+ active: z.string().nullable().optional(),
23
+ defaultSize: sizeValue.nullable().optional(),
24
+ minSize: sizeValue.nullable().optional(),
25
+ }),
26
+ description: "Arranges child panels by split or tabs",
27
+ },
28
+ Panel: {
29
+ props: z.object({
30
+ id: z.string(),
31
+ contentId: z.string(),
32
+ defaultSize: sizeValue.nullable().optional(),
33
+ minSize: sizeValue.nullable().optional(),
34
+ maxSize: sizeValue.nullable().optional(),
35
+ sectionIds: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/)).optional(),
36
+ }),
37
+ description: "Leaf editor panel bound to one content slot",
38
+ },
39
+ Toolbar: {
40
+ props: z.object({
41
+ id: z.string(),
42
+ }),
43
+ description: "Per-panel command toolbar",
44
+ },
45
+ ToolbarButton: {
46
+ props: z.object({
47
+ commandId: z.string(),
48
+ icon: z.string().nullable().optional(),
49
+ }),
50
+ description: "Runs a command by id",
51
+ },
52
+ ToolbarSeparator: {
53
+ props: z.object({}),
54
+ description: "Visual break between toolbar groups",
55
+ },
56
+ ToolbarSpacer: {
57
+ props: z.object({}),
58
+ description: "Flexible gap that pushes following tools to the end",
59
+ },
60
+ },
61
+ actions: {
62
+ executeCommand: {
63
+ params: z.object({
64
+ commandId: z.string(),
65
+ }),
66
+ description: "Invoke a workspace command by id",
67
+ },
68
+ },
69
+ });
70
+
71
+ export const TOOLBAR_CHROME = new Set([
72
+ "Toolbar",
73
+ "ToolbarButton",
74
+ "ToolbarSeparator",
75
+ "ToolbarSpacer",
76
+ ]);
77
+
78
+ export const TOOLBAR_ITEMS = new Set([
79
+ "ToolbarButton",
80
+ "ToolbarSeparator",
81
+ "ToolbarSpacer",
82
+ ]);
@@ -0,0 +1,47 @@
1
+ import { isWhenEnabled, type ContextKeys } from "../../commands";
2
+
3
+ type CommandWhenSource = {
4
+ id: string;
5
+ when?: string;
6
+ };
7
+
8
+ const ALWAYS_VISIBLE_TOOLBAR_COMMANDS = new Set([
9
+ "editor.undo",
10
+ "editor.redo",
11
+ "bundle.add",
12
+ "bundle.restore",
13
+ "bundle.delete",
14
+ "bundle.confirm",
15
+ ]);
16
+
17
+ /**
18
+ * Surface policy for unavailable commands (`when` false):
19
+ * - menu: show + disabled
20
+ * - toolbar / palette: hide
21
+ * - undo / redo / bundle asset + confirm toolbar: show + disabled in edit; hide in read
22
+ * - keybinding / execute: no-op (handled in useCommands.execute)
23
+ */
24
+ export function isCommandAvailable(
25
+ command: CommandWhenSource | undefined,
26
+ ctx: ContextKeys,
27
+ ): boolean {
28
+ if (!command) return true;
29
+ return isWhenEnabled(command.when, ctx);
30
+ }
31
+
32
+ export function shouldHideCommand(
33
+ command: CommandWhenSource | undefined,
34
+ ctx: ContextKeys,
35
+ ): boolean {
36
+ if (command && ALWAYS_VISIBLE_TOOLBAR_COMMANDS.has(command.id)) {
37
+ return ctx.editorMode !== "edit";
38
+ }
39
+ return !isCommandAvailable(command, ctx);
40
+ }
41
+
42
+ export function shouldDisableCommand(
43
+ command: CommandWhenSource | undefined,
44
+ ctx: ContextKeys,
45
+ ): boolean {
46
+ return !isCommandAvailable(command, ctx);
47
+ }
@@ -0,0 +1,42 @@
1
+ import type { ContextKeys } from "../../commands";
2
+ import {
3
+ shouldDisableCommand,
4
+ shouldHideCommand,
5
+ } from "./command-affordances";
6
+
7
+ export type CommandLayoutSource = {
8
+ id: string;
9
+ title: string;
10
+ when?: string;
11
+ };
12
+
13
+ export type CommandLayoutEntry = {
14
+ visible: boolean;
15
+ disabled: boolean;
16
+ pressed: boolean;
17
+ label: string;
18
+ };
19
+
20
+ export type CommandPresentation = {
21
+ label?: string;
22
+ pressed?: boolean;
23
+ };
24
+
25
+ /** Snapshot of command affordances for json-render `/commands/{id}` state. */
26
+ export function commandLayoutState(
27
+ commands: readonly CommandLayoutSource[],
28
+ ctx: ContextKeys,
29
+ presentation: Readonly<Record<string, CommandPresentation>> = {},
30
+ ): { commands: Record<string, CommandLayoutEntry> } {
31
+ const next: Record<string, CommandLayoutEntry> = {};
32
+ for (const command of commands) {
33
+ const extra = presentation[command.id];
34
+ next[command.id] = {
35
+ visible: !shouldHideCommand(command, ctx),
36
+ disabled: shouldDisableCommand(command, ctx),
37
+ pressed: extra?.pressed === true,
38
+ label: extra?.label ?? command.title,
39
+ };
40
+ }
41
+ return { commands: next };
42
+ }
@@ -0,0 +1,358 @@
1
+ import {
2
+ keyToKbdTokens,
3
+ useCommands,
4
+ type MenuNode,
5
+ } from "../../commands";
6
+ import { IconCheck } from "@tabler/icons-react";
7
+ import {
8
+ useLayoutBinding,
9
+ type LayoutBindingValue,
10
+ } from "../layout-binding";
11
+ import { usePanelToggles } from "../slots-context";
12
+ import {
13
+ Button,
14
+ DropdownMenu,
15
+ DropdownMenuCheckboxItem,
16
+ DropdownMenuContent,
17
+ DropdownMenuGroup,
18
+ DropdownMenuItem,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuTrigger,
21
+ } from "@moloodev/ui";
22
+ import { resolveEditorIcon } from "../../commands/icons/catalog";
23
+ import { EditorShortcutKeys } from "./editor-shortcut-keys";
24
+ import {
25
+ editorMenuContentClass,
26
+ editorMenuItemClass,
27
+ } from "./menu-styles";
28
+ import { midMenuNodes } from "./mid-menu-nodes";
29
+ import { useEditorCommandAffordances } from "./use-editor-command-affordances";
30
+ import { visibleMenuLeaves } from "./visible-menu-leaves";
31
+ import type { CommandLayoutEntry } from "./command-layout-state";
32
+
33
+ type MenuCommandState = Readonly<Record<string, CommandLayoutEntry>>;
34
+
35
+ /** Horizontal editor menubar driven by merged contribution manifests. */
36
+ export function EditorMenuBar({
37
+ commandState,
38
+ onExecute,
39
+ }: {
40
+ commandState?: MenuCommandState;
41
+ onExecute?: (commandId: string) => unknown;
42
+ } = {}) {
43
+ const { menuTree, keybindings, execute, getContext } = useCommands();
44
+ const affordances = useEditorCommandAffordances();
45
+ const panels = usePanelToggles();
46
+ const ctx = getContext();
47
+ const keysByCommand = new Map(
48
+ keybindings.map((binding) => [binding.commandId, binding.key]),
49
+ );
50
+
51
+ const midNodes = midMenuNodes(menuTree, panels.length > 0);
52
+ const menuAffordances = {
53
+ disabled: (commandId: string) =>
54
+ commandState
55
+ ? commandState[commandId]?.disabled !== false
56
+ : affordances.disabled(commandId),
57
+ };
58
+ const executeCommand = (commandId: string) => {
59
+ if (
60
+ commandState &&
61
+ (!commandState[commandId]?.visible || menuAffordances.disabled(commandId))
62
+ ) return;
63
+ if (onExecute) void onExecute(commandId);
64
+ else void execute(commandId);
65
+ };
66
+
67
+ return (
68
+ <nav
69
+ className="flex h-8 shrink-0 items-center gap-0.5 border-b border-border bg-card px-1"
70
+ aria-label="에디터 메뉴"
71
+ >
72
+ {midNodes.map((node) => {
73
+ const visibleChildren = visibleMenuLeaves(node.children, ctx, commandState);
74
+ if (node.segment === "view") {
75
+ if (commandState && panels.length === 0 && visibleChildren.length === 0) {
76
+ return null;
77
+ }
78
+ return (
79
+ <ViewMenu
80
+ key={node.id}
81
+ commandNodes={visibleChildren}
82
+ keysByCommand={keysByCommand}
83
+ affordances={menuAffordances}
84
+ commandState={commandState}
85
+ onExecute={executeCommand}
86
+ />
87
+ );
88
+ }
89
+ if (
90
+ commandState &&
91
+ visibleMenuLeaves([node], ctx, commandState).length === 0
92
+ ) return null;
93
+ if (visibleChildren.length > 0) {
94
+ return (
95
+ <DropdownMenu key={node.id}>
96
+ <DropdownMenuTrigger
97
+ render={
98
+ <Button
99
+ type="button"
100
+ size="sm"
101
+ variant="ghost"
102
+ className="h-7 px-2 font-display text-xs font-semibold"
103
+ />
104
+ }
105
+ >
106
+ {node.label}
107
+ </DropdownMenuTrigger>
108
+ <DropdownMenuContent
109
+ align="start"
110
+ className={editorMenuContentClass}
111
+ >
112
+ <GroupedMenuLeaves
113
+ nodes={visibleChildren}
114
+ keysByCommand={keysByCommand}
115
+ affordances={menuAffordances}
116
+ commandState={commandState}
117
+ onExecute={executeCommand}
118
+ />
119
+ </DropdownMenuContent>
120
+ </DropdownMenu>
121
+ );
122
+ }
123
+ if (!node.commandId) return null;
124
+ if (commandState && commandState[node.commandId]?.visible !== true) return null;
125
+ return (
126
+ <MenuLeafButton
127
+ key={node.id}
128
+ node={node}
129
+ keysByCommand={keysByCommand}
130
+ disabled={menuAffordances.disabled(node.commandId)}
131
+ state={commandState?.[node.commandId]}
132
+ onExecute={executeCommand}
133
+ />
134
+ );
135
+ })}
136
+ </nav>
137
+ );
138
+ }
139
+
140
+ function ViewMenu({
141
+ commandNodes,
142
+ keysByCommand,
143
+ affordances,
144
+ commandState,
145
+ onExecute,
146
+ }: {
147
+ commandNodes: MenuNode[];
148
+ keysByCommand: Map<string, string>;
149
+ affordances: { disabled: (commandId: string) => boolean };
150
+ commandState?: MenuCommandState;
151
+ onExecute: (id: string) => void;
152
+ }) {
153
+ const panels = usePanelToggles();
154
+ return (
155
+ <DropdownMenu>
156
+ <DropdownMenuTrigger
157
+ render={
158
+ <Button
159
+ type="button"
160
+ size="sm"
161
+ variant="ghost"
162
+ className="h-7 px-2 font-display text-xs font-semibold"
163
+ />
164
+ }
165
+ >
166
+ 보기
167
+ </DropdownMenuTrigger>
168
+ <DropdownMenuContent align="start" className={editorMenuContentClass}>
169
+ {panels.length > 0 ? (
170
+ <DropdownMenuGroup>
171
+ {panels.map((panel) => (
172
+ <DropdownMenuCheckboxItem
173
+ key={panel.id}
174
+ className={`${editorMenuItemClass} pr-7`}
175
+ checked={panel.checked}
176
+ closeOnClick={false}
177
+ onCheckedChange={(checked) => {
178
+ panel.onCheckedChange(checked === true);
179
+ }}
180
+ >
181
+ {panel.label}
182
+ </DropdownMenuCheckboxItem>
183
+ ))}
184
+ </DropdownMenuGroup>
185
+ ) : null}
186
+ {panels.length > 0 && commandNodes.length > 0 ? (
187
+ <DropdownMenuSeparator className="my-0.5" />
188
+ ) : null}
189
+ {commandNodes.length > 0 ? (
190
+ <GroupedMenuLeaves
191
+ nodes={commandNodes}
192
+ keysByCommand={keysByCommand}
193
+ affordances={affordances}
194
+ commandState={commandState}
195
+ onExecute={onExecute}
196
+ />
197
+ ) : null}
198
+ </DropdownMenuContent>
199
+ </DropdownMenu>
200
+ );
201
+ }
202
+
203
+ function GroupedMenuLeaves({
204
+ nodes,
205
+ keysByCommand,
206
+ affordances,
207
+ commandState,
208
+ onExecute,
209
+ }: {
210
+ nodes: MenuNode[];
211
+ keysByCommand: Map<string, string>;
212
+ affordances: {
213
+ disabled: (commandId: string) => boolean;
214
+ };
215
+ commandState?: MenuCommandState;
216
+ onExecute: (id: string) => void;
217
+ }) {
218
+ const groups: MenuNode[][] = [];
219
+ for (const node of nodes) {
220
+ const group = node.group ?? "";
221
+ const last = groups[groups.length - 1];
222
+ if (!last || (last[0]?.group ?? "") !== group) {
223
+ groups.push([node]);
224
+ } else {
225
+ last.push(node);
226
+ }
227
+ }
228
+
229
+ return (
230
+ <>
231
+ {groups.map((groupNodes, index) => (
232
+ <div key={groupNodes[0]?.group ?? index}>
233
+ {index > 0 ? (
234
+ <DropdownMenuSeparator className="my-0.5" />
235
+ ) : null}
236
+ <DropdownMenuGroup>
237
+ {groupNodes.map((child) => (
238
+ <MenuLeaf
239
+ key={child.id}
240
+ node={child}
241
+ keysByCommand={keysByCommand}
242
+ disabled={
243
+ child.commandId
244
+ ? affordances.disabled(child.commandId)
245
+ : false
246
+ }
247
+ state={child.commandId ? commandState?.[child.commandId] : undefined}
248
+ onExecute={onExecute}
249
+ />
250
+ ))}
251
+ </DropdownMenuGroup>
252
+ </div>
253
+ ))}
254
+ </>
255
+ );
256
+ }
257
+
258
+ function menuLeafIcon(
259
+ resolveIcon: LayoutBindingValue["resolveIcon"],
260
+ commandId: string,
261
+ ) {
262
+ if (resolveIcon) return resolveIcon(undefined, commandId);
263
+ return resolveEditorIcon(undefined, commandId);
264
+ }
265
+
266
+ function isPanelToggleCommand(commandId: string) {
267
+ return commandId.includes(".togglePanel.");
268
+ }
269
+
270
+ export function MenuLeaf({
271
+ node,
272
+ keysByCommand,
273
+ disabled = false,
274
+ state,
275
+ onExecute,
276
+ }: {
277
+ node: MenuNode;
278
+ keysByCommand: Map<string, string>;
279
+ disabled?: boolean;
280
+ state?: CommandLayoutEntry;
281
+ onExecute: (id: string) => void;
282
+ }) {
283
+ const { resolveIcon } = useLayoutBinding();
284
+ if (!node.commandId) return null;
285
+ const Icon = menuLeafIcon(resolveIcon, node.commandId);
286
+ const key = keysByCommand.get(node.commandId) ?? node.shortcut;
287
+ const tokens = key ? keyToKbdTokens(key) : [];
288
+
289
+ const icon = Icon ? (
290
+ <Icon className="size-3.5 shrink-0" />
291
+ ) : (
292
+ <span className="size-3.5 shrink-0" aria-hidden />
293
+ );
294
+ const labelAndKeys = (
295
+ <>
296
+ <span className="min-w-0 flex-1 truncate">{state?.label ?? node.label}</span>
297
+ <EditorShortcutKeys tokens={tokens} className="ml-auto" />
298
+ </>
299
+ );
300
+
301
+ if (isPanelToggleCommand(node.commandId)) {
302
+ return (
303
+ <DropdownMenuCheckboxItem
304
+ className={`${editorMenuItemClass} pr-7`}
305
+ disabled={disabled}
306
+ checked={state?.pressed === true}
307
+ closeOnClick
308
+ onCheckedChange={() => onExecute(node.commandId!)}
309
+ >
310
+ {icon}
311
+ {labelAndKeys}
312
+ </DropdownMenuCheckboxItem>
313
+ );
314
+ }
315
+
316
+ return (
317
+ <DropdownMenuItem
318
+ className={editorMenuItemClass}
319
+ disabled={disabled}
320
+ aria-pressed={state?.pressed ? true : undefined}
321
+ onClick={() => onExecute(node.commandId!)}
322
+ >
323
+ {state?.pressed ? <IconCheck className="size-3.5 shrink-0" aria-hidden /> : icon}
324
+ {labelAndKeys}
325
+ </DropdownMenuItem>
326
+ );
327
+ }
328
+
329
+ function MenuLeafButton({
330
+ node,
331
+ keysByCommand,
332
+ disabled = false,
333
+ state,
334
+ onExecute,
335
+ }: {
336
+ node: MenuNode;
337
+ keysByCommand: Map<string, string>;
338
+ disabled?: boolean;
339
+ state?: CommandLayoutEntry;
340
+ onExecute: (id: string) => void;
341
+ }) {
342
+ if (!node.commandId) return null;
343
+ const key = keysByCommand.get(node.commandId) ?? node.shortcut;
344
+ return (
345
+ <Button
346
+ type="button"
347
+ size="sm"
348
+ variant="ghost"
349
+ disabled={disabled}
350
+ aria-pressed={state?.pressed ? true : undefined}
351
+ aria-keyshortcuts={key}
352
+ className="h-7 px-2 font-display text-xs font-semibold"
353
+ onClick={() => onExecute(node.commandId!)}
354
+ >
355
+ {state?.label ?? node.label}
356
+ </Button>
357
+ );
358
+ }
@@ -0,0 +1,27 @@
1
+ import { Kbd, KbdGroup, cn, kbdKey } from "@moloodev/ui";
2
+
3
+ type EditorShortcutKeysProps = {
4
+ tokens: readonly string[];
5
+ className?: string;
6
+ };
7
+
8
+ /** Menu-style shortcut chips (⌘ / ⇧ / key). */
9
+ export function EditorShortcutKeys({
10
+ tokens,
11
+ className,
12
+ }: EditorShortcutKeysProps) {
13
+ if (tokens.length === 0) return null;
14
+
15
+ return (
16
+ <KbdGroup className={cn("shrink-0 gap-0.5", className)}>
17
+ {tokens.map((token) => (
18
+ <Kbd
19
+ key={token}
20
+ className="h-3.5 min-w-3.5 rounded border px-0.5 text-[0.625rem] shadow-none"
21
+ >
22
+ {kbdKey(token)}
23
+ </Kbd>
24
+ ))}
25
+ </KbdGroup>
26
+ );
27
+ }
@@ -0,0 +1,3 @@
1
+ /** Shared chrome menu surface styles (editor menu + mode menu). */
2
+ export const editorMenuItemClass = "gap-1.5 px-2 py-1 text-xs font-semibold";
3
+ export const editorMenuContentClass = "w-44 p-0.5";
@@ -0,0 +1,27 @@
1
+ import type { MenuNode } from "../../commands";
2
+
3
+ /** Same order as contribution `view` roots (`SEGMENT_META.view`). */
4
+ const VIEW_MENU_ORDER = 40;
5
+
6
+ /**
7
+ * Mid-menu roots keep a single `view` entry. Panel chrome joins that
8
+ * submenu; it is not a second top-level 「보기」.
9
+ */
10
+ export function midMenuNodes(
11
+ nodes: readonly MenuNode[],
12
+ hasPanels: boolean,
13
+ ): MenuNode[] {
14
+ if (nodes.some((node) => node.segment === "view") || !hasPanels) {
15
+ return [...nodes];
16
+ }
17
+ const placeholder: MenuNode = {
18
+ id: "view",
19
+ segment: "view",
20
+ label: "보기",
21
+ order: VIEW_MENU_ORDER,
22
+ children: [],
23
+ };
24
+ return [...nodes, placeholder].sort(
25
+ (a, b) => a.order - b.order || a.label.localeCompare(b.label),
26
+ );
27
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo } from "react";
2
+ import { useCommands } from "../../commands";
3
+ import {
4
+ shouldDisableCommand,
5
+ shouldHideCommand,
6
+ } from "./command-affordances";
7
+
8
+ /**
9
+ * Disable/hide rules from merged command `when` + context bag.
10
+ * Subscribes via useCommands so context / undo updates re-render chrome.
11
+ */
12
+ export function useEditorCommandAffordances() {
13
+ const { commands, getContext } = useCommands();
14
+ const ctx = getContext();
15
+ const commandsById = useMemo(
16
+ () => new Map(commands.map((command) => [command.id, command])),
17
+ [commands],
18
+ );
19
+
20
+ return useMemo(
21
+ () => ({
22
+ /** Toolbar / palette: omit when unavailable (undo/redo stay). */
23
+ hidden(commandId: string) {
24
+ return shouldHideCommand(commandsById.get(commandId), ctx);
25
+ },
26
+ /** Menu: keep visible, disable when unavailable. */
27
+ disabled(commandId: string) {
28
+ return shouldDisableCommand(commandsById.get(commandId), ctx);
29
+ },
30
+ }),
31
+ [commandsById, ctx],
32
+ );
33
+ }
@@ -0,0 +1,15 @@
1
+ import { isWhenEnabled, type ContextKeys, type MenuNode } from "../../commands";
2
+ import type { CommandLayoutEntry } from "./command-layout-state";
3
+
4
+ export function visibleMenuLeaves(
5
+ nodes: readonly MenuNode[],
6
+ ctx: ContextKeys,
7
+ commandState?: Readonly<Record<string, CommandLayoutEntry>>,
8
+ ): MenuNode[] {
9
+ return nodes.filter((node) => {
10
+ if (commandState && node.commandId && node.children.length === 0) {
11
+ return commandState[node.commandId]?.visible === true;
12
+ }
13
+ return isWhenEnabled(node.when, ctx);
14
+ });
15
+ }
@@ -0,0 +1,31 @@
1
+ import { IconFileAlert, IconFolderOpen } from "@tabler/icons-react";
2
+ import { Button } from "@moloodev/ui";
3
+
4
+ export function ContentErrorNotice({
5
+ onOpenBrowser,
6
+ }: {
7
+ onOpenBrowser: () => void;
8
+ }) {
9
+ return (
10
+ <section
11
+ role="alert"
12
+ className="flex min-h-0 flex-1 items-center justify-center p-6"
13
+ >
14
+ <div className="max-w-md">
15
+ <IconFileAlert
16
+ aria-hidden
17
+ className="mb-4 size-8 text-muted-foreground"
18
+ />
19
+ <h2 className="text-lg font-semibold">애셋을 열 수 없습니다</h2>
20
+ <p className="mt-3 text-sm leading-6 text-muted-foreground">
21
+ 저장된 데이터 형식을 읽을 수 없어 편집을 중지했습니다. 다른 애셋을
22
+ 열거나 이전 리비전을 확인해 주세요.
23
+ </p>
24
+ <Button className="mt-5" variant="outline" onClick={onOpenBrowser}>
25
+ <IconFolderOpen aria-hidden />
26
+ 애셋 브라우저 열기
27
+ </Button>
28
+ </div>
29
+ </section>
30
+ );
31
+ }