flexdesk 0.2.0 → 0.3.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/css/base.css +1484 -181
- package/css/flexdesk.css +1311 -18
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1311 -18
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2983 -142
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +135 -24
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +82 -0
- package/src/tiling/wm.js +2352 -74
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- package/dist/chunk-UCJ2WD4D.js.map +0 -7
|
@@ -5,12 +5,258 @@
|
|
|
5
5
|
* The renderer keeps a small per-leaf DOM cache so content factories
|
|
6
6
|
* mount exactly once per (kind, props) pair — switching the focused
|
|
7
7
|
* leaf or resizing doesn't re-mount tabs / pages.
|
|
8
|
+
*
|
|
9
|
+
* ── C22. TWO TAB LAYOUTS, AND THE TREE KNOWS ABOUT NEITHER ────────────────
|
|
10
|
+
*
|
|
11
|
+
* A leaf's tabs live in the TREE (`Leaf: { tabs, activeTabIdx }`) and are the
|
|
12
|
+
* source of truth; `content`/`title` mirror `tabs[active]`. Where the strip is
|
|
13
|
+
* DRAWN, and what it looks like, is therefore a rendering decision and nothing
|
|
14
|
+
* else — which is why both layouts share one tab model, one action vocabulary
|
|
15
|
+
* (`switch` / `close` / `move` / `menu` / `open-menu`, all through
|
|
16
|
+
* `ctx.onLeafTabAction`) and one set of tree mutations in the WM.
|
|
17
|
+
*
|
|
18
|
+
* `bottom` — the framework's own spreadsheet strip under the tile body.
|
|
19
|
+
* THE DEFAULT, and it stays the default: an existing embedder
|
|
20
|
+
* upgrading must not find its panes rearranged.
|
|
21
|
+
* `top` — `NotebookTabBar` (`../editor/notebook_tab_bar.js`), the editor
|
|
22
|
+
* tab strip, mounted BETWEEN the chrome and the body. The chrome
|
|
23
|
+
* is the pane's identity — its glyph, its title, its verbs — and
|
|
24
|
+
* the tabs are its contents, so the tabs go under it, not over.
|
|
25
|
+
*
|
|
26
|
+
* The tab bar is reused rather than reimplemented. It already has the
|
|
27
|
+
* behaviours a top strip is expected to have — drag-to-reorder, a scrolling
|
|
28
|
+
* overflow, close buttons, dirty dots — and a second implementation of them
|
|
29
|
+
* here is a second set of the same bugs.
|
|
30
|
+
*
|
|
31
|
+
* ── The layout is also readable and writable as an ATTRIBUTE ──────────────
|
|
32
|
+
*
|
|
33
|
+
* `tabLayout` is a `createShell` option like `snapPromotion` and
|
|
34
|
+
* `promoteInPlace`, but unlike those it is something a USER may reasonably
|
|
35
|
+
* change while looking at the thing it changes. An embedder's settings pane
|
|
36
|
+
* holds no renderer reference — it holds, at most, the element it handed to
|
|
37
|
+
* `createShell` — so the renderer mirrors the option onto its own root as
|
|
38
|
+
* `data-twm-tabs` and OBSERVES it. Setting that attribute on the root element
|
|
39
|
+
* is then a complete, reference-free way to switch layout live, and the same
|
|
40
|
+
* attribute is the hook a stylesheet keys off. `setTabLayout()` is the direct
|
|
41
|
+
* route for anyone who does hold the renderer.
|
|
42
|
+
*
|
|
43
|
+
* Switching is deliberately cheap: only the strip is rebuilt and the tab bar
|
|
44
|
+
* element is MOVED between its two positions. The body is never detached, so
|
|
45
|
+
* no content factory is unmounted and nothing mounted in a tile — a grid with
|
|
46
|
+
* staged edits, an editor with an undo stack — notices that it happened.
|
|
47
|
+
*
|
|
48
|
+
* ── C33. A TAB DRAGGED INTO A TILE ───────────────────────────────────────
|
|
49
|
+
*
|
|
50
|
+
* *"I would like to be able to drag & drop tabs into tiles, too."* A tab could
|
|
51
|
+
* already be dragged WITHIN its own strip, and a WINDOW could already be
|
|
52
|
+
* dropped onto a tile; between the two there was nothing.
|
|
53
|
+
*
|
|
54
|
+
* ══ TWO DRAG MECHANISMS, AND THEY CANNOT BE ONE GESTURE ═════════════════
|
|
55
|
+
*
|
|
56
|
+
* This codebase drags things in two incompatible ways, and the choice here was
|
|
57
|
+
* made for us. Tab reorder is NATIVE HTML5 DRAG-AND-DROP — `draggable="true"`
|
|
58
|
+
* plus `dragstart`/`dragover`/`drop` — in both layouts: the `top` strip gets it
|
|
59
|
+
* from the shared `DragReorder`, the `bottom` strip hand-rolls the same three
|
|
60
|
+
* events. Window snapping is POINTER EVENTS
|
|
61
|
+
* (`../ui/components/managed_window.js`, `_dragState` with a `pointermove`
|
|
62
|
+
* listener on `document`). **Once a native drag begins the browser stops
|
|
63
|
+
* dispatching `pointermove` for its whole duration**, so a tab drag can never
|
|
64
|
+
* reach the pointer-driven snap controller, and a pointer-driven tab drag would
|
|
65
|
+
* be a second mechanism sitting beside the reorder that already works. So: this
|
|
66
|
+
* is HTML5 DnD, and it extends the gesture that exists rather than competing
|
|
67
|
+
* with it.
|
|
68
|
+
*
|
|
69
|
+
* ══ THE GEOMETRY IS SHARED ANYWAY ═══════════════════════════════════════
|
|
70
|
+
*
|
|
71
|
+
* What could not be shared is the transport; what MUST be shared is the answer.
|
|
72
|
+
* `wm.tabDropProbe` (R18) runs the same zone matrix a window drop runs, through
|
|
73
|
+
* the same `_dropZoneFor` tail (R17) — so a 28px edge means SPLIT and a centre
|
|
74
|
+
* means TAB-or-FILL for both, and the vocabulary is learned once. Product
|
|
75
|
+
* owner, 2026-08-27: the edge drop splits, "reusing the window snap-zone
|
|
76
|
+
* vocabulary".
|
|
77
|
+
*
|
|
78
|
+
* ══ THE SINGLE-TAB TILE HAS NO STRIP, SO THE CHROME IS THE HANDLE ═══════
|
|
79
|
+
*
|
|
80
|
+
* Both layouts hide the bar below two tabs (`_renderTabBar`), which is the
|
|
81
|
+
* common case — one table in one tile — so a feature that needs a tab strip to
|
|
82
|
+
* grab is a feature most tiles cannot offer. Product owner, 2026-08-27: the
|
|
83
|
+
* tile chrome / breadcrumb is the drag source for a single-tab leaf. With one
|
|
84
|
+
* tab there is no ambiguity to resolve — "move this tab" and "move what is in
|
|
85
|
+
* this pane" name the same thing — which is exactly why it is limited to that
|
|
86
|
+
* case: on a multi-tab leaf the strip already names each tab individually and a
|
|
87
|
+
* chrome drag would have to guess which one was meant.
|
|
88
|
+
*
|
|
89
|
+
* IT DOES NOT COST THE CHROME ITS EXISTING GESTURES — BUT IT DID, AND THE
|
|
90
|
+
* PARAGRAPH THAT USED TO SIT HERE IS WHY. It read: the pull "loses cleanly"
|
|
91
|
+
* to a native drag, and "is also still reachable, on every part of the chrome
|
|
92
|
+
* a single-tab leaf's title does not cover". That is a description of
|
|
93
|
+
* `draggable` on the TITLE. `_syncChromeDragSource` sets it on the CHROME —
|
|
94
|
+
* the whole strip — so there was no part left over, and since both layouts
|
|
95
|
+
* hide the tab strip below two tabs, a single-tab leaf is the ORDINARY tile
|
|
96
|
+
* rather than an edge case. Pull-down-to-float was dead application-wide, and
|
|
97
|
+
* the sentence saying it was fine sat in this file the whole time.
|
|
98
|
+
*
|
|
99
|
+
* The two gestures are separated by DIRECTION now, through one predicate
|
|
100
|
+
* (`_isDownwardPull`) that both of them read: a downward grab is the float,
|
|
101
|
+
* and `dragstart` refuses the native drag so the pointer goes back to the
|
|
102
|
+
* pull; anything else is the tab drag and is untouched. The double-click
|
|
103
|
+
* cannot collide with either — a `dblclick` carries no movement, so no drag
|
|
104
|
+
* ever starts.
|
|
105
|
+
*
|
|
106
|
+
* A CLAIM ABOUT A BROWSER NEEDS A BROWSER. Nothing in this repository's suites
|
|
107
|
+
* can begin a native drag — jsdom dispatches only the drag events a test hands
|
|
108
|
+
* it — so this collision was invisible to every gate on both sides and stayed
|
|
109
|
+
* that way through review. It was settled by driving real mouse input at
|
|
110
|
+
* Edge 152 over CDP: as shipped, a 140px pull on a table tile's chrome gives
|
|
111
|
+
* `dragstart`, `pointercancel`, `dragend` and no window; with the refusal,
|
|
112
|
+
* `pointermove` resumes and the pull fires at 26px; sideways and diagonal
|
|
113
|
+
* grabs still drag.
|
|
114
|
+
*
|
|
115
|
+
* A pane whose content VETOED `promote` (C20) is not a drag source either, and
|
|
116
|
+
* that is one rule rather than two: a tab you may not lift out of its pane is a
|
|
117
|
+
* tab you may not drag into another one. It is what keeps an embedder's master
|
|
118
|
+
* tile — the ground floating windows stand on — from being dragged away.
|
|
119
|
+
*
|
|
120
|
+
* ══ THE DRAG STATE LIVES ON THE RENDERER ════════════════════════════════
|
|
121
|
+
*
|
|
122
|
+
* `_tabDrag` is an instance field, not a per-leaf closure, and that is
|
|
123
|
+
* structural. The bottom strip's existing reorder keeps `dragFromIdx` in a
|
|
124
|
+
* closure built per leaf render, so leaf B's handlers cannot see that leaf A
|
|
125
|
+
* started a drag — which is fine for a reorder that begins and ends in one
|
|
126
|
+
* strip and useless for one that crosses tiles.
|
|
127
|
+
*
|
|
128
|
+
* The identity has to live there for a second reason: under the HTML5 protected
|
|
129
|
+
* mode `dataTransfer.getData()` returns the empty string for every event except
|
|
130
|
+
* `drop`, so the source leaf CANNOT be read while deciding whether to arm.
|
|
131
|
+
* `TILE_TAB_MIME` is therefore a type-only marker — an admission ticket, not a
|
|
132
|
+
* message — and everything else is read off `_tabDrag`.
|
|
133
|
+
*
|
|
134
|
+
* ══ AND THE LISTENERS ARE BOUND ONCE, ON THE ROOT ═══════════════════════
|
|
135
|
+
*
|
|
136
|
+
* `render()` does `this.root.innerHTML = ''`, so a listener attached to a leaf
|
|
137
|
+
* wrap during a repaint-heavy gesture is gone by the next frame; the
|
|
138
|
+
* constructor's `mousedown` is the pattern and these follow it. For the same
|
|
139
|
+
* reason NOTHING on the `dragover` path may call `render()` — rebuilding the
|
|
140
|
+
* wrap that holds the drag source aborts the native drag, and the DOM
|
|
141
|
+
* afterwards reads perfectly correct, which is the exact shape of the
|
|
142
|
+
* five-times-reported `mousedown`-repaint defect one mechanism over.
|
|
143
|
+
*
|
|
144
|
+
* OUT OF SCOPE, deliberately and in writing: dragging a tab out of a FLOATING
|
|
145
|
+
* WINDOW's strip (its tabs live in the WM's window record, not in the tree, so
|
|
146
|
+
* the source policy is a different function's); dropping on empty space to
|
|
147
|
+
* float a window (in HTML5 DnD "dropped on nothing" is `dragend` with no
|
|
148
|
+
* `drop`, which is also what Escape produces, so it would float a window every
|
|
149
|
+
* time a user changed their mind); and crossing DESKTOPS (only the active one
|
|
150
|
+
* is rendered, so there is nothing to hit).
|
|
8
151
|
*/
|
|
9
152
|
|
|
153
|
+
import { NotebookTabBar } from '../editor/notebook_tab_bar.js';
|
|
154
|
+
|
|
10
155
|
const SPLITTER_PX = 4;
|
|
156
|
+
/** The two tab layouts, and the framework's default. `bottom` is the default
|
|
157
|
+
* because it is what every existing embedder already renders. */
|
|
158
|
+
const TAB_LAYOUTS = ['bottom', 'top'];
|
|
159
|
+
const DEFAULT_TAB_LAYOUT = 'bottom';
|
|
160
|
+
|
|
161
|
+
/** Anything that is not one of the two layouts is the default rather than an
|
|
162
|
+
* error: this value arrives from a DOM attribute and from persisted user
|
|
163
|
+
* settings, and neither is a place to throw. */
|
|
164
|
+
function _normalizeTabLayout(value) {
|
|
165
|
+
return TAB_LAYOUTS.includes(value) ? value : DEFAULT_TAB_LAYOUT;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `NotebookTabBar`'s vocabulary is FILE PATHS, and a tile tab is not a file.
|
|
170
|
+
*
|
|
171
|
+
* The path is only ever an identity string to it — the key its reorder uses,
|
|
172
|
+
* the value it compares against `activeFilePath`, the selector it renames by —
|
|
173
|
+
* so a leaf's tab INDEX serves, and is the one identity a tab list is
|
|
174
|
+
* guaranteed to have (the tree gives tabs no ids and this renderer may not
|
|
175
|
+
* add any).
|
|
176
|
+
*
|
|
177
|
+
* The `builtin://` prefix is not decoration. `#beginInlineRename` refuses to
|
|
178
|
+
* start on a path with it (`notebook_tab_bar.js:402`), which is exactly what a
|
|
179
|
+
* tile tab needs: double-click rename would rename a TAB, and a tab is a view
|
|
180
|
+
* of an entity whose name lives somewhere this renderer cannot reach. A rename
|
|
181
|
+
* that silently does nothing is worse than no rename, and using the component's
|
|
182
|
+
* own guard beats disabling the gesture from outside.
|
|
183
|
+
*/
|
|
184
|
+
function _tabKey(idx) { return `builtin://tab/${idx}`; }
|
|
185
|
+
|
|
186
|
+
function _tabKeyIndex(key) {
|
|
187
|
+
const n = Number(String(key ?? '').replace('builtin://tab/', ''));
|
|
188
|
+
return Number.isInteger(n) && n >= 0 ? n : -1;
|
|
189
|
+
}
|
|
190
|
+
/** How far the chrome must be pulled DOWN before the pane lifts out as a
|
|
191
|
+
* floating window. Far enough that focusing a tile by clicking its header
|
|
192
|
+
* never becomes one by accident. */
|
|
193
|
+
const TILE_LIFT_PX = 24;
|
|
194
|
+
/** WHAT IS NOT A GRIP, for both of the chrome's float gestures.
|
|
195
|
+
*
|
|
196
|
+
* A tile's chrome is a grip with controls sitting on it — the split, float
|
|
197
|
+
* and close buttons on the right, whatever the content contributed on the
|
|
198
|
+
* left, and (defensively; the strip is a sibling of the chrome today, not a
|
|
199
|
+
* child) a tab. Neither the downward pull nor the double-click may fire when
|
|
200
|
+
* the press landed on one of those.
|
|
201
|
+
*
|
|
202
|
+
* ONE CONSTANT, TWO LISTENERS, and that is the point of writing it down
|
|
203
|
+
* rather than repeating the selector: two doors onto one verb that disagree
|
|
204
|
+
* about where the door is are worse than one door. A button added to this
|
|
205
|
+
* strip later must become inert under BOTH gestures at once, and it cannot
|
|
206
|
+
* do that if each of them carries its own copy of the rule. */
|
|
207
|
+
const CHROME_NO_FLOAT = 'button, .twm-leaf__tab';
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* IS THIS GESTURE THE DOWNWARD PULL? Asked in TWO places that must not be
|
|
211
|
+
* allowed to disagree, which is the only reason it is a function.
|
|
212
|
+
*
|
|
213
|
+
* The pull asks it of every `pointermove`, to decide whether 24px of travel
|
|
214
|
+
* has become a float. `_syncChromeDragSource`'s `dragstart` asks it of the
|
|
215
|
+
* first few pixels, to decide whether to REFUSE the native drag and leave the
|
|
216
|
+
* pointer to the pull. Two answers to "which way is this going" would produce
|
|
217
|
+
* a gesture that is neither: the drag declines, the pull never accepts, and
|
|
218
|
+
* the chrome does nothing at all.
|
|
219
|
+
*
|
|
220
|
+
* `sideways <= down` rather than `<`, so a gesture exactly on the diagonal
|
|
221
|
+
* belongs to the pull in both readers — the pull's own test has always been
|
|
222
|
+
* `sideways > down` rejects, so this is the same boundary written once.
|
|
223
|
+
*/
|
|
224
|
+
function _isDownwardPull(down, sideways) {
|
|
225
|
+
return down > 0 && sideways <= down;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* C33. THE MARKER THAT SAYS "THIS DRAG IS ONE OF OURS", AND NOTHING ELSE.
|
|
230
|
+
*
|
|
231
|
+
* It carries the literal string `'1'` because it carries no information at all.
|
|
232
|
+
* Under the HTML5 protected-mode rules `dataTransfer.getData()` answers the
|
|
233
|
+
* empty string for every event of a drag except `drop`, and only `types` is
|
|
234
|
+
* readable during `dragenter`/`dragover` — which is precisely when the decision
|
|
235
|
+
* to arm a drop zone has to be made. So the source leaf and tab index travel in
|
|
236
|
+
* `TileRenderer._tabDrag` and this type is an admission ticket.
|
|
237
|
+
*
|
|
238
|
+
* Encoding the leaf id into the type NAME (`…/tile-tab/leaf-7`) would make it
|
|
239
|
+
* readable on dragover and is the trap: `types` would then be unbounded, every
|
|
240
|
+
* consumer would have to parse it, and the string would become a wire format
|
|
241
|
+
* nobody declared. One constant, one meaning.
|
|
242
|
+
*/
|
|
243
|
+
export const TILE_TAB_MIME = 'application/x-twm-tile-tab';
|
|
244
|
+
|
|
245
|
+
/** Does this drag carry a tile tab? The ONLY admission test available during
|
|
246
|
+
* `dragover`, per the note on `TILE_TAB_MIME`. */
|
|
247
|
+
function _isTileTabDrag(e) {
|
|
248
|
+
try { return !!e.dataTransfer?.types?.includes(TILE_TAB_MIME); }
|
|
249
|
+
catch { return false; }
|
|
250
|
+
}
|
|
11
251
|
|
|
12
252
|
export class TileRenderer {
|
|
13
|
-
|
|
253
|
+
/** C33. Readable from a consumer that holds only the renderer — the Tables
|
|
254
|
+
* drop suite drives real drag events and has to build a `dataTransfer`
|
|
255
|
+
* stub that this renderer will admit. */
|
|
256
|
+
static get TAB_MIME() { return TILE_TAB_MIME; }
|
|
257
|
+
|
|
258
|
+
constructor({ root, tree, content, ctx, onFocusChange, onAfterRender,
|
|
259
|
+
tabLayout = null }) {
|
|
14
260
|
if (!content || typeof content.mount !== 'function') {
|
|
15
261
|
throw new Error('TileRenderer: a content registry is required');
|
|
16
262
|
}
|
|
@@ -20,14 +266,77 @@ export class TileRenderer {
|
|
|
20
266
|
this.content = content;
|
|
21
267
|
this.ctx = ctx || {};
|
|
22
268
|
this.onFocusChange = onFocusChange || (() => {});
|
|
23
|
-
|
|
269
|
+
/** Fired after every repaint, once the tree's DOM is settled.
|
|
270
|
+
*
|
|
271
|
+
* A LEAF'S WRAP IS REBUILT whenever its content key changes — a tab
|
|
272
|
+
* added, a tab removed, the leaf re-seeded — and anything an embedder
|
|
273
|
+
* parented INTO that wrap goes with the old one. A window contained in
|
|
274
|
+
* a pane (C21) is exactly that: promote a second tab out of a
|
|
275
|
+
* two-tab leaf and the first promotion's window is silently removed
|
|
276
|
+
* from the document, because its container was the wrap that the
|
|
277
|
+
* second promotion's re-render replaced.
|
|
278
|
+
*
|
|
279
|
+
* The renderer cannot know about windows, so it says "I have
|
|
280
|
+
* repainted" and the WM re-homes what it owns. */
|
|
281
|
+
this.onAfterRender = onAfterRender || (() => {});
|
|
282
|
+
// leafId -> { wrapEl, bodyEl, chromeEl, content, kindKey, tabStrip }
|
|
24
283
|
this._leafCache = new Map();
|
|
25
284
|
this._drag = null;
|
|
285
|
+
/** C33. The tab currently being carried: `{leafId, idx, el}`, or null.
|
|
286
|
+
* On the RENDERER rather than in a per-leaf closure — see the header:
|
|
287
|
+
* the bottom strip's `dragFromIdx` is per leaf render, and a drag that
|
|
288
|
+
* crosses tiles needs an identity leaf B's handlers can read. */
|
|
289
|
+
this._tabDrag = null;
|
|
290
|
+
/** The chrome pull-down's live pointer — `{startX, startY, x, y}` —
|
|
291
|
+
* while a press is held on a leaf chrome, else null. Read by the
|
|
292
|
+
* `dragstart` in `_syncChromeDragSource`, which has to decide whether
|
|
293
|
+
* this gesture is the float and get out of its way. Here rather than
|
|
294
|
+
* in the pull's own closure for `_tabDrag`'s reason: the reader is a
|
|
295
|
+
* method bound to a different element. */
|
|
296
|
+
this._chromePull = null;
|
|
297
|
+
/** The last `wm.tabDropProbe` answer, stashed on `dragover` because
|
|
298
|
+
* `drop` must not re-probe: the pointer can be one pixel outside the
|
|
299
|
+
* zone the preview drew, and the rectangle drawn is the rectangle the
|
|
300
|
+
* drop delivers (C15). */
|
|
301
|
+
this._tabDropProbe = null;
|
|
302
|
+
this._tabDropPreviewEl = null;
|
|
303
|
+
/** Set by `createShell().dispose()`. A renderer whose shell is gone must
|
|
304
|
+
* not paint: an embedder that rebuilds its shell over the same root
|
|
305
|
+
* would otherwise find a stale callback repainting the previous tree
|
|
306
|
+
* on top of the live one, and the two would fight over one element. */
|
|
307
|
+
this.disposed = false;
|
|
26
308
|
this.root.classList.add('twm-root');
|
|
309
|
+
// C22. The ATTRIBUTE WINS over the config when it is already set, and
|
|
310
|
+
// that order is the point: an embedder that persists this per user has
|
|
311
|
+
// its answer before `createShell` is called, on the element it owns,
|
|
312
|
+
// and does not have to race the shell's construction to apply it.
|
|
313
|
+
this.tabLayout = _normalizeTabLayout(
|
|
314
|
+
this.root.dataset?.twmTabs ?? tabLayout ?? DEFAULT_TAB_LAYOUT);
|
|
315
|
+
if (this.root.dataset) this.root.dataset.twmTabs = this.tabLayout;
|
|
316
|
+
// …and stays live. `setTabLayout` writes the attribute back, so this
|
|
317
|
+
// fires once with nothing to do rather than looping.
|
|
318
|
+
this._layoutObserver = typeof MutationObserver === 'function'
|
|
319
|
+
? new MutationObserver(() => this.setTabLayout(this.root.dataset?.twmTabs))
|
|
320
|
+
: null;
|
|
321
|
+
this._layoutObserver?.observe(this.root,
|
|
322
|
+
{ attributes: true, attributeFilter: ['data-twm-tabs'] });
|
|
27
323
|
this.root.addEventListener('mousedown', this._onMouseDown.bind(this));
|
|
324
|
+
// C33. ON THE ROOT, IN THE CONSTRUCTOR, FOR THE SAME REASON THE
|
|
325
|
+
// `mousedown` ABOVE IS. `render()` clears the root, so a listener bound
|
|
326
|
+
// to a leaf wrap is a listener that survives until the next repaint —
|
|
327
|
+
// and a drag is exactly the situation in which repaints happen.
|
|
328
|
+
this.root.addEventListener('dragover', this._onTabDragOver.bind(this));
|
|
329
|
+
this.root.addEventListener('drop', this._onTabDrop.bind(this));
|
|
330
|
+
this.root.addEventListener('dragleave', this._onTabDragLeave.bind(this));
|
|
331
|
+
// `dragend` FIRES ON THE SOURCE AND IS THE ONLY GUARANTEED END. A drag
|
|
332
|
+
// cancelled with Escape, or released over a window that refused it,
|
|
333
|
+
// produces `dragend` and no `drop` at all — so every scrap of painted
|
|
334
|
+
// state is torn down here rather than in the drop.
|
|
335
|
+
this.root.addEventListener('dragend', this._onTabDragEnd.bind(this));
|
|
28
336
|
}
|
|
29
337
|
|
|
30
338
|
render() {
|
|
339
|
+
if (this.disposed) return;
|
|
31
340
|
// Detaching a wrap (remove() then re-append() in `_mount`) wipes
|
|
32
341
|
// `scrollTop` on every scrollable descendant of that wrap.
|
|
33
342
|
// Snapshot per-leaf so we can restore after re-attach — keyed
|
|
@@ -43,6 +352,26 @@ export class TileRenderer {
|
|
|
43
352
|
savedScrolls.set(leafId, snap);
|
|
44
353
|
entry.wrapEl.remove();
|
|
45
354
|
}
|
|
355
|
+
// R13. A WINDOW PARENTED TO THE ROOT IS NOT A TILE, AND IS NOT THE
|
|
356
|
+
// RENDERER'S TO THROW AWAY. `innerHTML = ''` below is indiscriminate —
|
|
357
|
+
// it destroys every direct child — and the window manager parents
|
|
358
|
+
// floating windows here deliberately: FOR THE LENGTH OF EVERY DRAG
|
|
359
|
+
// (`dragHost`, R1), which is the live route and not a rare one, since a
|
|
360
|
+
// repaint during a drag needs nothing more exotic than the drag itself
|
|
361
|
+
// changing focus or a splitter moving. It was also permanent for a
|
|
362
|
+
// window snapped to fill the layer; R14 removed that mode — the top
|
|
363
|
+
// edge docks now — but the drag remains, and a consumer calling the
|
|
364
|
+
// public `toggleMaximize({claimable: false})` on a window the WM has
|
|
365
|
+
// left on its drag host puts one up here for good. Any repaint while a
|
|
366
|
+
// window was up here deleted its element silently: no error, nothing
|
|
367
|
+
// thrown, and a `ManagedWindow` object still perfectly alive with
|
|
368
|
+
// nothing on screen — the same shape of bug as the leaf wraps above,
|
|
369
|
+
// which is why they are detached and re-attached too rather than merely
|
|
370
|
+
// skipped. The tiles have to be rebuilt around them.
|
|
371
|
+
const floats = [...this.root.children].filter((el) =>
|
|
372
|
+
el.classList?.contains('twm-managed-window')
|
|
373
|
+
|| el.classList?.contains('twm-managed-window__backdrop'));
|
|
374
|
+
for (const el of floats) el.remove();
|
|
46
375
|
this.root.innerHTML = '';
|
|
47
376
|
const tree = this.tree;
|
|
48
377
|
if (!tree.rootId) {
|
|
@@ -51,11 +380,18 @@ export class TileRenderer {
|
|
|
51
380
|
empty.textContent = 'Empty desktop — Ctrl+K to open something.';
|
|
52
381
|
this.root.appendChild(empty);
|
|
53
382
|
this._cleanCache(new Set());
|
|
383
|
+
// An empty desktop is still a desktop a window can be floating
|
|
384
|
+
// over, and it is the one branch that returns early.
|
|
385
|
+
for (const el of floats) this.root.appendChild(el);
|
|
54
386
|
return;
|
|
55
387
|
}
|
|
56
388
|
const liveLeafIds = new Set();
|
|
57
389
|
this._mount(tree.rootId, this.root, liveLeafIds);
|
|
58
390
|
this._cleanCache(liveLeafIds);
|
|
391
|
+
// LAST, and before `onAfterRender` — which is where the WM re-homes
|
|
392
|
+
// the contained ones into their rebuilt wraps, and it can only move an
|
|
393
|
+
// element that is still in the document.
|
|
394
|
+
for (const el of floats) this.root.appendChild(el);
|
|
59
395
|
this._updateFocusClasses();
|
|
60
396
|
|
|
61
397
|
// Restore scrolls — once synchronously and once next frame so
|
|
@@ -74,12 +410,38 @@ export class TileRenderer {
|
|
|
74
410
|
};
|
|
75
411
|
restore();
|
|
76
412
|
requestAnimationFrame(restore);
|
|
413
|
+
try { this.onAfterRender(); }
|
|
414
|
+
catch (err) { console.error('[tile-renderer] onAfterRender threw', err); }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Unmount everything and stop painting. `dispose()` on the shell calls
|
|
418
|
+
* this: `root.innerHTML = ''` detaches DOM without telling a single content
|
|
419
|
+
* factory, so a page module that installed a `window` listener or an
|
|
420
|
+
* interval keeps both, invisibly, for the life of the tab. */
|
|
421
|
+
destroy() {
|
|
422
|
+
this._layoutObserver?.disconnect();
|
|
423
|
+
this._layoutObserver = null;
|
|
424
|
+
// C33. The drop preview is parented to `document.body`, not to the
|
|
425
|
+
// root, so `_cleanCache` cannot reach it: a shell torn down mid-drag
|
|
426
|
+
// would leave a blue rectangle painted over the page with nothing left
|
|
427
|
+
// that knows how to remove it.
|
|
428
|
+
this._clearTabDropPreview();
|
|
429
|
+
this._tabDrag = null;
|
|
430
|
+
this._tabDropProbe = null;
|
|
431
|
+
this._cleanCache(new Set());
|
|
432
|
+
this.disposed = true;
|
|
77
433
|
}
|
|
78
434
|
|
|
79
435
|
_cleanCache(liveSet) {
|
|
80
436
|
for (const [leafId, entry] of [...this._leafCache.entries()]) {
|
|
81
437
|
if (!liveSet.has(leafId)) {
|
|
82
438
|
try { entry.content?.destroy?.(); } catch (_) {}
|
|
439
|
+
// C22. The top strip is a COMPONENT, not markup: it holds a
|
|
440
|
+
// DragReorder with container listeners and a context menu
|
|
441
|
+
// parented to `document.body`. Dropping the element it lives in
|
|
442
|
+
// leaves both, invisibly, for the life of the page — the same
|
|
443
|
+
// reason `destroy()` exists on the content above it.
|
|
444
|
+
this._disposeTabStrip(entry);
|
|
83
445
|
entry.wrapEl.remove();
|
|
84
446
|
this._leafCache.delete(leafId);
|
|
85
447
|
}
|
|
@@ -126,20 +488,30 @@ export class TileRenderer {
|
|
|
126
488
|
const activeTab = (Array.isArray(leaf.tabs) && leaf.tabs.length > 0)
|
|
127
489
|
? leaf.tabs[Math.max(0, Math.min(leaf.tabs.length - 1, leaf.activeTabIdx || 0))]
|
|
128
490
|
: null;
|
|
129
|
-
const
|
|
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__';
|
|
491
|
+
const kindKey = this._leafKindKey(leaf);
|
|
134
492
|
|
|
135
493
|
let entry = this._leafCache.get(leaf.id);
|
|
136
494
|
if (entry && entry.kindKey === kindKey) {
|
|
137
495
|
entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
|
|
496
|
+
// C19. Re-read the glyph beside the title, and for the same reason:
|
|
497
|
+
// a cached leaf is NOT re-mounted, so anything the chrome shows
|
|
498
|
+
// that can change while the content stays put has to be refreshed
|
|
499
|
+
// here or it is frozen at whatever it was when the tile was built.
|
|
500
|
+
_paintLeafIcon(entry.iconEl, leaf, entry.content, this.ctx);
|
|
138
501
|
return entry.wrapEl;
|
|
139
502
|
}
|
|
140
503
|
// Build fresh.
|
|
141
504
|
if (entry) {
|
|
505
|
+
// DELETED, NOT JUST SUPERSEDED. The map still held this entry while
|
|
506
|
+
// the content's own `destroy` ran, so anything reaching the
|
|
507
|
+
// renderer from inside that teardown — `leafChrome`, and
|
|
508
|
+
// `rebaselineLeaf`, which would have written a key onto a wrap
|
|
509
|
+
// already removed from the document — read a corpse. The rebuild
|
|
510
|
+
// re-registers under the same id a few lines below, so the only
|
|
511
|
+
// window this closes is the one nothing should be looking in.
|
|
512
|
+
this._leafCache.delete(leaf.id);
|
|
142
513
|
try { entry.content?.destroy?.(); } catch (_) {}
|
|
514
|
+
this._disposeTabStrip(entry);
|
|
143
515
|
entry.wrapEl.remove();
|
|
144
516
|
}
|
|
145
517
|
const wrap = document.createElement('div');
|
|
@@ -153,11 +525,27 @@ export class TileRenderer {
|
|
|
153
525
|
// tile activation.
|
|
154
526
|
const chrome = document.createElement('div');
|
|
155
527
|
chrome.className = 'twm-leaf__chrome';
|
|
528
|
+
// C19. The glyph a floating window has always had, in the tile chrome
|
|
529
|
+
// too — `ManagedWindow._build` puts one left of its title
|
|
530
|
+
// (`../ui/components/managed_window.js:588-590`) and a tile, which is
|
|
531
|
+
// the SAME content in a different state, had nothing there. Two ways of
|
|
532
|
+
// looking at one table should not disagree about what a table looks
|
|
533
|
+
// like.
|
|
534
|
+
const icon = document.createElement('span');
|
|
535
|
+
icon.className = 'twm-leaf__icon material-symbols-outlined';
|
|
156
536
|
const title = document.createElement('span');
|
|
157
537
|
title.className = 'twm-leaf__title';
|
|
158
538
|
title.textContent = leaf.title || (leaf.content ? leaf.content.kind : 'empty');
|
|
159
539
|
const actions = document.createElement('span');
|
|
160
540
|
actions.className = 'twm-leaf__actions';
|
|
541
|
+
// C18. Content-contributed chrome actions land HERE, to the left of the
|
|
542
|
+
// structural ones, and the divider is not decoration: the buttons on
|
|
543
|
+
// the right act on the TILE (split it, float it, close it) and the ones
|
|
544
|
+
// on the left act on what is IN it (this table's settings, this table
|
|
545
|
+
// in a browser window). Two kinds of verb in one strip with nothing
|
|
546
|
+
// between them is how "close" gets read as "close the table".
|
|
547
|
+
const contentActions = document.createElement('span');
|
|
548
|
+
contentActions.className = 'twm-leaf__actions twm-leaf__actions--content';
|
|
161
549
|
// Panel tiles (left nav / right / bottom) can't be promoted to
|
|
162
550
|
// managed windows — they're chrome, not content. Hide the
|
|
163
551
|
// promote button in their chrome.
|
|
@@ -173,29 +561,37 @@ export class TileRenderer {
|
|
|
173
561
|
<button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
|
|
174
562
|
<span class="material-symbols-outlined">splitscreen_add</span>
|
|
175
563
|
</button>
|
|
176
|
-
<button class="twm-leaf__btn" data-action="promote" title="
|
|
177
|
-
<span class="material-symbols-outlined">
|
|
564
|
+
<button class="twm-leaf__btn" data-action="promote" title="Float this pane as a window (Alt+F)">
|
|
565
|
+
<span class="material-symbols-outlined">web_asset</span>
|
|
178
566
|
</button>`}
|
|
179
567
|
<button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
|
|
180
568
|
<span class="material-symbols-outlined">close</span>
|
|
181
569
|
</button>
|
|
182
570
|
`;
|
|
183
|
-
chrome.append(title, actions);
|
|
571
|
+
chrome.append(icon, title, contentActions, actions);
|
|
572
|
+
// Painted BEFORE the mount as well as after it, so a kind whose factory
|
|
573
|
+
// supplies no icon of its own — and every kind that existed before this
|
|
574
|
+
// did — still wears the taxonomy's glyph from the first frame rather
|
|
575
|
+
// than acquiring one a mount later.
|
|
576
|
+
_paintLeafIcon(icon, leaf, null, this.ctx);
|
|
184
577
|
const body = document.createElement('div');
|
|
185
578
|
body.className = 'twm-leaf__body';
|
|
186
579
|
body.tabIndex = -1; // focusable via JS so document.activeElement
|
|
187
580
|
// lands inside the body when the tile is
|
|
188
581
|
// activated (keyboard-handler scope check).
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// bottom of the tile regardless of body scroll.
|
|
582
|
+
// The tab strip — only rendered when the leaf carries more than one
|
|
583
|
+
// tab. It is built as part of the leaf element, not of the body, so it
|
|
584
|
+
// stays pinned at its edge of the tile regardless of body scroll.
|
|
193
585
|
const tabBar = document.createElement('div');
|
|
194
586
|
tabBar.className = 'twm-leaf__tabbar';
|
|
195
587
|
if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
|
|
196
588
|
tabBar.classList.add('twm-leaf__tabbar--hidden');
|
|
197
589
|
}
|
|
198
|
-
|
|
590
|
+
// C22. CHROME FIRST IN BOTH LAYOUTS. The chrome is the pane's identity
|
|
591
|
+
// and the tabs are what is inside it; a strip above the title would say
|
|
592
|
+
// the tabs own the pane rather than the other way round.
|
|
593
|
+
wrap.append(chrome, ...(this.tabLayout === 'top' ? [tabBar, body]
|
|
594
|
+
: [body, tabBar]));
|
|
199
595
|
|
|
200
596
|
wrap.addEventListener('mousedown', (e) => {
|
|
201
597
|
if (e.target.closest('.twm-splitter')) return;
|
|
@@ -220,6 +616,123 @@ export class TileRenderer {
|
|
|
220
616
|
}, 0);
|
|
221
617
|
}
|
|
222
618
|
});
|
|
619
|
+
// ── PULL THE HEADER DOWN TO FLOAT THE PANE ──────────────────────
|
|
620
|
+
//
|
|
621
|
+
// The inverse of the gesture everyone already knows: a maximised window
|
|
622
|
+
// is un-maximised by dragging its titlebar away from the edge, and a
|
|
623
|
+
// tile is a pane maximised into its slot. So dragging a tile's chrome
|
|
624
|
+
// DOWNWARD lifts it out as a floating window — the same thing the
|
|
625
|
+
// promote button does, reached the way a window manager teaches you to
|
|
626
|
+
// reach it.
|
|
627
|
+
//
|
|
628
|
+
// Downward only, and past a real threshold. A tile's chrome is also
|
|
629
|
+
// where you click to focus the pane, and a click carries a few pixels
|
|
630
|
+
// of movement with it; anything less than a deliberate pull is a click.
|
|
631
|
+
// Sideways is left alone: it means nothing here, and claiming it would
|
|
632
|
+
// make the strip feel like it grabs the pointer.
|
|
633
|
+
chrome.addEventListener('pointerdown', (e) => {
|
|
634
|
+
if (e.button !== 0) return;
|
|
635
|
+
if (e.target.closest(CHROME_NO_FLOAT)) return;
|
|
636
|
+
const startY = e.clientY;
|
|
637
|
+
const startX = e.clientX;
|
|
638
|
+
// PUBLISHED ON THE RENDERER, for `_syncChromeDragSource` to read.
|
|
639
|
+
//
|
|
640
|
+
// The native drag beats the pull to the pointer and there is no
|
|
641
|
+
// threshold at which it does not: Chromium starts one after about
|
|
642
|
+
// 5px of travel while the pull needs 24, so the ONLY moment the
|
|
643
|
+
// direction of this gesture can still be acted on is inside the
|
|
644
|
+
// `dragstart` the drag announces itself with. That handler has no
|
|
645
|
+
// other way to learn it — a `dragstart`'s own `clientX`/`clientY`
|
|
646
|
+
// read 0 for a drag begun this way (measured, Edge 152 /
|
|
647
|
+
// Chromium 152), so the event cannot answer the question being
|
|
648
|
+
// asked of it and this record has to.
|
|
649
|
+
//
|
|
650
|
+
// On the renderer rather than in this closure for `_tabDrag`'s
|
|
651
|
+
// reason: the reader is a method bound to a different element.
|
|
652
|
+
const pull = { startX, startY, x: startX, y: startY, lifted: false };
|
|
653
|
+
this._chromePull = pull;
|
|
654
|
+
const onMove = (move) => {
|
|
655
|
+
pull.x = move.clientX;
|
|
656
|
+
pull.y = move.clientY;
|
|
657
|
+
if (pull.lifted) return;
|
|
658
|
+
const down = move.clientY - startY;
|
|
659
|
+
const sideways = Math.abs(move.clientX - startX);
|
|
660
|
+
if (down < TILE_LIFT_PX || !_isDownwardPull(down, sideways)) return;
|
|
661
|
+
pull.lifted = true;
|
|
662
|
+
// ONLY `pointermove` COMES OFF HERE, AND THE RECORD STAYS —
|
|
663
|
+
// `cleanup()` would take it, and taking it is a defect.
|
|
664
|
+
//
|
|
665
|
+
// A `pointermove` can be COALESCED: one event may carry the
|
|
666
|
+
// whole 24px and promote on its first delivery, and Blink
|
|
667
|
+
// hands that move to script BEFORE starting the drag it
|
|
668
|
+
// crossed the threshold for — so a `dragstart` still arrives
|
|
669
|
+
// afterwards, and `_syncChromeDragSource` has to be able to
|
|
670
|
+
// read the gesture to refuse it. Without the record it did
|
|
671
|
+
// not, and a tab drag began on the pane that had just floated
|
|
672
|
+
// out of the tile. `pointerup` below is what clears it.
|
|
673
|
+
window.removeEventListener('pointermove', onMove);
|
|
674
|
+
this.ctx.onLeafAction?.(leaf.id, 'promote');
|
|
675
|
+
};
|
|
676
|
+
const cleanup = () => {
|
|
677
|
+
if (this._chromePull === pull) this._chromePull = null;
|
|
678
|
+
window.removeEventListener('pointermove', onMove);
|
|
679
|
+
window.removeEventListener('pointerup', cleanup);
|
|
680
|
+
window.removeEventListener('pointercancel', cleanup);
|
|
681
|
+
};
|
|
682
|
+
window.addEventListener('pointermove', onMove);
|
|
683
|
+
window.addEventListener('pointerup', cleanup);
|
|
684
|
+
window.addEventListener('pointercancel', cleanup);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// ── …OR DOUBLE-CLICK IT, WHICH IS THE GESTURE THAT ALREADY HAD A
|
|
688
|
+
// RETURN JOURNEY AND NO OUTWARD ONE ────────────────────────────
|
|
689
|
+
//
|
|
690
|
+
// Double-clicking a FLOATING window's title bar docks it back into a
|
|
691
|
+
// tile — `managed_window.js:919` binds it to `toggleMaximize()`, whose
|
|
692
|
+
// claimed verb under this WM is `bringBackWindow` ("maximise means back
|
|
693
|
+
// to tile", R7 — see the `onMaximize` `wm._promote` builds). So the
|
|
694
|
+
// half of the toggle that PUTS a
|
|
695
|
+
// pane back was spelled as a double-click on a header, and the half
|
|
696
|
+
// that takes it out was not: a user who learned the docking gesture had
|
|
697
|
+
// to reach for the float button, or discover the pull above, to undo
|
|
698
|
+
// what a double-click had just done. One gesture, one strip, both
|
|
699
|
+
// directions.
|
|
700
|
+
//
|
|
701
|
+
// The pull and this are two doors onto ONE verb, not two verbs. Both go
|
|
702
|
+
// through `onLeafAction(leaf.id, 'promote')`, so the WM's guards apply
|
|
703
|
+
// to both without knowing there are two — a panel tile and a window
|
|
704
|
+
// placeholder are refused in `wm._floatableLeaf` and a
|
|
705
|
+
// second double-click on an already-floated pane finds a re-seeded
|
|
706
|
+
// start tile rather than the content, which is the tree telling the
|
|
707
|
+
// truth rather than a case to special-case here.
|
|
708
|
+
//
|
|
709
|
+
// NATIVE `dblclick`, NOT A HAND-ROLLED CLICK COUNT — and that is a
|
|
710
|
+
// claim about the handlers above, not a preference. C28
|
|
711
|
+
// (`managed_window.js:1305`) is the case that says why: its pointerdown
|
|
712
|
+
// re-parented the window element out of the document before the pointer
|
|
713
|
+
// had moved, Blink and Gecko drop the pending `click` when the pressed
|
|
714
|
+
// element leaves the document, and the title bar's `dblclick` therefore
|
|
715
|
+
// never fired once in a real browser while looking perfectly correct in
|
|
716
|
+
// jsdom. Nothing on this strip does that. The wrap's `mousedown`
|
|
717
|
+
// toggles a class and calls `onFocusChange`, which the WM turns into
|
|
718
|
+
// `wm._notifyChange` — a callback and two bus emits, no repaint; its
|
|
719
|
+
// deferred `body.focus()` moves focus and moves
|
|
720
|
+
// nothing else, and focus does not reset a click count. The pull's
|
|
721
|
+
// `pointerdown` only registers window listeners and neither
|
|
722
|
+
// `preventDefault`s nor captures the pointer. The chrome the first
|
|
723
|
+
// click lands on is still in the document, still the same element, when
|
|
724
|
+
// the second one lands.
|
|
725
|
+
//
|
|
726
|
+
// No `e.button` test, unlike the pull: `dblclick` is dispatched for the
|
|
727
|
+
// primary button only. No `preventDefault`, and NO NEW CSS: the strip
|
|
728
|
+
// is already `user-select: none` (`css/overrides.css:1001`, mirrored
|
|
729
|
+
// into the generated `css/flexdesk.css:4743`), so a double-click here
|
|
730
|
+
// cannot word-select the title on its way to floating the pane.
|
|
731
|
+
chrome.addEventListener('dblclick', (e) => {
|
|
732
|
+
if (e.target.closest(CHROME_NO_FLOAT)) return;
|
|
733
|
+
this.ctx.onLeafAction?.(leaf.id, 'promote');
|
|
734
|
+
});
|
|
735
|
+
|
|
223
736
|
// Right-click on the chrome → tile context menu (split, close,
|
|
224
737
|
// promote, move to desktop). Listening on `chrome` only so the
|
|
225
738
|
// tile body keeps its own contextmenu (e.g. DataTable's).
|
|
@@ -256,11 +769,45 @@ export class TileRenderer {
|
|
|
256
769
|
const activeProps = activeTab?.props ?? leaf.content.props;
|
|
257
770
|
content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
|
|
258
771
|
if (content.title) title.textContent = content.title;
|
|
772
|
+
// C19. `icon` is the natural sibling of `title` and is read the
|
|
773
|
+
// same way: AFTER the mount, because that is the first moment the
|
|
774
|
+
// content knows what it is — a canvas pane you emptied is the same
|
|
775
|
+
// KIND as the overview and wants a different glyph.
|
|
776
|
+
_paintLeafIcon(icon, leaf, content, this.ctx);
|
|
777
|
+
// C18. A content factory may return `chromeActions`:
|
|
778
|
+
// `[{icon, title, onClick}]`. Read AFTER the mount, because that is
|
|
779
|
+
// the first moment the content knows what it can offer — a table
|
|
780
|
+
// does not know it has settings until it has a definition.
|
|
781
|
+
_paintContentActions(contentActions, content.chromeActions);
|
|
782
|
+
// C20. A content factory may also VETO structural verbs:
|
|
783
|
+
// `chrome: { promote: false, close: false }`. Read after the mount
|
|
784
|
+
// for the same reason `chromeActions` is — it is the first moment
|
|
785
|
+
// the content knows what it is.
|
|
786
|
+
//
|
|
787
|
+
// The case that forced it: an embedder's MASTER tile. It is the
|
|
788
|
+
// ground floating windows stand on and the thing you are left with
|
|
789
|
+
// when everything else is closed, so "float this pane as a window"
|
|
790
|
+
// and "close this pane" are offers to destroy the only surface the
|
|
791
|
+
// application has. The renderer already made this exact judgement
|
|
792
|
+
// for `panel:*` kinds by hardcoding it; this is that judgement,
|
|
793
|
+
// available to anyone.
|
|
794
|
+
_vetoStructuralActions(actions, content.chrome);
|
|
259
795
|
} else {
|
|
260
796
|
body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
|
|
261
797
|
}
|
|
262
798
|
entry = { wrapEl: wrap, bodyEl: body, chromeEl: chrome,
|
|
263
|
-
titleEl: title,
|
|
799
|
+
titleEl: title, iconEl: icon, content,
|
|
800
|
+
// DERIVED HERE, NOT REUSED FROM ABOVE. `kindKey` was computed
|
|
801
|
+
// before the mount; content that records its sub-tab or its
|
|
802
|
+
// scroll offset WHILE mounting has already written to the
|
|
803
|
+
// tree by now, so the value captured earlier is stale and the
|
|
804
|
+
// very next repaint would tear down the tile that had just
|
|
805
|
+
// said where it was.
|
|
806
|
+
kindKey: this._leafKindKey(leaf), tabBarEl: tabBar,
|
|
807
|
+
// C22. The `top` layout's NotebookTabBar, and the child it
|
|
808
|
+
// mounts into. Null under the `bottom` layout, which is plain
|
|
809
|
+
// markup this file writes itself.
|
|
810
|
+
tabStrip: null, tabStripHostEl: null };
|
|
264
811
|
this._leafCache.set(leaf.id, entry);
|
|
265
812
|
|
|
266
813
|
// Render tab strip last so it has access to the cached entry.
|
|
@@ -268,25 +815,474 @@ export class TileRenderer {
|
|
|
268
815
|
return wrap;
|
|
269
816
|
}
|
|
270
817
|
|
|
271
|
-
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
818
|
+
/**
|
|
819
|
+
* The cache key for a leaf: everything that must change before its wrap is
|
|
820
|
+
* torn down and rebuilt. Extracted so `rebaselineLeaf` below computes the
|
|
821
|
+
* same string this does — two spellings of one key is a cache that misses
|
|
822
|
+
* on every render or never misses at all, and both look like working code.
|
|
823
|
+
*/
|
|
824
|
+
_leafKindKey(leaf) {
|
|
825
|
+
const tabFingerprint = (leaf.tabs || []).map((t) =>
|
|
826
|
+
`${t.kind}::${JSON.stringify(t.props || {})}`).join('|') + `#${leaf.activeTabIdx || 0}`;
|
|
827
|
+
return leaf.content
|
|
828
|
+
? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}`
|
|
829
|
+
: '__empty__';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* The cache key a leaf has RIGHT NOW, for a caller that is about to change
|
|
834
|
+
* the tree and wants to say what it expected to be changing from. See
|
|
835
|
+
* `rebaselineLeaf`.
|
|
836
|
+
*/
|
|
837
|
+
leafKey(leafId) {
|
|
838
|
+
const leaf = this.tree?.get?.(leafId);
|
|
839
|
+
return (leaf && leaf.kind === 'leaf') ? this._leafKindKey(leaf) : null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* C34. ACCEPT A PROPS WRITE THE CONTENT MADE ABOUT ITSELF, WITHOUT
|
|
844
|
+
* REBUILDING THE TILE THAT MADE IT.
|
|
845
|
+
*
|
|
846
|
+
* The cache key above includes every tab's props, and that is right for
|
|
847
|
+
* NAVIGATION: a `table` tab whose `id` changes is different content and the
|
|
848
|
+
* tile must be re-mounted. It is exactly wrong for VIEW STATE. The
|
|
849
|
+
* framework hands every tile a `workspaceTabs.updateProps(patch)`
|
|
850
|
+
* (`page_factory.js`) documented as *"persist editor sub-state into this
|
|
851
|
+
* tile's WM tab props"* — a scroll offset, an open section, a selected
|
|
852
|
+
* sub-tab. Writing one changed the fingerprint, so the NEXT repaint (a
|
|
853
|
+
* focus change, a tab switch, a window promoted three tiles away) missed
|
|
854
|
+
* the cache, destroyed the content and mounted it again. The tile was torn
|
|
855
|
+
* down BY the call that existed to let it remember something, and because
|
|
856
|
+
* the rebuild reads the props back the result looked almost right — the
|
|
857
|
+
* editor came back at the saved scroll position, with everything uncommitted
|
|
858
|
+
* in it gone.
|
|
859
|
+
*
|
|
860
|
+
* So the key is re-baselined instead: the entry keeps its live DOM and
|
|
861
|
+
* starts answering to the new props. The next real navigation still misses
|
|
862
|
+
* and still rebuilds, because that changes the kind or the tab set and this
|
|
863
|
+
* only ever accepts what is already on screen.
|
|
864
|
+
*
|
|
865
|
+
* ══ IT ACCEPTS ONLY THE DELTA IT WAS CALLED FOR ═══════════════════
|
|
866
|
+
*
|
|
867
|
+
* The key is re-derived from the tree as it is NOW, so a first version of
|
|
868
|
+
* this swallowed every difference at once — including a change the tree had
|
|
869
|
+
* taken and the renderer had not drawn yet. Any mutation that does not
|
|
870
|
+
* repaint (`updateActiveTabProps` is itself one, and an embedder writing
|
|
871
|
+
* through `TileTree` directly is another) followed by a props write would
|
|
872
|
+
* have been accepted onto a wrap still showing the OLD content, and the
|
|
873
|
+
* tile would never have re-mounted: one thing drawn under another's title,
|
|
874
|
+
* permanently, with nothing left that knows the two disagree.
|
|
875
|
+
*
|
|
876
|
+
* `expected` is the fix and it is the caller's own honesty: the key it read
|
|
877
|
+
* BEFORE its write. If the cached entry is not still at that key, something
|
|
878
|
+
* else has changed since the tile was mounted and this is not the caller's
|
|
879
|
+
* to accept — refuse, and let the ordinary miss rebuild it.
|
|
880
|
+
*
|
|
881
|
+
* A structural test was tried first and is not enough. *Same kind, different
|
|
882
|
+
* props* is a scroll offset AND it is a navigation to another table; nothing
|
|
883
|
+
* in the leaf can tell them apart, because the difference is which caller
|
|
884
|
+
* asked. `expected` asks the caller.
|
|
885
|
+
*
|
|
886
|
+
* @param {string} leafId
|
|
887
|
+
* @param {string} [expected] the key the caller read before its own write.
|
|
888
|
+
* Omitted means "accept whatever is there", which is what the first
|
|
889
|
+
* version did and is kept only so an older caller does not silently
|
|
890
|
+
* change behaviour — every caller in this tree passes one.
|
|
891
|
+
* @returns {boolean} whether a cached wrap was re-baselined
|
|
892
|
+
*/
|
|
893
|
+
rebaselineLeaf(leafId, expected = undefined) {
|
|
894
|
+
const entry = this._leafCache.get(leafId);
|
|
895
|
+
const leaf = this.tree?.get?.(leafId);
|
|
896
|
+
if (!entry || !leaf || leaf.kind !== 'leaf') return false;
|
|
897
|
+
if (expected !== undefined && entry.kindKey !== expected) return false;
|
|
898
|
+
entry.kindKey = this._leafKindKey(leaf);
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* C22. Change the tab layout of a LIVE renderer.
|
|
904
|
+
*
|
|
905
|
+
* Only the strip is rebuilt. The tab bar element is MOVED between its two
|
|
906
|
+
* positions and the body is never detached, so no content factory is
|
|
907
|
+
* unmounted — which is the whole reason this is a method rather than
|
|
908
|
+
* "rebuild the shell with the other option". A grid holding staged edits
|
|
909
|
+
* must not lose them because someone changed where its tabs are drawn.
|
|
910
|
+
*
|
|
911
|
+
* Accepts anything: the value arrives from a DOM attribute and from
|
|
912
|
+
* persisted user settings, and an unknown one means the default.
|
|
913
|
+
*/
|
|
914
|
+
setTabLayout(layout) {
|
|
915
|
+
const next = _normalizeTabLayout(layout);
|
|
916
|
+
// The attribute is written back below, which re-enters through the
|
|
917
|
+
// observer; this is where that stops.
|
|
918
|
+
if (next === this.tabLayout) return;
|
|
919
|
+
this.tabLayout = next;
|
|
920
|
+
if (this.root.dataset && this.root.dataset.twmTabs !== next) {
|
|
921
|
+
this.root.dataset.twmTabs = next;
|
|
922
|
+
}
|
|
923
|
+
if (this.disposed) return;
|
|
924
|
+
for (const [leafId, entry] of this._leafCache) {
|
|
925
|
+
this._placeTabBar(entry);
|
|
926
|
+
// The two strips are different components, not two skins of one:
|
|
927
|
+
// whichever was there is torn down and the other is built.
|
|
928
|
+
this._disposeTabStrip(entry);
|
|
929
|
+
entry.tabBarEl.innerHTML = '';
|
|
930
|
+
const leaf = this.tree.get(leafId);
|
|
931
|
+
if (leaf) this._renderTabBar(leaf, entry);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Put a leaf's tab bar on the side the current layout says. Moving an
|
|
936
|
+
* attached element is a re-parent, not a rebuild — the body keeps its
|
|
937
|
+
* DOM, its listeners and its scroll. */
|
|
938
|
+
_placeTabBar(entry) {
|
|
939
|
+
const { wrapEl, bodyEl, tabBarEl } = entry;
|
|
940
|
+
if (!wrapEl || !tabBarEl || !bodyEl) return;
|
|
941
|
+
if (this.tabLayout === 'top') wrapEl.insertBefore(tabBarEl, bodyEl);
|
|
942
|
+
else wrapEl.appendChild(tabBarEl);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Tear down the `top` layout's component, if this leaf has one. Safe to
|
|
946
|
+
* call on a leaf that never had one, and on one that already lost it. */
|
|
947
|
+
_disposeTabStrip(entry) {
|
|
948
|
+
if (!entry?.tabStrip) return;
|
|
949
|
+
try { entry.tabStrip.dispose(); } catch (err) {
|
|
950
|
+
console.error('[tile] tab strip dispose threw', err);
|
|
951
|
+
}
|
|
952
|
+
entry.tabStrip = null;
|
|
953
|
+
entry.tabStripHostEl = null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Paint a leaf's tab strip in whichever layout is current. The strip is
|
|
957
|
+
* hidden for single-tab leaves in BOTH layouts — the common case, and the
|
|
958
|
+
* reason existing single-pane layouts read as identical to today. */
|
|
275
959
|
_renderTabBar(leaf, entry) {
|
|
960
|
+
// C33. BEFORE the single-tab early return, because the single-tab leaf
|
|
961
|
+
// is exactly the case this answers: no strip is drawn, so the chrome is
|
|
962
|
+
// the only thing left to grab.
|
|
963
|
+
this._syncChromeDragSource(leaf, entry);
|
|
276
964
|
const bar = entry.tabBarEl;
|
|
277
965
|
if (!bar) return;
|
|
278
966
|
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
967
|
+
bar.classList.toggle('twm-leaf__tabbar--top', this.tabLayout === 'top');
|
|
279
968
|
if (tabs.length <= 1) {
|
|
280
969
|
bar.classList.add('twm-leaf__tabbar--hidden');
|
|
970
|
+
this._disposeTabStrip(entry);
|
|
281
971
|
bar.innerHTML = '';
|
|
282
972
|
return;
|
|
283
973
|
}
|
|
284
974
|
bar.classList.remove('twm-leaf__tabbar--hidden');
|
|
975
|
+
if (this.tabLayout === 'top') this._renderTopTabBar(leaf, entry, tabs);
|
|
976
|
+
else this._renderBottomTabBar(leaf, entry, tabs);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* C33. THE CHROME IS THE DRAG SOURCE FOR A LEAF THAT HAS ONE TAB.
|
|
981
|
+
*
|
|
982
|
+
* Product owner, 2026-08-27. The full argument is in the file header; what
|
|
983
|
+
* this function owns is the THREE conditions and why each is a condition
|
|
984
|
+
* rather than a preference:
|
|
985
|
+
*
|
|
986
|
+
* EXACTLY ONE TAB. With two or more, the strip is drawn and names each
|
|
987
|
+
* tab; a chrome drag would then have to guess which one was meant, and
|
|
988
|
+
* guessing is what the strip exists to avoid. With exactly one, "this
|
|
989
|
+
* tab" and "what is in this pane" are the same thing.
|
|
990
|
+
*
|
|
991
|
+
* NOT A PANEL. Panel tiles are chrome, not content — the same exclusion
|
|
992
|
+
* `_floatableLeaf` and `_snapProbe` already make, and for the same reason:
|
|
993
|
+
* there is nothing in them that belongs anywhere else.
|
|
994
|
+
*
|
|
995
|
+
* THE CONTENT DID NOT VETO `promote` (C20). One rule instead of two: a
|
|
996
|
+
* tab you may not lift out of its pane is a tab you may not drag into
|
|
997
|
+
* another one. This is what excludes an embedder's master tile — the
|
|
998
|
+
* ground its floating windows stand on — whose whole reason for existing
|
|
999
|
+
* is that it stays where it is.
|
|
1000
|
+
*
|
|
1001
|
+
* THE LISTENERS ARE BOUND ONCE PER CHROME ELEMENT and the ATTRIBUTE is
|
|
1002
|
+
* re-decided on every pass. That split is deliberate: `draggable` changes
|
|
1003
|
+
* the moment a second tab arrives, while the chrome element itself survives
|
|
1004
|
+
* for as long as its wrap does, and re-adding a listener on every render
|
|
1005
|
+
* would stack one per repaint.
|
|
1006
|
+
*/
|
|
1007
|
+
_syncChromeDragSource(leaf, entry) {
|
|
1008
|
+
const chrome = entry?.chromeEl;
|
|
1009
|
+
if (!chrome) return;
|
|
1010
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
1011
|
+
const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
|
|
1012
|
+
const vetoed = this.leafChrome(leaf.id)?.promote === false;
|
|
1013
|
+
const on = tabs.length === 1 && !isPanel && !vetoed;
|
|
1014
|
+
if (on) chrome.setAttribute('draggable', 'true');
|
|
1015
|
+
else chrome.removeAttribute('draggable');
|
|
1016
|
+
if (chrome.__twmTabDragBound) return;
|
|
1017
|
+
chrome.__twmTabDragBound = true;
|
|
1018
|
+
chrome.addEventListener('dragstart', (ev) => {
|
|
1019
|
+
// A drag that began on a BUTTON is not a drag of the pane. Buttons
|
|
1020
|
+
// are not draggable on their own, but a draggable ancestor makes
|
|
1021
|
+
// them so — hence the same guard the two float gestures apply, from
|
|
1022
|
+
// the same constant, so a control added to this strip later becomes
|
|
1023
|
+
// inert under all three at once.
|
|
1024
|
+
if (ev.target?.closest?.(CHROME_NO_FLOAT)) { ev.preventDefault(); return; }
|
|
1025
|
+
// ══ A DOWNWARD GESTURE IS THE PULL, AND IT HAS TO BE REFUSED
|
|
1026
|
+
// HERE, BECAUSE NOTHING LATER CAN ═══════════════════════════
|
|
1027
|
+
//
|
|
1028
|
+
// The file header used to claim these two gestures could not
|
|
1029
|
+
// collide: *"the pull … loses cleanly … The pull is also still
|
|
1030
|
+
// reachable, on every part of the chrome a single-tab leaf's title
|
|
1031
|
+
// does not cover."* That describes `draggable` on the TITLE. It is
|
|
1032
|
+
// set on the CHROME, four lines above — the whole strip — so the
|
|
1033
|
+
// pull was not merely losing where the title sits, it had no
|
|
1034
|
+
// reachable surface left on any single-tab tile. And below two tabs
|
|
1035
|
+
// the strip is hidden, so single-tab is the common case: the
|
|
1036
|
+
// reported symptom is *"pulling down a tile at title bar does not
|
|
1037
|
+
// anymore turn it into a managed window"*, and it was every tile.
|
|
1038
|
+
//
|
|
1039
|
+
// MEASURED, against the running application in Edge 152 rather
|
|
1040
|
+
// than in jsdom, which cannot start a native drag and therefore
|
|
1041
|
+
// cannot see any of this: press the chrome and pull down 140px and
|
|
1042
|
+
// the sequence is `dragstart`, `pointercancel`, `dragend`, with
|
|
1043
|
+
// zero windows promoted. The drag arrives at ~5-16px of travel and
|
|
1044
|
+
// `pointercancel` tears the pull's listeners down at ~8, so the
|
|
1045
|
+
// 24px threshold is never reached and never can be.
|
|
1046
|
+
//
|
|
1047
|
+
// Refusing the drag here is what hands the pointer back: a
|
|
1048
|
+
// `dragstart` that is cancelled starts no drag, so no
|
|
1049
|
+
// `pointercancel` is fired and `pointermove` simply resumes —
|
|
1050
|
+
// measured, same harness, the pull then fires at 26px. That is
|
|
1051
|
+
// also the house idiom for declining a native drag
|
|
1052
|
+
// (`DragReorder._start`'s `draggableGuard`, `drag_reorder.js`).
|
|
1053
|
+
//
|
|
1054
|
+
// C33 IS UNTOUCHED BY IT. A sideways or diagonal grab reports
|
|
1055
|
+
// `down <= sideways`, is not refused, and drags exactly as before
|
|
1056
|
+
// — verified in the same browser run. What this costs is the one
|
|
1057
|
+
// gesture that was ambiguous by construction: you can no longer
|
|
1058
|
+
// start a tab drag by pulling straight DOWN out of the chrome,
|
|
1059
|
+
// which is the direction the float has claimed since before the
|
|
1060
|
+
// tab drag existed.
|
|
1061
|
+
//
|
|
1062
|
+
// `_chromePull` OUTLIVES THE PROMOTE, and that is load-bearing
|
|
1063
|
+
// rather than tidy: a coalesced `pointermove` can carry the whole
|
|
1064
|
+
// 24px and promote on its first delivery, and Blink hands that
|
|
1065
|
+
// move to script BEFORE starting the drag it crossed the threshold
|
|
1066
|
+
// for — so a `dragstart` still arrives afterwards. Clearing the
|
|
1067
|
+
// record on lift left this read with nothing, so the drag was not
|
|
1068
|
+
// refused and a tab drag began on the pane that had just floated
|
|
1069
|
+
// out of the tile. The record's last coordinates are the lift's,
|
|
1070
|
+
// which are downward by definition, so the same test answers.
|
|
1071
|
+
const pull = this._chromePull;
|
|
1072
|
+
if (pull && _isDownwardPull(pull.y - pull.startY,
|
|
1073
|
+
Math.abs(pull.x - pull.startX))) {
|
|
1074
|
+
ev.preventDefault();
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
// Re-read the count rather than trusting the attribute: the tree
|
|
1078
|
+
// can gain a tab between a render and a gesture, and a stale
|
|
1079
|
+
// `draggable` would carry tab 0 out of a pane that now has three.
|
|
1080
|
+
const live = this.tree.get(leaf.id);
|
|
1081
|
+
if ((live?.tabs || []).length !== 1) { ev.preventDefault(); return; }
|
|
1082
|
+
this._beginTabDrag(ev, leaf.id, 0, chrome, { seedPlainText: true });
|
|
1083
|
+
});
|
|
1084
|
+
chrome.addEventListener('dragend', () => this._onTabDragEnd());
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* C22. The `top` layout: `NotebookTabBar`, the editor tab strip.
|
|
1089
|
+
*
|
|
1090
|
+
* The component is mounted into a CHILD of the bar rather than into the bar
|
|
1091
|
+
* itself, because `mount()` assigns `container.className = 'tabs
|
|
1092
|
+
* notebook-tabs'` (`notebook_tab_bar.js:61`) — handing it `.twm-leaf__tabbar`
|
|
1093
|
+
* would take that class, and with it the strip's height, its background and
|
|
1094
|
+
* `--hidden`, off the element this file still controls.
|
|
1095
|
+
*
|
|
1096
|
+
* Every gesture routes through the SAME `ctx.onLeafTabAction` vocabulary the
|
|
1097
|
+
* bottom strip uses, so the WM's tree mutations, its persistence and its
|
|
1098
|
+
* change notifications are reached by one path from both layouts.
|
|
1099
|
+
*/
|
|
1100
|
+
_renderTopTabBar(leaf, entry, tabs) {
|
|
1101
|
+
const bar = entry.tabBarEl;
|
|
1102
|
+
if (!entry.tabStrip) {
|
|
1103
|
+
bar.innerHTML = '';
|
|
1104
|
+
const host = document.createElement('div');
|
|
1105
|
+
bar.appendChild(host);
|
|
1106
|
+
const strip = new NotebookTabBar();
|
|
1107
|
+
strip.mount(host, this._topTabCallbacks(leaf.id));
|
|
1108
|
+
entry.tabStrip = strip;
|
|
1109
|
+
entry.tabStripHostEl = host;
|
|
1110
|
+
// ONE TAB CONTEXT MENU IN THE APPLICATION, not two.
|
|
1111
|
+
//
|
|
1112
|
+
// `NotebookTabBar` opens its own `.nb-context-menu` on right-click,
|
|
1113
|
+
// whose verbs are a notebook's (rename, duplicate, reveal in
|
|
1114
|
+
// explorer) and whose close verbs duplicate — in a different
|
|
1115
|
+
// typeface — the menu the WM already builds for the bottom strip
|
|
1116
|
+
// (`wm._showTabContextMenu`). A user who switches layout must not
|
|
1117
|
+
// discover that right-clicking a tab now means something else.
|
|
1118
|
+
//
|
|
1119
|
+
// CAPTURE phase, on the container: the component binds its handler
|
|
1120
|
+
// on each tab element in the bubble phase, so stopping the event on
|
|
1121
|
+
// the way DOWN is what keeps its menu from opening at all. There is
|
|
1122
|
+
// no callback for this — the menu is not a callback, it is the
|
|
1123
|
+
// component's own DOM.
|
|
1124
|
+
host.addEventListener('contextmenu', (ev) => {
|
|
1125
|
+
const tabEl = ev.target.closest?.('.tab');
|
|
1126
|
+
if (!tabEl) return;
|
|
1127
|
+
ev.preventDefault();
|
|
1128
|
+
ev.stopPropagation();
|
|
1129
|
+
const idx = this._topTabIndex(host, tabEl);
|
|
1130
|
+
if (idx < 0) return;
|
|
1131
|
+
this.ctx.onLeafTabAction?.(leaf.id, 'menu',
|
|
1132
|
+
{ idx, x: ev.clientX, y: ev.clientY });
|
|
1133
|
+
}, true);
|
|
1134
|
+
}
|
|
1135
|
+
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
1136
|
+
entry.tabStrip.update(
|
|
1137
|
+
tabs.map((t, i) => ({
|
|
1138
|
+
filePath: _tabKey(i),
|
|
1139
|
+
label: t.title || t.kind || '',
|
|
1140
|
+
// The component's `fileType` picks a glyph out of a static map
|
|
1141
|
+
// of FILE kinds. A tile's kinds are the embedder's, and the
|
|
1142
|
+
// taxonomy already answers for them — see `_paintTopTabs`.
|
|
1143
|
+
fileType: t.kind || 'unknown',
|
|
1144
|
+
// Nothing sets `dirty` on a tab spec today, so the dot is never
|
|
1145
|
+
// drawn. Read anyway, because the day a tile can say it holds
|
|
1146
|
+
// unsaved work this is where it says it, and the alternative is
|
|
1147
|
+
// a second place to remember.
|
|
1148
|
+
isDirty: !!t.dirty,
|
|
1149
|
+
})),
|
|
1150
|
+
_tabKey(activeIdx));
|
|
1151
|
+
this._paintTopTabs(entry.tabStripHostEl, tabs);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* The two things `NotebookTabBar` derives from a vocabulary a tile does not
|
|
1156
|
+
* have, corrected in one pass over the DOM it just wrote.
|
|
1157
|
+
*
|
|
1158
|
+
* Its tooltip is the file PATH (`notebook_tab_bar.js:163`) and its glyph
|
|
1159
|
+
* comes from a static fileType map (`:135`). Ours are `builtin://tab/3` and
|
|
1160
|
+
* a content kind, so left alone a tab would advertise its own array index
|
|
1161
|
+
* and wear the generic `description` glyph — while the tile chrome an inch
|
|
1162
|
+
* above it shows the taxonomy's icon for exactly the same kind.
|
|
1163
|
+
*
|
|
1164
|
+
* Reaching into a component's DOM is worth one paragraph of justification.
|
|
1165
|
+
* The alternative for the glyph is `NotebookTabBar.setFileTypeIcons()`,
|
|
1166
|
+
* which is STATIC and REPLACES the whole map — so a page that also uses the
|
|
1167
|
+
* editor would find its own file icons deleted by whichever of the two
|
|
1168
|
+
* rendered last. The class names used here are the component's published
|
|
1169
|
+
* contract, stated in its header comment.
|
|
1170
|
+
*/
|
|
1171
|
+
_paintTopTabs(hostEl, tabs) {
|
|
1172
|
+
if (!hostEl) return;
|
|
1173
|
+
const els = hostEl.querySelectorAll('.tab');
|
|
1174
|
+
els.forEach((el, i) => {
|
|
1175
|
+
const spec = tabs[i];
|
|
1176
|
+
if (!spec) return;
|
|
1177
|
+
el.title = spec.title || spec.kind || '';
|
|
1178
|
+
const icon = spec.kind ? this.ctx?.taxonomy?.meta?.(spec.kind)?.icon : null;
|
|
1179
|
+
const glyph = el.querySelector('.tab-icon');
|
|
1180
|
+
if (glyph && icon) glyph.textContent = icon;
|
|
1181
|
+
else if (glyph && !icon) glyph.hidden = true;
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Which tab an element in the top strip is, by DOM position. Position
|
|
1186
|
+
* rather than the `data-path` key because the key is only ever the index
|
|
1187
|
+
* and reading it back would be a second, parallel answer to the same
|
|
1188
|
+
* question. */
|
|
1189
|
+
_topTabIndex(hostEl, tabEl) {
|
|
1190
|
+
return Array.prototype.indexOf.call(hostEl.querySelectorAll('.tab'), tabEl);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** The callbacks `NotebookTabBar` calls. Everything the component offers
|
|
1194
|
+
* that a tile tab cannot honour is deliberately absent rather than stubbed
|
|
1195
|
+
* — `onRename` is refused by the `builtin://` key, and the rest
|
|
1196
|
+
* (`onDuplicate`, `onSplitRight`, `onRevealInExplorer`, `onCloseAll`, …)
|
|
1197
|
+
* are only ever reached from the context menu this renderer suppresses.
|
|
1198
|
+
*
|
|
1199
|
+
* `onDropFromOtherPane` IS NOW WIRED, AND THIS IS THE RECORD OF WHY IT WAS
|
|
1200
|
+
* NOT. The refusal read: *"its payload is the dragged tab's key alone,
|
|
1201
|
+
* which carries no source-leaf identity, and `TileTree` has no
|
|
1202
|
+
* move-a-tab-between-leaves operation to receive it. Wiring it would need
|
|
1203
|
+
* both, and both are tree changes."* Both were true and C33 built both.
|
|
1204
|
+
* `TileTree.moveTabToLeaf` is the tree operation; `TileRenderer._tabDrag`
|
|
1205
|
+
* is the source identity — held on the renderer rather than in the payload
|
|
1206
|
+
* because HTML5's protected mode makes the payload unreadable at the only
|
|
1207
|
+
* moment it would be needed (see `TILE_TAB_MIME`). The payload handed to
|
|
1208
|
+
* the callback is therefore still ignored, exactly as the refusal said it
|
|
1209
|
+
* would have to be.
|
|
1210
|
+
*
|
|
1211
|
+
* A DROP ON A FOREIGN STRIP APPENDS. `NotebookTabBar` hands the callback a
|
|
1212
|
+
* key and no event, so there is no pointer position to derive a slot from
|
|
1213
|
+
* — and inventing one for this strip and not for the bottom one would give
|
|
1214
|
+
* the two layouts different answers to the same gesture. "Add this tab to
|
|
1215
|
+
* that tile" is what was asked for; where it sits in the strip is a
|
|
1216
|
+
* reorder away, in the mechanism that already does reorders. */
|
|
1217
|
+
_topTabCallbacks(leafId) {
|
|
1218
|
+
return {
|
|
1219
|
+
// C33. `DragReorder` calls this through `NotebookTabBar`, which
|
|
1220
|
+
// sets its own `application/x-ecosim-tab` first and unchanged — so
|
|
1221
|
+
// an editor pane sharing the page cannot notice that tiles are
|
|
1222
|
+
// dragging tabs too.
|
|
1223
|
+
onDragStart: (ev, key, item) => {
|
|
1224
|
+
const idx = _tabKeyIndex(key);
|
|
1225
|
+
if (idx < 0) return;
|
|
1226
|
+
this._beginTabDrag(ev, leafId, idx, item || null);
|
|
1227
|
+
},
|
|
1228
|
+
// What lets THIS strip admit a tab dragged out of another tile's
|
|
1229
|
+
// strip: `#isExternalTabDrag` tests `TAB_MIME` plus whatever the
|
|
1230
|
+
// host names here, and defers while its own reorder is running.
|
|
1231
|
+
externalTabMimes: [TILE_TAB_MIME],
|
|
1232
|
+
onDropFromOtherPane: () => {
|
|
1233
|
+
const src = this._tabDrag;
|
|
1234
|
+
this._onTabDragEnd();
|
|
1235
|
+
if (!src || src.leafId === leafId) return;
|
|
1236
|
+
this.ctx.onLeafTabAction?.(src.leafId, 'drop-into', {
|
|
1237
|
+
idx: src.idx,
|
|
1238
|
+
target: { leafId, mode: 'tab', toIdx: -1 },
|
|
1239
|
+
});
|
|
1240
|
+
},
|
|
1241
|
+
onActivate: (key) => {
|
|
1242
|
+
const idx = _tabKeyIndex(key);
|
|
1243
|
+
if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, 'switch', { idx });
|
|
1244
|
+
},
|
|
1245
|
+
onClose: (key) => {
|
|
1246
|
+
const idx = _tabKeyIndex(key);
|
|
1247
|
+
if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, 'close', { idx });
|
|
1248
|
+
},
|
|
1249
|
+
// DRAG-TO-REORDER ARRIVES AS A PERMUTATION, and the tree moves ONE
|
|
1250
|
+
// tab at a time (`TileTree.moveLeafTab(leafId, from, to)`). They
|
|
1251
|
+
// reconcile because a drag only ever moves one element: every other
|
|
1252
|
+
// key shifts by exactly one place, so the element that travelled
|
|
1253
|
+
// furthest between the two orders IS the one that was dragged.
|
|
1254
|
+
onReorder: (order) => {
|
|
1255
|
+
const leaf = this.tree.get(leafId);
|
|
1256
|
+
const count = (leaf?.tabs || []).length;
|
|
1257
|
+
if (!Array.isArray(order) || order.length !== count) return;
|
|
1258
|
+
let from = -1;
|
|
1259
|
+
let to = -1;
|
|
1260
|
+
let furthest = 0;
|
|
1261
|
+
order.forEach((key, newIdx) => {
|
|
1262
|
+
const oldIdx = _tabKeyIndex(key);
|
|
1263
|
+
if (oldIdx < 0) return;
|
|
1264
|
+
const travelled = Math.abs(newIdx - oldIdx);
|
|
1265
|
+
if (travelled > furthest) {
|
|
1266
|
+
furthest = travelled;
|
|
1267
|
+
from = oldIdx;
|
|
1268
|
+
to = newIdx;
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
if (from < 0 || from === to) return;
|
|
1272
|
+
this.ctx.onLeafTabAction?.(leafId, 'move', { from, to });
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** The `bottom` layout — the framework's own strip, unchanged. Hamburger
|
|
1278
|
+
* button at the start, then one trapezoid-shaped tab per stored tab spec. */
|
|
1279
|
+
_renderBottomTabBar(leaf, entry, tabs) {
|
|
1280
|
+
const bar = entry.tabBarEl;
|
|
285
1281
|
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
286
1282
|
bar.innerHTML = `
|
|
287
1283
|
<button type="button" class="twm-leaf__tab-hamburger"
|
|
288
1284
|
data-action="tab-menu"
|
|
289
|
-
title="
|
|
1285
|
+
title="Show open tabs" aria-label="Show open tabs">
|
|
290
1286
|
<span class="material-symbols-outlined">menu</span>
|
|
291
1287
|
</button>
|
|
292
1288
|
<ol class="twm-leaf__tabs" role="tablist">
|
|
@@ -346,6 +1342,13 @@ export class TileRenderer {
|
|
|
346
1342
|
// drop target reads it and emits a 'move' action. We don't use
|
|
347
1343
|
// HTML5 setData('text/plain') for the live state because the
|
|
348
1344
|
// dragstart/drop pair runs entirely within the same tab list.
|
|
1345
|
+
//
|
|
1346
|
+
// C33. …WHICH IS EXACTLY WHY `dragFromIdx` IS NOT ENOUGH ANY MORE. It
|
|
1347
|
+
// is a closure built per leaf render, so leaf B's handlers cannot see
|
|
1348
|
+
// that leaf A started a drag. It stays, unchanged, as the in-strip
|
|
1349
|
+
// reorder's own state — the gesture it was written for still begins and
|
|
1350
|
+
// ends in one list — and the cross-tile identity is taken on the
|
|
1351
|
+
// renderer beside it. Two facts, two homes, and neither one guessing.
|
|
349
1352
|
let dragFromIdx = null;
|
|
350
1353
|
bar.querySelectorAll('.twm-leaf__tab').forEach((li) => {
|
|
351
1354
|
li.addEventListener('dragstart', (ev) => {
|
|
@@ -355,7 +1358,14 @@ export class TileRenderer {
|
|
|
355
1358
|
// called — supply a placeholder string.
|
|
356
1359
|
try { ev.dataTransfer.setData('text/plain', String(dragFromIdx)); }
|
|
357
1360
|
catch {}
|
|
1361
|
+
this._beginTabDrag(ev, leaf.id, dragFromIdx, li);
|
|
358
1362
|
});
|
|
1363
|
+
// C33. THE DRAGGED TAB NOW LOOKS DRAGGED. `DragReorder` has put
|
|
1364
|
+
// `.dragging` on the top strip's tab since it was written; this
|
|
1365
|
+
// strip's hand-rolled reorder set no class at all, so a drag here
|
|
1366
|
+
// looked exactly like a click that did nothing until the tab
|
|
1367
|
+
// arrived somewhere else. One rule serves both strips.
|
|
1368
|
+
li.addEventListener('dragend', () => { dragFromIdx = null; this._onTabDragEnd(); });
|
|
359
1369
|
li.addEventListener('dragover', (ev) => {
|
|
360
1370
|
if (dragFromIdx == null) return;
|
|
361
1371
|
ev.preventDefault();
|
|
@@ -372,6 +1382,225 @@ export class TileRenderer {
|
|
|
372
1382
|
dragFromIdx = null;
|
|
373
1383
|
});
|
|
374
1384
|
});
|
|
1385
|
+
|
|
1386
|
+
// ── C33. AND A TAB FROM ANOTHER TILE, DROPPED ON THIS STRIP ──────
|
|
1387
|
+
//
|
|
1388
|
+
// The container level, not the tab level, because the useful target is
|
|
1389
|
+
// "this strip" rather than "this tab": the per-tab handlers above
|
|
1390
|
+
// return without `preventDefault` when `dragFromIdx` is null, which is
|
|
1391
|
+
// always true of a foreign drag, so the event reaches here by bubbling
|
|
1392
|
+
// and the two never contend.
|
|
1393
|
+
//
|
|
1394
|
+
// The gate is the same shape as `NotebookTabBar.#isExternalTabDrag`:
|
|
1395
|
+
// the type-only marker, plus a source that is not this leaf. The
|
|
1396
|
+
// `dragleave` is guarded with `contains(relatedTarget)` because
|
|
1397
|
+
// `dragleave` fires every time the pointer crosses into a CHILD, and
|
|
1398
|
+
// without the guard the strip would unhighlight itself the moment the
|
|
1399
|
+
// pointer touched a tab inside it — the same fix `editor_pane.js` and
|
|
1400
|
+
// `notebook_tab_bar.js` already carry.
|
|
1401
|
+
// BOUND ONCE PER BAR ELEMENT, not once per render. `_renderBottomTabBar`
|
|
1402
|
+
// rewrites `bar.innerHTML` — which takes the per-tab listeners with the
|
|
1403
|
+
// `<li>`s they were on — but the BAR survives, and `setTabLayout` calls
|
|
1404
|
+
// straight back in on the same element. Without this flag a user who
|
|
1405
|
+
// switched layout twice would get three copies of the drop handler and
|
|
1406
|
+
// three `drop-into` actions from one release.
|
|
1407
|
+
const foreign = (ev) => !!this._tabDrag
|
|
1408
|
+
&& this._tabDrag.leafId !== leaf.id
|
|
1409
|
+
&& _isTileTabDrag(ev);
|
|
1410
|
+
if (bar.__twmBarDropBound) return;
|
|
1411
|
+
bar.__twmBarDropBound = true;
|
|
1412
|
+
bar.addEventListener('dragover', (ev) => {
|
|
1413
|
+
if (!foreign(ev)) return;
|
|
1414
|
+
ev.preventDefault();
|
|
1415
|
+
try { ev.dataTransfer.dropEffect = 'move'; } catch {}
|
|
1416
|
+
// The tile zone and the strip zone must never be armed at once, and
|
|
1417
|
+
// this is the TILE zone that goes — never `_clearTabDropZone`,
|
|
1418
|
+
// which would take the class added on the next line straight back
|
|
1419
|
+
// off. See `_onTabDragOver`: this handler runs FIRST (the bar is a
|
|
1420
|
+
// descendant of the root, and both listeners are on the bubble
|
|
1421
|
+
// phase), so anything it paints has to survive what runs after it.
|
|
1422
|
+
this._clearTileDropZone();
|
|
1423
|
+
bar.classList.add('twm-leaf__tabbar--drop-target');
|
|
1424
|
+
});
|
|
1425
|
+
bar.addEventListener('dragleave', (ev) => {
|
|
1426
|
+
if (!bar.contains(ev.relatedTarget)) {
|
|
1427
|
+
bar.classList.remove('twm-leaf__tabbar--drop-target');
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
bar.addEventListener('drop', (ev) => {
|
|
1431
|
+
if (!foreign(ev)) return;
|
|
1432
|
+
ev.preventDefault();
|
|
1433
|
+
bar.classList.remove('twm-leaf__tabbar--drop-target');
|
|
1434
|
+
const src = this._tabDrag;
|
|
1435
|
+
this._onTabDragEnd();
|
|
1436
|
+
// APPEND, like the top strip's drop does, and for the same reason:
|
|
1437
|
+
// one gesture must not mean two things depending on where the user
|
|
1438
|
+
// draws their tabs. See `_topTabCallbacks`.
|
|
1439
|
+
this.ctx.onLeafTabAction?.(src.leafId, 'drop-into', {
|
|
1440
|
+
idx: src.idx,
|
|
1441
|
+
target: { leafId: leaf.id, mode: 'tab', toIdx: -1 },
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// ══ C33. THE TAB DRAG ═══════════════════════════════════════════════
|
|
1447
|
+
|
|
1448
|
+
/** Take the identity of the tab now being carried, and mark it.
|
|
1449
|
+
*
|
|
1450
|
+
* `seedPlainText` is for the CHROME source only. The two strips already
|
|
1451
|
+
* set `text/plain` themselves — `DragReorder._start` writes the reorder
|
|
1452
|
+
* key, the bottom strip writes the index — and overwriting either would
|
|
1453
|
+
* hand `NotebookTabBar.#onStripDrop`'s `getData(TAB_MIME) ||
|
|
1454
|
+
* getData('text/plain')` fallback a number where it expects a key. The
|
|
1455
|
+
* chrome has no such writer and Firefox refuses to begin a drag with an
|
|
1456
|
+
* empty `dataTransfer`, so it supplies its own. */
|
|
1457
|
+
_beginTabDrag(ev, leafId, idx, el, { seedPlainText = false } = {}) {
|
|
1458
|
+
this._tabDrag = { leafId, idx, el: el || null };
|
|
1459
|
+
this._tabDropProbe = null;
|
|
1460
|
+
try {
|
|
1461
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
1462
|
+
ev.dataTransfer.setData(TILE_TAB_MIME, '1');
|
|
1463
|
+
if (seedPlainText) ev.dataTransfer.setData('text/plain', String(idx));
|
|
1464
|
+
} catch { /* a synthetic event, or a dataTransfer in protected mode */ }
|
|
1465
|
+
el?.classList?.add('dragging');
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Arm — or refuse — the tile under the pointer.
|
|
1470
|
+
*
|
|
1471
|
+
* NOTHING HERE MAY RENDER. `render()` clears the root, which detaches the
|
|
1472
|
+
* element the browser is dragging, and the browser cancels the gesture the
|
|
1473
|
+
* moment that happens. The DOM afterwards reads perfectly correct, which is
|
|
1474
|
+
* what makes this failure so hard to see; it is the drag-and-drop cousin of
|
|
1475
|
+
* the `mousedown`-repaint defect recorded five times against the taskbar.
|
|
1476
|
+
*
|
|
1477
|
+
* `preventDefault()` is not decoration either: without it the browser
|
|
1478
|
+
* refuses the drop outright and `drop` never fires, which reads exactly
|
|
1479
|
+
* like a broken handler.
|
|
1480
|
+
*/
|
|
1481
|
+
_onTabDragOver(e) {
|
|
1482
|
+
const src = this._tabDrag;
|
|
1483
|
+
if (!src || !_isTileTabDrag(e)) return;
|
|
1484
|
+
// A STRIP OWNS ITS OWN DROPS. Inside a tab bar the question is a
|
|
1485
|
+
// position in a list, not a zone of a tile, and both strips answer it
|
|
1486
|
+
// themselves — so this hands the event on rather than arming a zone
|
|
1487
|
+
// behind them.
|
|
1488
|
+
//
|
|
1489
|
+
// `_clearTileDropZone` and NOT `_clearTabDropZone`: the strip's handler
|
|
1490
|
+
// has ALREADY RUN by the time this one does — it is bound on a
|
|
1491
|
+
// descendant of the root and both are bubble-phase — so clearing the
|
|
1492
|
+
// strip classes here would remove the highlight it just painted, on
|
|
1493
|
+
// every `dragover`, and the strip would arm and disarm invisibly for
|
|
1494
|
+
// the whole gesture.
|
|
1495
|
+
if (e.target?.closest?.('.twm-leaf__tabbar')) { this._clearTileDropZone(); return; }
|
|
1496
|
+
const probe = this.ctx.wm?.tabDropProbe?.(e, { sourceLeafId: src.leafId }) || null;
|
|
1497
|
+
this._tabDropProbe = probe;
|
|
1498
|
+
if (!probe) { this._clearTabDropZone(); return; }
|
|
1499
|
+
e.preventDefault();
|
|
1500
|
+
try { e.dataTransfer.dropEffect = 'move'; } catch {}
|
|
1501
|
+
for (const [leafId, entry] of this._leafCache) {
|
|
1502
|
+
entry.wrapEl.classList.toggle('twm-leaf--drop-target', leafId === probe.leafId);
|
|
1503
|
+
}
|
|
1504
|
+
this._showTabDropPreview(probe.rect);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** Release. The zone that was ARMED is the zone that runs — the stashed
|
|
1508
|
+
* probe rather than a fresh one — because C15's rule is that the rectangle
|
|
1509
|
+
* drawn during the drag is the rectangle the drop delivers, and a pointer
|
|
1510
|
+
* one pixel outside the band at release must not quietly mean something
|
|
1511
|
+
* else. */
|
|
1512
|
+
_onTabDrop(e) {
|
|
1513
|
+
const src = this._tabDrag;
|
|
1514
|
+
const probe = this._tabDropProbe;
|
|
1515
|
+
if (!src || !_isTileTabDrag(e)) return;
|
|
1516
|
+
if (e.target?.closest?.('.twm-leaf__tabbar')) return;
|
|
1517
|
+
e.preventDefault();
|
|
1518
|
+
this._onTabDragEnd();
|
|
1519
|
+
if (!probe) return;
|
|
1520
|
+
this.ctx.onLeafTabAction?.(src.leafId, 'drop-into', {
|
|
1521
|
+
idx: src.idx,
|
|
1522
|
+
target: {
|
|
1523
|
+
leafId: probe.leafId,
|
|
1524
|
+
mode: probe.mode,
|
|
1525
|
+
// The same derivation `_snapCommit` applies to a window drop
|
|
1526
|
+
// (`wm.js`, the `_dock` literal) — one reading of a side, so a
|
|
1527
|
+
// tab and a window cannot land on opposite halves of one edge.
|
|
1528
|
+
dir: (probe.side === 'left' || probe.side === 'right') ? 'h' : 'v',
|
|
1529
|
+
before: (probe.side === 'left' || probe.side === 'top'),
|
|
1530
|
+
toIdx: -1,
|
|
1531
|
+
},
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** Leaving the root entirely disarms, and only that. The drag is still
|
|
1536
|
+
* live — it may come back — so `_tabDrag` survives and only the painting
|
|
1537
|
+
* goes. */
|
|
1538
|
+
_onTabDragLeave(e) {
|
|
1539
|
+
if (!this._tabDrag) return;
|
|
1540
|
+
if (this.root.contains(e.relatedTarget)) return;
|
|
1541
|
+
this._clearTabDropZone();
|
|
1542
|
+
this._tabDropProbe = null;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** The only guaranteed end of a drag. Escape produces this and no `drop`;
|
|
1546
|
+
* so does a release over a target that refused. Idempotent, because the
|
|
1547
|
+
* drop path calls it too and `dragend` still arrives afterwards. */
|
|
1548
|
+
_onTabDragEnd() {
|
|
1549
|
+
this._tabDrag?.el?.classList?.remove('dragging');
|
|
1550
|
+
this._tabDrag = null;
|
|
1551
|
+
this._tabDropProbe = null;
|
|
1552
|
+
this._clearTabDropZone();
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** The TILE zone only — the outlined pane and the preview rectangle. Split
|
|
1556
|
+
* out from the whole because a strip that has just armed itself must not
|
|
1557
|
+
* be disarmed by the root handler running behind it. */
|
|
1558
|
+
_clearTileDropZone() {
|
|
1559
|
+
for (const [, entry] of this._leafCache) {
|
|
1560
|
+
entry.wrapEl.classList.remove('twm-leaf--drop-target');
|
|
1561
|
+
}
|
|
1562
|
+
this._clearTabDropPreview();
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/** Everything: the tile zone and both strips'. The end of a gesture, where
|
|
1566
|
+
* nothing may be left painted. */
|
|
1567
|
+
_clearTabDropZone() {
|
|
1568
|
+
this._clearTileDropZone();
|
|
1569
|
+
for (const [, entry] of this._leafCache) {
|
|
1570
|
+
entry.tabBarEl?.classList?.remove('twm-leaf__tabbar--drop-target');
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** The rectangle a release would fill.
|
|
1575
|
+
*
|
|
1576
|
+
* IT IS THE WINDOW DROP'S OWN PREVIEW ELEMENT — same two classes, same
|
|
1577
|
+
* stylesheet rules (`css/base.css`, `.twm-snap-preview`) — so a tab drop
|
|
1578
|
+
* and a window drop cannot come to disagree about what a drop looks like.
|
|
1579
|
+
* `--viewport` is what makes `position: fixed` apply, and that is required
|
|
1580
|
+
* rather than cosmetic: the rectangle came from `getBoundingClientRect` on
|
|
1581
|
+
* a leaf, which speaks viewport pixels.
|
|
1582
|
+
*
|
|
1583
|
+
* Parented to `document.body` and not to the root, for R13's reason:
|
|
1584
|
+
* `render()`'s `innerHTML = ''` takes every direct child of the root, and
|
|
1585
|
+
* a repaint during a drag needs nothing more exotic than the drag itself. */
|
|
1586
|
+
_showTabDropPreview(rect) {
|
|
1587
|
+
if (!rect) { this._clearTabDropPreview(); return; }
|
|
1588
|
+
if (!this._tabDropPreviewEl) {
|
|
1589
|
+
const el = document.createElement('div');
|
|
1590
|
+
el.className = 'twm-snap-preview twm-snap-preview--viewport';
|
|
1591
|
+
el.setAttribute('aria-hidden', 'true');
|
|
1592
|
+
this._tabDropPreviewEl = el;
|
|
1593
|
+
}
|
|
1594
|
+
const el = this._tabDropPreviewEl;
|
|
1595
|
+
Object.assign(el.style, {
|
|
1596
|
+
left: `${rect.left ?? rect.x}px`, top: `${rect.top ?? rect.y}px`,
|
|
1597
|
+
width: `${rect.width}px`, height: `${rect.height}px`,
|
|
1598
|
+
});
|
|
1599
|
+
if (el.parentNode !== document.body) document.body.appendChild(el);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
_clearTabDropPreview() {
|
|
1603
|
+
this._tabDropPreviewEl?.remove();
|
|
375
1604
|
}
|
|
376
1605
|
|
|
377
1606
|
_updateFocusClasses() {
|
|
@@ -388,6 +1617,43 @@ export class TileRenderer {
|
|
|
388
1617
|
return this._leafCache.get(leafId)?.wrapEl || null;
|
|
389
1618
|
}
|
|
390
1619
|
|
|
1620
|
+
/** C20, extended. The `chrome` veto object the mounted content declared —
|
|
1621
|
+
* `{ promote: false, close: false }` — or null when the leaf is not
|
|
1622
|
+
* rendered or its content declared nothing.
|
|
1623
|
+
*
|
|
1624
|
+
* IT EXISTS BECAUSE A VETO PAINTED ON A BUTTON IS NOT A VETO. C20 landed
|
|
1625
|
+
* as `_vetoStructuralActions`, which removes the button from this strip —
|
|
1626
|
+
* and a removed button is only the door the CONTENT can see. The verb has
|
|
1627
|
+
* three other doors: the tile's right-click menu (`shell.js`'s "Float this
|
|
1628
|
+
* pane as a window"), the chrome pull-down, and the chrome's double-click.
|
|
1629
|
+
* All three reach `WindowManager.floatPane` without passing this file, so
|
|
1630
|
+
* a master tile that declared itself unfloatable was floated by any of
|
|
1631
|
+
* them — reproduced: the ground pane floats, every window standing on it
|
|
1632
|
+
* is force-closed, and the pane is re-seeded WITHOUT its `canvas` prop.
|
|
1633
|
+
*
|
|
1634
|
+
* So the WM asks the renderer what the content said, and enforces it in
|
|
1635
|
+
* `_floatableLeaf` where every door already converges. The renderer stays
|
|
1636
|
+
* the only place that knows what was mounted; the WM stays the only place
|
|
1637
|
+
* that decides whether a verb runs. `closeFocused` now reads `close` the
|
|
1638
|
+
* same way, for the same reason and after the same defect: Alt+W, the tile
|
|
1639
|
+
* context menu and the tab strip's × all closed a pane whose own button
|
|
1640
|
+
* was greyed out with a tooltip saying it could not be.
|
|
1641
|
+
*
|
|
1642
|
+
* ══ THIS RETURNS THE FACTORY'S LIVE OBJECT, AND THAT IS A CONTRACT ══
|
|
1643
|
+
*
|
|
1644
|
+
* Not a copy and not a snapshot. `chrome` is READ ONCE, at mount — a
|
|
1645
|
+
* repaint of a cached leaf re-reads only the title and the glyph — so a
|
|
1646
|
+
* veto whose ANSWER CHANGES over the life of the tile must be kept up to
|
|
1647
|
+
* date by the content that stated it, by mutating the object it returned.
|
|
1648
|
+
* Tables' ground pane is exactly that case: its close is refused only
|
|
1649
|
+
* while it is the last content pane, and it re-syncs on `wm:changed`.
|
|
1650
|
+
* Repainting the button alone is not enough now that a verb consults this
|
|
1651
|
+
* — a stale `{disabled: true}` refuses a close every affordance on screen
|
|
1652
|
+
* says is available, which is the same class of lie as a dead control. */
|
|
1653
|
+
leafChrome(leafId) {
|
|
1654
|
+
return this._leafCache.get(leafId)?.content?.chrome || null;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
391
1657
|
// ── Drag-resize ────────────────────────────────────────────────
|
|
392
1658
|
_onMouseDown(e) {
|
|
393
1659
|
const splitter = e.target.closest('.twm-splitter');
|
|
@@ -457,3 +1723,102 @@ function _esc(s) {
|
|
|
457
1723
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
458
1724
|
}[c]));
|
|
459
1725
|
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* C19. Decide, and paint, the glyph left of a tile's title.
|
|
1729
|
+
*
|
|
1730
|
+
* TWO SOURCES, IN THIS ORDER, and the order is the whole design. The content
|
|
1731
|
+
* factory's `content.icon` wins because only the content knows what this
|
|
1732
|
+
* PARTICULAR thing is — one table has a cog, the next has a calendar, and the
|
|
1733
|
+
* taxonomy has no opinion about either. Everything else falls back to
|
|
1734
|
+
* `taxonomy.meta(kind).icon`, which is the glyph the top-nav, the command
|
|
1735
|
+
* palette and the breadcrumb already draw for that kind — so an embedder who
|
|
1736
|
+
* changes nothing at all still gets a tile chrome that agrees with the three
|
|
1737
|
+
* surfaces around it, and an embedder who has never returned an `icon` from a
|
|
1738
|
+
* factory gets one for free.
|
|
1739
|
+
*
|
|
1740
|
+
* A kind with neither — a `panel:*` tile, whose kinds are the framework's own
|
|
1741
|
+
* and are deliberately not in anybody's taxonomy — is HIDDEN rather than blank.
|
|
1742
|
+
* The chrome is a flex row with a gap; an empty span would still take its gap
|
|
1743
|
+
* and push the title off the left margin by a glyph's worth of nothing.
|
|
1744
|
+
*/
|
|
1745
|
+
function _paintLeafIcon(iconEl, leaf, content, ctx) {
|
|
1746
|
+
if (!iconEl) return;
|
|
1747
|
+
const kind = leaf?.content?.kind;
|
|
1748
|
+
const name = content?.icon || (kind ? ctx?.taxonomy?.meta?.(kind)?.icon : null);
|
|
1749
|
+
iconEl.textContent = name || '';
|
|
1750
|
+
iconEl.hidden = !name;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* C18. Paint a content factory's own chrome buttons into its tile's strip.
|
|
1755
|
+
*
|
|
1756
|
+
* The framework owns the tile and its verbs; the embedder owns the content and
|
|
1757
|
+
* ITS verbs, and until now had nowhere to put them. Tables ended up hanging a
|
|
1758
|
+
* table's settings and its pop-out off the grid's own toolbar, one row further
|
|
1759
|
+
* down, where they read as things you do to the ROWS.
|
|
1760
|
+
*
|
|
1761
|
+
* Deliberately not a registry and not a config: the factory returns them from
|
|
1762
|
+
* the same object it already returns `title` and `destroy` on, so they arrive
|
|
1763
|
+
* with the mount and leave with it.
|
|
1764
|
+
*/
|
|
1765
|
+
function _paintContentActions(hostEl, specs) {
|
|
1766
|
+
hostEl.innerHTML = '';
|
|
1767
|
+
if (!Array.isArray(specs) || specs.length === 0) {
|
|
1768
|
+
hostEl.hidden = true;
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
hostEl.hidden = false;
|
|
1772
|
+
for (const spec of specs) {
|
|
1773
|
+
if (!spec || !spec.icon) continue;
|
|
1774
|
+
const btn = document.createElement('button');
|
|
1775
|
+
btn.type = 'button';
|
|
1776
|
+
btn.className = 'twm-leaf__btn';
|
|
1777
|
+
btn.title = spec.title || '';
|
|
1778
|
+
btn.setAttribute('aria-label', spec.title || '');
|
|
1779
|
+
btn.innerHTML =
|
|
1780
|
+
`<span class="material-symbols-outlined">${spec.icon}</span>`;
|
|
1781
|
+
// `stopPropagation` so a click does not also reach the leaf's own
|
|
1782
|
+
// mousedown, which focuses the tile and then parks focus inside the
|
|
1783
|
+
// body — stealing it back from whatever the action just opened.
|
|
1784
|
+
btn.addEventListener('click', (e) => {
|
|
1785
|
+
e.stopPropagation();
|
|
1786
|
+
try { spec.onClick?.(); } catch (err) { console.error('[tile] action threw', err); }
|
|
1787
|
+
});
|
|
1788
|
+
hostEl.appendChild(btn);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* C20. Let a content factory say a structural verb does not apply to it.
|
|
1794
|
+
*
|
|
1795
|
+
* Three answers, and the difference between the last two is a real one:
|
|
1796
|
+
*
|
|
1797
|
+
* (absent) / true the verb applies. The default, so an embedder
|
|
1798
|
+
* that says nothing gets every button as before.
|
|
1799
|
+
* `false` the verb is MEANINGLESS here — remove it. A
|
|
1800
|
+
* panel tile cannot be floated; there is nothing to
|
|
1801
|
+
* explain, and a permanently dead control is
|
|
1802
|
+
* clutter that teaches nothing.
|
|
1803
|
+
* `{ disabled, title }` the verb applies to this KIND of pane but not
|
|
1804
|
+
* right now. Keep the button, grey it, and say
|
|
1805
|
+
* why — "you may close a pane, but not the last
|
|
1806
|
+
* one" is a rule the user should be able to
|
|
1807
|
+
* discover by pointing at the thing it governs,
|
|
1808
|
+
* rather than by noticing that a button they
|
|
1809
|
+
* remember is missing.
|
|
1810
|
+
*/
|
|
1811
|
+
function _vetoStructuralActions(hostEl, chrome) {
|
|
1812
|
+
if (!chrome) return;
|
|
1813
|
+
for (const [action, rule] of Object.entries(chrome)) {
|
|
1814
|
+
const btn = hostEl.querySelector(`[data-action="${action}"]`);
|
|
1815
|
+
if (!btn) continue;
|
|
1816
|
+
if (rule === false) { btn.remove(); continue; }
|
|
1817
|
+
if (rule && typeof rule === 'object' && rule.disabled) {
|
|
1818
|
+
btn.disabled = true;
|
|
1819
|
+
btn.setAttribute('aria-disabled', 'true');
|
|
1820
|
+
btn.classList.add('twm-leaf__btn--disabled');
|
|
1821
|
+
if (rule.title) btn.title = rule.title;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|