@moloodev/editor-sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +62 -3
  2. package/src/channel.ts +287 -0
  3. package/src/chrome-manifest.ts +111 -0
  4. package/src/commands/clipboard/router.ts +99 -0
  5. package/src/commands/clipboard/types.ts +20 -0
  6. package/src/commands/history/stack.ts +53 -0
  7. package/src/commands/icons/catalog.ts +172 -0
  8. package/src/commands/index.ts +100 -0
  9. package/src/commands/keys/display.ts +16 -0
  10. package/src/commands/keys/normalize.ts +55 -0
  11. package/src/commands/keys/reserved.ts +62 -0
  12. package/src/commands/keys/validate.ts +77 -0
  13. package/src/commands/manifest/configuration.ts +59 -0
  14. package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
  15. package/src/commands/manifest/editor-document.ts +501 -0
  16. package/src/commands/manifest/ids.ts +2 -0
  17. package/src/commands/manifest/merge.ts +215 -0
  18. package/src/commands/manifest/types.ts +113 -0
  19. package/src/commands/manifest/validate-manifest.ts +45 -0
  20. package/src/commands/react/context.tsx +249 -0
  21. package/src/commands/react/hotkey-defaults.ts +11 -0
  22. package/src/commands/react/scope-context.tsx +31 -0
  23. package/src/commands/react/scope-provider.tsx +123 -0
  24. package/src/commands/react/store.ts +182 -0
  25. package/src/commands/registry/create.ts +55 -0
  26. package/src/commands/registry/types.ts +12 -0
  27. package/src/commands/when/evaluate.ts +80 -0
  28. package/src/commands/when/index.ts +19 -0
  29. package/src/commands/when/parse.ts +276 -0
  30. package/src/commands/when/types.ts +24 -0
  31. package/src/commands/when/validate.ts +20 -0
  32. package/src/crdt/awareness.ts +29 -0
  33. package/src/crdt/bytes.ts +26 -0
  34. package/src/crdt/connect.ts +285 -0
  35. package/src/crdt/diff.ts +86 -0
  36. package/src/crdt/doc.ts +67 -0
  37. package/src/crdt/front-matter.ts +45 -0
  38. package/src/crdt/index.ts +76 -0
  39. package/src/crdt/persist.ts +40 -0
  40. package/src/crdt/protocol.ts +119 -0
  41. package/src/crdt/shared.ts +104 -0
  42. package/src/crdt/text-body.ts +125 -0
  43. package/src/crdt/undo.ts +59 -0
  44. package/src/crdt/vite-plugin.ts +42 -0
  45. package/src/crdt/yjs.ts +16 -0
  46. package/src/editor-common.ts +160 -0
  47. package/src/editor-id.ts +56 -0
  48. package/src/frame-attributes.ts +9 -0
  49. package/src/index.ts +229 -0
  50. package/src/json-schema.ts +13 -0
  51. package/src/layout/bind-toolbar.ts +60 -0
  52. package/src/layout/catalog.ts +82 -0
  53. package/src/layout/chrome/command-affordances.ts +47 -0
  54. package/src/layout/chrome/command-layout-state.ts +42 -0
  55. package/src/layout/chrome/editor-menu.tsx +358 -0
  56. package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
  57. package/src/layout/chrome/menu-styles.ts +3 -0
  58. package/src/layout/chrome/mid-menu-nodes.ts +27 -0
  59. package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
  60. package/src/layout/chrome/visible-menu-leaves.ts +15 -0
  61. package/src/layout/content-error-notice.tsx +31 -0
  62. package/src/layout/define-editor-layout.ts +60 -0
  63. package/src/layout/define-editor-layout.types.ts +57 -0
  64. package/src/layout/doc-undo-bridge.tsx +22 -0
  65. package/src/layout/dock-zones.tsx +61 -0
  66. package/src/layout/drop.ts +39 -0
  67. package/src/layout/editor-layout.tsx +287 -0
  68. package/src/layout/editor-loading.tsx +23 -0
  69. package/src/layout/frame-layout-binding.tsx +74 -0
  70. package/src/layout/frame.ts +23 -0
  71. package/src/layout/hide.ts +103 -0
  72. package/src/layout/index.ts +14 -0
  73. package/src/layout/layout-binding.tsx +42 -0
  74. package/src/layout/layout-storage-key.ts +6 -0
  75. package/src/layout/layout-values.ts +48 -0
  76. package/src/layout/mutate.ts +486 -0
  77. package/src/layout/nesting-context.tsx +20 -0
  78. package/src/layout/panel-header-action.tsx +29 -0
  79. package/src/layout/panel-header.tsx +168 -0
  80. package/src/layout/panel-hide-menu.tsx +6 -0
  81. package/src/layout/panel-menu.tsx +85 -0
  82. package/src/layout/panel-section.tsx +114 -0
  83. package/src/layout/panel-size.ts +94 -0
  84. package/src/layout/panel-title.tsx +5 -0
  85. package/src/layout/registry.tsx +333 -0
  86. package/src/layout/restore-panel.ts +26 -0
  87. package/src/layout/slots-context.tsx +189 -0
  88. package/src/layout/tab-button.tsx +60 -0
  89. package/src/layout/toolbar-scroll.ts +22 -0
  90. package/src/layout/toolbar.tsx +168 -0
  91. package/src/layout/types.ts +41 -0
  92. package/src/layout/use-collapsed-sections.ts +50 -0
  93. package/src/layout/validate-layout-spec.ts +143 -0
  94. package/src/message-samples.ts +377 -0
  95. package/src/origin-headers.ts +57 -0
  96. package/src/print/index.ts +10 -0
  97. package/src/print/inspect.ts +16 -0
  98. package/src/print/schema.ts +122 -0
  99. package/src/protocol-entry.ts +19 -0
  100. package/src/protocol.ts +471 -0
  101. package/src/render-result.ts +25 -0
  102. package/src/view/editor-chrome.ts +28 -0
  103. package/src/view/hooks.ts +260 -0
  104. package/src/view/mount-editor-view.ts +36 -0
  105. package/src/view/port-binding.ts +127 -0
  106. package/src/view/port-commands.ts +36 -0
  107. package/src/view/port.ts +35 -0
  108. package/src/view/theme-class.ts +11 -0
  109. package/src/view.ts +313 -0
  110. package/README.md +0 -3
