@adecore/ui 0.19.0 → 0.20.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 (40) hide show
  1. package/README.md +4 -0
  2. package/dist/ColumnResizeHandle.d.ts +3 -2
  3. package/dist/ColumnResizeHandle.js +2 -2
  4. package/dist/SlidingColumn.d.ts +2 -1
  5. package/dist/SlidingColumn.js +11 -4
  6. package/dist/index.d.ts +6 -0
  7. package/dist/index.js +4 -0
  8. package/dist/locales/en.json +10 -0
  9. package/dist/locales/nl.json +10 -0
  10. package/dist/theme.css +181 -0
  11. package/dist/useColumnResize.js +12 -17
  12. package/dist/workspace/SplitView.d.ts +49 -0
  13. package/dist/workspace/SplitView.js +421 -0
  14. package/dist/workspace/TabStrip.d.ts +42 -0
  15. package/dist/workspace/TabStrip.js +168 -0
  16. package/dist/workspace/Workspace.d.ts +15 -0
  17. package/dist/workspace/Workspace.js +27 -0
  18. package/dist/workspace/context.d.ts +6 -0
  19. package/dist/workspace/context.js +5 -0
  20. package/dist/workspace/geometry.d.ts +39 -0
  21. package/dist/workspace/geometry.js +153 -0
  22. package/dist/workspace/model.d.ts +83 -0
  23. package/dist/workspace/model.js +360 -0
  24. package/dist/workspace/preview.d.ts +35 -0
  25. package/dist/workspace/preview.js +72 -0
  26. package/package.json +1 -1
  27. package/src/ColumnResizeHandle.tsx +4 -3
  28. package/src/SlidingColumn.tsx +23 -6
  29. package/src/index.ts +19 -0
  30. package/src/locales/en.json +10 -0
  31. package/src/locales/nl.json +10 -0
  32. package/src/theme.css +181 -0
  33. package/src/useColumnResize.ts +12 -18
  34. package/src/workspace/SplitView.tsx +656 -0
  35. package/src/workspace/TabStrip.tsx +296 -0
  36. package/src/workspace/Workspace.tsx +72 -0
  37. package/src/workspace/context.ts +8 -0
  38. package/src/workspace/geometry.ts +190 -0
  39. package/src/workspace/model.ts +440 -0
  40. package/src/workspace/preview.ts +116 -0
