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.
- package/CHANGELOG.md +144 -41
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +98 -37
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/finalize.js +14 -1
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +19 -4
- package/dist/assets/store-s3.js +56 -13
- package/dist/assets/types.d.ts +26 -2
- package/dist/cli/cli.js +112 -34
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +58 -36
- package/dist/editor/EntryNavigator.d.ts +8 -1
- package/dist/editor/EntryNavigator.js +31 -16
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +11 -2
- package/dist/editor/PreviewFrame.js +20 -4
- package/dist/editor/PreviewPane.d.ts +15 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.js +6 -2
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +29 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +20 -7
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +14 -7
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +16 -0
- package/dist/editor/preview-bridge.js +12 -3
- package/dist/editor/preview-path.d.ts +11 -4
- package/dist/editor/preview-path.js +62 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.js +2 -0
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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,
|
|
104
|
-
|
|
105
|
-
|
|
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: {
|
|
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
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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:
|
|
16
|
-
width:
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
paddingInlineEnd: DIVIDER_WIDTH_PX,
|
|
174
211
|
};
|
|
175
|
-
return (_jsxs("section", { ref:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
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
|
}
|
package/dist/editor/theme.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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,
|
|
48
|
-
*
|
|
49
|
-
* Edits made after the removal are kept, because it changes only the one list or
|
|
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.
|