@ai-matrx/canvas 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ Second independent live sweep (admin, clone):
6
+
7
+ - **Splits never make unusable panes.** A 3-way split of a 640px canvas gave 154/154/308px panes whose headers overlapped. The web now has `paneMinWidth` = `CANVAS_PANE_MIN_WIDTH` (280px): a side-by-side split whose panes would be narrower renders STACKED (the layout tree is unchanged; a wider canvas gets the columns back).
8
+ - **Tabs: a name in full when there is room, an ellipsis when there is not, a menu past that** (Chrome / VS Code). Tabs shrink — the active tab least (`flex-shrink: 0.2`) — to a floor of icon + ~6 letters + "…" (`CANVAS_TAB_MIN_WIDTH` 78px; active `CANVAS_ACTIVE_TAB_MIN_WIDTH` 100px). A name really cut (`data-truncated`, measured >1px) ends in "…" (a lone tab's title too). Tabs that do not fit at their floor go into **"All tabs (N)"** (every tab by name, the current one marked; host-drawn through `showMenu` when the host draws menus); the active tab is always shown. At the narrowest the strip is the active tab's title and that menu. The strip no longer scrolls: the scroll chevrons (which sat over "+" and "⋮") are gone, the strip is `flex-basis: 0` + `overflow: hidden`, and nothing in the header overlaps another control. The wheel over an overflowing strip steps through tabs. Pure rule: `fitCanvasTabs(count, activeIndex, width)`.
9
+ - **Phones: one pane, the header always describes it.** The pane menu offers no Split on a phone. The presented pane is the focused one only when it holds a tab, else the first pane that does (`selectCanvasPresentedPaneId`) — header and body read the same pane (a split used to show "Quick Chat" over an empty launcher, with no Back).
10
+ - **A toggle never leaves an empty canvas.** `toggleKind` (header toggles, `CanvasKindIcons`) closing the canvas's LAST tab also puts the canvas away.
11
+ - **Edge drags select nothing.** The width handle and split handles run through `beginCanvasResize`: the page's selection is cleared and `selectstart` is refused for the drag (on top of `user-select: none`); a lost pointer capture ends the drag.
12
+ - **Forward.** `CanvasItem.forward` (JSON, persisted): Back pushes what it leaves; Forward (`controller.forward`, reducer `forward`, `selectCanvasCanGoForward`) returns the SAME live body Back left; a navigate elsewhere ends the trail. The header shows Forward beside Back only when there is somewhere to go. Shortcut data: `canvas.back` (⌘[) and `canvas.forward` (⌘]) — `web: false` (browsers own those keys).
13
+ - **One pane menu.** A body offers its own actions with `useCanvasMenuItems(itemId, items)`; they join the pane's one "⋮" menu after the kind's `menuItems` and before the host's `itemActions` and the pane actions (Split, Pop out, Close). Read when the menu opens. A body never draws a second overflow button in the header.
14
+
15
+ ### Consumer action
16
+
17
+ - A kind body that portals its own overflow ("…") into the pane header: offer those actions through `useCanvasMenuItems` instead.
18
+ - A kind `HeaderAction` drawn with tap-target buttons: the pane header is a solid bar — use `TapTargetButtonTransparent` (the placement rules: glass only floats).
19
+ - CSS that targeted `.mxc-tabs-scroll` or the strip's fade: gone (the strip no longer scrolls).
20
+
21
+ ## 0.6.1
22
+
23
+ Live measurement of 0.6.0 (a 756px pane, five tabs, Quick Chat's header actions present):
24
+
25
+ - **The strip takes the header's free width** (`flex: 1`). Sized to its own intrinsic width, the browser undercut every text tab by 2–3px and cut each name; now tabs lay out in real room ("+" sits at the strip's end).
26
+ - **No ellipsis.** System fonts overflow their intrinsic width by a fraction of a pixel, which put "…" on names that fit ("Quick Ch…"). Titles clip; a name really cut (`data-truncated` on the tab, measured: more than 1px) fades at its end.
27
+ - **Room for names:** only the active tab reserves its (always shown) close's room — a background tab's close overlays on hover with the name fading under it; icon gap 4px; an empty leading slot takes no room and no gap (the actions keep the end with `margin-left: auto`).
28
+
29
+ ### Consumer action
30
+
31
+ - None.
32
+
3
33
  ## 0.6.0
4
34
 
5
35
  Independent live sweep (admin, clone):
package/README.md CHANGED
@@ -58,15 +58,30 @@ Every pane header has "+" (a launcher tab). Choosing in the launcher NAVIGATES t
58
58
  ```ts
59
59
  canvas.navigate(item.id, { kind: "doc", key: docId, data: { docId } }); // records history; Back returns
60
60
  canvas.back(item.id);
61
+ canvas.forward(item.id); // after Back: the same live body Back left
61
62
  ```
62
63
 
63
64
  Navigating to something already open elsewhere focuses it there (a launcher tab closes; any other tab keeps what it shows). History is plain JSON and persists. A lone tab shown as a title keeps its own close.
64
65
 
65
66
  **Back never throws a body away.** The newest `CANVAS_KEEP_ALIVE_DEPTH` (5) steps Back can reach, and the 5 most recent contents Back stepped away from (`item.kept`), keep their bodies mounted and hidden. Back returns the SAME live body; choosing a content Back left (Quick Chat again from the launcher) revives its body — a typed draft is still there. Older steps still work but mount fresh. Closing the tab disposes every kept body.
66
67
 
67
- **The strip.** A tab is as wide as its name (up to 12rem); tabs shrink evenly only when the header runs out of room (floor 4.5rem), then the strip scrolls — the clipped edge fades and chevrons page it. The active tab stays in view.
68
+ **Forward.** After Back the header also shows Forward (`selectCanvasCanGoForward`); it returns the same live body Back left. A navigate somewhere new ends the forward trail. Shortcut data `canvas.back` / `canvas.forward` (⌘[ / ⌘]) is for a desktop host; browsers own those keys.
68
69
 
69
- **Phones** (`CANVAS_PHONE_MAX_WIDTH`, 767px): the column presents ONE pane — the focused one — whose strip carries every pane's tabs; choosing a tab from another pane presents that pane. The layout tree is untouched, so a wider screen gets its split back.
70
+ **The strip.** A tab is as wide as its name (up to 12rem). Short of room, tabs shrink — the active tab least — to icon + ~6 letters + "…" (`CANVAS_TAB_MIN_WIDTH`; the active tab keeps `CANVAS_ACTIVE_TAB_MIN_WIDTH`); a name really cut ends in an ellipsis and every tab's tooltip is its full name. Tabs that do not fit at their floor are reached from "All tabs (N)"; the active tab is always shown. At the narrowest the strip is the active tab's title and that menu. Nothing scrolls and nothing in the header overlaps another control (`fitCanvasTabs` is the rule).
71
+
72
+ **Splits.** A side-by-side split whose panes would be narrower than `paneMinWidth` (web: `CANVAS_PANE_MIN_WIDTH`, 280px) renders stacked; the tree is unchanged.
73
+
74
+ **Phones** (`CANVAS_PHONE_MAX_WIDTH`, 767px): the column presents ONE pane — the focused one when it holds a tab, else the first that does (`selectCanvasPresentedPaneId`) — whose strip carries every pane's tabs; choosing a tab from another pane presents that pane. The pane menu offers no Split. The layout tree is untouched, so a wider screen gets its split back.
75
+
76
+ ## One pane menu
77
+
78
+ A pane has ONE menu: the kind's `menuItems`, then what the body offers right now, then the host's `itemActions`, then Split / Pop out / Close. A body adds its own actions — never a second overflow button in the header:
79
+
80
+ ```tsx
81
+ useCanvasMenuItems(item.id, [{ id: "next", label: "Next record", onSelect: goNext }]);
82
+ ```
83
+
84
+ A kind's `HeaderAction` sits on the solid pane header: draw tap-target buttons with `TapTargetButtonTransparent` (glass only floats).
70
85
 
71
86
  ## A page's own launcher entries
72
87
 
package/dist/index.cjs CHANGED
@@ -30,6 +30,7 @@ __export(src_exports, {
30
30
  CANVAS_MIN_SPLIT_FRACTION: () => CANVAS_MIN_SPLIT_FRACTION,
31
31
  CANVAS_MIN_WIDTH: () => CANVAS_MIN_WIDTH,
32
32
  CANVAS_NARROW_WIDTH: () => CANVAS_NARROW_WIDTH,
33
+ CANVAS_PANE_MIN_WIDTH: () => CANVAS_PANE_MIN_WIDTH,
33
34
  CANVAS_PHONE_MAX_WIDTH: () => CANVAS_PHONE_MAX_WIDTH,
34
35
  CANVAS_SHORTCUTS: () => CANVAS_SHORTCUTS,
35
36
  CANVAS_STORAGE_KEY: () => CANVAS_STORAGE_KEY,
@@ -70,6 +71,7 @@ __export(src_exports, {
70
71
  sanitizeCanvasSnapshot: () => sanitizeCanvasSnapshot,
71
72
  selectCanvasActiveItem: () => selectCanvasActiveItem,
72
73
  selectCanvasCanGoBack: () => selectCanvasCanGoBack,
74
+ selectCanvasCanGoForward: () => selectCanvasCanGoForward,
73
75
  selectCanvasExtraWindow: () => selectCanvasExtraWindow,
74
76
  selectCanvasFocusedPaneId: () => selectCanvasFocusedPaneId,
75
77
  selectCanvasIsFullscreen: () => selectCanvasIsFullscreen,
@@ -82,6 +84,7 @@ __export(src_exports, {
82
84
  selectCanvasLayout: () => selectCanvasLayout,
83
85
  selectCanvasPane: () => selectCanvasPane,
84
86
  selectCanvasPaneCount: () => selectCanvasPaneCount,
87
+ selectCanvasPresentedPaneId: () => selectCanvasPresentedPaneId,
85
88
  selectCanvasWidth: () => selectCanvasWidth,
86
89
  selectCanvasWindowItemCount: () => selectCanvasWindowItemCount,
87
90
  selectCanvasWindowOfItem: () => selectCanvasWindowOfItem,
@@ -267,6 +270,8 @@ var canvasActions = {
267
270
  navigate: (itemId, to) => ({ type: `${P}navigate`, payload: { itemId, to, now: Date.now() } }),
268
271
  /** Returns a tab to what it showed before its last navigate. */
269
272
  back: (itemId) => ({ type: `${P}back`, payload: { itemId, now: Date.now() } }),
273
+ /** Re-does the last Back (the step Back left), with that step's live body. */
274
+ forward: (itemId) => ({ type: `${P}forward`, payload: { itemId, now: Date.now() } }),
270
275
  closeItem: (itemId) => ({ type: `${P}closeItem`, payload: { itemId } }),
271
276
  closeOthers: (itemId) => ({ type: `${P}closeOthers`, payload: { itemId } }),
272
277
  activate: (itemId) => ({ type: `${P}activate`, payload: { itemId } }),
@@ -467,6 +472,7 @@ function newItem(kind, key, title, data, now, steps) {
467
472
  const id = canvasItemId(kind, key);
468
473
  const history = steps?.history ?? [];
469
474
  const kept = steps?.kept ?? [];
475
+ const forward = steps?.forward ?? [];
470
476
  return {
471
477
  id,
472
478
  kind,
@@ -478,7 +484,8 @@ function newItem(kind, key, title, data, now, steps) {
478
484
  // A reused instance is the SAME live body coming back (Back, or returning to a kept step).
479
485
  instance: steps?.instance ?? `${now.toString(36)}:${id}`,
480
486
  ...history.length > 0 ? { history: history.slice(-CANVAS_HISTORY_LIMIT) } : {},
481
- ...kept.length > 0 ? { kept: kept.slice(-CANVAS_KEEP_ALIVE_DEPTH) } : {}
487
+ ...kept.length > 0 ? { kept: kept.slice(-CANVAS_KEEP_ALIVE_DEPTH) } : {},
488
+ ...forward.length > 0 ? { forward: forward.slice(-CANVAS_HISTORY_LIMIT) } : {}
482
489
  };
483
490
  }
484
491
  function historyEntry(item) {
@@ -524,12 +531,14 @@ function sanitizeHistory(raw) {
524
531
  function sanitizeItem(item) {
525
532
  const history = sanitizeHistory(item.history);
526
533
  const kept = sanitizeHistory(item.kept).slice(-CANVAS_KEEP_ALIVE_DEPTH);
527
- const { history: _history, kept: _kept, instance, ...rest } = item;
534
+ const forward = sanitizeHistory(item.forward);
535
+ const { history: _history, kept: _kept, forward: _forward, instance, ...rest } = item;
528
536
  return {
529
537
  ...rest,
530
538
  ...typeof instance === "string" && instance ? { instance } : {},
531
539
  ...history.length > 0 ? { history } : {},
532
- ...kept.length > 0 ? { kept } : {}
540
+ ...kept.length > 0 ? { kept } : {},
541
+ ...forward.length > 0 ? { forward } : {}
533
542
  };
534
543
  }
535
544
  function clampWidth(width) {
@@ -720,6 +729,9 @@ function canvasReducer(state = createInitialCanvasState(), action) {
720
729
  const keptIndex = keptAll.findIndex((entry) => canvasItemId(entry.kind, entry.key) === targetId);
721
730
  const revived = keptIndex >= 0 ? keptAll[keptIndex] : void 0;
722
731
  const kept = keptAll.filter((_, i) => i !== keptIndex);
732
+ const forwardAll = current.forward ?? [];
733
+ const nextForward = forwardAll[forwardAll.length - 1];
734
+ const forward = nextForward && canvasItemId(nextForward.kind, nextForward.key) === targetId ? forwardAll.slice(0, -1) : [];
723
735
  return replaceInPlace(
724
736
  state,
725
737
  current,
@@ -729,7 +741,7 @@ function canvasReducer(state = createInitialCanvasState(), action) {
729
741
  to.title !== void 0 ? to.title : revived?.title ?? null,
730
742
  to.data !== void 0 ? to.data : revived?.data ?? null,
731
743
  now,
732
- { history, kept, instance: revived?.instance }
744
+ { history, kept, forward, instance: revived?.instance }
733
745
  )
734
746
  );
735
747
  }
@@ -745,10 +757,36 @@ function canvasReducer(state = createInitialCanvasState(), action) {
745
757
  return focusExisting(canvasReducer(state, canvasActions.closeItem(itemId)), targetId);
746
758
  }
747
759
  const kept = [...(current.kept ?? []).filter((k) => canvasItemId(k.kind, k.key) !== itemId), historyEntry(current)];
760
+ const forward = [...current.forward ?? [], historyEntry(current)];
748
761
  return replaceInPlace(
749
762
  state,
750
763
  current,
751
- newItem(entry.kind, entry.key, entry.title, entry.data, now, { history: history.slice(0, -1), kept, instance: entry.instance })
764
+ newItem(entry.kind, entry.key, entry.title, entry.data, now, { history: history.slice(0, -1), kept, forward, instance: entry.instance })
765
+ );
766
+ }
767
+ case `${P}forward`: {
768
+ const { itemId, now } = action.payload;
769
+ const current = state.items[itemId];
770
+ const forwardAll = current?.forward ?? [];
771
+ const entry = forwardAll[forwardAll.length - 1];
772
+ if (!current || !entry || !paneOf(state, itemId)) return state;
773
+ const targetId = canvasItemId(entry.kind, entry.key);
774
+ if (targetId === itemId) return state;
775
+ if (state.items[targetId]) {
776
+ return focusExisting(canvasReducer(state, canvasActions.closeItem(itemId)), targetId);
777
+ }
778
+ const keptAll = current.kept ?? [];
779
+ const revived = keptAll.find((k) => canvasItemId(k.kind, k.key) === targetId);
780
+ const kept = keptAll.filter((k) => canvasItemId(k.kind, k.key) !== targetId);
781
+ return replaceInPlace(
782
+ state,
783
+ current,
784
+ newItem(entry.kind, entry.key, entry.title, entry.data, now, {
785
+ history: [...current.history ?? [], historyEntry(current)],
786
+ kept,
787
+ forward: forwardAll.slice(0, -1),
788
+ instance: revived?.instance ?? entry.instance
789
+ })
752
790
  );
753
791
  }
754
792
  case `${P}closeItem`: {
@@ -1077,6 +1115,7 @@ function toPersistableSnapshot(state, isRestorable) {
1077
1115
  }
1078
1116
 
1079
1117
  // src/core/rules.ts
1118
+ var CANVAS_PANE_MIN_WIDTH = 280;
1080
1119
  var CANVAS_WEB_LAYOUT_RULES = {
1081
1120
  defaultWidth: CANVAS_DEFAULT_WIDTH,
1082
1121
  minWidth: CANVAS_MIN_WIDTH,
@@ -1084,7 +1123,9 @@ var CANVAS_WEB_LAYOUT_RULES = {
1084
1123
  // Split down 70/30 like the desktop: what opens below (notifications, a feed) is the smaller pane.
1085
1124
  stackSplit: 0.7,
1086
1125
  sideSplit: 0.5,
1087
- paneMinWidth: 0
1126
+ // A pane narrower than this cannot show a header (Back, a readable tab, "+", the menu, close) and a
1127
+ // body side by side with another: a split right that would go under it stacks instead.
1128
+ paneMinWidth: CANVAS_PANE_MIN_WIDTH
1088
1129
  };
1089
1130
  var CANVAS_DESKTOP_LAYOUT_RULES = {
1090
1131
  defaultWidth: { fraction: 0.36 },
@@ -1131,6 +1172,9 @@ var CANVAS_SHORTCUTS = [
1131
1172
  { id: "canvas.toggleFullscreen", accelerator: "CmdOrCtrl+Shift+Enter", when: "canvas-focus", label: "Expand canvas", web: true },
1132
1173
  { id: "canvas.splitRight", accelerator: "CmdOrCtrl+Alt+\\", when: "canvas-focus", label: "Split right", web: true },
1133
1174
  { id: "canvas.splitDown", accelerator: "CmdOrCtrl+Alt+Shift+\\", when: "canvas-focus", label: "Split down", web: true },
1175
+ // Browsers own ⌘[ / ⌘] for their own history: data for a desktop host (and the header's Back / Forward).
1176
+ { id: "canvas.back", accelerator: "CmdOrCtrl+[", when: "canvas-focus", label: "Back", web: false },
1177
+ { id: "canvas.forward", accelerator: "CmdOrCtrl+]", when: "canvas-focus", label: "Forward", web: false },
1134
1178
  { id: "canvas.closeTab", accelerator: "CmdOrCtrl+W", when: "canvas-focus", label: "Close tab", web: false },
1135
1179
  { id: "canvas.nextTab", accelerator: "Ctrl+Tab", when: "canvas-focus", label: "Next tab", web: false },
1136
1180
  { id: "canvas.previousTab", accelerator: "Ctrl+Shift+Tab", when: "canvas-focus", label: "Previous tab", web: false }
@@ -1180,11 +1224,17 @@ function runCanvasCommand(controller, id, windowId = controller.windowId) {
1180
1224
  if (!frame2?.isOpen || !pane) return false;
1181
1225
  controller.splitPane(pane.id, id === "canvas.splitRight" ? "horizontal" : "vertical", pane.itemIds.length > 1 ? pane.activeItemId ?? void 0 : void 0);
1182
1226
  return true;
1227
+ case "canvas.back":
1228
+ if (!frame2?.isOpen || !pane?.activeItemId) return false;
1229
+ return controller.back(pane.activeItemId) !== null;
1230
+ case "canvas.forward":
1231
+ if (!frame2?.isOpen || !pane?.activeItemId) return false;
1232
+ return controller.forward(pane.activeItemId) !== null;
1183
1233
  default:
1184
1234
  return false;
1185
1235
  }
1186
1236
  }
1187
- var CODE_FOR = { "\\": "Backslash", Tab: "Tab", Escape: "Escape", Enter: "Enter" };
1237
+ var CODE_FOR = { "\\": "Backslash", "[": "BracketLeft", "]": "BracketRight", Tab: "Tab", Escape: "Escape", Enter: "Enter" };
1188
1238
  function matchesCanvasAccelerator(input, accelerator, isMac) {
1189
1239
  const parts = accelerator.split("+");
1190
1240
  const key = parts.pop() ?? "";
@@ -1301,6 +1351,13 @@ function view(shared, windowId) {
1301
1351
  dispatch(canvasActions.back(itemId));
1302
1352
  return canvasItemId(entry.kind, entry.key);
1303
1353
  },
1354
+ forward(itemId) {
1355
+ const forward = store.getState().items[itemId]?.forward ?? [];
1356
+ const entry = forward[forward.length - 1];
1357
+ if (!entry) return null;
1358
+ dispatch(canvasActions.forward(itemId));
1359
+ return canvasItemId(entry.kind, entry.key);
1360
+ },
1304
1361
  newTab(paneId) {
1305
1362
  const state = store.getState();
1306
1363
  const frame2 = canvasWindowFrame(state, windowId);
@@ -1584,6 +1641,16 @@ function selectCanvasItemHistory(s, itemId) {
1584
1641
  return s.items[itemId]?.history ?? NO_HISTORY;
1585
1642
  }
1586
1643
  var selectCanvasCanGoBack = (s, itemId) => (s.items[itemId]?.history?.length ?? 0) > 0;
1644
+ var selectCanvasCanGoForward = (s, itemId) => (s.items[itemId]?.forward?.length ?? 0) > 0;
1645
+ function selectCanvasPresentedPaneId(s, windowId) {
1646
+ const f = frame(s, windowId);
1647
+ if (!f) return null;
1648
+ const ids = listPaneIds(f.layout);
1649
+ const holds = (id) => (s.panes[id]?.itemIds.length ?? 0) > 0;
1650
+ const focused = ids.find((id) => id === f.focusedPaneId);
1651
+ if (focused && holds(focused)) return focused;
1652
+ return ids.find(holds) ?? focused ?? ids[0] ?? null;
1653
+ }
1587
1654
 
1588
1655
  // src/core/presentation.ts
1589
1656
  var CANVAS_PHONE_MAX_WIDTH = 767;
@@ -1633,7 +1700,9 @@ function toggleKind(controller, input) {
1633
1700
  const before = selectCanvasKindVisibility(controller.getState(), input.kind, key);
1634
1701
  if (before === "visible") {
1635
1702
  if ((input.whenVisible ?? "close") === "close") {
1703
+ const windowId = canvasWindowOfItem(controller.getState(), itemId);
1636
1704
  controller.close(itemId);
1705
+ if (windowId && selectCanvasWindowItemCount(controller.getState(), windowId) === 0) controller.hide(windowId);
1637
1706
  } else {
1638
1707
  controller.hide(canvasWindowOfItem(controller.getState(), itemId) ?? void 0);
1639
1708
  }