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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- 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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
458
|
+
}[c]));
|
|
459
|
+
}
|