@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,103 @@
|
|
|
1
|
+
import type { Spec } from "@json-render/core";
|
|
2
|
+
import {
|
|
3
|
+
findParentKey,
|
|
4
|
+
listLeafPanels,
|
|
5
|
+
setActiveChild,
|
|
6
|
+
} from "./mutate";
|
|
7
|
+
import type { LayoutElement } from "./types";
|
|
8
|
+
|
|
9
|
+
function elementsOf(spec: Spec): Record<string, LayoutElement> {
|
|
10
|
+
return spec.elements as Record<string, LayoutElement>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isTabsGroup(el: LayoutElement | undefined): boolean {
|
|
14
|
+
return el?.type === "PanelGroup" && el.props.layout === "tabs";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** json-render: omit / `true` is shown; `false` is collapsed. */
|
|
18
|
+
function isElementVisible(
|
|
19
|
+
el: LayoutElement | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
return el?.visible !== false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isSpecVisible(spec: Spec, key: string): boolean {
|
|
25
|
+
return isElementVisible(elementsOf(spec)[key]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function setElementVisible(
|
|
29
|
+
spec: Spec,
|
|
30
|
+
key: string,
|
|
31
|
+
visible: boolean,
|
|
32
|
+
): Spec {
|
|
33
|
+
const el = elementsOf(spec)[key];
|
|
34
|
+
if (!el) return spec;
|
|
35
|
+
if (isElementVisible(el) === visible) return spec;
|
|
36
|
+
|
|
37
|
+
const next = structuredClone(spec);
|
|
38
|
+
const dest = elementsOf(next)[key];
|
|
39
|
+
if (!dest) return spec;
|
|
40
|
+
if (visible) delete dest.visible;
|
|
41
|
+
else dest.visible = false;
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Visible neighbor after `hiddenKey`, or the previous one if none follow. */
|
|
46
|
+
export function nextChildAfterHide(
|
|
47
|
+
spec: Spec,
|
|
48
|
+
children: string[],
|
|
49
|
+
hiddenKey: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
const remaining = children.filter(
|
|
52
|
+
(key) => key !== hiddenKey && isSpecVisible(spec, key),
|
|
53
|
+
);
|
|
54
|
+
if (remaining.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
const index = children.indexOf(hiddenKey);
|
|
57
|
+
for (let i = index + 1; i < children.length; i += 1) {
|
|
58
|
+
const key = children[i];
|
|
59
|
+
if (key && remaining.includes(key)) return key;
|
|
60
|
+
}
|
|
61
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
62
|
+
const key = children[i];
|
|
63
|
+
if (key && remaining.includes(key)) return key;
|
|
64
|
+
}
|
|
65
|
+
return remaining[0] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Collapse a leaf (`visible: false`). Last tab also collapses its group. */
|
|
69
|
+
export function hidePanel(spec: Spec, panelKey: string): Spec {
|
|
70
|
+
const leaf = listLeafPanels(spec).find((panel) => panel.key === panelKey);
|
|
71
|
+
if (!leaf) return spec;
|
|
72
|
+
if (!isSpecVisible(spec, panelKey)) return spec;
|
|
73
|
+
|
|
74
|
+
let next = setElementVisible(spec, panelKey, false);
|
|
75
|
+
const parentKey = findParentKey(elementsOf(next), panelKey);
|
|
76
|
+
if (!parentKey) return next;
|
|
77
|
+
|
|
78
|
+
const parent = elementsOf(next)[parentKey];
|
|
79
|
+
if (!isTabsGroup(parent)) return next;
|
|
80
|
+
|
|
81
|
+
const neighbor = nextChildAfterHide(next, parent.children ?? [], panelKey);
|
|
82
|
+
if (!neighbor) return setElementVisible(next, parentKey, false);
|
|
83
|
+
|
|
84
|
+
const active =
|
|
85
|
+
typeof parent.props.active === "string" ? parent.props.active : undefined;
|
|
86
|
+
if (active === panelKey || !isSpecVisible(next, active ?? "")) {
|
|
87
|
+
next = setActiveChild(next, parentKey, neighbor);
|
|
88
|
+
}
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Show a leaf and, if it sits in a collapsed tabs group, the group too. */
|
|
93
|
+
export function showPanel(spec: Spec, panelKey: string): Spec {
|
|
94
|
+
if (!elementsOf(spec)[panelKey]) return spec;
|
|
95
|
+
|
|
96
|
+
let next = setElementVisible(spec, panelKey, true);
|
|
97
|
+
const parentKey = findParentKey(elementsOf(next), panelKey);
|
|
98
|
+
const parent = parentKey ? elementsOf(next)[parentKey] : undefined;
|
|
99
|
+
if (!parentKey || !isTabsGroup(parent)) return next;
|
|
100
|
+
|
|
101
|
+
next = setElementVisible(next, parentKey, true);
|
|
102
|
+
return setActiveChild(next, parentKey, panelKey);
|
|
103
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { readStoredSpec } from "./editor-layout";
|
|
2
|
+
export { EditorLoading } from "./editor-loading";
|
|
3
|
+
|
|
4
|
+
export { sectionIdsOf, validateLayoutSpec } from "./validate-layout-spec";
|
|
5
|
+
export { LayoutBindingProvider } from "./layout-binding";
|
|
6
|
+
export type { LayoutActionHandler, CommandIconComponent } from "./layout-binding";
|
|
7
|
+
export { createStateStore } from "@json-render/core";
|
|
8
|
+
export type { Spec } from "./types";
|
|
9
|
+
|
|
10
|
+
export { editorLayoutStorageKey } from "./layout-storage-key";
|
|
11
|
+
export { EditorMenuBar } from "./chrome/editor-menu";
|
|
12
|
+
export { commandLayoutState, type CommandLayoutEntry } from "./chrome/command-layout-state";
|
|
13
|
+
|
|
14
|
+
export { defineLayoutSpec, defineEditorLayout, type EditorLayoutBinding } from "./define-editor-layout";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { StateStore } from "@json-render/core";
|
|
3
|
+
|
|
4
|
+
export type CommandIconComponent = ComponentType<{
|
|
5
|
+
className?: string;
|
|
6
|
+
strokeWidth?: number | string;
|
|
7
|
+
}>;
|
|
8
|
+
|
|
9
|
+
export type LayoutActionHandler = (
|
|
10
|
+
params?: Record<string, unknown>,
|
|
11
|
+
) => unknown | Promise<unknown>;
|
|
12
|
+
|
|
13
|
+
export type LayoutBindingValue = {
|
|
14
|
+
store?: StateStore;
|
|
15
|
+
handlers?: Record<string, LayoutActionHandler>;
|
|
16
|
+
menuBar?: ReactNode;
|
|
17
|
+
resolveIcon?: (
|
|
18
|
+
icon: string | undefined,
|
|
19
|
+
commandId: string,
|
|
20
|
+
) => CommandIconComponent | null;
|
|
21
|
+
shortcutFor?: (commandId: string) => string | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LayoutBindingContext = createContext<LayoutBindingValue>({});
|
|
25
|
+
|
|
26
|
+
export function LayoutBindingProvider({
|
|
27
|
+
value,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
value: LayoutBindingValue;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<LayoutBindingContext.Provider value={value}>
|
|
35
|
+
{children}
|
|
36
|
+
</LayoutBindingContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useLayoutBinding(): LayoutBindingValue {
|
|
41
|
+
return useContext(LayoutBindingContext);
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Browser-local layout Spec key — separate from DB editor settings. */
|
|
2
|
+
export function editorLayoutStorageKey(editorId: string, layoutVersion?: number): string {
|
|
3
|
+
return layoutVersion === undefined
|
|
4
|
+
? `moloo.editor.layout.${editorId}`
|
|
5
|
+
: `${editorLayoutStorageKey(editorId)}.v${layoutVersion}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Spec } from "@json-render/core";
|
|
2
|
+
|
|
3
|
+
/** Structurally matches the frame protocol without importing editor-api. */
|
|
4
|
+
export type LayoutValues = {
|
|
5
|
+
panels: Record<string, { visible: boolean; size?: number | `${number}%` }>;
|
|
6
|
+
collapsed?: Record<string, boolean>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function layoutValuesOf(
|
|
10
|
+
spec: Spec,
|
|
11
|
+
initialSpec: Spec,
|
|
12
|
+
collapsed?: Readonly<Record<string, boolean>>,
|
|
13
|
+
): LayoutValues {
|
|
14
|
+
const panels: LayoutValues["panels"] = {};
|
|
15
|
+
for (const [key, initial] of Object.entries(initialSpec.elements)) {
|
|
16
|
+
if (initial.type !== "Panel" && initial.type !== "PanelGroup") continue;
|
|
17
|
+
const element = spec.elements[key];
|
|
18
|
+
if (!element) continue;
|
|
19
|
+
const value = element.props.defaultSize;
|
|
20
|
+
const size = typeof value === "number"
|
|
21
|
+
? Math.round(value)
|
|
22
|
+
: typeof value === "string" && value.endsWith("%")
|
|
23
|
+
? `${Math.trunc(Number.parseFloat(value) * 10_000) / 10_000}%` as const
|
|
24
|
+
: undefined;
|
|
25
|
+
panels[key] = {
|
|
26
|
+
visible: element.visible !== false,
|
|
27
|
+
...(typeof size === "number" ||
|
|
28
|
+
(typeof size === "string" && size.endsWith("%"))
|
|
29
|
+
? { size: size as number | `${number}%` }
|
|
30
|
+
: {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (collapsed === undefined || Object.keys(collapsed).length === 0) return { panels };
|
|
34
|
+
return { panels, collapsed: { ...collapsed } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function applyLayoutValues(initialSpec: Spec, values: LayoutValues): Spec {
|
|
38
|
+
const spec = structuredClone(initialSpec);
|
|
39
|
+
for (const [key, value] of Object.entries(values.panels)) {
|
|
40
|
+
const element = spec.elements[key];
|
|
41
|
+
if (!element || (element.type !== "Panel" && element.type !== "PanelGroup")) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
element.visible = value.visible;
|
|
45
|
+
if (value.size !== undefined) element.props.defaultSize = value.size;
|
|
46
|
+
}
|
|
47
|
+
return spec;
|
|
48
|
+
}
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import type { Spec } from "@json-render/core";
|
|
2
|
+
import {
|
|
3
|
+
groupSizeFromLeaves,
|
|
4
|
+
sizesEqual,
|
|
5
|
+
sizeUnit,
|
|
6
|
+
storedSizeFromLayout,
|
|
7
|
+
} from "./panel-size";
|
|
8
|
+
import type { DockEdge, LayoutElement } from "./types";
|
|
9
|
+
|
|
10
|
+
function cloneSpec(spec: Spec): Spec {
|
|
11
|
+
return structuredClone(spec);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function elementsOf(spec: Spec): Record<string, LayoutElement> {
|
|
15
|
+
return spec.elements as Record<string, LayoutElement>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function findParentKey(
|
|
19
|
+
elements: Record<string, LayoutElement>,
|
|
20
|
+
childKey: string,
|
|
21
|
+
): string | null {
|
|
22
|
+
for (const [key, el] of Object.entries(elements)) {
|
|
23
|
+
if (el.children?.includes(childKey)) return key;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isTabsGroup(el: LayoutElement | undefined): boolean {
|
|
29
|
+
return el?.type === "PanelGroup" && el.props.layout === "tabs";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function edgeOrientation(edge: DockEdge): "horizontal" | "vertical" {
|
|
33
|
+
return edge === "east" || edge === "west" ? "horizontal" : "vertical";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function placeBefore(edge: DockEdge): boolean {
|
|
37
|
+
return edge === "west" || edge === "north";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function newGroupKey(elements: Record<string, LayoutElement>): string {
|
|
41
|
+
let n = 0;
|
|
42
|
+
let key = `group-${n}`;
|
|
43
|
+
while (elements[key]) {
|
|
44
|
+
n += 1;
|
|
45
|
+
key = `group-${n}`;
|
|
46
|
+
}
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Replace parentKey with its sole child in the grandparent (or as root). */
|
|
51
|
+
function unwrapSingleton(spec: Spec, parentKey: string): void {
|
|
52
|
+
const elements = elementsOf(spec);
|
|
53
|
+
const parent = elements[parentKey];
|
|
54
|
+
if (!parent?.children || parent.children.length !== 1) return;
|
|
55
|
+
|
|
56
|
+
const sole = parent.children[0]!;
|
|
57
|
+
const grandKey = findParentKey(elements, parentKey);
|
|
58
|
+
|
|
59
|
+
if (grandKey) {
|
|
60
|
+
const grand = elements[grandKey]!;
|
|
61
|
+
grand.children = grand.children!.map((k) => (k === parentKey ? sole : k));
|
|
62
|
+
} else if (spec.root === parentKey) {
|
|
63
|
+
spec.root = sole;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
delete elements[parentKey];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function activateNeighbor(
|
|
70
|
+
children: string[],
|
|
71
|
+
removedIndex: number,
|
|
72
|
+
): string | undefined {
|
|
73
|
+
return children[Math.min(removedIndex, children.length - 1)] ?? children[0];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function soleChildOrEmpty(
|
|
77
|
+
elements: Record<string, LayoutElement>,
|
|
78
|
+
_removedKey: string,
|
|
79
|
+
): string {
|
|
80
|
+
const remaining = Object.keys(elements);
|
|
81
|
+
return remaining[0] ?? "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove `childKey` from its parent. Unwrap the parent if only one child remains.
|
|
86
|
+
* Returns the former parent key (may have been deleted by unwrap).
|
|
87
|
+
*/
|
|
88
|
+
function removeFromParent(spec: Spec, childKey: string): string | null {
|
|
89
|
+
const elements = elementsOf(spec);
|
|
90
|
+
const parentKey = findParentKey(elements, childKey);
|
|
91
|
+
if (!parentKey) return null;
|
|
92
|
+
|
|
93
|
+
const parent = elements[parentKey]!;
|
|
94
|
+
const removedIndex = (parent.children ?? []).indexOf(childKey);
|
|
95
|
+
parent.children = (parent.children ?? []).filter((k) => k !== childKey);
|
|
96
|
+
|
|
97
|
+
if (isTabsGroup(parent) && parent.props.active === childKey) {
|
|
98
|
+
parent.props.active = activateNeighbor(parent.children, removedIndex);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parent.children.length === 1) {
|
|
102
|
+
unwrapSingleton(spec, parentKey);
|
|
103
|
+
} else if (parent.children.length === 0) {
|
|
104
|
+
const grandKey = findParentKey(elements, parentKey);
|
|
105
|
+
delete elements[parentKey];
|
|
106
|
+
if (grandKey) {
|
|
107
|
+
const grand = elements[grandKey];
|
|
108
|
+
if (grand) {
|
|
109
|
+
grand.children = (grand.children ?? []).filter((k) => k !== parentKey);
|
|
110
|
+
if (grand.children.length === 1) unwrapSingleton(spec, grandKey);
|
|
111
|
+
}
|
|
112
|
+
} else if (spec.root === parentKey) {
|
|
113
|
+
spec.root = soleChildOrEmpty(elements, parentKey);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return parentKey;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replaceInParent(
|
|
121
|
+
spec: Spec,
|
|
122
|
+
targetKey: string,
|
|
123
|
+
replacementKey: string,
|
|
124
|
+
): void {
|
|
125
|
+
const elements = elementsOf(spec);
|
|
126
|
+
let parentKey: string | null = null;
|
|
127
|
+
for (const [key, el] of Object.entries(elements)) {
|
|
128
|
+
if (key === replacementKey) continue;
|
|
129
|
+
if (el.children?.includes(targetKey)) {
|
|
130
|
+
parentKey = key;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (parentKey) {
|
|
135
|
+
const parent = elements[parentKey]!;
|
|
136
|
+
parent.children = (parent.children ?? []).map((k) =>
|
|
137
|
+
k === targetKey ? replacementKey : k,
|
|
138
|
+
);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (spec.root === targetKey) spec.root = replacementKey;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function insertAt(children: string[], key: string, index: number): string[] {
|
|
145
|
+
const kids = [...children];
|
|
146
|
+
const at = Math.max(0, Math.min(index, kids.length));
|
|
147
|
+
kids.splice(at, 0, key);
|
|
148
|
+
return kids;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function findPanelKeyByContent(
|
|
152
|
+
spec: Spec,
|
|
153
|
+
contentId: string,
|
|
154
|
+
): string | null {
|
|
155
|
+
for (const leaf of listLeafPanels(spec)) {
|
|
156
|
+
if (leaf.contentId === contentId) return leaf.key;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** List leaf Panel element keys in tree order. */
|
|
162
|
+
export function listLeafPanels(spec: Spec): {
|
|
163
|
+
key: string;
|
|
164
|
+
contentId: string;
|
|
165
|
+
id: string;
|
|
166
|
+
}[] {
|
|
167
|
+
const elements = elementsOf(spec);
|
|
168
|
+
const out: {
|
|
169
|
+
key: string;
|
|
170
|
+
contentId: string;
|
|
171
|
+
id: string;
|
|
172
|
+
}[] = [];
|
|
173
|
+
|
|
174
|
+
function walk(key: string | null | undefined) {
|
|
175
|
+
if (!key) return;
|
|
176
|
+
const el = elements[key];
|
|
177
|
+
if (!el) return;
|
|
178
|
+
if (el.type === "Panel") {
|
|
179
|
+
out.push({
|
|
180
|
+
key,
|
|
181
|
+
contentId: String(el.props.contentId ?? ""),
|
|
182
|
+
id: String(el.props.id ?? key),
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const child of el.children ?? []) walk(child);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
walk(spec.root);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Pull `sourceKey` out of its tabs group and split that group's box
|
|
195
|
+
* so the remaining tabs stay together on the opposite edge.
|
|
196
|
+
*/
|
|
197
|
+
function splitTabsGroup(
|
|
198
|
+
spec: Spec,
|
|
199
|
+
sourceKey: string,
|
|
200
|
+
groupKey: string,
|
|
201
|
+
edge: DockEdge,
|
|
202
|
+
): Spec {
|
|
203
|
+
const elements = elementsOf(spec);
|
|
204
|
+
const group = elements[groupKey];
|
|
205
|
+
if (!group) return spec;
|
|
206
|
+
|
|
207
|
+
const remaining = (group.children ?? []).filter((key) => key !== sourceKey);
|
|
208
|
+
if (remaining.length === 0) return spec;
|
|
209
|
+
|
|
210
|
+
const orientation = edgeOrientation(edge);
|
|
211
|
+
const before = placeBefore(edge);
|
|
212
|
+
const wrapKey = newGroupKey(elements);
|
|
213
|
+
const siblingKey = remaining.length === 1 ? remaining[0]! : groupKey;
|
|
214
|
+
const ordered = before ? [sourceKey, siblingKey] : [siblingKey, sourceKey];
|
|
215
|
+
const sizeProps: Record<string, unknown> = {};
|
|
216
|
+
for (const key of ["defaultSize", "minSize", "maxSize"] as const) {
|
|
217
|
+
if (group.props[key] == null) continue;
|
|
218
|
+
sizeProps[key] = group.props[key];
|
|
219
|
+
if (remaining.length > 1) delete group.props[key];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (remaining.length > 1) {
|
|
223
|
+
group.children = remaining;
|
|
224
|
+
if (group.props.active === sourceKey) {
|
|
225
|
+
group.props.active = remaining[0];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
elements[wrapKey] = {
|
|
230
|
+
type: "PanelGroup",
|
|
231
|
+
props: { id: wrapKey, layout: "split", orientation, ...sizeProps },
|
|
232
|
+
children: ordered,
|
|
233
|
+
};
|
|
234
|
+
replaceInParent(spec, groupKey, wrapKey);
|
|
235
|
+
if (remaining.length === 1) delete elements[groupKey];
|
|
236
|
+
return spec;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Dock `sourceKey` onto `targetKey` at `edge` by mutating a cloned Spec.
|
|
241
|
+
* No-ops when source === target or either key is missing.
|
|
242
|
+
*/
|
|
243
|
+
export function dockPanel(
|
|
244
|
+
spec: Spec,
|
|
245
|
+
sourceKey: string,
|
|
246
|
+
targetKey: string,
|
|
247
|
+
edge: DockEdge,
|
|
248
|
+
): Spec {
|
|
249
|
+
if (sourceKey === targetKey) return spec;
|
|
250
|
+
|
|
251
|
+
const next = cloneSpec(spec);
|
|
252
|
+
const elements = elementsOf(next);
|
|
253
|
+
|
|
254
|
+
if (!elements[sourceKey] || !elements[targetKey]) return spec;
|
|
255
|
+
if (elements[sourceKey]!.type !== "Panel") return spec;
|
|
256
|
+
|
|
257
|
+
const target = elements[targetKey]!;
|
|
258
|
+
if (isTabsGroup(target) && (target.children ?? []).includes(sourceKey)) {
|
|
259
|
+
return splitTabsGroup(next, sourceKey, targetKey, edge);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
removeFromParent(next, sourceKey);
|
|
263
|
+
|
|
264
|
+
const els = elementsOf(next);
|
|
265
|
+
if (!els[targetKey]) return spec;
|
|
266
|
+
|
|
267
|
+
const targetParentKey = findParentKey(els, targetKey);
|
|
268
|
+
const orientation = edgeOrientation(edge);
|
|
269
|
+
const before = placeBefore(edge);
|
|
270
|
+
|
|
271
|
+
if (targetParentKey) {
|
|
272
|
+
const targetParent = els[targetParentKey]!;
|
|
273
|
+
const parentOrientation = targetParent.props.orientation as
|
|
274
|
+
| "horizontal"
|
|
275
|
+
| "vertical"
|
|
276
|
+
| undefined;
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
targetParent.type === "PanelGroup" &&
|
|
280
|
+
targetParent.props.layout !== "tabs" &&
|
|
281
|
+
parentOrientation === orientation
|
|
282
|
+
) {
|
|
283
|
+
const kids = [...(targetParent.children ?? [])];
|
|
284
|
+
const idx = kids.indexOf(targetKey);
|
|
285
|
+
if (idx === -1) return spec;
|
|
286
|
+
kids.splice(before ? idx : idx + 1, 0, sourceKey);
|
|
287
|
+
targetParent.children = kids;
|
|
288
|
+
return next;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const groupKey = newGroupKey(els);
|
|
293
|
+
const ordered = before ? [sourceKey, targetKey] : [targetKey, sourceKey];
|
|
294
|
+
|
|
295
|
+
els[groupKey] = {
|
|
296
|
+
type: "PanelGroup",
|
|
297
|
+
props: { id: groupKey, layout: "split", orientation },
|
|
298
|
+
children: ordered,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
replaceInParent(next, targetKey, groupKey);
|
|
302
|
+
return next;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function stackPanel(
|
|
306
|
+
spec: Spec,
|
|
307
|
+
sourceKey: string,
|
|
308
|
+
targetKey: string,
|
|
309
|
+
index: number,
|
|
310
|
+
): Spec {
|
|
311
|
+
if (sourceKey === targetKey) return spec;
|
|
312
|
+
|
|
313
|
+
const elements = elementsOf(spec);
|
|
314
|
+
const source = elements[sourceKey];
|
|
315
|
+
const target = elements[targetKey];
|
|
316
|
+
if (!source || !target || source.type !== "Panel") return spec;
|
|
317
|
+
|
|
318
|
+
const targetParentKey = findParentKey(elements, targetKey);
|
|
319
|
+
const tabsHostKey = isTabsGroup(target)
|
|
320
|
+
? targetKey
|
|
321
|
+
: target.type === "Panel" && isTabsGroup(elements[targetParentKey ?? ""])
|
|
322
|
+
? targetParentKey
|
|
323
|
+
: null;
|
|
324
|
+
|
|
325
|
+
if (tabsHostKey) {
|
|
326
|
+
const next = cloneSpec(spec);
|
|
327
|
+
removeFromParent(next, sourceKey);
|
|
328
|
+
const host = elementsOf(next)[tabsHostKey];
|
|
329
|
+
if (!host) return spec;
|
|
330
|
+
host.children = insertAt(host.children ?? [], sourceKey, index);
|
|
331
|
+
host.props.active = sourceKey;
|
|
332
|
+
return next;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (target.type !== "Panel") return spec;
|
|
336
|
+
|
|
337
|
+
const next = cloneSpec(spec);
|
|
338
|
+
removeFromParent(next, sourceKey);
|
|
339
|
+
const els = elementsOf(next);
|
|
340
|
+
if (!els[targetKey]) return spec;
|
|
341
|
+
|
|
342
|
+
const groupKey = newGroupKey(els);
|
|
343
|
+
els[groupKey] = {
|
|
344
|
+
type: "PanelGroup",
|
|
345
|
+
props: { id: groupKey, layout: "tabs", active: sourceKey },
|
|
346
|
+
children: insertAt([targetKey], sourceKey, index),
|
|
347
|
+
};
|
|
348
|
+
replaceInParent(next, targetKey, groupKey);
|
|
349
|
+
return next;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Tabs-strip drop: reorder if already a child; otherwise join the strip. */
|
|
353
|
+
export function applyTabsDrop(
|
|
354
|
+
spec: Spec,
|
|
355
|
+
sourceKey: string,
|
|
356
|
+
targetKey: string,
|
|
357
|
+
index: number,
|
|
358
|
+
): Spec {
|
|
359
|
+
const children = elementsOf(spec)[targetKey]?.children ?? [];
|
|
360
|
+
return children.includes(sourceKey)
|
|
361
|
+
? moveInTabs(spec, sourceKey, targetKey, index)
|
|
362
|
+
: stackPanel(spec, sourceKey, targetKey, index);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function moveInTabs(
|
|
366
|
+
spec: Spec,
|
|
367
|
+
sourceKey: string,
|
|
368
|
+
groupKey: string,
|
|
369
|
+
index: number,
|
|
370
|
+
): Spec {
|
|
371
|
+
const group = elementsOf(spec)[groupKey];
|
|
372
|
+
if (!group || !isTabsGroup(group)) return spec;
|
|
373
|
+
|
|
374
|
+
const children = group.children ?? [];
|
|
375
|
+
const from = children.indexOf(sourceKey);
|
|
376
|
+
if (from === -1) return spec;
|
|
377
|
+
|
|
378
|
+
let insertAtIndex = Math.max(0, Math.min(index, children.length));
|
|
379
|
+
if (from === insertAtIndex || from + 1 === insertAtIndex) return spec;
|
|
380
|
+
|
|
381
|
+
const next = cloneSpec(spec);
|
|
382
|
+
const dest = elementsOf(next)[groupKey];
|
|
383
|
+
if (!dest) return spec;
|
|
384
|
+
|
|
385
|
+
const tabs = [...(dest.children ?? [])];
|
|
386
|
+
const [taken] = tabs.splice(from, 1);
|
|
387
|
+
if (!taken) return spec;
|
|
388
|
+
if (from < insertAtIndex) insertAtIndex -= 1;
|
|
389
|
+
tabs.splice(insertAtIndex, 0, taken);
|
|
390
|
+
dest.children = tabs;
|
|
391
|
+
dest.props.active = sourceKey;
|
|
392
|
+
return next;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function setActiveChild(
|
|
396
|
+
spec: Spec,
|
|
397
|
+
groupKey: string,
|
|
398
|
+
childKey: string,
|
|
399
|
+
): Spec {
|
|
400
|
+
const group = elementsOf(spec)[groupKey];
|
|
401
|
+
if (!group || !isTabsGroup(group)) return spec;
|
|
402
|
+
if (!(group.children ?? []).includes(childKey)) return spec;
|
|
403
|
+
if (group.props.active === childKey) return spec;
|
|
404
|
+
|
|
405
|
+
const next = cloneSpec(spec);
|
|
406
|
+
const dest = elementsOf(next)[groupKey];
|
|
407
|
+
if (dest) dest.props.active = childKey;
|
|
408
|
+
return next;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function persistUnitFor(
|
|
412
|
+
child: LayoutElement,
|
|
413
|
+
elements: Record<string, LayoutElement>,
|
|
414
|
+
): "px" | "%" | null {
|
|
415
|
+
const own = sizeUnit(child.props.defaultSize as number | string | null);
|
|
416
|
+
if (own) return own;
|
|
417
|
+
if (child.type !== "PanelGroup") return null;
|
|
418
|
+
if (isTabsGroup(child)) {
|
|
419
|
+
const leaves = (child.children ?? [])
|
|
420
|
+
.map((key) => elements[key])
|
|
421
|
+
.filter((el): el is LayoutElement => el?.type === "Panel")
|
|
422
|
+
.map((el) => ({
|
|
423
|
+
defaultSize: el.props.defaultSize as number | string | null | undefined,
|
|
424
|
+
}));
|
|
425
|
+
return sizeUnit(groupSizeFromLeaves(leaves).defaultSize) ?? "%";
|
|
426
|
+
}
|
|
427
|
+
return "%";
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function markVisible(el: LayoutElement, visible: boolean) {
|
|
431
|
+
if (visible) delete el.visible;
|
|
432
|
+
else el.visible = false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Write a finished split-group layout back onto child `defaultSize`
|
|
437
|
+
* and element `visible`. Flex leaves (no defaultSize) stay flex.
|
|
438
|
+
* 0% marks the child collapsed (`visible: false`) and does not store size 0.
|
|
439
|
+
*/
|
|
440
|
+
export function applyGroupLayout(
|
|
441
|
+
spec: Spec,
|
|
442
|
+
groupKey: string,
|
|
443
|
+
layout: Record<string, number>,
|
|
444
|
+
groupSizePx: number,
|
|
445
|
+
): Spec {
|
|
446
|
+
if (groupSizePx <= 0) return spec;
|
|
447
|
+
|
|
448
|
+
const elements = elementsOf(spec);
|
|
449
|
+
const group = elements[groupKey];
|
|
450
|
+
if (!group || group.type !== "PanelGroup" || group.props.layout === "tabs") {
|
|
451
|
+
return spec;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let next: Spec | null = null;
|
|
455
|
+
for (const childKey of group.children ?? []) {
|
|
456
|
+
const child = (next ? elementsOf(next) : elements)[childKey];
|
|
457
|
+
if (!child) continue;
|
|
458
|
+
const panelId = String(child.props.id ?? childKey);
|
|
459
|
+
const percent = layout[panelId];
|
|
460
|
+
if (percent == null) continue;
|
|
461
|
+
|
|
462
|
+
if (percent <= 0) {
|
|
463
|
+
if (child.visible === false) continue;
|
|
464
|
+
if (!next) next = cloneSpec(spec);
|
|
465
|
+
markVisible(elementsOf(next)[childKey]!, false);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (child.visible === false) {
|
|
470
|
+
if (!next) next = cloneSpec(spec);
|
|
471
|
+
markVisible(elementsOf(next)[childKey]!, true);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const live = (next ? elementsOf(next) : elements)[childKey]!;
|
|
475
|
+
const unit = persistUnitFor(live, next ? elementsOf(next) : elements);
|
|
476
|
+
if (!unit) continue;
|
|
477
|
+
|
|
478
|
+
const stored = storedSizeFromLayout(percent, groupSizePx, unit);
|
|
479
|
+
const existing = live.props.defaultSize as number | string | null | undefined;
|
|
480
|
+
if (sizesEqual(existing, stored)) continue;
|
|
481
|
+
|
|
482
|
+
if (!next) next = cloneSpec(spec);
|
|
483
|
+
elementsOf(next)[childKey]!.props.defaultSize = stored;
|
|
484
|
+
}
|
|
485
|
+
return next ?? spec;
|
|
486
|
+
}
|