@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,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,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
|
+
}
|