flexdesk 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -95,17 +95,26 @@ import { openForm } from '../ui/components/modal.js';
95
95
  * only the tab on screen, leaving its
96
96
  * siblings in the tile. Default off: the
97
97
  * whole pane floats, strip and all (R8).
98
- * @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
98
+ * @param {'top'|'bottom'|'header'} [cfg.tabLayout] C22. Where a multi-tab leaf
99
+ * draws its
99
100
  * tabs. `'bottom'` is the framework's own
100
101
  * spreadsheet strip under the tile body
101
102
  * and is the DEFAULT, so no existing
102
103
  * embedder's panes rearrange on upgrade;
103
104
  * `'top'` mounts the editor tab bar
104
- * between the chrome and the body. The
105
+ * between the chrome and the body;
106
+ * `'header'` (C38) mounts it inside the
107
+ * chrome in place of the title, on every
108
+ * tile, with a `+` for a new tab. The
105
109
  * renderer mirrors this onto `root` as
106
110
  * `data-twm-tabs` and watches it, so an
107
111
  * embedder whose settings pane holds only
108
112
  * the root element can change it live.
113
+ * @param {boolean} [cfg.newTabButton] C38. Whether the `'header'` strip
114
+ * draws its `+`. Default on; an embedder
115
+ * whose new tab has nowhere useful to go
116
+ * passes `false`. `wm.newTab()` is there
117
+ * either way.
109
118
  * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
110
119
  * panelToggles?: { left?, right?, bottom? },
111
120
  * zoom? }
