flexdesk 0.4.3 → 0.4.5
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/README.md +22 -0
- package/css/base.css +45 -0
- package/css/flexdesk.css +45 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-P4AALM3A.js → chunk-BIAOGX6K.js} +41 -13
- package/dist/chunk-BIAOGX6K.js.map +7 -0
- package/dist/{chunk-4RGQSRMC.js → chunk-SWB5ARKN.js} +2 -2
- package/dist/flexdesk.css +45 -0
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +329 -1
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +12 -1
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/tiling/tile_tree.js +5 -0
- package/src/tiling/wm.js +14 -1
- package/src/ui/components/column_fit.js +170 -0
- package/src/ui/components/data_table.js +53 -15
- package/src/ui/utils/auto_scrollbars.js +270 -0
- package/dist/chunk-P4AALM3A.js.map +0 -7
- /package/dist/{chunk-4RGQSRMC.js.map → chunk-SWB5ARKN.js.map} +0 -0
package/package.json
CHANGED
package/src/tiling/tile_tree.js
CHANGED
|
@@ -690,6 +690,11 @@ export class TileTree {
|
|
|
690
690
|
split(leafId, dir /* 'h' | 'v' */) {
|
|
691
691
|
const leaf = this.get(leafId);
|
|
692
692
|
if (!leaf || leaf.kind !== 'leaf') return null;
|
|
693
|
+
// A PANEL IS NEVER SPLIT. The left, right and bottom panels are fixed
|
|
694
|
+
// chrome around the content area, and a split of one put a second
|
|
695
|
+
// pane inside the navigator. Refused here, where every split path
|
|
696
|
+
// meets, so no chord, menu or drop can do it by another route.
|
|
697
|
+
if (_isPanel(leaf)) return null;
|
|
693
698
|
|
|
694
699
|
const newLeaf = makeLeaf();
|
|
695
700
|
// Case 1: root split. Wrap root in a new Split.
|
package/src/tiling/wm.js
CHANGED
|
@@ -689,7 +689,9 @@ export class WindowManager {
|
|
|
689
689
|
// ── Split / close / focus / move ────────────────────────────────
|
|
690
690
|
split(dir) {
|
|
691
691
|
const tree = this._tree();
|
|
692
|
-
|
|
692
|
+
// From a panel, the chord means the content area: the same reading
|
|
693
|
+
// Backspace gives it. A panel itself is never split (TileTree.split).
|
|
694
|
+
const focused = this._contentLeafId(tree.focusedLeafId);
|
|
693
695
|
if (!focused) return;
|
|
694
696
|
// Chrome split buttons mirror Alt+H / Alt+V. The freshly created
|
|
695
697
|
// pane is SEEDED with the default HOME content (the taxonomy root)
|
|
@@ -723,6 +725,7 @@ export class WindowManager {
|
|
|
723
725
|
* leaf id, or null if the leaf can't be split. */
|
|
724
726
|
splitLeafWith(leafId, dir, kind, props = {}, title = '') {
|
|
725
727
|
const tree = this._tree();
|
|
728
|
+
leafId = this._contentLeafId(leafId);
|
|
726
729
|
const src = leafId ? tree.get(leafId) : null;
|
|
727
730
|
if (!src || src.kind !== 'leaf') return null;
|
|
728
731
|
const newId = tree.split(leafId, dir);
|
|
@@ -735,6 +738,16 @@ export class WindowManager {
|
|
|
735
738
|
return newId;
|
|
736
739
|
}
|
|
737
740
|
|
|
741
|
+
/** A leaf that can hold content: `leafId` itself, or the primary tile when
|
|
742
|
+
* `leafId` is a panel, a window placeholder or gone. */
|
|
743
|
+
_contentLeafId(leafId) {
|
|
744
|
+
const tree = this._tree();
|
|
745
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
746
|
+
const kind = String(leaf?.content?.kind || '');
|
|
747
|
+
if (leaf && leaf.kind === 'leaf' && !kind.startsWith('panel:') && kind !== PLACEHOLDER_KIND) return leafId;
|
|
748
|
+
return tree.primaryLeafId();
|
|
749
|
+
}
|
|
750
|
+
|
|
738
751
|
/** Resolve the focused tile's active content for the keyboard-driven
|
|
739
752
|
* "open focused tile elsewhere" chords (Alt+T / Alt+N / Alt+Shift+H /
|
|
740
753
|
* Alt+Shift+V). Returns null for panels, window placeholders and
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* column_fit.js — how wide each column of a table should be.
|
|
3
|
+
*
|
|
4
|
+
* A pure function over measurements, so the policy can be read and tested
|
|
5
|
+
* without a DOM. A table measures its columns, hands them here, and applies
|
|
6
|
+
* the widths it gets back.
|
|
7
|
+
*
|
|
8
|
+
* ── The policy ─────────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* Columns are not all alike, and treating them alike is what went wrong
|
|
11
|
+
* before. An id, a date, a status, a person: SHORT columns, whose values are
|
|
12
|
+
* all about as wide as each other, and which are useless the moment they are
|
|
13
|
+
* cut ("2026-09-…", "review-q…"). A summary or a title: TEXT columns, whose
|
|
14
|
+
* values run as long as people write them, and which are readable cut.
|
|
15
|
+
*
|
|
16
|
+
* The old fit spread spare room EVENLY, so a 40px "BUG" chip got 90px while
|
|
17
|
+
* the summary beside it was clipped in nine rows out of ten, and it shrank the
|
|
18
|
+
* WIDEST column first, so a narrow tile crushed the title to 85px while every
|
|
19
|
+
* short column kept its width.
|
|
20
|
+
*
|
|
21
|
+
* 1. Short columns get their full content width. Text columns start at a
|
|
22
|
+
* readable width.
|
|
23
|
+
* 2. Room to spare goes to the text columns, in proportion to what each
|
|
24
|
+
* still needs, and past their content so the table fills its box. With no
|
|
25
|
+
* text column it goes to the last column, so no column in the middle
|
|
26
|
+
* opens a gap.
|
|
27
|
+
* 3. Too little room: shrink in order of harm. Short columns first give up
|
|
28
|
+
* only their rare longest values (down to `typical`, what most values
|
|
29
|
+
* fit). Then text columns go down to a floor. Then short columns go down
|
|
30
|
+
* to theirs, and text columns once more. What still does not fit scrolls
|
|
31
|
+
* sideways rather than being crushed further.
|
|
32
|
+
*
|
|
33
|
+
* Within each step the widest columns give way first and the narrow ones keep
|
|
34
|
+
* their width (a water level), so one long column never forces every other
|
|
35
|
+
* column down with it.
|
|
36
|
+
*
|
|
37
|
+
* Columns the user dragged are PINNED: they keep their width and sit out every
|
|
38
|
+
* step.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export const COLUMN_FIT = Object.freeze({
|
|
42
|
+
/** Absolute floor, and the drag-resize minimum. */
|
|
43
|
+
FLOOR: 40,
|
|
44
|
+
/** Sub-pixel safety against an ellipsis on content that exactly fits. */
|
|
45
|
+
PAD: 2,
|
|
46
|
+
/** A column whose full width reaches this is free text. */
|
|
47
|
+
TEXT_AT: 240,
|
|
48
|
+
/** A text column's starting share when room allows it. */
|
|
49
|
+
TEXT_MIN: 200,
|
|
50
|
+
/** How far a text column shrinks before short columns go below `typical`. */
|
|
51
|
+
TEXT_FLOOR: 120,
|
|
52
|
+
/** How far a short column shrinks before the table scrolls. */
|
|
53
|
+
SHORT_FLOOR: 56,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} ColumnMeasure
|
|
58
|
+
* @property {number} natural widest cell, padding included (px)
|
|
59
|
+
* @property {number} [typical] width most cells fit, e.g. the 95th percentile (px);
|
|
60
|
+
* defaults to `natural`
|
|
61
|
+
* @property {number} [header] the header label's width (px)
|
|
62
|
+
* @property {number} [pinned] a user-dragged width, honoured exactly
|
|
63
|
+
* @property {boolean} [text] force (true) or forbid (false) text treatment;
|
|
64
|
+
* omitted, it is decided from the width
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {ColumnMeasure[]} columns
|
|
69
|
+
* @param {number} avail the width the table has (px); 0 or less means "not laid out"
|
|
70
|
+
* @returns {number[]} a width per column (px, possibly fractional). The sum
|
|
71
|
+
* equals `avail` whenever the floors fit, and exceeds it only when they do
|
|
72
|
+
* not, in which case the table should scroll.
|
|
73
|
+
*/
|
|
74
|
+
export function fitColumns(columns, avail) {
|
|
75
|
+
const { FLOOR, PAD, TEXT_AT, TEXT_MIN, TEXT_FLOOR, SHORT_FLOOR } = COLUMN_FIT;
|
|
76
|
+
const n = columns.length;
|
|
77
|
+
const cols = columns.map((c) => {
|
|
78
|
+
const header = Math.max(0, Number(c.header) || 0);
|
|
79
|
+
const natural = Math.max(0, Number(c.natural) || 0);
|
|
80
|
+
const full = Math.max(FLOOR, Math.ceil(Math.max(natural, header) + PAD));
|
|
81
|
+
const typical = Math.min(full, Math.max(FLOOR, Math.ceil(Math.max(
|
|
82
|
+
Number.isFinite(c.typical) ? c.typical : natural, header) + PAD)));
|
|
83
|
+
const pinned = Number.isFinite(c.pinned) ? Math.max(FLOOR, Math.round(c.pinned)) : null;
|
|
84
|
+
const text = pinned == null && (typeof c.text === 'boolean' ? c.text : full >= TEXT_AT);
|
|
85
|
+
return { full, typical, pinned, text };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const text = [];
|
|
89
|
+
const short = [];
|
|
90
|
+
cols.forEach((c, i) => { if (c.pinned == null) (c.text ? text : short).push(i); });
|
|
91
|
+
if (!(avail > 1)) return cols.map((c) => (c.pinned != null ? c.pinned : c.full));
|
|
92
|
+
|
|
93
|
+
// 1. The starting point: short columns at their full content width, text
|
|
94
|
+
// columns at a readable width (or their content, if shorter).
|
|
95
|
+
const widths = cols.map((c) => {
|
|
96
|
+
if (c.pinned != null) return c.pinned;
|
|
97
|
+
return c.text ? Math.min(c.full, TEXT_MIN) : c.full;
|
|
98
|
+
});
|
|
99
|
+
const total = () => widths.reduce((a, b) => a + b, 0);
|
|
100
|
+
let slack = avail - total();
|
|
101
|
+
|
|
102
|
+
// 2. Room to spare goes to the text columns, in proportion to what each
|
|
103
|
+
// still needs, then beyond their content so the table fills its box.
|
|
104
|
+
// Widths are not rounded: CSS takes fractional pixels, and rounding is
|
|
105
|
+
// what made the total miss the box by a pixel and a column jitter by one
|
|
106
|
+
// as its tile was resized.
|
|
107
|
+
if (slack >= 0) {
|
|
108
|
+
slack = raise(widths, text, (i) => cols[i].full, slack);
|
|
109
|
+
if (slack > 0) {
|
|
110
|
+
if (text.length) text.forEach((i) => { widths[i] += slack / text.length; });
|
|
111
|
+
else widths[short.length ? short[short.length - 1] : n - 1] += slack;
|
|
112
|
+
}
|
|
113
|
+
return widths;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Not enough room: shrink in order of harm until it fits.
|
|
117
|
+
const steps = [
|
|
118
|
+
[short, (i) => cols[i].typical],
|
|
119
|
+
[text, () => TEXT_FLOOR],
|
|
120
|
+
[short, (i) => Math.min(cols[i].typical, SHORT_FLOOR)],
|
|
121
|
+
[text, () => FLOOR * 2],
|
|
122
|
+
];
|
|
123
|
+
for (const [idxs, low] of steps) {
|
|
124
|
+
const over = total() - avail;
|
|
125
|
+
if (over <= 1e-6) break;
|
|
126
|
+
lowerTo(widths, idxs, low, over);
|
|
127
|
+
}
|
|
128
|
+
return widths;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Give up to `slack` px to the columns in `idxs`, each toward `target(i)`, in
|
|
133
|
+
* proportion to how far each still is from it. Returns what is left.
|
|
134
|
+
*/
|
|
135
|
+
function raise(widths, idxs, target, slack) {
|
|
136
|
+
if (!(slack > 0)) return slack;
|
|
137
|
+
const wants = idxs.map((i) => Math.max(0, target(i) - widths[i]));
|
|
138
|
+
const need = wants.reduce((a, b) => a + b, 0);
|
|
139
|
+
if (!(need > 0)) return slack;
|
|
140
|
+
const give = Math.min(slack, need);
|
|
141
|
+
idxs.forEach((i, k) => { widths[i] += give * wants[k] / need; });
|
|
142
|
+
return slack - give;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Take up to `over` px off the columns in `idxs`, widest first, none below
|
|
147
|
+
* `low(i)`. A water level: every column above the level is cut down to it, and
|
|
148
|
+
* the level is solved so the total cut is exactly `over`, or everything the
|
|
149
|
+
* lows allow when that is less. Solved on real numbers: the result is exact,
|
|
150
|
+
* and a wider box can never make any column narrower.
|
|
151
|
+
*/
|
|
152
|
+
function lowerTo(widths, idxs, low, over) {
|
|
153
|
+
const floorOf = (i) => Math.min(widths[i], Math.max(0, low(i)));
|
|
154
|
+
const cand = idxs.filter((i) => widths[i] > floorOf(i));
|
|
155
|
+
if (!cand.length || over <= 0) return;
|
|
156
|
+
const floors = new Map(cand.map((i) => [i, floorOf(i)]));
|
|
157
|
+
const capacity = cand.reduce((a, i) => a + widths[i] - floors.get(i), 0);
|
|
158
|
+
if (capacity <= over) {
|
|
159
|
+
for (const i of cand) widths[i] = floors.get(i);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const cutAt = (level) => cand.reduce((a, i) => a + Math.max(0, widths[i] - Math.max(floors.get(i), level)), 0);
|
|
163
|
+
let lo = 0;
|
|
164
|
+
let hi = Math.max(...cand.map((i) => widths[i]));
|
|
165
|
+
for (let k = 0; k < 64; k++) {
|
|
166
|
+
const mid = (lo + hi) / 2;
|
|
167
|
+
if (cutAt(mid) > over) lo = mid; else hi = mid;
|
|
168
|
+
}
|
|
169
|
+
for (const i of cand) widths[i] = Math.min(widths[i], Math.max(floors.get(i), hi));
|
|
170
|
+
}
|
|
@@ -411,6 +411,13 @@ export class DataTable {
|
|
|
411
411
|
render() {
|
|
412
412
|
// Capture active filter input before re-render
|
|
413
413
|
const activeFilterColIdx = this._getActiveFilterColIdx();
|
|
414
|
+
// KEYBOARD FOCUS SURVIVES A RE-RENDER. Rendering replaces the table
|
|
415
|
+
// element, and a focused element that is removed hands focus to the
|
|
416
|
+
// page body, where the arrow keys reach nobody. A data refresh does
|
|
417
|
+
// exactly that (a record changed on disk, a live reload), so keyboard
|
|
418
|
+
// navigation silently died the first time the store moved.
|
|
419
|
+
const doc = this.container.ownerDocument;
|
|
420
|
+
const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
|
|
414
421
|
|
|
415
422
|
this._cleanup();
|
|
416
423
|
this.container.innerHTML = '';
|
|
@@ -581,6 +588,9 @@ export class DataTable {
|
|
|
581
588
|
if (activeFilterColIdx !== null) {
|
|
582
589
|
this._restoreFilterFocus(activeFilterColIdx);
|
|
583
590
|
}
|
|
591
|
+
else if (hadTableFocus && this._tableEl) {
|
|
592
|
+
try { this._tableEl.focus({ preventScroll: true }); } catch (_) { /* detached */ }
|
|
593
|
+
}
|
|
584
594
|
}
|
|
585
595
|
|
|
586
596
|
/**
|
|
@@ -2267,7 +2277,20 @@ export class DataTable {
|
|
|
2267
2277
|
});
|
|
2268
2278
|
}
|
|
2269
2279
|
|
|
2280
|
+
/**
|
|
2281
|
+
* CSS px per screen px. Content inside a zoomed tile reports rects and
|
|
2282
|
+
* pointer coordinates in screen pixels, while `style.width` is CSS pixels;
|
|
2283
|
+
* mixing them widened every column by the zoom factor on a mere click.
|
|
2284
|
+
*/
|
|
2285
|
+
_zoomFactor() {
|
|
2286
|
+
const el = this._tableWrapEl || this._tableEl;
|
|
2287
|
+
const css = el?.offsetWidth || 0;
|
|
2288
|
+
const screen = el?.getBoundingClientRect?.().width || 0;
|
|
2289
|
+
return css > 0 && screen > 0 ? screen / css : 1;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2270
2292
|
_beginColResize(ev, domIdx) {
|
|
2293
|
+
if (ev.button != null && ev.button !== 0) return;
|
|
2271
2294
|
ev.preventDefault();
|
|
2272
2295
|
ev.stopPropagation();
|
|
2273
2296
|
const headerTable = this._headerTableEl;
|
|
@@ -2275,24 +2298,38 @@ export class DataTable {
|
|
|
2275
2298
|
const headRow = headerTable && headerTable.querySelector('thead > tr');
|
|
2276
2299
|
if (!headRow) return;
|
|
2277
2300
|
|
|
2278
|
-
//
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
//
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
|
|
2288
|
-
this._applyTableWidth();
|
|
2289
|
-
|
|
2301
|
+
// NOTHING HAPPENS UNTIL THE POINTER MOVES. Freezing on mousedown pinned
|
|
2302
|
+
// every column on a plain click, and the first click of a double-click,
|
|
2303
|
+
// so a click on a grip was already a resize and switched auto-fit off
|
|
2304
|
+
// for every column for good.
|
|
2305
|
+
//
|
|
2306
|
+
// Once the drag is real: freeze EVERY column at its current width on
|
|
2307
|
+
// BOTH tables and lock fixed layout, so the drag moves only the grabbed
|
|
2308
|
+
// column and a neighbour can never absorb it. Widths come from the
|
|
2309
|
+
// header row (the column source of truth), in CSS pixels.
|
|
2290
2310
|
const startX = ev.clientX;
|
|
2311
|
+
const THRESHOLD = 3;
|
|
2291
2312
|
const MIN = 40;
|
|
2292
|
-
|
|
2313
|
+
let startWidths = null;
|
|
2314
|
+
const begin = () => {
|
|
2315
|
+
const z = this._zoomFactor();
|
|
2316
|
+
startWidths = [...headRow.children].map((c) => {
|
|
2317
|
+
const set = parseFloat(c.style.width);
|
|
2318
|
+
return Number.isFinite(set) ? set : c.getBoundingClientRect().width / z;
|
|
2319
|
+
});
|
|
2320
|
+
headerTable.style.tableLayout = 'fixed';
|
|
2321
|
+
if (bodyTable) bodyTable.style.tableLayout = 'fixed';
|
|
2322
|
+
startWidths.forEach((w, i) => this._pinColumnWidth(i, w));
|
|
2323
|
+
this._applyTableWidth();
|
|
2324
|
+
document.body.classList.add('twm-dt-col-resizing');
|
|
2325
|
+
};
|
|
2293
2326
|
const onMove = (mv) => {
|
|
2294
|
-
const
|
|
2295
|
-
|
|
2327
|
+
const dx = mv.clientX - startX;
|
|
2328
|
+
if (!startWidths) {
|
|
2329
|
+
if (Math.abs(dx) < THRESHOLD) return;
|
|
2330
|
+
begin();
|
|
2331
|
+
}
|
|
2332
|
+
const w = Math.max(MIN, Math.round(startWidths[domIdx] + dx / this._zoomFactor()));
|
|
2296
2333
|
this._pinColumnWidth(domIdx, w);
|
|
2297
2334
|
// Pass the dragged column so the fill invariant reclaims any
|
|
2298
2335
|
// freed width into a DIFFERENT (flexible/last) column, never
|
|
@@ -2303,6 +2340,7 @@ export class DataTable {
|
|
|
2303
2340
|
const onUp = () => {
|
|
2304
2341
|
document.removeEventListener('mousemove', onMove);
|
|
2305
2342
|
document.removeEventListener('mouseup', onUp);
|
|
2343
|
+
if (!startWidths) return; // a click: nothing changed
|
|
2306
2344
|
document.body.classList.remove('twm-dt-col-resizing');
|
|
2307
2345
|
this._updateCellTooltips();
|
|
2308
2346
|
this._savePersisted();
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto_scrollbars.js — the overlay scrollbar, on EVERY scrollable element.
|
|
3
|
+
*
|
|
4
|
+
* `installOverlayScrollbar` (overlay_scrollbar.js) gives one container a thin
|
|
5
|
+
* bar that shows on hover and fades away. It is installed container by
|
|
6
|
+
* container, it puts the bar INSIDE the container, and it holds a resize
|
|
7
|
+
* observer, a subtree mutation observer and a window listener for each one.
|
|
8
|
+
* That is fine for a handful of known panes and wrong for "all of them": a
|
|
9
|
+
* page that redraws with `innerHTML = ''` deletes its bar for good, and a
|
|
10
|
+
* table body, a dropdown, a textarea and a dialog each want one.
|
|
11
|
+
*
|
|
12
|
+
* So this is the same look, done once for a whole subtree:
|
|
13
|
+
*
|
|
14
|
+
* - Native scrollbars are hidden under `root`, so no element reserves
|
|
15
|
+
* layout width for one and nothing reflows when a bar appears.
|
|
16
|
+
* - Bars live in ONE fixed layer on `document.body`, positioned over the
|
|
17
|
+
* element they belong to. Nothing is added to a scroll container, so a
|
|
18
|
+
* redraw cannot remove a bar, and a textarea (which cannot hold children)
|
|
19
|
+
* gets one too.
|
|
20
|
+
* - An element gets its bars when the pointer is over it or it scrolls, if
|
|
21
|
+
* it actually overflows. They fade out a moment after the pointer leaves
|
|
22
|
+
* or the scrolling stops. Nested scroll areas each show their own.
|
|
23
|
+
* - The thumb drags and the track pages, as a native bar does.
|
|
24
|
+
*
|
|
25
|
+
* Discovery is lazy: the pointer and scroll events name the elements worth
|
|
26
|
+
* measuring, so nothing walks the DOM looking for scroll areas.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const LAYER_ID = 'twm-autoscroll-layer';
|
|
30
|
+
const ROOT_CLASS = 'twm-autoscroll';
|
|
31
|
+
const HIDE_AFTER_MS = 900;
|
|
32
|
+
const MIN_THUMB = 30;
|
|
33
|
+
const BAR = 6;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the thumb goes. Pure, so it can be tested without a layout engine.
|
|
37
|
+
*
|
|
38
|
+
* @param {number} scrollSize scrollHeight / scrollWidth
|
|
39
|
+
* @param {number} clientSize clientHeight / clientWidth
|
|
40
|
+
* @param {number} scrollPos scrollTop / scrollLeft
|
|
41
|
+
* @param {number} trackLen the bar's length on screen (px)
|
|
42
|
+
* @returns {{size:number, pos:number}|null} null when nothing overflows
|
|
43
|
+
*/
|
|
44
|
+
export function thumbGeometry(scrollSize, clientSize, scrollPos, trackLen) {
|
|
45
|
+
const max = scrollSize - clientSize;
|
|
46
|
+
if (!(max > 1) || !(trackLen > 0)) return null;
|
|
47
|
+
const size = Math.min(trackLen, Math.max(MIN_THUMB, trackLen * clientSize / scrollSize));
|
|
48
|
+
const pos = (trackLen - size) * Math.min(1, Math.max(0, scrollPos / max));
|
|
49
|
+
return { size, pos };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether an element scrolls on an axis: its overflow allows it and its
|
|
53
|
+
* content is larger than its box. */
|
|
54
|
+
function scrollsOn(el, axis) {
|
|
55
|
+
if (!(el instanceof Element)) return false;
|
|
56
|
+
const cs = getComputedStyle(el);
|
|
57
|
+
const overflow = axis === 'y' ? cs.overflowY : cs.overflowX;
|
|
58
|
+
if (overflow !== 'auto' && overflow !== 'scroll' && !(el.tagName === 'TEXTAREA' && overflow !== 'hidden')) return false;
|
|
59
|
+
return axis === 'y' ? el.scrollHeight - el.clientHeight > 1 : el.scrollWidth - el.clientWidth > 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Give every scrollable element under `root` an auto-hiding overlay scrollbar.
|
|
64
|
+
*
|
|
65
|
+
* @param {HTMLElement} [root=document.body]
|
|
66
|
+
* @param {{exclude?: (el: Element) => boolean}} [opts] elements to leave to
|
|
67
|
+
* their own scrollbar. Containers that already carry an
|
|
68
|
+
* `installOverlayScrollbar` bar are always left alone.
|
|
69
|
+
* @returns {{dispose: () => void}}
|
|
70
|
+
*/
|
|
71
|
+
export function installAutoScrollbars(root = document.body, { exclude = null } = {}) {
|
|
72
|
+
const doc = root.ownerDocument || document;
|
|
73
|
+
const win = doc.defaultView || window;
|
|
74
|
+
root.classList.add(ROOT_CLASS);
|
|
75
|
+
|
|
76
|
+
let layer = doc.getElementById(LAYER_ID);
|
|
77
|
+
if (!layer) {
|
|
78
|
+
layer = doc.createElement('div');
|
|
79
|
+
layer.id = LAYER_ID;
|
|
80
|
+
layer.className = 'twm-autoscroll-layer';
|
|
81
|
+
doc.body.appendChild(layer);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** element -> { v, h, timer, hovered } */
|
|
85
|
+
const managed = new Map();
|
|
86
|
+
let hoverChain = new Set();
|
|
87
|
+
|
|
88
|
+
const eligible = (el) => el instanceof Element
|
|
89
|
+
&& root.contains(el)
|
|
90
|
+
&& !el.__overlayScrollbarInstalled
|
|
91
|
+
&& !el.closest?.('.twm-autoscroll-layer')
|
|
92
|
+
&& !(exclude && exclude(el));
|
|
93
|
+
|
|
94
|
+
const makeBar = (el, axis) => {
|
|
95
|
+
const bar = doc.createElement('div');
|
|
96
|
+
bar.className = `twm-autoscroll-bar twm-autoscroll-bar--${axis}`;
|
|
97
|
+
const thumb = doc.createElement('div');
|
|
98
|
+
thumb.className = 'twm-autoscroll-thumb';
|
|
99
|
+
bar.appendChild(thumb);
|
|
100
|
+
layer.appendChild(bar);
|
|
101
|
+
|
|
102
|
+
// The track pages, like a native bar's.
|
|
103
|
+
bar.addEventListener('mousedown', (e) => {
|
|
104
|
+
if (e.target !== bar) return;
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
const r = thumb.getBoundingClientRect();
|
|
107
|
+
const before = axis === 'y' ? e.clientY < r.top : e.clientX < r.left;
|
|
108
|
+
const page = (axis === 'y' ? el.clientHeight : el.clientWidth) * 0.9;
|
|
109
|
+
if (axis === 'y') el.scrollTop += before ? -page : page;
|
|
110
|
+
else el.scrollLeft += before ? -page : page;
|
|
111
|
+
});
|
|
112
|
+
// The thumb drags.
|
|
113
|
+
thumb.addEventListener('mousedown', (e) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
e.stopPropagation();
|
|
116
|
+
const rec = managed.get(el);
|
|
117
|
+
if (rec) rec.dragging = true;
|
|
118
|
+
const start = axis === 'y' ? e.clientY : e.clientX;
|
|
119
|
+
const startScroll = axis === 'y' ? el.scrollTop : el.scrollLeft;
|
|
120
|
+
const trackLen = axis === 'y' ? bar.getBoundingClientRect().height : bar.getBoundingClientRect().width;
|
|
121
|
+
const thumbLen = axis === 'y' ? thumb.getBoundingClientRect().height : thumb.getBoundingClientRect().width;
|
|
122
|
+
const max = axis === 'y' ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;
|
|
123
|
+
const perPx = trackLen > thumbLen ? max / (trackLen - thumbLen) : 0;
|
|
124
|
+
bar.classList.add('twm-autoscroll-bar--active');
|
|
125
|
+
const move = (mv) => {
|
|
126
|
+
const d = (axis === 'y' ? mv.clientY : mv.clientX) - start;
|
|
127
|
+
if (axis === 'y') el.scrollTop = startScroll + d * perPx;
|
|
128
|
+
else el.scrollLeft = startScroll + d * perPx;
|
|
129
|
+
};
|
|
130
|
+
const up = () => {
|
|
131
|
+
doc.removeEventListener('mousemove', move);
|
|
132
|
+
doc.removeEventListener('mouseup', up);
|
|
133
|
+
bar.classList.remove('twm-autoscroll-bar--active');
|
|
134
|
+
if (rec) rec.dragging = false;
|
|
135
|
+
hideLater(el);
|
|
136
|
+
};
|
|
137
|
+
doc.addEventListener('mousemove', move);
|
|
138
|
+
doc.addEventListener('mouseup', up);
|
|
139
|
+
});
|
|
140
|
+
bar.addEventListener('mouseenter', () => { const rec = managed.get(el); if (rec) { rec.onBar = true; show(el); } });
|
|
141
|
+
bar.addEventListener('mouseleave', () => { const rec = managed.get(el); if (rec) { rec.onBar = false; hideLater(el); } });
|
|
142
|
+
return { bar, thumb };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const record = (el) => {
|
|
146
|
+
let rec = managed.get(el);
|
|
147
|
+
if (!rec) {
|
|
148
|
+
rec = { v: null, h: null, timer: 0, dragging: false, onBar: false };
|
|
149
|
+
managed.set(el, rec);
|
|
150
|
+
}
|
|
151
|
+
return rec;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Place and size an element's bars; drop the element when it is gone. */
|
|
155
|
+
const place = (el) => {
|
|
156
|
+
const rec = managed.get(el);
|
|
157
|
+
if (!rec) return;
|
|
158
|
+
if (!el.isConnected) { drop(el); return; }
|
|
159
|
+
const r = el.getBoundingClientRect();
|
|
160
|
+
const vw = win.innerWidth;
|
|
161
|
+
const vh = win.innerHeight;
|
|
162
|
+
const top = Math.max(0, r.top);
|
|
163
|
+
const left = Math.max(0, r.left);
|
|
164
|
+
const bottom = Math.min(vh, r.bottom);
|
|
165
|
+
const right = Math.min(vw, r.right);
|
|
166
|
+
// Screen px per CSS px, for content inside a zoomed tile.
|
|
167
|
+
const z = el.offsetHeight ? r.height / el.offsetHeight : 1;
|
|
168
|
+
for (const axis of ['y', 'x']) {
|
|
169
|
+
const key = axis === 'y' ? 'v' : 'h';
|
|
170
|
+
const overflows = scrollsOn(el, axis);
|
|
171
|
+
if (!overflows) { if (rec[key]) rec[key].bar.style.display = 'none'; continue; }
|
|
172
|
+
if (!rec[key]) rec[key] = makeBar(el, axis);
|
|
173
|
+
const { bar, thumb } = rec[key];
|
|
174
|
+
bar.style.display = '';
|
|
175
|
+
if (axis === 'y') {
|
|
176
|
+
const len = bottom - top;
|
|
177
|
+
Object.assign(bar.style, { top: `${top}px`, left: `${right - BAR - 1}px`, height: `${len}px`, width: `${BAR}px` });
|
|
178
|
+
const g = thumbGeometry(el.scrollHeight * z, el.clientHeight * z, el.scrollTop * z, len);
|
|
179
|
+
if (!g) { bar.style.display = 'none'; continue; }
|
|
180
|
+
Object.assign(thumb.style, { top: `${g.pos}px`, height: `${g.size}px`, left: '0', width: '100%' });
|
|
181
|
+
} else {
|
|
182
|
+
const len = right - left;
|
|
183
|
+
Object.assign(bar.style, { left: `${left}px`, top: `${bottom - BAR - 1}px`, width: `${len}px`, height: `${BAR}px` });
|
|
184
|
+
const g = thumbGeometry(el.scrollWidth * z, el.clientWidth * z, el.scrollLeft * z, len);
|
|
185
|
+
if (!g) { bar.style.display = 'none'; continue; }
|
|
186
|
+
Object.assign(thumb.style, { left: `${g.pos}px`, width: `${g.size}px`, top: '0', height: '100%' });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const setVisible = (el, on) => {
|
|
192
|
+
const rec = managed.get(el);
|
|
193
|
+
if (!rec) return;
|
|
194
|
+
for (const b of [rec.v, rec.h]) if (b) b.bar.classList.toggle('twm-autoscroll-bar--on', on);
|
|
195
|
+
};
|
|
196
|
+
const show = (el) => {
|
|
197
|
+
const rec = record(el);
|
|
198
|
+
clearTimeout(rec.timer);
|
|
199
|
+
place(el);
|
|
200
|
+
setVisible(el, true);
|
|
201
|
+
};
|
|
202
|
+
const hideLater = (el) => {
|
|
203
|
+
const rec = managed.get(el);
|
|
204
|
+
if (!rec) return;
|
|
205
|
+
clearTimeout(rec.timer);
|
|
206
|
+
rec.timer = setTimeout(() => {
|
|
207
|
+
if (rec.dragging || rec.onBar || hoverChain.has(el)) return;
|
|
208
|
+
setVisible(el, false);
|
|
209
|
+
}, HIDE_AFTER_MS);
|
|
210
|
+
};
|
|
211
|
+
const drop = (el) => {
|
|
212
|
+
const rec = managed.get(el);
|
|
213
|
+
if (!rec) return;
|
|
214
|
+
clearTimeout(rec.timer);
|
|
215
|
+
rec.v?.bar.remove();
|
|
216
|
+
rec.h?.bar.remove();
|
|
217
|
+
managed.delete(el);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** The scrollable elements from `target` up to `root`. */
|
|
221
|
+
const scrollChain = (target) => {
|
|
222
|
+
const out = new Set();
|
|
223
|
+
for (let el = target instanceof Element ? target : target?.parentElement; el && el !== doc.documentElement; el = el.parentElement) {
|
|
224
|
+
if (el.closest?.('.twm-autoscroll-layer')) return out;
|
|
225
|
+
if (eligible(el) && (scrollsOn(el, 'y') || scrollsOn(el, 'x'))) out.add(el);
|
|
226
|
+
if (el === root) break;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const onOver = (e) => {
|
|
232
|
+
if (e.target?.closest?.('.twm-autoscroll-layer')) return;
|
|
233
|
+
const next = scrollChain(e.target);
|
|
234
|
+
for (const el of hoverChain) if (!next.has(el)) hideLater(el);
|
|
235
|
+
for (const el of next) show(el);
|
|
236
|
+
hoverChain = next;
|
|
237
|
+
};
|
|
238
|
+
const onLeaveWindow = (e) => {
|
|
239
|
+
if (e.relatedTarget) return;
|
|
240
|
+
for (const el of hoverChain) hideLater(el);
|
|
241
|
+
hoverChain = new Set();
|
|
242
|
+
};
|
|
243
|
+
const onScroll = (e) => {
|
|
244
|
+
const el = e.target === doc ? null : e.target;
|
|
245
|
+
if (el && eligible(el)) { show(el); hideLater(el); }
|
|
246
|
+
// A scrolling ancestor moves every bar inside it.
|
|
247
|
+
for (const other of managed.keys()) if (other !== el) place(other);
|
|
248
|
+
};
|
|
249
|
+
let raf = 0;
|
|
250
|
+
const onResize = () => {
|
|
251
|
+
cancelAnimationFrame(raf);
|
|
252
|
+
raf = requestAnimationFrame(() => { for (const el of managed.keys()) place(el); });
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
doc.addEventListener('mouseover', onOver, true);
|
|
256
|
+
doc.addEventListener('mouseout', onLeaveWindow, true);
|
|
257
|
+
doc.addEventListener('scroll', onScroll, true);
|
|
258
|
+
win.addEventListener('resize', onResize);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
dispose() {
|
|
262
|
+
doc.removeEventListener('mouseover', onOver, true);
|
|
263
|
+
doc.removeEventListener('mouseout', onLeaveWindow, true);
|
|
264
|
+
doc.removeEventListener('scroll', onScroll, true);
|
|
265
|
+
win.removeEventListener('resize', onResize);
|
|
266
|
+
for (const el of [...managed.keys()]) drop(el);
|
|
267
|
+
root.classList.remove(ROOT_CLASS);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|