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