x4js 2.3.15 → 2.3.17

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.
@@ -0,0 +1,748 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file docklayout.ts
9
+ * @author Etienne Cochard
10
+ *
11
+ * @copyright (c) 2026 R-libre ingenierie
12
+ *
13
+ * Use of this source code is governed by an MIT-style license
14
+ * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
+ **/
16
+
17
+ /**
18
+ * How the panels of a DockingView are laid out: docked, floating or hidden.
19
+ *
20
+ * There are two kinds of panels, which never mix:
21
+ *
22
+ * tools docked in a band along a side of the contents: one above
23
+ * the other, side by side, or as tabs of the same stack;
24
+ * they can also float. Which bands a tool may be in is said
25
+ * by the view (DockKinds.sides): the left and the right
26
+ * ones, unless it says otherwise.
27
+ * contents what is worked on (a document, as the editors of an IDE).
28
+ * They share the room between the bands: side by side, one
29
+ * above the other, or as tabs of the same stack.
30
+ *
31
+ * A layout is plain data, so that it can be saved as it is:
32
+ *
33
+ * left, right the bands of the sides: a width, and a tree of tools.
34
+ * They take the whole height.
35
+ * top, bottom the bands above and under the contents, between those of
36
+ * the sides: a height, and a tree of tools.
37
+ * content the tree of the contents.
38
+ *
39
+ * In a tree, a split puts its children side by side (row)
40
+ * or one above the other (column); a stack holds panels,
41
+ * one tab each.
42
+ *
43
+ * floats the tools that float above the rest, with their rectangle.
44
+ * hidden the panels that are closed. They keep their place, and
45
+ * come back there when shown again.
46
+ *
47
+ * Panels are known by their id only: what they show is the business of the
48
+ * view (dockingview.ts), which also says what may go where. Nothing here
49
+ * changes the layout it is given: every function returns a new one.
50
+ *
51
+ * (this file imports nothing, and knows nothing of the DOM)
52
+ */
53
+
54
+ export type DockSide = "left" | "right" | "top" | "bottom";
55
+
56
+ /** where a panel goes, relative to another one: beside it, or with it as a tab */
57
+ export type DockWhere = DockSide | "tab";
58
+
59
+ /**
60
+ * `size` is the share a child has of its split: the room is divided in
61
+ * proportion. The sizes on screen, in pixels, are written there (see the
62
+ * view); 0 is a share that is not known yet.
63
+ */
64
+
65
+ export interface DockSplit {
66
+ type: "split";
67
+ dir: "row" | "column";
68
+ size: number;
69
+ children: DockNode[];
70
+ }
71
+
72
+ export interface DockStack {
73
+ type: "stack";
74
+ size: number;
75
+ panels: string[];
76
+ active: string;
77
+ }
78
+
79
+ export type DockNode = DockSplit | DockStack;
80
+
81
+ /**
82
+ * The tools docked on one side: `size` is its width in pixels (its height,
83
+ * for the band of the top or of the bottom), `root` is null when it has none.
84
+ */
85
+ export interface DockBand {
86
+ size: number;
87
+ root: DockNode;
88
+ }
89
+
90
+ export interface DockRect {
91
+ x: number;
92
+ y: number;
93
+ w: number;
94
+ h: number;
95
+ }
96
+
97
+ /** a floating panel; its rectangle is relative to the box that holds the layout */
98
+ export interface DockFloat extends DockRect {
99
+ panel: string;
100
+ }
101
+
102
+ /**
103
+ * `top` and `bottom` may be missing: a band that is missing is an empty
104
+ * one. A layout that does not use them does not have them.
105
+ */
106
+
107
+ export interface DockLayout {
108
+ left: DockBand;
109
+ right: DockBand;
110
+ top?: DockBand;
111
+ bottom?: DockBand;
112
+ content: DockNode;
113
+ floats: DockFloat[];
114
+ hidden: string[];
115
+ }
116
+
117
+ /** the panels that exist, by kind */
118
+ export interface DockKinds {
119
+ tools: string[];
120
+ contents: string[];
121
+
122
+ /** the bands a tool may be docked in, by its id; the left and the right ones for a tool that is not there */
123
+ sides?: Record<string, DockSide[]>;
124
+ }
125
+
126
+ /** the bands, in the order they are looked at */
127
+ const SIDES: DockSide[] = ["left", "right", "top", "bottom"];
128
+
129
+ /** where a tool may be docked when nothing says it */
130
+ const USUAL_SIDES: DockSide[] = ["left", "right"];
131
+
132
+ function copy( layout: DockLayout ): DockLayout {
133
+ return structuredClone( layout );
134
+ }
135
+
136
+ /**
137
+ * true for the bands of the top and of the bottom: they lie flat. Their
138
+ * size is a height, and their tools are side by side where those of the
139
+ * left and of the right are one above the other.
140
+ */
141
+
142
+ export function isFlat( side: DockSide ): boolean {
143
+ return side === "top" || side === "bottom";
144
+ }
145
+
146
+ /**
147
+ * The bands a tool may be docked in. A list that is empty counts as none
148
+ * given: a tool has to be somewhere when its layout is lost.
149
+ */
150
+
151
+ export function dockSides( kinds: DockKinds, panel: string ): DockSide[] {
152
+ const given = kinds.sides ? kinds.sides[panel] : null;
153
+
154
+ if( !given ) {
155
+ return USUAL_SIDES;
156
+ }
157
+
158
+ const sides = given.filter( side => SIDES.includes( side ) );
159
+ return sides.length ? sides : USUAL_SIDES;
160
+ }
161
+
162
+ /**
163
+ * the stacks of a tree, from left to right and from top to bottom
164
+ */
165
+
166
+ export function stacksOf( node: DockNode ): DockStack[] {
167
+ if( !node ) {
168
+ return [];
169
+ }
170
+
171
+ return node.type === "stack" ? [node] : node.children.flatMap( child => stacksOf( child ) );
172
+ }
173
+
174
+ function allStacks( layout: DockLayout ): DockStack[] {
175
+ return [...SIDES.flatMap( side => stacksOf( layout[side]?.root ) ), ...stacksOf( layout.content )];
176
+ }
177
+
178
+ function parentOf( root: DockNode, node: DockNode ): DockSplit {
179
+ if( !root || root.type === "stack" ) {
180
+ return null;
181
+ }
182
+
183
+ return root.children.includes( node ) ? root : root.children.map( child => parentOf( child, node ) ).find( p => !!p ) ?? null;
184
+ }
185
+
186
+ /**
187
+ * Tidy a tree: no stack without panel, no split with a single child, no
188
+ * split straight inside a split of the same direction.
189
+ */
190
+
191
+ function normalize( node: DockNode ): DockNode {
192
+ if( !node ) {
193
+ return null;
194
+ }
195
+
196
+ if( node.type === "stack" ) {
197
+ if( node.panels.length === 0 ) {
198
+ return null;
199
+ }
200
+
201
+ if( !node.panels.includes( node.active ) ) {
202
+ node.active = node.panels[0];
203
+ }
204
+
205
+ return node;
206
+ }
207
+
208
+ node.children = node.children.flatMap( child => {
209
+ const tidy = normalize( child );
210
+ if( !tidy ) {
211
+ return [];
212
+ }
213
+
214
+ return tidy.type === "split" && tidy.dir === node.dir ? tidy.children : [tidy];
215
+ } );
216
+
217
+ if( node.children.length === 0 ) {
218
+ return null;
219
+ }
220
+
221
+ // the only child takes the place of the split
222
+ if( node.children.length === 1 ) {
223
+ node.children[0].size = node.size;
224
+ return node.children[0];
225
+ }
226
+
227
+ return node;
228
+ }
229
+
230
+ function tidy( layout: DockLayout ): void {
231
+ layout.content = normalize( layout.content );
232
+
233
+ for( const side of SIDES ) {
234
+ const band = layout[side];
235
+ if( band ) {
236
+ band.root = normalize( band.root );
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * take a panel out of where it is; the layout is not tidied
243
+ */
244
+
245
+ function detach( layout: DockLayout, panel: string ): void {
246
+ // A tool that was beside others, alone in its stack, takes its width
247
+ // with it: its band gets narrower by its share of the row. In a band
248
+ // that lies flat it is its height, and its share of the column.
249
+ for( const side of SIDES ) {
250
+ const band = layout[side];
251
+ const root = band?.root;
252
+ const across = isFlat( side ) ? "column" : "row";
253
+ const at = root?.type === "split" && root.dir === across ? root.children.findIndex( child => child.type === "stack" && child.panels.length === 1 && child.panels[0] === panel ) : -1;
254
+
255
+ if( root?.type === "split" && at >= 0 ) {
256
+ const shares = sharesOf( root.children );
257
+ band.size = Math.round( band.size * ( 1 - shares[at] / shares.reduce( ( a, b ) => a + b ) ) );
258
+ }
259
+ }
260
+
261
+ for( const stack of allStacks( layout ) ) {
262
+ stack.panels = stack.panels.filter( id => id !== panel );
263
+ }
264
+
265
+ layout.floats = layout.floats.filter( float => float.panel !== panel );
266
+ }
267
+
268
+ /**
269
+ * every panel of a layout: docked, floating, hidden or not
270
+ */
271
+
272
+ export function dockPanels( layout: DockLayout ): string[] {
273
+ return [...allStacks( layout ).flatMap( stack => stack.panels ), ...layout.floats.map( float => float.panel )];
274
+ }
275
+
276
+ /**
277
+ * the stack a panel is docked in, null when it floats or is unknown
278
+ */
279
+
280
+ export function stackOf( layout: DockLayout, panel: string ): DockStack {
281
+ return allStacks( layout ).find( stack => stack.panels.includes( panel ) ) ?? null;
282
+ }
283
+
284
+ /**
285
+ * the band a panel is docked in, null when it is a content, floats or is unknown
286
+ */
287
+
288
+ export function bandOf( layout: DockLayout, panel: string ): DockSide {
289
+ return SIDES.find( side => stacksOf( layout[side]?.root ).some( stack => stack.panels.includes( panel ) ) ) ?? null;
290
+ }
291
+
292
+ export function floatOf( layout: DockLayout, panel: string ): DockFloat {
293
+ return layout.floats.find( float => float.panel === panel ) ?? null;
294
+ }
295
+
296
+ /**
297
+ * the panels of a stack that are not hidden
298
+ */
299
+
300
+ export function visiblePanels( stack: DockStack, hidden: string[] ): string[] {
301
+ return stack.panels.filter( id => !hidden.includes( id ) );
302
+ }
303
+
304
+ /**
305
+ * the panel a stack shows: its active one, or the first that is not hidden
306
+ */
307
+
308
+ export function shownPanel( stack: DockStack, hidden: string[] ): string {
309
+ const visible = visiblePanels( stack, hidden );
310
+ return visible.includes( stack.active ) ? stack.active : visible[0] ?? null;
311
+ }
312
+
313
+ /**
314
+ * false for a stack whose panels are all hidden, and for a split of such stacks
315
+ */
316
+
317
+ export function isShown( node: DockNode, hidden: string[] ): boolean {
318
+ return stacksOf( node ).some( stack => visiblePanels( stack, hidden ).length > 0 );
319
+ }
320
+
321
+ /**
322
+ * The share of each node among those that are shown together, as numbers
323
+ * that only mean something compared to each other. A node whose size is
324
+ * not known gets the average of the others.
325
+ */
326
+
327
+ export function sharesOf( nodes: DockNode[] ): number[] {
328
+ const known = nodes.map( node => node.size ).filter( size => size > 0 );
329
+ const average = known.length ? known.reduce( ( a, b ) => a + b ) / known.length : 1;
330
+
331
+ return nodes.map( node => node.size > 0 ? node.size : average );
332
+ }
333
+
334
+ /**
335
+ * Dock a panel beside another one, or with it as a tab.
336
+ *
337
+ * `target` is a panel of the stack that receives it. Beside it, the panel
338
+ * takes half of its room. But a tool put on the left or on the right of
339
+ * another one does not make it narrower: with `width`, the band gets
340
+ * wider by that much, which is what the tool gets.
341
+ *
342
+ * In a band that lies flat (top, bottom) it is the other way round: a
343
+ * tool put above or under another one makes the band higher, by `width`,
344
+ * which is then a height.
345
+ */
346
+
347
+ export function dockMove( layout: DockLayout, panel: string, target: string, where: DockWhere, width = 0 ): DockLayout {
348
+ const result = copy( layout );
349
+ const dest = stackOf( result, target );
350
+
351
+ if( !dest ) {
352
+ return layout;
353
+ }
354
+
355
+ if( where === "tab" ) {
356
+ if( !dest.panels.includes( panel ) ) {
357
+ detach( result, panel );
358
+ dest.panels.push( panel );
359
+ }
360
+
361
+ dest.active = panel;
362
+ tidy( result );
363
+ return result;
364
+ }
365
+
366
+ // beside the stack it is alone in: nothing to do
367
+ if( dest.panels.length === 1 && dest.panels[0] === panel ) {
368
+ return layout;
369
+ }
370
+
371
+ const side = bandOf( result, target );
372
+ const band = side ? result[side] : null;
373
+
374
+ detach( result, panel );
375
+
376
+ const dir = where === "left" || where === "right" ? "row" : "column";
377
+ const before = where === "left" || where === "top";
378
+
379
+ // across the band: the direction in which it grows
380
+ const wider = !!band && dir === ( isFlat( side ) ? "column" : "row" ) && width > 0;
381
+
382
+ const half = dest.size / 2;
383
+ const fresh: DockStack = { type: "stack", size: wider ? width : half, panels: [panel], active: panel };
384
+ const parent = parentOf( band ? band.root : result.content, dest );
385
+
386
+ // the parent already cuts that way: one more child
387
+ if( parent && parent.dir === dir ) {
388
+ dest.size = wider ? dest.size : half;
389
+ parent.children.splice( parent.children.indexOf( dest ) + ( before ? 0 : 1 ), 0, fresh );
390
+ }
391
+ else {
392
+ // the stack is cut in two: a split takes its place, its two children share it
393
+ const split: DockSplit = { type: "split", dir, size: dest.size, children: before ? [fresh, dest] : [dest, fresh] };
394
+
395
+ dest.size = wider ? band.size : 0;
396
+ fresh.size = wider ? width : 0;
397
+
398
+ if( parent ) {
399
+ parent.children[parent.children.indexOf( dest )] = split;
400
+ }
401
+ else if( band ) {
402
+ band.root = split;
403
+ }
404
+ else {
405
+ result.content = split;
406
+ }
407
+ }
408
+
409
+ if( wider ) {
410
+ band.size += width;
411
+ }
412
+
413
+ tidy( result );
414
+ return result;
415
+ }
416
+
417
+ /**
418
+ * Dock a tool in a band, after the tools that are already there: under
419
+ * them on the left and on the right, on their right in a band that lies
420
+ * flat (top, bottom). `size` is the width the band gets when it was empty;
421
+ * its height, when it lies flat.
422
+ */
423
+
424
+ export function dockBand( layout: DockLayout, panel: string, side: DockSide, size = 0 ): DockLayout {
425
+ const result = copy( layout );
426
+ const band = result[side] ??= { size: 0, root: null };
427
+
428
+ detach( result, panel );
429
+ tidy( result );
430
+
431
+ // along the band: the direction its tools follow each other in
432
+ const along = isFlat( side ) ? "row" : "column";
433
+
434
+ const fresh: DockStack = { type: "stack", size: 0, panels: [panel], active: panel };
435
+ const root = band.root;
436
+
437
+ if( !root ) {
438
+ band.root = fresh;
439
+
440
+ if( size > 0 ) {
441
+ band.size = size;
442
+ }
443
+ }
444
+ else if( root.type === "split" && root.dir === along ) {
445
+ // as big as the others, on average
446
+ fresh.size = sharesOf( root.children ).reduce( ( a, b ) => a + b ) / root.children.length;
447
+ root.children.push( fresh );
448
+ }
449
+ else {
450
+ // what was there and the tool share the band
451
+ root.size = 0;
452
+ band.root = { type: "split", dir: along, size: 0, children: [root, fresh] };
453
+ }
454
+
455
+ return result;
456
+ }
457
+
458
+ /**
459
+ * A content that is nowhere yet: a tab of the first stack of contents.
460
+ */
461
+
462
+ function addContent( layout: DockLayout, panel: string ): DockLayout {
463
+ const first = stacksOf( layout.content )[0];
464
+ if( first ) {
465
+ return dockMove( layout, panel, first.panels[0], "tab" );
466
+ }
467
+
468
+ const result = copy( layout );
469
+ result.content = { type: "stack", size: 0, panels: [panel], active: panel };
470
+ return result;
471
+ }
472
+
473
+ /**
474
+ * Make a panel float, or move the one that already does. It comes above
475
+ * the other floating panels.
476
+ */
477
+
478
+ export function dockFloat( layout: DockLayout, panel: string, rect: DockRect ): DockLayout {
479
+ const result = copy( layout );
480
+
481
+ detach( result, panel );
482
+ tidy( result );
483
+ result.floats.push( { panel, x: rect.x, y: rect.y, w: rect.w, h: rect.h } );
484
+
485
+ return result;
486
+ }
487
+
488
+ /**
489
+ * Hide a panel or show it again, where it was.
490
+ */
491
+
492
+ export function dockShow( layout: DockLayout, panel: string, show = true ): DockLayout {
493
+ const result = copy( layout );
494
+
495
+ result.hidden = result.hidden.filter( id => id !== panel );
496
+ if( !show ) {
497
+ result.hidden.push( panel );
498
+ }
499
+
500
+ // a panel that comes back is the one its stack shows
501
+ const stack = stackOf( result, panel );
502
+ if( show && stack ) {
503
+ stack.active = panel;
504
+ }
505
+
506
+ return result;
507
+ }
508
+
509
+ /**
510
+ * the tab of a panel comes in front of the others of its stack
511
+ */
512
+
513
+ export function dockActivate( layout: DockLayout, panel: string ): DockLayout {
514
+ const result = copy( layout );
515
+ const stack = stackOf( result, panel );
516
+
517
+ if( stack ) {
518
+ stack.active = panel;
519
+ }
520
+
521
+ return result;
522
+ }
523
+
524
+
525
+ // :: RESTORE :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
526
+
527
+ function isObject( value: unknown ): value is Record<string, unknown> {
528
+ return typeof value === "object" && value !== null;
529
+ }
530
+
531
+ /** a number of a saved layout: 0 when it is missing or not a size */
532
+ function sizeOf( from: Record<string, unknown>, name: string ): number {
533
+ const value = name in from ? from[name] : 0;
534
+ return typeof value === "number" && Number.isFinite( value ) && value > 0 ? value : 0;
535
+ }
536
+
537
+ /**
538
+ * A layout rebuilt from something that was saved, or null when it is not a
539
+ * layout or holds no panel. Only what is understood is kept, and only the
540
+ * panels that are known, each once and where its kind may be: a tool in a
541
+ * band it may be docked in, or floating; a content among the contents.
542
+ *
543
+ * (a field is read only once it is known to be there: what was saved may be
544
+ * a state of x4, which reports the reading of a missing property)
545
+ */
546
+
547
+ function sanitize( saved: unknown, kinds: DockKinds ): DockLayout {
548
+ if( !isObject( saved ) ) {
549
+ return null;
550
+ }
551
+
552
+ const seen = new Set<string>( );
553
+
554
+ const known = ( among: string[] ) => ( id: unknown ): id is string => {
555
+ if( typeof id !== "string" || !among.includes( id ) || seen.has( id ) ) {
556
+ return false;
557
+ }
558
+
559
+ seen.add( id );
560
+ return true;
561
+ };
562
+
563
+ const stack = ( from: unknown, among: string[] ): DockStack => {
564
+ if( !isObject( from ) || !( "panels" in from ) || !Array.isArray( from.panels ) ) {
565
+ return null;
566
+ }
567
+
568
+ const ids = [...from.panels].filter( known( among ) );
569
+ const active = "active" in from && ids.includes( from.active as string ) ? from.active as string : ids[0];
570
+
571
+ return ids.length ? { type: "stack", size: sizeOf( from, "size" ), panels: ids, active } : null;
572
+ };
573
+
574
+ const node = ( from: unknown, among: string[] ): DockNode => {
575
+ if( !isObject( from ) || !( "type" in from ) ) {
576
+ return null;
577
+ }
578
+
579
+ if( from.type === "stack" ) {
580
+ return stack( from, among );
581
+ }
582
+
583
+ if( from.type === "split" && "children" in from && Array.isArray( from.children ) ) {
584
+ const children = [...from.children].map( child => node( child, among ) ).filter( child => !!child );
585
+ const dir = "dir" in from && from.dir === "column" ? "column" : "row";
586
+
587
+ return { type: "split", dir, size: sizeOf( from, "size" ), children };
588
+ }
589
+
590
+ return null;
591
+ };
592
+
593
+ // a band only keeps the tools that may be docked in it
594
+ const band = ( name: DockSide ): DockBand => {
595
+ const from = name in saved ? saved[name] : null;
596
+ if( !isObject( from ) ) {
597
+ return { size: 0, root: null };
598
+ }
599
+
600
+ const allowed = kinds.tools.filter( id => dockSides( kinds, id ).includes( name ) );
601
+ return { size: sizeOf( from, "size" ), root: normalize( "root" in from ? node( from.root, allowed ) : null ) };
602
+ };
603
+
604
+ const left = band( "left" );
605
+ const right = band( "right" );
606
+ const top = band( "top" );
607
+ const bottom = band( "bottom" );
608
+ const content = normalize( "content" in saved ? node( saved.content, kinds.contents ) : null );
609
+
610
+ const floats: DockFloat[] = [];
611
+ const hidden: string[] = [];
612
+ const floating = known( kinds.tools );
613
+
614
+ if( "floats" in saved && Array.isArray( saved.floats ) ) {
615
+ for( const float of [...saved.floats] ) {
616
+ if( isObject( float ) && "panel" in float && floating( float.panel ) ) {
617
+ floats.push( { panel: float.panel, x: sizeOf( float, "x" ), y: sizeOf( float, "y" ), w: sizeOf( float, "w" ), h: sizeOf( float, "h" ) } );
618
+ }
619
+ }
620
+ }
621
+
622
+ if( "hidden" in saved && Array.isArray( saved.hidden ) ) {
623
+ for( const id of [...saved.hidden] ) {
624
+ if( typeof id === "string" && seen.has( id ) && !hidden.includes( id ) ) {
625
+ hidden.push( id );
626
+ }
627
+ }
628
+ }
629
+
630
+ // nothing in it that is known: not worth more than no layout at all
631
+ if( seen.size === 0 ) {
632
+ return null;
633
+ }
634
+
635
+ // the bands of the top and of the bottom are only there when they hold
636
+ // something: a layout that does not use them is as it always was
637
+ const layout: DockLayout = { left, right, content, floats, hidden };
638
+
639
+ if( top.root ) {
640
+ layout.top = top;
641
+ }
642
+
643
+ if( bottom.root ) {
644
+ layout.bottom = bottom;
645
+ }
646
+
647
+ return layout;
648
+ }
649
+
650
+ /**
651
+ * The layout to start with: the one that was saved if it can be used,
652
+ * otherwise the default one.
653
+ *
654
+ * `kinds` are the panels that exist today. A saved layout may be older
655
+ * than the application: the panels it does not know are added where the
656
+ * default layout has them, those that are gone are dropped. So is a tool
657
+ * that is in a band it may not be docked in anymore: it goes where the
658
+ * default layout has it.
659
+ */
660
+
661
+ export function dockRestore( saved: unknown, kinds: DockKinds, defaults: DockLayout ): DockLayout {
662
+ const usual = sanitize( defaults, kinds ) ?? { left: { size: 0, root: null }, right: { size: 0, root: null }, content: null, floats: [], hidden: [] };
663
+ let layout = sanitize( saved, kinds ) ?? usual;
664
+
665
+ for( const panel of [...kinds.contents, ...kinds.tools] ) {
666
+ if( dockPanels( layout ).includes( panel ) ) {
667
+ continue;
668
+ }
669
+
670
+ // where the default layout has it; otherwise on the right if it may, or in the first band it may be in
671
+ const allowed = dockSides( kinds, panel );
672
+ const float = floatOf( usual, panel );
673
+ const side = bandOf( usual, panel ) ?? ( allowed.includes( "right" ) ? "right" : allowed[0] );
674
+
675
+ if( kinds.contents.includes( panel ) ) {
676
+ layout = addContent( layout, panel );
677
+ }
678
+ else if( float ) {
679
+ layout = dockFloat( layout, panel, float );
680
+ }
681
+ else {
682
+ // as wide as the default layout has that band, when it has it
683
+ const band = usual[side];
684
+ layout = dockBand( layout, panel, side, band ? band.size : 0 );
685
+ }
686
+
687
+ if( usual.hidden.includes( panel ) ) {
688
+ layout = dockShow( layout, panel, false );
689
+ }
690
+ }
691
+
692
+ return layout;
693
+ }
694
+
695
+
696
+ // :: GEOMETRY ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
697
+
698
+ /**
699
+ * Where a point falls in a rectangle: near one of its sides, in a band of
700
+ * `band` pixels (a third of the rectangle at most), or in its "center".
701
+ * null when it is outside.
702
+ */
703
+
704
+ export function dropZone( rect: DockRect, x: number, y: number, band: number ): DockSide | "center" {
705
+ if( x < rect.x || y < rect.y || x >= rect.x + rect.w || y >= rect.y + rect.h ) {
706
+ return null;
707
+ }
708
+
709
+ const bandX = Math.min( band, rect.w / 3 );
710
+ const bandY = Math.min( band, rect.h / 3 );
711
+
712
+ // how deep the point is in each band: 0 on the side, 1 where the band ends
713
+ const depths: [DockSide, number][] = [
714
+ ["left", ( x - rect.x ) / bandX],
715
+ ["right", ( rect.x + rect.w - x ) / bandX],
716
+ ["top", ( y - rect.y ) / bandY],
717
+ ["bottom", ( rect.y + rect.h - y ) / bandY],
718
+ ];
719
+
720
+ const [side, depth] = depths.reduce( ( a, b ) => b[1] < a[1] ? b : a );
721
+ return depth < 1 ? side : "center";
722
+ }
723
+
724
+ /**
725
+ * the part of a rectangle a panel takes when it is docked on one of its sides
726
+ */
727
+
728
+ export function sideRect( rect: DockRect, side: DockSide, size: number ): DockRect {
729
+ switch( side ) {
730
+ case "left": return { x: rect.x, y: rect.y, w: size, h: rect.h };
731
+ case "right": return { x: rect.x + rect.w - size, y: rect.y, w: size, h: rect.h };
732
+ case "top": return { x: rect.x, y: rect.y, w: rect.w, h: size };
733
+ case "bottom": return { x: rect.x, y: rect.y + rect.h - size, w: rect.w, h: size };
734
+ }
735
+ }
736
+
737
+ /**
738
+ * A rectangle brought back inside another one, as far as needed for its
739
+ * top-left part to stay there: `keep` pixels of it, so that a floating
740
+ * panel can always be caught by its title.
741
+ */
742
+
743
+ export function keepInside( rect: DockRect, within: DockRect, keep: number ): DockRect {
744
+ const x = Math.max( within.x, Math.min( rect.x, within.x + within.w - keep ) );
745
+ const y = Math.max( within.y, Math.min( rect.y, within.y + within.h - keep ) );
746
+
747
+ return { x, y, w: rect.w, h: rect.h };
748
+ }