@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,20 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
const NestingContext = createContext(false);
|
|
4
|
+
|
|
5
|
+
/** True when rendering inside another PanelGroup (needs ResizablePanel wrapper). */
|
|
6
|
+
export function useIsNestedLayout(): boolean {
|
|
7
|
+
return useContext(NestingContext);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function LayoutNestingProvider({
|
|
11
|
+
nested,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
nested: boolean;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<NestingContext.Provider value={nested}>{children}</NestingContext.Provider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { buttonVariants } from "@moloodev/ui";
|
|
3
|
+
import { useLayoutBinding } from "./layout-binding";
|
|
4
|
+
|
|
5
|
+
const subscribe = () => () => {};
|
|
6
|
+
const emptySnapshot = () => undefined;
|
|
7
|
+
|
|
8
|
+
export function PanelHeaderAction({ header }: {
|
|
9
|
+
header: { commandId: string; icon: string };
|
|
10
|
+
}) {
|
|
11
|
+
const { store, handlers, resolveIcon } = useLayoutBinding();
|
|
12
|
+
const value: unknown = useSyncExternalStore(
|
|
13
|
+
store?.subscribe ?? subscribe,
|
|
14
|
+
store ? () => store.get(`/commands/${header.commandId}`) : emptySnapshot,
|
|
15
|
+
store ? () => store.get(`/commands/${header.commandId}`) : emptySnapshot,
|
|
16
|
+
);
|
|
17
|
+
const state = typeof value === "object" && value !== null ? value : {};
|
|
18
|
+
if ("visible" in state && state.visible === false) return null;
|
|
19
|
+
const label = "label" in state && typeof state.label === "string" ? state.label : header.commandId;
|
|
20
|
+
const pressed = "pressed" in state && state.pressed === true;
|
|
21
|
+
const disabled = "disabled" in state && state.disabled === true;
|
|
22
|
+
const Icon = resolveIcon?.(header.icon, header.commandId);
|
|
23
|
+
return <button type="button" data-slot="panel-header-action" aria-label={label} title={label}
|
|
24
|
+
aria-pressed={pressed ? true : undefined} disabled={disabled}
|
|
25
|
+
className={buttonVariants({ variant: pressed ? "secondary" : "ghost", size: "icon-sm" })}
|
|
26
|
+
onClick={() => { void handlers?.executeCommand?.({ commandId: header.commandId }); }}>
|
|
27
|
+
{Icon ? <Icon className="size-4" strokeWidth={1.75} /> : null}
|
|
28
|
+
</button>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { useDragDropMonitor, useDroppable } from "@dnd-kit/react";
|
|
3
|
+
import { cn } from "@moloodev/ui";
|
|
4
|
+
import { stackDropId, tabsDropId } from "./drop";
|
|
5
|
+
import { TabButton } from "./tab-button";
|
|
6
|
+
import { useRuntime } from "./slots-context";
|
|
7
|
+
import type { LayoutElement } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Soft slot so the insert index is readable while dragging. */
|
|
10
|
+
function TabInsertGap({ width }: { width: number }) {
|
|
11
|
+
return (
|
|
12
|
+
<span
|
|
13
|
+
aria-hidden
|
|
14
|
+
className="relative flex h-full shrink-0 items-center justify-center transition-[width] duration-150 ease-out"
|
|
15
|
+
style={{ width: Math.max(width, 12) }}
|
|
16
|
+
>
|
|
17
|
+
<span className="absolute inset-y-1 inset-x-0.5 rounded-sm bg-primary/20" />
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function insertIndexFromX(
|
|
23
|
+
strip: HTMLElement,
|
|
24
|
+
clientX: number,
|
|
25
|
+
draggingId: string | null,
|
|
26
|
+
): number {
|
|
27
|
+
const buttons = [
|
|
28
|
+
...strip.querySelectorAll<HTMLElement>("[data-panel-key]"),
|
|
29
|
+
].filter((el) => el.dataset.panelKey !== draggingId);
|
|
30
|
+
let index = 0;
|
|
31
|
+
for (const el of buttons) {
|
|
32
|
+
const rect = el.getBoundingClientRect();
|
|
33
|
+
if (clientX >= rect.left + rect.width / 2) index += 1;
|
|
34
|
+
}
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toMoveIndex(
|
|
39
|
+
keys: string[],
|
|
40
|
+
panelKey: string,
|
|
41
|
+
remainingIndex: number,
|
|
42
|
+
): number {
|
|
43
|
+
if (!keys.includes(panelKey)) return remainingIndex;
|
|
44
|
+
let seen = 0;
|
|
45
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
46
|
+
if (keys[i] === panelKey) continue;
|
|
47
|
+
if (seen === remainingIndex) return i;
|
|
48
|
+
seen += 1;
|
|
49
|
+
}
|
|
50
|
+
return keys.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clientXOf(event: { nativeEvent?: Event; to?: { x: number } }) {
|
|
54
|
+
const native = event.nativeEvent;
|
|
55
|
+
if (native instanceof PointerEvent || native instanceof MouseEvent) {
|
|
56
|
+
return native.clientX;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function contentIdOf(
|
|
62
|
+
elements: Record<string, LayoutElement>,
|
|
63
|
+
panelKey: string,
|
|
64
|
+
): string {
|
|
65
|
+
const el = elements[panelKey];
|
|
66
|
+
return el?.type === "Panel" ? String(el.props.contentId ?? "") : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function PanelHeader({
|
|
70
|
+
stripKey,
|
|
71
|
+
childKeys,
|
|
72
|
+
activeKey,
|
|
73
|
+
dropKind,
|
|
74
|
+
}: {
|
|
75
|
+
stripKey: string;
|
|
76
|
+
childKeys: string[];
|
|
77
|
+
activeKey: string | null;
|
|
78
|
+
dropKind: "tabs" | "stack";
|
|
79
|
+
}) {
|
|
80
|
+
const {
|
|
81
|
+
spec,
|
|
82
|
+
getSlot,
|
|
83
|
+
isPanelVisible,
|
|
84
|
+
dragPanelKey,
|
|
85
|
+
dragWidth,
|
|
86
|
+
insertPreview,
|
|
87
|
+
setInsertPreview,
|
|
88
|
+
} = useRuntime();
|
|
89
|
+
const elements = spec.elements as Record<string, LayoutElement>;
|
|
90
|
+
const stripRef = useRef<HTMLElement | null>(null);
|
|
91
|
+
const { ref, isDropTarget } = useDroppable({
|
|
92
|
+
id: dropKind === "tabs" ? tabsDropId(stripKey) : stackDropId(stripKey),
|
|
93
|
+
data: { kind: dropKind, panelKey: stripKey },
|
|
94
|
+
});
|
|
95
|
+
const isDropTargetRef = useRef(isDropTarget);
|
|
96
|
+
isDropTargetRef.current = isDropTarget;
|
|
97
|
+
const dragPanelKeyRef = useRef(dragPanelKey);
|
|
98
|
+
dragPanelKeyRef.current = dragPanelKey;
|
|
99
|
+
const dragWidthRef = useRef(dragWidth);
|
|
100
|
+
dragWidthRef.current = dragWidth;
|
|
101
|
+
|
|
102
|
+
const headed = childKeys.filter((key) => {
|
|
103
|
+
const slot = getSlot(contentIdOf(elements, key));
|
|
104
|
+
return Boolean(slot) && (isPanelVisible(key) || key === dragPanelKey);
|
|
105
|
+
});
|
|
106
|
+
const headedRef = useRef(headed);
|
|
107
|
+
headedRef.current = headed;
|
|
108
|
+
const insertHere =
|
|
109
|
+
insertPreview?.panelKey === stripKey ? insertPreview : null;
|
|
110
|
+
|
|
111
|
+
useDragDropMonitor({
|
|
112
|
+
onDragMove(event) {
|
|
113
|
+
const dragging = dragPanelKeyRef.current;
|
|
114
|
+
if (!isDropTargetRef.current || !dragging) return;
|
|
115
|
+
const strip = stripRef.current;
|
|
116
|
+
const clientX = clientXOf(event);
|
|
117
|
+
if (!strip || clientX == null) return;
|
|
118
|
+
const remaining = insertIndexFromX(strip, clientX, dragging);
|
|
119
|
+
const index = toMoveIndex(headedRef.current, dragging, remaining);
|
|
120
|
+
setInsertPreview({
|
|
121
|
+
panelKey: stripKey,
|
|
122
|
+
index,
|
|
123
|
+
width: dragWidthRef.current,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (headed.length === 0 && !insertHere) return null;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<header
|
|
132
|
+
ref={(node) => {
|
|
133
|
+
stripRef.current = node;
|
|
134
|
+
ref(node);
|
|
135
|
+
}}
|
|
136
|
+
className={cn(
|
|
137
|
+
"flex h-10 shrink-0 items-center overflow-x-auto border-b border-border bg-card",
|
|
138
|
+
isDropTarget && "bg-primary/8",
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{headed.map((key, index) => {
|
|
142
|
+
const slot = getSlot(contentIdOf(elements, key));
|
|
143
|
+
if (!slot) return null;
|
|
144
|
+
const gap =
|
|
145
|
+
insertHere && insertHere.index === index ? (
|
|
146
|
+
<TabInsertGap width={insertHere.width} />
|
|
147
|
+
) : null;
|
|
148
|
+
return (
|
|
149
|
+
<span key={key} className="flex h-full items-center">
|
|
150
|
+
{gap}
|
|
151
|
+
{slot.action}
|
|
152
|
+
<TabButton
|
|
153
|
+
panelKey={key}
|
|
154
|
+
active={key === activeKey}
|
|
155
|
+
label={
|
|
156
|
+
slot.dragNodes.length > 0 ? slot.dragNodes : slot.title
|
|
157
|
+
}
|
|
158
|
+
menu={slot.menu}
|
|
159
|
+
/>
|
|
160
|
+
</span>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
{insertHere && insertHere.index >= headed.length ? (
|
|
164
|
+
<TabInsertGap width={insertHere.width} />
|
|
165
|
+
) : null}
|
|
166
|
+
</header>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type RefObject,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
ContextMenu,
|
|
12
|
+
ContextMenuContent,
|
|
13
|
+
ContextMenuGroup,
|
|
14
|
+
ContextMenuItem,
|
|
15
|
+
ContextMenuTrigger,
|
|
16
|
+
} from "@moloodev/ui";
|
|
17
|
+
import { useRuntime } from "./slots-context";
|
|
18
|
+
|
|
19
|
+
const itemClass = "gap-1.5 px-2 py-1 text-xs font-semibold";
|
|
20
|
+
|
|
21
|
+
type MenuActions = {
|
|
22
|
+
hideAfterCloseRef: RefObject<boolean>;
|
|
23
|
+
setOpen: (open: boolean) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const MenuActionsContext = createContext<MenuActions | null>(null);
|
|
27
|
+
|
|
28
|
+
/** Wraps a header button so right-click opens that panel's menu. */
|
|
29
|
+
export function MenuHost({
|
|
30
|
+
panelKey,
|
|
31
|
+
trigger,
|
|
32
|
+
children,
|
|
33
|
+
}: {
|
|
34
|
+
panelKey: string;
|
|
35
|
+
trigger: ReactElement;
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const hideAfterCloseRef = useRef(false);
|
|
40
|
+
const { hidePanel } = useRuntime();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<MenuActionsContext.Provider value={{ hideAfterCloseRef, setOpen }}>
|
|
44
|
+
<ContextMenu
|
|
45
|
+
open={open}
|
|
46
|
+
onOpenChange={(next) => {
|
|
47
|
+
setOpen(next);
|
|
48
|
+
if (!next && hideAfterCloseRef.current) {
|
|
49
|
+
hideAfterCloseRef.current = false;
|
|
50
|
+
hidePanel(panelKey);
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<ContextMenuTrigger render={trigger} />
|
|
55
|
+
{children}
|
|
56
|
+
</ContextMenu>
|
|
57
|
+
</MenuActionsContext.Provider>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function MenuContent({ children }: { children?: ReactNode }) {
|
|
62
|
+
return (
|
|
63
|
+
<ContextMenuContent className="w-28 min-w-0 p-0.5">
|
|
64
|
+
<ContextMenuGroup>{children}</ContextMenuGroup>
|
|
65
|
+
</ContextMenuContent>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function HideItem({ children }: { children?: ReactNode }) {
|
|
70
|
+
const actions = useContext(MenuActionsContext);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<ContextMenuItem
|
|
74
|
+
className={itemClass}
|
|
75
|
+
onClick={() => {
|
|
76
|
+
if (actions) actions.hideAfterCloseRef.current = true;
|
|
77
|
+
actions?.setOpen(false);
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{children}
|
|
81
|
+
</ContextMenuItem>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { MenuContent, HideItem };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
useContext,
|
|
5
|
+
useId,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Collapsible, CollapsibleTrigger } from "@moloodev/ui";
|
|
10
|
+
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
|
11
|
+
|
|
12
|
+
const ReadOnlyContext = createContext(false);
|
|
13
|
+
|
|
14
|
+
const SectionCollapseContext = createContext<{
|
|
15
|
+
collapsed: Readonly<Record<string, boolean>>;
|
|
16
|
+
onCollapsedChange: (id: string, collapsed: boolean) => void;
|
|
17
|
+
} | null>(null);
|
|
18
|
+
|
|
19
|
+
export function SectionCollapseProvider({
|
|
20
|
+
collapsed,
|
|
21
|
+
onCollapsedChange,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
collapsed: Readonly<Record<string, boolean>>;
|
|
25
|
+
onCollapsedChange: (id: string, collapsed: boolean) => void;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<SectionCollapseContext.Provider value={{ collapsed, onCollapsedChange }}>
|
|
30
|
+
{children}
|
|
31
|
+
</SectionCollapseContext.Provider>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type HeadingLevel = 2 | 3 | 4 | 5 | 6;
|
|
36
|
+
|
|
37
|
+
const HEADING_TAG = {
|
|
38
|
+
2: "h2",
|
|
39
|
+
3: "h3",
|
|
40
|
+
4: "h4",
|
|
41
|
+
5: "h5",
|
|
42
|
+
6: "h6",
|
|
43
|
+
} satisfies Record<HeadingLevel, "h2" | "h3" | "h4" | "h5" | "h6">;
|
|
44
|
+
|
|
45
|
+
const headingClassName =
|
|
46
|
+
"m-0 font-[inherit] text-[length:inherit] leading-[inherit] tracking-[inherit] [font-weight:inherit]";
|
|
47
|
+
|
|
48
|
+
export function ReadOnlyScope({
|
|
49
|
+
value,
|
|
50
|
+
children,
|
|
51
|
+
}: {
|
|
52
|
+
value: boolean;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<ReadOnlyContext.Provider value={value}>{children}</ReadOnlyContext.Provider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function PanelSection({
|
|
61
|
+
id,
|
|
62
|
+
title,
|
|
63
|
+
children,
|
|
64
|
+
level = 2,
|
|
65
|
+
}: {
|
|
66
|
+
id: string;
|
|
67
|
+
title: string;
|
|
68
|
+
children?: ReactNode;
|
|
69
|
+
level?: HeadingLevel;
|
|
70
|
+
}) {
|
|
71
|
+
const stored = useContext(SectionCollapseContext);
|
|
72
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(true);
|
|
73
|
+
const open = stored === null ? uncontrolledOpen : stored.collapsed[id] !== true;
|
|
74
|
+
const readOnly = useContext(ReadOnlyContext);
|
|
75
|
+
const contentId = useId();
|
|
76
|
+
if (Children.toArray(children).length === 0) return null;
|
|
77
|
+
const Heading = HEADING_TAG[level];
|
|
78
|
+
return (
|
|
79
|
+
<Collapsible
|
|
80
|
+
data-panel-section=""
|
|
81
|
+
className="flex min-h-0 flex-1 flex-col overflow-hidden border-border not-first:border-t data-closed:flex-none"
|
|
82
|
+
open={open}
|
|
83
|
+
onOpenChange={(next) => {
|
|
84
|
+
if (typeof next !== "boolean") return;
|
|
85
|
+
if (stored === null) setUncontrolledOpen(next);
|
|
86
|
+
else stored.onCollapsedChange(id, !next);
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<Heading className={`${headingClassName} shrink-0`}>
|
|
90
|
+
<CollapsibleTrigger
|
|
91
|
+
aria-controls={contentId}
|
|
92
|
+
className="flex h-9 w-full min-w-0 items-center gap-2 px-3 text-left"
|
|
93
|
+
>
|
|
94
|
+
{open ? (
|
|
95
|
+
<IconChevronDown className="size-4" />
|
|
96
|
+
) : (
|
|
97
|
+
<IconChevronRight className="size-4" />
|
|
98
|
+
)}
|
|
99
|
+
<span className="label text-muted-foreground">{title}</span>
|
|
100
|
+
</CollapsibleTrigger>
|
|
101
|
+
</Heading>
|
|
102
|
+
{open ? (
|
|
103
|
+
<fieldset
|
|
104
|
+
id={contentId}
|
|
105
|
+
data-slot="panel-section-content"
|
|
106
|
+
disabled={readOnly}
|
|
107
|
+
className="m-0 min-h-0 min-w-0 flex-1 overflow-auto border-0 p-3 pt-2 space-y-4"
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</fieldset>
|
|
111
|
+
) : null}
|
|
112
|
+
</Collapsible>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
type LeafSizeProps = {
|
|
2
|
+
defaultSize?: number | string | null;
|
|
3
|
+
minSize?: number | string | null;
|
|
4
|
+
maxSize?: number | string | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type SizeUnit = "px" | "%";
|
|
8
|
+
|
|
9
|
+
type ParsedSize = {
|
|
10
|
+
n: number;
|
|
11
|
+
unit: SizeUnit;
|
|
12
|
+
source: number | string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseSize(value: number | string | null | undefined): ParsedSize | null {
|
|
16
|
+
if (value == null || value === "") return null;
|
|
17
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
18
|
+
return { n: value, unit: "px", source: value };
|
|
19
|
+
}
|
|
20
|
+
if (typeof value !== "string") return null;
|
|
21
|
+
const px = /^(\d+(?:\.\d+)?)px$/.exec(value);
|
|
22
|
+
if (px) return { n: Number(px[1]), unit: "px", source: value };
|
|
23
|
+
const pct = /^(\d+(?:\.\d+)?)%$/.exec(value);
|
|
24
|
+
if (pct) return { n: Number(pct[1]), unit: "%", source: value };
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sizeUnit(
|
|
29
|
+
value: number | string | null | undefined,
|
|
30
|
+
): SizeUnit | null {
|
|
31
|
+
return parseSize(value)?.unit ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sizesEqual(
|
|
35
|
+
left: number | string | null | undefined,
|
|
36
|
+
right: number | string | null | undefined,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (left === right) return true;
|
|
39
|
+
const a = parseSize(left);
|
|
40
|
+
const b = parseSize(right);
|
|
41
|
+
return a != null && b != null && a.unit === b.unit && a.n === b.n;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a live group percentage back in the same unit the spec already uses. */
|
|
45
|
+
export function storedSizeFromLayout(
|
|
46
|
+
percent: number,
|
|
47
|
+
groupSizePx: number,
|
|
48
|
+
unit: SizeUnit,
|
|
49
|
+
): number | string {
|
|
50
|
+
if (unit === "px") return Math.round((percent / 100) * groupSizePx);
|
|
51
|
+
const rounded = Math.round(percent * 10) / 10;
|
|
52
|
+
return `${rounded}%`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Largest comparable size. Pixel values win over percents when mixed. */
|
|
56
|
+
function largestSize(
|
|
57
|
+
values: Array<number | string | null | undefined>,
|
|
58
|
+
): number | string | undefined {
|
|
59
|
+
const parsed = values.map(parseSize).filter((item): item is ParsedSize => item != null);
|
|
60
|
+
if (parsed.length === 0) return undefined;
|
|
61
|
+
const pixels = parsed.filter((item) => item.unit === "px");
|
|
62
|
+
const pool = pixels.length > 0 ? pixels : parsed;
|
|
63
|
+
return pool.reduce((best, item) => (item.n > best.n ? item : best)).source;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Tabs-group chrome size from every leaf, not the active tab. */
|
|
67
|
+
export function groupSizeFromLeaves(
|
|
68
|
+
leaves: LeafSizeProps[],
|
|
69
|
+
options?: { empty?: boolean },
|
|
70
|
+
): {
|
|
71
|
+
defaultSize?: number | string;
|
|
72
|
+
minSize?: number | string;
|
|
73
|
+
maxSize?: number | string;
|
|
74
|
+
} {
|
|
75
|
+
if (options?.empty) {
|
|
76
|
+
return { minSize: 0 };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
defaultSize: largestSize(leaves.map((leaf) => leaf.defaultSize)),
|
|
80
|
+
minSize: largestSize(leaves.map((leaf) => leaf.minSize)),
|
|
81
|
+
maxSize: largestSize(leaves.map((leaf) => leaf.maxSize)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pixel defaults stay in pixels when the group is resized after mount. */
|
|
86
|
+
export function groupResizeBehaviorFor(
|
|
87
|
+
defaultSize?: number | string | null,
|
|
88
|
+
): "preserve-pixel-size" | "preserve-relative-size" {
|
|
89
|
+
if (typeof defaultSize === "number") return "preserve-pixel-size";
|
|
90
|
+
if (typeof defaultSize === "string" && defaultSize.endsWith("px")) {
|
|
91
|
+
return "preserve-pixel-size";
|
|
92
|
+
}
|
|
93
|
+
return "preserve-relative-size";
|
|
94
|
+
}
|