@ai-matrx/canvas 0.4.0 → 0.5.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 +27 -0
- package/README.md +61 -1
- package/dist/index.cjs +203 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +203 -21
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +958 -288
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +93 -3
- package/dist/react.d.ts +93 -3
- package/dist/react.js +966 -293
- package/dist/react.js.map +1 -1
- package/dist/styles.css +221 -26
- package/dist/{toggleKind-A9v45oSb.d.cts → toggleKind-BD78LnMB.d.cts} +105 -1
- package/dist/{toggleKind-A9v45oSb.d.ts → toggleKind-BD78LnMB.d.ts} +105 -1
- package/dist/tokens.css +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
Owner feedback after using the canvas live (Arman, 2026-10-02): a stable canvas that never dismounts, no UI traps, readable tabs, the shell's motion, content that knows its shape, and the pieces web and desktop both need.
|
|
6
|
+
|
|
7
|
+
- **Stable bodies (never dismount).** Every open item's body is rendered ONCE, in a body layer at the column root keyed by the item's instance, and placed over its pane's body slot by measured rect (DOM bodies and native bodies alike: a body on a slot). A tab switch hides it (`visibility` + `content-visibility: hidden` + `inert`: state and scroll kept); a split, a move between panes, a width drag and expand re-place it; putting the canvas away keeps it. A body mounts the first time it is shown and stays mounted until its tab closes. A rekeyed draft keeps its body (`CanvasItem.instance`). Moving a tab to another window remounts it (a different column). Bodies re-render only when their own item, pane, focus, visibility or presentation changes.
|
|
8
|
+
- **`unmountWhenHidden`** (kind field): opt out for genuinely heavy hidden bodies. **`keepAlive` is deprecated and does nothing** — staying mounted is the default.
|
|
9
|
+
- **`CanvasKindProps.isVisible`**: on screen right now (active tab, canvas open) — pause costly work while false.
|
|
10
|
+
- **The column never unmounts.** Put away, it is 0 wide, `inert`, `aria-hidden` and hidden after the slide; `useCanvasColumnWidth()` still reads 0.
|
|
11
|
+
- **No UI traps.** Every pane header has a **"+" New tab** (opens the launcher as a tab; a pane holds one). A lone tab shown as a title has **its own close**. **Tab history:** `controller.navigate(itemId, { kind, key, title?, data? })` replaces a tab's content in place and records where it was (`CanvasItem.history`, JSON, persisted, at most `CANVAS_HISTORY_LIMIT` = 50); the header shows **Back** while the active tab has history; `controller.back(itemId)`. Choosing in the launcher navigates the launcher tab (Back returns to it); choosing in an empty pane's launcher does the same. Identity still holds: navigating to an item open elsewhere focuses it there — a launcher tab then closes, any other tab keeps what it shows; Back to an item opened elsewhere since focuses it and closes the step. Reducer actions `navigate` / `back`, `controller.newTab(paneId?)`, selectors `selectCanvasCanGoBack` / `selectCanvasItemHistory`, `CANVAS_LAUNCHER_KIND`.
|
|
12
|
+
- **Dense tabs.** 12.5px text, 14px icon, 6px side padding, 26px tall; tabs shrink evenly from 8rem to a 3.25rem floor, the active tab keeps icon + ~10 letters, the close is overlaid (no width while hidden; the title fades under it), the strip scrolls sideways with the wheel and keeps the active tab in view.
|
|
13
|
+
- **Motion.** Open, put away, expand and restore animate the column's width on `--mxc-motion-duration` / `--mxc-motion-ease` (tokens.css: 600ms, `cubic-bezier(0.4, 0, 0.2, 1)` — the AI Matrx shell sidebar's slide); the content keeps its width so it slides instead of reflowing; off while an edge is dragged and under `prefers-reduced-motion`. Native views are re-measured every frame of the slide.
|
|
14
|
+
- **Presentation primitive.** `CanvasKindProps.presentation = { host: "canvas", width, height, orientation: "portrait" | "landscape" | "square", isNarrow, isFullscreen, paneCount }`, live from the slot; `useCanvasPresentation()` gives any nested component the same value (null outside the canvas); `preferredFlowDirection(presentation)` → `"vertical" | "horizontal"`; pure `canvasPresentation`, `canvasOrientation`, `CANVAS_NARROW_WIDTH` in the core.
|
|
15
|
+
- **Contextual launcher entries.** `useCanvasLauncherEntry({ kind, key, data?, title?, icon? })` offers a page's own thing in the launcher while it is mounted ("On this page", before the global kinds); entries for unregistered kinds are not offered.
|
|
16
|
+
- **`preferredTarget`** (kind field): `"split-down" | "split-right" | "focused"` — where a NEW item of the kind opens while the canvas already shows something; a pane already holding the kind takes it; an explicit `target` wins. `open` and `toggleKind` honour it (controller option `preferredTarget`).
|
|
17
|
+
- **`CanvasKindIcons`**: a header's cluster of kind toggles (`kinds: (string | { kind, key?, data?, title?, whenVisible?, label? })[]`, `variant`), each pressed while its item is in front; unregistered kinds draw nothing.
|
|
18
|
+
- **Web split-down is 70/30** (`CANVAS_WEB_LAYOUT_RULES.stackSplit` 0.5 → 0.7), like the desktop: what opens below is the smaller pane.
|
|
19
|
+
|
|
20
|
+
### Consumer action
|
|
21
|
+
|
|
22
|
+
- A host that reflows beside the docked column (`right: var(--shell-canvas-w)`, a grid track) transitions that property with `var(--mxc-motion-duration) var(--mxc-motion-ease)`, turns the transition off under `:root[data-mxc-resizing]` and under `prefers-reduced-motion: reduce` (README § Motion). A shell that hid the app in full screen keeps its docked inset and lets the column grow over it.
|
|
23
|
+
- Delete `keepAlive` from kinds (no-op). Add `unmountWhenHidden: true` only for a body that must not stay mounted.
|
|
24
|
+
- Code that builds `CanvasKindProps` itself passes `presentation` and `isVisible`.
|
|
25
|
+
- Code that read `.mxc-item[hidden]` or expected `.mxc-column` to be absent when put away reads `[data-visible]` on `.mxc-item-host` and `[data-open]` on `.mxc-column`.
|
|
26
|
+
- A host-side "open new tab below" rule (web `toolCanvas` `target: "split-down"`) becomes the kind's `preferredTarget`.
|
|
27
|
+
- Code iterating `state.items` skips `CANVAS_LAUNCHER_KIND` ("New tab") items where a launcher is not a real thing.
|
|
28
|
+
- Desktop: the toolbar icon cluster can be `CanvasKindIcons`; a kind body that drew its own back button for in-canvas navigation can use `controller.navigate` / Back.
|
|
29
|
+
|
|
3
30
|
## 0.4.0
|
|
4
31
|
|
|
5
32
|
- **`CanvasKind.Toolbar`** — a toolbar ROW between the pane header and the body, rendered for the active item only (a browser's back / forward / reload / address / screenshot / open in browser / new tab), so the header keeps just the tab strip and pane controls. Always DOM and never covered by a native view: a `body: "native"` frame is measured below it. A toolbar that throws renders nothing instead of taking the pane down. Requested by the desktop session.
|
package/README.md
CHANGED
|
@@ -29,7 +29,6 @@ defineCanvasKind<{ sessionKey: string }>({
|
|
|
29
29
|
restore: "session", // closing detaches; a restore or a move reattaches
|
|
30
30
|
onAttach: ({ sessionKey, reason }) => attach(sessionKey, reason),
|
|
31
31
|
onDetach: ({ sessionKey }) => detach(sessionKey), // never kill here
|
|
32
|
-
keepAlive: true,
|
|
33
32
|
HeaderLeading: NewTerminalButton, // "+ new terminal", a browser bar…
|
|
34
33
|
HeaderAction: ClearButton,
|
|
35
34
|
unavailable: () => (backendUp() ? null : "Terminal · next release"),
|
|
@@ -38,6 +37,67 @@ defineCanvasKind<{ sessionKey: string }>({
|
|
|
38
37
|
|
|
39
38
|
`body: "native"` makes the body a placeholder whose frame the host receives (below).
|
|
40
39
|
|
|
40
|
+
## Bodies never dismount
|
|
41
|
+
|
|
42
|
+
Every open item's body is rendered once, in the column's body layer, and placed over its pane by measured rect. Switching tabs hides it; splitting, moving a tab between panes, dragging the width, expanding and putting the canvas away only re-place or hide it. React state, focus and scroll survive everything but closing the tab. A body mounts the first time it is shown. `isVisible` tells it whether it is on screen (pause polling while false); `unmountWhenHidden: true` opts a genuinely heavy body out.
|
|
43
|
+
|
|
44
|
+
## Content that knows its shape
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
function GraphView({ presentation }: CanvasKindProps) { // or useCanvasPresentation() anywhere inside the body
|
|
48
|
+
return <Graph direction={preferredFlowDirection(presentation) === "vertical" ? "TB" : "LR"} />;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`presentation = { host: "canvas", width, height, orientation: "portrait" | "landscape" | "square", isNarrow, isFullscreen, paneCount }`, live. `useCanvasPresentation()` returns it to any nested component and `null` outside the canvas. When you have a lot of vertical space and little horizontal space, adapt: lay graphs, timelines and card rows out top to bottom, stack side-by-side columns, shorten toolbars.
|
|
53
|
+
|
|
54
|
+
## Tabs, New tab and Back
|
|
55
|
+
|
|
56
|
+
Every pane header has "+" (a launcher tab). Choosing in the launcher NAVIGATES that tab in place, so the header offers Back. Do the same from a kind:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
canvas.navigate(item.id, { kind: "doc", key: docId, data: { docId } }); // records history; Back returns
|
|
60
|
+
canvas.back(item.id);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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
|
+
## A page's own launcher entries
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
useCanvasLauncherEntry({ kind: "documents", key: conversationId, data: { conversationId }, title: "This chat's documents" });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Shown first ("On this page") while the calling component is mounted.
|
|
72
|
+
|
|
73
|
+
## Where a new item opens
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
defineCanvasKind({ id: "notifications", preferredTarget: "split-down", /* … */ });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
While the canvas already shows something, a NEW item of the kind opens below (the layout rules' split share, 70/30) or beside; a pane already showing the kind takes it. An explicit `target` wins. `open` and `toggleKind` honour it.
|
|
80
|
+
|
|
81
|
+
## A header's tool icons
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<CanvasKindIcons kinds={["terminal", "review", { kind: "browser", label: "Browser" }]} variant="glass" />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Each icon toggles or focuses its kind and is pressed while its item is in front.
|
|
88
|
+
|
|
89
|
+
## Motion
|
|
90
|
+
|
|
91
|
+
The column animates its width on `--mxc-motion-duration` / `--mxc-motion-ease` (tokens.css: 600ms, `cubic-bezier(0.4, 0, 0.2, 1)`), with its content keeping its width so it slides. A host that reflows beside it moves in lockstep on the same pair:
|
|
92
|
+
|
|
93
|
+
```css
|
|
94
|
+
.app-shell { right: var(--canvas-w); transition: right var(--mxc-motion-duration) var(--mxc-motion-ease); }
|
|
95
|
+
:root[data-mxc-resizing] .app-shell { transition: none; } /* the edge is being dragged */
|
|
96
|
+
@media (prefers-reduced-motion: reduce) { .app-shell { transition: none; } }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`<CanvasFrame>` needs nothing: its main area is a flex sibling and follows the column every frame.
|
|
100
|
+
|
|
41
101
|
## A toolbar row under the header
|
|
42
102
|
|
|
43
103
|
A kind's `Toolbar` renders between the pane header and its body for the active
|
package/dist/index.cjs
CHANGED
|
@@ -23,15 +23,20 @@ __export(src_exports, {
|
|
|
23
23
|
CANVAS_DEFAULT_KEY: () => CANVAS_DEFAULT_KEY,
|
|
24
24
|
CANVAS_DEFAULT_WIDTH: () => CANVAS_DEFAULT_WIDTH,
|
|
25
25
|
CANVAS_DESKTOP_LAYOUT_RULES: () => CANVAS_DESKTOP_LAYOUT_RULES,
|
|
26
|
+
CANVAS_HISTORY_LIMIT: () => CANVAS_HISTORY_LIMIT,
|
|
27
|
+
CANVAS_LAUNCHER_KIND: () => CANVAS_LAUNCHER_KIND,
|
|
26
28
|
CANVAS_MAIN_WINDOW: () => CANVAS_MAIN_WINDOW,
|
|
27
29
|
CANVAS_MIN_SPLIT_FRACTION: () => CANVAS_MIN_SPLIT_FRACTION,
|
|
28
30
|
CANVAS_MIN_WIDTH: () => CANVAS_MIN_WIDTH,
|
|
31
|
+
CANVAS_NARROW_WIDTH: () => CANVAS_NARROW_WIDTH,
|
|
29
32
|
CANVAS_SHORTCUTS: () => CANVAS_SHORTCUTS,
|
|
30
33
|
CANVAS_STORAGE_KEY: () => CANVAS_STORAGE_KEY,
|
|
31
34
|
CANVAS_WEB_LAYOUT_RULES: () => CANVAS_WEB_LAYOUT_RULES,
|
|
32
35
|
bindCanvasToReduxStore: () => bindCanvasToReduxStore,
|
|
33
36
|
canvasActions: () => canvasActions,
|
|
34
37
|
canvasItemId: () => canvasItemId,
|
|
38
|
+
canvasOrientation: () => canvasOrientation,
|
|
39
|
+
canvasPresentation: () => canvasPresentation,
|
|
35
40
|
canvasReducer: () => canvasReducer,
|
|
36
41
|
canvasShortcutHint: () => canvasShortcutHint,
|
|
37
42
|
canvasWindowFrame: () => canvasWindowFrame,
|
|
@@ -57,10 +62,12 @@ __export(src_exports, {
|
|
|
57
62
|
matchesCanvasAccelerator: () => matchesCanvasAccelerator,
|
|
58
63
|
normalizeSizes: () => normalizeSizes,
|
|
59
64
|
parseCanvasItemId: () => parseCanvasItemId,
|
|
65
|
+
preferredFlowDirection: () => preferredFlowDirection,
|
|
60
66
|
resolveCanvasLayoutRules: () => resolveCanvasLayoutRules,
|
|
61
67
|
runCanvasCommand: () => runCanvasCommand,
|
|
62
68
|
sanitizeCanvasSnapshot: () => sanitizeCanvasSnapshot,
|
|
63
69
|
selectCanvasActiveItem: () => selectCanvasActiveItem,
|
|
70
|
+
selectCanvasCanGoBack: () => selectCanvasCanGoBack,
|
|
64
71
|
selectCanvasExtraWindow: () => selectCanvasExtraWindow,
|
|
65
72
|
selectCanvasFocusedPaneId: () => selectCanvasFocusedPaneId,
|
|
66
73
|
selectCanvasIsFullscreen: () => selectCanvasIsFullscreen,
|
|
@@ -68,6 +75,7 @@ __export(src_exports, {
|
|
|
68
75
|
selectCanvasIsOpen: () => selectCanvasIsOpen,
|
|
69
76
|
selectCanvasItem: () => selectCanvasItem,
|
|
70
77
|
selectCanvasItemCount: () => selectCanvasItemCount,
|
|
78
|
+
selectCanvasItemHistory: () => selectCanvasItemHistory,
|
|
71
79
|
selectCanvasKindVisibility: () => selectCanvasKindVisibility,
|
|
72
80
|
selectCanvasLayout: () => selectCanvasLayout,
|
|
73
81
|
selectCanvasPane: () => selectCanvasPane,
|
|
@@ -85,6 +93,8 @@ module.exports = __toCommonJS(src_exports);
|
|
|
85
93
|
|
|
86
94
|
// src/core/types.ts
|
|
87
95
|
var CANVAS_MAIN_WINDOW = "main";
|
|
96
|
+
var CANVAS_LAUNCHER_KIND = "canvas.launcher";
|
|
97
|
+
var CANVAS_HISTORY_LIMIT = 50;
|
|
88
98
|
var CANVAS_MIN_WIDTH = 360;
|
|
89
99
|
var CANVAS_DEFAULT_WIDTH = 640;
|
|
90
100
|
var CANVAS_MIN_SPLIT_FRACTION = 0.12;
|
|
@@ -250,6 +260,10 @@ var canvasActions = {
|
|
|
250
260
|
}),
|
|
251
261
|
/** Gives an item a new identity in place (e.g. a draft that was just saved and now has an id). */
|
|
252
262
|
rekey: (itemId, key) => ({ type: `${P}rekey`, payload: { itemId, key } }),
|
|
263
|
+
/** Replaces a tab's content with another item in place, remembering what it showed (Back returns). */
|
|
264
|
+
navigate: (itemId, to) => ({ type: `${P}navigate`, payload: { itemId, to, now: Date.now() } }),
|
|
265
|
+
/** Returns a tab to what it showed before its last navigate. */
|
|
266
|
+
back: (itemId) => ({ type: `${P}back`, payload: { itemId, now: Date.now() } }),
|
|
253
267
|
closeItem: (itemId) => ({ type: `${P}closeItem`, payload: { itemId } }),
|
|
254
268
|
closeOthers: (itemId) => ({ type: `${P}closeOthers`, payload: { itemId } }),
|
|
255
269
|
activate: (itemId) => ({ type: `${P}activate`, payload: { itemId } }),
|
|
@@ -446,6 +460,63 @@ function sendHome(state, itemId, home, activate) {
|
|
|
446
460
|
next = insertInto(next, homeFrame.focusedPaneId, itemId, activate);
|
|
447
461
|
return activate ? withFrame(next, target, { focusedPaneId: homeFrame.focusedPaneId }) : next;
|
|
448
462
|
}
|
|
463
|
+
function newItem(kind, key, title, data, now, history) {
|
|
464
|
+
const id = canvasItemId(kind, key);
|
|
465
|
+
return {
|
|
466
|
+
id,
|
|
467
|
+
kind,
|
|
468
|
+
key,
|
|
469
|
+
title,
|
|
470
|
+
data,
|
|
471
|
+
openedAt: now,
|
|
472
|
+
updatedAt: now,
|
|
473
|
+
instance: `${now.toString(36)}:${id}`,
|
|
474
|
+
...history && history.length > 0 ? { history: history.slice(-CANVAS_HISTORY_LIMIT) } : {}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function historyEntry(item) {
|
|
478
|
+
return { kind: item.kind, key: item.key, title: item.title, data: item.data };
|
|
479
|
+
}
|
|
480
|
+
function replaceInPlace(state, current, next) {
|
|
481
|
+
const pane = paneOf(state, current.id);
|
|
482
|
+
if (!pane) return state;
|
|
483
|
+
const items = { ...state.items, [next.id]: next };
|
|
484
|
+
delete items[current.id];
|
|
485
|
+
const withItems = { ...state, items };
|
|
486
|
+
const replaced = withPane(withItems, {
|
|
487
|
+
...pane,
|
|
488
|
+
itemIds: pane.itemIds.map((id) => id === current.id ? next.id : id),
|
|
489
|
+
activeItemId: pane.activeItemId === current.id ? next.id : pane.activeItemId
|
|
490
|
+
});
|
|
491
|
+
return focus(replaced, pane.id);
|
|
492
|
+
}
|
|
493
|
+
function focusExisting(state, itemId) {
|
|
494
|
+
const pane = paneOf(state, itemId);
|
|
495
|
+
if (!pane) return state;
|
|
496
|
+
const next = focus(withPane(state, { ...pane, activeItemId: itemId }), pane.id);
|
|
497
|
+
const windowId = canvasWindowOfPane(next, pane.id);
|
|
498
|
+
return windowId ? withFrame(next, windowId, { isOpen: true }) : next;
|
|
499
|
+
}
|
|
500
|
+
function sanitizeHistory(raw) {
|
|
501
|
+
if (!Array.isArray(raw)) return [];
|
|
502
|
+
const out = [];
|
|
503
|
+
for (const entry of raw) {
|
|
504
|
+
if (!entry || typeof entry !== "object") continue;
|
|
505
|
+
const e = entry;
|
|
506
|
+
if (typeof e.kind !== "string" || typeof e.key !== "string") continue;
|
|
507
|
+
out.push({ kind: e.kind, key: e.key, title: typeof e.title === "string" ? e.title : null, data: e.data ?? null });
|
|
508
|
+
}
|
|
509
|
+
return out.slice(-CANVAS_HISTORY_LIMIT);
|
|
510
|
+
}
|
|
511
|
+
function sanitizeItem(item) {
|
|
512
|
+
const history = sanitizeHistory(item.history);
|
|
513
|
+
const { history: _history, instance, ...rest } = item;
|
|
514
|
+
return {
|
|
515
|
+
...rest,
|
|
516
|
+
...typeof instance === "string" && instance ? { instance } : {},
|
|
517
|
+
...history.length > 0 ? { history } : {}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
449
520
|
function clampWidth(width) {
|
|
450
521
|
return Number.isFinite(width) ? Math.max(CANVAS_MIN_WIDTH, Math.round(width)) : null;
|
|
451
522
|
}
|
|
@@ -493,7 +564,7 @@ function sanitizeCanvasSnapshot(raw) {
|
|
|
493
564
|
const items = {};
|
|
494
565
|
for (const [id, item] of Object.entries(s.items)) {
|
|
495
566
|
if (item && typeof item.kind === "string" && typeof item.key === "string" && canvasItemId(item.kind, item.key) === id) {
|
|
496
|
-
items[id] = item;
|
|
567
|
+
items[id] = sanitizeItem(item);
|
|
497
568
|
}
|
|
498
569
|
}
|
|
499
570
|
const placed = /* @__PURE__ */ new Set();
|
|
@@ -567,7 +638,7 @@ function canvasReducer(state = createInitialCanvasState(), action) {
|
|
|
567
638
|
next = insertInto(next, frame2.focusedPaneId, itemId, true);
|
|
568
639
|
}
|
|
569
640
|
} else {
|
|
570
|
-
const item =
|
|
641
|
+
const item = newItem(kind, key, title ?? null, data ?? null, now);
|
|
571
642
|
next = ensureWindow({ ...next, items: { ...next.items, [itemId]: item } }, revealIn);
|
|
572
643
|
const frame2 = canvasWindowFrame(next, revealIn) ?? next;
|
|
573
644
|
let paneId = frame2.focusedPaneId;
|
|
@@ -616,6 +687,35 @@ function canvasReducer(state = createInitialCanvasState(), action) {
|
|
|
616
687
|
}
|
|
617
688
|
return { ...state, items, panes };
|
|
618
689
|
}
|
|
690
|
+
case `${P}navigate`: {
|
|
691
|
+
const { itemId, to, now } = action.payload;
|
|
692
|
+
const current = state.items[itemId];
|
|
693
|
+
if (!current || !paneOf(state, itemId)) return state;
|
|
694
|
+
const targetId = canvasItemId(to.kind, to.key);
|
|
695
|
+
if (targetId === itemId) {
|
|
696
|
+
const refresh = { type: `${P}update`, payload: { itemId, data: to.data, title: to.title, now } };
|
|
697
|
+
return canvasReducer(state, refresh);
|
|
698
|
+
}
|
|
699
|
+
if (state.items[targetId]) {
|
|
700
|
+
const focused = focusExisting(state, targetId);
|
|
701
|
+
return current.kind === CANVAS_LAUNCHER_KIND ? canvasReducer(focused, canvasActions.closeItem(itemId)) : focused;
|
|
702
|
+
}
|
|
703
|
+
const history = [...current.history ?? [], historyEntry(current)];
|
|
704
|
+
return replaceInPlace(state, current, newItem(to.kind, to.key, to.title ?? null, to.data ?? null, now, history));
|
|
705
|
+
}
|
|
706
|
+
case `${P}back`: {
|
|
707
|
+
const { itemId, now } = action.payload;
|
|
708
|
+
const current = state.items[itemId];
|
|
709
|
+
const history = current?.history ?? [];
|
|
710
|
+
const entry = history[history.length - 1];
|
|
711
|
+
if (!current || !entry || !paneOf(state, itemId)) return state;
|
|
712
|
+
const targetId = canvasItemId(entry.kind, entry.key);
|
|
713
|
+
if (targetId === itemId) return state;
|
|
714
|
+
if (state.items[targetId]) {
|
|
715
|
+
return focusExisting(canvasReducer(state, canvasActions.closeItem(itemId)), targetId);
|
|
716
|
+
}
|
|
717
|
+
return replaceInPlace(state, current, newItem(entry.kind, entry.key, entry.title, entry.data, now, history.slice(0, -1)));
|
|
718
|
+
}
|
|
619
719
|
case `${P}closeItem`: {
|
|
620
720
|
const { itemId } = action.payload;
|
|
621
721
|
if (!state.items[itemId]) return state;
|
|
@@ -946,7 +1046,8 @@ var CANVAS_WEB_LAYOUT_RULES = {
|
|
|
946
1046
|
defaultWidth: CANVAS_DEFAULT_WIDTH,
|
|
947
1047
|
minWidth: CANVAS_MIN_WIDTH,
|
|
948
1048
|
centreMinWidth: 420,
|
|
949
|
-
|
|
1049
|
+
// Split down 70/30 like the desktop: what opens below (notifications, a feed) is the smaller pane.
|
|
1050
|
+
stackSplit: 0.7,
|
|
950
1051
|
sideSplit: 0.5,
|
|
951
1052
|
paneMinWidth: 0
|
|
952
1053
|
};
|
|
@@ -1079,7 +1180,8 @@ function createCanvasController(options) {
|
|
|
1079
1180
|
presentationListeners: /* @__PURE__ */ new Set(),
|
|
1080
1181
|
views: /* @__PURE__ */ new Map(),
|
|
1081
1182
|
started: /* @__PURE__ */ new Map(),
|
|
1082
|
-
sessionStop: null
|
|
1183
|
+
sessionStop: null,
|
|
1184
|
+
launcherSeq: 0
|
|
1083
1185
|
};
|
|
1084
1186
|
return view(shared, canvasWindowId(options.windowId ?? CANVAS_MAIN_WINDOW));
|
|
1085
1187
|
}
|
|
@@ -1094,6 +1196,36 @@ function view(shared, windowId) {
|
|
|
1094
1196
|
for (const listener of [...shared.presentationListeners]) listener();
|
|
1095
1197
|
};
|
|
1096
1198
|
const shareFor = (orientation) => orientation === "vertical" ? rules.stackSplit : rules.sideSplit;
|
|
1199
|
+
const acceptable = (input) => {
|
|
1200
|
+
if (input.kind !== CANVAS_LAUNCHER_KIND && options.isKnownKind && !options.isKnownKind(input.kind)) {
|
|
1201
|
+
onError({ code: "unknown-kind", message: `No canvas kind "${input.kind}" is registered.`, detail: input });
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
if (input.data !== void 0) {
|
|
1205
|
+
const bad = findNonJson(input.data);
|
|
1206
|
+
if (bad) {
|
|
1207
|
+
onError({
|
|
1208
|
+
code: "non-json-data",
|
|
1209
|
+
message: `Canvas data for "${input.kind}" must be plain JSON; ${bad} is not.`,
|
|
1210
|
+
detail: { kind: input.kind, key: input.key }
|
|
1211
|
+
});
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return true;
|
|
1216
|
+
};
|
|
1217
|
+
const preferredTargetFor = (input) => {
|
|
1218
|
+
const preferred = options.preferredTarget?.(input.kind);
|
|
1219
|
+
if (!preferred || preferred === "focused") return "focused";
|
|
1220
|
+
const state = store.getState();
|
|
1221
|
+
if (canvasItemId(input.kind, input.key) in state.items) return "focused";
|
|
1222
|
+
const frame2 = canvasWindowFrame(state, input.windowId ?? windowId);
|
|
1223
|
+
if (!frame2?.isOpen) return "focused";
|
|
1224
|
+
const panes = listPaneIds(frame2.layout);
|
|
1225
|
+
const holding = panes.find((paneId) => state.panes[paneId]?.itemIds.some((id) => state.items[id]?.kind === input.kind));
|
|
1226
|
+
if (holding) return { paneId: holding };
|
|
1227
|
+
return state.panes[frame2.focusedPaneId]?.activeItemId ? preferred : "focused";
|
|
1228
|
+
};
|
|
1097
1229
|
const controller = {
|
|
1098
1230
|
store,
|
|
1099
1231
|
windowId,
|
|
@@ -1101,24 +1233,11 @@ function view(shared, windowId) {
|
|
|
1101
1233
|
forWindow: (id) => view(shared, canvasWindowId(id)),
|
|
1102
1234
|
getState: () => store.getState(),
|
|
1103
1235
|
open(input) {
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1106
|
-
return null;
|
|
1107
|
-
}
|
|
1108
|
-
if (input.data !== void 0) {
|
|
1109
|
-
const bad = findNonJson(input.data);
|
|
1110
|
-
if (bad) {
|
|
1111
|
-
onError({
|
|
1112
|
-
code: "non-json-data",
|
|
1113
|
-
message: `Canvas data for "${input.kind}" must be plain JSON; ${bad} is not.`,
|
|
1114
|
-
detail: { kind: input.kind, key: input.key }
|
|
1115
|
-
});
|
|
1116
|
-
return null;
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
const target = input.target ?? "focused";
|
|
1236
|
+
if (!acceptable(input)) return null;
|
|
1237
|
+
const target = input.target ?? preferredTargetFor(input);
|
|
1120
1238
|
const splitShare = target === "split-down" ? rules.stackSplit : target === "split-right" ? rules.sideSplit : void 0;
|
|
1121
|
-
const
|
|
1239
|
+
const withTarget = input.target === target ? input : { ...input, target };
|
|
1240
|
+
const resolved = withTarget.windowId || windowId === CANVAS_MAIN_WINDOW ? withTarget : { ...withTarget, windowId };
|
|
1122
1241
|
dispatch(canvasActions.open(resolved, splitShare === void 0 ? void 0 : { splitShare }));
|
|
1123
1242
|
return canvasItemId(input.kind, input.key);
|
|
1124
1243
|
},
|
|
@@ -1134,6 +1253,34 @@ function view(shared, windowId) {
|
|
|
1134
1253
|
dispatch(canvasActions.update(itemId, patch));
|
|
1135
1254
|
return true;
|
|
1136
1255
|
},
|
|
1256
|
+
navigate(itemId, to) {
|
|
1257
|
+
if (!store.getState().items[itemId] || !acceptable(to)) return null;
|
|
1258
|
+
dispatch(canvasActions.navigate(itemId, to));
|
|
1259
|
+
const shown = canvasItemId(to.kind, to.key);
|
|
1260
|
+
return shown in store.getState().items ? shown : null;
|
|
1261
|
+
},
|
|
1262
|
+
back(itemId) {
|
|
1263
|
+
const history = store.getState().items[itemId]?.history ?? [];
|
|
1264
|
+
const entry = history[history.length - 1];
|
|
1265
|
+
if (!entry) return null;
|
|
1266
|
+
dispatch(canvasActions.back(itemId));
|
|
1267
|
+
return canvasItemId(entry.kind, entry.key);
|
|
1268
|
+
},
|
|
1269
|
+
newTab(paneId) {
|
|
1270
|
+
const state = store.getState();
|
|
1271
|
+
const frame2 = canvasWindowFrame(state, windowId);
|
|
1272
|
+
const target = paneId && state.panes[paneId] ? paneId : frame2?.focusedPaneId ?? state.focusedPaneId;
|
|
1273
|
+
const existing = state.panes[target]?.itemIds.find((id) => state.items[id]?.kind === CANVAS_LAUNCHER_KIND);
|
|
1274
|
+
if (existing) {
|
|
1275
|
+
dispatch(canvasActions.activate(existing));
|
|
1276
|
+
dispatch(canvasActions.setOpen(true, canvasWindowOfItem(store.getState(), existing) ?? windowId));
|
|
1277
|
+
return existing;
|
|
1278
|
+
}
|
|
1279
|
+
shared.launcherSeq += 1;
|
|
1280
|
+
const key = `${Date.now().toString(36)}-${shared.launcherSeq}`;
|
|
1281
|
+
dispatch(canvasActions.open({ kind: CANVAS_LAUNCHER_KIND, key, target: { paneId: target }, ...windowId === CANVAS_MAIN_WINDOW ? {} : { windowId } }));
|
|
1282
|
+
return canvasItemId(CANVAS_LAUNCHER_KIND, key);
|
|
1283
|
+
},
|
|
1137
1284
|
rekey(itemId, key) {
|
|
1138
1285
|
const item = store.getState().items[itemId];
|
|
1139
1286
|
if (!item) return null;
|
|
@@ -1397,6 +1544,41 @@ function selectCanvasWindows(s) {
|
|
|
1397
1544
|
}
|
|
1398
1545
|
var selectCanvasExtraWindow = (s, windowId) => s.windows?.[windowId];
|
|
1399
1546
|
var selectCanvasWindowOfItem = canvasWindowOfItem;
|
|
1547
|
+
var NO_HISTORY = Object.freeze([]);
|
|
1548
|
+
function selectCanvasItemHistory(s, itemId) {
|
|
1549
|
+
return s.items[itemId]?.history ?? NO_HISTORY;
|
|
1550
|
+
}
|
|
1551
|
+
var selectCanvasCanGoBack = (s, itemId) => (s.items[itemId]?.history?.length ?? 0) > 0;
|
|
1552
|
+
|
|
1553
|
+
// src/core/presentation.ts
|
|
1554
|
+
var CANVAS_NARROW_WIDTH = 480;
|
|
1555
|
+
var SQUARE_BAND = 1.2;
|
|
1556
|
+
function canvasOrientation(width, height) {
|
|
1557
|
+
if (!(width > 0) || !(height > 0)) return "square";
|
|
1558
|
+
const ratio = width / height;
|
|
1559
|
+
if (ratio >= SQUARE_BAND) return "landscape";
|
|
1560
|
+
if (ratio <= 1 / SQUARE_BAND) return "portrait";
|
|
1561
|
+
return "square";
|
|
1562
|
+
}
|
|
1563
|
+
function canvasPresentation(input) {
|
|
1564
|
+
const width = Math.max(0, Math.round(input.width));
|
|
1565
|
+
const height = Math.max(0, Math.round(input.height));
|
|
1566
|
+
return {
|
|
1567
|
+
host: "canvas",
|
|
1568
|
+
width,
|
|
1569
|
+
height,
|
|
1570
|
+
orientation: canvasOrientation(width, height),
|
|
1571
|
+
isNarrow: width > 0 && width < CANVAS_NARROW_WIDTH,
|
|
1572
|
+
isFullscreen: input.isFullscreen,
|
|
1573
|
+
paneCount: Math.max(1, input.paneCount)
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
function preferredFlowDirection(presentation) {
|
|
1577
|
+
if (!presentation) return "horizontal";
|
|
1578
|
+
if (presentation.orientation === "portrait") return "vertical";
|
|
1579
|
+
if (presentation.orientation === "landscape") return "horizontal";
|
|
1580
|
+
return presentation.isNarrow ? "vertical" : "horizontal";
|
|
1581
|
+
}
|
|
1400
1582
|
|
|
1401
1583
|
// src/core/toggleKind.ts
|
|
1402
1584
|
var CANVAS_DEFAULT_KEY = "default";
|