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.
- package/aicontext.md +170 -13
- package/package.json +1 -1
- package/src/components/colorinput/colorinput.module.scss +2 -2
- package/src/components/colorpicker/colorpicker.module.scss +2 -2
- package/src/components/combobox/combobox.ts +5 -1
- package/src/components/components.ts +1 -0
- package/src/components/dockingview/dockingview.module.scss +297 -0
- package/src/components/dockingview/dockingview.ts +1036 -0
- package/src/components/dockingview/docklayout.ts +748 -0
- package/src/components/gauge/gauge.ts +4 -0
- package/src/components/input/input.ts +6 -1
- package/src/components/notification/notification.module.scss +8 -3
- package/src/components/notification/notification.ts +3 -0
- package/src/components/progress/progress.ts +7 -3
- package/src/components/switch/switch.ts +2 -1
- package/src/core/component.ts +1 -1
|
@@ -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
|
+
}
|