x4js 2.3.15 → 2.3.16

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,899 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file dockingview.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
+ import { class_ns } from '../../core/core_tools';
18
+ import { Component, ComponentEvent, ComponentEvents, ComponentProps } from '../../core/component';
19
+ import { EventCallback } from '../../core/core_events';
20
+ import { CSizer } from '../sizers/sizer';
21
+
22
+ import { bandOf, dockActivate, dockBand, dockFloat, dockMove, dockRestore, dockShow, dropZone, floatOf, isShown, keepInside, sharesOf, shownPanel, sideRect, stackOf, stacksOf, visiblePanels } from './docklayout';
23
+ import { DockFloat, DockKinds, DockLayout, DockNode, DockRect, DockStack } from './docklayout';
24
+
25
+ import "./dockingview.module.scss";
26
+
27
+ // the layout is given and read by the host: its types go with the component
28
+ export type { DockLayout, DockBand, DockNode, DockSplit, DockStack, DockFloat } from './docklayout';
29
+
30
+ /** under this distance, in pixels, a press on a title is a click, not a drag */
31
+ const DRAG_SLOP = 4;
32
+
33
+ /** depth of the zone along a side of a panel where a drop docks beside it, in pixels */
34
+ const SIDE_BAND = 48;
35
+
36
+ /** smallest size a sizer gives to a docked panel, in pixels (the stylesheet has the same) */
37
+ const MIN_SIZE = 48;
38
+
39
+ /** what a sizer always leaves to the contents, between the two bands */
40
+ const MIN_AREA = 80;
41
+
42
+ /** width of a band whose layout gives none */
43
+ const BAND_SIZE = 200;
44
+
45
+ /** smallest size of a panel that starts to float */
46
+ const FLOAT_MIN_W = 160;
47
+ const FLOAT_MIN_H = 120;
48
+
49
+ /** what always stays inside the box of a floating panel, in pixels: its title can be caught */
50
+ const FLOAT_KEEP = 40;
51
+
52
+ /** where a title is held when its panel leaves a dock to float, from its top-left corner */
53
+ const FLOAT_GRAB = { x: 30, y: 12 };
54
+
55
+ export interface DockPanel {
56
+ id: string;
57
+
58
+ /** shown in its tab; the id when missing */
59
+ title?: string;
60
+
61
+ content: Component;
62
+
63
+ /** "tool" when missing */
64
+ kind?: "tool" | "content";
65
+
66
+ /** a tool: instead of what the box says. A content can be closed only if it says so. */
67
+ closable?: boolean;
68
+
69
+ /** a tool: instead of what the box says. A content never floats. */
70
+ floating?: boolean;
71
+ }
72
+
73
+ export interface DockingViewProps extends ComponentProps {
74
+ panels: DockPanel[];
75
+
76
+ /** the layout to start with, and to come back to (resetLayout) */
77
+ layout: DockLayout;
78
+
79
+ /** tools can float above the rest */
80
+ floating?: boolean;
81
+
82
+ /** tools have a button that hides them (see showPanel) */
83
+ closable?: boolean;
84
+
85
+ /**
86
+ * A name: the layout is then remembered under it, in the storage of
87
+ * the application, and found again the next time.
88
+ */
89
+ persist?: string;
90
+
91
+ layoutChange?: EventCallback<ComponentEvent>;
92
+ }
93
+
94
+ interface DockingViewEvents extends ComponentEvents {
95
+ /** the user, or the host, changed the layout (see getState) */
96
+ layoutChange: ComponentEvent;
97
+ }
98
+
99
+ /** what happens if the panel that is dragged is dropped now */
100
+ interface Drop {
101
+ rect: DockRect; // where it will be, in the box
102
+ apply: ( ) => DockLayout;
103
+ }
104
+
105
+ interface Drag {
106
+ panel: string;
107
+ from: { x: number, y: number }; // the pointer when the title was pressed
108
+ grab: { x: number, y: number }; // from the corner of the panel to the pointer, when it floats
109
+ moving: boolean; // the pointer went far enough: it is not a click anymore
110
+ drop: Drop;
111
+ }
112
+
113
+ /**
114
+ * A view whose panels the user arranges by dragging their title.
115
+ *
116
+ * The host gives the panels and a layout to start with (docklayout.ts):
117
+ *
118
+ * @example
119
+ * new DockingView( {
120
+ * floating: true,
121
+ * closable: true,
122
+ * persist: "myapp.layout",
123
+ * panels: [
124
+ * { id: "tools", title: "Tools", content: tools },
125
+ * { id: "page", kind: "content", content: editor },
126
+ * ],
127
+ * layout: {
128
+ * left: { size: 180, root: { type: "stack", size: 0, panels: ["tools"], active: "tools" } },
129
+ * right: { size: 0, root: null },
130
+ * content: { type: "stack", size: 0, panels: ["page"], active: "page" },
131
+ * floats: [],
132
+ * hidden: [],
133
+ * },
134
+ * } );
135
+ *
136
+ * There are two kinds of panels, which never mix.
137
+ *
138
+ * A tool is docked in a band, on the left or on the right. Its title
139
+ * dragged to a side of another tool is put beside it, above or under it;
140
+ * to its middle, it becomes one of its tabs; to the left or to the right
141
+ * of the contents, it joins the band of that side. With `floating`, a tool
142
+ * dropped anywhere else floats above the rest. With `closable`, its title
143
+ * has a button that hides it; showPanel() brings it back.
144
+ *
145
+ * A content is what is worked on (a document, as the editors of an IDE).
146
+ * Contents share the room between the bands: a title dragged to a side of
147
+ * another content is put beside it, to its middle it becomes one of its
148
+ * tabs. A content alone has no title.
149
+ *
150
+ * The view only works on the layout, which does all the reasoning. It makes
151
+ * its boxes again each time the layout changes, around what the panels
152
+ * show, which is never built twice.
153
+ *
154
+ * @cssvar
155
+ * ```
156
+ * --dock-line
157
+ * --dock-accent
158
+ * --dock-title-background
159
+ * --dock-title-background-hover
160
+ * --dock-title-color
161
+ * --dock-title-color-active
162
+ * --dock-float-background
163
+ * --dock-float-shadow
164
+ * ```
165
+ */
166
+
167
+ @class_ns( "x4" )
168
+ export class DockingView extends Component<DockingViewProps, DockingViewEvents> {
169
+
170
+ private layout: DockLayout;
171
+ private saved: { layout: DockLayout } = null; // the state that remembers it
172
+
173
+ // what each panel shows, in a box of its own that is moved from place to place
174
+ private pages = new Map<string, Component>( );
175
+
176
+ // the boxes of the layout as it is shown now
177
+ private nodes = new Map<DockNode, Component>( );
178
+ private bands = new Map<"left" | "right", Component>( );
179
+ private floats = new Map<string, Component>( );
180
+ private area: Component; // the room of the contents, between the bands
181
+
182
+ private frame: Component; // holds the boxes of the layout
183
+ private fills: ( ( ) => void )[] = []; // puts the pages in the boxes that were just made
184
+ private park: Component; // where the pages of the hidden panels wait
185
+ private preview: Component; // shows where the panel that is dragged will go
186
+ private grip: Component; // holds the pointer during a drag
187
+
188
+ private drag: Drag = null;
189
+
190
+ constructor( props: DockingViewProps ) {
191
+ super( props );
192
+
193
+ this.mapPropEvents( props, "layoutChange" );
194
+
195
+ for( const panel of props.panels ) {
196
+ this.pages.set( panel.id, new Component( { cls: "dock-page", content: panel.content } ) );
197
+ }
198
+
199
+ this.frame = new Component( { cls: "dock-frame" } );
200
+ this.park = new Component( { cls: "dock-park", hidden: true, content: [...this.pages.values( )] } );
201
+ this.preview = new Component( { cls: "dock-preview", hidden: true } );
202
+
203
+ // The pointer of a drag is not captured by the box itself: a handler
204
+ // here would be called for every pointer event of what the panels show.
205
+ this.grip = new Component( {
206
+ cls: "dock-grip",
207
+ dom_events: {
208
+ pointermove: ev => this.onDragMove( ev ),
209
+ pointerup: ev => this.onDragEnd( ev, true ),
210
+ pointercancel: ev => this.onDragEnd( ev, false ),
211
+ },
212
+ } );
213
+
214
+ if( props.persist ) {
215
+ this.saved = this.loadPState( props.persist, { layout: null } );
216
+ }
217
+
218
+ this.layout = dockRestore( this.saved?.layout ?? null, this.kinds( ), props.layout );
219
+
220
+ // the box got its size, or another one: the floating panels must stay in it
221
+ this.addDOMEvent( "resized", ( ) => this.placeFloats( ) );
222
+
223
+ this.setContent( [this.frame, this.preview, this.grip, this.park] );
224
+ this.rebuild( );
225
+ }
226
+
227
+ // :: LAYOUT ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
228
+
229
+ /**
230
+ * the layout as it is now: plain data, to be saved and given back to setState
231
+ */
232
+
233
+ getState( ): DockLayout {
234
+ this.measure( );
235
+ return structuredClone( this.layout );
236
+ }
237
+
238
+ /**
239
+ * A layout that was saved. What it does not say (a panel added since)
240
+ * comes from the layout of the props, and so does everything when it
241
+ * cannot be used.
242
+ */
243
+
244
+ setState( layout: unknown ): void {
245
+ this.apply( dockRestore( layout, this.kinds( ), this.props.layout ) );
246
+ }
247
+
248
+ /**
249
+ * back to the layout of the props
250
+ */
251
+
252
+ resetLayout( ): void {
253
+ this.setState( null );
254
+ }
255
+
256
+ /**
257
+ * Hide a panel, or show it again where it was.
258
+ */
259
+
260
+ showPanel( id: string, show = true ): void {
261
+ if( show !== this.isPanelVisible( id ) ) {
262
+ this.apply( dockShow( this.layout, id, show ) );
263
+ }
264
+ }
265
+
266
+ isPanelVisible( id: string ): boolean {
267
+ return this.pages.has( id ) && !this.layout.hidden.includes( id );
268
+ }
269
+
270
+ private panel( id: string ): DockPanel {
271
+ return this.props.panels.find( panel => panel.id === id );
272
+ }
273
+
274
+ private isTool( id: string ): boolean {
275
+ return this.panel( id ).kind !== "content";
276
+ }
277
+
278
+ private kinds( ): DockKinds {
279
+ const ids = this.props.panels.map( panel => panel.id );
280
+ return { tools: ids.filter( id => this.isTool( id ) ), contents: ids.filter( id => !this.isTool( id ) ) };
281
+ }
282
+
283
+ private mayFloat( id: string ): boolean {
284
+ return this.isTool( id ) && ( this.panel( id ).floating ?? this.props.floating ?? false );
285
+ }
286
+
287
+ private mayClose( id: string ): boolean {
288
+ return this.panel( id ).closable ?? ( this.isTool( id ) && !!this.props.closable );
289
+ }
290
+
291
+ private apply( layout: DockLayout ): void {
292
+ if( layout === this.layout ) {
293
+ return;
294
+ }
295
+
296
+ this.layout = layout;
297
+ this.rebuild( );
298
+ this.changed( );
299
+ }
300
+
301
+ /**
302
+ * the layout changed, by its shape or by a size
303
+ */
304
+
305
+ private changed( ): void {
306
+ this.measure( );
307
+
308
+ // a copy: the state must not see what is done to the layout later
309
+ if( this.saved ) {
310
+ this.saved.layout = structuredClone( this.layout );
311
+ }
312
+
313
+ this.fire( "layoutChange", {} );
314
+ }
315
+
316
+ // :: BUILDING ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
317
+
318
+ /**
319
+ * Make the boxes of the layout again: the band of the left, the room
320
+ * of the contents, the band of the right, and the floating tools.
321
+ *
322
+ * What the panels show is not built again: their pages are moved. They
323
+ * go straight from a place in the document to another one, the park
324
+ * first, then the new boxes once these are in the document: x4 then
325
+ * sees a move, not something that leaves (it would clean it up) nor
326
+ * something new. Those of the hidden panels stay in the park.
327
+ */
328
+
329
+ private rebuild( ): void {
330
+ const { content, floats, hidden } = this.layout;
331
+
332
+ // a move takes the keyboard from what has it: given back below
333
+ const focused = document.activeElement;
334
+
335
+ this.park.appendContent( [...this.pages.values( )] );
336
+ this.nodes.clear( );
337
+ this.bands.clear( );
338
+ this.floats.clear( );
339
+ this.fills = [];
340
+
341
+ // a content alone has no title: there is nowhere to drag it to
342
+ const titled = this.kinds( ).contents.filter( id => !hidden.includes( id ) ).length > 1;
343
+
344
+ this.area = new Component( {
345
+ cls: "dock-area",
346
+ content: content && isShown( content, hidden ) ? this.makeNode( content, titled ) : null,
347
+ } );
348
+
349
+ this.frame.setContent( [
350
+ ...this.makeBand( "left" ),
351
+ this.area,
352
+ ...this.makeBand( "right" ),
353
+ ...floats.filter( float => !hidden.includes( float.panel ) ).map( float => this.makeFloat( float ) ),
354
+ ] );
355
+
356
+ this.fills.forEach( fill => fill( ) );
357
+ this.placeFloats( );
358
+
359
+ if( focused instanceof HTMLElement && this.dom.contains( focused ) ) {
360
+ focused.focus( );
361
+ }
362
+ }
363
+
364
+ /**
365
+ * A band and the sizer that changes its width, in the order they are
366
+ * shown; nothing when none of its tools is shown.
367
+ */
368
+
369
+ private makeBand( side: "left" | "right" ): Component[] {
370
+ const band = this.layout[side];
371
+
372
+ if( !band.root || !isShown( band.root, this.layout.hidden ) ) {
373
+ return [];
374
+ }
375
+
376
+ const box = new Component( {
377
+ cls: `dock-band ${side}`,
378
+ width: band.size || BAND_SIZE,
379
+ content: this.makeNode( band.root, true ),
380
+ } );
381
+
382
+ this.bands.set( side, box );
383
+
384
+ // what the band gets is taken from the contents
385
+ const sign = side === "left" ? 1 : -1;
386
+ let size = 0;
387
+ let max = 0;
388
+
389
+ const sizer = this.makeSizer( true, ( ) => {
390
+ size = band.size;
391
+ max = size + Math.max( 0, this.area.getBoundingRect( ).width - MIN_AREA );
392
+ }, delta => {
393
+ band.size = Math.round( Math.max( MIN_SIZE, Math.min( size + delta * sign, max ) ) );
394
+ box.setWidth( band.size );
395
+ } );
396
+
397
+ return side === "left" ? [box, sizer] : [sizer, box];
398
+ }
399
+
400
+ private makeNode( node: DockNode, titled: boolean ): Component {
401
+ const box = node.type === "stack"
402
+ ? this.makeStack( node, titled )
403
+ : new Component( { cls: `dock-split ${node.dir}`, content: this.makeShares( node.children, node.dir === "row", titled ) } );
404
+
405
+ this.nodes.set( node, box );
406
+ return box;
407
+ }
408
+
409
+ /**
410
+ * The children of a split: those that are shown, each with its share
411
+ * of the room, and a sizer between two of them.
412
+ */
413
+
414
+ private makeShares( nodes: DockNode[], horizontal: boolean, titled: boolean ): Component[] {
415
+ const shown = nodes.filter( node => isShown( node, this.layout.hidden ) );
416
+ const shares = sharesOf( shown );
417
+ const content: Component[] = [];
418
+
419
+ shown.forEach( ( node, i ) => {
420
+ if( i > 0 ) {
421
+ content.push( this.makeShareSizer( shown[i - 1], node, horizontal ) );
422
+ }
423
+
424
+ const box = this.makeNode( node, titled );
425
+ this.setShare( box, shares[i] );
426
+ content.push( box );
427
+ } );
428
+
429
+ return content;
430
+ }
431
+
432
+ /**
433
+ * the share of a box among its neighbours: the room is divided in proportion
434
+ */
435
+
436
+ private setShare( box: Component, share: number ): void {
437
+ box.setStyleValue( "flex", `${share} 1 0px` );
438
+ }
439
+
440
+ private makeStack( stack: DockStack, titled: boolean ): Component {
441
+ const hidden = this.layout.hidden;
442
+ const panels = visiblePanels( stack, hidden );
443
+ const shown = shownPanel( stack, hidden );
444
+
445
+ return new Component( {
446
+ cls: "dock-stack",
447
+ content: [
448
+ titled ? this.makeTitles( panels, shown ) : null,
449
+ this.makeBody( panels, shown ),
450
+ ],
451
+ } );
452
+ }
453
+
454
+ /**
455
+ * the box where a stack or a floating panel shows its pages; only `shown` is visible
456
+ */
457
+
458
+ private makeBody( panels: string[], shown: string ): Component {
459
+ const body = new Component( { cls: "dock-body" } );
460
+
461
+ this.fills.push( ( ) => body.setContent( panels.map( id => this.pages.get( id ).show( id === shown ) ) ) );
462
+ return body;
463
+ }
464
+
465
+ /**
466
+ * The titles of a stack, one tab per panel. The bar itself, beside the
467
+ * tabs, is a handle too: it takes the panel that is shown.
468
+ */
469
+
470
+ private makeTitles( panels: string[], shown: string ): Component {
471
+ return new Component( {
472
+ cls: "dock-titles",
473
+ content: panels.map( id => this.makeTitle( id, id === shown ) ),
474
+ dom_events: {
475
+ pointerdown: ev => this.onTitleDown( ev, shown ),
476
+ },
477
+ } );
478
+ }
479
+
480
+ private makeTitle( id: string, active: boolean ): Component {
481
+ // Its own press must not reach the title, which would start a drag.
482
+ // Preventing the default is what stops x4 from calling the handlers above.
483
+ const close = !this.mayClose( id ) ? null : new Component( {
484
+ cls: "dock-close",
485
+ content: "×",
486
+ dom_events: {
487
+ pointerdown: ev => { ev.stopPropagation( ); ev.preventDefault( ); },
488
+ click: ( ) => this.showPanel( id, false ),
489
+ },
490
+ } );
491
+
492
+ return new Component( {
493
+ cls: active ? "dock-title active" : "dock-title",
494
+ content: [
495
+ new Component( { tag: "span", content: this.panel( id ).title ?? id } ),
496
+ close,
497
+ ],
498
+ dom_events: {
499
+ pointerdown: ev => this.onTitleDown( ev, id ),
500
+ },
501
+ } );
502
+ }
503
+
504
+ /**
505
+ * A floating tool: a title, what it shows, and a corner to resize it.
506
+ */
507
+
508
+ private makeFloat( float: DockFloat ): Component {
509
+ const id = float.panel;
510
+ const box = new Component( { cls: "dock-float" } );
511
+
512
+ const corner = new CSizer( "bottom-right", box );
513
+ corner.on( "stop", ( ) => this.onFloatSized( id ) );
514
+
515
+ box.setContent( [
516
+ this.makeTitles( [id], id ),
517
+ this.makeBody( [id], id ),
518
+ corner,
519
+ ] );
520
+
521
+ this.floats.set( id, box );
522
+ return box;
523
+ }
524
+
525
+ /**
526
+ * Put the floating tools where the layout says, brought back inside
527
+ * the box when it became too small for them. The layout keeps what the
528
+ * user chose: they go back there when the box grows again.
529
+ */
530
+
531
+ private placeFloats( ): void {
532
+ const within = this.rectOf( this );
533
+
534
+ for( const float of this.layout.floats ) {
535
+ const at = within.w > 0 ? keepInside( float, within, FLOAT_KEEP ) : float;
536
+ this.floats.get( float.panel )?.setStyle( { left: at.x, top: at.y, width: float.w, height: float.h } );
537
+ }
538
+ }
539
+
540
+ private onFloatSized( id: string ): void {
541
+ const float = floatOf( this.layout, id );
542
+ const rect = this.rectOf( this.floats.get( id ) );
543
+
544
+ float.w = Math.round( rect.w );
545
+ float.h = Math.round( rect.h );
546
+
547
+ this.changed( );
548
+ }
549
+
550
+ // :: SIZES :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
551
+
552
+ /**
553
+ * a rectangle on screen, from the top-left corner of the box
554
+ */
555
+
556
+ private rectOf( component: Component ): DockRect {
557
+ const origin = this.getBoundingRect( );
558
+ const rc = component.getBoundingRect( );
559
+
560
+ return { x: rc.left - origin.left, y: rc.top - origin.top, w: rc.width, h: rc.height };
561
+ }
562
+
563
+ /**
564
+ * Write in the layout the sizes that are on screen, in pixels: the
565
+ * shares then say what is seen, whatever the size the window had when
566
+ * they were given. Nothing is read while the box is not shown.
567
+ */
568
+
569
+ private measure( ): void {
570
+ const visit = ( node: DockNode, horizontal: boolean ) => {
571
+ const box = this.nodes.get( node );
572
+
573
+ if( box && horizontal !== null ) {
574
+ const rc = box.getBoundingRect( );
575
+ const size = Math.round( horizontal ? rc.width : rc.height );
576
+
577
+ if( size > 0 ) {
578
+ node.size = size;
579
+ }
580
+ }
581
+
582
+ if( node.type === "split" ) {
583
+ node.children.forEach( child => visit( child, node.dir === "row" ) );
584
+ }
585
+ };
586
+
587
+ if( this.layout.content ) {
588
+ visit( this.layout.content, null );
589
+ }
590
+
591
+ for( const side of ["left", "right"] as const ) {
592
+ const band = this.layout[side];
593
+ const width = Math.round( this.bands.get( side )?.getBoundingRect( ).width ?? 0 );
594
+
595
+ if( width > 0 ) {
596
+ band.size = width;
597
+ }
598
+
599
+ if( band.root ) {
600
+ visit( band.root, null );
601
+ }
602
+ }
603
+ }
604
+
605
+ /**
606
+ * A line between two boxes, that is dragged. `take` is called when it
607
+ * is pressed, once the layout holds the sizes that are on screen;
608
+ * `move` gets how far the pointer went since.
609
+ */
610
+
611
+ private makeSizer( horizontal: boolean, take: ( ) => void, move: ( delta: number ) => void ): Component {
612
+ const along = ( ev: PointerEvent ) => horizontal ? ev.clientX : ev.clientY;
613
+ const sizer = new Component( { cls: horizontal ? "dock-sizer row" : "dock-sizer column" } );
614
+
615
+ let from: number = null;
616
+
617
+ const end = ( ev: PointerEvent ) => {
618
+ if( from !== null ) {
619
+ from = null;
620
+ sizer.releaseCapture( ev.pointerId );
621
+ this.changed( );
622
+ }
623
+ };
624
+
625
+ sizer.setDOMEvents( {
626
+ pointerdown: ev => {
627
+ ev.stopPropagation( );
628
+ ev.preventDefault( );
629
+
630
+ if( ev.button !== 0 ) {
631
+ return;
632
+ }
633
+
634
+ this.measure( );
635
+ take( );
636
+
637
+ from = along( ev );
638
+ sizer.setCapture( ev.pointerId );
639
+ },
640
+
641
+ pointermove: ev => {
642
+ if( from !== null ) {
643
+ move( along( ev ) - from );
644
+ }
645
+ },
646
+
647
+ pointerup: end,
648
+ pointercancel: end,
649
+ } );
650
+
651
+ return sizer;
652
+ }
653
+
654
+ /**
655
+ * the sizer between two neighbours: what one gets is taken from the other
656
+ */
657
+
658
+ private makeShareSizer( first: DockNode, second: DockNode, horizontal: boolean ): Component {
659
+ let size = 0;
660
+ let total = 0;
661
+
662
+ return this.makeSizer( horizontal, ( ) => {
663
+ size = first.size;
664
+ total = first.size + second.size;
665
+ }, delta => {
666
+ first.size = Math.round( Math.max( MIN_SIZE, Math.min( size + delta, total - MIN_SIZE ) ) );
667
+ second.size = total - first.size;
668
+
669
+ this.setShare( this.nodes.get( first ), first.size );
670
+ this.setShare( this.nodes.get( second ), second.size );
671
+ } );
672
+ }
673
+
674
+ // :: DRAGGING ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
675
+
676
+ private onTitleDown( ev: PointerEvent, id: string ): void {
677
+ // the bar of the titles is above a title: it must not take the press for itself
678
+ ev.stopPropagation( );
679
+ ev.preventDefault( );
680
+
681
+ if( ev.button !== 0 ) {
682
+ return;
683
+ }
684
+
685
+ // a floating panel is held where it was caught
686
+ const float = floatOf( this.layout, id );
687
+ const origin = this.getBoundingRect( );
688
+ const grab = float ? { x: ev.clientX - origin.left - float.x, y: ev.clientY - origin.top - float.y } : FLOAT_GRAB;
689
+
690
+ this.drag = { panel: id, from: { x: ev.clientX, y: ev.clientY }, grab, moving: false, drop: null };
691
+ this.grip.setCapture( ev.pointerId );
692
+ }
693
+
694
+ private onDragMove( ev: PointerEvent ): void {
695
+ const drag = this.drag;
696
+ if( !drag ) {
697
+ return;
698
+ }
699
+
700
+ if( !drag.moving ) {
701
+ if( Math.hypot( ev.clientX - drag.from.x, ev.clientY - drag.from.y ) < DRAG_SLOP ) {
702
+ return;
703
+ }
704
+
705
+ drag.moving = true;
706
+ this.measure( );
707
+ }
708
+
709
+ const origin = this.getBoundingRect( );
710
+ const x = ev.clientX - origin.left;
711
+ const y = ev.clientY - origin.top;
712
+
713
+ drag.drop = this.isTool( drag.panel ) ? this.dropTool( x, y ) : this.dropContent( x, y );
714
+
715
+ if( drag.drop ) {
716
+ const { x: left, y: top, w: width, h: height } = drag.drop.rect;
717
+ this.preview.setStyle( { left, top, width, height } );
718
+ }
719
+
720
+ this.preview.show( !!drag.drop );
721
+ }
722
+
723
+ private onDragEnd( ev: PointerEvent, done: boolean ): void {
724
+ const drag = this.drag;
725
+ if( !drag ) {
726
+ return;
727
+ }
728
+
729
+ this.drag = null;
730
+ this.grip.releaseCapture( ev.pointerId );
731
+ this.preview.hide( );
732
+
733
+ if( !done ) {
734
+ return;
735
+ }
736
+
737
+ if( drag.moving ) {
738
+ if( drag.drop ) {
739
+ this.apply( drag.drop.apply( ) );
740
+ }
741
+
742
+ return;
743
+ }
744
+
745
+ // a click: the tab comes in front of its stack, a floating panel above the others
746
+ const float = floatOf( this.layout, drag.panel );
747
+
748
+ if( float ) {
749
+ if( this.layout.floats.indexOf( float ) < this.layout.floats.length - 1 ) {
750
+ this.apply( dockFloat( this.layout, drag.panel, float ) );
751
+ }
752
+ }
753
+ else if( stackOf( this.layout, drag.panel ).active !== drag.panel ) {
754
+ this.apply( dockActivate( this.layout, drag.panel ) );
755
+ }
756
+ }
757
+
758
+ /**
759
+ * What dropping the tool that is dragged at this point of the box
760
+ * would do, null for nothing.
761
+ *
762
+ * a side of a tool beside it, above or under it
763
+ * the middle of another tool one of its tabs
764
+ * the left or the right of the contents the band of that side, under its tools
765
+ * anywhere else it floats, if it may
766
+ */
767
+
768
+ private dropTool( x: number, y: number ): Drop {
769
+ const { panel, grab } = this.drag;
770
+ const layout = this.layout;
771
+ const hidden = layout.hidden;
772
+
773
+ const within = this.rectOf( this );
774
+ const own = stackOf( layout, panel );
775
+ const float = floatOf( layout, panel );
776
+ const page = this.rectOf( this.pages.get( panel ) );
777
+
778
+ const inside = ( rect: DockRect ) => dropZone( rect, x, y, 1 ) !== null;
779
+
780
+ // the width the tool brings to a band: the one it has now, a third of the box at most
781
+ const width = Math.round( Math.max( MIN_SIZE, Math.min( ( float?.w ?? page.w ) || BAND_SIZE, within.w / 3 ) ) );
782
+
783
+ const floating = ( ): Drop => {
784
+ if( !this.mayFloat( panel ) ) {
785
+ return null;
786
+ }
787
+
788
+ // the size it already floats with, or the one it has in its band
789
+ const w = float?.w ?? Math.max( FLOAT_MIN_W, Math.min( page.w, within.w / 2 ) );
790
+ const h = float?.h ?? Math.max( FLOAT_MIN_H, Math.min( page.h, within.h / 2 ) );
791
+ const rect = keepInside( { x: x - grab.x, y: y - grab.y, w, h }, within, FLOAT_KEEP );
792
+
793
+ return { rect, apply: ( ) => dockFloat( layout, panel, rect ) };
794
+ };
795
+
796
+ // outside the box, or above another floating tool, which takes no tab
797
+ const above = layout.floats.some( other => other.panel !== panel && !hidden.includes( other.panel ) && inside( this.rectOf( this.floats.get( other.panel ) ) ) );
798
+
799
+ if( above || !inside( within ) ) {
800
+ return floating( );
801
+ }
802
+
803
+ // a tool of a band
804
+ for( const stack of [...stacksOf( layout.left.root ), ...stacksOf( layout.right.root )] ) {
805
+ const box = this.nodes.get( stack );
806
+ const rect = box ? this.rectOf( box ) : null;
807
+ const zone = rect ? dropZone( rect, x, y, SIDE_BAND ) : null;
808
+
809
+ if( !zone ) {
810
+ continue;
811
+ }
812
+
813
+ const panels = visiblePanels( stack, hidden );
814
+
815
+ if( zone === "center" ) {
816
+ // the middle of its own stack: it leaves it
817
+ return stack === own ? floating( ) : { rect, apply: ( ) => dockMove( layout, panel, panels[0], "tab" ) };
818
+ }
819
+
820
+ // beside the stack it is alone in: it is already there
821
+ if( stack === own && panels.length === 1 ) {
822
+ return null;
823
+ }
824
+
825
+ // Half of the stack is shown. On the left or on the right, the
826
+ // band gets wider instead: the two tools keep their width.
827
+ const half = ( zone === "left" || zone === "right" ? rect.w : rect.h ) / 2;
828
+ return { rect: sideRect( rect, zone, half ), apply: ( ) => dockMove( layout, panel, panels[0], zone, width ) };
829
+ }
830
+
831
+ // the left or the right of the contents: the band of that side
832
+ const area = this.rectOf( this.area );
833
+ const side = x < area.x + SIDE_BAND ? "left" : x >= area.x + area.w - SIDE_BAND ? "right" : null;
834
+
835
+ if( !side ) {
836
+ return floating( );
837
+ }
838
+
839
+ const band = this.bands.get( side );
840
+ const root = layout[side].root;
841
+ const shown = stacksOf( root ).filter( stack => isShown( stack, hidden ) );
842
+
843
+ // the only tool of that band: it is already there
844
+ if( bandOf( layout, panel ) === side && shown.length === 1 && visiblePanels( own, hidden ).length === 1 ) {
845
+ return null;
846
+ }
847
+
848
+ // A band that shows nothing is made beside the contents, as wide as
849
+ // the tool is now. Otherwise the tool goes under what the band
850
+ // holds: one more row of it, or its lower half.
851
+ const rows = root?.type === "split" && root.dir === "column" ? root.children.filter( child => isShown( child, hidden ) ).length : 1;
852
+ const bandRect = band ? this.rectOf( band ) : null;
853
+ const rect = bandRect ? sideRect( bandRect, "bottom", bandRect.h / ( rows + 1 ) ) : sideRect( area, side, width );
854
+
855
+ return { rect, apply: ( ) => dockBand( layout, panel, side, band ? 0 : width ) };
856
+ }
857
+
858
+ /**
859
+ * The same for a content: it only goes with the other contents.
860
+ *
861
+ * a side of another content beside it
862
+ * the middle of another content one of its tabs
863
+ */
864
+
865
+ private dropContent( x: number, y: number ): Drop {
866
+ const panel = this.drag.panel;
867
+ const layout = this.layout;
868
+ const own = stackOf( layout, panel );
869
+
870
+ for( const [node, box] of this.nodes ) {
871
+ if( node.type !== "stack" || this.isTool( node.panels[0] ) ) {
872
+ continue;
873
+ }
874
+
875
+ const rect = this.rectOf( box );
876
+ const zone = dropZone( rect, x, y, SIDE_BAND );
877
+ if( !zone ) {
878
+ continue;
879
+ }
880
+
881
+ const panels = visiblePanels( node, layout.hidden );
882
+
883
+ if( zone === "center" ) {
884
+ return node === own ? null : { rect, apply: ( ) => dockMove( layout, panel, panels[0], "tab" ) };
885
+ }
886
+
887
+ // beside the stack it is alone in: it is already there
888
+ if( node === own && panels.length === 1 ) {
889
+ return null;
890
+ }
891
+
892
+ // half of the stack
893
+ const half = ( zone === "left" || zone === "right" ? rect.w : rect.h ) / 2;
894
+ return { rect: sideRect( rect, zone, half ), apply: ( ) => dockMove( layout, panel, panels[0], zone ) };
895
+ }
896
+
897
+ return null;
898
+ }
899
+ }