@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.
- 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,60 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { Spec } from "./types";
|
|
3
|
+
import { validateLayoutSpec } from "./validate-layout-spec";
|
|
4
|
+
|
|
5
|
+
export type LayoutJson = {
|
|
6
|
+
root: string;
|
|
7
|
+
elements: Record<string, {
|
|
8
|
+
type: string;
|
|
9
|
+
props: { readonly contentId?: string; readonly [key: string]: unknown };
|
|
10
|
+
children?: readonly string[];
|
|
11
|
+
}>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PanelIdOf<S extends LayoutJson> = {
|
|
15
|
+
[K in keyof S["elements"]]: S["elements"][K] extends {
|
|
16
|
+
type: "Panel";
|
|
17
|
+
props: { contentId: infer Id extends string };
|
|
18
|
+
} ? Id : never;
|
|
19
|
+
}[keyof S["elements"]];
|
|
20
|
+
|
|
21
|
+
type NoExtra<Id extends string, T> = {
|
|
22
|
+
[K in keyof T]: K extends Id ? T[K] : never;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type HeaderCommand = { commandId: string; icon: string };
|
|
26
|
+
const editorLayoutBrand: unique symbol = Symbol("editorLayout");
|
|
27
|
+
|
|
28
|
+
export type EditorLayoutBinding<S extends LayoutJson> = {
|
|
29
|
+
readonly [editorLayoutBrand]: S;
|
|
30
|
+
readonly spec: S;
|
|
31
|
+
readonly validated: Spec;
|
|
32
|
+
readonly panels: Record<PanelIdOf<S>, ComponentType>;
|
|
33
|
+
readonly titles: Record<PanelIdOf<S>, string>;
|
|
34
|
+
readonly panelHeaders: Partial<Record<PanelIdOf<S>, HeaderCommand>>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function defineLayoutSpec<const S extends LayoutJson>(spec: S): S {
|
|
38
|
+
validateLayoutSpec(spec);
|
|
39
|
+
return spec;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function defineEditorLayout<
|
|
43
|
+
const S extends LayoutJson,
|
|
44
|
+
const Panels extends Record<PanelIdOf<S>, ComponentType>,
|
|
45
|
+
const Titles extends Record<PanelIdOf<S>, string>,
|
|
46
|
+
const Headers extends Partial<Record<PanelIdOf<S>, HeaderCommand>>,
|
|
47
|
+
>(spec: S, input: {
|
|
48
|
+
panels: Panels & NoExtra<PanelIdOf<S>, Panels>;
|
|
49
|
+
titles: Titles & NoExtra<PanelIdOf<S>, Titles>;
|
|
50
|
+
panelHeaders?: Headers & NoExtra<PanelIdOf<S>, Headers>;
|
|
51
|
+
}): EditorLayoutBinding<S> {
|
|
52
|
+
return {
|
|
53
|
+
[editorLayoutBrand]: spec,
|
|
54
|
+
spec,
|
|
55
|
+
validated: validateLayoutSpec(spec),
|
|
56
|
+
panels: input.panels,
|
|
57
|
+
titles: input.titles,
|
|
58
|
+
panelHeaders: input.panelHeaders ?? {},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineLayoutSpec, defineEditorLayout } from "./define-editor-layout";
|
|
2
|
+
|
|
3
|
+
const spec = defineLayoutSpec({
|
|
4
|
+
root: "root",
|
|
5
|
+
elements: {
|
|
6
|
+
root: { type: "PanelGroup", props: { id: "root" }, children: ["a", "b"] },
|
|
7
|
+
a: { type: "Panel", props: { contentId: "a" }, children: [] },
|
|
8
|
+
b: { type: "Panel", props: { contentId: "b" }, children: [] },
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const Body = () => null;
|
|
13
|
+
|
|
14
|
+
defineEditorLayout(spec, {
|
|
15
|
+
panels: { a: Body, b: Body },
|
|
16
|
+
titles: { a: "A", b: "B" },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
defineEditorLayout(spec, {
|
|
20
|
+
// @ts-expect-error missing panel b
|
|
21
|
+
panels: { a: Body },
|
|
22
|
+
titles: { a: "A", b: "B" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
defineEditorLayout(spec, {
|
|
26
|
+
panels: {
|
|
27
|
+
a: Body,
|
|
28
|
+
b: Body,
|
|
29
|
+
// @ts-expect-error extra panel c
|
|
30
|
+
c: Body,
|
|
31
|
+
},
|
|
32
|
+
titles: { a: "A", b: "B" },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
defineEditorLayout(spec, {
|
|
36
|
+
panels: { a: Body, b: Body },
|
|
37
|
+
titles: {
|
|
38
|
+
a: "A",
|
|
39
|
+
b: "B",
|
|
40
|
+
// @ts-expect-error extra title c
|
|
41
|
+
c: "C",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const binding = defineEditorLayout(spec, { panels: { a: Body, b: Body }, titles: { a: "A", b: "B" } });
|
|
46
|
+
|
|
47
|
+
defineEditorLayout(spec, {
|
|
48
|
+
panels: { a: Body, b: Body }, titles: { a: "A", b: "B" },
|
|
49
|
+
panelHeaders: {
|
|
50
|
+
// @ts-expect-error extra header c
|
|
51
|
+
c: { commandId: "add", icon: "plus" },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
import { EditorLayout } from "./editor-layout";
|
|
56
|
+
// @ts-expect-error panel children are not a public mount
|
|
57
|
+
EditorLayout({ binding, children: null });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { createUndo, observe, type Doc } from "../crdt";
|
|
3
|
+
import { useCommands } from "../commands";
|
|
4
|
+
|
|
5
|
+
export function DocUndoBridge({ doc }: { doc: Doc | null }) {
|
|
6
|
+
const { activeScopeId, replaceUndoStack } = useCommands();
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!doc || !activeScopeId || !replaceUndoStack) return;
|
|
10
|
+
const inner = createUndo(doc);
|
|
11
|
+
replaceUndoStack(activeScopeId, inner);
|
|
12
|
+
const unsub = observe(doc, () => {
|
|
13
|
+
replaceUndoStack(activeScopeId, inner);
|
|
14
|
+
});
|
|
15
|
+
return () => {
|
|
16
|
+
unsub();
|
|
17
|
+
replaceUndoStack(activeScopeId, null);
|
|
18
|
+
};
|
|
19
|
+
}, [activeScopeId, doc, replaceUndoStack]);
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useDroppable } from "@dnd-kit/react";
|
|
2
|
+
import { cn } from "@moloodev/ui";
|
|
3
|
+
import { dockDropId } from "./drop";
|
|
4
|
+
import type { DockEdge } from "./types";
|
|
5
|
+
import { useRuntime } from "./slots-context";
|
|
6
|
+
|
|
7
|
+
const EDGES: DockEdge[] = ["north", "south", "east", "west"];
|
|
8
|
+
|
|
9
|
+
/** Thin outer-edge hit. Body interior is not a target. */
|
|
10
|
+
const edgeClass: Record<DockEdge, string> = {
|
|
11
|
+
north: "inset-x-0 top-0 h-3",
|
|
12
|
+
south: "inset-x-0 bottom-0 h-3",
|
|
13
|
+
east: "inset-y-0 right-0 w-3",
|
|
14
|
+
west: "inset-y-0 left-0 w-3",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const activeClass: Record<DockEdge, string> = {
|
|
18
|
+
north: "border-t-2 border-primary bg-primary/25",
|
|
19
|
+
south: "border-b-2 border-primary bg-primary/25",
|
|
20
|
+
east: "border-r-2 border-primary bg-primary/25",
|
|
21
|
+
west: "border-l-2 border-primary bg-primary/25",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function DockZone({
|
|
25
|
+
panelKey,
|
|
26
|
+
edge,
|
|
27
|
+
}: {
|
|
28
|
+
panelKey: string;
|
|
29
|
+
edge: DockEdge;
|
|
30
|
+
}) {
|
|
31
|
+
const { ref, isDropTarget } = useDroppable({
|
|
32
|
+
id: dockDropId(panelKey, edge),
|
|
33
|
+
data: { kind: "dock", panelKey, edge },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={cn(
|
|
40
|
+
"pointer-events-auto absolute z-30",
|
|
41
|
+
edgeClass[edge],
|
|
42
|
+
isDropTarget && activeClass[edge],
|
|
43
|
+
)}
|
|
44
|
+
aria-hidden
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Outer-edge drop targets shown while a panel is being dragged. */
|
|
50
|
+
export function PanelDockZones({ panelKey }: { panelKey: string }) {
|
|
51
|
+
const { isDragging } = useRuntime();
|
|
52
|
+
if (!isDragging) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="pointer-events-none absolute inset-0 z-30">
|
|
56
|
+
{EDGES.map((edge) => (
|
|
57
|
+
<DockZone key={edge} panelKey={panelKey} edge={edge} />
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DockEdge } from "./types";
|
|
2
|
+
|
|
3
|
+
type DropTarget =
|
|
4
|
+
| { kind: "dock"; targetKey: string; edge: DockEdge }
|
|
5
|
+
| { kind: "tabs"; targetKey: string }
|
|
6
|
+
| { kind: "stack"; targetKey: string };
|
|
7
|
+
|
|
8
|
+
export function dockDropId(panelKey: string, edge: DockEdge): string {
|
|
9
|
+
return `${panelKey}:${edge}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function tabsDropId(groupKey: string): string {
|
|
13
|
+
return `${groupKey}:tabs`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stackDropId(panelKey: string): string {
|
|
17
|
+
return `${panelKey}:stack`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseDrop(
|
|
21
|
+
id: string | number | undefined,
|
|
22
|
+
): DropTarget | null {
|
|
23
|
+
if (typeof id !== "string") return null;
|
|
24
|
+
if (id.endsWith(":stack")) {
|
|
25
|
+
const targetKey = id.slice(0, -":stack".length);
|
|
26
|
+
return targetKey ? { kind: "stack", targetKey } : null;
|
|
27
|
+
}
|
|
28
|
+
if (id.endsWith(":tabs")) {
|
|
29
|
+
const targetKey = id.slice(0, -":tabs".length);
|
|
30
|
+
return targetKey ? { kind: "tabs", targetKey } : null;
|
|
31
|
+
}
|
|
32
|
+
const match = /^(.*):(north|south|east|west)$/.exec(id);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
return {
|
|
35
|
+
kind: "dock",
|
|
36
|
+
targetKey: match[1]!,
|
|
37
|
+
edge: match[2] as DockEdge,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ComponentType,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
DragDropProvider,
|
|
11
|
+
DragOverlay,
|
|
12
|
+
type DragEndEvent,
|
|
13
|
+
type DragOverEvent,
|
|
14
|
+
type DragStartEvent,
|
|
15
|
+
} from "@dnd-kit/react";
|
|
16
|
+
import {
|
|
17
|
+
ActionProvider,
|
|
18
|
+
Renderer,
|
|
19
|
+
StateProvider,
|
|
20
|
+
VisibilityProvider,
|
|
21
|
+
} from "@json-render/react";
|
|
22
|
+
import type { Spec } from "@json-render/core";
|
|
23
|
+
import { bindToolbarSpec } from "./bind-toolbar";
|
|
24
|
+
import { useLayoutBinding } from "./layout-binding";
|
|
25
|
+
import { LayoutNestingProvider } from "./nesting-context";
|
|
26
|
+
import { applyTabsDrop, dockPanel, listLeafPanels, stackPanel } from "./mutate";
|
|
27
|
+
import { parseDrop } from "./drop";
|
|
28
|
+
import { Title } from "./panel-title";
|
|
29
|
+
import { PanelHideMenu } from "./panel-hide-menu";
|
|
30
|
+
import { PanelHeaderAction } from "./panel-header-action";
|
|
31
|
+
import type { EditorLayoutBinding, LayoutJson } from "./define-editor-layout";
|
|
32
|
+
import type { PanelSlot } from "./types";
|
|
33
|
+
import { layoutRegistry } from "./registry";
|
|
34
|
+
import { SlotsProvider, useRuntime } from "./slots-context";
|
|
35
|
+
import { validateLayoutSpec } from "./validate-layout-spec";
|
|
36
|
+
|
|
37
|
+
type EditorLayoutProps = {
|
|
38
|
+
initialSpec?: Spec;
|
|
39
|
+
binding?: EditorLayoutBinding<LayoutJson> & {
|
|
40
|
+
panels: Record<string, ComponentType>;
|
|
41
|
+
titles: Record<string, string>;
|
|
42
|
+
panelHeaders: Partial<Record<string, { commandId: string; icon: string }>>;
|
|
43
|
+
};
|
|
44
|
+
/** When set, Spec is loaded/saved in localStorage under this key. */
|
|
45
|
+
storageKey?: string;
|
|
46
|
+
onSpecChange?: (spec: Spec) => void;
|
|
47
|
+
onDragActiveChange?: (active: boolean) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const emptyHandlers = {};
|
|
51
|
+
const emptyState = {};
|
|
52
|
+
|
|
53
|
+
export function readStoredSpec(storageKey: string, fallback: Spec): Spec {
|
|
54
|
+
if (typeof window === "undefined") return fallback;
|
|
55
|
+
try {
|
|
56
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
57
|
+
if (!raw) return fallback;
|
|
58
|
+
const parsed: unknown = JSON.parse(raw);
|
|
59
|
+
const stored = validateLayoutSpec(parsed);
|
|
60
|
+
const storedIds = new Set(listLeafPanels(stored).map((panel) => panel.id));
|
|
61
|
+
const complete = listLeafPanels(fallback).every((panel) =>
|
|
62
|
+
storedIds.has(panel.id),
|
|
63
|
+
);
|
|
64
|
+
return complete ? stored : fallback;
|
|
65
|
+
} catch {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeStoredSpec(storageKey: string, spec: Spec) {
|
|
71
|
+
try {
|
|
72
|
+
window.localStorage.setItem(storageKey, JSON.stringify(spec));
|
|
73
|
+
} catch {
|
|
74
|
+
// Quota / private mode — ignore.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function LayoutRenderer({ spec }: { spec: Spec }) {
|
|
79
|
+
const binding = useLayoutBinding();
|
|
80
|
+
const bound = useMemo(() => bindToolbarSpec(spec), [spec]);
|
|
81
|
+
return (
|
|
82
|
+
<LayoutNestingProvider nested={false}>
|
|
83
|
+
{/* Avoid JSONUIProvider: ValidationProvider + ActionProvider loops here. */}
|
|
84
|
+
<StateProvider
|
|
85
|
+
store={binding.store}
|
|
86
|
+
initialState={binding.store ? undefined : emptyState}
|
|
87
|
+
>
|
|
88
|
+
<VisibilityProvider>
|
|
89
|
+
<ActionProvider handlers={binding.handlers ?? emptyHandlers}>
|
|
90
|
+
<Renderer spec={bound} registry={layoutRegistry} />
|
|
91
|
+
</ActionProvider>
|
|
92
|
+
</VisibilityProvider>
|
|
93
|
+
</StateProvider>
|
|
94
|
+
</LayoutNestingProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function EditorLayoutInner({
|
|
99
|
+
spec,
|
|
100
|
+
setSpec,
|
|
101
|
+
onDragActiveChange,
|
|
102
|
+
}: {
|
|
103
|
+
spec: Spec;
|
|
104
|
+
setSpec: (updater: Spec | ((prev: Spec) => Spec)) => void;
|
|
105
|
+
onDragActiveChange?: (active: boolean) => void;
|
|
106
|
+
}) {
|
|
107
|
+
const {
|
|
108
|
+
setDragging,
|
|
109
|
+
getSlot,
|
|
110
|
+
setDragPanelKey,
|
|
111
|
+
setDragWidth,
|
|
112
|
+
setInsertPreview,
|
|
113
|
+
insertPreviewRef,
|
|
114
|
+
} = useRuntime();
|
|
115
|
+
const { menuBar } = useLayoutBinding();
|
|
116
|
+
|
|
117
|
+
const onDragStart = useCallback(
|
|
118
|
+
(event: DragStartEvent) => {
|
|
119
|
+
setDragging(true);
|
|
120
|
+
onDragActiveChange?.(true);
|
|
121
|
+
const id = event.operation.source?.id;
|
|
122
|
+
if (typeof id !== "string") return;
|
|
123
|
+
setDragPanelKey(id);
|
|
124
|
+
const el = event.operation.source?.element;
|
|
125
|
+
if (el instanceof HTMLElement) {
|
|
126
|
+
setDragWidth(el.getBoundingClientRect().width);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[onDragActiveChange, setDragPanelKey, setDragWidth, setDragging],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const onDragOver = useCallback(
|
|
133
|
+
(event: DragOverEvent) => {
|
|
134
|
+
const targetId = event.operation.target?.id;
|
|
135
|
+
const drop = parseDrop(
|
|
136
|
+
typeof targetId === "string" || typeof targetId === "number"
|
|
137
|
+
? targetId
|
|
138
|
+
: undefined,
|
|
139
|
+
);
|
|
140
|
+
if (!drop || drop.kind === "dock") {
|
|
141
|
+
setInsertPreview(null);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
[setInsertPreview],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const onDragEnd = useCallback(
|
|
148
|
+
(event: DragEndEvent) => {
|
|
149
|
+
const sourceKey =
|
|
150
|
+
typeof event.operation.source?.id === "string"
|
|
151
|
+
? event.operation.source.id
|
|
152
|
+
: null;
|
|
153
|
+
const targetId = event.operation.target?.id;
|
|
154
|
+
const drop = parseDrop(
|
|
155
|
+
typeof targetId === "string" || typeof targetId === "number"
|
|
156
|
+
? targetId
|
|
157
|
+
: undefined,
|
|
158
|
+
);
|
|
159
|
+
const insert = insertPreviewRef.current;
|
|
160
|
+
|
|
161
|
+
setDragging(false);
|
|
162
|
+
onDragActiveChange?.(false);
|
|
163
|
+
setDragPanelKey(null);
|
|
164
|
+
setInsertPreview(null);
|
|
165
|
+
setDragWidth(0);
|
|
166
|
+
|
|
167
|
+
if (event.canceled || !sourceKey || !drop) return;
|
|
168
|
+
|
|
169
|
+
const index = insert?.panelKey === drop.targetKey ? insert.index : 0;
|
|
170
|
+
|
|
171
|
+
if (drop.kind === "dock") {
|
|
172
|
+
setSpec((s) => dockPanel(s, sourceKey, drop.targetKey, drop.edge));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (drop.kind === "tabs") {
|
|
176
|
+
setSpec((s) => applyTabsDrop(s, sourceKey, drop.targetKey, index));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (drop.kind === "stack") {
|
|
180
|
+
setSpec((s) => stackPanel(s, sourceKey, drop.targetKey, index));
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[
|
|
184
|
+
onDragActiveChange,
|
|
185
|
+
insertPreviewRef,
|
|
186
|
+
setDragPanelKey,
|
|
187
|
+
setDragWidth,
|
|
188
|
+
setDragging,
|
|
189
|
+
setInsertPreview,
|
|
190
|
+
setSpec,
|
|
191
|
+
],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<DragDropProvider
|
|
196
|
+
onDragStart={onDragStart}
|
|
197
|
+
onDragOver={onDragOver}
|
|
198
|
+
onDragEnd={onDragEnd}
|
|
199
|
+
>
|
|
200
|
+
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-card">
|
|
201
|
+
{menuBar}
|
|
202
|
+
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
203
|
+
<LayoutRenderer spec={spec} />
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<DragOverlay dropAnimation={null}>
|
|
207
|
+
{(source) => {
|
|
208
|
+
const id = String(source?.id ?? "");
|
|
209
|
+
const leaf = listLeafPanels(spec).find((panel) => panel.key === id);
|
|
210
|
+
const slot = leaf ? getSlot(leaf.contentId) : undefined;
|
|
211
|
+
if (!slot) return null;
|
|
212
|
+
return (
|
|
213
|
+
<div className="flex h-10 items-center gap-1.5 rounded-md bg-card px-2.5 shadow-moloo ring-2 ring-primary">
|
|
214
|
+
{slot.dragNodes.length > 0 ? slot.dragNodes : slot.title}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}}
|
|
218
|
+
</DragOverlay>
|
|
219
|
+
</DragDropProvider>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Spec-driven resizable editor chrome with tabbed panels and edge docking. */
|
|
224
|
+
export function EditorLayout({
|
|
225
|
+
initialSpec: providedSpec,
|
|
226
|
+
binding,
|
|
227
|
+
storageKey,
|
|
228
|
+
onSpecChange,
|
|
229
|
+
onDragActiveChange,
|
|
230
|
+
}: EditorLayoutProps) {
|
|
231
|
+
const initialSpec = providedSpec ?? binding?.validated;
|
|
232
|
+
if (!initialSpec) throw new Error("EditorLayout requires a binding or initialSpec");
|
|
233
|
+
const [spec, setSpecState] = useState(() =>
|
|
234
|
+
storageKey ? readStoredSpec(storageKey, initialSpec) : initialSpec,
|
|
235
|
+
);
|
|
236
|
+
const loadedFrom = useRef({ storageKey, initialSpec });
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (
|
|
240
|
+
loadedFrom.current.storageKey === storageKey &&
|
|
241
|
+
loadedFrom.current.initialSpec === initialSpec
|
|
242
|
+
) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
loadedFrom.current = { storageKey, initialSpec };
|
|
246
|
+
if (!storageKey) {
|
|
247
|
+
setSpecState(initialSpec);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
setSpecState(readStoredSpec(storageKey, initialSpec));
|
|
251
|
+
}, [storageKey, initialSpec]);
|
|
252
|
+
|
|
253
|
+
const setSpec = useCallback(
|
|
254
|
+
(updater: Spec | ((prev: Spec) => Spec)) => {
|
|
255
|
+
setSpecState((prev) => {
|
|
256
|
+
const next = typeof updater === "function" ? updater(prev) : updater;
|
|
257
|
+
if (next === prev) return prev;
|
|
258
|
+
if (storageKey) writeStoredSpec(storageKey, next);
|
|
259
|
+
onSpecChange?.(next);
|
|
260
|
+
return next;
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
[onSpecChange, storageKey],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const slots = useMemo(() => new Map<string, PanelSlot>(
|
|
267
|
+
Object.entries<ComponentType>(binding?.panels ?? {}).map(([contentId, BodyComponent]) => {
|
|
268
|
+
const title = binding?.titles[contentId] ?? contentId;
|
|
269
|
+
const header = binding?.panelHeaders[contentId];
|
|
270
|
+
return [contentId, {
|
|
271
|
+
name: contentId,
|
|
272
|
+
title,
|
|
273
|
+
dragNodes: [<Title key="title">{title}</Title>],
|
|
274
|
+
menu: <PanelHideMenu />,
|
|
275
|
+
...(header ? { action: <PanelHeaderAction header={header} /> } : {}),
|
|
276
|
+
body: <BodyComponent />,
|
|
277
|
+
hasHeader: true,
|
|
278
|
+
}];
|
|
279
|
+
}),
|
|
280
|
+
), [binding]);
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<SlotsProvider spec={spec} slots={slots} setSpec={setSpec}>
|
|
284
|
+
<EditorLayoutInner spec={spec} setSpec={setSpec} onDragActiveChange={onDragActiveChange} />
|
|
285
|
+
</SlotsProvider>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Spinner } from "@moloodev/ui";
|
|
3
|
+
|
|
4
|
+
const SPINNER_DELAY_MS = 300;
|
|
5
|
+
|
|
6
|
+
export function EditorLoading() {
|
|
7
|
+
const [spinner, setSpinner] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const timer = setTimeout(() => setSpinner(true), SPINNER_DELAY_MS);
|
|
10
|
+
return () => clearTimeout(timer);
|
|
11
|
+
}, []);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
data-slot="editor-loading"
|
|
16
|
+
className="flex min-h-0 min-w-0 flex-1 items-center justify-center bg-card"
|
|
17
|
+
>
|
|
18
|
+
{spinner ? (
|
|
19
|
+
<Spinner aria-label="불러오는 중" className="text-muted-foreground" />
|
|
20
|
+
) : null}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
|
2
|
+
import { createStateStore } from "@json-render/core";
|
|
3
|
+
import { useCommands, useScope } from "../commands";
|
|
4
|
+
import {
|
|
5
|
+
LayoutBindingProvider,
|
|
6
|
+
type CommandIconComponent,
|
|
7
|
+
type LayoutActionHandler,
|
|
8
|
+
type LayoutBindingValue,
|
|
9
|
+
} from "./layout-binding";
|
|
10
|
+
import { commandLayoutState, type CommandPresentation } from "./chrome/command-layout-state";
|
|
11
|
+
import { usePanelToggles } from "./slots-context";
|
|
12
|
+
|
|
13
|
+
export function FrameLayoutBinding({ children, controller, icons, presentation }: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
icons: Record<string, CommandIconComponent>;
|
|
16
|
+
controller?: ReactNode;
|
|
17
|
+
presentation?: Readonly<Record<string, CommandPresentation>>;
|
|
18
|
+
}) {
|
|
19
|
+
const { commands, execute, getContext, keybindings } = useCommands();
|
|
20
|
+
const ctx = getContext();
|
|
21
|
+
const snapshot = useMemo(
|
|
22
|
+
() => commandLayoutState(commands, ctx, presentation),
|
|
23
|
+
[commands, ctx, presentation],
|
|
24
|
+
);
|
|
25
|
+
const store = useMemo(() => createStateStore(snapshot), []);
|
|
26
|
+
useLayoutEffect(() => {
|
|
27
|
+
if (store.get("/commands") !== snapshot.commands) {
|
|
28
|
+
store.set("/commands", snapshot.commands);
|
|
29
|
+
}
|
|
30
|
+
}, [store, snapshot.commands]);
|
|
31
|
+
const handlers = useMemo<Record<string, LayoutActionHandler>>(
|
|
32
|
+
() => ({
|
|
33
|
+
executeCommand: async (params) => {
|
|
34
|
+
const commandId =
|
|
35
|
+
params && typeof params.commandId === "string" ? params.commandId : "";
|
|
36
|
+
if (commandId) await execute(commandId);
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
[execute],
|
|
40
|
+
);
|
|
41
|
+
const keysByCommand = useMemo(
|
|
42
|
+
() => new Map(keybindings.map((binding) => [binding.commandId, binding.key])),
|
|
43
|
+
[keybindings],
|
|
44
|
+
);
|
|
45
|
+
const value = useMemo<LayoutBindingValue>(
|
|
46
|
+
() => ({
|
|
47
|
+
store,
|
|
48
|
+
handlers,
|
|
49
|
+
menuBar: controller,
|
|
50
|
+
resolveIcon: (icon) => icon ? icons[icon] ?? null : null,
|
|
51
|
+
shortcutFor: (commandId) => keysByCommand.get(commandId),
|
|
52
|
+
}),
|
|
53
|
+
[controller, handlers, icons, keysByCommand, store],
|
|
54
|
+
);
|
|
55
|
+
return <LayoutBindingProvider value={value}>{children}</LayoutBindingProvider>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function PanelToggleCommands({ prefix, onPresentation }: {
|
|
59
|
+
prefix: string;
|
|
60
|
+
onPresentation: Dispatch<SetStateAction<Readonly<Record<string, CommandPresentation>>>>;
|
|
61
|
+
}) {
|
|
62
|
+
const toggles = usePanelToggles();
|
|
63
|
+
const { registerHandler } = useScope();
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const commandId = (panelId: string) => `${prefix}.togglePanel.${panelId}`;
|
|
66
|
+
const stops = toggles.map((toggle) => registerHandler(commandId(toggle.id), () => toggle.onCheckedChange(!toggle.checked)));
|
|
67
|
+
onPresentation((previous) => {
|
|
68
|
+
const next = Object.fromEntries(toggles.map((toggle) => [commandId(toggle.id), { pressed: toggle.checked }]));
|
|
69
|
+
return Object.keys(previous).length === toggles.length && toggles.every((toggle) => previous[commandId(toggle.id)]?.pressed === toggle.checked) ? previous : next;
|
|
70
|
+
});
|
|
71
|
+
return () => { for (const stop of stops) stop(); };
|
|
72
|
+
}, [prefix, toggles, registerHandler, onPresentation]);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { EditorLayout } from "./editor-layout";
|
|
2
|
+
export { PanelSection, ReadOnlyScope, SectionCollapseProvider } from "./panel-section";
|
|
3
|
+
export { useCollapsedSections } from "./use-collapsed-sections";
|
|
4
|
+
export { PanelHideMenu } from "./panel-hide-menu";
|
|
5
|
+
export { LayoutBindingProvider } from "./layout-binding";
|
|
6
|
+
export type {
|
|
7
|
+
LayoutBindingValue,
|
|
8
|
+
LayoutActionHandler,
|
|
9
|
+
CommandIconComponent,
|
|
10
|
+
} from "./layout-binding";
|
|
11
|
+
export { usePanelToggles } from "./slots-context";
|
|
12
|
+
export { validateLayoutSpec } from "./validate-layout-spec";
|
|
13
|
+
export { commandLayoutState } from "./chrome/command-layout-state";
|
|
14
|
+
export type { CommandPresentation } from "./chrome/command-layout-state";
|
|
15
|
+
export { ContentErrorNotice } from "./content-error-notice";
|
|
16
|
+
export { DocUndoBridge } from "./doc-undo-bridge";
|
|
17
|
+
export { editorMenuContentClass, editorMenuItemClass } from "./chrome/menu-styles";
|
|
18
|
+
export { layoutValuesOf, applyLayoutValues } from "./layout-values";
|
|
19
|
+
export type { LayoutValues } from "./layout-values";
|
|
20
|
+
export { FrameLayoutBinding, PanelToggleCommands } from "./frame-layout-binding";
|
|
21
|
+
export type { DockEdge, PanelToggle, Spec } from "./types";
|
|
22
|
+
|
|
23
|
+
export { defineLayoutSpec, defineEditorLayout, type EditorLayoutBinding } from "./define-editor-layout";
|