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,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotebookTabBar — tab strip showing open files, with dirty indicators,
|
|
3
|
+
* close buttons, drag-and-drop reordering, double-click rename, and
|
|
4
|
+
* right-click context menu.
|
|
5
|
+
*
|
|
6
|
+
* Uses the same `.tab` / `.tab-title` / `.tab-dirty` / `.tab-close` DOM structure
|
|
7
|
+
* as the flow-page tab bar, so all editor tab bars share a unified appearance.
|
|
8
|
+
*
|
|
9
|
+
* Calls back into parent via:
|
|
10
|
+
* onActivate(filePath)
|
|
11
|
+
* onClose(filePath)
|
|
12
|
+
* onCloseOthers(filePath)
|
|
13
|
+
* onCloseToRight(filePath)
|
|
14
|
+
* onCloseAll()
|
|
15
|
+
* onCloseSaved()
|
|
16
|
+
* onRename(oldPath, newPath)
|
|
17
|
+
* onDuplicate(filePath)
|
|
18
|
+
* onReorder(orderedPaths)
|
|
19
|
+
* onRevealInExplorer(filePath)
|
|
20
|
+
* onSplitRight(filePath)
|
|
21
|
+
* onMoveToOtherPane(filePath)
|
|
22
|
+
* onDropFromOtherPane(filePath) — cross-pane drag-and-drop
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { DragReorder } from '../ui/components/drag_reorder.js';
|
|
26
|
+
|
|
27
|
+
/** Custom MIME type so tab bars recognise cross-pane drags. */
|
|
28
|
+
const TAB_MIME = 'application/x-ecosim-tab';
|
|
29
|
+
|
|
30
|
+
export class NotebookTabBar {
|
|
31
|
+
/** @type {HTMLElement} */
|
|
32
|
+
#container = null;
|
|
33
|
+
|
|
34
|
+
/** @type {object[]} [{filePath, label, fileType, isDirty}] */
|
|
35
|
+
#tabs = [];
|
|
36
|
+
|
|
37
|
+
/** @type {string|null} */
|
|
38
|
+
#activeTab = null;
|
|
39
|
+
|
|
40
|
+
/** @type {object} callbacks */
|
|
41
|
+
#callbacks = {};
|
|
42
|
+
|
|
43
|
+
// ─── Context menu ────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** @type {HTMLElement|null} */
|
|
46
|
+
#contextMenuEl = null;
|
|
47
|
+
|
|
48
|
+
/** @type {Function|null} */
|
|
49
|
+
#boundHideContextMenu = null;
|
|
50
|
+
|
|
51
|
+
// ─── Drag and drop ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** Same-pane reorder. Cross-pane drops stay in this class's own
|
|
54
|
+
* container handlers (#onStripDrag*).
|
|
55
|
+
* @type {DragReorder|null} */
|
|
56
|
+
#dragReorder = null;
|
|
57
|
+
|
|
58
|
+
mount(container, callbacks = {}) {
|
|
59
|
+
this.#container = container;
|
|
60
|
+
this.#callbacks = callbacks;
|
|
61
|
+
this.#container.className = 'tabs notebook-tabs';
|
|
62
|
+
this.#boundHideContextMenu = () => this.#hideContextMenu();
|
|
63
|
+
this.#installStripDnDHandlers();
|
|
64
|
+
this.#dragReorder = new DragReorder({
|
|
65
|
+
container: this.#container,
|
|
66
|
+
itemSelector: '.tab',
|
|
67
|
+
keyAttr: 'path',
|
|
68
|
+
axis: 'x',
|
|
69
|
+
draggableGuard: (e) => {
|
|
70
|
+
if (e.target.closest('.twm-tab-close')) return false;
|
|
71
|
+
const title = e.target.closest('.tab')?.querySelector('.twm-tab-title');
|
|
72
|
+
return title?.getAttribute('contenteditable') !== 'true';
|
|
73
|
+
},
|
|
74
|
+
onDragStart: (e, key) => {
|
|
75
|
+
// Carry the cross-pane MIME so other panes accept this tab.
|
|
76
|
+
try { e.dataTransfer.setData(TAB_MIME, key); } catch (_) {}
|
|
77
|
+
},
|
|
78
|
+
onReorder: (order) => this.#callbacks.onReorder?.(order),
|
|
79
|
+
});
|
|
80
|
+
this.#render();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sync tabs from ProjectModel state.
|
|
85
|
+
* @param {Array<{ filePath: string, fileType: string, isDirty: boolean }>} tabs
|
|
86
|
+
* @param {string|null} activeFilePath
|
|
87
|
+
*/
|
|
88
|
+
update(tabs, activeFilePath) {
|
|
89
|
+
this.#tabs = tabs;
|
|
90
|
+
this.#activeTab = activeFilePath;
|
|
91
|
+
this.#render();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
markDirty(filePath, isDirty) {
|
|
95
|
+
const tab = this.#tabs.find(t => t.filePath === filePath);
|
|
96
|
+
if (tab) {
|
|
97
|
+
tab.isDirty = isDirty;
|
|
98
|
+
this.#render();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
dispose() {
|
|
103
|
+
this.#hideContextMenu();
|
|
104
|
+
this.#removeStripDnDHandlers();
|
|
105
|
+
this.#dragReorder?.destroy();
|
|
106
|
+
this.#dragReorder = null;
|
|
107
|
+
this.#container = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Material symbol per file kind, merged over the generic set. The map used to
|
|
111
|
+
// hardcode EcoAgent's kinds — `scenario`, `dashboard`, `model` — so a generic
|
|
112
|
+
// tab bar knew one application's file taxonomy. Pass `fileTypeIcons` to add
|
|
113
|
+
// your own.
|
|
114
|
+
static #FILE_TYPE_ICONS = {
|
|
115
|
+
module: 'extension',
|
|
116
|
+
test: 'fact_check',
|
|
117
|
+
diff: 'difference',
|
|
118
|
+
unknown: 'description',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
static #EXTRA_ICONS = {};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Add your application's file kinds to the tab-bar icon map.
|
|
125
|
+
*
|
|
126
|
+
* The map used to hardcode `scenario: 'science'`, `dashboard: 'dashboard'` and
|
|
127
|
+
* `model: 'schema'` — EcoAgent's file taxonomy, baked into a generic tab bar.
|
|
128
|
+
*
|
|
129
|
+
* @param {Object.<string, string>} icons fileType -> material symbol name
|
|
130
|
+
*/
|
|
131
|
+
static setFileTypeIcons(icons = {}) {
|
|
132
|
+
NotebookTabBar.#EXTRA_ICONS = { ...icons };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static #iconFor(fileType) {
|
|
136
|
+
return NotebookTabBar.#EXTRA_ICONS[fileType]
|
|
137
|
+
?? NotebookTabBar.#FILE_TYPE_ICONS[fileType]
|
|
138
|
+
?? NotebookTabBar.#FILE_TYPE_ICONS.unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
142
|
+
// Rendering
|
|
143
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
#render() {
|
|
146
|
+
if (!this.#container) return;
|
|
147
|
+
|
|
148
|
+
this.#container.innerHTML = '';
|
|
149
|
+
|
|
150
|
+
for (const tab of this.#tabs) {
|
|
151
|
+
const isActive = tab.filePath === this.#activeTab;
|
|
152
|
+
const rawPath = tab.filePath.replace(/^(diff|builtin):\/\//, '');
|
|
153
|
+
const label = tab.label
|
|
154
|
+
?? (tab.fileType === 'diff'
|
|
155
|
+
? `↔ ${rawPath.split('/').pop()}`
|
|
156
|
+
: rawPath.split('/').pop().replace(/\.[^.]+$/, ''));
|
|
157
|
+
const icon = NotebookTabBar.#iconFor(tab.fileType);
|
|
158
|
+
|
|
159
|
+
const tabEl = document.createElement('div');
|
|
160
|
+
tabEl.className = `tab${isActive ? ' active' : ''}${tab.isPreview ? ' tab--preview' : ''}`;
|
|
161
|
+
tabEl.dataset.path = tab.filePath;
|
|
162
|
+
tabEl.title = tab.filePath;
|
|
163
|
+
|
|
164
|
+
tabEl.innerHTML = `
|
|
165
|
+
<span class="material-symbols-outlined tab-icon">${icon}</span>
|
|
166
|
+
<span class="twm-tab-title">${this.#escHtml(label)}</span>
|
|
167
|
+
${tab.isDirty ? '<span class="tab-dirty">\u25cf</span>' : ''}
|
|
168
|
+
${tab.readonly ? '' : '<button class="twm-tab-close" title="Close tab" aria-label="Close tab">\u00d7</button>'}
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
// Click → activate
|
|
172
|
+
tabEl.addEventListener('click', (e) => {
|
|
173
|
+
if (e.target.closest('.twm-tab-close')) return;
|
|
174
|
+
if (tabEl.querySelector('.twm-tab-title')?.getAttribute('contenteditable') === 'true') return;
|
|
175
|
+
this.#callbacks.onActivate?.(tab.filePath);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Close button
|
|
179
|
+
tabEl.querySelector('.twm-tab-close')?.addEventListener('click', (e) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
this.#callbacks.onClose?.(tab.filePath);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Double-click → inline rename
|
|
185
|
+
tabEl.addEventListener('dblclick', (e) => {
|
|
186
|
+
if (e.target.closest('.twm-tab-close')) return;
|
|
187
|
+
this.#beginInlineRename(tab.filePath);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// F2 → inline rename
|
|
191
|
+
tabEl.addEventListener('keydown', (e) => {
|
|
192
|
+
if (e.key === 'F2') {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
this.#beginInlineRename(tab.filePath);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Context menu
|
|
199
|
+
tabEl.addEventListener('contextmenu', (e) => {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
this.#showContextMenu(e.clientX, e.clientY, tab.filePath);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
this.#container.appendChild(tabEl);
|
|
206
|
+
}
|
|
207
|
+
// Make the freshly-rendered tabs draggable-to-reorder.
|
|
208
|
+
this.#dragReorder?.attach();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
212
|
+
// Drag and drop reordering (same-pane + cross-pane)
|
|
213
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
214
|
+
|
|
215
|
+
#installStripDnDHandlers() {
|
|
216
|
+
if (!this.#container) return;
|
|
217
|
+
this.#container.addEventListener('dragover', this.#onStripDragOver);
|
|
218
|
+
this.#container.addEventListener('drop', this.#onStripDrop);
|
|
219
|
+
this.#container.addEventListener('dragleave', this.#onStripDragLeave);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#removeStripDnDHandlers() {
|
|
223
|
+
if (!this.#container) return;
|
|
224
|
+
this.#container.removeEventListener('dragover', this.#onStripDragOver);
|
|
225
|
+
this.#container.removeEventListener('drop', this.#onStripDrop);
|
|
226
|
+
this.#container.removeEventListener('dragleave', this.#onStripDragLeave);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Check whether a drag event carries a cross-pane tab (not from this bar). */
|
|
230
|
+
#isExternalTabDrag(e) {
|
|
231
|
+
if (this.#dragReorder?.isActive()) return false; // same-pane drag is active
|
|
232
|
+
try { return e.dataTransfer.types.includes(TAB_MIME); } catch { return false; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Same-pane reorder is handled by the shared DragReorder module (set
|
|
236
|
+
// up in mount()). These container handlers cover ONLY cross-pane tab
|
|
237
|
+
// drops — gated on `#isExternalTabDrag`, which defers while our own
|
|
238
|
+
// reorder drag is active.
|
|
239
|
+
|
|
240
|
+
#onStripDragOver = (e) => {
|
|
241
|
+
if (this.#isExternalTabDrag(e)) {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
|
|
244
|
+
this.#container.classList.add('notebook-tabs--drop-target');
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
#onStripDrop = (e) => {
|
|
249
|
+
if (this.#isExternalTabDrag(e)) {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
this.#container.classList.remove('notebook-tabs--drop-target');
|
|
252
|
+
const filePath = e.dataTransfer.getData(TAB_MIME) || e.dataTransfer.getData('text/plain');
|
|
253
|
+
if (filePath) {
|
|
254
|
+
this.#callbacks.onDropFromOtherPane?.(filePath);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
#onStripDragLeave = (e) => {
|
|
260
|
+
if (!this.#container.contains(e.relatedTarget)) {
|
|
261
|
+
this.#container.classList.remove('notebook-tabs--drop-target');
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
266
|
+
// Context menu
|
|
267
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
268
|
+
|
|
269
|
+
#showContextMenu(x, y, filePath) {
|
|
270
|
+
this.#hideContextMenu();
|
|
271
|
+
|
|
272
|
+
const idx = this.#tabs.findIndex(t => t.filePath === filePath);
|
|
273
|
+
const total = this.#tabs.length;
|
|
274
|
+
|
|
275
|
+
const allItems = [
|
|
276
|
+
{ label: 'Rename', icon: 'edit', action: 'rename', shortcut: 'F2' },
|
|
277
|
+
{ label: 'Duplicate', icon: 'content_copy', action: 'duplicate' },
|
|
278
|
+
{ separator: true },
|
|
279
|
+
{ label: 'Split Right', icon: 'vertical_split', action: 'split-right', shortcut: 'Ctrl+\\' },
|
|
280
|
+
{ label: 'Move to Other Pane',icon: 'swap_horiz', action: 'move-to-other' },
|
|
281
|
+
{ separator: true },
|
|
282
|
+
{ label: 'Close', icon: 'close', action: 'close', danger: true },
|
|
283
|
+
{ label: 'Close Others', icon: 'close_fullscreen', action: 'close-others', danger: true, disabled: total <= 1 },
|
|
284
|
+
{ label: 'Close to the Right',icon: 'tab_close', action: 'close-right', danger: true, disabled: idx === -1 || idx >= total - 1 },
|
|
285
|
+
{ label: 'Close All', icon: 'tab_close_inactive', action: 'close-all', danger: true },
|
|
286
|
+
{ label: 'Close Saved', icon: 'check_circle', action: 'close-saved', danger: true },
|
|
287
|
+
{ separator: true },
|
|
288
|
+
{ label: 'Reveal in Explorer', icon: 'folder_open', action: 'reveal' },
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const menuFilter = this.#callbacks.menuFilter;
|
|
292
|
+
const items = menuFilter
|
|
293
|
+
? allItems.filter(item => item.separator || menuFilter(item.action, filePath) !== false)
|
|
294
|
+
: allItems;
|
|
295
|
+
|
|
296
|
+
// Collapse consecutive / leading / trailing separators
|
|
297
|
+
const cleaned = [];
|
|
298
|
+
for (const item of items) {
|
|
299
|
+
if (item.separator) {
|
|
300
|
+
if (cleaned.length > 0 && !cleaned[cleaned.length - 1].separator) cleaned.push(item);
|
|
301
|
+
} else {
|
|
302
|
+
cleaned.push(item);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
while (cleaned.length > 0 && cleaned[cleaned.length - 1].separator) cleaned.pop();
|
|
306
|
+
|
|
307
|
+
const menu = document.createElement('div');
|
|
308
|
+
menu.className = 'nb-context-menu';
|
|
309
|
+
|
|
310
|
+
for (const item of cleaned) {
|
|
311
|
+
if (item.separator) {
|
|
312
|
+
const sep = document.createElement('div');
|
|
313
|
+
sep.className = 'nb-context-menu__separator';
|
|
314
|
+
menu.appendChild(sep);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const btn = document.createElement('button');
|
|
319
|
+
btn.className = 'nb-context-menu__item';
|
|
320
|
+
if (item.danger) btn.classList.add('nb-context-menu__item--danger');
|
|
321
|
+
if (item.disabled) btn.classList.add('nb-context-menu__item--disabled');
|
|
322
|
+
|
|
323
|
+
btn.innerHTML = `<span class="material-symbols-outlined">${item.icon}</span><span>${this.#escHtml(item.label)}</span>${
|
|
324
|
+
item.shortcut ? `<span class="nb-context-menu__shortcut">${item.shortcut}</span>` : ''
|
|
325
|
+
}`;
|
|
326
|
+
|
|
327
|
+
if (!item.disabled) {
|
|
328
|
+
btn.addEventListener('click', (e) => {
|
|
329
|
+
e.stopPropagation();
|
|
330
|
+
this.#hideContextMenu();
|
|
331
|
+
this.#handleContextMenuAction(item.action, filePath);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
menu.appendChild(btn);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
document.body.appendChild(menu);
|
|
338
|
+
this.#contextMenuEl = menu;
|
|
339
|
+
|
|
340
|
+
// Position with viewport clamping
|
|
341
|
+
const rect = menu.getBoundingClientRect();
|
|
342
|
+
const left = Math.min(x, window.innerWidth - rect.width - 8);
|
|
343
|
+
const top = Math.min(y, window.innerHeight - rect.height - 8);
|
|
344
|
+
menu.style.left = `${Math.max(0, left)}px`;
|
|
345
|
+
menu.style.top = `${Math.max(0, top)}px`;
|
|
346
|
+
|
|
347
|
+
// Animate in
|
|
348
|
+
requestAnimationFrame(() => menu.classList.add('visible'));
|
|
349
|
+
|
|
350
|
+
// Dismiss on outside click or Escape
|
|
351
|
+
setTimeout(() => {
|
|
352
|
+
document.addEventListener('click', this.#boundHideContextMenu, true);
|
|
353
|
+
document.addEventListener('contextmenu', this.#boundHideContextMenu, true);
|
|
354
|
+
}, 0);
|
|
355
|
+
document.addEventListener('keydown', this.#onKeyDown);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#hideContextMenu() {
|
|
359
|
+
if (!this.#contextMenuEl) return;
|
|
360
|
+
this.#contextMenuEl.remove();
|
|
361
|
+
this.#contextMenuEl = null;
|
|
362
|
+
document.removeEventListener('click', this.#boundHideContextMenu, true);
|
|
363
|
+
document.removeEventListener('contextmenu', this.#boundHideContextMenu, true);
|
|
364
|
+
document.removeEventListener('keydown', this.#onKeyDown);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#onKeyDown = (e) => {
|
|
368
|
+
if (e.key === 'Escape') this.#hideContextMenu();
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
#handleContextMenuAction(action, filePath) {
|
|
372
|
+
switch (action) {
|
|
373
|
+
case 'rename': this.#beginInlineRename(filePath); break;
|
|
374
|
+
case 'duplicate': this.#callbacks.onDuplicate?.(filePath); break;
|
|
375
|
+
case 'close': this.#callbacks.onClose?.(filePath); break;
|
|
376
|
+
case 'close-others': this.#callbacks.onCloseOthers?.(filePath); break;
|
|
377
|
+
case 'close-right': this.#callbacks.onCloseToRight?.(filePath); break;
|
|
378
|
+
case 'close-all': this.#callbacks.onCloseAll?.(); break;
|
|
379
|
+
case 'close-saved': this.#callbacks.onCloseSaved?.(); break;
|
|
380
|
+
case 'split-right': this.#callbacks.onSplitRight?.(filePath); break;
|
|
381
|
+
case 'move-to-other':this.#callbacks.onMoveToOtherPane?.(filePath); break;
|
|
382
|
+
case 'reveal': this.#callbacks.onRevealInExplorer?.(filePath); break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
387
|
+
// Inline rename (contenteditable on tab title)
|
|
388
|
+
// ═════════════════════════════════════════════════════════════════════════
|
|
389
|
+
|
|
390
|
+
#beginInlineRename(filePath) {
|
|
391
|
+
const tabEl = this.#container?.querySelector(`.tab[data-path="${CSS.escape(filePath)}"]`);
|
|
392
|
+
if (!tabEl) return;
|
|
393
|
+
|
|
394
|
+
const title = tabEl.querySelector('.twm-tab-title');
|
|
395
|
+
if (!title || title.getAttribute('contenteditable') === 'true') return;
|
|
396
|
+
|
|
397
|
+
const tab = this.#tabs.find(t => t.filePath === filePath);
|
|
398
|
+
if (!tab) return;
|
|
399
|
+
|
|
400
|
+
// model.notebook, builtin, and readonly files cannot be renamed
|
|
401
|
+
if (tab.fileType === 'model') return;
|
|
402
|
+
if (filePath.startsWith('builtin://')) return;
|
|
403
|
+
if (tab.readonly) return;
|
|
404
|
+
|
|
405
|
+
const original = title.textContent || '';
|
|
406
|
+
title.setAttribute('contenteditable', 'true');
|
|
407
|
+
title.focus();
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const range = document.createRange();
|
|
411
|
+
range.selectNodeContents(title);
|
|
412
|
+
const sel = window.getSelection();
|
|
413
|
+
sel.removeAllRanges();
|
|
414
|
+
sel.addRange(range);
|
|
415
|
+
} catch (_) { /* ignore */ }
|
|
416
|
+
|
|
417
|
+
let committed = false;
|
|
418
|
+
|
|
419
|
+
const finish = (commit) => {
|
|
420
|
+
if (committed) return;
|
|
421
|
+
committed = true;
|
|
422
|
+
|
|
423
|
+
const newName = (title.textContent || '').trim();
|
|
424
|
+
title.removeAttribute('contenteditable');
|
|
425
|
+
title.removeEventListener('keydown', onKeyDown);
|
|
426
|
+
title.removeEventListener('blur', onBlur, true);
|
|
427
|
+
|
|
428
|
+
try { window.getSelection()?.removeAllRanges?.(); } catch (_) { /* ignore */ }
|
|
429
|
+
|
|
430
|
+
if (commit && newName && newName !== original && /^[\w][\w.-]*$/.test(newName)) {
|
|
431
|
+
title.textContent = newName;
|
|
432
|
+
const dir = filePath.split('/').slice(0, -1).join('/');
|
|
433
|
+
const ext = filePath.split('.').pop();
|
|
434
|
+
const newPath = `${dir}/${newName}.${ext}`;
|
|
435
|
+
this.#callbacks.onRename?.(filePath, newPath);
|
|
436
|
+
} else {
|
|
437
|
+
title.textContent = original;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const onKeyDown = (e) => {
|
|
442
|
+
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
|
443
|
+
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
|
444
|
+
e.stopPropagation();
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const onBlur = () => finish(true);
|
|
448
|
+
|
|
449
|
+
title.addEventListener('keydown', onKeyDown);
|
|
450
|
+
title.addEventListener('blur', onBlur, true);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#escHtml(s) {
|
|
454
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotebookUndoManager
|
|
3
|
+
*
|
|
4
|
+
* Command-pattern undo/redo for notebook cell operations.
|
|
5
|
+
*
|
|
6
|
+
* Each command: { description: string, undo: () => (void | Promise), redo: () => (void | Promise) }
|
|
7
|
+
*
|
|
8
|
+
* Operations tracked by NotebookEditor:
|
|
9
|
+
* - Cell add
|
|
10
|
+
* - Cell delete
|
|
11
|
+
* - Cell move (up/down, drag-reorder)
|
|
12
|
+
* - Cell config change (debounced per-cell snapshot)
|
|
13
|
+
*/
|
|
14
|
+
export class NotebookUndoManager {
|
|
15
|
+
|
|
16
|
+
/** @type {Array<{ description: string, undo: Function, redo: Function }>} */
|
|
17
|
+
#undoStack = [];
|
|
18
|
+
|
|
19
|
+
/** @type {Array<{ description: string, undo: Function, redo: Function }>} */
|
|
20
|
+
#redoStack = [];
|
|
21
|
+
|
|
22
|
+
/** @type {number} */
|
|
23
|
+
#maxSize;
|
|
24
|
+
|
|
25
|
+
/** @type {Function|null} Called with (canUndo: boolean, canRedo: boolean) after each change */
|
|
26
|
+
#onStateChange;
|
|
27
|
+
|
|
28
|
+
/** @type {boolean} Prevents re-entrant undo/redo */
|
|
29
|
+
#applying = false;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{ maxSize?: number, onStateChange?: Function }} opts
|
|
33
|
+
*/
|
|
34
|
+
constructor({ maxSize = 100, onStateChange = null } = {}) {
|
|
35
|
+
this.#maxSize = maxSize;
|
|
36
|
+
this.#onStateChange = onStateChange;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
get canUndo() { return this.#undoStack.length > 0; }
|
|
42
|
+
get canRedo() { return this.#redoStack.length > 0; }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Record a new command. Clears the redo stack.
|
|
46
|
+
* Should NOT be called from inside undo/redo callbacks.
|
|
47
|
+
*
|
|
48
|
+
* @param {{ description: string, undo: Function, redo: Function }} command
|
|
49
|
+
*/
|
|
50
|
+
push(command) {
|
|
51
|
+
if (this.#applying) return; // ignore changes triggered by undo/redo
|
|
52
|
+
this.#undoStack.push(command);
|
|
53
|
+
if (this.#undoStack.length > this.#maxSize) {
|
|
54
|
+
this.#undoStack.shift();
|
|
55
|
+
}
|
|
56
|
+
this.#redoStack = [];
|
|
57
|
+
this.#notify();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Undo the last command.
|
|
62
|
+
* @returns {Promise<boolean>} true if something was undone
|
|
63
|
+
*/
|
|
64
|
+
async undo() {
|
|
65
|
+
const cmd = this.#undoStack.pop();
|
|
66
|
+
if (!cmd) return false;
|
|
67
|
+
this.#applying = true;
|
|
68
|
+
try {
|
|
69
|
+
await cmd.undo();
|
|
70
|
+
} finally {
|
|
71
|
+
this.#applying = false;
|
|
72
|
+
}
|
|
73
|
+
this.#redoStack.push(cmd);
|
|
74
|
+
this.#notify();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Redo the last undone command.
|
|
80
|
+
* @returns {Promise<boolean>} true if something was redone
|
|
81
|
+
*/
|
|
82
|
+
async redo() {
|
|
83
|
+
const cmd = this.#redoStack.pop();
|
|
84
|
+
if (!cmd) return false;
|
|
85
|
+
this.#applying = true;
|
|
86
|
+
try {
|
|
87
|
+
await cmd.redo();
|
|
88
|
+
} finally {
|
|
89
|
+
this.#applying = false;
|
|
90
|
+
}
|
|
91
|
+
this.#undoStack.push(cmd);
|
|
92
|
+
this.#notify();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reset both stacks (e.g., after file load). */
|
|
97
|
+
clear() {
|
|
98
|
+
this.#undoStack = [];
|
|
99
|
+
this.#redoStack = [];
|
|
100
|
+
this.#notify();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
#notify() {
|
|
106
|
+
this.#onStateChange?.(this.canUndo, this.canRedo);
|
|
107
|
+
}
|
|
108
|
+
}
|