flexdesk 0.4.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- const focused = tree.focusedLeafId;
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
@@ -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
- // Freeze EVERY column at its current width on BOTH tables and
2279
- // lock fixed layout up front, so the drag moves only the grabbed
2280
- // column and a neighbour can never absorb it. Widths come from
2281
- // the header row (the column source of truth); the body's first
2282
- // row is pinned to match so the two tables stay in lock-step.
2283
- const startWidths = [...headRow.children].map(
2284
- (c) => c.getBoundingClientRect().width);
2285
- headerTable.style.tableLayout = 'fixed';
2286
- if (bodyTable) bodyTable.style.tableLayout = 'fixed';
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
- document.body.classList.add('twm-dt-col-resizing');
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 w = Math.max(
2295
- MIN, Math.round(startWidths[domIdx] + (mv.clientX - startX)));
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
+ }