flexdesk 0.1.0

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * tile_renderer.js — maps a TileTree into nested flex containers, with
3
+ * resize splitters between siblings and a chrome strip per leaf.
4
+ *
5
+ * The renderer keeps a small per-leaf DOM cache so content factories
6
+ * mount exactly once per (kind, props) pair — switching the focused
7
+ * leaf or resizing doesn't re-mount tabs / pages.
8
+ */
9
+
10
+ const SPLITTER_PX = 4;
11
+
12
+ export class TileRenderer {
13
+ constructor({ root, tree, content, ctx, onFocusChange }) {
14
+ if (!content || typeof content.mount !== 'function') {
15
+ throw new Error('TileRenderer: a content registry is required');
16
+ }
17
+ this.root = root;
18
+ this.tree = tree;
19
+ // Shell-scoped kind -> factory table (see content_registry.js).
20
+ this.content = content;
21
+ this.ctx = ctx || {};
22
+ this.onFocusChange = onFocusChange || (() => {});
23
+ // leafId -> { wrapEl, bodyEl, chromeEl, content, kindKey }
24
+ this._leafCache = new Map();
25
+ this._drag = null;
26
+ this.root.classList.add('twm-root');
27
+ this.root.addEventListener('mousedown', this._onMouseDown.bind(this));
28
+ }
29
+
30
+ render() {
31
+ // Detaching a wrap (remove() then re-append() in `_mount`) wipes
32
+ // `scrollTop` on every scrollable descendant of that wrap.
33
+ // Snapshot per-leaf so we can restore after re-attach — keyed
34
+ // by element refs that survive the detach.
35
+ const savedScrolls = new Map(); // leafId -> [{el, top}, …]
36
+ for (const [leafId, entry] of this._leafCache.entries()) {
37
+ const snap = [];
38
+ entry.wrapEl.querySelectorAll('*').forEach((el) => {
39
+ if (el.scrollTop > 0 || el.scrollLeft > 0) {
40
+ snap.push({ el, top: el.scrollTop, left: el.scrollLeft });
41
+ }
42
+ });
43
+ savedScrolls.set(leafId, snap);
44
+ entry.wrapEl.remove();
45
+ }
46
+ this.root.innerHTML = '';
47
+ const tree = this.tree;
48
+ if (!tree.rootId) {
49
+ const empty = document.createElement('div');
50
+ empty.className = 'twm-empty';
51
+ empty.textContent = 'Empty desktop — Ctrl+K to open something.';
52
+ this.root.appendChild(empty);
53
+ this._cleanCache(new Set());
54
+ return;
55
+ }
56
+ const liveLeafIds = new Set();
57
+ this._mount(tree.rootId, this.root, liveLeafIds);
58
+ this._cleanCache(liveLeafIds);
59
+ this._updateFocusClasses();
60
+
61
+ // Restore scrolls — once synchronously and once next frame so
62
+ // any layout reflow between detach/reattach doesn't leave the
63
+ // browser's clamped value in place.
64
+ const restore = () => {
65
+ for (const [leafId, snap] of savedScrolls) {
66
+ if (!this._leafCache.has(leafId)) continue;
67
+ for (const { el, top, left } of snap) {
68
+ if (el.isConnected) {
69
+ el.scrollTop = top;
70
+ el.scrollLeft = left;
71
+ }
72
+ }
73
+ }
74
+ };
75
+ restore();
76
+ requestAnimationFrame(restore);
77
+ }
78
+
79
+ _cleanCache(liveSet) {
80
+ for (const [leafId, entry] of [...this._leafCache.entries()]) {
81
+ if (!liveSet.has(leafId)) {
82
+ try { entry.content?.destroy?.(); } catch (_) {}
83
+ entry.wrapEl.remove();
84
+ this._leafCache.delete(leafId);
85
+ }
86
+ }
87
+ }
88
+
89
+ _mount(nodeId, parentEl, liveSet) {
90
+ const node = this.tree.get(nodeId);
91
+ if (!node) return;
92
+ if (node.kind === 'leaf') {
93
+ liveSet.add(node.id);
94
+ const el = this._leafEl(node);
95
+ parentEl.appendChild(el);
96
+ return;
97
+ }
98
+ // Split node — flex container with N children + N-1 splitters.
99
+ const split = document.createElement('div');
100
+ split.className = `twm-split twm-split--${node.dir}`;
101
+ split.dataset.splitId = node.id;
102
+ parentEl.appendChild(split);
103
+ const total = node.sizes.reduce((a, b) => a + b, 0) || node.children.length;
104
+ node.children.forEach((cid, i) => {
105
+ const slot = document.createElement('div');
106
+ slot.className = 'twm-slot';
107
+ const frac = (node.sizes[i] || 1) / total;
108
+ slot.style.flex = `${frac} ${frac} 0`;
109
+ split.appendChild(slot);
110
+ this._mount(cid, slot, liveSet);
111
+ if (i < node.children.length - 1) {
112
+ const splitter = document.createElement('div');
113
+ splitter.className = `twm-splitter twm-splitter--${node.dir}`;
114
+ splitter.dataset.splitId = node.id;
115
+ splitter.dataset.slotIdx = String(i);
116
+ split.appendChild(splitter);
117
+ }
118
+ });
119
+ }
120
+
121
+ _leafEl(leaf) {
122
+ // Tab strip key is part of the cache key so the strip's chrome
123
+ // (which tabs exist, which one is active) re-renders when tabs
124
+ // mutate, but the active tab's body itself stays cached when the
125
+ // user clicks back to a tab they already visited.
126
+ const activeTab = (Array.isArray(leaf.tabs) && leaf.tabs.length > 0)
127
+ ? leaf.tabs[Math.max(0, Math.min(leaf.tabs.length - 1, leaf.activeTabIdx || 0))]
128
+ : null;
129
+ const tabFingerprint = (leaf.tabs || []).map((t) =>
130
+ `${t.kind}::${JSON.stringify(t.props || {})}`).join('|') + `#${leaf.activeTabIdx || 0}`;
131
+ const kindKey = leaf.content
132
+ ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}`
133
+ : '__empty__';
134
+
135
+ let entry = this._leafCache.get(leaf.id);
136
+ if (entry && entry.kindKey === kindKey) {
137
+ entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
138
+ return entry.wrapEl;
139
+ }
140
+ // Build fresh.
141
+ if (entry) {
142
+ try { entry.content?.destroy?.(); } catch (_) {}
143
+ entry.wrapEl.remove();
144
+ }
145
+ const wrap = document.createElement('div');
146
+ wrap.className = 'twm-leaf';
147
+ wrap.dataset.leafId = leaf.id;
148
+ // No tabIndex on the wrap — that would capture focus on click
149
+ // and break the `hostEl.contains(document.activeElement)` guard
150
+ // many tab modules use to scope their keyboard handlers. The
151
+ // body is given tabIndex=-1 below so we can focus the body
152
+ // (which IS the hostEl seen by the mounted tab content) on
153
+ // tile activation.
154
+ const chrome = document.createElement('div');
155
+ chrome.className = 'twm-leaf__chrome';
156
+ const title = document.createElement('span');
157
+ title.className = 'twm-leaf__title';
158
+ title.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
159
+ const actions = document.createElement('span');
160
+ actions.className = 'twm-leaf__actions';
161
+ // Panel tiles (left nav / right / bottom) can't be promoted to
162
+ // managed windows — they're chrome, not content. Hide the
163
+ // promote button in their chrome.
164
+ const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
165
+ // Split + promote are structural actions on a content tile; panels
166
+ // (left nav / right / bottom) are chrome, not content, so they only
167
+ // get the close affordance.
168
+ actions.innerHTML = `
169
+ ${isPanel ? '' : `
170
+ <button class="twm-leaf__btn" data-action="split-h" title="Split horizontally (Alt+H)">
171
+ <span class="material-symbols-outlined">splitscreen_vertical_add</span>
172
+ </button>
173
+ <button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
174
+ <span class="material-symbols-outlined">splitscreen_add</span>
175
+ </button>
176
+ <button class="twm-leaf__btn" data-action="promote" title="Promote to window (Alt+F)">
177
+ <span class="material-symbols-outlined">open_in_new</span>
178
+ </button>`}
179
+ <button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
180
+ <span class="material-symbols-outlined">close</span>
181
+ </button>
182
+ `;
183
+ chrome.append(title, actions);
184
+ const body = document.createElement('div');
185
+ body.className = 'twm-leaf__body';
186
+ body.tabIndex = -1; // focusable via JS so document.activeElement
187
+ // lands inside the body when the tile is
188
+ // activated (keyboard-handler scope check).
189
+ // Bottom tab strip — only rendered when the leaf carries
190
+ // more than one tab. The strip is built/rebuilt as part of
191
+ // the leaf element, not the body, so it stays pinned at the
192
+ // bottom of the tile regardless of body scroll.
193
+ const tabBar = document.createElement('div');
194
+ tabBar.className = 'twm-leaf__tabbar';
195
+ if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
196
+ tabBar.classList.add('twm-leaf__tabbar--hidden');
197
+ }
198
+ wrap.append(chrome, body, tabBar);
199
+
200
+ wrap.addEventListener('mousedown', (e) => {
201
+ if (e.target.closest('.twm-splitter')) return;
202
+ this.tree.focus(leaf.id);
203
+ this._updateFocusClasses();
204
+ this.onFocusChange(leaf.id);
205
+ // Park focus on the deepest focus host inside the body so
206
+ // page modules whose keydown scope is
207
+ // `hostEl.contains(document.activeElement)` actually see
208
+ // the activation. The page-shell wrapper, when present,
209
+ // is what tab modules treat as `hostEl`; falling back to
210
+ // the leaf body itself is fine for panel tiles.
211
+ const tgt = e.target;
212
+ const focusable = tgt.closest?.(
213
+ 'input, textarea, select, button, a, [contenteditable="true"], [tabindex]');
214
+ if (!focusable || !body.contains(focusable)) {
215
+ setTimeout(() => {
216
+ const ps = body.querySelector('.twm-page-shell__content')
217
+ ?? body;
218
+ if (ps.tabIndex == null || ps.tabIndex < -1) ps.tabIndex = -1;
219
+ ps.focus({ preventScroll: true });
220
+ }, 0);
221
+ }
222
+ });
223
+ // Right-click on the chrome → tile context menu (split, close,
224
+ // promote, move to desktop). Listening on `chrome` only so the
225
+ // tile body keeps its own contextmenu (e.g. DataTable's).
226
+ chrome.addEventListener('contextmenu', (e) => {
227
+ e.preventDefault();
228
+ this.tree.focus(leaf.id);
229
+ this._updateFocusClasses();
230
+ this.onFocusChange(leaf.id);
231
+ this.ctx.onTileContextMenu?.(leaf.id, e.clientX, e.clientY);
232
+ });
233
+ actions.addEventListener('click', (e) => {
234
+ const btn = e.target.closest('button');
235
+ if (!btn) return;
236
+ e.stopPropagation();
237
+ this.ctx.onLeafAction?.(leaf.id, btn.dataset.action);
238
+ });
239
+
240
+ // Mount content. The per-leaf ctx carries `leafId` so the
241
+ // mounted content can route subsequent navigation (e.g.
242
+ // `workspaceTabs.openTab(...)`) back into its own tile via
243
+ // `wm.openFromContext(ctx, ...)` instead of always hitting
244
+ // the primary tile.
245
+ let content = {};
246
+ if (leaf.content) {
247
+ const leafCtx = { ...this.ctx, leafId: leaf.id };
248
+ // Mount with the ACTIVE TAB's props, not `content.props`.
249
+ // `wm.updateActiveTabProps` (the canonical per-tile view-state
250
+ // store — survives reload + desktop switches via desktops.json)
251
+ // writes only into `tabs[active].props` and deliberately leaves
252
+ // `content.props` untouched so the cache key (kindKey) stays
253
+ // stable and a tab-prop change doesn't churn-rebuild the leaf.
254
+ // The trade-off: `content.props` goes stale, so a leaf rebuilt
255
+ // after a desktop round-trip must read the fresh tab props here.
256
+ const activeProps = activeTab?.props ?? leaf.content.props;
257
+ content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
258
+ if (content.title) title.textContent = content.title;
259
+ } else {
260
+ body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
261
+ }
262
+ entry = { wrapEl: wrap, bodyEl: body, chromeEl: chrome,
263
+ titleEl: title, content, kindKey, tabBarEl: tabBar };
264
+ this._leafCache.set(leaf.id, entry);
265
+
266
+ // Render tab strip last so it has access to the cached entry.
267
+ this._renderTabBar(leaf, entry);
268
+ return wrap;
269
+ }
270
+
271
+ /** Paint a leaf's bottom tab strip. The strip is hidden for
272
+ * single-tab leaves (the common case) so existing layouts read as
273
+ * identical to today. Hamburger button at the start, then one
274
+ * trapezoid-shaped tab per stored tab spec. */
275
+ _renderTabBar(leaf, entry) {
276
+ const bar = entry.tabBarEl;
277
+ if (!bar) return;
278
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
279
+ if (tabs.length <= 1) {
280
+ bar.classList.add('twm-leaf__tabbar--hidden');
281
+ bar.innerHTML = '';
282
+ return;
283
+ }
284
+ bar.classList.remove('twm-leaf__tabbar--hidden');
285
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
286
+ bar.innerHTML = `
287
+ <button type="button" class="twm-leaf__tab-hamburger"
288
+ data-action="tab-menu"
289
+ title="Open in new tab from this page's content">
290
+ <span class="material-symbols-outlined">menu</span>
291
+ </button>
292
+ <ol class="twm-leaf__tabs" role="tablist">
293
+ ${tabs.map((t, i) => `
294
+ <li class="twm-leaf__tab${i === activeIdx ? ' twm-leaf__tab--on' : ''}"
295
+ role="tab" data-tab-idx="${i}"
296
+ title="${_esc(t.title || t.kind)}"
297
+ draggable="true">
298
+ <span class="twm-leaf__tab-label">${_esc(t.title || t.kind)}</span>
299
+ <button type="button" class="twm-leaf__tab-close"
300
+ data-action="tab-close" data-tab-idx="${i}"
301
+ title="Close this tab"
302
+ aria-label="Close tab">
303
+ <span class="material-symbols-outlined">close</span>
304
+ </button>
305
+ </li>
306
+ `).join('')}
307
+ </ol>
308
+ `;
309
+
310
+ // Tab activation. Click anywhere on a tab (except its × button)
311
+ // switches to it; the × closes it.
312
+ bar.querySelectorAll('.twm-leaf__tab').forEach((li) => {
313
+ li.addEventListener('click', (ev) => {
314
+ if (ev.target.closest('[data-action="tab-close"]')) return;
315
+ const idx = Number(li.dataset.tabIdx);
316
+ this.ctx.onLeafTabAction?.(leaf.id, 'switch', { idx });
317
+ });
318
+ // Right-click → browser-style tab context menu (close, close
319
+ // others, close right / left). The WM mounts the actual menu
320
+ // — we just hand it the (idx, x, y) anchor.
321
+ li.addEventListener('contextmenu', (ev) => {
322
+ ev.preventDefault();
323
+ ev.stopPropagation();
324
+ const idx = Number(li.dataset.tabIdx);
325
+ this.ctx.onLeafTabAction?.(leaf.id, 'menu',
326
+ { idx, x: ev.clientX, y: ev.clientY });
327
+ });
328
+ });
329
+ bar.querySelectorAll('[data-action="tab-close"]').forEach((btn) => {
330
+ btn.addEventListener('click', (ev) => {
331
+ ev.preventDefault();
332
+ ev.stopPropagation();
333
+ const idx = Number(btn.dataset.tabIdx);
334
+ this.ctx.onLeafTabAction?.(leaf.id, 'close', { idx });
335
+ });
336
+ });
337
+ bar.querySelector('[data-action="tab-menu"]')
338
+ ?.addEventListener('click', (ev) => {
339
+ ev.preventDefault();
340
+ const r = ev.currentTarget.getBoundingClientRect();
341
+ this.ctx.onLeafTabAction?.(leaf.id, 'open-menu',
342
+ { x: r.left, y: r.top });
343
+ });
344
+
345
+ // Drag-to-reorder. Stash the source idx in dataTransfer; the
346
+ // drop target reads it and emits a 'move' action. We don't use
347
+ // HTML5 setData('text/plain') for the live state because the
348
+ // dragstart/drop pair runs entirely within the same tab list.
349
+ let dragFromIdx = null;
350
+ bar.querySelectorAll('.twm-leaf__tab').forEach((li) => {
351
+ li.addEventListener('dragstart', (ev) => {
352
+ dragFromIdx = Number(li.dataset.tabIdx);
353
+ ev.dataTransfer.effectAllowed = 'move';
354
+ // Firefox refuses to start a drag unless setData is
355
+ // called — supply a placeholder string.
356
+ try { ev.dataTransfer.setData('text/plain', String(dragFromIdx)); }
357
+ catch {}
358
+ });
359
+ li.addEventListener('dragover', (ev) => {
360
+ if (dragFromIdx == null) return;
361
+ ev.preventDefault();
362
+ ev.dataTransfer.dropEffect = 'move';
363
+ });
364
+ li.addEventListener('drop', (ev) => {
365
+ if (dragFromIdx == null) return;
366
+ ev.preventDefault();
367
+ const toIdx = Number(li.dataset.tabIdx);
368
+ if (toIdx !== dragFromIdx) {
369
+ this.ctx.onLeafTabAction?.(leaf.id, 'move',
370
+ { from: dragFromIdx, to: toIdx });
371
+ }
372
+ dragFromIdx = null;
373
+ });
374
+ });
375
+ }
376
+
377
+ _updateFocusClasses() {
378
+ const focused = this.tree.focusedLeafId;
379
+ for (const [leafId, entry] of this._leafCache) {
380
+ entry.wrapEl.classList.toggle('twm-leaf--focused', leafId === focused);
381
+ }
382
+ }
383
+
384
+ /** The `.twm-leaf` wrap element for a leaf id, or null if it isn't
385
+ * currently rendered. Used by the panel-key router to resolve which
386
+ * DOM subtree owns keyboard input. */
387
+ leafEl(leafId) {
388
+ return this._leafCache.get(leafId)?.wrapEl || null;
389
+ }
390
+
391
+ // ── Drag-resize ────────────────────────────────────────────────
392
+ _onMouseDown(e) {
393
+ const splitter = e.target.closest('.twm-splitter');
394
+ if (!splitter) return;
395
+ e.preventDefault();
396
+ const splitId = splitter.dataset.splitId;
397
+ const slotIdx = Number(splitter.dataset.slotIdx);
398
+ const split = this.tree.get(splitId);
399
+ if (!split) return;
400
+ const containerEl = splitter.parentElement;
401
+ const horizontal = split.dir === 'h';
402
+ const rect = containerEl.getBoundingClientRect();
403
+ const totalPx = horizontal ? rect.width : rect.height;
404
+ this._drag = {
405
+ splitId, slotIdx, horizontal, totalPx,
406
+ startSizes: split.sizes.slice(),
407
+ startPos: horizontal ? e.clientX : e.clientY,
408
+ split,
409
+ };
410
+ document.body.classList.add('twm-dragging');
411
+ const move = (ev) => this._onMouseMove(ev);
412
+ const up = (ev) => {
413
+ this._onMouseUp(ev);
414
+ document.removeEventListener('mousemove', move);
415
+ document.removeEventListener('mouseup', up);
416
+ };
417
+ document.addEventListener('mousemove', move);
418
+ document.addEventListener('mouseup', up);
419
+ }
420
+
421
+ _onMouseMove(e) {
422
+ if (!this._drag) return;
423
+ const { horizontal, totalPx, startSizes, startPos, slotIdx, split } = this._drag;
424
+ const delta = (horizontal ? e.clientX : e.clientY) - startPos;
425
+ const totalSize = startSizes.reduce((a, b) => a + b, 0);
426
+ const px = totalPx - SPLITTER_PX * (split.children.length - 1);
427
+ if (px <= 0) return;
428
+ const deltaFrac = (delta / px) * totalSize;
429
+ const minFrac = totalSize * 0.05;
430
+ const newSizes = startSizes.slice();
431
+ newSizes[slotIdx] = Math.max(minFrac, startSizes[slotIdx] + deltaFrac);
432
+ newSizes[slotIdx + 1] = Math.max(minFrac, startSizes[slotIdx + 1] - deltaFrac);
433
+ // Snap back to total to avoid drift.
434
+ const sum = newSizes.reduce((a, b) => a + b, 0);
435
+ const scale = totalSize / sum;
436
+ for (let i = 0; i < newSizes.length; i++) newSizes[i] *= scale;
437
+ this.tree.setSplitSizes(split.id, newSizes);
438
+ // Cheap update: just rewrite flex on the slots.
439
+ const containerEl = document.querySelector(`.twm-split[data-split-id="${split.id}"]`);
440
+ if (!containerEl) return;
441
+ const slots = containerEl.querySelectorAll(':scope > .twm-slot');
442
+ slots.forEach((slot, i) => {
443
+ const frac = newSizes[i] / totalSize;
444
+ slot.style.flex = `${frac} ${frac} 0`;
445
+ });
446
+ }
447
+
448
+ _onMouseUp() {
449
+ this._drag = null;
450
+ document.body.classList.remove('twm-dragging');
451
+ }
452
+ }
453
+
454
+
455
+ function _esc(s) {
456
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
457
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
458
+ }[c]));
459
+ }