flexdesk 0.4.4 → 0.4.6

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.6",
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": {
@@ -61,6 +61,8 @@ export class NotebookTabBar {
61
61
  this.#container.className = 'tabs notebook-tabs';
62
62
  this.#boundHideContextMenu = () => this.#hideContextMenu();
63
63
  this.#installStripDnDHandlers();
64
+ // NOT PASSIVE: it has to be able to take the wheel from the page.
65
+ this.#container.addEventListener('wheel', this.#onWheel, { passive: false });
64
66
  this.#dragReorder = new DragReorder({
65
67
  container: this.#container,
66
68
  itemSelector: '.tab',
@@ -118,6 +120,9 @@ export class NotebookTabBar {
118
120
  dispose() {
119
121
  this.#hideContextMenu();
120
122
  this.#removeStripDnDHandlers();
123
+ this.#container?.removeEventListener('wheel', this.#onWheel);
124
+ if (this.#revealFrame) cancelAnimationFrame(this.#revealFrame);
125
+ this.#revealFrame = 0;
121
126
  this.#dragReorder?.destroy();
122
127
  this.#dragReorder = null;
123
128
  this.#container = null;
@@ -161,6 +166,11 @@ export class NotebookTabBar {
161
166
  #render() {
162
167
  if (!this.#container) return;
163
168
 
169
+ // THE STRIP KEEPS ITS SCROLL ACROSS A REBUILD. Emptying it shrinks its
170
+ // scroll width to nothing, which clamps `scrollLeft` to 0 — so every
171
+ // render (a tab activated, a title changed) threw a strip scrolled to
172
+ // its tenth tab back to its first.
173
+ const keepLeft = this.#container.scrollLeft;
164
174
  this.#container.innerHTML = '';
165
175
 
166
176
  for (const tab of this.#tabs) {
@@ -222,8 +232,87 @@ export class NotebookTabBar {
222
232
  }
223
233
  // Make the freshly-rendered tabs draggable-to-reorder.
224
234
  this.#dragReorder?.attach();
235
+
236
+ this.#container.scrollLeft = keepLeft;
237
+ // A tab that has just BECOME active — opened, switched to, moved here
238
+ // from another pane — is scrolled into the strip. Only then: a render
239
+ // for any other reason (a title, a dirty dot) leaves the strip where the
240
+ // reader put it, rather than yanking it back to the active tab.
241
+ const revealKey = `${this.#activeTab}\u0000${this.#tabs.length}`;
242
+ if (revealKey !== this.#revealedKey) {
243
+ this.#revealedKey = revealKey;
244
+ this.#revealActive(true);
245
+ }
225
246
  }
226
247
 
248
+ // ═════════════════════════════════════════════════════════════════════════
249
+ // Scrolling (1.5) — the strip scrolls rather than wrapping or shrinking its
250
+ // tabs, and hides its scrollbar (a bar in a 26px strip eats a third of every
251
+ // label). So it owes a mouse user every other way in: a plain wheel, the
252
+ // active tab brought into view, and — in `DragReorder._autoScrollEdge` —
253
+ // the edges scrolling while a tab is dragged toward them.
254
+ // ═════════════════════════════════════════════════════════════════════════
255
+
256
+ /** @type {string|null} the active tab + count last scrolled into view */
257
+ #revealedKey = null;
258
+
259
+ /** @type {number} a pending retry, for a strip not yet laid out */
260
+ #revealFrame = 0;
261
+
262
+ /** Bring the active tab fully into the strip — and ONLY the strip. Not
263
+ * `scrollIntoView`, which scrolls every scrollable ancestor as well and
264
+ * would drag the tile, the window or the page along with the tab. */
265
+ #revealActive(retry = false) {
266
+ const el = this.#container;
267
+ if (!el) return;
268
+ const active = el.querySelector(':scope > .tab.active');
269
+ if (!active) return;
270
+ if (!el.isConnected || el.clientWidth === 0) {
271
+ // Mounted but not laid out yet (the renderer updates a strip before
272
+ // it attaches it). Once, on the next frame — not a loop.
273
+ if (retry && !this.#revealFrame && typeof requestAnimationFrame === 'function') {
274
+ this.#revealFrame = requestAnimationFrame(() => {
275
+ this.#revealFrame = 0;
276
+ this.#revealActive(false);
277
+ });
278
+ }
279
+ return;
280
+ }
281
+ const box = el.getBoundingClientRect();
282
+ const tab = active.getBoundingClientRect();
283
+ // Client pixels to the strip's own: 1 unless something above it zooms.
284
+ const scale = (el.offsetWidth ? box.width / el.offsetWidth : 1) || 1;
285
+ const left = (tab.left - box.left) / scale + el.scrollLeft;
286
+ const right = left + tab.width / scale;
287
+ if (left < el.scrollLeft) el.scrollLeft = left;
288
+ else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = right - el.clientWidth;
289
+ }
290
+
291
+ /**
292
+ * A VERTICAL WHEEL SCROLLS THE STRIP SIDEWAYS. Chromium and Firefox scroll
293
+ * an element that overflows only horizontally with Shift+wheel or a
294
+ * touchpad, and not with the plain wheel most people have — so with the
295
+ * scrollbar hidden, every tab past the edge was unreachable with a mouse.
296
+ *
297
+ * The wheel is taken from the page only while the strip can actually move
298
+ * that way: at either end it is left alone, so a wheel over a strip that
299
+ * has nothing more to show still scrolls whatever is behind it. A gesture
300
+ * that is already horizontal (a touchpad, Shift+wheel) and Ctrl+wheel (zoom)
301
+ * are the browser's, untouched.
302
+ */
303
+ #onWheel = (e) => {
304
+ const el = this.#container;
305
+ if (!el || e.ctrlKey || e.shiftKey) return;
306
+ if (Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return;
307
+ const room = el.scrollWidth - el.clientWidth;
308
+ if (room <= 0) return;
309
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientWidth : 1;
310
+ const next = Math.max(0, Math.min(room, el.scrollLeft + e.deltaY * unit));
311
+ if (Math.abs(next - el.scrollLeft) < 0.5) return;
312
+ e.preventDefault();
313
+ el.scrollLeft = next;
314
+ };
315
+
227
316
  // ═════════════════════════════════════════════════════════════════════════
228
317
  // Drag and drop reordering (same-pane + cross-pane)
229
318
  // ═════════════════════════════════════════════════════════════════════════
@@ -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