canopycms 0.1.0 → 0.2.0-int.119

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -4,6 +4,22 @@ import { useRef } from 'react';
4
4
  import { Portal, getDefaultZIndex } from '@mantine/core';
5
5
  import { RemoveScroll } from 'react-remove-scroll';
6
6
  import { useHideHostChrome } from './host-chrome.js';
7
+ /** The line between two shell regions, as under the top bar and a shown banner. */
8
+ export const SHELL_REGION_BORDER = '1px solid var(--canopy-input-border)';
9
+ /**
10
+ * Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
11
+ * Its pointer target is wider, `DIVIDER_TARGET_PX`. A finger's reaches 6px past each side of the
12
+ * bar: under the panes' separator, the narrow window's overlay panel keeps its header's close
13
+ * button clear, as that header's top padding is 10px.
14
+ */
15
+ export const DIVIDER_WIDTH_PX = 4;
16
+ export const DIVIDER_TARGET_PX = { fine: 12, coarse: 16 };
17
+ /** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
18
+ export const DIVIDER_COLOR = 'var(--canopy-input-border)';
19
+ // A banner component may render nothing, so the region's line follows its content, not the prop.
20
+ const SHELL_STYLES = `
21
+ [data-canopy-region='banner']:not(:empty) { border-block-end: ${SHELL_REGION_BORDER}; }
22
+ `;
7
23
  const gridStyle = {
8
24
  position: 'fixed',
9
25
  inset: 0,
@@ -27,5 +43,5 @@ const region = (area) => ({ gridArea: area, minWidth: 0, minHeight: 0 });
27
43
  export function EditorShell({ topBar, banner, rail, panel, main, overlays }) {
28
44
  const anchorRef = useRef(null);
29
45
  useHideHostChrome(anchorRef);
30
- return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), _jsx("div", { style: region('banner'), "data-canopy-region": "banner", children: banner }), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
46
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [_jsx("style", { children: SHELL_STYLES }), topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), _jsx("div", { style: region('banner'), "data-canopy-region": "banner", children: banner }), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
31
47
  }
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import { type DrawerRootProps } from '@mantine/core';
3
+ interface OverlayHeaderProps {
4
+ title: React.ReactNode;
5
+ /** Sits beside the title and is part of its accessible name, such as a status badge. */
6
+ adornment?: React.ReactNode;
7
+ /** For a drawer that moves focus to its title. */
8
+ titleRef?: React.Ref<HTMLHeadingElement>;
9
+ titleTabIndex?: number;
10
+ }
11
+ export interface OverlayDrawerProps extends Omit<DrawerRootProps, 'children' | 'title' | 'padding'>, OverlayHeaderProps {
12
+ children: React.ReactNode;
13
+ }
14
+ /**
15
+ * A Mantine `Drawer` with an `OverlayHeader`, a blurred backdrop and a body padded under the rule.
16
+ * It is a `Drawer.Root`, so theme overrides for it go under `DrawerRoot`, not `Drawer`.
17
+ */
18
+ export declare const OverlayDrawer: React.FC<OverlayDrawerProps>;
19
+ export {};
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ import { Drawer, Group } from '@mantine/core';
4
+ /**
5
+ * The header of every right-hand drawer, sized and ruled like the content panel's header in
6
+ * `SidePanelHost`: a 16px semibold title, a default-size close button, `md` by `xs` padding and a
7
+ * 1px bottom rule. It is the drawer's own `Drawer.Header`, so the title labels the dialog and the
8
+ * header stays pinned while the body scrolls.
9
+ */
10
+ const OverlayHeader = ({ title, adornment, titleRef, titleTabIndex, }) => (_jsxs(Drawer.Header, { mih: 0, px: "md", py: "xs", style: { borderBlockEnd: '1px solid var(--canopy-input-border)' }, children: [_jsx(Drawer.Title, { ref: titleRef, tabIndex: titleTabIndex, fw: 600, fz: "md", miw: 0, children: _jsxs(Group, { component: "span", gap: "xs", wrap: "nowrap", children: [title, adornment] }) }), _jsx(Drawer.CloseButton, { "aria-label": "Close" })] }));
11
+ /**
12
+ * A Mantine `Drawer` with an `OverlayHeader`, a blurred backdrop and a body padded under the rule.
13
+ * It is a `Drawer.Root`, so theme overrides for it go under `DrawerRoot`, not `Drawer`.
14
+ */
15
+ export const OverlayDrawer = ({ title, adornment, titleRef, titleTabIndex, children, ...root }) => (_jsxs(Drawer.Root, { padding: "md", ...root, children: [_jsx(Drawer.Overlay, { blur: 2 }), _jsxs(Drawer.Content, { children: [_jsx(OverlayHeader, { title: title, adornment: adornment, titleRef: titleRef, titleTabIndex: titleTabIndex }), _jsx(Drawer.Body, { pt: "md", children: children })] })] }));
@@ -17,6 +17,11 @@ export interface PaneLayoutProps {
17
17
  form?: React.ReactNode;
18
18
  /** Laid over the form pane, outside its scrolling: the side panel in overlay mode. */
19
19
  formOverlay?: React.ReactNode;
20
+ /**
21
+ * Stacked arrangement only: docked at the form pane's inline start, pushing the form aside, so
22
+ * the preview above keeps its full width. A maximized preview leaves it beside it, full height.
23
+ */
24
+ formAside?: React.ReactNode;
20
25
  /** Takes the form out of focus and the pointer's reach while an open `formOverlay` hides it. */
21
26
  formInert?: boolean;
22
27
  /** Lays the preview over the whole layout, form and separator inert, without remounting it. */
@@ -25,4 +30,4 @@ export interface PaneLayoutProps {
25
30
  onSeparatorDrag?: (dragging: boolean) => void;
26
31
  }
27
32
  /** The resizable preview/form split that fills the shell's main region. */
28
- export declare function PaneLayout({ arrangement, sideSplitPercent, stackedSplitPercent, onSplitPercentChange, preview, form, formOverlay, formInert, previewMaximized, onSeparatorDrag, }: PaneLayoutProps): import("react/jsx-runtime").JSX.Element;
33
+ export declare function PaneLayout({ arrangement, sideSplitPercent, stackedSplitPercent, onSplitPercentChange, preview, form, formOverlay, formAside, formInert, previewMaximized, onSeparatorDrag, }: PaneLayoutProps): import("react/jsx-runtime").JSX.Element;
@@ -1,8 +1,9 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useLayoutEffect, useRef } from 'react';
4
4
  import { Box } from '@mantine/core';