@@ -134,6 +143,7 @@ export async function createShell({
134
143
  backToOpenList = false,
135
144
  floatActiveTab = false,
136
145
  tabLayout = null,
146
+ newTabButton = true,
137
147
  chrome = {},
138
148
  // An embedder that moved its sections out of the top bar — into an icon
139
149
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
@@ -177,6 +187,7 @@ export async function createShell({
177
187
  backToOpenList,
178
188
  floatActiveTab,
179
189
  tabLayout,
190
+ newTabButton,
180
191
  // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
181
192
  // spreads it into every content factory, which is how the breadcrumb
182
193
  // gets `taxonomy` + `rootCrumb` without a content factory knowing they
@@ -22,6 +22,36 @@
22
22
  * tab strip, mounted BETWEEN the chrome and the body. The chrome
23
23
  * is the pane's identity — its glyph, its title, its verbs — and
24
24
  * the tabs are its contents, so the tabs go under it, not over.
25
+ * `header` — C38. The same `NotebookTabBar`, mounted INSIDE the chrome in
26
+ * place of the title, with a `+` after the last tab. One strip
27
+ * instead of two, so a pane spends one row on chrome, not two.
28
+ *
29
+ * ── C38. THE TABS ARE THE TITLE ─────────────────────────────────────────
30
+ *
31
+ * *"Replace the title with one element with the tab — the title is always in
32
+ * the tabs and we always can create more tabs. This is a more compact layout."*
33
+ *
34
+ * Under `header` the strip is drawn for EVERY content leaf, one tab included,
35
+ * because it is now the only place the pane's name appears. The chrome's own
36
+ * title and glyph are hidden by a wrap class (`twm-leaf--tabs-in-header`), not
37
+ * removed: a panel leaf has no tabs to draw, keeps its title, and a switch
38
+ * back to another layout finds the title element where it left it.
39
+ *
40
+ * The active tab's label is the CONTENT's title when the factory returned one
41
+ * — the string the chrome title used to show — so nothing the title said is
42
+ * lost by moving it into the tab.
43
+ *
44
+ * `+` appends a tab holding the taxonomy root, which is what a split seeds the
45
+ * new pane with (`wm.newTab`). It is a button, so `CHROME_NO_FLOAT` already
46
+ * makes it inert to the pull and the double-click. `newTabButton: false`
47
+ * leaves it out, for an embedder whose root is not somewhere a new tab should
48
+ * start; the tabs still replace the title.
49
+ *
50
+ * The chrome stops being a drag source here (C33): a single-tab leaf now has a
51
+ * tab to grab, and a strip that names the tab is the reason C33 limited the
52
+ * chrome to the case where there was none. A drag that begins on a tab
53
+ * belongs to the strip, so the chrome's `dragstart` lets it pass untouched.
54
+ * The empty chrome around the tabs still takes the pull and the double-click.
25
55
  *
26
56
  * The tab bar is reused rather than reimplemented. It already has the
27
57
  * behaviours a top strip is expected to have — drag-to-reorder, a scrolling
@@ -153,9 +183,9 @@
153
183
  import { NotebookTabBar } from '../editor/notebook_tab_bar.js';
154
184
 
155
185
  const SPLITTER_PX = 4;
156
- /** The two tab layouts, and the framework's default. `bottom` is the default
186
+ /** The three tab layouts, and the framework's default. `bottom` is the default
157
187
  * because it is what every existing embedder already renders. */
158
- const TAB_LAYOUTS = ['bottom', 'top'];
188
+ const TAB_LAYOUTS = ['bottom', 'top', 'header'];
159
189
  const DEFAULT_TAB_LAYOUT = 'bottom';
160
190
 
161
191
  /** Anything that is not one of the two layouts is the default rather than an
@@ -195,8 +225,9 @@ const TILE_LIFT_PX = 24;
195
225
  *
196
226
  * A tile's chrome is a grip with controls sitting on it — the split, float
197
227
  * 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
228
+ * left, and a tab — the strip is a CHILD of the chrome under the `header`
229
+ * layout (C38), and a sibling of it otherwise. Neither the downward pull nor
230
+ * the double-click may fire when
200
231
  * the press landed on one of those.
201
232
  *
202
233
  * ONE CONSTANT, TWO LISTENERS, and that is the point of writing it down
@@ -204,7 +235,7 @@ const TILE_LIFT_PX = 24;
204
235
  * about where the door is are worse than one door. A button added to this
205
236
  * strip later must become inert under BOTH gestures at once, and it cannot
206
237
  * do that if each of them carries its own copy of the rule. */
207
- const CHROME_NO_FLOAT = 'button, .twm-leaf__tab';
238
+ const CHROME_NO_FLOAT = 'button, .twm-leaf__tab, .notebook-tabs .tab';
208
239
 
209
240
  /**
210
241
  * IS THIS GESTURE THE DOWNWARD PULL? Asked in TWO places that must not be
@@ -256,7 +287,7 @@ export class TileRenderer {
256
287
  static get TAB_MIME() { return TILE_TAB_MIME; }
257
288
 
258
289
  constructor({ root, tree, content, ctx, onFocusChange, onAfterRender,
259
- tabLayout = null }) {
290
+ tabLayout = null, newTabButton = true }) {
260
291
  if (!content || typeof content.mount !== 'function') {
261
292
  throw new Error('TileRenderer: a content registry is required');
262
293
  }
@@ -279,6 +310,9 @@ export class TileRenderer {
279
310
  * The renderer cannot know about windows, so it says "I have
280
311
  * repainted" and the WM re-homes what it owns. */
281
312
  this.onAfterRender = onAfterRender || (() => {});
313
+ /** C38. Whether the `header` strip draws its `+`. An embedder whose
314
+ * new tab would open nothing useful turns it off; `wm.newTab` stays. */
315
+ this.newTabButton = newTabButton !== false;
282
316
  // leafId -> { wrapEl, bodyEl, chromeEl, content, kindKey, tabStrip }
283
317
  this._leafCache = new Map();
284
318
  this._drag = null;
@@ -498,6 +532,11 @@ export class TileRenderer {
498
532
  // that can change while the content stays put has to be refreshed
499
533
  // here or it is frozen at whatever it was when the tile was built.
500
534
  _paintLeafIcon(entry.iconEl, leaf, entry.content, this.ctx);
535
+ // C38. …and under `header` the tabs ARE the title, so a renamed
536
+ // tab is a renamed title and has to be refreshed for the same
537
+ // reason. Only when a label changed: rebuilding the strip's tabs
538
+ // on every repaint would detach a tab mid-drag.
539
+ if (this.tabLayout === 'header') this._refreshHeaderLabels(leaf, entry);
501
540
  return entry.wrapEl;
502
541
  }
503
542
  // Build fresh.
@@ -589,9 +628,11 @@ export class TileRenderer {
589
628
  }
590
629
  // C22. CHROME FIRST IN BOTH LAYOUTS. The chrome is the pane's identity
591
630
  // 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]));
631
+ // the tabs own the pane rather than the other way round. C38's
632
+ // `header` puts the strip inside the chrome — `_placeTabBar` decides.
633
+ wrap.append(chrome, body, tabBar);
634
+ this._placeTabBar({ wrapEl: wrap, bodyEl: body, chromeEl: chrome,
635
+ titleEl: title, tabBarEl: tabBar });
595
636
 
596
637
  wrap.addEventListener('mousedown', (e) => {
597
638
  if (e.target.closest('.twm-splitter')) return;
@@ -947,9 +988,12 @@ export class TileRenderer {
947
988
  * attached element is a re-parent, not a rebuild — the body keeps its
948
989
  * DOM, its listeners and its scroll. */
949
990
  _placeTabBar(entry) {
950
- const { wrapEl, bodyEl, tabBarEl } = entry;
991
+ const { wrapEl, bodyEl, tabBarEl, chromeEl, titleEl } = entry;
951
992
  if (!wrapEl || !tabBarEl || !bodyEl) return;
952
- if (this.tabLayout === 'top') wrapEl.insertBefore(tabBarEl, bodyEl);
993
+ // C38. Right after the title, so it takes the title's place and the
994
+ // content's actions and the structural buttons stay on the right.
995
+ if (this.tabLayout === 'header' && chromeEl && titleEl) titleEl.after(tabBarEl);
996
+ else if (this.tabLayout === 'top') wrapEl.insertBefore(tabBarEl, bodyEl);
953
997
  else wrapEl.appendChild(tabBarEl);
954
998
  }
955
999
 
@@ -975,16 +1019,48 @@ export class TileRenderer {
975
1019
  const bar = entry.tabBarEl;
976
1020
  if (!bar) return;
977
1021
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1022
+ const header = this.tabLayout === 'header';
978
1023
  bar.classList.toggle('twm-leaf__tabbar--top', this.tabLayout === 'top');
979
- if (tabs.length <= 1) {
1024
+ bar.classList.toggle('twm-leaf__tabbar--header', header);
1025
+ // C38. One tab is enough under `header`: the strip is the title. A
1026
+ // panel, or a leaf with no content, has no tab to name and keeps the
1027
+ // chrome's title.
1028
+ const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
1029
+ const inHeader = header && !isPanel && !!leaf.content && tabs.length >= 1;
1030
+ entry.wrapEl?.classList.toggle('twm-leaf--tabs-in-header', inHeader);
1031
+ if (!inHeader && tabs.length <= 1) {
980
1032
  bar.classList.add('twm-leaf__tabbar--hidden');
981
1033
  this._disposeTabStrip(entry);
982
1034
  bar.innerHTML = '';
983
1035
  return;
984
1036
  }
985
1037
  bar.classList.remove('twm-leaf__tabbar--hidden');
986
- if (this.tabLayout === 'top') this._renderTopTabBar(leaf, entry, tabs);
987
- else this._renderBottomTabBar(leaf, entry, tabs);
1038
+ if (this.tabLayout === 'bottom') this._renderBottomTabBar(leaf, entry, tabs);
1039
+ else this._renderTopTabBar(leaf, entry, tabs);
1040
+ }
1041
+
1042
+ /** C38. The labels the `header` strip shows for `leaf`, in one string, so
1043
+ * a repaint can tell whether anything it draws has changed. */
1044
+ _headerLabels(leaf, entry) {
1045
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1046
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
1047
+ return tabs.map((t, i) => this._tabLabel(t, i === activeIdx, entry)).join('\n');
1048
+ }
1049
+
1050
+ /** C38. A cached leaf is not re-mounted, so a tab renamed in the tree
1051
+ * (`TileTree.renameMatchingTabs`) reaches the strip only through here. */
1052
+ _refreshHeaderLabels(leaf, entry) {
1053
+ const labels = this._headerLabels(leaf, entry);
1054
+ if (entry.headerLabels === labels) return;
1055
+ this._renderTabBar(leaf, entry);
1056
+ }
1057
+
1058
+ /** What a tab in the `top` or `header` strip is called. A tab with no
1059
+ * title of its own falls back, when it is the active one, to the title
1060
+ * its content gave at mount — the string the chrome title would have
1061
+ * shown, and under `header` the chrome title is hidden (C38). */
1062
+ _tabLabel(t, active, entry) {
1063
+ return t.title || (active && entry?.content?.title) || t.kind || '';
988
1064
  }
989
1065
 
990
1066
  /**
@@ -1021,12 +1097,19 @@ export class TileRenderer {
1021
1097
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1022
1098
  const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
1023
1099
  const vetoed = this.leafChrome(leaf.id)?.promote === false;
1024
- const on = tabs.length === 1 && !isPanel && !vetoed;
1100
+ // C38. Not under `header`: the one tab is drawn in the strip, and the
1101
+ // strip is its drag source.
1102
+ const on = tabs.length === 1 && !isPanel && !vetoed && this.tabLayout !== 'header';
1025
1103
  if (on) chrome.setAttribute('draggable', 'true');
1026
1104
  else chrome.removeAttribute('draggable');
1027
1105
  if (chrome.__twmTabDragBound) return;
1028
1106
  chrome.__twmTabDragBound = true;
1029
1107
  chrome.addEventListener('dragstart', (ev) => {
1108
+ // C38. A drag that began in the strip — which the `header` layout
1109
+ // puts INSIDE the chrome — is the strip's, and has bubbled here.
1110
+ // Not refused (that would cancel the tab's own drag) and not
1111
+ // re-begun as tab 0: left alone.
1112
+ if (entry.tabBarEl?.contains(ev.target)) return;
1030
1113
  // A drag that began on a BUTTON is not a drag of the pane. Buttons
1031
1114
  // are not draggable on their own, but a draggable ancestor makes
1032
1115
  // them so — hence the same guard the two float gestures apply, from
@@ -1143,11 +1226,32 @@ export class TileRenderer {
1143
1226
  { idx, x: ev.clientX, y: ev.clientY });
1144
1227
  }, true);
1145
1228
  }
1229
+ // C38. The `+` after the last tab, `header` only. Outside the
1230
+ // component's container so its `update()` never rewrites it, and
1231
+ // built with the strip so `setTabLayout` (which empties the bar and
1232
+ // disposes the strip) takes it away with the strip.
1233
+ if (this.tabLayout === 'header' && this.newTabButton
1234
+ && !bar.querySelector('.twm-leaf__tab-new')) {
1235
+ const add = document.createElement('button');
1236
+ add.type = 'button';
1237
+ add.className = 'twm-leaf__tab-new';
1238
+ add.dataset.action = 'tab-new';
1239
+ add.title = 'New tab';
1240
+ add.setAttribute('aria-label', 'New tab');
1241
+ add.innerHTML = '<span class="material-symbols-outlined">add</span>';
1242
+ add.addEventListener('click', (ev) => {
1243
+ ev.preventDefault();
1244
+ ev.stopPropagation();
1245
+ this.ctx.onLeafTabAction?.(leaf.id, 'new', {});
1246
+ });
1247
+ bar.appendChild(add);
1248
+ }
1146
1249
  const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
1250
+ entry.headerLabels = this._headerLabels(leaf, entry);
1147
1251
  entry.tabStrip.update(
1148
1252
  tabs.map((t, i) => ({
1149
1253
  filePath: _tabKey(i),
1150
- label: t.title || t.kind || '',
1254
+ label: this._tabLabel(t, i === activeIdx, entry),
1151
1255
  // The component's `fileType` picks a glyph out of a static map
1152
1256
  // of FILE kinds. A tile's kinds are the embedder's, and the
1153
1257
  // taxonomy already answers for them — see `_paintTopTabs`.
@@ -1159,7 +1263,7 @@ export class TileRenderer {
1159
1263
  isDirty: !!t.dirty,
1160
1264
  })),
1161
1265
  _tabKey(activeIdx));
1162
- this._paintTopTabs(entry.tabStripHostEl, tabs);
1266
+ this._paintTopTabs(entry.tabStripHostEl, tabs, activeIdx, entry);
1163
1267
  }
1164
1268
 
1165
1269
  /**
@@ -1179,13 +1283,13 @@ export class TileRenderer {
1179
1283
  * rendered last. The class names used here are the component's published
1180
1284
  * contract, stated in its header comment.
1181
1285
  */
1182
- _paintTopTabs(hostEl, tabs) {
1286
+ _paintTopTabs(hostEl, tabs, activeIdx = -1, entry = null) {
1183
1287
  if (!hostEl) return;
1184
1288
  const els = hostEl.querySelectorAll('.tab');
1185
1289
  els.forEach((el, i) => {
1186
1290
  const spec = tabs[i];
1187
1291
  if (!spec) return;
1188
- el.title = spec.title || spec.kind || '';
1292
+ el.title = this._tabLabel(spec, i === activeIdx, entry);
1189
1293
  const icon = spec.kind ? this.ctx?.taxonomy?.meta?.(spec.kind)?.icon : null;
1190
1294
  const glyph = el.querySelector('.tab-icon');
1191
1295
  if (glyph && icon) glyph.textContent = icon;
package/src/tiling/wm.js CHANGED
@@ -35,7 +35,7 @@ export class WindowManager {
35
35
  constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content,
36
36
  panelDefaults = null, snapPromotion = false,
37
37
  promoteInPlace = false, tabLayout = null, backToOpenList = false,
38
- floatActiveTab = false }) {
38
+ floatActiveTab = false, newTabButton = true }) {
39
39
  if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
40
40
  if (!content || typeof content.mount !== 'function') {
41
41
  throw new Error('WindowManager: a content registry is required '
@@ -121,6 +121,8 @@ export class WindowManager {
121
121
  * `data-twm-tabs` and watches it, so an embedder can change it
122
122
  * live without holding a renderer reference. */
123
123
  tabLayout,
124
+ /** C38. Whether the `header` strip draws its `+`. */
125
+ newTabButton,
124
126
  ctx: {
125
127
  ...this.ctx,
126
128
  wm: this,
@@ -177,6 +179,24 @@ export class WindowManager {
177
179
  this.renderer.setTabLayout(layout);
178
180
  }
179
181
 
182
+ /** C38. Open a new tab in a tile and show it. It holds the taxonomy root —
183
+ * what a split seeds its new pane with, so "a new tab" and "a new pane"
184
+ * start in the same place. Defaults to the focused tile. A panel tile
185
+ * grows no tabs (`TileTree.appendLeafTab`); then this does nothing and
186
+ * answers -1, else the new tab's index. */
187
+ newTab(leafId = null) {
188
+ const tree = this._tree();
189
+ const id = leafId ?? tree.focusedLeafId;
190
+ const seed = this._rootLeaf();
191
+ const idx = tree.appendLeafTab(id, seed.content, seed.title);
192
+ if (idx < 0) return -1;
193
+ tree.focus(id);
194
+ this.renderer.render();
195
+ this._persist();
196
+ this._notifyChange('tab-new');
197
+ return idx;
198
+ }
199
+
180
200
  /** Emit on bus + call onChange. Use this instead of the bare callback
181
201
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
182
202
  * subscribe through the existing event system. */
@@ -3449,6 +3469,12 @@ export class WindowManager {
3449
3469
  this._notifyChange('twm-tab-close');
3450
3470
  return;
3451
3471
  }
3472
+ if (action === 'new') {
3473
+ // C38. The `header` strip's `+`. `newTab` renders, persists and
3474
+ // notifies on its own.
3475
+ this.newTab(leafId);
3476
+ return;
3477
+ }
3452
3478
  if (action === 'move') {
3453
3479
  tree.moveLeafTab(leafId, data.from, data.to);
3454
3480
  this.renderer.render();