@ai-matrx/canvas 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs CHANGED
@@ -34,6 +34,7 @@ var react_exports = {};
34
34
  __export(react_exports, {
35
35
  BrowserBar: () => BrowserBar,
36
36
  BrowserTabs: () => BrowserTabs,
37
+ CANVAS_TAB_LIST_ROOM: () => CANVAS_TAB_LIST_ROOM,
37
38
  CANVAS_TAB_MIN_WIDTH: () => CANVAS_TAB_MIN_WIDTH,
38
39
  CanvasColumn: () => CanvasColumn,
39
40
  CanvasFrame: () => CanvasFrame,
@@ -50,8 +51,8 @@ __export(react_exports, {
50
51
  CanvasWindowTabs: () => CanvasWindowTabs,
51
52
  beginCanvasResize: () => beginCanvasResize,
52
53
  canvasSessionKey: () => canvasSessionKey,
53
- canvasTabsOverflow: () => canvasTabsOverflow,
54
54
  defineCanvasKind: () => defineCanvasKind,
55
+ fitCanvasTabWindow: () => fitCanvasTabWindow,
55
56
  getCanvasKind: () => getCanvasKind,
56
57
  itemTitle: () => itemTitle,
57
58
  listCanvasKinds: () => listCanvasKinds,
@@ -1055,6 +1056,7 @@ function toPersistableSnapshot(state, isRestorable) {
1055
1056
 
1056
1057
  // src/core/rules.ts
1057
1058
  var CANVAS_PANE_MIN_WIDTH = 280;
1059
+ var CANVAS_PANE_MIN_HEIGHT = 200;
1058
1060
  var CANVAS_WEB_LAYOUT_RULES = {
1059
1061
  defaultWidth: CANVAS_DEFAULT_WIDTH,
1060
1062
  minWidth: CANVAS_MIN_WIDTH,
@@ -1068,7 +1070,10 @@ var CANVAS_WEB_LAYOUT_RULES = {
1068
1070
  sideSplit: 0.5,
1069
1071
  // A pane narrower than this cannot show a header (Back, a readable tab, "+", the menu, close) and a
1070
1072
  // body side by side with another: a split right that would go under it stacks instead.
1071
- paneMinWidth: CANVAS_PANE_MIN_WIDTH
1073
+ paneMinWidth: CANVAS_PANE_MIN_WIDTH,
1074
+ // 2026-10-07 live: Quick Notes / Notifications / Quick Chat stacked in an 800px window left
1075
+ // Notifications its header and one row.
1076
+ paneMinHeight: CANVAS_PANE_MIN_HEIGHT
1072
1077
  };
1073
1078
  var clampFraction = (value, fallback) => Number.isFinite(value) ? Math.min(0.88, Math.max(0.12, value)) : fallback;
1074
1079
  function resolveCanvasLayoutRules(rules) {
@@ -1080,7 +1085,8 @@ function resolveCanvasLayoutRules(rules) {
1080
1085
  centreMinWidth: positive(merged.centreMinWidth, CANVAS_WEB_LAYOUT_RULES.centreMinWidth),
1081
1086
  stackSplit: clampFraction(merged.stackSplit, 0.5),
1082
1087
  sideSplit: clampFraction(merged.sideSplit, 0.5),
1083
- paneMinWidth: positive(merged.paneMinWidth, 0)
1088
+ paneMinWidth: positive(merged.paneMinWidth, 0),
1089
+ paneMinHeight: positive(merged.paneMinHeight, 0)
1084
1090
  };
1085
1091
  }
1086
1092
  function defaultCanvasWidth(rules, region) {
@@ -1099,6 +1105,30 @@ function clampDraggedCanvasWidth(next, region, rules) {
1099
1105
  function shouldStackSplit(width, children, rules) {
1100
1106
  return rules.paneMinWidth > 0 && width > 0 && width < rules.paneMinWidth * children;
1101
1107
  }
1108
+ function fitCanvasSplit(size, orientation, rules) {
1109
+ if (!size || size.width <= 0 || size.height <= 0) return orientation;
1110
+ const sideFits = rules.paneMinWidth <= 0 || size.width >= 2 * rules.paneMinWidth;
1111
+ const stackFits = rules.paneMinHeight <= 0 || size.height >= 2 * rules.paneMinHeight;
1112
+ if (orientation === "horizontal") return sideFits || stackFits ? "horizontal" : null;
1113
+ return stackFits ? "vertical" : sideFits ? "horizontal" : null;
1114
+ }
1115
+ function fitCanvasSplitSizes(sizes, total, mins) {
1116
+ if (!(total > 0) || sizes.length === 0) return [...sizes];
1117
+ const sum = sizes.reduce((a, b) => a + b, 0) || 1;
1118
+ const px = sizes.map((s) => s / sum * total);
1119
+ const floor = mins.map((m) => Number.isFinite(m) && m > 0 ? m : 0);
1120
+ const needed = floor.reduce((a, b) => a + b, 0);
1121
+ if (needed >= total) return needed > 0 ? floor.map((m) => m / needed) : px.map((p) => p / total);
1122
+ const short = px.reduce((acc, p, i) => acc + Math.max(0, (floor[i] ?? 0) - p), 0);
1123
+ if (short <= 0) return px.map((p) => p / total);
1124
+ const spare = px.map((p, i) => Math.max(0, p - (floor[i] ?? 0)));
1125
+ const spareSum = spare.reduce((a, b) => a + b, 0);
1126
+ return px.map((p, i) => {
1127
+ const min = floor[i] ?? 0;
1128
+ if (p <= min) return min / total;
1129
+ return (p - short * (spare[i] ?? 0) / spareSum) / total;
1130
+ });
1131
+ }
1102
1132
 
1103
1133
  // src/core/commands.ts
1104
1134
  var CANVAS_SHORTCUTS = [
@@ -1196,6 +1226,7 @@ function createCanvasController(options) {
1196
1226
  options,
1197
1227
  onError: options.onError ?? consoleCanvasErrorSink,
1198
1228
  rules: resolveCanvasLayoutRules(options.layoutRules),
1229
+ measures: /* @__PURE__ */ new Set(),
1199
1230
  presentations: /* @__PURE__ */ new Map(),
1200
1231
  presentationListeners: /* @__PURE__ */ new Set(),
1201
1232
  views: /* @__PURE__ */ new Map(),
@@ -1216,6 +1247,14 @@ function view(shared, windowId) {
1216
1247
  for (const listener of [...shared.presentationListeners]) listener();
1217
1248
  };
1218
1249
  const shareFor = (orientation) => orientation === "vertical" ? rules.stackSplit : rules.sideSplit;
1250
+ const measurePane = (paneId) => {
1251
+ for (const measure of shared.measures) {
1252
+ const size = measure(paneId);
1253
+ if (size) return size;
1254
+ }
1255
+ return null;
1256
+ };
1257
+ const fitSplit = (paneId, orientation) => fitCanvasSplit(measurePane(paneId), orientation, rules);
1219
1258
  const acceptable = (input) => {
1220
1259
  if (input.kind !== CANVAS_LAUNCHER_KIND && options.isKnownKind && !options.isKnownKind(input.kind)) {
1221
1260
  onError({ code: "unknown-kind", message: `No canvas kind "${input.kind}" is registered.`, detail: input });
@@ -1246,6 +1285,13 @@ function view(shared, windowId) {
1246
1285
  if (holding) return { paneId: holding };
1247
1286
  return state.panes[frame2.focusedPaneId]?.activeItemId ? preferred : "focused";
1248
1287
  };
1288
+ const splitTargetFor = (asked, forWindow) => {
1289
+ const state = store.getState();
1290
+ const frame2 = canvasWindowFrame(state, forWindow ? canvasWindowId(forWindow) : windowId);
1291
+ if (!frame2) return asked;
1292
+ const fitted = fitSplit(frame2.focusedPaneId, asked === "split-down" ? "vertical" : "horizontal");
1293
+ return fitted === null ? "focused" : fitted === "vertical" ? "split-down" : "split-right";
1294
+ };
1249
1295
  const controller = {
1250
1296
  store,
1251
1297
  windowId,
@@ -1254,7 +1300,8 @@ function view(shared, windowId) {
1254
1300
  getState: () => store.getState(),
1255
1301
  open(input) {
1256
1302
  if (!acceptable(input)) return null;
1257
- const target = input.target ?? preferredTargetFor(input);
1303
+ const asked = input.target ?? preferredTargetFor(input);
1304
+ const target = asked === "split-down" || asked === "split-right" ? splitTargetFor(asked, input.windowId) : asked;
1258
1305
  const splitShare = target === "split-down" ? rules.stackSplit : target === "split-right" ? rules.sideSplit : void 0;
1259
1306
  const withTarget = input.target === target ? input : { ...input, target };
1260
1307
  const resolved = withTarget.windowId || windowId === CANVAS_MAIN_WINDOW ? withTarget : { ...withTarget, windowId };
@@ -1319,7 +1366,15 @@ function view(shared, windowId) {
1319
1366
  activate: (itemId) => dispatch(canvasActions.activate(itemId)),
1320
1367
  focusPane: (paneId) => dispatch(canvasActions.focusPane(paneId)),
1321
1368
  moveItem: (itemId, toPaneId, index) => dispatch(canvasActions.moveItem(itemId, toPaneId, index)),
1322
- splitPane: (paneId, orientation, moveItemId) => dispatch(canvasActions.splitPane(paneId, orientation, moveItemId, shareFor(orientation))),
1369
+ splitPane(paneId, orientation, moveItemId) {
1370
+ const fitted = fitSplit(paneId, orientation);
1371
+ if (fitted) dispatch(canvasActions.splitPane(paneId, fitted, moveItemId, shareFor(fitted)));
1372
+ },
1373
+ fitSplit,
1374
+ registerPaneMeasure(measure) {
1375
+ shared.measures.add(measure);
1376
+ return () => void shared.measures.delete(measure);
1377
+ },
1323
1378
  closePane: (paneId) => dispatch(canvasActions.closePane(paneId)),
1324
1379
  resizeSplit: (splitId, sizes) => dispatch(canvasActions.resizeSplit(splitId, sizes)),
1325
1380
  show: (w = windowId) => dispatch(canvasActions.setOpen(true, w)),
@@ -2935,8 +2990,23 @@ var Rendered = (0, import_react8.memo)(function Rendered2({
2935
2990
  presentation
2936
2991
  }) {
2937
2992
  const props = { item, data: item.data, paneId, isFocused, isVisible, canvas, windowId, presentation };
2938
- return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ItemBoundary, { item, onClose: () => canvas.close(item.id), onRetry: () => resetKindComponent(kind), children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react8.Suspense, { fallback: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mxc-loading", "aria-busy": "true" }), children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(KindRender, { kind, props }) }) });
2993
+ const [attempt, setAttempt] = (0, import_react8.useState)(0);
2994
+ const retry = () => {
2995
+ resetKindComponent(kind);
2996
+ setAttempt((n) => n + 1);
2997
+ };
2998
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ItemBoundary, { item, onClose: () => canvas.close(item.id), onRetry: retry, children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react8.Suspense, { fallback: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(LoadingBody, { onRetry: retry, onClose: () => canvas.close(item.id) }), children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(KindRender, { kind, props }) }, attempt) });
2939
2999
  });
3000
+ var CANVAS_BODY_STALL_MS = 2e4;
3001
+ function LoadingBody({ onRetry, onClose }) {
3002
+ const [stalled, setStalled] = (0, import_react8.useState)(false);
3003
+ (0, import_react8.useEffect)(() => {
3004
+ const timer = setTimeout(() => setStalled(true), CANVAS_BODY_STALL_MS);
3005
+ return () => clearTimeout(timer);
3006
+ }, []);
3007
+ if (!stalled) return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "mxc-loading", "aria-busy": "true" });
3008
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ItemProblem, { message: "This item is taking a long time to load.", onRetry, onClose });
3009
+ }
2940
3010
  function KindRender({ kind, props }) {
2941
3011
  const Render = kindComponent(kind);
2942
3012
  if (!Render) throw new Error(`[@ai-matrx/canvas] kind "${kind.id}" has nothing to render.`);
@@ -3215,9 +3285,36 @@ function PaneTitle({ item, kind }) {
3215
3285
  }
3216
3286
  var CANVAS_TAB_MIN_WIDTH = 120;
3217
3287
  var TAB_GAP = 2;
3218
- function canvasTabsOverflow(count, width) {
3219
- if (count <= 1 || width <= 0) return false;
3220
- return count * CANVAS_TAB_MIN_WIDTH + (count - 1) * TAB_GAP > width;
3288
+ var CANVAS_TAB_LIST_ROOM = 44;
3289
+ function fitCanvasTabWindow(units, current, width, previousStart = 0) {
3290
+ const n = units.length;
3291
+ const need = (i) => Math.max(1, units[i] ?? 1) * CANVAS_TAB_MIN_WIDTH;
3292
+ const all = units.reduce((sum, _, i) => sum + need(i), 0) + Math.max(0, n - 1) * TAB_GAP;
3293
+ if (n <= 1 || width <= 0 || all <= width) return { start: 0, end: n, squeezed: n === 1 && width > 0 && all > width };
3294
+ const room = width - CANVAS_TAB_LIST_ROOM;
3295
+ const at = Math.min(n - 1, Math.max(0, current));
3296
+ let start = Math.min(Math.max(0, previousStart), at);
3297
+ let used = 0;
3298
+ let end = start;
3299
+ while (end < n && used + need(end) + (end > start ? TAB_GAP : 0) <= room) {
3300
+ used += need(end) + (end > start ? TAB_GAP : 0);
3301
+ end += 1;
3302
+ }
3303
+ if (end <= at) {
3304
+ start = at;
3305
+ end = at + 1;
3306
+ used = need(at);
3307
+ while (start > 0 && used + TAB_GAP + need(start - 1) <= room) {
3308
+ start -= 1;
3309
+ used += TAB_GAP + need(start);
3310
+ }
3311
+ } else {
3312
+ while (start > 0 && used + TAB_GAP + need(start - 1) <= room) {
3313
+ start -= 1;
3314
+ used += TAB_GAP + need(start);
3315
+ }
3316
+ }
3317
+ return { start, end, squeezed: need(at) > room && end - start === 1 };
3221
3318
  }
3222
3319
  function planCanvasTabStrip(tabs, actives) {
3223
3320
  const joined = actives ? tabs.filter((tab) => actives.get(tab.paneId) === tab.itemId) : [];
@@ -3252,9 +3349,12 @@ function TabStrip({
3252
3349
  activeItemId,
3253
3350
  actives
3254
3351
  }) {
3352
+ const canvas = useCanvas();
3255
3353
  const strip = (0, import_react9.useRef)(null);
3256
3354
  const list = (0, import_react9.useRef)(null);
3257
3355
  const [width, setWidth] = (0, import_react9.useState)(0);
3356
+ const windowStart = (0, import_react9.useRef)(0);
3357
+ const focusAfterMove = (0, import_react9.useRef)(false);
3258
3358
  const read = (0, import_react9.useRef)(() => {
3259
3359
  });
3260
3360
  useIsoLayoutEffect4(() => {
@@ -3279,31 +3379,31 @@ function TabStrip({
3279
3379
  useIsoLayoutEffect4(() => {
3280
3380
  read.current();
3281
3381
  }, [tabs.length]);
3282
- (0, import_react9.useEffect)(() => {
3283
- const el = list.current;
3284
- if (!el) return void 0;
3285
- const onWheel = (event) => {
3286
- if (Math.abs(event.deltaY) <= Math.abs(event.deltaX) || el.scrollWidth <= el.clientWidth) return;
3287
- event.preventDefault();
3288
- el.scrollLeft += event.deltaY;
3289
- };
3290
- el.addEventListener("wheel", onWheel, { passive: false });
3291
- return () => el.removeEventListener("wheel", onWheel);
3292
- }, []);
3293
3382
  useIsoLayoutEffect4(() => {
3294
- const el = list.current;
3295
- if (!el || !activeItemId) return;
3296
- const tab = [...el.querySelectorAll("[data-mxc-tab]")].find((node) => node.dataset.mxcTab === activeItemId);
3297
- if (!tab) return;
3298
- const target = tab.closest(".mxc-tab-group") ?? tab;
3299
- const box = el.getBoundingClientRect();
3300
- const rect = target.getBoundingClientRect();
3301
- if (rect.left < box.left) el.scrollLeft -= box.left - rect.left;
3302
- else if (rect.right > box.right) el.scrollLeft += rect.right - box.right;
3303
- }, [activeItemId, width, tabs.length]);
3383
+ if (!focusAfterMove.current || !activeItemId) return;
3384
+ focusAfterMove.current = false;
3385
+ tabElement(list.current, activeItemId)?.focus();
3386
+ }, [activeItemId]);
3304
3387
  const entries = planCanvasTabStrip(tabs, actives);
3305
- const overflow = canvasTabsOverflow(tabs.length, width);
3388
+ const entryTabs = (entry) => entry.type === "tab" ? [entry.tab] : entry.tabs;
3389
+ const current = Math.max(
3390
+ 0,
3391
+ entries.findIndex((entry) => entryTabs(entry).some((tab) => tab.itemId === activeItemId))
3392
+ );
3393
+ const fit = fitCanvasTabWindow(
3394
+ entries.map((entry) => entryTabs(entry).length),
3395
+ current,
3396
+ width,
3397
+ windowStart.current
3398
+ );
3399
+ useIsoLayoutEffect4(() => {
3400
+ windowStart.current = fit.start;
3401
+ });
3402
+ const shown = entries.slice(fit.start, fit.end);
3403
+ const hidden = tabs.length - shown.reduce((sum, entry) => sum + entryTabs(entry).length, 0);
3404
+ const overflow = hidden > 0;
3306
3405
  const joined = entries.some((entry) => entry.type === "split");
3406
+ const order = entries.flatMap(entryTabs).map((tab) => tab.itemId);
3307
3407
  const isActive = (tab) => actives ? actives.get(tab.paneId) === tab.itemId : tab.itemId === activeItemId;
3308
3408
  const tabView = (tab) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3309
3409
  ItemTab,
@@ -3316,6 +3416,22 @@ function TabStrip({
3316
3416
  },
3317
3417
  tab.itemId
3318
3418
  );
3419
+ const onKeyDown = (event) => {
3420
+ const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
3421
+ if (!keys.includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || order.length < 2) return;
3422
+ const from = event.target.closest("[data-mxc-tab]")?.dataset.mxcTab;
3423
+ const at = from ? order.indexOf(from) : -1;
3424
+ if (at < 0) return;
3425
+ event.preventDefault();
3426
+ const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
3427
+ const step = event.key === "ArrowRight" !== rtl ? 1 : -1;
3428
+ const next = event.key === "Home" ? 0 : event.key === "End" ? order.length - 1 : (at + step + order.length) % order.length;
3429
+ const target = order[next];
3430
+ if (!target) return;
3431
+ focusAfterMove.current = true;
3432
+ canvas.activate(target);
3433
+ tabElement(list.current, target)?.focus();
3434
+ };
3319
3435
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
3320
3436
  "div",
3321
3437
  {
@@ -3332,8 +3448,9 @@ function TabStrip({
3332
3448
  role: "tablist",
3333
3449
  "aria-label": "Canvas tabs",
3334
3450
  "aria-multiselectable": joined ? true : void 0,
3335
- onKeyDown: moveTabFocus,
3336
- children: entries.map(
3451
+ "data-squeezed": fit.squeezed ? "" : void 0,
3452
+ onKeyDown,
3453
+ children: shown.map(
3337
3454
  (entry) => entry.type === "tab" ? tabView(entry.tab) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3338
3455
  "div",
3339
3456
  {
@@ -3348,32 +3465,24 @@ function TabStrip({
3348
3465
  )
3349
3466
  }
3350
3467
  ),
3351
- overflow ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(TabListMenu, { tabs, activeItemId }) : null
3468
+ overflow ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(TabListMenu, { tabs, activeItemId, hidden }) : null
3352
3469
  ]
3353
3470
  }
3354
3471
  );
3355
3472
  }
3356
- function moveTabFocus(event) {
3357
- const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
3358
- if (!keys.includes(event.key) || event.altKey || event.ctrlKey || event.metaKey) return;
3359
- const tabs = [...event.currentTarget.querySelectorAll('[role="tab"]')];
3360
- const at = tabs.findIndex((tab) => tab === event.target || tab.contains(event.target));
3361
- if (at < 0 || tabs.length < 2) return;
3362
- event.preventDefault();
3363
- const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
3364
- const step = event.key === "ArrowRight" !== rtl ? 1 : -1;
3365
- const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (at + step + tabs.length) % tabs.length;
3366
- const target = tabs[next];
3367
- target?.focus();
3368
- target?.click();
3369
- }
3370
- function TabListMenu({ tabs, activeItemId }) {
3473
+ function tabElement(list, itemId) {
3474
+ return [...list?.querySelectorAll("[data-mxc-tab]") ?? []].find((node) => node.dataset.mxcTab === itemId);
3475
+ }
3476
+ function TabListMenu({ tabs, activeItemId, hidden }) {
3371
3477
  const canvas = useCanvas();
3372
3478
  const { ports, layer } = useCanvasInternals();
3373
3479
  const blocking = (0, import_react9.useRef)(null);
3374
3480
  (0, import_react9.useEffect)(() => () => blocking.current?.(), []);
3375
3481
  const label = `All tabs (${tabs.length})`;
3376
- const trigger = (props) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(CanvasHeaderButton, { className: "mxc-tab-list", label, ...props, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronDownIcon, {}) });
3482
+ const trigger = (props) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(CanvasHeaderButton, { className: "mxc-tab-list", label, ...props, children: [
3483
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "mxc-tab-list-count", "aria-hidden": "true", children: hidden }),
3484
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronDownIcon, {})
3485
+ ] });
3377
3486
  const showMenu = ports.showMenu;
3378
3487
  if (showMenu) {
3379
3488
  return trigger({
@@ -3491,10 +3600,9 @@ function PaneMenu({
3491
3600
  return [
3492
3601
  [...kindItems, ...bodyItems, ...hostItems].map((entry) => ({ ...entry, id: `item:${entry.id}` })),
3493
3602
  [
3494
- ...canSplit ? [
3495
- { id: "split-right", label: "Split right", icon: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(SplitRightIcon, {}), onSelect: () => canvas.splitPane(paneId, "horizontal", moveId) },
3496
- { id: "split-down", label: "Split down", icon: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(SplitDownIcon, {}), onSelect: () => canvas.splitPane(paneId, "vertical", moveId) }
3497
- ] : [],
3603
+ // Only a split that leaves both panes usable (the layout rules' minimums) is offered.
3604
+ ...canSplit && canvas.fitSplit(paneId, "horizontal") === "horizontal" ? [{ id: "split-right", label: "Split right", icon: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(SplitRightIcon, {}), onSelect: () => canvas.splitPane(paneId, "horizontal", moveId) }] : [],
3605
+ ...canSplit && canvas.fitSplit(paneId, "vertical") === "vertical" ? [{ id: "split-down", label: "Split down", icon: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(SplitDownIcon, {}), onSelect: () => canvas.splitPane(paneId, "vertical", moveId) }] : [],
3498
3606
  ...activeItem && role === "pop-out" ? [{ id: "dock-back", label: "Dock back", icon: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(DockIcon, {}), onSelect: () => void canvas.dockBack(activeItem.id) }] : activeItem && ports.popOut ? [{ id: "pop-out", label: "Pop out", icon: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(PopOutIcon, {}), onSelect: () => void canvas.popOut(activeItem.id) }] : []
3499
3607
  ],
3500
3608
  activeItem ? [
@@ -3603,6 +3711,15 @@ function CanvasColumn({ className, style, onLiveWidth, placement = "docked" }) {
3603
3711
  const [slots] = (0, import_react10.useState)(createCanvasSlots);
3604
3712
  const ref = (0, import_react10.useRef)(null);
3605
3713
  useRegisterCanvasPresence();
3714
+ (0, import_react10.useEffect)(
3715
+ () => controller.registerPaneMeasure((paneId) => {
3716
+ const section = slots.section(paneId);
3717
+ if (!section?.isConnected) return null;
3718
+ const rect = section.getBoundingClientRect();
3719
+ return rect.width > 0 && rect.height > 0 ? { width: rect.width, height: rect.height } : null;
3720
+ }),
3721
+ [controller, slots]
3722
+ );
3606
3723
  (0, import_react10.useEffect)(() => {
3607
3724
  const el = ref.current;
3608
3725
  if (!el) return void 0;
@@ -3724,28 +3841,42 @@ function LayoutNodeView({ node }) {
3724
3841
  if (node.type === "pane") return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(CanvasPaneView, { paneId: node.paneId });
3725
3842
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(SplitView, { node });
3726
3843
  }
3727
- function useMeasuredWidth(ref, enabled) {
3728
- const [width, setWidth] = (0, import_react10.useState)(0);
3844
+ function useMeasuredSize(ref, enabled) {
3845
+ const [size, setSize] = (0, import_react10.useState)({ width: 0, height: 0 });
3729
3846
  useIsoLayoutEffect5(() => {
3730
3847
  const el = ref.current;
3731
3848
  if (!enabled || !el) return;
3732
- setWidth(el.getBoundingClientRect().width);
3849
+ const read = () => {
3850
+ const rect = el.getBoundingClientRect();
3851
+ setSize((prev) => prev.width === rect.width && prev.height === rect.height ? prev : { width: rect.width, height: rect.height });
3852
+ };
3853
+ read();
3733
3854
  if (typeof ResizeObserver === "undefined") return;
3734
- const observer = new ResizeObserver(() => setWidth(el.getBoundingClientRect().width));
3855
+ const observer = new ResizeObserver(read);
3735
3856
  observer.observe(el);
3736
3857
  return () => observer.disconnect();
3737
3858
  }, [enabled]);
3738
- return width;
3859
+ return size;
3860
+ }
3861
+ function panesAlong(node, orientation) {
3862
+ if (node.type === "pane") return 1;
3863
+ const counts = node.children.map((child) => panesAlong(child, orientation));
3864
+ return node.orientation === orientation ? counts.reduce((a, b) => a + b, 0) : Math.max(...counts);
3865
+ }
3866
+ function childMinimums(node, orientation, rules) {
3867
+ const unit = orientation === "vertical" ? rules.paneMinHeight : rules.paneMinWidth;
3868
+ return node.children.map((child) => unit * panesAlong(child, orientation));
3739
3869
  }
3740
3870
  function SplitView({ node }) {
3741
3871
  const canvas = useCanvas();
3742
3872
  const ref = (0, import_react10.useRef)(null);
3743
3873
  const [liveSizes, setLiveSizes] = (0, import_react10.useState)(null);
3744
- const sizes = liveSizes ?? node.sizes;
3745
- const measuredWidth = useMeasuredWidth(ref, node.orientation === "horizontal" && canvas.rules.paneMinWidth > 0);
3746
- const stacked = node.orientation === "horizontal" && shouldStackSplit(measuredWidth, node.children.length, canvas.rules);
3874
+ const measured = useMeasuredSize(ref, canvas.rules.paneMinWidth > 0 || canvas.rules.paneMinHeight > 0);
3875
+ const stacked = node.orientation === "horizontal" && shouldStackSplit(measured.width, node.children.length, canvas.rules);
3747
3876
  const orientation = stacked ? "vertical" : node.orientation;
3748
3877
  const horizontal = orientation === "horizontal";
3878
+ const mins = childMinimums(node, orientation, canvas.rules);
3879
+ const sizes = fitCanvasSplitSizes(liveSizes ?? node.sizes, horizontal ? measured.width : measured.height, mins);
3749
3880
  const beginDrag = (index) => (event) => {
3750
3881
  const el = ref.current;
3751
3882
  if (!el) return;
@@ -3754,14 +3885,15 @@ function SplitView({ node }) {
3754
3885
  const rect = el.getBoundingClientRect();
3755
3886
  const total = horizontal ? rect.width : rect.height;
3756
3887
  const origin = horizontal ? event.clientX : event.clientY;
3757
- const base = [...node.sizes];
3888
+ const base = [...sizes];
3758
3889
  const pair = (base[index] ?? 0) + (base[index + 1] ?? 0);
3890
+ const minFirst = Math.max(CANVAS_MIN_SPLIT_FRACTION, total > 0 ? (mins[index] ?? 0) / total : 0);
3891
+ const minSecond = Math.max(CANVAS_MIN_SPLIT_FRACTION, total > 0 ? (mins[index + 1] ?? 0) / total : 0);
3759
3892
  const target = event.currentTarget;
3760
3893
  const end = beginCanvasResize(horizontal ? "col" : "row");
3761
3894
  const compute = (pos) => {
3762
3895
  const delta = (pos - origin) / total;
3763
- const min = CANVAS_MIN_SPLIT_FRACTION;
3764
- const first = Math.min(pair - min, Math.max(min, (base[index] ?? 0) + delta));
3896
+ const first = Math.max(Math.min(pair - minSecond, Math.max(minFirst, (base[index] ?? 0) + delta)), Math.min(minFirst, pair / 2));
3765
3897
  const next = [...base];
3766
3898
  next[index] = first;
3767
3899
  next[index + 1] = pair - first;