5
5
  import { Group, Panel, Separator, useGroupRef } from 'react-resizable-panels';
6
+ import { DIVIDER_COLOR, DIVIDER_TARGET_PX, DIVIDER_WIDTH_PX } from './EditorShell.js';
6
7
  export const SPLIT_PERCENT_MIN = 15;
7
8
  export const SPLIT_PERCENT_MAX = 85;
8
9
  export const DEFAULT_SIDE_SPLIT_PERCENT = 52;
@@ -13,29 +14,35 @@ const FORM = 'canopy-form';
13
14
  // the form also keeps a usable minimum in a narrow window.
14
15
  const PREVIEW_LIMITS = { minSize: `${SPLIT_PERCENT_MIN}%`, maxSize: `${SPLIT_PERCENT_MAX}%` };
15
16
  const FORM_MIN_PX = 160;
17
+ const ASIDE_TOP = '--canopy-form-aside-top';
18
+ const ASIDE_WIDTH = '--canopy-form-aside-width';
19
+ // The pointer target is the group's `resizeTargetMinimumSize`, wider than the bar it draws. Lifted
20
+ // over the panes' positioned contents, which would otherwise paint over its focus outline.
16
21
  const SEPARATOR_STYLES = `
17
22
  .canopy-pane-separator {
18
- min-width: 8px;
19
- min-height: 8px;
20
- background: var(--canopy-surface-muted);
21
- box-shadow: inset 0 0 0 1px var(--canopy-input-border);
23
+ position: relative;
24
+ z-index: 1;
25
+ min-width: ${DIVIDER_WIDTH_PX}px;
26
+ min-height: ${DIVIDER_WIDTH_PX}px;
27
+ background: ${DIVIDER_COLOR};
22
28
  }
23
- .canopy-pane-separator:is([data-separator='hover'], [data-separator='active']) {
29
+ .canopy-pane-separator:is([data-separator='hover'], [data-separator='active'], :focus-visible) {
24
30
  background: var(--canopy-focus-ring);
25
31
  }
26
32
  .canopy-pane-separator:focus-visible {
27
33
  outline: 2px solid var(--canopy-focus-ring);
28
- outline-offset: -2px;
29
34
  }
30
35
  `;
