@ai-matrx/canvas 0.1.1 → 0.2.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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/core/types.ts
2
+ var CANVAS_MAIN_WINDOW = "main";
2
3
  var CANVAS_MIN_WIDTH = 360;
3
4
  var CANVAS_DEFAULT_WIDTH = 640;
4
5
  var CANVAS_MIN_SPLIT_FRACTION = 0.12;
@@ -50,12 +51,19 @@ function findNonJson(value, path = "data") {
50
51
  function isCanvasJson(value) {
51
52
  return findNonJson(value) === null;
52
53
  }
54
+ function canvasWindowId(id) {
55
+ return id;
56
+ }
53
57
 
54
58
  // src/core/layout.ts
55
59
  function listPaneIds(node) {
56
60
  if (node.type === "pane") return [node.paneId];
57
61
  return node.children.flatMap(listPaneIds);
58
62
  }
63
+ function containsPane(node, paneId) {
64
+ if (node.type === "pane") return node.paneId === paneId;
65
+ return node.children.some((child) => containsPane(child, paneId));
66
+ }
59
67
  function evenSizes(count) {
60
68
  return Array.from({ length: count }, () => 1 / count);
61
69
  }
@@ -66,7 +74,8 @@ function normalizeSizes(sizes, count) {
66
74
  const total = sizes.reduce((sum, s) => sum + s, 0);
67
75
  return sizes.map((s) => s / total);
68
76
  }
69
- function splitPane(node, paneId, newPaneId, orientation, newSplitId) {
77
+ function splitPane(node, paneId, newPaneId, orientation, newSplitId, firstShare = 0.5) {
78
+ const first = Number.isFinite(firstShare) ? Math.min(1 - CANVAS_MIN_SPLIT_FRACTION, Math.max(CANVAS_MIN_SPLIT_FRACTION, firstShare)) : 0.5;
70
79
  if (node.type === "pane") {
71
80
  if (node.paneId !== paneId) return node;
72
81
  return {
@@ -74,7 +83,7 @@ function splitPane(node, paneId, newPaneId, orientation, newSplitId) {
74
83
  id: newSplitId,
75
84
  orientation,
76
85
  children: [node, { type: "pane", paneId: newPaneId }],
77
- sizes: [0.5, 0.5]
86
+ sizes: [first, 1 - first]
78
87
  };
79
88
  }
80
89
  const index = node.children.findIndex(
@@ -85,13 +94,13 @@ function splitPane(node, paneId, newPaneId, orientation, newSplitId) {
85
94
  const children = [...node.children];
86
95
  children.splice(index + 1, 0, { type: "pane", paneId: newPaneId });
87
96
  const sizes = [...node.sizes];
88
- sizes.splice(index, 1, share / 2, share / 2);
97
+ sizes.splice(index, 1, share * first, share * (1 - first));
89
98
  return { ...node, children, sizes: normalizeSizes(sizes, children.length) };
90
99
  }
91
100
  return {
92
101
  ...node,
93
102
  children: node.children.map(
94
- (child) => splitPane(child, paneId, newPaneId, orientation, newSplitId)
103
+ (child) => splitPane(child, paneId, newPaneId, orientation, newSplitId, first)
95
104
  )
96
105
  };
97
106
  }
@@ -110,6 +119,10 @@ function removePane(node, paneId) {
110
119
  if (children.length === 1) return children[0] ?? null;
111
120
  return { ...node, children, sizes: normalizeSizes(sizes, children.length) };
112
121
  }
122
+ function containsSplit(node, splitId) {
123
+ if (node.type === "pane") return false;
124
+ return node.id === splitId || node.children.some((child) => containsSplit(child, splitId));
125
+ }
113
126
  function resizeSplit(node, splitId, sizes) {
114
127
  if (node.type === "pane") return node;
115
128
  if (node.id === splitId) {
@@ -140,8 +153,12 @@ function isValidLayout(node, knownPanes, seen = /* @__PURE__ */ new Set()) {
140
153
 
141
154
  // src/core/reducer.ts
142
155
  var P = "matrxCanvas/";
156
+ var win = (windowId) => windowId && windowId !== CANVAS_MAIN_WINDOW ? { windowId } : {};
143
157
  var canvasActions = {
144
- open: (input) => ({ type: `${P}open`, payload: { ...input, now: Date.now() } }),
158
+ open: (input, options) => ({
159
+ type: `${P}open`,
160
+ payload: { ...input, now: Date.now(), ...options?.splitShare !== void 0 ? { splitShare: options.splitShare } : {} }
161
+ }),
145
162
  update: (itemId, patch) => ({
146
163
  type: `${P}update`,
147
164
  payload: { itemId, ...patch, now: Date.now() }
@@ -156,19 +173,32 @@ var canvasActions = {
156
173
  type: `${P}moveItem`,
157
174
  payload: { itemId, toPaneId, index }
158
175
  }),
159
- splitPane: (paneId, orientation, moveItemId) => ({
176
+ /** `share` = the existing pane's fraction of the space (layout rules' stackSplit / sideSplit). */
177
+ splitPane: (paneId, orientation, moveItemId, share) => ({
160
178
  type: `${P}splitPane`,
161
- payload: { paneId, orientation, moveItemId }
179
+ payload: { paneId, orientation, moveItemId, ...share !== void 0 ? { share } : {} }
162
180
  }),
163
181
  closePane: (paneId) => ({ type: `${P}closePane`, payload: { paneId } }),
164
182
  resizeSplit: (splitId, sizes) => ({
165
183
  type: `${P}resizeSplit`,
166
184
  payload: { splitId, sizes }
167
185
  }),
168
- setOpen: (open) => ({ type: `${P}setOpen`, payload: { open } }),
169
- toggle: () => ({ type: `${P}toggle` }),
170
- setFullscreen: (fullscreen) => ({ type: `${P}setFullscreen`, payload: { fullscreen } }),
171
- setWidth: (width) => ({ type: `${P}setWidth`, payload: { width } }),
186
+ setOpen: (open, windowId) => ({ type: `${P}setOpen`, payload: { open, ...win(windowId) } }),
187
+ toggle: (windowId) => windowId && windowId !== CANVAS_MAIN_WINDOW ? { type: `${P}toggle`, payload: { windowId } } : { type: `${P}toggle` },
188
+ setFullscreen: (fullscreen, windowId) => ({
189
+ type: `${P}setFullscreen`,
190
+ payload: { fullscreen, ...win(windowId) }
191
+ }),
192
+ setWidth: (width, windowId) => ({ type: `${P}setWidth`, payload: { width, ...win(windowId) } }),
193
+ /** Moves a tab into its own pop-out window `windowId` (made by the host's popOut port). */
194
+ popOut: (itemId, windowId) => ({ type: `${P}popOut`, payload: { itemId, windowId } }),
195
+ /** Returns a popped-out tab to the window it came from. */
196
+ dockBack: (itemId) => ({ type: `${P}dockBack`, payload: { itemId } }),
197
+ /** A host window closed: its tabs dock home (default) or close with it. The main window never closes. */
198
+ closeWindow: (windowId, mode = "dock") => ({
199
+ type: `${P}closeWindow`,
200
+ payload: { windowId, mode }
201
+ }),
172
202
  hydrate: (snapshot) => ({ type: `${P}hydrate`, payload: { snapshot } }),
173
203
  reset: () => ({ type: `${P}reset` })
174
204
  };
@@ -181,15 +211,80 @@ function createInitialCanvasState() {
181
211
  version: 1,
182
212
  isOpen: false,
183
213
  isFullscreen: false,
184
- width: CANVAS_DEFAULT_WIDTH,
214
+ width: null,
185
215
  layout: { type: "pane", paneId },
216
+ focusedPaneId: paneId,
217
+ windows: {},
186
218
  panes: { [paneId]: { id: paneId, itemIds: [], activeItemId: null } },
187
219
  items: {},
188
- focusedPaneId: paneId,
189
220
  seq: 1,
190
221
  hydrated: false
191
222
  };
192
223
  }
224
+ function listCanvasWindowIds(state) {
225
+ return [CANVAS_MAIN_WINDOW, ...Object.keys(state.windows ?? {})];
226
+ }
227
+ function canvasWindowFrame(state, windowId = CANVAS_MAIN_WINDOW) {
228
+ return windowId === CANVAS_MAIN_WINDOW ? state : state.windows?.[windowId] ?? null;
229
+ }
230
+ function canvasWindowOfPane(state, paneId) {
231
+ for (const id of listCanvasWindowIds(state)) {
232
+ const frame2 = canvasWindowFrame(state, id);
233
+ if (frame2 && containsPane(frame2.layout, paneId)) return id;
234
+ }
235
+ return null;
236
+ }
237
+ function canvasWindowOfItem(state, itemId) {
238
+ const pane = paneOf(state, itemId);
239
+ return pane ? canvasWindowOfPane(state, pane.id) : null;
240
+ }
241
+ function withFrame(state, windowId, patch) {
242
+ if (windowId === CANVAS_MAIN_WINDOW) return { ...state, ...patch };
243
+ const current = state.windows[windowId];
244
+ if (!current) return state;
245
+ return { ...state, windows: { ...state.windows, [windowId]: { ...current, ...patch } } };
246
+ }
247
+ function ensureWindow(state, windowId, role = "window", home = CANVAS_MAIN_WINDOW) {
248
+ if (windowId === CANVAS_MAIN_WINDOW || state.windows[windowId]) return state;
249
+ const seq = state.seq + 1;
250
+ const paneId = paneIdFromSeq(seq);
251
+ const frame2 = {
252
+ id: windowId,
253
+ role,
254
+ home,
255
+ isOpen: false,
256
+ isFullscreen: false,
257
+ width: null,
258
+ layout: { type: "pane", paneId },
259
+ focusedPaneId: paneId
260
+ };
261
+ return {
262
+ ...state,
263
+ seq,
264
+ panes: { ...state.panes, [paneId]: { id: paneId, itemIds: [], activeItemId: null } },
265
+ windows: { ...state.windows, [windowId]: frame2 }
266
+ };
267
+ }
268
+ function removeWindow(state, windowId) {
269
+ const frame2 = state.windows[windowId];
270
+ if (!frame2) return state;
271
+ const panes = { ...state.panes };
272
+ for (const paneId of listPaneIds(frame2.layout)) delete panes[paneId];
273
+ const windows = { ...state.windows };
274
+ delete windows[windowId];
275
+ return { ...state, panes, windows };
276
+ }
277
+ function windowItemIds(state, windowId) {
278
+ const frame2 = canvasWindowFrame(state, windowId);
279
+ if (!frame2) return [];
280
+ return listPaneIds(frame2.layout).flatMap((paneId) => state.panes[paneId]?.itemIds ?? []);
281
+ }
282
+ function dropEmptyPopOut(state, windowId) {
283
+ if (!windowId || windowId === CANVAS_MAIN_WINDOW) return state;
284
+ const frame2 = state.windows[windowId];
285
+ if (!frame2 || frame2.role !== "pop-out" || windowItemIds(state, windowId).length > 0) return state;
286
+ return removeWindow(state, windowId);
287
+ }
193
288
  function paneOf(state, itemId) {
194
289
  for (const pane of Object.values(state.panes)) {
195
290
  if (pane.itemIds.includes(itemId)) return pane;
@@ -199,6 +294,10 @@ function paneOf(state, itemId) {
199
294
  function withPane(state, pane) {
200
295
  return { ...state, panes: { ...state.panes, [pane.id]: pane } };
201
296
  }
297
+ function focus(state, paneId) {
298
+ const windowId = canvasWindowOfPane(state, paneId);
299
+ return windowId ? withFrame(state, windowId, { focusedPaneId: paneId }) : state;
300
+ }
202
301
  function nextActive(itemIds, removed, current) {
203
302
  if (current !== removed) return current;
204
303
  const index = itemIds.indexOf(removed);
@@ -215,27 +314,29 @@ function detachItem(state, itemId) {
215
314
  });
216
315
  }
217
316
  function dropPane(state, paneId) {
218
- const layout = removePane(state.layout, paneId);
317
+ const windowId = canvasWindowOfPane(state, paneId);
318
+ const frame2 = windowId ? canvasWindowFrame(state, windowId) : null;
319
+ if (!windowId || !frame2) return state;
320
+ const layout = removePane(frame2.layout, paneId);
219
321
  if (!layout) return state;
220
322
  const panes = { ...state.panes };
221
323
  delete panes[paneId];
222
- const focusedPaneId = state.focusedPaneId === paneId ? listPaneIds(layout)[0] ?? state.focusedPaneId : state.focusedPaneId;
223
- return { ...state, layout, panes, focusedPaneId };
324
+ const focusedPaneId = frame2.focusedPaneId === paneId ? listPaneIds(layout)[0] ?? frame2.focusedPaneId : frame2.focusedPaneId;
325
+ return withFrame({ ...state, panes }, windowId, { layout, focusedPaneId });
224
326
  }
225
- function addSplit(state, paneId, orientation) {
327
+ function addSplit(state, paneId, orientation, share) {
328
+ const windowId = canvasWindowOfPane(state, paneId);
329
+ const frame2 = windowId ? canvasWindowFrame(state, windowId) : null;
330
+ if (!windowId || !frame2) return null;
226
331
  const seq = state.seq + 1;
227
332
  const newPaneId = paneIdFromSeq(seq);
228
- const layout = splitPane(state.layout, paneId, newPaneId, orientation, splitIdFromSeq(seq));
229
- return {
230
- newPaneId,
231
- state: {
232
- ...state,
233
- seq,
234
- layout,
235
- panes: { ...state.panes, [newPaneId]: { id: newPaneId, itemIds: [], activeItemId: null } },
236
- focusedPaneId: newPaneId
237
- }
238
- };
333
+ const layout = splitPane(frame2.layout, paneId, newPaneId, orientation, splitIdFromSeq(seq), share ?? 0.5);
334
+ const next = withFrame(
335
+ { ...state, seq, panes: { ...state.panes, [newPaneId]: { id: newPaneId, itemIds: [], activeItemId: null } } },
336
+ windowId,
337
+ { layout, focusedPaneId: newPaneId }
338
+ );
339
+ return { newPaneId, state: next };
239
340
  }
240
341
  function insertInto(state, paneId, itemId, activate, index) {
241
342
  const pane = state.panes[paneId];
@@ -253,46 +354,96 @@ function afterActive(itemIds, active) {
253
354
  const index = active ? itemIds.indexOf(active) : -1;
254
355
  return index < 0 ? itemIds.length : index + 1;
255
356
  }
357
+ function sendHome(state, itemId, home, activate) {
358
+ const homeFrame = canvasWindowFrame(state, home) ?? state;
359
+ const target = canvasWindowFrame(state, home) ? home : CANVAS_MAIN_WINDOW;
360
+ let next = detachItem(state, itemId);
361
+ next = insertInto(next, homeFrame.focusedPaneId, itemId, activate);
362
+ return activate ? withFrame(next, target, { focusedPaneId: homeFrame.focusedPaneId }) : next;
363
+ }
256
364
  function clampWidth(width) {
257
- return Number.isFinite(width) ? Math.max(CANVAS_MIN_WIDTH, Math.round(width)) : CANVAS_DEFAULT_WIDTH;
365
+ return Number.isFinite(width) ? Math.max(CANVAS_MIN_WIDTH, Math.round(width)) : null;
366
+ }
367
+ function foldCanvasWindows(state) {
368
+ const extra = Object.keys(state.windows ?? {});
369
+ if (extra.length === 0) return state.windows ? state : { ...state, windows: {} };
370
+ let next = state;
371
+ for (const windowId of extra) {
372
+ for (const itemId of windowItemIds(next, windowId)) next = sendHome(next, itemId, CANVAS_MAIN_WINDOW, false);
373
+ next = removeWindow(next, windowId);
374
+ }
375
+ return next;
258
376
  }
259
377
  function sanitizeCanvasSnapshot(raw) {
260
378
  if (!raw || typeof raw !== "object") return null;
261
379
  const s = raw;
262
- if (s.version !== 1 || !s.panes || !s.items || !s.layout) return null;
380
+ if (s.version !== 1 || !s.panes || typeof s.panes !== "object" || !s.items || typeof s.items !== "object" || !s.layout) return null;
263
381
  const paneIds = new Set(Object.keys(s.panes));
264
- if (!isValidLayout(s.layout, paneIds)) return null;
265
- const layoutPanes = new Set(listPaneIds(s.layout));
266
- if (layoutPanes.size !== paneIds.size) return null;
382
+ const seen = /* @__PURE__ */ new Set();
383
+ if (!isValidLayout(s.layout, paneIds, seen)) return null;
384
+ const windows = {};
385
+ if (s.windows && typeof s.windows === "object") {
386
+ for (const [id, value] of Object.entries(s.windows)) {
387
+ if (!value || typeof value !== "object" || id === CANVAS_MAIN_WINDOW) continue;
388
+ const w = value;
389
+ const claimed = new Set(seen);
390
+ if (!isValidLayout(w.layout, paneIds, claimed)) continue;
391
+ for (const pane of claimed) seen.add(pane);
392
+ const layoutPanes = listPaneIds(w.layout);
393
+ const focused2 = w.focusedPaneId && layoutPanes.includes(w.focusedPaneId) ? w.focusedPaneId : layoutPanes[0];
394
+ if (!focused2) continue;
395
+ windows[id] = {
396
+ id,
397
+ role: w.role === "pop-out" ? "pop-out" : "window",
398
+ home: typeof w.home === "string" ? w.home : CANVAS_MAIN_WINDOW,
399
+ isOpen: w.isOpen === true,
400
+ isFullscreen: false,
401
+ width: typeof w.width === "number" ? clampWidth(w.width) : null,
402
+ layout: normalizeLayout(w.layout),
403
+ focusedPaneId: focused2
404
+ };
405
+ }
406
+ }
407
+ if (seen.size !== paneIds.size) return null;
267
408
  const items = {};
268
409
  for (const [id, item] of Object.entries(s.items)) {
269
410
  if (item && typeof item.kind === "string" && typeof item.key === "string" && canvasItemId(item.kind, item.key) === id) {
270
411
  items[id] = item;
271
412
  }
272
413
  }
414
+ const placed = /* @__PURE__ */ new Set();
273
415
  const panes = {};
274
416
  for (const [id, pane] of Object.entries(s.panes)) {
275
- const itemIds = (Array.isArray(pane.itemIds) ? pane.itemIds : []).filter((itemId) => itemId in items);
417
+ const itemIds = (Array.isArray(pane.itemIds) ? pane.itemIds : []).filter((itemId) => itemId in items && !placed.has(itemId));
418
+ for (const itemId of itemIds) placed.add(itemId);
276
419
  const activeItemId = pane.activeItemId && itemIds.includes(pane.activeItemId) ? pane.activeItemId : itemIds[0] ?? null;
277
420
  panes[id] = { id, itemIds, activeItemId };
278
421
  }
279
- const focused = s.focusedPaneId && paneIds.has(s.focusedPaneId) ? s.focusedPaneId : listPaneIds(s.layout)[0];
422
+ const mainPanes = listPaneIds(s.layout);
423
+ const focused = s.focusedPaneId && mainPanes.includes(s.focusedPaneId) ? s.focusedPaneId : mainPanes[0];
280
424
  if (!focused) return null;
281
425
  const layout = normalizeLayout(s.layout);
282
- const seq = Math.max(typeof s.seq === "number" && Number.isFinite(s.seq) ? s.seq : 0, highestIdNumber(layout));
283
- return {
426
+ const highest = Math.max(highestIdNumber(layout), ...Object.values(windows).map((w) => highestIdNumber(w.layout)));
427
+ const seq = Math.max(typeof s.seq === "number" && Number.isFinite(s.seq) ? s.seq : 0, highest);
428
+ let state = {
284
429
  version: 1,
285
430
  isOpen: s.isOpen === true,
286
431
  // Full screen is a moment, not a preference: a reload never starts there.
287
432
  isFullscreen: false,
288
- width: clampWidth(typeof s.width === "number" ? s.width : CANVAS_DEFAULT_WIDTH),
433
+ width: typeof s.width === "number" ? clampWidth(s.width) : null,
289
434
  layout,
435
+ focusedPaneId: focused,
436
+ windows,
290
437
  panes,
291
438
  items,
292
- focusedPaneId: focused,
293
439
  seq,
294
440
  hydrated: true
295
441
  };
442
+ state = foldCanvasWindows(state);
443
+ for (const id of Object.keys(items)) {
444
+ if (!placed.has(id)) state = insertInto(state, state.focusedPaneId, id, false);
445
+ }
446
+ return state;
296
447
  }
297
448
  function normalizeLayout(node) {
298
449
  if (node.type === "pane") return node;
@@ -305,12 +456,14 @@ function highestIdNumber(node) {
305
456
  }
306
457
  function canvasReducer(state = createInitialCanvasState(), action) {
307
458
  if (!isCanvasAction(action)) return state;
459
+ if (!state.windows) state = { ...state, windows: {} };
308
460
  switch (action.type) {
309
461
  case `${P}open`: {
310
- const { kind, key, title, data, target = "focused", reveal = true, activate = true, now } = action.payload;
462
+ const { kind, key, title, data, target = "focused", reveal = true, activate = true, now, splitShare } = action.payload;
311
463
  const itemId = canvasItemId(kind, key);
312
464
  const existing = state.items[itemId];
313
465
  let next = state;
466
+ let revealIn = action.payload.windowId ?? CANVAS_MAIN_WINDOW;
314
467
  if (existing) {
315
468
  const item = {
316
469
  ...existing,
@@ -320,23 +473,33 @@ function canvasReducer(state = createInitialCanvasState(), action) {
320
473
  };
321
474
  next = { ...next, items: { ...next.items, [itemId]: item } };
322
475
  const pane = paneOf(next, itemId);
323
- if (pane && activate) next = { ...withPane(next, { ...pane, activeItemId: itemId }), focusedPaneId: pane.id };
324
- if (!pane) next = insertInto(next, next.focusedPaneId, itemId, true);
476
+ if (pane) {
477
+ revealIn = canvasWindowOfPane(next, pane.id) ?? CANVAS_MAIN_WINDOW;
478
+ if (activate) next = focus(withPane(next, { ...pane, activeItemId: itemId }), pane.id);
479
+ } else {
480
+ next = ensureWindow(next, revealIn);
481
+ const frame2 = canvasWindowFrame(next, revealIn) ?? next;
482
+ next = insertInto(next, frame2.focusedPaneId, itemId, true);
483
+ }
325
484
  } else {
326
485
  const item = { id: itemId, kind, key, title: title ?? null, data: data ?? null, openedAt: now, updatedAt: now };
327
- next = { ...next, items: { ...next.items, [itemId]: item } };
328
- let paneId = next.focusedPaneId;
486
+ next = ensureWindow({ ...next, items: { ...next.items, [itemId]: item } }, revealIn);
487
+ const frame2 = canvasWindowFrame(next, revealIn) ?? next;
488
+ let paneId = frame2.focusedPaneId;
329
489
  if (typeof target === "object" && next.panes[target.paneId]) {
330
490
  paneId = target.paneId;
491
+ revealIn = canvasWindowOfPane(next, paneId) ?? revealIn;
331
492
  } else if (target === "split-right" || target === "split-down") {
332
- const split = addSplit(next, next.focusedPaneId, target === "split-right" ? "horizontal" : "vertical");
333
- next = split.state;
334
- paneId = split.newPaneId;
493
+ const split = addSplit(next, frame2.focusedPaneId, target === "split-right" ? "horizontal" : "vertical", splitShare);
494
+ if (split) {
495
+ next = split.state;
496
+ paneId = split.newPaneId;
497
+ }
335
498
  }
336
499
  next = insertInto(next, paneId, itemId, activate);
337
- if (activate) next = { ...next, focusedPaneId: paneId };
500
+ if (activate) next = focus(next, paneId);
338
501
  }
339
- return reveal ? { ...next, isOpen: true } : next;
502
+ return reveal ? withFrame(next, revealIn, { isOpen: true }) : next;
340
503
  }
341
504
  case `${P}update`: {
342
505
  const { itemId, data, title, now } = action.payload;
@@ -372,12 +535,13 @@ function canvasReducer(state = createInitialCanvasState(), action) {
372
535
  const { itemId } = action.payload;
373
536
  if (!state.items[itemId]) return state;
374
537
  const pane = paneOf(state, itemId);
538
+ const windowId = pane ? canvasWindowOfPane(state, pane.id) : null;
375
539
  let next = detachItem(state, itemId);
376
540
  const items = { ...next.items };
377
541
  delete items[itemId];
378
542
  next = { ...next, items };
379
543
  if (pane && next.panes[pane.id]?.itemIds.length === 0) next = dropPane(next, pane.id);
380
- return next;
544
+ return dropEmptyPopOut(next, windowId);
381
545
  }
382
546
  case `${P}closeOthers`: {
383
547
  const { itemId } = action.payload;
@@ -390,26 +554,31 @@ function canvasReducer(state = createInitialCanvasState(), action) {
390
554
  case `${P}activate`: {
391
555
  const pane = paneOf(state, action.payload.itemId);
392
556
  if (!pane) return state;
393
- return { ...withPane(state, { ...pane, activeItemId: action.payload.itemId }), focusedPaneId: pane.id };
557
+ return focus(withPane(state, { ...pane, activeItemId: action.payload.itemId }), pane.id);
558
+ }
559
+ case `${P}focusPane`: {
560
+ const windowId = canvasWindowOfPane(state, action.payload.paneId);
561
+ const frame2 = windowId ? canvasWindowFrame(state, windowId) : null;
562
+ if (!windowId || !frame2 || frame2.focusedPaneId === action.payload.paneId) return state;
563
+ return withFrame(state, windowId, { focusedPaneId: action.payload.paneId });
394
564
  }
395
- case `${P}focusPane`:
396
- return state.panes[action.payload.paneId] ? { ...state, focusedPaneId: action.payload.paneId } : state;
397
565
  case `${P}moveItem`: {
398
566
  const { itemId, toPaneId, index } = action.payload;
399
567
  const from = paneOf(state, itemId);
400
568
  if (!from || !state.panes[toPaneId]) return state;
401
- if (from.id === toPaneId && index === void 0) return state.focusedPaneId === toPaneId ? state : { ...state, focusedPaneId: toPaneId };
569
+ if (from.id === toPaneId && index === void 0) return canvasReducer(state, canvasActions.focusPane(toPaneId));
570
+ const fromWindow = canvasWindowOfPane(state, from.id);
402
571
  let next = from.id === toPaneId ? state : detachItem(state, itemId);
403
- next = insertInto(next, toPaneId, itemId, true, index);
404
- next = { ...next, focusedPaneId: toPaneId };
572
+ next = focus(insertInto(next, toPaneId, itemId, true, index), toPaneId);
405
573
  if (from.id !== toPaneId && next.panes[from.id]?.itemIds.length === 0) next = dropPane(next, from.id);
406
- return next;
574
+ return dropEmptyPopOut(next, fromWindow);
407
575
  }
408
576
  case `${P}splitPane`: {
409
- const { paneId, orientation, moveItemId } = action.payload;
577
+ const { paneId, orientation, moveItemId, share } = action.payload;
410
578
  const pane = state.panes[paneId];
411
579
  if (!pane) return state;
412
- const split = addSplit(state, paneId, orientation);
580
+ const split = addSplit(state, paneId, orientation, share);
581
+ if (!split) return state;
413
582
  let next = split.state;
414
583
  if (moveItemId && pane.itemIds.includes(moveItemId) && pane.itemIds.length > 1) {
415
584
  next = detachItem(next, moveItemId);
@@ -419,23 +588,106 @@ function canvasReducer(state = createInitialCanvasState(), action) {
419
588
  }
420
589
  case `${P}closePane`: {
421
590
  const pane = state.panes[action.payload.paneId];
422
- if (!pane) return state;
423
- const onlyPane = listPaneIds(state.layout).length === 1;
424
- if (onlyPane) return { ...state, isOpen: false, isFullscreen: false };
591
+ const windowId = pane ? canvasWindowOfPane(state, pane.id) : null;
592
+ const frame2 = windowId ? canvasWindowFrame(state, windowId) : null;
593
+ if (!pane || !windowId || !frame2) return state;
594
+ const onlyPane = listPaneIds(frame2.layout).length === 1;
595
+ if (onlyPane) {
596
+ if (windowId !== CANVAS_MAIN_WINDOW && state.windows[windowId]?.role === "pop-out") {
597
+ return canvasReducer(state, canvasActions.closeWindow(windowId, "dock"));
598
+ }
599
+ return withFrame(state, windowId, { isOpen: false, isFullscreen: false });
600
+ }
425
601
  const items = { ...state.items };
426
602
  for (const id of pane.itemIds) delete items[id];
427
603
  return dropPane({ ...state, items }, pane.id);
428
604
  }
429
- case `${P}resizeSplit`:
430
- return { ...state, layout: resizeSplit(state.layout, action.payload.splitId, action.payload.sizes) };
431
- case `${P}setOpen`:
432
- return action.payload.open === state.isOpen ? state : { ...state, isOpen: action.payload.open, isFullscreen: action.payload.open ? state.isFullscreen : false };
433
- case `${P}toggle`:
434
- return { ...state, isOpen: !state.isOpen, isFullscreen: state.isOpen ? false : state.isFullscreen };
435
- case `${P}setFullscreen`:
436
- return { ...state, isFullscreen: action.payload.fullscreen, isOpen: action.payload.fullscreen ? true : state.isOpen };
437
- case `${P}setWidth`:
438
- return { ...state, width: clampWidth(action.payload.width) };
605
+ case `${P}resizeSplit`: {
606
+ const { splitId, sizes } = action.payload;
607
+ for (const windowId of listCanvasWindowIds(state)) {
608
+ const frame2 = canvasWindowFrame(state, windowId);
609
+ if (frame2 && containsSplit(frame2.layout, splitId)) {
610
+ return withFrame(state, windowId, { layout: resizeSplit(frame2.layout, splitId, sizes) });
611
+ }
612
+ }
613
+ return state;
614
+ }
615
+ case `${P}setOpen`: {
616
+ const { open, windowId = CANVAS_MAIN_WINDOW } = action.payload;
617
+ const next = open ? ensureWindow(state, windowId) : state;
618
+ const frame2 = canvasWindowFrame(next, windowId);
619
+ if (!frame2 || frame2.isOpen === open) return next;
620
+ return withFrame(next, windowId, { isOpen: open, isFullscreen: open ? frame2.isFullscreen : false });
621
+ }
622
+ case `${P}toggle`: {
623
+ const windowId = action.payload?.windowId ?? CANVAS_MAIN_WINDOW;
624
+ const next = ensureWindow(state, windowId);
625
+ const frame2 = canvasWindowFrame(next, windowId);
626
+ if (!frame2) return state;
627
+ return withFrame(next, windowId, { isOpen: !frame2.isOpen, isFullscreen: frame2.isOpen ? false : frame2.isFullscreen });
628
+ }
629
+ case `${P}setFullscreen`: {
630
+ const { fullscreen, windowId = CANVAS_MAIN_WINDOW } = action.payload;
631
+ const next = fullscreen ? ensureWindow(state, windowId) : state;
632
+ const frame2 = canvasWindowFrame(next, windowId);
633
+ if (!frame2) return state;
634
+ return withFrame(next, windowId, { isFullscreen: fullscreen, isOpen: fullscreen ? true : frame2.isOpen });
635
+ }
636
+ case `${P}setWidth`: {
637
+ const { width, windowId = CANVAS_MAIN_WINDOW } = action.payload;
638
+ const next = ensureWindow(state, windowId);
639
+ return withFrame(next, windowId, { width: clampWidth(width) });
640
+ }
641
+ case `${P}popOut`: {
642
+ const { itemId, windowId } = action.payload;
643
+ const from = paneOf(state, itemId);
644
+ if (!from || windowId === CANVAS_MAIN_WINDOW) return state;
645
+ const fromWindow = canvasWindowOfPane(state, from.id) ?? CANVAS_MAIN_WINDOW;
646
+ if (fromWindow === windowId) return state;
647
+ let next = ensureWindow(state, windowId, "pop-out", fromWindow);
648
+ const target = canvasWindowFrame(next, windowId);
649
+ if (!target) return state;
650
+ next = detachItem(next, itemId);
651
+ next = insertInto(next, target.focusedPaneId, itemId, true);
652
+ next = withFrame(next, windowId, { isOpen: true, focusedPaneId: target.focusedPaneId });
653
+ if (next.panes[from.id]?.itemIds.length === 0) next = dropPane(next, from.id);
654
+ next = dropEmptyPopOut(next, fromWindow);
655
+ if (canvasWindowFrame(next, fromWindow) && windowItemIds(next, fromWindow).length === 0) {
656
+ next = withFrame(next, fromWindow, { isOpen: false, isFullscreen: false });
657
+ }
658
+ return next;
659
+ }
660
+ case `${P}dockBack`: {
661
+ const { itemId } = action.payload;
662
+ const windowId = canvasWindowOfItem(state, itemId);
663
+ if (!windowId || windowId === CANVAS_MAIN_WINDOW) return state;
664
+ const frame2 = state.windows[windowId];
665
+ if (!frame2) return state;
666
+ const home = canvasWindowFrame(state, frame2.home) ? frame2.home : CANVAS_MAIN_WINDOW;
667
+ let next = sendHome(state, itemId, home, true);
668
+ next = withFrame(next, home, { isOpen: true });
669
+ const left = paneOf(state, itemId);
670
+ if (left && next.panes[left.id]?.itemIds.length === 0) next = dropPane(next, left.id);
671
+ return dropEmptyPopOut(next, windowId);
672
+ }
673
+ case `${P}closeWindow`: {
674
+ const { windowId, mode } = action.payload;
675
+ const frame2 = state.windows[windowId];
676
+ if (!frame2) return state;
677
+ const itemIds = windowItemIds(state, windowId);
678
+ let next = state;
679
+ if (mode === "close") {
680
+ const items = { ...next.items };
681
+ for (const id of itemIds) delete items[id];
682
+ next = { ...next, items };
683
+ } else if (itemIds.length > 0) {
684
+ const home = canvasWindowFrame(state, frame2.home) ? frame2.home : CANVAS_MAIN_WINDOW;
685
+ const active = state.panes[frame2.focusedPaneId]?.activeItemId ?? null;
686
+ for (const id of itemIds) next = sendHome(next, id, home, id === active);
687
+ next = withFrame(next, home, { isOpen: true });
688
+ }
689
+ return removeWindow(next, windowId);
690
+ }
439
691
  case `${P}hydrate`: {
440
692
  const snapshot = action.payload.snapshot ? sanitizeCanvasSnapshot(action.payload.snapshot) : null;
441
693
  if (!snapshot) return { ...state, hydrated: true };
@@ -482,6 +734,64 @@ function bindCanvasToReduxStore(store, select) {
482
734
  };
483
735
  }
484
736
 
737
+ // src/core/remote.ts
738
+ function createRemoteCanvasStore(options) {
739
+ let state = options.initialSnapshot;
740
+ const listeners = /* @__PURE__ */ new Set();
741
+ const report = options.onError ?? ((error, action) => console.error(`[@ai-matrx/canvas] remote dispatch of ${action.type} failed`, error));
742
+ const stop = options.subscribe((snapshot) => {
743
+ const next = shareStructure(state, snapshot);
744
+ if (next === state) return;
745
+ state = next;
746
+ for (const listener of [...listeners]) listener();
747
+ });
748
+ return {
749
+ authority: "remote",
750
+ getState: () => state,
751
+ dispatch(action) {
752
+ if (!isCanvasAction(action)) return;
753
+ try {
754
+ void Promise.resolve(options.dispatch(action)).catch((error) => report(error, action));
755
+ } catch (error) {
756
+ report(error, action);
757
+ }
758
+ },
759
+ subscribe(listener) {
760
+ listeners.add(listener);
761
+ return () => listeners.delete(listener);
762
+ },
763
+ dispose() {
764
+ stop();
765
+ listeners.clear();
766
+ }
767
+ };
768
+ }
769
+ function shareStructure(prev, next) {
770
+ if (Object.is(prev, next)) return prev;
771
+ if (typeof prev !== "object" || typeof next !== "object" || prev === null || next === null) return next;
772
+ if (Array.isArray(prev) !== Array.isArray(next)) return next;
773
+ if (Array.isArray(prev) && Array.isArray(next)) {
774
+ let same2 = prev.length === next.length;
775
+ const out2 = next.map((value, i) => {
776
+ const shared = i < prev.length ? shareStructure(prev[i], value) : value;
777
+ if (shared !== prev[i]) same2 = false;
778
+ return shared;
779
+ });
780
+ return same2 ? prev : out2;
781
+ }
782
+ const a = prev;
783
+ const b = next;
784
+ const keys = Object.keys(b);
785
+ let same = keys.length === Object.keys(a).length;
786
+ const out = {};
787
+ for (const key of keys) {
788
+ const shared = key in a ? shareStructure(a[key], b[key]) : b[key];
789
+ if (shared !== a[key] || !(key in a)) same = false;
790
+ out[key] = shared;
791
+ }
792
+ return same ? prev : out;
793
+ }
794
+
485
795
  // src/core/persistence.ts
486
796
  var CANVAS_STORAGE_KEY = "ai-matrx.canvas.v1";
487
797
  function createLocalStorageCanvasPersistence(key = CANVAS_STORAGE_KEY, storage = typeof window === "undefined" ? null : safeLocalStorage()) {
@@ -498,6 +808,16 @@ function createLocalStorageCanvasPersistence(key = CANVAS_STORAGE_KEY, storage =
498
808
  }
499
809
  };
500
810
  }
811
+ function createKeyValueCanvasPersistence(kv, key = CANVAS_STORAGE_KEY) {
812
+ return {
813
+ async load() {
814
+ const raw = await kv.get(key);
815
+ if (raw === void 0 || raw === null) return null;
816
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
817
+ },
818
+ save: (snapshot) => kv.set(key, JSON.parse(JSON.stringify(snapshot)))
819
+ };
820
+ }
501
821
  function safeLocalStorage() {
502
822
  try {
503
823
  const probe = "__mxc_probe__";
@@ -509,6 +829,7 @@ function safeLocalStorage() {
509
829
  }
510
830
  }
511
831
  function toPersistableSnapshot(state, isRestorable) {
832
+ state = foldCanvasWindows(state);
512
833
  const keep = new Set(
513
834
  Object.values(state.items).filter((item) => isRestorable(item.kind)).map((item) => item.id)
514
835
  );
@@ -535,22 +856,164 @@ function toPersistableSnapshot(state, isRestorable) {
535
856
  return { ...state, items, panes, layout, focusedPaneId };
536
857
  }
537
858
 
859
+ // src/core/rules.ts
860
+ var CANVAS_WEB_LAYOUT_RULES = {
861
+ defaultWidth: CANVAS_DEFAULT_WIDTH,
862
+ minWidth: CANVAS_MIN_WIDTH,
863
+ centreMinWidth: 420,
864
+ stackSplit: 0.5,
865
+ sideSplit: 0.5,
866
+ paneMinWidth: 0
867
+ };
868
+ var CANVAS_DESKTOP_LAYOUT_RULES = {
869
+ defaultWidth: { fraction: 0.36 },
870
+ minWidth: CANVAS_MIN_WIDTH,
871
+ centreMinWidth: 520,
872
+ stackSplit: 0.7,
873
+ sideSplit: 0.5,
874
+ paneMinWidth: 360
875
+ };
876
+ var clampFraction = (value, fallback) => Number.isFinite(value) ? Math.min(0.88, Math.max(0.12, value)) : fallback;
877
+ function resolveCanvasLayoutRules(rules) {
878
+ const merged = { ...CANVAS_WEB_LAYOUT_RULES, ...rules ?? {} };
879
+ const positive = (value, fallback) => Number.isFinite(value) && value >= 0 ? value : fallback;
880
+ return {
881
+ defaultWidth: typeof merged.defaultWidth === "number" ? positive(merged.defaultWidth, CANVAS_DEFAULT_WIDTH) : { fraction: Math.min(1, Math.max(0.05, Number.isFinite(merged.defaultWidth.fraction) ? merged.defaultWidth.fraction : 0.36)) },
882
+ minWidth: positive(merged.minWidth, CANVAS_MIN_WIDTH),
883
+ centreMinWidth: positive(merged.centreMinWidth, CANVAS_WEB_LAYOUT_RULES.centreMinWidth),
884
+ stackSplit: clampFraction(merged.stackSplit, 0.5),
885
+ sideSplit: clampFraction(merged.sideSplit, 0.5),
886
+ paneMinWidth: positive(merged.paneMinWidth, 0)
887
+ };
888
+ }
889
+ function defaultCanvasWidth(rules, region) {
890
+ if (typeof rules.defaultWidth === "number") return rules.defaultWidth;
891
+ return region > 0 ? Math.round(region * rules.defaultWidth.fraction) : CANVAS_DEFAULT_WIDTH;
892
+ }
893
+ function fitCanvasWidth(stored, region, rules) {
894
+ const wanted = stored ?? defaultCanvasWidth(rules, region);
895
+ if (region <= 0) return Math.max(rules.minWidth, wanted);
896
+ return Math.max(rules.minWidth, Math.min(wanted, region - rules.centreMinWidth));
897
+ }
898
+ function clampDraggedCanvasWidth(next, region, rules) {
899
+ const max = Math.max(rules.minWidth, region - rules.centreMinWidth);
900
+ return Math.min(max, Math.max(rules.minWidth, Math.round(next)));
901
+ }
902
+ function shouldStackSplit(width, children, rules) {
903
+ return rules.paneMinWidth > 0 && width > 0 && width < rules.paneMinWidth * children;
904
+ }
905
+
906
+ // src/core/commands.ts
907
+ var CANVAS_SHORTCUTS = [
908
+ { id: "canvas.toggle", accelerator: "CmdOrCtrl+\\", when: "presented", label: "Show or hide canvas", web: true },
909
+ { id: "canvas.exitFullscreen", accelerator: "Escape", when: "fullscreen", label: "Restore canvas", web: true },
910
+ { id: "canvas.toggleFullscreen", accelerator: "CmdOrCtrl+Shift+Enter", when: "canvas-focus", label: "Expand canvas", web: true },
911
+ { id: "canvas.splitRight", accelerator: "CmdOrCtrl+Alt+\\", when: "canvas-focus", label: "Split right", web: true },
912
+ { id: "canvas.splitDown", accelerator: "CmdOrCtrl+Alt+Shift+\\", when: "canvas-focus", label: "Split down", web: true },
913
+ { id: "canvas.closeTab", accelerator: "CmdOrCtrl+W", when: "canvas-focus", label: "Close tab", web: false },
914
+ { id: "canvas.nextTab", accelerator: "Ctrl+Tab", when: "canvas-focus", label: "Next tab", web: false },
915
+ { id: "canvas.previousTab", accelerator: "Ctrl+Shift+Tab", when: "canvas-focus", label: "Previous tab", web: false }
916
+ ];
917
+ function canvasShortcutHint(id, isMac) {
918
+ const shortcut = CANVAS_SHORTCUTS.find((entry) => entry.id === id);
919
+ if (!shortcut) return null;
920
+ return shortcut.accelerator.split("+").map((part) => {
921
+ if (part === "CmdOrCtrl") return isMac ? "\u2318" : "Ctrl+";
922
+ if (part === "Ctrl") return isMac ? "\u2303" : "Ctrl+";
923
+ if (part === "Alt") return isMac ? "\u2325" : "Alt+";
924
+ if (part === "Shift") return isMac ? "\u21E7" : "Shift+";
925
+ if (part === "Escape") return "Esc";
926
+ return part;
927
+ }).join("");
928
+ }
929
+ function runCanvasCommand(controller, id, windowId = controller.windowId) {
930
+ const state = controller.getState();
931
+ const frame2 = canvasWindowFrame(state, windowId);
932
+ const pane = frame2 ? state.panes[frame2.focusedPaneId] : void 0;
933
+ switch (id) {
934
+ case "canvas.toggle":
935
+ controller.toggle(windowId);
936
+ return true;
937
+ case "canvas.toggleFullscreen":
938
+ controller.setFullscreen(!(frame2?.isFullscreen ?? false), windowId);
939
+ return true;
940
+ case "canvas.exitFullscreen":
941
+ if (!frame2?.isFullscreen) return false;
942
+ controller.setFullscreen(false, windowId);
943
+ return true;
944
+ case "canvas.closeTab":
945
+ if (!frame2?.isOpen || !pane?.activeItemId) return false;
946
+ controller.close(pane.activeItemId);
947
+ return true;
948
+ case "canvas.nextTab":
949
+ case "canvas.previousTab": {
950
+ if (!frame2?.isOpen || !pane?.activeItemId || pane.itemIds.length < 2) return false;
951
+ const at = pane.itemIds.indexOf(pane.activeItemId);
952
+ const step = id === "canvas.nextTab" ? 1 : -1;
953
+ const next = pane.itemIds[(at + step + pane.itemIds.length) % pane.itemIds.length];
954
+ if (next) controller.activate(next);
955
+ return true;
956
+ }
957
+ case "canvas.splitRight":
958
+ case "canvas.splitDown":
959
+ if (!frame2?.isOpen || !pane) return false;
960
+ controller.splitPane(pane.id, id === "canvas.splitRight" ? "horizontal" : "vertical", pane.itemIds.length > 1 ? pane.activeItemId ?? void 0 : void 0);
961
+ return true;
962
+ default:
963
+ return false;
964
+ }
965
+ }
966
+ var CODE_FOR = { "\\": "Backslash", Tab: "Tab", Escape: "Escape", Enter: "Enter" };
967
+ function matchesCanvasAccelerator(input, accelerator, isMac) {
968
+ const parts = accelerator.split("+");
969
+ const key = parts.pop() ?? "";
970
+ const want = { meta: false, ctrl: false, alt: false, shift: false };
971
+ for (const part of parts) {
972
+ if (part === "CmdOrCtrl") want[isMac ? "meta" : "ctrl"] = true;
973
+ else if (part === "Cmd" || part === "Meta") want.meta = true;
974
+ else if (part === "Ctrl") want.ctrl = true;
975
+ else if (part === "Alt") want.alt = true;
976
+ else if (part === "Shift") want.shift = true;
977
+ }
978
+ if (input.metaKey !== want.meta || input.ctrlKey !== want.ctrl || input.altKey !== want.alt || input.shiftKey !== want.shift) return false;
979
+ const code = CODE_FOR[key] ?? (/^[A-Z]$/i.test(key) ? `Key${key.toUpperCase()}` : void 0);
980
+ if (code && input.code === code) return true;
981
+ return input.key.toLowerCase() === key.toLowerCase();
982
+ }
983
+
538
984
  // src/core/controller.ts
539
985
  var consoleCanvasErrorSink = (report) => {
540
986
  console.error(`[@ai-matrx/canvas] ${report.code}: ${report.message}`, report.detail ?? "");
541
987
  };
542
988
  function createCanvasController(options) {
989
+ const shared = {
990
+ options,
991
+ onError: options.onError ?? consoleCanvasErrorSink,
992
+ rules: resolveCanvasLayoutRules(options.layoutRules),
993
+ presentations: /* @__PURE__ */ new Map(),
994
+ presentationListeners: /* @__PURE__ */ new Set(),
995
+ views: /* @__PURE__ */ new Map(),
996
+ started: /* @__PURE__ */ new Map(),
997
+ sessionStop: null
998
+ };
999
+ return view(shared, canvasWindowId(options.windowId ?? CANVAS_MAIN_WINDOW));
1000
+ }
1001
+ function view(shared, windowId) {
1002
+ const cached = shared.views.get(windowId);
1003
+ if (cached) return cached;
1004
+ const { options, onError, rules } = shared;
543
1005
  const { store, persistence = null } = options;
544
- const onError = options.onError ?? consoleCanvasErrorSink;
545
1006
  const isRestorable = options.isRestorable ?? (() => true);
546
1007
  const dispatch = (action) => store.dispatch(action);
547
- let presentations = 0;
548
- const presentationListeners = /* @__PURE__ */ new Set();
549
1008
  const notifyPresentation = () => {
550
- for (const listener of [...presentationListeners]) listener();
1009
+ for (const listener of [...shared.presentationListeners]) listener();
551
1010
  };
1011
+ const shareFor = (orientation) => orientation === "vertical" ? rules.stackSplit : rules.sideSplit;
552
1012
  const controller = {
553
1013
  store,
1014
+ windowId,
1015
+ rules,
1016
+ forWindow: (id) => view(shared, canvasWindowId(id)),
554
1017
  getState: () => store.getState(),
555
1018
  open(input) {
556
1019
  if (options.isKnownKind && !options.isKnownKind(input.kind)) {
@@ -568,7 +1031,10 @@ function createCanvasController(options) {
568
1031
  return null;
569
1032
  }
570
1033
  }
571
- dispatch(canvasActions.open(input));
1034
+ const target = input.target ?? "focused";
1035
+ const splitShare = target === "split-down" ? rules.stackSplit : target === "split-right" ? rules.sideSplit : void 0;
1036
+ const resolved = input.windowId || windowId === CANVAS_MAIN_WINDOW ? input : { ...input, windowId };
1037
+ dispatch(canvasActions.open(resolved, splitShare === void 0 ? void 0 : { splitShare }));
572
1038
  return canvasItemId(input.kind, input.key);
573
1039
  },
574
1040
  update(itemId, patch) {
@@ -594,42 +1060,76 @@ function createCanvasController(options) {
594
1060
  activate: (itemId) => dispatch(canvasActions.activate(itemId)),
595
1061
  focusPane: (paneId) => dispatch(canvasActions.focusPane(paneId)),
596
1062
  moveItem: (itemId, toPaneId, index) => dispatch(canvasActions.moveItem(itemId, toPaneId, index)),
597
- splitPane: (paneId, orientation, moveItemId) => dispatch(canvasActions.splitPane(paneId, orientation, moveItemId)),
1063
+ splitPane: (paneId, orientation, moveItemId) => dispatch(canvasActions.splitPane(paneId, orientation, moveItemId, shareFor(orientation))),
598
1064
  closePane: (paneId) => dispatch(canvasActions.closePane(paneId)),
599
1065
  resizeSplit: (splitId, sizes) => dispatch(canvasActions.resizeSplit(splitId, sizes)),
600
- show: () => dispatch(canvasActions.setOpen(true)),
601
- hide: () => dispatch(canvasActions.setOpen(false)),
602
- toggle: () => dispatch(canvasActions.toggle()),
603
- setFullscreen: (fullscreen) => dispatch(canvasActions.setFullscreen(fullscreen)),
604
- setWidth: (width) => dispatch(canvasActions.setWidth(width)),
1066
+ show: (w = windowId) => dispatch(canvasActions.setOpen(true, w)),
1067
+ hide: (w = windowId) => dispatch(canvasActions.setOpen(false, w)),
1068
+ toggle: (w = windowId) => dispatch(canvasActions.toggle(w)),
1069
+ setFullscreen: (fullscreen, w = windowId) => dispatch(canvasActions.setFullscreen(fullscreen, w)),
1070
+ setWidth: (width, w = windowId) => dispatch(canvasActions.setWidth(width, w)),
1071
+ async popOut(itemId) {
1072
+ const item = store.getState().items[itemId];
1073
+ if (!item) return null;
1074
+ const port = options.windowPorts?.().popOut;
1075
+ if (!port) {
1076
+ onError({ code: "pop-out-unavailable", message: "This app cannot pop canvas tabs out into their own window.", detail: { itemId } });
1077
+ return null;
1078
+ }
1079
+ let result;
1080
+ try {
1081
+ result = await port(item);
1082
+ } catch (error) {
1083
+ onError({ code: "pop-out-failed", message: "The window for this tab could not be opened.", detail: error });
1084
+ return null;
1085
+ }
1086
+ if (!result) {
1087
+ onError({ code: "pop-out-refused", message: "The app declined to pop this tab out.", detail: { itemId } });
1088
+ return null;
1089
+ }
1090
+ const target = canvasWindowId(result.windowId);
1091
+ dispatch(canvasActions.popOut(itemId, target));
1092
+ return target;
1093
+ },
1094
+ dockBack(itemId) {
1095
+ const state = store.getState();
1096
+ const from = canvasWindowOfItem(state, itemId);
1097
+ if (!from || from === CANVAS_MAIN_WINDOW || state.windows[from]?.role !== "pop-out") return false;
1098
+ dispatch(canvasActions.dockBack(itemId));
1099
+ options.windowPorts?.().dockBack?.(itemId, from);
1100
+ return true;
1101
+ },
1102
+ closeWindow: (w, mode = "dock") => dispatch(canvasActions.closeWindow(w, mode)),
605
1103
  has: (kind, key) => canvasItemId(kind, key) in store.getState().items,
606
- isPresented: () => presentations > 0,
607
- registerPresentation() {
608
- presentations += 1;
1104
+ isPresented: (w = windowId) => (shared.presentations.get(w) ?? 0) > 0,
1105
+ registerPresentation(w = windowId) {
1106
+ shared.presentations.set(w, (shared.presentations.get(w) ?? 0) + 1);
609
1107
  notifyPresentation();
610
1108
  let done = false;
611
1109
  return () => {
612
1110
  if (done) return;
613
1111
  done = true;
614
- presentations -= 1;
1112
+ shared.presentations.set(w, Math.max(0, (shared.presentations.get(w) ?? 1) - 1));
615
1113
  notifyPresentation();
616
1114
  };
617
1115
  },
618
1116
  subscribePresentation(listener) {
619
- presentationListeners.add(listener);
620
- return () => presentationListeners.delete(listener);
1117
+ shared.presentationListeners.add(listener);
1118
+ return () => shared.presentationListeners.delete(listener);
621
1119
  },
622
1120
  start() {
623
1121
  let disposed = false;
624
1122
  let timer = null;
625
1123
  let lastSaved = null;
1124
+ const owns = store.authority !== "remote";
1125
+ const memory = owns ? persistence : null;
626
1126
  const save = () => {
627
1127
  timer = null;
628
1128
  const state = store.getState();
629
- if (!persistence || !state.hydrated || state === lastSaved) return;
1129
+ if (!memory || !state.hydrated || state === lastSaved) return;
630
1130
  lastSaved = state;
631
1131
  try {
632
- void Promise.resolve(persistence.save(toPersistableSnapshot(state, isRestorable))).catch(
1132
+ void Promise.resolve(memory.save(toPersistableSnapshot(state, isRestorable))).catch(
633
1133
  (error) => onError({ code: "persistence-save", message: "Saving the canvas layout failed.", detail: error })
634
1134
  );
635
1135
  } catch (error) {
@@ -638,18 +1138,18 @@ function createCanvasController(options) {
638
1138
  };
639
1139
  let seen = store.getState();
640
1140
  const unsubscribe = store.subscribe(() => {
641
- if (!persistence || disposed) return;
1141
+ if (!memory || disposed) return;
642
1142
  const current = store.getState();
643
1143
  if (current === seen) return;
644
1144
  seen = current;
645
1145
  if (timer) clearTimeout(timer);
646
1146
  timer = setTimeout(save, options.saveDelayMs ?? 250);
647
1147
  });
648
- if (store.getState().hydrated) {
649
- } else if (!persistence) {
1148
+ if (!owns || store.getState().hydrated) {
1149
+ } else if (!memory) {
650
1150
  dispatch(canvasActions.hydrate(null));
651
1151
  } else {
652
- Promise.resolve().then(() => persistence.load()).then(
1152
+ Promise.resolve().then(() => memory.load()).then(
653
1153
  (snapshot) => {
654
1154
  if (disposed) return;
655
1155
  const openedThisSession = store.getState().isOpen;
@@ -668,9 +1168,11 @@ function createCanvasController(options) {
668
1168
  }
669
1169
  );
670
1170
  }
1171
+ const stopSessions = startSessions(shared, windowId);
671
1172
  return () => {
672
1173
  disposed = true;
673
1174
  unsubscribe();
1175
+ stopSessions();
674
1176
  if (timer) {
675
1177
  clearTimeout(timer);
676
1178
  save();
@@ -678,48 +1180,180 @@ function createCanvasController(options) {
678
1180
  };
679
1181
  }
680
1182
  };
1183
+ shared.views.set(windowId, controller);
681
1184
  return controller;
682
1185
  }
1186
+ function startSessions(shared, windowId) {
1187
+ const { options, onError } = shared;
1188
+ const sessions = options.sessions;
1189
+ if (!sessions) return () => void 0;
1190
+ shared.started.set(windowId, (shared.started.get(windowId) ?? 0) + 1);
1191
+ let stopped = false;
1192
+ if (!shared.sessionStop) {
1193
+ const store = options.store;
1194
+ const local = store.authority !== "remote";
1195
+ let tracked = null;
1196
+ const call = (fn) => {
1197
+ try {
1198
+ fn();
1199
+ } catch (error) {
1200
+ onError({ code: "session-hook", message: "A canvas session hook threw.", detail: error });
1201
+ }
1202
+ };
1203
+ const read = (fn) => {
1204
+ try {
1205
+ return fn();
1206
+ } catch (error) {
1207
+ onError({ code: "session-hook", message: "A canvas kind's sessionKey threw.", detail: error });
1208
+ return null;
1209
+ }
1210
+ };
1211
+ let prevItems = /* @__PURE__ */ new Set();
1212
+ const snapshot = (state) => {
1213
+ const out = /* @__PURE__ */ new Map();
1214
+ for (const w of listCanvasWindowIds(state)) {
1215
+ if (!local && !shared.started.has(w)) continue;
1216
+ const frame2 = canvasWindowFrame(state, w);
1217
+ if (!frame2) continue;
1218
+ for (const paneId of listPaneIds(frame2.layout)) {
1219
+ for (const itemId of state.panes[paneId]?.itemIds ?? []) {
1220
+ const item = state.items[itemId];
1221
+ const hooks = item ? sessions(item.kind) : void 0;
1222
+ if (!item || !hooks) continue;
1223
+ const key = read(() => hooks.sessionKey(item));
1224
+ if (key) out.set(itemId, { item, key, windowId: w });
1225
+ }
1226
+ }
1227
+ }
1228
+ return out;
1229
+ };
1230
+ const sync = () => {
1231
+ const state = options.store.getState();
1232
+ if (!state.hydrated) return;
1233
+ const next = snapshot(state);
1234
+ const first = tracked === null;
1235
+ const prev = tracked ?? /* @__PURE__ */ new Map();
1236
+ for (const [id, entry] of prev) {
1237
+ const now = next.get(id);
1238
+ if (now && now.key === entry.key) continue;
1239
+ const hooks = sessions(entry.item.kind);
1240
+ const reason = state.items[id] && !now ? "leave" : "close";
1241
+ call(() => hooks?.onDetach?.({ item: entry.item, sessionKey: entry.key, windowId: entry.windowId, reason }));
1242
+ }
1243
+ for (const [id, entry] of next) {
1244
+ const before = prev.get(id);
1245
+ if (before && before.key === entry.key) continue;
1246
+ const hooks = sessions(entry.item.kind);
1247
+ const reason = first ? "restore" : prevItems.has(id) ? "arrive" : "open";
1248
+ call(() => hooks?.onAttach?.({ item: entry.item, sessionKey: entry.key, windowId: entry.windowId, reason }));
1249
+ }
1250
+ tracked = next;
1251
+ prevItems = new Set(Object.keys(state.items));
1252
+ };
1253
+ let lastState = null;
1254
+ const unsubscribe = store.subscribe(() => {
1255
+ const state = store.getState();
1256
+ if (state === lastState) return;
1257
+ lastState = state;
1258
+ sync();
1259
+ });
1260
+ sync();
1261
+ shared.sessionStop = () => {
1262
+ unsubscribe();
1263
+ for (const entry of tracked?.values() ?? []) {
1264
+ const hooks = sessions(entry.item.kind);
1265
+ call(() => hooks?.onDetach?.({ item: entry.item, sessionKey: entry.key, windowId: entry.windowId, reason: "unmount" }));
1266
+ }
1267
+ tracked = null;
1268
+ };
1269
+ }
1270
+ return () => {
1271
+ if (stopped) return;
1272
+ stopped = true;
1273
+ const left = (shared.started.get(windowId) ?? 1) - 1;
1274
+ if (left > 0) shared.started.set(windowId, left);
1275
+ else shared.started.delete(windowId);
1276
+ if (shared.started.size === 0 && shared.sessionStop) {
1277
+ const stop = shared.sessionStop;
1278
+ shared.sessionStop = null;
1279
+ stop();
1280
+ }
1281
+ };
1282
+ }
683
1283
 
684
1284
  // src/core/selectors.ts
685
- var selectCanvasIsOpen = (s) => s.isOpen;
686
- var selectCanvasIsFullscreen = (s) => s.isFullscreen;
687
- var selectCanvasWidth = (s) => s.width;
688
- var selectCanvasLayout = (s) => s.layout;
689
- var selectCanvasFocusedPaneId = (s) => s.focusedPaneId;
1285
+ var frame = (s, w) => canvasWindowFrame(s, w ?? CANVAS_MAIN_WINDOW);
1286
+ var selectCanvasIsOpen = (s, windowId) => frame(s, windowId)?.isOpen ?? false;
1287
+ var selectCanvasIsFullscreen = (s, windowId) => frame(s, windowId)?.isFullscreen ?? false;
1288
+ var selectCanvasWidth = (s, windowId) => frame(s, windowId)?.width ?? null;
1289
+ var selectCanvasLayout = (s, windowId) => frame(s, windowId)?.layout ?? null;
1290
+ var selectCanvasFocusedPaneId = (s, windowId) => frame(s, windowId)?.focusedPaneId ?? null;
690
1291
  var selectCanvasIsHydrated = (s) => s.hydrated;
691
1292
  var selectCanvasItemCount = (s) => Object.keys(s.items).length;
692
1293
  var selectCanvasPane = (s, paneId) => s.panes[paneId];
693
1294
  var selectCanvasItem = (s, itemId) => s.items[itemId];
694
- function selectCanvasPaneCount(s) {
695
- return listPaneIds(s.layout).length;
1295
+ function selectCanvasPaneCount(s, windowId) {
1296
+ const layout = frame(s, windowId)?.layout;
1297
+ return layout ? listPaneIds(layout).length : 0;
696
1298
  }
697
- function selectCanvasActiveItem(s) {
698
- const pane = s.panes[s.focusedPaneId];
1299
+ function selectCanvasWindowItemCount(s, windowId) {
1300
+ const layout = frame(s, windowId)?.layout;
1301
+ if (!layout) return 0;
1302
+ return listPaneIds(layout).reduce((sum, paneId) => sum + (s.panes[paneId]?.itemIds.length ?? 0), 0);
1303
+ }
1304
+ function selectCanvasActiveItem(s, windowId) {
1305
+ const f = frame(s, windowId);
1306
+ const pane = f ? s.panes[f.focusedPaneId] : void 0;
699
1307
  return pane?.activeItemId ? s.items[pane.activeItemId] ?? null : null;
700
1308
  }
1309
+ var NO_WINDOWS = Object.freeze({});
1310
+ function selectCanvasWindows(s) {
1311
+ return s.windows ?? NO_WINDOWS;
1312
+ }
1313
+ var selectCanvasExtraWindow = (s, windowId) => s.windows?.[windowId];
1314
+ var selectCanvasWindowOfItem = canvasWindowOfItem;
701
1315
  export {
702
1316
  CANVAS_DEFAULT_WIDTH,
1317
+ CANVAS_DESKTOP_LAYOUT_RULES,
1318
+ CANVAS_MAIN_WINDOW,
703
1319
  CANVAS_MIN_SPLIT_FRACTION,
704
1320
  CANVAS_MIN_WIDTH,
1321
+ CANVAS_SHORTCUTS,
705
1322
  CANVAS_STORAGE_KEY,
1323
+ CANVAS_WEB_LAYOUT_RULES,
706
1324
  bindCanvasToReduxStore,
707
1325
  canvasActions,
708
1326
  canvasItemId,
709
1327
  canvasReducer,
1328
+ canvasShortcutHint,
1329
+ canvasWindowFrame,
1330
+ canvasWindowId,
1331
+ canvasWindowOfItem,
1332
+ canvasWindowOfPane,
1333
+ clampDraggedCanvasWidth,
710
1334
  consoleCanvasErrorSink,
711
1335
  createCanvasController,
712
1336
  createCanvasStore,
713
1337
  createInitialCanvasState,
1338
+ createKeyValueCanvasPersistence,
714
1339
  createLocalStorageCanvasPersistence,
1340
+ createRemoteCanvasStore,
1341
+ defaultCanvasWidth,
715
1342
  findNonJson,
1343
+ fitCanvasWidth,
1344
+ foldCanvasWindows,
716
1345
  isCanvasAction,
717
1346
  isCanvasJson,
1347
+ listCanvasWindowIds,
718
1348
  listPaneIds,
1349
+ matchesCanvasAccelerator,
719
1350
  normalizeSizes,
720
1351
  parseCanvasItemId,
1352
+ resolveCanvasLayoutRules,
1353
+ runCanvasCommand,
721
1354
  sanitizeCanvasSnapshot,
722
1355
  selectCanvasActiveItem,
1356
+ selectCanvasExtraWindow,
723
1357
  selectCanvasFocusedPaneId,
724
1358
  selectCanvasIsFullscreen,
725
1359
  selectCanvasIsHydrated,
@@ -730,6 +1364,11 @@ export {
730
1364
  selectCanvasPane,
731
1365
  selectCanvasPaneCount,
732
1366
  selectCanvasWidth,
1367
+ selectCanvasWindowItemCount,
1368
+ selectCanvasWindowOfItem,
1369
+ selectCanvasWindows,
1370
+ shareStructure,
1371
+ shouldStackSplit,
733
1372
  toPersistableSnapshot
734
1373
  };
735
1374
  //# sourceMappingURL=index.js.map