@@ -0,0 +1,296 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ type DragEvent,
10
+ type MouseEvent,
11
+ type ReactNode,
12
+ type UIEvent,
13
+ type WheelEvent
14
+ } from 'react';
15
+ import { useRender } from '@base-ui-components/react/use-render';
16
+ import { Pin, X } from 'lucide-react';
17
+ import clsx from 'clsx';
18
+ import { useTranslation } from 'react-i18next';
19
+ import { Icon } from '../Icon.tsx';
20
+ import { IconButton, type IconButtonProps } from '../IconButton.tsx';
21
+ import { Tooltip } from '../Tooltip.tsx';
22
+ import { tabGapAt } from './geometry.ts';
23
+
24
+ export interface TabStripItem {
25
+ id: string;
26
+ label: string;
27
+ hint?: string;
28
+ icon?: ReactNode;
29
+ detail?: ReactNode;
30
+ attention?: ReactNode;
31
+ unsaved?: boolean | string;
32
+ pinned?: boolean;
33
+ closable?: boolean;
34
+ tabId?: string;
35
+ panelId?: string;
36
+ }
37
+
38
+ export interface TabStripProps extends Omit<useRender.ComponentProps<'div'>, 'children' | 'onDragStart' | 'onDragEnd' | 'onDoubleClick' | 'onContextMenu'> {
39
+ items: readonly TabStripItem[];
40
+ value: string | null;
41
+ onValueChange(value: string): void;
42
+ renderTab?(item: TabStripItem): ReactNode;
43
+ focused?: boolean;
44
+ insertAt?: number | null;
45
+ onClose?(id: string): void;
46
+ onDoubleClick?(id: string, event: MouseEvent<HTMLButtonElement>): void;
47
+ onContextMenu?(id: string, event: MouseEvent<HTMLSpanElement>): void;
48
+ onDragStart?(id: string, event: DragEvent<HTMLButtonElement>): void;
49
+ onDragEnd?(event: DragEvent<HTMLButtonElement>): void;
50
+ onTabDragOver?(index: number, event: DragEvent<HTMLDivElement>): void;
51
+ onTabDrop?(index: number, event: DragEvent<HTMLDivElement>): void;
52
+ }
53
+
54
+ export function TabStrip({
55
+ items,
56
+ value,
57
+ onValueChange,
58
+ renderTab,
59
+ focused = true,
60
+ insertAt,
61
+ onClose,
62
+ onDoubleClick,
63
+ onContextMenu,
64
+ onDragStart,
65
+ onDragEnd,
66
+ onTabDragOver,
67
+ onTabDrop,
68
+ render,
69
+ className,
70
+ ref,
71
+ ...props
72
+ }: TabStripProps) {
73
+ const { t } = useTranslation('ui');
74
+ const strip = useRef<HTMLDivElement>(null);
75
+ const closingFocus = useRef<string | null>(null);
76
+ useLayoutEffect(() => {
77
+ if (closingFocus.current && !items.some((item) => item.id === closingFocus.current)) {
78
+ closingFocus.current = null;
79
+ const target = strip.current?.querySelector<HTMLElement>('[aria-selected="true"]') ?? strip.current?.querySelector<HTMLElement>('[role="tab"]');
80
+ target?.focus({ preventScroll: true });
81
+ }
82
+ });
83
+ const close = (id: string, action?: () => void): void => {
84
+ if (strip.current?.contains(strip.current.ownerDocument.activeElement)) {
85
+ closingFocus.current = id;
86
+ }
87
+ if (action) {
88
+ action();
89
+ } else {
90
+ onClose?.(id);
91
+ }
92
+ };
93
+ const [edges, setEdges] = useState({ start: false, end: false });
94
+ const [insertion, setInsertion] = useState(0);
95
+ const measure = useCallback((): void => {
96
+ const element = strip.current;
97
+ if (!element) {
98
+ return;
99
+ }
100
+ const start = element.scrollLeft > 0;
101
+ const end = element.scrollLeft + element.clientWidth < element.scrollWidth - 1;
102
+ setEdges((current) => (current.start === start && current.end === end ? current : { start, end }));
103
+ if (insertAt !== undefined && insertAt !== null) {
104
+ const tabs = Array.from(element.querySelectorAll<HTMLElement>('[data-document-tab]'));
105
+ const after = tabs[insertAt];
106
+ const last = tabs.at(-1);
107
+ setInsertion(after?.offsetLeft ?? (last ? last.offsetLeft + last.offsetWidth : 0));
108
+ }
109
+ }, [insertAt]);
110
+ useLayoutEffect(measure);
111
+ useEffect(() => {
112
+ const element = strip.current;
113
+ if (!element || typeof ResizeObserver === 'undefined') {
114
+ return;
115
+ }
116
+ const observer = new ResizeObserver(measure);
117
+ observer.observe(element);
118
+ element.querySelectorAll('[data-document-tab]').forEach((tab) => observer.observe(tab));
119
+ return () => observer.disconnect();
120
+ });
121
+ useEffect(() => {
122
+ strip.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
123
+ }, [value]);
124
+ const gap = (event: DragEvent<HTMLDivElement>): number => {
125
+ const rects = Array.from(event.currentTarget.querySelectorAll('[data-document-tab]'), (tab) => tab.getBoundingClientRect());
126
+ return tabGapAt(rects, event.clientX);
127
+ };
128
+ const context: TabContextValue = {
129
+ items,
130
+ value,
131
+ onValueChange,
132
+ onClose,
133
+ onDoubleClick,
134
+ onContextMenu,
135
+ onDragStart,
136
+ onDragEnd,
137
+ close,
138
+ focus: (index) => strip.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[index]?.focus()
139
+ };
140
+ return useRender({
141
+ render,
142
+ ref: [strip, ref ?? null],
143
+ defaultTagName: 'div',
144
+ props: {
145
+ ...props,
146
+ role: 'tablist',
147
+ 'aria-label': props['aria-label'] ?? t('workspace.tabs'),
148
+ className: clsx('ade-tab-strip scroll-fade-x', className),
149
+ 'data-focused': focused || undefined,
150
+ 'data-fade-start': edges.start || undefined,
151
+ 'data-fade-end': edges.end || undefined,
152
+ onScroll: (event: UIEvent<HTMLDivElement>) => {
153
+ props.onScroll?.(event);
154
+ measure();
155
+ },
156
+ onWheel: (event: WheelEvent<HTMLDivElement>) => {
157
+ props.onWheel?.(event);
158
+ if (!event.defaultPrevented && !event.deltaX && event.deltaY) {
159
+ event.currentTarget.scrollLeft += event.deltaY;
160
+ }
161
+ },
162
+ onDragOver: (event: DragEvent<HTMLDivElement>) => {
163
+ props.onDragOver?.(event);
164
+ if (!event.defaultPrevented) {
165
+ onTabDragOver?.(gap(event), event);
166
+ }
167
+ },
168
+ onDrop: (event: DragEvent<HTMLDivElement>) => {
169
+ props.onDrop?.(event);
170
+ if (!event.defaultPrevented) {
171
+ onTabDrop?.(gap(event), event);
172
+ }
173
+ },
174
+ children: (
175
+ <>
176
+ {items.map((item) => (
177
+ <TabContext.Provider key={item.id} value={context}>
178
+ {renderTab ? renderTab(item) : <DocumentTab item={item} />}
179
+ </TabContext.Provider>
180
+ ))}
181
+ {insertAt !== undefined && insertAt !== null && <span aria-hidden="true" className="ade-tab-insertion" style={{ left: insertion }} />}
182
+ </>
183
+ )
184
+ }
185
+ });
186
+ }
187
+
188
+ interface TabContextValue extends Pick<
189
+ TabStripProps,
190
+ 'items' | 'value' | 'onValueChange' | 'onClose' | 'onDoubleClick' | 'onContextMenu' | 'onDragStart' | 'onDragEnd'
191
+ > {
192
+ close(id: string, action?: () => void): void;
193
+ focus(index: number): void;
194
+ }
195
+ const TabContext = createContext<TabContextValue | null>(null);
196
+
197
+ export interface DocumentTabProps extends Omit<useRender.ComponentProps<'span'>, 'children' | 'onDoubleClick' | 'onDragStart' | 'onDragEnd'> {
198
+ item: TabStripItem;
199
+ onClose?(): void;
200
+ closeShortcut?: IconButtonProps['kbd'];
201
+ onDoubleClick?: TabStripProps['onDoubleClick'];
202
+ onDragStart?: TabStripProps['onDragStart'];
203
+ onDragEnd?: TabStripProps['onDragEnd'];
204
+ }
205
+
206
+ /** A tab with application-owned metadata and menus, rendered inside TabStrip's renderTab slot. */
207
+ export function DocumentTab({ item, onClose, closeShortcut, onDoubleClick, onDragStart, onDragEnd, className, render, ref, ...props }: DocumentTabProps) {
208
+ const context = useContext(TabContext);
209
+ if (!context) {
210
+ throw new Error('DocumentTab must be rendered inside TabStrip.');
211
+ }
212
+ const { t } = useTranslation('ui');
213
+ const { items, value, onValueChange } = context;
214
+ const index = items.findIndex((candidate) => candidate.id === item.id);
215
+ const close = (): void => context.close(item.id, onClose);
216
+ return useRender({
217
+ render,
218
+ ref,
219
+ defaultTagName: 'span',
220
+ props: {
221
+ ...props,
222
+ className: clsx('ade-document-tab', className),
223
+ 'data-document-tab': item.id,
224
+ 'data-active': item.id === value || undefined,
225
+ 'data-detail': !!item.detail || undefined,
226
+ onContextMenu: (event: MouseEvent<HTMLSpanElement>) => {
227
+ props.onContextMenu?.(event);
228
+ context.onContextMenu?.(item.id, event);
229
+ },
230
+ children: (
231
+ <>
232
+ <Tooltip label={item.hint ?? item.label}>
233
+ <button
234
+ type="button"
235
+ role="tab"
236
+ id={item.tabId}
237
+ aria-controls={item.panelId}
238
+ aria-selected={item.id === value}
239
+ tabIndex={item.id === value || (value === null && index === 0) ? 0 : -1}
240
+ className="ade-document-tab-open"
241
+ draggable={!!(onDragStart ?? context.onDragStart)}
242
+ onClick={() => onValueChange(item.id)}
243
+ onDoubleClick={(event) => (onDoubleClick ?? context.onDoubleClick)?.(item.id, event)}
244
+ onDragStart={(event) => {
245
+ event.stopPropagation();
246
+ (onDragStart ?? context.onDragStart)?.(item.id, event);
247
+ }}
248
+ onDragEnd={onDragEnd ?? context.onDragEnd}
249
+ onKeyDown={(event) => {
250
+ const next =
251
+ event.key === 'ArrowRight'
252
+ ? (index + 1) % items.length
253
+ : event.key === 'ArrowLeft'
254
+ ? (index + items.length - 1) % items.length
255
+ : event.key === 'Home'
256
+ ? 0
257
+ : event.key === 'End'
258
+ ? items.length - 1
259
+ : null;
260
+ if (next !== null) {
261
+ event.preventDefault();
262
+ onValueChange(items[next]!.id);
263
+ context.focus(next);
264
+ } else if (event.key === 'Delete' && item.closable !== false && (onClose || context.onClose)) {
265
+ event.preventDefault();
266
+ close();
267
+ }
268
+ }}
269
+ >
270
+ {item.icon}
271
+ <span className="truncate">{item.label}</span>
272
+ {item.detail}
273
+ {item.attention}
274
+ {item.unsaved && (
275
+ <span className="ade-tab-unsaved">
276
+ <span className="sr-only">{typeof item.unsaved === 'string' ? item.unsaved : t('workspace.unsaved')}</span>
277
+ </span>
278
+ )}
279
+ </button>
280
+ </Tooltip>
281
+ {item.pinned && <Icon icon={Pin} size={12} className="shrink-0 text-text-muted" />}
282
+ {item.closable !== false && (onClose || context.onClose) && (
283
+ <IconButton
284
+ icon={X}
285
+ size="2xs"
286
+ label={t('workspace.closeTab', { name: item.label })}
287
+ kbd={closeShortcut}
288
+ className="ade-document-tab-close"
289
+ onClick={close}
290
+ />
291
+ )}
292
+ </>
293
+ )
294
+ }
295
+ });
296
+ }
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useRender } from '@base-ui-components/react/use-render';
4
+ import { WorkspaceContext, type WorkspaceLayout } from './context.ts';
5
+
6
+ export interface WorkspaceProps extends useRender.ComponentProps<'div'> {
7
+ layout?: WorkspaceLayout;
8
+ toolbar?: ReactNode;
9
+ sidebar?: ReactNode;
10
+ sidePanel?: ReactNode;
11
+ /** Auto-sized regions keep their own width, separators and opening animation. */
12
+ sidebarWidth?: number | 'auto';
13
+ sidePanelWidth?: number | 'auto';
14
+ sidebarOpen?: boolean;
15
+ sidePanelOpen?: boolean;
16
+ }
17
+
18
+ export function Workspace({
19
+ layout = 'standard',
20
+ toolbar,
21
+ sidebar,
22
+ sidePanel,
23
+ sidebarWidth = 248,
24
+ sidePanelWidth = 320,
25
+ sidebarOpen,
26
+ sidePanelOpen,
27
+ children,
28
+ className,
29
+ ref,
30
+ render,
31
+ style,
32
+ ...props
33
+ }: WorkspaceProps) {
34
+ const hasSidebar = sidebar !== undefined && sidebar !== null && sidebar !== false;
35
+ const hasPanel = sidePanel !== undefined && sidePanel !== null && sidePanel !== false;
36
+ const leftOpen = hasSidebar && (sidebarOpen ?? true);
37
+ const rightOpen = hasPanel && (sidePanelOpen ?? true);
38
+ const hasToolbar = toolbar !== undefined && toolbar !== null && toolbar !== false;
39
+ const element = useRender({
40
+ render,
41
+ ref,
42
+ defaultTagName: 'div',
43
+ props: {
44
+ ...props,
45
+ className: clsx('ade-workspace', className),
46
+ style,
47
+ 'data-layout': layout,
48
+ 'data-toolbar': hasToolbar || undefined,
49
+ 'data-sidebar': leftOpen || undefined,
50
+ 'data-side-panel': rightOpen || undefined,
51
+ children: (
52
+ <>
53
+ {hasSidebar && (
54
+ <aside className="ade-workspace-sidebar" data-auto={sidebarWidth === 'auto' || undefined} style={{ width: sidebarWidth }}>
55
+ {sidebar}
56
+ </aside>
57
+ )}
58
+ <div className="ade-workspace-center">
59
+ {hasToolbar && <div className="ade-workspace-toolbar">{toolbar}</div>}
60
+ <div className="ade-workspace-content">{children}</div>
61
+ </div>
62
+ {hasPanel && (
63
+ <aside className="ade-workspace-side-panel" data-auto={sidePanelWidth === 'auto' || undefined} style={{ width: sidePanelWidth }}>
64
+ {sidePanel}
65
+ </aside>
66
+ )}
67
+ </>
68
+ )
69
+ }
70
+ });
71
+ return <WorkspaceContext value={{ layout, edges: { top: !hasToolbar, right: !rightOpen, bottom: true, left: !leftOpen } }}>{element}</WorkspaceContext>;
72
+ }
@@ -0,0 +1,8 @@
1
+ import { createContext } from 'react';
2
+ import type { SplitEdges } from './geometry.ts';
3
+
4
+ export type WorkspaceLayout = 'standard' | 'roomy';
5
+ export const WorkspaceContext = createContext<{ layout: WorkspaceLayout; edges: SplitEdges }>({
6
+ layout: 'standard',
7
+ edges: { top: true, right: true, bottom: true, left: true }
8
+ });
@@ -0,0 +1,190 @@
1
+ import { findSplitNode, type SplitAxis, type SplitLayout, type SplitNode, type SplitPane, type SplitSide } from './model.ts';
2
+
3
+ export interface SplitRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface SplitEdges {
10
+ top?: boolean;
11
+ right?: boolean;
12
+ bottom?: boolean;
13
+ left?: boolean;
14
+ }
15
+ export interface SplitMinimum {
16
+ width: number;
17
+ height: number;
18
+ }
19
+ export interface SplitPaneRect extends SplitRect {
20
+ pane: SplitPane;
21
+ }
22
+ export interface SplitDivider extends SplitRect {
23
+ splitId: string;
24
+ index: number;
25
+ axis: SplitAxis;
26
+ lengths: number[];
27
+ minimums: number[];
28
+ }
29
+ export interface SplitGeometry {
30
+ panes: SplitPaneRect[];
31
+ dividers: SplitDivider[];
32
+ }
33
+
34
+ function distribute(total: number, weights: number[], minimums: number[]): number[] {
35
+ const floor = minimums.reduce((sum, size) => sum + size, 0);
36
+ if (floor >= total) {
37
+ return minimums.map((size) => (floor ? (size * total) / floor : total / minimums.length));
38
+ }
39
+ const result = weights.map(() => 0);
40
+ const pending = new Set(weights.map((_, i) => i));
41
+ let remaining = total;
42
+ while (pending.size) {
43
+ const sum = [...pending].reduce((value, i) => value + weights[i]!, 0);
44
+ const small = [...pending].filter((i) => (remaining * weights[i]!) / sum < minimums[i]!);
45
+ if (!small.length) {
46
+ pending.forEach((i) => {
47
+ result[i] = (remaining * weights[i]!) / sum;
48
+ });
49
+ break;
50
+ }
51
+ small.forEach((i) => {
52
+ result[i] = minimums[i]!;
53
+ remaining -= minimums[i]!;
54
+ pending.delete(i);
55
+ });
56
+ }
57
+ return result;
58
+ }
59
+
60
+ export function splitGeometry(layout: SplitLayout, width: number, height: number, gap: number, minimum: (pane: SplitPane) => SplitMinimum): SplitGeometry {
61
+ const result: SplitGeometry = { panes: [], dividers: [] };
62
+ const minimums = new Map<string, SplitMinimum>();
63
+ const measure = (node: SplitNode): SplitMinimum => {
64
+ if (node.type === 'pane') {
65
+ const value = minimum(node);
66
+ const safe = {
67
+ width: Math.max(0, Number.isFinite(value.width) ? value.width : 0),
68
+ height: Math.max(0, Number.isFinite(value.height) ? value.height : 0)
69
+ };
70
+ minimums.set(node.id, safe);
71
+ return safe;
72
+ }
73
+ const children = node.children.map(measure);
74
+ const horizontal = node.axis === 'horizontal';
75
+ const value = {
76
+ width: horizontal
77
+ ? children.reduce((sum, item) => sum + item.width, 0) + gap * (children.length - 1)
78
+ : Math.max(...children.map((item) => item.width)),
79
+ height: horizontal
80
+ ? Math.max(...children.map((item) => item.height))
81
+ : children.reduce((sum, item) => sum + item.height, 0) + gap * (children.length - 1)
82
+ };
83
+ minimums.set(node.id, value);
84
+ return value;
85
+ };
86
+ const walk = (node: SplitNode, rect: SplitRect): void => {
87
+ if (node.type === 'pane') {
88
+ result.panes.push({ ...rect, pane: node });
89
+ return;
90
+ }
91
+ const horizontal = node.axis === 'horizontal';
92
+ const axisLength = horizontal ? rect.width : rect.height;
93
+ const spacing = Math.min(gap, axisLength / (node.children.length - 1));
94
+ const available = Math.max(0, axisLength - spacing * (node.children.length - 1));
95
+ const limits = node.children.map((child) => minimums.get(child.id)![horizontal ? 'width' : 'height']);
96
+ const lengths = distribute(available, node.sizes, limits);
97
+ let offset = 0;
98
+ node.children.forEach((child, i) => {
99
+ const end = Math.round(offset + lengths[i]!);
100
+ const start = Math.round(offset);
101
+ walk(child, horizontal ? { ...rect, x: rect.x + start, width: end - start } : { ...rect, y: rect.y + start, height: end - start });
102
+ if (i < node.children.length - 1) {
103
+ result.dividers.push({
104
+ ...rect,
105
+ ...(horizontal ? { x: rect.x + end, width: spacing } : { y: rect.y + end, height: spacing }),
106
+ splitId: node.id,
107
+ index: i,
108
+ axis: node.axis,
109
+ lengths,
110
+ minimums: limits
111
+ });
112
+ }
113
+ offset += lengths[i]! + spacing;
114
+ });
115
+ };
116
+ const root = layout.maximized ? (findSplitNode(layout.root, layout.maximized) ?? layout.root) : layout.root;
117
+ measure(root);
118
+ walk(root, { x: 0, y: 0, width: Math.max(0, width), height: Math.max(0, height) });
119
+ return result;
120
+ }
121
+
122
+ export function resizeSplit(divider: SplitDivider, delta: number, symmetric = false, snapDistance = 0, minimumShare = 0): number[] {
123
+ const lengths = [...divider.lengths];
124
+ const { index } = divider;
125
+ const total = lengths.reduce((sum, size) => sum + size, 0);
126
+ if (total <= 0 || lengths.some((size) => size <= 0)) {
127
+ return lengths.map(() => 1 / lengths.length);
128
+ }
129
+ const mirror = lengths.length - divider.index - 2;
130
+ const affected = new Set([index, index + 1, ...(symmetric ? [mirror, mirror + 1] : [])]);
131
+ const span = [...affected].reduce((sum, i) => sum + lengths[i]!, 0);
132
+ const share = Number.isFinite(minimumShare) ? Math.max(0, Math.min(1 / affected.size, minimumShare)) : 0;
133
+ const min = divider.minimums.map((size, i) => Math.min(lengths[i]!, Math.max(size, affected.has(i) ? span * share : 0)));
134
+ if (symmetric && mirror !== index) {
135
+ const changes = lengths.map(() => 0);
136
+ changes[index]!++;
137
+ changes[index + 1]!--;
138
+ changes[mirror]!--;
139
+ changes[mirror + 1]!++;
140
+ let lower = -Infinity;
141
+ let upper = Infinity;
142
+ changes.forEach((coefficient, i) => {
143
+ if (coefficient > 0) {
144
+ lower = Math.max(lower, (min[i]! - lengths[i]!) / coefficient);
145
+ }
146
+ if (coefficient < 0) {
147
+ upper = Math.min(upper, (lengths[i]! - min[i]!) / -coefficient);
148
+ }
149
+ });
150
+ const change = Math.max(lower, Math.min(delta, upper));
151
+ return lengths.map((size, i) => Math.max(Number.EPSILON, (size + changes[i]! * change) / total));
152
+ }
153
+ const change = Math.max(min[index]! - lengths[index]!, Math.min(delta, lengths[index + 1]! - min[index + 1]!));
154
+ const pair = lengths[index]! + lengths[index + 1]!;
155
+ const middle = pair / 2;
156
+ const next = lengths[index]! + change;
157
+ // A snap must not cross either pane's minimum, including minimums inherited from nested splits.
158
+ lengths[index] = Math.abs(next - middle) <= snapDistance && middle >= min[index]! && middle >= min[index + 1]! ? middle : next;
159
+ lengths[index + 1] = pair - lengths[index]!;
160
+ return lengths.map((size) => Math.max(Number.EPSILON, size / total));
161
+ }
162
+
163
+ export function paneRadius(rect: SplitRect, width: number, height: number, edges: SplitEdges, radius: number): string {
164
+ const left = rect.x === 0 && edges.left;
165
+ const top = rect.y === 0 && edges.top;
166
+ const right = rect.x + rect.width >= width && edges.right;
167
+ const bottom = rect.y + rect.height >= height && edges.bottom;
168
+ return [top || left, top || right, bottom || right, bottom || left].map((flush) => `${flush ? 0 : radius}px`).join(' ');
169
+ }
170
+
171
+ export function splitSideAt(rect: SplitRect, x: number, y: number): SplitSide | undefined {
172
+ const horizontal = (x - rect.x) / rect.width;
173
+ const vertical = (y - rect.y) / rect.height;
174
+ const nearest = Math.min(horizontal, 1 - horizontal, vertical, 1 - vertical);
175
+ if (nearest > 0.25) {
176
+ return undefined;
177
+ }
178
+ if (nearest === horizontal) {
179
+ return 'left';
180
+ }
181
+ if (nearest === 1 - horizontal) {
182
+ return 'right';
183
+ }
184
+ return nearest === vertical ? 'top' : 'bottom';
185
+ }
186
+
187
+ export function tabGapAt(rects: readonly { left: number; right: number }[], x: number): number {
188
+ const index = rects.findIndex((rect) => x < (rect.left + rect.right) / 2);
189
+ return index < 0 ? rects.length : index;
190
+ }