@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,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,6 @@
1
+ import { MenuContent, HideItem } from "./panel-menu";
2
+
3
+ /** Panel overflow: hide the current panel. */
4
+ export function PanelHideMenu() {
5
+ return <MenuContent><HideItem>숨기기</HideItem></MenuContent>;
6
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export function Title({ children }: { children?: ReactNode }) {
4
+ return <span className="select-none truncate font-display text-xs font-extrabold tracking-[-0.02em]">{children}</span>;
5
+ }