31
36
  const splitLayout = (percent) => ({ [PREVIEW]: percent, [FORM]: 100 - percent });
32
37
  /** The resizable preview/form split that fills the shell's main region. */
33
- export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_PERCENT, stackedSplitPercent = DEFAULT_STACKED_SPLIT_PERCENT, onSplitPercentChange, preview, form, formOverlay, formInert = false, previewMaximized = false, onSeparatorDrag, }) {
38
+ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_PERCENT, stackedSplitPercent = DEFAULT_STACKED_SPLIT_PERCENT, onSplitPercentChange, preview, form, formOverlay, formAside, formInert = false, previewMaximized = false, onSeparatorDrag, }) {
34
39
  const groupRef = useGroupRef();
35
40
  const separatorRef = useRef(null);
36
41
  const previewRef = useRef(null);
37
42
  const formRef = useRef(null);
38
43
  const formPanelRef = useRef(null);
44
+ const layoutRef = useRef(null);
45
+ const asideProbeRef = useRef(null);
39
46
  const percent = arrangement === 'side' ? sideSplitPercent : stackedSplitPercent;
40
47
  // Stored sizes load after mount, and the group must never remount: that would reload the preview.
41
48
  useEffect(() => {
@@ -53,6 +60,36 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
53
60
  formPanelRef.current?.toggleAttribute('inert', previewMaximized);
54
61
  separatorRef.current?.toggleAttribute('inert', previewMaximized);
55
62
  }, [formInert, previewMaximized]);
63
+ const aside = arrangement === 'stacked' ? formAside : undefined;
64
+ const hasAside = Boolean(aside);
65
+ // The aside precedes the panes in the DOM, so focus reaches it first, as it reaches the shell's
66
+ // panel column first side by side. It is placed from the form row's measured top, and the form
67
+ // and a maximized preview start at its measured width. Written straight to CSS variables, so a
68
+ // drag moves them in the frame it moves the split.
69
+ useLayoutEffect(() => {
70
+ const layout = layoutRef.current;
71
+ if (!layout || !hasAside)
72
+ return;
73
+ const measure = () => {
74
+ const top = layout.getBoundingClientRect().top;
75
+ const formTop = formPanelRef.current?.getBoundingClientRect().top ?? top;
76
+ layout.style.setProperty(ASIDE_TOP, `${formTop - top}px`);
77
+ const width = asideProbeRef.current?.getBoundingClientRect().width ?? 0;
78
+ layout.style.setProperty(ASIDE_WIDTH, `${width}px`);
79
+ };
80
+ measure();
81
+ // The probe has no height, so moving the aside's top never resizes what is observed.
82
+ const observer = new ResizeObserver(measure);
83
+ for (const element of [layout, formPanelRef.current, asideProbeRef.current]) {
84
+ if (element)
85
+ observer.observe(element);
86
+ }
87
+ return () => {
88
+ observer.disconnect();
89
+ layout.style.removeProperty(ASIDE_TOP);
90
+ layout.style.removeProperty(ASIDE_WIDTH);
91
+ };
92
+ }, [hasAside]);
56
93
  const onSeparatorDragRef = useRef(onSeparatorDrag);
57
94
  useEffect(() => {
58
95
  onSeparatorDragRef.current = onSeparatorDrag;
@@ -84,7 +121,19 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
84
121
  return () => observer.disconnect();
85
122
  }, [groupRef]);
86
123
  const side = arrangement === 'side';
87
- return (_jsxs(_Fragment, { children: [_jsx("style", { children: SEPARATOR_STYLES }), _jsxs(Group, { groupRef: groupRef, orientation: side ? 'horizontal' : 'vertical', defaultLayout: splitLayout(percent),
124
+ // A maximized preview leaves a docked `aside` beside it, now the layout's full height, as the
125
+ // shell's own panel column stays beside it in the side arrangement.
126
+ const maximizedStyle = aside
127
+ ? { insetBlock: 0, insetInlineEnd: 0, insetInlineStart: `var(${ASIDE_WIDTH}, 0px)` }
128
+ : { inset: 0 };
129
+ return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children: SEPARATOR_STYLES }), aside && (_jsxs("div", { "data-testid": "form-aside", "data-lifted": previewMaximized || undefined, style: {
130
+ position: 'absolute',
131
+ insetInlineStart: 0,
132
+ bottom: 0,
133
+ top: previewMaximized ? 0 : `var(${ASIDE_TOP}, 0px)`,
134
+ // Over a maximized preview, which would cover its separator's target and focus ring.
135
+ zIndex: previewMaximized ? 3 : 1,
136
+ }, children: [aside, _jsx("div", { ref: asideProbeRef, "aria-hidden": true, style: { height: 0 } })] })), _jsxs(Group, { groupRef: groupRef, orientation: side ? 'horizontal' : 'vertical', resizeTargetMinimumSize: DIVIDER_TARGET_PX, defaultLayout: splitLayout(percent),
88
137
  // The hidden separator would otherwise still take drags over a maximized preview: the
89
138
  // library hit-tests separators by their rectangles, which `inert` leaves in place.
90
139
  disabled: previewMaximized, onLayoutChange: (layout) => {
@@ -100,11 +149,17 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
100
149
  onSplitPercentChange?.(arrangement, next);
101
150
  },
102
151
  // The maximized preview's containing block: the panels themselves are unpositioned.
103
- style: { height: '100%', width: '100%', position: 'relative' }, children: [_jsx(Panel, { id: PREVIEW, ...PREVIEW_LIMITS, children: _jsx(Box, { ref: previewRef, "data-testid": "preview-pane", "data-maximized": previewMaximized || undefined, h: "100%", w: "100%", style: previewMaximized
104
- ? { overflow: 'auto', position: 'absolute', inset: 0, zIndex: 1 }
105
- : { overflow: 'auto' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, pos: "relative", h: "100%", w: "100%",
152
+ style: { height: '100%', width: '100%', position: 'relative' }, children: [_jsx(Panel, { id: PREVIEW, ...PREVIEW_LIMITS, children: _jsx(Box, { ref: previewRef, "data-testid": "preview-pane", "data-maximized": previewMaximized || undefined,
153
+ // Sized in `style`, not by props: Mantine's style props would override the insets.
154
+ style: previewMaximized
155
+ ? // Over the separator, which sits over the panes.
156
+ { overflow: 'auto', position: 'absolute', zIndex: 2, ...maximizedStyle }
157
+ : { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, pos: "relative", h: "100%",
106
158
  // `formOverlay` stacks within this pane, so a maximized preview always covers it.
107
- style: { isolation: 'isolate' }, children: [_jsx(Box, { ref: formRef, "data-testid": "form-pane", h: "100%", w: "100%", style: {
159
+ style: {
160
+ isolation: 'isolate',
161
+ marginInlineStart: aside ? `var(${ASIDE_WIDTH}, 0px)` : undefined,
162
+ }, children: [_jsx(Box, { ref: formRef, "data-testid": "form-pane", h: "100%", w: "100%", style: {
108
163
  // FormHeader reads this to span the padding.
109
164
  '--canopy-form-pane-padding': '20px',
110
165
  padding: 'var(--canopy-form-pane-padding)',
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
- /** Docked beside the main region, or laid over the form pane only. */
2
+ /** Docked, pushing its neighbours aside, or laid over the form pane only. */
3
3
  type SidePanelMode = 'docked' | 'overlay';
4
4
  export interface SidePanelHostProps {
5
5
  title: string;
@@ -26,10 +26,12 @@ export interface SidePanelHostProps {
26
26
  'data-testid'?: string;
27
27
  }
28
28
  /**
29
- * A titled side panel, resizable from its inline-end edge. Docked, it is a column of the shell;
30
- * as an overlay it covers the form pane only, so the preview stays in view, and Escape closes it.
31
- * A `focusRequest` moves focus to its `data-autofocus` element, else to the panel; closing with
32
- * focus inside returns focus to `returnFocusTo`.
29
+ * A titled side panel, resizable from its inline-end edge. Docked, it pushes its neighbours aside
30
+ * and opens and closes at once: a sliding width would reflow the preview's page on every frame.
31
+ * As an overlay it covers the form pane only, so the preview stays in view, slides in and out as
32
+ * the drawers do unless the user prefers reduced motion, and Escape closes it. A `focusRequest`
33
+ * moves focus to its `data-autofocus` element, else to the panel; closing with focus inside
34
+ * returns focus to `returnFocusTo`.
33
35
  */
34
- export declare function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest, returnFocusTo, actions, children, id, 'data-testid': testId, }: SidePanelHostProps): import("react/jsx-runtime").JSX.Element | null;
36
+ export declare function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest, returnFocusTo, actions, children, id, 'data-testid': testId, }: SidePanelHostProps): import("react/jsx-runtime").JSX.Element;
35
37
  export {};
@@ -1,58 +1,89 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, useState, } from 'react';
4
- import { CloseButton, Group, Text, Tooltip } from '@mantine/core';
5
- import { useWindowEvent } from '@mantine/hooks';
6
- import { focusIfIdle } from '../utils/focus-return.js';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useId, useLayoutEffect, useRef, useState, } from 'react';
4
+ import { CloseButton, Group, Text, Tooltip, Transition } from '@mantine/core';
5
+ import { useReducedMotion, useWindowEvent } from '@mantine/hooks';
6
+ import { FOCUS_RETURN_ATTR, focusIfIdle } from '../utils/focus-return.js';
7
+ import { DIVIDER_COLOR, DIVIDER_TARGET_PX, DIVIDER_WIDTH_PX } from './EditorShell.js';
7
8
  const KEY_STEP_PX = 16;
8
9
  const RESIZING_ATTR = 'data-canopy-panel-resizing';
10
+ /** The drawers' slide: Mantine's `Transition` default. */
11
+ const SLIDE_MS = 250;
12
+ const targetOverhang = (target) => (target - DIVIDER_WIDTH_PX) / 2;
13
+ // The separator is the panel's last pixels; its `::before` is the wider pointer target.
9
14
  const PANEL_STYLES = `
10
15
  body[${RESIZING_ATTR}] { cursor: col-resize; user-select: none; }
11
16
  body[${RESIZING_ATTR}] iframe { pointer-events: none; }
12
17
  .canopy-side-panel-separator {
13
18
  position: absolute;
14
19
  inset-block: 0;
15
- inset-inline-end: -3px;
16
- width: 6px;
20
+ inset-inline-end: 0;
21
+ width: ${DIVIDER_WIDTH_PX}px;
22
+ background: ${DIVIDER_COLOR};
17
23
  cursor: col-resize;
18
24
  touch-action: none;
19
25
  z-index: 1;
20
26
  }
27
+ .canopy-side-panel-separator::before {
28
+ content: '';
29
+ position: absolute;
30
+ inset-block: 0;
31
+ inset-inline: -${targetOverhang(DIVIDER_TARGET_PX.fine)}px;
32
+ }
33
+ @media (pointer: coarse) {
34
+ .canopy-side-panel-separator::before {
35
+ inset-inline: -${targetOverhang(DIVIDER_TARGET_PX.coarse)}px;
36
+ }
37
+ }
21
38
  .canopy-side-panel-separator:hover,
39
+ .canopy-side-panel-separator:focus-visible,
22
40
  .canopy-side-panel-separator[data-dragging] {
23
41
  background: var(--canopy-focus-ring);
24
42
  }
25
43
  .canopy-side-panel-separator:focus-visible {
26
44
  outline: 2px solid var(--canopy-focus-ring);
27
- outline-offset: -2px;
28
45
  }
29
46
  `;
30
47
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
31
48
  /**
32
- * A titled side panel, resizable from its inline-end edge. Docked, it is a column of the shell;
33
- * as an overlay it covers the form pane only, so the preview stays in view, and Escape closes it.
34
- * A `focusRequest` moves focus to its `data-autofocus` element, else to the panel; closing with
35
- * focus inside returns focus to `returnFocusTo`.
49
+ * A titled side panel, resizable from its inline-end edge. Docked, it pushes its neighbours aside
50
+ * and opens and closes at once: a sliding width would reflow the preview's page on every frame.
51
+ * As an overlay it covers the form pane only, so the preview stays in view, slides in and out as
52
+ * the drawers do unless the user prefers reduced motion, and Escape closes it. A `focusRequest`
53
+ * moves focus to its `data-autofocus` element, else to the panel; closing with focus inside
54
+ * returns focus to `returnFocusTo`.
36
55
  */
37
56
  export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest = 0, returnFocusTo, actions, children, id, 'data-testid': testId, }) {
38
57
  const fallbackId = useId();
39
58
  const panelId = id ?? fallbackId;
40
59
  const titleId = `${panelId}-title`;
41
- const panelRef = useRef(null);
60
+ // State, not a ref: an overlay's section mounts a frame after `opened`, as its slide starts.
61
+ const [panel, setPanel] = useState(null);
42
62
  const focusInside = useRef(false);
63
+ const closingWithFocus = useRef(false);
43
64
  const wasOpened = useRef(opened);
44
65
  const servedFocusRequest = useRef(focusRequest);
66
+ const reduceMotion = useReducedMotion();
67
+ // The slide's direction, read from the page as each opening starts, as the resize keys read it.
68
+ const anchorRef = useRef(null);
69
+ const [rtl, setRtl] = useState(false);
70
+ useLayoutEffect(() => {
71
+ const anchor = anchorRef.current;
72
+ if (opened && anchor)
73
+ setRtl(getComputedStyle(anchor).direction === 'rtl');
74
+ }, [opened]);
45
75
  const drag = useRef(null);
46
76
  const [dragWidth, setDragWidth] = useState(null);
47
77
  if (!opened && dragWidth !== null)
48
78
  setDragWidth(null);
49
79
  const shownWidth = clamp(dragWidth ?? width, minWidth, maxWidth);
50
80
  useEffect(() => {
51
- const panel = panelRef.current;
52
- const asked = opened && focusRequest !== servedFocusRequest.current;
53
- servedFocusRequest.current = focusRequest;
54
- if (!asked || !panel)
81
+ // A request made while closed lapses; one made as it opens waits for the section to mount.
82
+ if (!opened)
83
+ servedFocusRequest.current = focusRequest;
84
+ if (!opened || !panel || focusRequest === servedFocusRequest.current)
55
85
  return;
86
+ servedFocusRequest.current = focusRequest;
56
87
  const autofocus = () => panel.querySelector('[data-autofocus]');
57
88
  const field = autofocus();
58
89
  if (field) {
@@ -73,13 +104,21 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
73
104
  });
74
105
  observer.observe(panel, { childList: true, subtree: true });
75
106
  return () => observer.disconnect();
76
- }, [opened, focusRequest]);
107
+ }, [opened, focusRequest, panel]);
108
+ // A sliding-out overlay is inert, so it neither holds focus nor takes the pointer. Whether focus
109
+ // was inside is read first, as `inert` can blur it.
110
+ useLayoutEffect(() => {
111
+ if (!opened && wasOpened.current)
112
+ closingWithFocus.current = focusInside.current;
113
+ panel?.toggleAttribute('inert', !opened);
114
+ }, [opened, panel]);
77
115
  useEffect(() => {
78
116
  if (!opened && wasOpened.current) {
79
- // Its separator is gone, and with it the pointer capture that would have ended a drag.
117
+ // Its separator is gone or inert, and with it the pointer capture that would end a drag.
80
118
  drag.current = null;
81
119
  document.body.removeAttribute(RESIZING_ATTR);
82
- if (focusInside.current) {
120
+ if (closingWithFocus.current) {
121
+ closingWithFocus.current = false;
83
122
  focusInside.current = false;
84
123
  focusIfIdle(returnFocusTo?.() ?? null);
85
124
  }
@@ -94,15 +133,13 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
94
133
  event.key === 'Escape' &&
95
134
  !event.defaultPrevented &&
96
135
  event.target instanceof Node &&
97
- panelRef.current?.contains(event.target)) {
136
+ panel?.contains(event.target)) {
98
137
  event.preventDefault();
99
138
  onClose();
100
139
  }
101
140
  });
102
141
  // A drag cut short by unmounting must not leave the page's iframes deaf to the pointer.
103
142
  useEffect(() => () => document.body.removeAttribute(RESIZING_ATTR), []);
104
- if (!opened)
105
- return null;
106
143
  const endDrag = () => {
107
144
  const state = drag.current;
108
145
  if (!state)
@@ -170,15 +207,15 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
170
207
  display: 'flex',
171
208
  flexDirection: 'column',
172
209
  background: 'var(--mantine-color-body)',
173
- borderInlineEnd: '1px solid var(--canopy-input-border)',
210
+ paddingInlineEnd: DIVIDER_WIDTH_PX,
174
211
  };
175
- return (_jsxs("section", { ref: panelRef, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, style: style, onFocus: () => {
176
- focusInside.current = true;
177
- }, onBlur: (event) => {
178
- if (!event.currentTarget.contains(event.relatedTarget))
179
- focusInside.current = false;
180
- }, children: [_jsx("style", { children: PANEL_STYLES }), _jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", px: "md", py: "xs", style: { borderBlockEnd: '1px solid var(--canopy-input-border)' }, children: [_jsx(Text, { id: titleId, component: "h2", fw: 600, size: "md", m: 0, children: title }), _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [actions, _jsx(Tooltip, { label: `Close ${title.toLowerCase()} panel`, children: _jsx(CloseButton, { "aria-label": `Close ${title.toLowerCase()} panel`, onClick: onClose }) })] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }), _jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${title.toLowerCase()} panel`, "aria-controls": panelId, "aria-valuenow": Math.round(shownWidth), "aria-valuemin": minWidth, "aria-valuemax": maxWidth,
181
- // A focusable separator is a window splitter, a widget; jsx-a11y counts every one static.
182
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
183
- tabIndex: 0, className: "canopy-side-panel-separator", "data-dragging": dragWidth !== null || undefined, ...separatorHandlers })] }));
212
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), _jsx(Transition, { mounted: opened, transition: rtl ? 'slide-left' : 'slide-right', duration: overlay && !reduceMotion ? SLIDE_MS : 0, children: (transitionStyles) => (_jsxs("section", { ref: setPanel, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, [FOCUS_RETURN_ATTR]: opened ? undefined : '', style: { ...style, ...transitionStyles }, onFocus: () => {
213
+ focusInside.current = true;
214
+ }, onBlur: (event) => {
215
+ if (!event.currentTarget.contains(event.relatedTarget))
216
+ focusInside.current = false;
217
+ }, children: [_jsx("style", { children: PANEL_STYLES }), _jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", px: "md", py: "xs", style: { borderBlockEnd: '1px solid var(--canopy-input-border)' }, children: [_jsx(Text, { id: titleId, component: "h2", fw: 600, size: "md", m: 0, children: title }), _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [actions, _jsx(Tooltip, { label: `Close ${title.toLowerCase()} panel`, children: _jsx(CloseButton, { "aria-label": `Close ${title.toLowerCase()} panel`, onClick: onClose }) })] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }), _jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${title.toLowerCase()} panel`, "aria-controls": panelId, "aria-valuenow": Math.round(shownWidth), "aria-valuemin": minWidth, "aria-valuemax": maxWidth,
218
+ // A focusable separator is a window splitter, a widget; jsx-a11y counts every one static.
219
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
220
+ tabIndex: 0, className: "canopy-side-panel-separator", "data-dragging": dragWidth !== null || undefined, ...separatorHandlers })] })) })] }));
184
221
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Box, Group } from '@mantine/core';
4
+ import { SHELL_REGION_BORDER } from './EditorShell.js';
4
5
  const TOP_BAR_HEIGHT = 48;
5
6
  /**
6
7
  * The shell's top bar: one 48px row from `md` up. Narrower, `leading` takes a row of its own
@@ -8,7 +9,7 @@ const TOP_BAR_HEIGHT = 48;
8
9
  */
9
10
  export function TopBar({ leading, trailing }) {
10
11
  return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", px: "sm", py: 6, mih: TOP_BAR_HEIGHT, style: {
11
- borderBottom: '1px solid var(--canopy-input-border)',
12
+ borderBottom: SHELL_REGION_BORDER,
12
13
  background: 'var(--mantine-color-body)',
13
14
  }, children: [_jsx(Box, { flex: { base: '1 0 100%', md: '1 1 0%' }, miw: 0, children: leading }), _jsx(Group, { gap: 6, wrap: "wrap", align: "center", justify: "flex-end", flex: { base: '1 0 100%', md: '0 1 auto' }, miw: 0, children: trailing })] }));
14
15
  }
@@ -254,6 +254,7 @@ const primaryFill = (theme, scheme) => {
254
254
  * dark, at 3:1 on gray.1 or dark.5. Mantine draws focus rings, focused borders and a checked
255
255
  * Switch in `--mantine-primary-color-filled`, so that becomes the ring; `canopyComponents` points
256
256
  * Checkbox, Radio and Tabs at it. A tick on it is white, or black where white is under 3:1.
257
+ * `--canopy-unsaved-mark` is the same colour, named for the dot that marks unsaved changes.
257
258
  */
258
259
  const focusRing = (theme, scheme) => {
259
260
  const scale = theme.colors[theme.primaryColor];
@@ -274,6 +275,7 @@ const focusRing = (theme, scheme) => {
274
275
  ? ring.value
275
276
  : `var(--mantine-color-${theme.primaryColor}-${ring.shade})`,
276
277
  '--canopy-focus-ring-contrast': contrast,
278
+ '--canopy-unsaved-mark': 'var(--canopy-focus-ring)',
277
279
  '--mantine-primary-color-filled': 'var(--canopy-focus-ring)',
278
280
  };
279
281
  };
@@ -3,12 +3,18 @@ import type { CommentThread } from '../comment-store.js';
3
3
  import { type CanopyPathSegment } from './canopy-path.js';
4
4
  /**
5
5
  * One list a removal sits inside (a block list, or an object list holding the removed item's
6
- * list): the index of the item enclosing the removal, and every other item.
6
+ * list): the index of the item enclosing the removal, and that item's `ITEM_KEY`, which an edit
7
+ * or a move keeps. An item with no usable key is anchored by every other item's content instead.
7
8
  */
8
- export interface RemovalAnchor {
9
+ export type RemovalAnchor = {
9
10
  index: number;
11
+ } & ({
12
+ key: string;
13
+ siblings?: never;
14
+ } | {
15
+ key?: never;
10
16
  siblings: unknown[];
11
- }
17
+ });
12
18
  /**
13
19
  * What a form's Remove took out, for Undo. Paths are canopy paths (`blocks[2].items`), which skip
14
20
  * a block's `value` wrapper; `restoreRemoval` resolves them against the value it restores into.
@@ -30,11 +36,10 @@ export type FieldRemoval = ({
30
36
  }) & {
31
37
  /**
32
38
  * The enclosing lists as they were before the removal (`removalAnchors`). Undo puts nothing
33
- * back once one has changed, so an item never lands in whichever block now holds its index.
39
+ * back once another item holds an enclosing index, so an item never lands in whichever block
40
+ * now holds it; an edit to any item keeps a keyed anchor.
34
41
  */
35
42
  anchors?: RemovalAnchor[];
36
- /** `clear` for an optional object's Clear, which its toast calls clearing, not removing. */
37
- action?: 'remove' | 'clear';
38
43
  };
39
44
  /**
40
45
  * The canopy path `restoreRemoval` puts a removal back at: a list item returns to its old index,
@@ -43,13 +48,17 @@ export type FieldRemoval = ({
43
48
  export declare function restoredFieldPath(fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval): CanopyPathSegment[];
44
49
  /** The enclosing lists of a removal in `value`, taken before the removal applies; see `anchors`. */
45
50
  export declare function removalAnchors(fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval): RemovalAnchor[] | undefined;
51
+ /** Whether `restoreRemoval` would put the removal back, without building the restored value. */
52
+ export declare const canRestoreRemoval: (fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval) => boolean;
46
53
  /**
47
- * `value` with a removal put back, or undefined when it can't be: the list or field is gone, an
48
- * enclosing list has changed, the item is already there, or the image field has been filled.
49
- * Edits made after the removal are kept, because it changes only the one list or field, in the
50
- * value as it is now.
54
+ * `value` with a removal put back, or undefined when it can't be: the list or field is gone,
55
+ * another item holds an enclosing index, the item is already there, or the image field or object
56
+ * has been filled. Edits made after the removal are kept, because it changes only the one list or
57
+ * field, in the value as it is now.
51
58
  */
52
59
  export declare function restoreRemoval(fields: readonly FieldConfig[], value: Record<string, unknown>, removal: FieldRemoval): Record<string, unknown> | undefined;
60
+ /** Where a removal's moved threads went, said with its Undo; undefined when none moved. */
61
+ export declare const movedThreadsNote: (count: number) => string | undefined;
53
62
  /**
54
63
  * How many open field threads a removal at `path` moves to the entry: those inside it keyed to an
55
64
  * item at or below it. One keyed only above stays on its field, which a Clear leaves in place.