@@ -0,0 +1,333 @@
1
+ import {
2
+ Children,
3
+ Fragment,
4
+ useCallback,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { defineRegistry } from "@json-render/react";
11
+ import {
12
+ ResizableHandle,
13
+ ResizablePanel,
14
+ ResizablePanelGroup,
15
+ usePanelRef,
16
+ } from "@moloodev/ui";
17
+ import { catalog } from "./catalog";
18
+ import {
19
+ LayoutToolbar,
20
+ LayoutToolbarButton,
21
+ LayoutToolbarSeparator,
22
+ LayoutToolbarSpacer,
23
+ } from "./toolbar";
24
+ import { PanelDockZones } from "./dock-zones";
25
+ import {
26
+ LayoutNestingProvider,
27
+ useIsNestedLayout,
28
+ } from "./nesting-context";
29
+ import { findPanelKeyByContent, findParentKey } from "./mutate";
30
+ import { PanelHeader } from "./panel-header";
31
+ import { groupResizeBehaviorFor, groupSizeFromLeaves } from "./panel-size";
32
+ import { restoreCollapsedPanel } from "./restore-panel";
33
+ import { useRuntime } from "./slots-context";
34
+ import type { LayoutElement } from "./types";
35
+
36
+ type LeafProps = {
37
+ id: string;
38
+ contentId: string;
39
+ defaultSize?: number | string | null;
40
+ minSize?: number | string | null;
41
+ maxSize?: number | string | null;
42
+ };
43
+
44
+ type GroupProps = {
45
+ id: string;
46
+ layout: "split" | "tabs";
47
+ orientation?: "horizontal" | "vertical" | null;
48
+ active?: string | null;
49
+ defaultSize?: number | string | null;
50
+ minSize?: number | string | null;
51
+ };
52
+
53
+ function sizeProp(value: number | string | null | undefined) {
54
+ return value ?? undefined;
55
+ }
56
+
57
+ function elementsOf(spec: { elements: unknown }): Record<string, LayoutElement> {
58
+ return spec.elements as Record<string, LayoutElement>;
59
+ }
60
+
61
+ function findElementKey(spec: { elements: unknown }, propsId: string): string {
62
+ const elements = elementsOf(spec);
63
+ if (elements[propsId]) return propsId;
64
+ for (const [key, el] of Object.entries(elements)) {
65
+ if (el.props.id === propsId) return key;
66
+ }
67
+ return propsId;
68
+ }
69
+
70
+ function tabsWrapperSize(
71
+ childKeys: string[],
72
+ elements: Record<string, LayoutElement>,
73
+ empty: boolean,
74
+ ) {
75
+ const leaves = childKeys
76
+ .map((key) => elements[key])
77
+ .filter((el): el is LayoutElement => el?.type === "Panel")
78
+ .map((el) => ({
79
+ defaultSize: el.props.defaultSize as number | string | null | undefined,
80
+ minSize: el.props.minSize as number | string | null | undefined,
81
+ maxSize: el.props.maxSize as number | string | null | undefined,
82
+ }));
83
+ return groupSizeFromLeaves(leaves, { empty });
84
+ }
85
+
86
+ /** Mount the group only after the host box has a real size so defaultSize applies. */
87
+ function useReadyBox() {
88
+ const ref = useRef<HTMLDivElement | null>(null);
89
+ const [ready, setReady] = useState(false);
90
+ useLayoutEffect(() => {
91
+ const el = ref.current;
92
+ if (!el) return;
93
+ const check = () => {
94
+ if (el.clientWidth > 0 && el.clientHeight > 0) setReady(true);
95
+ };
96
+ check();
97
+ const observer = new ResizeObserver(check);
98
+ observer.observe(el);
99
+ return () => observer.disconnect();
100
+ }, []);
101
+ return { ref, ready };
102
+ }
103
+
104
+ function interleaveHandles(children: ReactNode) {
105
+ const items = Children.toArray(children);
106
+ return items.map((child, index) => (
107
+ <Fragment key={index}>
108
+ {index > 0 ? <ResizableHandle /> : null}
109
+ {child}
110
+ </Fragment>
111
+ ));
112
+ }
113
+
114
+ function LayoutPanel({
115
+ props,
116
+ children,
117
+ }: {
118
+ props: LeafProps;
119
+ children?: ReactNode;
120
+ }) {
121
+ const { spec, getSlot, isPanelVisible } = useRuntime();
122
+ const panelKey = findPanelKeyByContent(spec, props.contentId) ?? props.id;
123
+ const parentKey = findParentKey(elementsOf(spec), panelKey);
124
+ const parent = parentKey ? elementsOf(spec)[parentKey] : undefined;
125
+ const inTabs = parent?.type === "PanelGroup" && parent.props.layout === "tabs";
126
+ const slot = getSlot(props.contentId);
127
+ const siblingKeys = parent?.children ?? [];
128
+ const visibleSiblings = siblingKeys.filter((key) => isPanelVisible(key));
129
+ const activeKey =
130
+ typeof parent?.props.active === "string" &&
131
+ visibleSiblings.includes(parent.props.active)
132
+ ? parent.props.active
133
+ : (visibleSiblings[0] ?? null);
134
+ const isActive = !inTabs || panelKey === activeKey;
135
+
136
+ const chrome = (
137
+ <div
138
+ className={
139
+ inTabs && !isActive
140
+ ? "hidden"
141
+ : "relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-card"
142
+ }
143
+ >
144
+ {inTabs ? null : (
145
+ <PanelHeader
146
+ stripKey={panelKey}
147
+ childKeys={[panelKey]}
148
+ activeKey={panelKey}
149
+ dropKind="stack"
150
+ />
151
+ )}
152
+ {isActive ? children : null}
153
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto has-[>[data-panel-section]]:overflow-hidden has-[[data-slot=editor-toolbar]]:bg-background">
154
+ {isActive ? (slot?.body ?? null) : null}
155
+ </div>
156
+ {inTabs ? null : <PanelDockZones panelKey={panelKey} />}
157
+ </div>
158
+ );
159
+
160
+ if (inTabs) return chrome;
161
+
162
+ return (
163
+ <ResizablePanel
164
+ id={props.id}
165
+ collapsible
166
+ collapsedSize={0}
167
+ defaultSize={sizeProp(props.defaultSize)}
168
+ minSize={sizeProp(props.minSize)}
169
+ maxSize={sizeProp(props.maxSize)}
170
+ groupResizeBehavior={groupResizeBehaviorFor(props.defaultSize)}
171
+ >
172
+ {chrome}
173
+ </ResizablePanel>
174
+ );
175
+ }
176
+
177
+ function LayoutSplitGroup({
178
+ props,
179
+ children,
180
+ }: {
181
+ props: GroupProps;
182
+ children?: ReactNode;
183
+ }) {
184
+ const nested = useIsNestedLayout();
185
+ const { ref, ready } = useReadyBox();
186
+ const { spec, persistGroupLayout } = useRuntime();
187
+ const groupKey = findElementKey(spec, props.id);
188
+
189
+ const onLayoutChanged = useCallback(
190
+ (layout: Record<string, number>, meta: { isUserInteraction: boolean }) => {
191
+ if (!meta.isUserInteraction) return;
192
+ const box = ref.current;
193
+ if (!box) return;
194
+ const groupPx =
195
+ (props.orientation ?? "horizontal") === "vertical"
196
+ ? box.clientHeight
197
+ : box.clientWidth;
198
+ persistGroupLayout(groupKey, layout, groupPx);
199
+ },
200
+ [groupKey, persistGroupLayout, props.orientation, ref],
201
+ );
202
+
203
+ const group = (
204
+ <div ref={ref} className="h-full min-h-0 min-w-0">
205
+ {ready ? (
206
+ <LayoutNestingProvider nested>
207
+ <ResizablePanelGroup
208
+ id={props.id}
209
+ orientation={props.orientation ?? "horizontal"}
210
+ className="h-full min-h-0 min-w-0"
211
+ onLayoutChanged={onLayoutChanged}
212
+ >
213
+ {interleaveHandles(children)}
214
+ </ResizablePanelGroup>
215
+ </LayoutNestingProvider>
216
+ ) : null}
217
+ </div>
218
+ );
219
+
220
+ if (!nested) return group;
221
+
222
+ return (
223
+ <ResizablePanel
224
+ id={props.id}
225
+ collapsible
226
+ collapsedSize={0}
227
+ defaultSize={sizeProp(props.defaultSize)}
228
+ minSize={props.minSize ?? "20%"}
229
+ groupResizeBehavior={groupResizeBehaviorFor(props.defaultSize)}
230
+ >
231
+ {group}
232
+ </ResizablePanel>
233
+ );
234
+ }
235
+
236
+ function LayoutTabsGroup({
237
+ props,
238
+ children,
239
+ }: {
240
+ props: GroupProps;
241
+ children?: ReactNode;
242
+ }) {
243
+ const nested = useIsNestedLayout();
244
+ const { spec, isPanelVisible } = useRuntime();
245
+ const panelRef = usePanelRef();
246
+ const groupKey = findElementKey(spec, props.id);
247
+ const elements = elementsOf(spec);
248
+ const childKeys = elements[groupKey]?.children ?? [];
249
+ const visibleChildren = childKeys.filter((key) => isPanelVisible(key));
250
+ const empty = visibleChildren.length === 0;
251
+ const activeKey =
252
+ props.active && visibleChildren.includes(props.active)
253
+ ? props.active
254
+ : (visibleChildren[0] ?? null);
255
+ const size = tabsWrapperSize(childKeys, elements, empty);
256
+ const defaultSize = sizeProp(props.defaultSize) ?? size.defaultSize;
257
+
258
+ const wasEmpty = useRef(empty);
259
+
260
+ useLayoutEffect(() => {
261
+ if (empty) {
262
+ panelRef.current?.collapse();
263
+ } else if (wasEmpty.current) {
264
+ restoreCollapsedPanel(panelRef.current, defaultSize);
265
+ }
266
+ wasEmpty.current = empty;
267
+ }, [defaultSize, empty, panelRef]);
268
+
269
+ const chrome = (
270
+ <div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-card">
271
+ <PanelHeader
272
+ stripKey={groupKey}
273
+ childKeys={childKeys}
274
+ activeKey={activeKey}
275
+ dropKind="tabs"
276
+ />
277
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden has-[[data-slot=editor-toolbar]]:bg-background">
278
+ {empty ? null : children}
279
+ </div>
280
+ <PanelDockZones panelKey={groupKey} />
281
+ </div>
282
+ );
283
+
284
+ if (!nested) return chrome;
285
+
286
+ return (
287
+ <ResizablePanel
288
+ id={props.id}
289
+ collapsible
290
+ collapsedSize={0}
291
+ panelRef={panelRef}
292
+ defaultSize={defaultSize}
293
+ minSize={empty ? 0 : size.minSize}
294
+ maxSize={size.maxSize}
295
+ groupResizeBehavior={groupResizeBehaviorFor(defaultSize)}
296
+ >
297
+ {chrome}
298
+ </ResizablePanel>
299
+ );
300
+ }
301
+
302
+ function LayoutPanelGroup({
303
+ props,
304
+ children,
305
+ }: {
306
+ props: GroupProps;
307
+ children?: ReactNode;
308
+ }) {
309
+ if (props.layout === "tabs") {
310
+ return <LayoutTabsGroup props={props}>{children}</LayoutTabsGroup>;
311
+ }
312
+ return <LayoutSplitGroup props={props}>{children}</LayoutSplitGroup>;
313
+ }
314
+
315
+ export const { registry: layoutRegistry } = defineRegistry(catalog, {
316
+ components: {
317
+ PanelGroup: ({ props, children }) => (
318
+ <LayoutPanelGroup props={props} children={children} />
319
+ ),
320
+ Panel: ({ props, children }) => (
321
+ <LayoutPanel props={props}>{children}</LayoutPanel>
322
+ ),
323
+ Toolbar: ({ children }) => <LayoutToolbar>{children}</LayoutToolbar>,
324
+ ToolbarButton: ({ props, emit }) => (
325
+ <LayoutToolbarButton props={props} emit={emit} />
326
+ ),
327
+ ToolbarSeparator: () => <LayoutToolbarSeparator />,
328
+ ToolbarSpacer: () => <LayoutToolbarSpacer />,
329
+ },
330
+ actions: {
331
+ executeCommand: async () => {},
332
+ },
333
+ });
@@ -0,0 +1,26 @@
1
+ export type CollapsibleHandle = {
2
+ expand: () => void;
3
+ isCollapsed: () => boolean;
4
+ getSize: () => { inPixels: number };
5
+ resize: (size: number | string) => void;
6
+ };
7
+
8
+ export function panelRestoreSize(props: {
9
+ defaultSize?: number | string | null;
10
+ minSize?: number | string | null;
11
+ }): number | string | undefined {
12
+ return props.defaultSize ?? props.minSize ?? undefined;
13
+ }
14
+
15
+ /** Expand a collapsed panel; if the library forgot the last size, apply `size`. */
16
+ export function restoreCollapsedPanel(
17
+ handle: CollapsibleHandle | null | undefined,
18
+ size?: number | string,
19
+ ) {
20
+ if (!handle) return;
21
+ handle.expand();
22
+ if (size == null) return;
23
+ if (handle.isCollapsed() || handle.getSize().inPixels === 0) {
24
+ handle.resize(size);
25
+ }
26
+ }
@@ -0,0 +1,189 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ type RefObject,
10
+ } from "react";
11
+ import type { Spec } from "@json-render/core";
12
+ import { hidePanel as hidePanelInSpec, isSpecVisible, showPanel } from "./hide";
13
+ import {
14
+ applyGroupLayout,
15
+ findParentKey,
16
+ listLeafPanels,
17
+ setActiveChild as setActiveChildInSpec,
18
+ } from "./mutate";
19
+ import type { LayoutElement, PanelSlot, PanelToggle, TabInsertPreview } from "./types";
20
+
21
+ type SetSpec = (updater: Spec | ((prev: Spec) => Spec)) => void;
22
+
23
+ type RuntimeContextValue = {
24
+ spec: Spec;
25
+ getSlot: (contentId: string) => PanelSlot | undefined;
26
+ isDragging: boolean;
27
+ setDragging: (dragging: boolean) => void;
28
+ hidePanel: (panelKey: string) => void;
29
+ isPanelVisible: (panelKey: string) => boolean;
30
+ setActiveChild: (panelKeyOrGroupKey: string) => void;
31
+ dragPanelKey: string | null;
32
+ setDragPanelKey: (panelKey: string | null) => void;
33
+ dragWidth: number;
34
+ setDragWidth: (width: number) => void;
35
+ insertPreview: TabInsertPreview | null;
36
+ setInsertPreview: (preview: TabInsertPreview | null) => void;
37
+ insertPreviewRef: RefObject<TabInsertPreview | null>;
38
+ persistGroupLayout: (
39
+ groupKey: string,
40
+ layout: Record<string, number>,
41
+ groupSizePx: number,
42
+ ) => void;
43
+ };
44
+
45
+ type TogglesContextValue = {
46
+ panelToggles: PanelToggle[];
47
+ };
48
+
49
+ const RuntimeContext = createContext<RuntimeContextValue | null>(null);
50
+ const TogglesContext = createContext<TogglesContextValue>({
51
+ panelToggles: [],
52
+ });
53
+
54
+ function elementsOf(spec: Spec): Record<string, LayoutElement> {
55
+ return spec.elements as Record<string, LayoutElement>;
56
+ }
57
+
58
+ export function SlotsProvider({
59
+ spec,
60
+ slots,
61
+ setSpec,
62
+ children,
63
+ }: {
64
+ spec: Spec;
65
+ slots: Map<string, PanelSlot>;
66
+ setSpec: SetSpec;
67
+ children: ReactNode;
68
+ }) {
69
+ const [isDragging, setDragging] = useState(false);
70
+ const [dragPanelKey, setDragPanelKey] = useState<string | null>(null);
71
+ const [dragWidth, setDragWidth] = useState(0);
72
+ const [insertPreview, setInsertPreview] = useState<TabInsertPreview | null>(
73
+ null,
74
+ );
75
+ const insertPreviewRef = useRef<TabInsertPreview | null>(null);
76
+ insertPreviewRef.current = insertPreview;
77
+
78
+ const setActiveChild = useCallback(
79
+ (panelKeyOrGroupKey: string) => {
80
+ setSpec((prev) => {
81
+ const elements = elementsOf(prev);
82
+ if (elements[panelKeyOrGroupKey]?.type !== "Panel") return prev;
83
+ const parentKey = findParentKey(elements, panelKeyOrGroupKey);
84
+ if (!parentKey) return prev;
85
+ return setActiveChildInSpec(prev, parentKey, panelKeyOrGroupKey);
86
+ });
87
+ },
88
+ [setSpec],
89
+ );
90
+
91
+ const persistGroupLayout = useCallback(
92
+ (
93
+ groupKey: string,
94
+ layout: Record<string, number>,
95
+ groupSizePx: number,
96
+ ) => {
97
+ setSpec((prev) => applyGroupLayout(prev, groupKey, layout, groupSizePx));
98
+ },
99
+ [setSpec],
100
+ );
101
+
102
+ const hidePanel = useCallback(
103
+ (panelKey: string) => {
104
+ setSpec((prev) => hidePanelInSpec(prev, panelKey));
105
+ },
106
+ [setSpec],
107
+ );
108
+
109
+ const isPanelVisible = useCallback(
110
+ (panelKey: string) => isSpecVisible(spec, panelKey),
111
+ [spec],
112
+ );
113
+
114
+ const getSlot = useCallback((contentId: string) => slots.get(contentId), [slots]);
115
+
116
+ const panelToggles = useMemo(() => {
117
+ return listLeafPanels(spec).flatMap((panel) => {
118
+ const slot = slots.get(panel.contentId);
119
+ if (!slot) return [];
120
+ return [
121
+ {
122
+ id: panel.key,
123
+ label: slot.title || panel.contentId,
124
+ checked: isSpecVisible(spec, panel.key),
125
+ onCheckedChange: (visible: boolean) => {
126
+ if (visible) {
127
+ setSpec((prev) => showPanel(prev, panel.key));
128
+ return;
129
+ }
130
+ hidePanel(panel.key);
131
+ },
132
+ } satisfies PanelToggle,
133
+ ];
134
+ });
135
+ }, [hidePanel, setSpec, slots, spec]);
136
+
137
+ const runtime = useMemo<RuntimeContextValue>(
138
+ () => ({
139
+ spec,
140
+ getSlot,
141
+ isDragging,
142
+ setDragging,
143
+ hidePanel,
144
+ isPanelVisible,
145
+ setActiveChild,
146
+ dragPanelKey,
147
+ setDragPanelKey,
148
+ dragWidth,
149
+ setDragWidth,
150
+ insertPreview,
151
+ setInsertPreview,
152
+ insertPreviewRef,
153
+ persistGroupLayout,
154
+ }),
155
+ [
156
+ spec,
157
+ getSlot,
158
+ isDragging,
159
+ hidePanel,
160
+ isPanelVisible,
161
+ setActiveChild,
162
+ dragPanelKey,
163
+ dragWidth,
164
+ insertPreview,
165
+ persistGroupLayout,
166
+ ],
167
+ );
168
+
169
+ const toggles = useMemo(() => ({ panelToggles }), [panelToggles]);
170
+
171
+ return (
172
+ <RuntimeContext.Provider value={runtime}>
173
+ <TogglesContext.Provider value={toggles}>{children}</TogglesContext.Provider>
174
+ </RuntimeContext.Provider>
175
+ );
176
+ }
177
+
178
+ export function useRuntime() {
179
+ const ctx = useContext(RuntimeContext);
180
+ if (!ctx) {
181
+ throw new Error("useRuntime must be used within EditorLayout");
182
+ }
183
+ return ctx;
184
+ }
185
+
186
+ /** Panel toggles for app menus — safe outside provider (returns []). */
187
+ export function usePanelToggles(): PanelToggle[] {
188
+ return useContext(TogglesContext).panelToggles;
189
+ }
@@ -0,0 +1,60 @@
1
+ import { type ReactElement, type ReactNode } from "react";
2
+ import { useDraggable } from "@dnd-kit/react";
3
+ import { cn } from "@moloodev/ui";
4
+ import { MenuHost } from "./panel-menu";
5
+ import { useRuntime } from "./slots-context";
6
+
7
+ export function TabButton({
8
+ panelKey,
9
+ active,
10
+ label,
11
+ menu,
12
+ }: {
13
+ panelKey: string;
14
+ active: boolean;
15
+ label: ReactNode;
16
+ menu: ReactNode | null;
17
+ }) {
18
+ const { setActiveChild, dragPanelKey } = useRuntime();
19
+ const { ref, isDragging } = useDraggable({
20
+ id: panelKey,
21
+ data: { panelKey },
22
+ });
23
+
24
+ const hidden = isDragging || dragPanelKey === panelKey;
25
+
26
+ const button = (
27
+ <button
28
+ ref={ref}
29
+ type="button"
30
+ data-panel-key={panelKey}
31
+ aria-pressed={active}
32
+ onClick={() => {
33
+ if (!isDragging) setActiveChild(panelKey);
34
+ }}
35
+ className={cn(
36
+ "relative flex h-full shrink-0 items-center gap-1.5 px-2.5",
37
+ "cursor-grab select-none active:cursor-grabbing",
38
+ "transition-[opacity,transform] duration-150 ease-out",
39
+ hidden && "pointer-events-none w-0 overflow-hidden px-0 opacity-0",
40
+ !hidden && (active ? "text-foreground" : "text-muted-foreground hover:text-foreground"),
41
+ )}
42
+ >
43
+ {label}
44
+ {active && !hidden ? (
45
+ <span
46
+ aria-hidden
47
+ className="absolute inset-x-2 bottom-0 h-0.5 bg-accent"
48
+ />
49
+ ) : null}
50
+ </button>
51
+ );
52
+
53
+ if (!menu) return button;
54
+
55
+ return (
56
+ <MenuHost panelKey={panelKey} trigger={button as ReactElement}>
57
+ {menu}
58
+ </MenuHost>
59
+ );
60
+ }
@@ -0,0 +1,22 @@
1
+ const EDGE = 1;
2
+
3
+ /** Whether the toolbar scroller can page left / right. */
4
+ export function toolbarScrollEdges(
5
+ scrollLeft: number,
6
+ clientWidth: number,
7
+ scrollWidth: number,
8
+ ): { canLeft: boolean; canRight: boolean } {
9
+ const max = scrollWidth - clientWidth;
10
+ if (max <= EDGE) {
11
+ return { canLeft: false, canRight: false };
12
+ }
13
+ return {
14
+ canLeft: scrollLeft > EDGE,
15
+ canRight: scrollLeft < max - EDGE,
16
+ };
17
+ }
18
+
19
+ /** Distance for one toolbar "page", with overlap on the previous page. */
20
+ export function toolbarPageDelta(clientWidth: number): number {
21
+ return Math.max(Math.round(clientWidth * 0.75), 48);
22
+ }