@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,440 @@
1
+ export type SplitAxis = 'horizontal' | 'vertical';
2
+ export type SplitSide = 'left' | 'right' | 'top' | 'bottom';
3
+
4
+ export interface SplitPane {
5
+ type: 'pane';
6
+ id: string;
7
+ views: string[];
8
+ active: string | null;
9
+ }
10
+
11
+ export interface SplitBranch {
12
+ type: 'split';
13
+ id: string;
14
+ axis: SplitAxis;
15
+ children: SplitNode[];
16
+ sizes: number[];
17
+ }
18
+
19
+ export type SplitNode = SplitPane | SplitBranch;
20
+
21
+ export interface SplitLayout {
22
+ root: SplitNode;
23
+ focused: string;
24
+ maximized: string | null;
25
+ }
26
+
27
+ export type SplitCommand =
28
+ | { type: 'activate'; paneId: string; viewId: string }
29
+ | { type: 'focus'; paneId: string }
30
+ | { type: 'maximize'; paneId: string | null }
31
+ | { type: 'close'; viewId: string }
32
+ | { type: 'closePane'; paneId: string }
33
+ | { type: 'insert'; paneId: string; viewId: string; index?: number }
34
+ | { type: 'split'; paneId: string; side: SplitSide; newPane: SplitPane; splitId: string }
35
+ | { type: 'move'; sourceId: string; targetId: string; viewId?: string; side?: SplitSide; index?: number; newPaneId?: string; splitId?: string }
36
+ | { type: 'swap'; sourceId: string; targetId: string; viewId?: string }
37
+ | { type: 'resize'; splitId: string; sizes: number[] }
38
+ | { type: 'equalize'; splitId: string; index?: number }
39
+ | { type: 'equalizeAxis'; axis: SplitAxis; length?: number; gap?: number };
40
+
41
+ export function createSplitLayout(views: string[] = [], paneId = 'main'): SplitLayout {
42
+ const unique = [...new Set(views)];
43
+ return { root: { type: 'pane', id: paneId, views: unique, active: unique[0] ?? null }, focused: paneId, maximized: null };
44
+ }
45
+
46
+ export function splitPanes(node: SplitNode): SplitPane[] {
47
+ return node.type === 'pane' ? [node] : node.children.flatMap(splitPanes);
48
+ }
49
+
50
+ export function findSplitNode(node: SplitNode, id: string): SplitNode | undefined {
51
+ if (node.id === id) {
52
+ return node;
53
+ }
54
+ return node.type === 'split' ? node.children.map((child) => findSplitNode(child, id)).find(Boolean) : undefined;
55
+ }
56
+
57
+ function shares(values: number[], length: number): number[] {
58
+ const safe = Array.from({ length }, (_, i) => (Number.isFinite(values[i]) && values[i]! > 0 ? values[i]! : 1));
59
+ const largest = Math.max(...safe, 1);
60
+ const total = safe.reduce((sum, value) => sum + value / largest, 0);
61
+ return safe.map((value) => value / largest / total);
62
+ }
63
+
64
+ function flattenSplits(node: SplitNode): SplitNode {
65
+ if (node.type === 'pane') {
66
+ return node;
67
+ }
68
+ const children: SplitNode[] = [];
69
+ const sizes: number[] = [];
70
+ node.children.forEach((child, i) => {
71
+ const next = flattenSplits(child);
72
+ // A divider balances neighboring tracks, not a pane against a hidden group on the same axis.
73
+ if (next.type === 'split' && next.axis === node.axis) {
74
+ children.push(...next.children);
75
+ sizes.push(...next.sizes.map((size) => size * node.sizes[i]!));
76
+ } else {
77
+ children.push(next);
78
+ sizes.push(node.sizes[i]!);
79
+ }
80
+ });
81
+ if (children.length === node.children.length && children.every((child, i) => child === node.children[i])) {
82
+ return node;
83
+ }
84
+ return { ...node, children, sizes: shares(sizes, children.length) };
85
+ }
86
+
87
+ /** Repairs persisted data at the application boundary; view payloads never enter the layout. */
88
+ export function normalizeSplitLayout(value: unknown): SplitLayout {
89
+ const ids = new Set<string>();
90
+ const views = new Set<string>();
91
+ const seen = new Set<object>();
92
+ let serial = 0;
93
+ const nextId = (candidate: unknown): string => {
94
+ let id = typeof candidate === 'string' && candidate.length > 0 ? candidate : `pane-${serial++}`;
95
+ while (ids.has(id)) {
96
+ id = `pane-${serial++}`;
97
+ }
98
+ ids.add(id);
99
+ return id;
100
+ };
101
+ const read = (input: unknown, depth: number): SplitNode | null => {
102
+ if (input === null || typeof input !== 'object' || seen.has(input) || depth > 64) {
103
+ return null;
104
+ }
105
+ seen.add(input);
106
+ const row = input as Record<string, unknown>;
107
+ if (row.type === 'pane') {
108
+ const items = Array.isArray(row.views)
109
+ ? row.views.filter((view): view is string => {
110
+ if (typeof view !== 'string' || view.length === 0 || views.has(view)) {
111
+ return false;
112
+ }
113
+ views.add(view);
114
+ return true;
115
+ })
116
+ : [];
117
+ return {
118
+ type: 'pane',
119
+ id: nextId(row.id),
120
+ views: items,
121
+ active: typeof row.active === 'string' && items.includes(row.active) ? row.active : (items[0] ?? null)
122
+ };
123
+ }
124
+ if (row.type !== 'split' || !Array.isArray(row.children)) {
125
+ return null;
126
+ }
127
+ const children: SplitNode[] = [];
128
+ const sizes: number[] = [];
129
+ row.children.forEach((child, i) => {
130
+ const result = read(child, depth + 1);
131
+ if (result) {
132
+ children.push(result);
133
+ sizes.push(Array.isArray(row.sizes) ? Number(row.sizes[i]) : 1);
134
+ }
135
+ });
136
+ if (children.length < 2) {
137
+ return children[0] ?? null;
138
+ }
139
+ return {
140
+ type: 'split',
141
+ id: nextId(row.id),
142
+ axis: row.axis === 'vertical' ? 'vertical' : 'horizontal',
143
+ children,
144
+ sizes: shares(sizes, children.length)
145
+ };
146
+ };
147
+ const input = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
148
+ const root = flattenSplits(read(input.root, 0) ?? createSplitLayout().root);
149
+ const panes = splitPanes(root);
150
+ return {
151
+ root,
152
+ focused: panes.find((pane) => pane.id === input.focused)?.id ?? panes[0]!.id,
153
+ maximized: panes.find((pane) => pane.id === input.maximized)?.id ?? null
154
+ };
155
+ }
156
+
157
+ function replace(node: SplitNode, id: string, transform: (node: SplitNode) => SplitNode | null): SplitNode | null {
158
+ if (node.id === id) {
159
+ return transform(node);
160
+ }
161
+ if (node.type === 'pane') {
162
+ return node;
163
+ }
164
+ const children: SplitNode[] = [];
165
+ const sizes: number[] = [];
166
+ node.children.forEach((child, i) => {
167
+ const next = replace(child, id, transform);
168
+ if (next) {
169
+ children.push(next);
170
+ sizes.push(node.sizes[i]!);
171
+ }
172
+ });
173
+ if (children.every((child, i) => child === node.children[i]) && children.length === node.children.length) {
174
+ return node;
175
+ }
176
+ if (children.length < 2) {
177
+ return children[0] ?? null;
178
+ }
179
+ return { ...node, children, sizes: shares(sizes, children.length) };
180
+ }
181
+
182
+ function finish(layout: SplitLayout, root: SplitNode | null, focus = layout.focused): SplitLayout {
183
+ const next = flattenSplits(root ?? createSplitLayout([], layout.focused).root);
184
+ const panes = splitPanes(next);
185
+ return {
186
+ root: next,
187
+ focused: panes.some((pane) => pane.id === focus) ? focus : panes[0]!.id,
188
+ maximized: panes.some((pane) => pane.id === layout.maximized) ? layout.maximized : null
189
+ };
190
+ }
191
+
192
+ function without(pane: SplitPane, views: string[]): SplitPane {
193
+ const remaining = pane.views.filter((view) => !views.includes(view));
194
+ const position = Math.max(0, pane.views.indexOf(pane.active ?? ''));
195
+ return {
196
+ ...pane,
197
+ views: remaining,
198
+ active: remaining.includes(pane.active ?? '') ? pane.active : (remaining[Math.min(position, remaining.length - 1)] ?? null)
199
+ };
200
+ }
201
+
202
+ function beside(target: SplitPane, pane: SplitPane, side: SplitSide, id: string): SplitBranch {
203
+ return {
204
+ type: 'split',
205
+ id,
206
+ axis: side === 'left' || side === 'right' ? 'horizontal' : 'vertical',
207
+ children: side === 'left' || side === 'top' ? [pane, target] : [target, pane],
208
+ sizes: [0.5, 0.5]
209
+ };
210
+ }
211
+
212
+ function equalizeAxis(root: SplitNode, axis: SplitAxis, length: number, gap: number): SplitNode {
213
+ const tracks = new Map<string, number>();
214
+ const measure = (node: SplitNode): number => {
215
+ const children = node.type === 'split' ? node.children.map(measure) : [];
216
+ const count = node.type === 'pane' ? 1 : node.axis === axis ? children.reduce((sum, value) => sum + value, 0) : Math.max(...children);
217
+ tracks.set(node.id, count);
218
+ return count;
219
+ };
220
+ measure(root);
221
+ const distribute = (node: SplitNode, extent: number): SplitNode => {
222
+ if (node.type === 'pane') {
223
+ return node;
224
+ }
225
+ if (node.axis !== axis) {
226
+ return { ...node, children: node.children.map((child) => distribute(child, extent)) };
227
+ }
228
+ const count = tracks.get(node.id)!;
229
+ const spacing = Math.min(gap, extent / (count - 1));
230
+ const unit = Math.max(0, extent - spacing * (count - 1)) / count;
231
+ // Spanning panels also occupy the gaps between their tracks.
232
+ const lengths = node.children.map((child) => {
233
+ const span = tracks.get(child.id)!;
234
+ return Math.max(Number.EPSILON, unit * span + spacing * (span - 1));
235
+ });
236
+ return { ...node, sizes: shares(lengths, lengths.length), children: node.children.map((child, i) => distribute(child, lengths[i]!)) };
237
+ };
238
+ return distribute(root, length);
239
+ }
240
+
241
+ /** Invalid or stale commands are no-ops. IDs for new nodes come from the caller. */
242
+ export function updateSplitLayout(layout: SplitLayout, command: SplitCommand): SplitLayout {
243
+ const panes = splitPanes(layout.root);
244
+ const pane = 'paneId' in command ? panes.find((item) => item.id === command.paneId) : undefined;
245
+ switch (command.type) {
246
+ case 'focus':
247
+ return pane && layout.focused !== pane.id ? { ...layout, focused: pane.id } : layout;
248
+ case 'maximize':
249
+ return command.paneId === null || pane ? { ...layout, maximized: command.paneId, focused: pane?.id ?? layout.focused } : layout;
250
+ case 'activate':
251
+ return pane?.views.includes(command.viewId)
252
+ ? finish(
253
+ layout,
254
+ replace(layout.root, pane.id, () => ({ ...pane, active: command.viewId })),
255
+ pane.id
256
+ )
257
+ : layout;
258
+ case 'close': {
259
+ const source = panes.find((item) => item.views.includes(command.viewId));
260
+ if (!source) {
261
+ return layout;
262
+ }
263
+ const next = without(source, [command.viewId]);
264
+ return finish(
265
+ layout,
266
+ replace(layout.root, source.id, () => (next.views.length || panes.length === 1 ? next : null))
267
+ );
268
+ }
269
+ case 'closePane':
270
+ return pane
271
+ ? finish(
272
+ layout,
273
+ replace(layout.root, pane.id, () => null)
274
+ )
275
+ : layout;
276
+ case 'insert': {
277
+ if (!pane) {
278
+ return layout;
279
+ }
280
+ const source = panes.find((item) => item.views.includes(command.viewId));
281
+ if (source) {
282
+ return updateSplitLayout(layout, { type: 'move', sourceId: source.id, targetId: pane.id, viewId: command.viewId, index: command.index });
283
+ }
284
+ const views = [...pane.views];
285
+ views.splice(Math.max(0, Math.min(views.length, command.index ?? views.length)), 0, command.viewId);
286
+ return finish(
287
+ layout,
288
+ replace(layout.root, pane.id, () => ({ ...pane, views, active: command.viewId })),
289
+ pane.id
290
+ );
291
+ }
292
+ case 'split': {
293
+ if (
294
+ !pane ||
295
+ findSplitNode(layout.root, command.newPane.id) ||
296
+ findSplitNode(layout.root, command.splitId) ||
297
+ command.newPane.id === command.splitId
298
+ ) {
299
+ return layout;
300
+ }
301
+ const allViews = new Set(panes.flatMap((item) => item.views));
302
+ if (command.newPane.views.some((view) => allViews.has(view)) || new Set(command.newPane.views).size !== command.newPane.views.length) {
303
+ return layout;
304
+ }
305
+ const added = {
306
+ ...command.newPane,
307
+ active: command.newPane.views.includes(command.newPane.active ?? '') ? command.newPane.active : (command.newPane.views[0] ?? null)
308
+ };
309
+ return finish(
310
+ { ...layout, maximized: null },
311
+ replace(layout.root, pane.id, () => beside(pane, added, command.side, command.splitId)),
312
+ added.id
313
+ );
314
+ }
315
+ case 'swap': {
316
+ const source = panes.find((item) => item.id === command.sourceId);
317
+ const target = panes.find((item) => item.id === command.targetId);
318
+ if (
319
+ !source ||
320
+ !target ||
321
+ source === target ||
322
+ source.views.length === 0 ||
323
+ (command.viewId !== undefined && !source.views.includes(command.viewId))
324
+ ) {
325
+ return layout;
326
+ }
327
+ if (command.viewId === undefined || source.views.length === 1) {
328
+ const exchange = (node: SplitNode): SplitNode => {
329
+ if (node.id === source.id) {
330
+ return target;
331
+ }
332
+ if (node.id === target.id) {
333
+ return source;
334
+ }
335
+ return node.type === 'pane' ? node : { ...node, children: node.children.map(exchange) };
336
+ };
337
+ return finish({ ...layout, maximized: null }, exchange(layout.root), source.id);
338
+ }
339
+ const moving = command.viewId;
340
+ const displaced = target.active;
341
+ const sourceNext =
342
+ displaced === null
343
+ ? without(source, [moving])
344
+ : {
345
+ ...source,
346
+ views: source.views.map((view) => (view === moving ? displaced : view)),
347
+ active: source.active === moving ? displaced : source.active
348
+ };
349
+ const targetNext = {
350
+ ...target,
351
+ views: displaced === null ? [moving] : target.views.map((view) => (view === displaced ? moving : view)),
352
+ active: moving
353
+ };
354
+ const root = replace(layout.root, source.id, () => sourceNext)!;
355
+ return finish(
356
+ { ...layout, maximized: null },
357
+ replace(root, target.id, () => targetNext),
358
+ target.id
359
+ );
360
+ }
361
+ case 'move': {
362
+ const source = panes.find((item) => item.id === command.sourceId);
363
+ const target = panes.find((item) => item.id === command.targetId);
364
+ if (!source || !target || (command.viewId !== undefined && !source.views.includes(command.viewId))) {
365
+ return layout;
366
+ }
367
+ const moving = command.viewId === undefined ? source.views : [command.viewId];
368
+ if (!moving.length || (source === target && (command.viewId === undefined || (command.side !== undefined && source.views.length === 1)))) {
369
+ return layout;
370
+ }
371
+ let added: SplitPane | undefined;
372
+ if (command.side) {
373
+ const id = command.viewId === undefined ? source.id : command.newPaneId;
374
+ if (
375
+ !id ||
376
+ !command.splitId ||
377
+ id === command.splitId ||
378
+ findSplitNode(layout.root, command.splitId) ||
379
+ (findSplitNode(layout.root, id) && !(id === source.id && command.viewId === undefined))
380
+ ) {
381
+ return layout;
382
+ }
383
+ added = { type: 'pane', id, views: moving, active: command.viewId ?? source.active };
384
+ }
385
+ const remaining = without(source, moving);
386
+ const root = replace(layout.root, source.id, () => (remaining.views.length || source === target ? remaining : null));
387
+ if (!root) {
388
+ return layout;
389
+ }
390
+ const next = replace(root, target.id, (node) => {
391
+ if (node.type !== 'pane') {
392
+ return node;
393
+ }
394
+ if (added && command.side && command.splitId) {
395
+ return beside(node, added, command.side, command.splitId);
396
+ }
397
+ const gap = Math.max(0, Math.min(target.views.length, command.index ?? target.views.length));
398
+ const lifted = source === target ? target.views.slice(0, gap).filter((view) => moving.includes(view)).length : 0;
399
+ const views = [...node.views];
400
+ views.splice(gap - lifted, 0, ...moving);
401
+ return { ...node, views, active: command.viewId ?? source.active ?? views[0] ?? null };
402
+ });
403
+ return finish({ ...layout, maximized: null }, next, added?.id ?? target.id);
404
+ }
405
+ case 'equalizeAxis': {
406
+ const length = command.length ?? 1;
407
+ const gap = command.gap ?? 0;
408
+ if (layout.root.type === 'pane' || !Number.isFinite(length) || length <= 0 || !Number.isFinite(gap) || gap < 0) {
409
+ return layout;
410
+ }
411
+ return finish(layout, equalizeAxis(layout.root, command.axis, length, gap));
412
+ }
413
+ case 'resize':
414
+ case 'equalize': {
415
+ const node = findSplitNode(layout.root, command.splitId);
416
+ if (!node || node.type !== 'split') {
417
+ return layout;
418
+ }
419
+ let sizes = [...node.sizes];
420
+ if (command.type === 'resize') {
421
+ if (command.sizes.length !== node.children.length || command.sizes.some((size) => !Number.isFinite(size) || size <= 0)) {
422
+ return layout;
423
+ }
424
+ sizes = shares(command.sizes, node.children.length);
425
+ } else if (command.index === undefined) {
426
+ sizes = node.children.map(() => 1 / node.children.length);
427
+ } else {
428
+ const index = command.index;
429
+ if (index < 0 || index >= sizes.length - 1) {
430
+ return layout;
431
+ }
432
+ sizes[index] = sizes[index + 1] = (sizes[index]! + sizes[index + 1]!) / 2;
433
+ }
434
+ return finish(
435
+ layout,
436
+ replace(layout.root, node.id, () => ({ ...node, sizes }))
437
+ );
438
+ }
439
+ }
440
+ }
@@ -0,0 +1,116 @@
1
+ /* The gap above the indicator's tab, so it reads as a tab standing in the bar. */
2
+ export const TAB_TOP_INSET = 4;
3
+
4
+ /* The corner of the indicator's tab, and of its body, which is `rounded-sm` like the other drop indicator. */
5
+ export const TAB_RADIUS = 6;
6
+ export const BODY_RADIUS = 4;
7
+
8
+ /* The stroke is centered on the path, so the path runs half of it inside the box to keep the outer edge on it. */
9
+ export const DROP_STROKE = 2;
10
+
11
+ /*
12
+ * What the drop preview draws, in the grid's coordinates. A tab and a plain rectangle are the same
13
+ * shape: a tab standing on a body, where the rectangle's tab has no height. `x` and `y` are the
14
+ * top left of the outline's box, which for a tab is the cell with its bar.
15
+ */
16
+ export interface DropPreviewShape {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ /* The part of the box above the body that the tab stands in; 0 for a rectangle. */
22
+ barHeight: number;
23
+ /* The tab's left edge and the top of its box, in the same coordinates as `x` and `y`. */
24
+ tabLeft: number;
25
+ tabWidth: number;
26
+ tabTop: number;
27
+ radius: number;
28
+ }
29
+
30
+ export interface PreviewRect {
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ }
36
+
37
+ function coordinate(value: number): string {
38
+ return String(Math.round(value * 100) / 100 + 0);
39
+ }
40
+
41
+ /*
42
+ * The preview for a split or a trade: the rectangle as a tab with no height, parked just inside its
43
+ * top left corner so the corner keeps its radius. It is where a tab target grows out of the rectangle.
44
+ */
45
+ export function rectPreview({ x, y, width, height }: PreviewRect): DropPreviewShape {
46
+ return { x, y, width, height, barHeight: 0, tabLeft: x + BODY_RADIUS, tabWidth: 0, tabTop: y, radius: TAB_RADIUS };
47
+ }
48
+
49
+ /* The preview for a tab: the tab and the body of the cell it lands in, `origin` being the cell's top left in the grid. */
50
+ export function tabPreview(drop: TabDrop, origin: { x: number; y: number }): DropPreviewShape {
51
+ return {
52
+ x: origin.x,
53
+ y: origin.y,
54
+ width: drop.width,
55
+ height: drop.height,
56
+ barHeight: drop.barHeight,
57
+ tabLeft: origin.x + drop.left,
58
+ tabWidth: drop.tabWidth,
59
+ tabTop: origin.y + TAB_TOP_INSET,
60
+ radius: TAB_RADIUS
61
+ };
62
+ }
63
+
64
+ /*
65
+ * The outline of a tab grown into the body under it, as one closed path so no line runs between the
66
+ * two. The commands are the same whatever the numbers are (an arc of radius 0 is a straight line), so
67
+ * the browser can move one outline into another with a transition on `d`. `inset` is how far the
68
+ * path runs inside the box, which is half the stroke, so the outer edge of the line is on the box.
69
+ */
70
+ export function dropPreviewPath({ x, y, width, height, barHeight, tabLeft, tabWidth, tabTop, radius }: DropPreviewShape, inset = DROP_STROKE / 2): string {
71
+ const left = inset;
72
+ const right = width - inset;
73
+ const bottom = height - inset;
74
+ const top = barHeight + inset;
75
+ const tabStart = Math.max(left, Math.min(tabLeft - x + inset, right));
76
+ const tabEnd = Math.max(tabStart, Math.min(tabLeft - x + tabWidth - inset, right));
77
+ const tabY = Math.min(tabTop - y + inset, top);
78
+ const tabCorner = Math.max(0, Math.min(radius - inset, (tabEnd - tabStart) / 2, top - tabY));
79
+ const bodyCorner = Math.max(0, BODY_RADIUS - inset);
80
+ // A tab flush with a side of the box continues that side, so the body has no corner there.
81
+ const cornerLeft = Math.min(bodyCorner, tabStart - left);
82
+ const cornerRight = Math.min(bodyCorner, right - tabEnd);
83
+ const cornerBottom = Math.max(0, Math.min(bodyCorner, (right - left) / 2, (bottom - top) / 2));
84
+ const at = (px: number, py: number): string => `${coordinate(x + px)},${coordinate(y + py)}`;
85
+ const arc = (corner: number, px: number, py: number): string => `A${coordinate(corner)},${coordinate(corner)} 0 0 1 ${at(px, py)}`;
86
+ return [
87
+ `M${at(left + cornerLeft, top)}`,
88
+ `H${coordinate(x + tabStart)}`,
89
+ `V${coordinate(y + tabY + tabCorner)}`,
90
+ arc(tabCorner, tabStart + tabCorner, tabY),
91
+ `H${coordinate(x + tabEnd - tabCorner)}`,
92
+ arc(tabCorner, tabEnd, tabY + tabCorner),
93
+ `V${coordinate(y + top)}`,
94
+ `H${coordinate(x + right - cornerRight)}`,
95
+ arc(cornerRight, right, top + cornerRight),
96
+ `V${coordinate(y + bottom - cornerBottom)}`,
97
+ arc(cornerBottom, right - cornerBottom, bottom),
98
+ `H${coordinate(x + left + cornerBottom)}`,
99
+ arc(cornerBottom, left, bottom - cornerBottom),
100
+ `V${coordinate(y + top + cornerLeft)}`,
101
+ arc(cornerLeft, left + cornerLeft, top),
102
+ 'Z'
103
+ ].join(' ');
104
+ }
105
+
106
+ /* Where a view dragged over a cell's bar would land as a tab, in the cell's own coordinates. */
107
+ export interface TabDrop {
108
+ /* The gap in the strip it lands in, counted in tabs from the left. */
109
+ gap: number;
110
+ left: number;
111
+ tabWidth: number;
112
+ /* The cell as a whole, bar included. */
113
+ width: number;
114
+ height: number;
115
+ barHeight: number;
116
+ }