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