canopycms 0.1.0 → 0.2.0-int.121
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 +202 -43
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +118 -47
- 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/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +29 -10
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +25 -6
- package/dist/assets/store-s3.js +81 -19
- package/dist/assets/types.d.ts +33 -4
- package/dist/cli/cli.js +151 -56
- package/dist/config/types.d.ts +3 -2
- 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 +93 -39
- package/dist/editor/EntryNavigator.d.ts +17 -5
- package/dist/editor/EntryNavigator.js +66 -31
- 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 +13 -2
- package/dist/editor/PreviewFrame.js +62 -5
- package/dist/editor/PreviewPane.d.ts +17 -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 +8 -7
- package/dist/editor/components/BranchLockBanner.js +30 -3
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchRowLock.d.ts +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -0
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +14 -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/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- 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/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- 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/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- 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 +24 -9
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +18 -9
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- 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/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- 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 +23 -0
- package/dist/editor/preview-bridge.js +105 -4
- package/dist/editor/preview-path.d.ts +16 -4
- package/dist/editor/preview-path.js +80 -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.d.ts +1 -0
- package/dist/editor/theme.js +19 -1
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* hosts through `canopycms/preview`, so everything it reaches stays free of CSS and editor UI
|
|
5
5
|
* (`pnpm lint:bundle`); the editor's side is `PreviewFrame.tsx`.
|
|
6
6
|
*/
|
|
7
|
-
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
7
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
8
8
|
import { createFieldProps } from './field-props.js';
|
|
9
9
|
import { setPreviewAssetBase } from './preview-asset-base.js';
|
|
10
10
|
import { isSamePreviewPath } from './preview-path.js';
|
|
@@ -15,6 +15,7 @@ export const CANOPY_PREVIEW_HIGHLIGHT = 'canopycms:preview:highlight';
|
|
|
15
15
|
export const CANOPY_PREVIEW_READY = 'canopycms:preview:ready';
|
|
16
16
|
export const CANOPY_PREVIEW_ERROR = 'canopycms:preview:error';
|
|
17
17
|
export const CANOPY_PREVIEW_MARKS = 'canopycms:preview:marks';
|
|
18
|
+
export const CANOPY_PREVIEW_NAVIGATE = 'canopycms:preview:navigate';
|
|
18
19
|
/**
|
|
19
20
|
* Resolve a (possibly relative) URL to an origin for postMessage targeting.
|
|
20
21
|
* Falls back to the page's own origin, so same-origin editor/preview setups
|
|
@@ -73,9 +74,13 @@ export const useCanopyPreview = (opts) => {
|
|
|
73
74
|
const resolvedPath = resolvePreviewPath(opts.path);
|
|
74
75
|
const editorOrigin = opts.editorOrigin;
|
|
75
76
|
const bridgeOpts = { editorOrigin };
|
|
76
|
-
const { data, isLoading } = usePreviewData(resolvedPath, opts.initialData,
|
|
77
|
+
const { data, isLoading } = usePreviewData(resolvedPath, opts.initialData, {
|
|
78
|
+
editorOrigin,
|
|
79
|
+
siteChrome: opts.siteChrome,
|
|
80
|
+
});
|
|
77
81
|
const highlightEnabled = usePreviewHighlight(bridgeOpts);
|
|
78
82
|
usePreviewFocusEmitter(resolvedPath, bridgeOpts);
|
|
83
|
+
usePreviewLinkNavigation({ editorOrigin, highlighting: highlightEnabled });
|
|
79
84
|
const fieldProps = createFieldProps();
|
|
80
85
|
/**
|
|
81
86
|
* Report that the current draft fails to compile/render (the editor surfaces it
|
|
@@ -107,6 +112,7 @@ export const usePreviewData = (path, initialData, opts) => {
|
|
|
107
112
|
// Every key of the loading state is optional, so an empty object is a valid one.
|
|
108
113
|
const [isLoading, setIsLoading] = useState({});
|
|
109
114
|
const editorOrigin = opts?.editorOrigin;
|
|
115
|
+
const siteChrome = opts?.siteChrome === true;
|
|
110
116
|
useEffect(() => {
|
|
111
117
|
// Only listen when actually framed by an editor; a standalone page (including
|
|
112
118
|
// one opened via window.open from a hostile site) must never accept drafts.
|
|
@@ -133,10 +139,15 @@ export const usePreviewData = (path, initialData, opts) => {
|
|
|
133
139
|
// so the first postMessage from the parent may arrive before this listener is set up.
|
|
134
140
|
const target = resolveMessageOrigin(editorOrigin);
|
|
135
141
|
if (!isOpaqueOrigin(target)) {
|
|
136
|
-
|
|
142
|
+
const ready = {
|
|
143
|
+
type: CANOPY_PREVIEW_READY,
|
|
144
|
+
path,
|
|
145
|
+
...(siteChrome ? { siteChrome } : {}),
|
|
146
|
+
};
|
|
147
|
+
window.parent.postMessage(ready, target);
|
|
137
148
|
}
|
|
138
149
|
return () => window.removeEventListener('message', handler);
|
|
139
|
-
}, [path, editorOrigin]);
|
|
150
|
+
}, [path, editorOrigin, siteChrome]);
|
|
140
151
|
return { data, isLoading };
|
|
141
152
|
};
|
|
142
153
|
/** React 18 warns about a layout effect in a server render, where no effect runs. */
|
|
@@ -262,8 +273,96 @@ export const usePreviewHighlight = (opts) => {
|
|
|
262
273
|
}, [enabled, editorOrigin]);
|
|
263
274
|
return enabled;
|
|
264
275
|
};
|
|
276
|
+
const isPlainClick = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
|
277
|
+
const linkClick = (event) => {
|
|
278
|
+
// The composed path reaches a link inside a shadow root, which the retargeted `target` hides.
|
|
279
|
+
const anchor = event
|
|
280
|
+
.composedPath()
|
|
281
|
+
.find((node) => node instanceof Element && node.matches('a[href], area[href]'));
|
|
282
|
+
if (!anchor)
|
|
283
|
+
return undefined;
|
|
284
|
+
let url;
|
|
285
|
+
try {
|
|
286
|
+
url = new URL(anchor.getAttribute('href') ?? '', document.baseURI);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
292
|
+
return undefined;
|
|
293
|
+
const here = new URL(window.location.href);
|
|
294
|
+
if (url.href.split('#')[0] === here.href.split('#')[0])
|
|
295
|
+
return undefined;
|
|
296
|
+
const target = anchor.getAttribute('target') ?? document.querySelector('base[target]')?.getAttribute('target');
|
|
297
|
+
if (!isPlainClick(event) || (target && target !== '_self') || anchor.hasAttribute('download')) {
|
|
298
|
+
return { kind: 'browser' };
|
|
299
|
+
}
|
|
300
|
+
return { kind: url.origin === here.origin ? 'navigate' : 'open', href: url.href };
|
|
301
|
+
};
|
|
302
|
+
const isInMark = (event) => event
|
|
303
|
+
.composedPath()
|
|
304
|
+
.some((node) => node instanceof Element && node.hasAttribute('data-canopy-path'));
|
|
305
|
+
/** Clicks `usePreviewLinkNavigation` followed itself, so click-to-focus leaves them be. */
|
|
306
|
+
const followedLinkClicks = new WeakSet();
|
|
307
|
+
/**
|
|
308
|
+
* Hook for preview pages to keep link clicks off public pages that may refuse framing. Captured on
|
|
309
|
+
* the window, so it runs before the page's own handlers, a client router's included.
|
|
310
|
+
*/
|
|
311
|
+
const usePreviewLinkNavigation = (opts) => {
|
|
312
|
+
const editorOrigin = opts.editorOrigin;
|
|
313
|
+
const highlighting = useRef(opts.highlighting);
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
highlighting.current = opts.highlighting;
|
|
316
|
+
});
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (window.parent === window)
|
|
319
|
+
return;
|
|
320
|
+
const targetOrigin = resolveMessageOrigin(editorOrigin);
|
|
321
|
+
if (isOpaqueOrigin(targetOrigin))
|
|
322
|
+
return;
|
|
323
|
+
// Links stay the browser's until the editor speaks: a frame that isn't a trusted editor, such
|
|
324
|
+
// as a site embedding a public page, would drop the message and leave every link dead.
|
|
325
|
+
let connected = false;
|
|
326
|
+
const handleMessage = (event) => {
|
|
327
|
+
const type = event.data?.type;
|
|
328
|
+
if (type !== CANOPY_PREVIEW_HIGHLIGHT && type !== CANOPY_PREVIEW_MESSAGE)
|
|
329
|
+
return;
|
|
330
|
+
if (isTrustedEditorMessage(event, editorOrigin))
|
|
331
|
+
connected = true;
|
|
332
|
+
};
|
|
333
|
+
const handleClick = (event) => {
|
|
334
|
+
if (!connected || event.defaultPrevented)
|
|
335
|
+
return;
|
|
336
|
+
const link = linkClick(event);
|
|
337
|
+
if (!link)
|
|
338
|
+
return;
|
|
339
|
+
// While fields are outlined, a marked link is a field to pick, which click-to-focus does.
|
|
340
|
+
if (highlighting.current && isPlainClick(event) && isInMark(event)) {
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (link.kind === 'browser')
|
|
345
|
+
return;
|
|
346
|
+
event.preventDefault();
|
|
347
|
+
followedLinkClicks.add(event);
|
|
348
|
+
if (link.kind === 'open') {
|
|
349
|
+
window.open(link.href, '_blank', 'noopener,noreferrer');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const msg = { type: CANOPY_PREVIEW_NAVIGATE, href: link.href };
|
|
353
|
+
window.parent.postMessage(msg, targetOrigin);
|
|
354
|
+
};
|
|
355
|
+
window.addEventListener('message', handleMessage);
|
|
356
|
+
window.addEventListener('click', handleClick, true);
|
|
357
|
+
return () => {
|
|
358
|
+
window.removeEventListener('message', handleMessage);
|
|
359
|
+
window.removeEventListener('click', handleClick, true);
|
|
360
|
+
};
|
|
361
|
+
}, [editorOrigin]);
|
|
362
|
+
};
|
|
265
363
|
/**
|
|
266
364
|
* Hook for preview pages to emit focus messages when elements with data-canopy-path are clicked.
|
|
365
|
+
* A click that follows a link, ours or the browser's, goes to another page, not to a field.
|
|
267
366
|
*/
|
|
268
367
|
export const usePreviewFocusEmitter = (entryPath, opts) => {
|
|
269
368
|
const editorOrigin = opts?.editorOrigin;
|
|
@@ -271,6 +370,8 @@ export const usePreviewFocusEmitter = (entryPath, opts) => {
|
|
|
271
370
|
if (window.parent === window)
|
|
272
371
|
return;
|
|
273
372
|
const handleClick = (event) => {
|
|
373
|
+
if (followedLinkClicks.has(event) || (!event.defaultPrevented && linkClick(event)))
|
|
374
|
+
return;
|
|
274
375
|
const target = event.target;
|
|
275
376
|
if (!target)
|
|
276
377
|
return;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reduces a preview URL to what identifies the page, its path and query, with no origin,
|
|
3
|
-
* fragment
|
|
4
|
-
* by its own location. An absolute `src`,
|
|
5
|
-
* spells the same page two ways, and the
|
|
3
|
+
* fragment, trailing slash or `chrome=1`. The editor names a page by the `src` it built and the
|
|
4
|
+
* framed page by its own location. An absolute `src`, a host redirect that adds or drops a
|
|
5
|
+
* trailing slash, or the site header and footer toggle, spells the same page two ways, and the
|
|
6
|
+
* bridge must still match them.
|
|
6
7
|
* @internal Exported for tests.
|
|
7
8
|
*/
|
|
8
9
|
export declare const normalizePreviewPath: (url: string) => string;
|
|
@@ -13,10 +14,21 @@ export interface PreviewMount {
|
|
|
13
14
|
basePath?: string;
|
|
14
15
|
previewPrefix?: string;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* `src` asking for the site's header and footer, or `undefined` when it cannot. Only a page under
|
|
19
|
+
* the `previewPrefix` mount is a `createPreviewPage` route, which reads `chrome=1`; any other URL
|
|
20
|
+
* is a page of the site itself, which the editor leaves as it is.
|
|
21
|
+
*/
|
|
22
|
+
export declare const siteChromeSrc: (src: string, mount?: PreviewMount) => string | undefined;
|
|
16
23
|
/**
|
|
17
24
|
* The previewed page's path on the site, as the preview toolbar shows and copies it: without the
|
|
18
|
-
* `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
|
|
25
|
+
* `previewPrefix` mount, the `branch` query the top bar already names, `chrome=1`, or a fragment. A relative
|
|
19
26
|
* URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
|
|
20
27
|
* absolute `previewPrefix` mount is what was taken out.
|
|
21
28
|
*/
|
|
22
29
|
export declare const displayPreviewPath: (src: string, mount?: PreviewMount) => string;
|
|
30
|
+
/**
|
|
31
|
+
* The page `url` shows, as origin plus `displayPreviewPath` without a trailing slash, so a preview
|
|
32
|
+
* link and the entry `src` framing its page match, under the `previewPrefix` mount or not.
|
|
33
|
+
*/
|
|
34
|
+
export declare const previewPageKey: (url: string, mount: PreviewMount, base: string) => string | undefined;
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes } from '../utils/url-prefix.js';
|
|
2
|
+
/**
|
|
3
|
+
* The query part that asks a `createPreviewPage` route to render the site's header and footer
|
|
4
|
+
* (`canopycms-next`'s `layout` option). It changes how the page looks, not which page it is.
|
|
5
|
+
*/
|
|
6
|
+
const SITE_CHROME_PART = 'chrome=1';
|
|
7
|
+
/**
|
|
8
|
+
* `search` without the parts `keep` refuses, as `?…`, or '' when none is left. Filtered as text:
|
|
9
|
+
* a URLSearchParams round trip would form-encode the rest (`flag` becomes `flag=`, `%20` `+`).
|
|
10
|
+
*/
|
|
11
|
+
const filterQuery = (search, keep) => {
|
|
12
|
+
const query = search
|
|
13
|
+
.slice(1)
|
|
14
|
+
.split('&')
|
|
15
|
+
.filter((part) => part !== '' && keep(part))
|
|
16
|
+
.join('&');
|
|
17
|
+
return query ? `?${query}` : '';
|
|
18
|
+
};
|
|
2
19
|
/**
|
|
3
20
|
* Reduces a preview URL to what identifies the page, its path and query, with no origin,
|
|
4
|
-
* fragment
|
|
5
|
-
* by its own location. An absolute `src`,
|
|
6
|
-
* spells the same page two ways, and the
|
|
21
|
+
* fragment, trailing slash or `chrome=1`. The editor names a page by the `src` it built and the
|
|
22
|
+
* framed page by its own location. An absolute `src`, a host redirect that adds or drops a
|
|
23
|
+
* trailing slash, or the site header and footer toggle, spells the same page two ways, and the
|
|
24
|
+
* bridge must still match them.
|
|
7
25
|
* @internal Exported for tests.
|
|
8
26
|
*/
|
|
9
27
|
export const normalizePreviewPath = (url) => {
|
|
@@ -14,27 +32,58 @@ export const normalizePreviewPath = (url) => {
|
|
|
14
32
|
catch {
|
|
15
33
|
return url;
|
|
16
34
|
}
|
|
17
|
-
|
|
35
|
+
const query = filterQuery(parsed.search, (part) => part !== SITE_CHROME_PART);
|
|
36
|
+
return `${stripTrailingSlashes(parsed.pathname) || '/'}${query}`;
|
|
18
37
|
};
|
|
19
38
|
/** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
|
|
20
39
|
export const isSamePreviewPath = (a, b) => normalizePreviewPath(a) === normalizePreviewPath(b);
|
|
21
|
-
/** `
|
|
22
|
-
const
|
|
40
|
+
/** The `previewPrefix` mount `url` sits under, and what follows it; `undefined` when not under one. */
|
|
41
|
+
const splitPreviewPrefix = (url, { basePath, previewPrefix }) => {
|
|
23
42
|
if (!previewPrefix || !stripTrailingSlashes(previewPrefix))
|
|
24
|
-
return
|
|
43
|
+
return undefined;
|
|
25
44
|
const mount = stripTrailingSlashes(joinUrlPrefix(basePath, joinUrlPrefix(previewPrefix, '/')));
|
|
26
45
|
if (!url.startsWith(mount))
|
|
27
|
-
return
|
|
46
|
+
return undefined;
|
|
28
47
|
const rest = url.slice(mount.length);
|
|
29
|
-
|
|
48
|
+
return rest === '' || /^[/?#]/.test(rest) ? { mount, rest } : undefined;
|
|
49
|
+
};
|
|
50
|
+
/** `url` with the `previewPrefix` mount taken out, so `/base/preview/posts/x` reads `/base/posts/x`. */
|
|
51
|
+
const stripPreviewPrefix = (url, previewMount) => {
|
|
52
|
+
const split = splitPreviewPrefix(url, previewMount);
|
|
53
|
+
if (!split)
|
|
30
54
|
return url;
|
|
55
|
+
const { mount, rest } = split;
|
|
31
56
|
// An absolute prefix skips `basePath`, so the page under it has none either.
|
|
32
|
-
const base = isAbsoluteUrl(mount)
|
|
57
|
+
const base = isAbsoluteUrl(mount)
|
|
58
|
+
? ''
|
|
59
|
+
: stripTrailingSlashes(joinUrlPrefix(previewMount.basePath, '/'));
|
|
33
60
|
return rest.startsWith('/') ? `${base}${rest}` : `${base || '/'}${rest}`;
|
|
34
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* `src` asking for the site's header and footer, or `undefined` when it cannot. Only a page under
|
|
64
|
+
* the `previewPrefix` mount is a `createPreviewPage` route, which reads `chrome=1`; any other URL
|
|
65
|
+
* is a page of the site itself, which the editor leaves as it is.
|
|
66
|
+
*/
|
|
67
|
+
export const siteChromeSrc = (src, mount = {}) => {
|
|
68
|
+
if (!splitPreviewPrefix(src, mount))
|
|
69
|
+
return undefined;
|
|
70
|
+
const hashIndex = src.indexOf('#');
|
|
71
|
+
const beforeHash = hashIndex === -1 ? src : src.slice(0, hashIndex);
|
|
72
|
+
const hash = hashIndex === -1 ? '' : src.slice(hashIndex);
|
|
73
|
+
const queryIndex = beforeHash.indexOf('?');
|
|
74
|
+
if (queryIndex !== -1 &&
|
|
75
|
+
beforeHash
|
|
76
|
+
.slice(queryIndex + 1)
|
|
77
|
+
.split('&')
|
|
78
|
+
.includes(SITE_CHROME_PART)) {
|
|
79
|
+
return src;
|
|
80
|
+
}
|
|
81
|
+
const separator = queryIndex === -1 ? '?' : beforeHash.endsWith('?') ? '' : '&';
|
|
82
|
+
return `${beforeHash}${separator}${SITE_CHROME_PART}${hash}`;
|
|
83
|
+
};
|
|
35
84
|
/**
|
|
36
85
|
* The previewed page's path on the site, as the preview toolbar shows and copies it: without the
|
|
37
|
-
* `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
|
|
86
|
+
* `previewPrefix` mount, the `branch` query the top bar already names, `chrome=1`, or a fragment. A relative
|
|
38
87
|
* URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
|
|
39
88
|
* absolute `previewPrefix` mount is what was taken out.
|
|
40
89
|
*/
|
|
@@ -47,14 +96,8 @@ export const displayPreviewPath = (src, mount = {}) => {
|
|
|
47
96
|
catch {
|
|
48
97
|
return url;
|
|
49
98
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const query = parsed.search
|
|
53
|
-
.slice(1)
|
|
54
|
-
.split('&')
|
|
55
|
-
.filter((part) => part !== '' && part.split('=', 1)[0] !== 'branch')
|
|
56
|
-
.join('&');
|
|
57
|
-
const path = `${parsed.pathname}${query ? `?${query}` : ''}`;
|
|
99
|
+
const query = filterQuery(parsed.search, (part) => part !== SITE_CHROME_PART && part.split('=', 1)[0] !== 'branch');
|
|
100
|
+
const path = `${parsed.pathname}${query}`;
|
|
58
101
|
if (!isAbsoluteUrl(url))
|
|
59
102
|
return path;
|
|
60
103
|
// `origin` is 'null' for a scheme the URL spec does not treat as special.
|
|
@@ -62,3 +105,21 @@ export const displayPreviewPath = (src, mount = {}) => {
|
|
|
62
105
|
? `//${parsed.host}${path}`
|
|
63
106
|
: `${parsed.protocol}//${parsed.host}${path}`;
|
|
64
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* The page `url` shows, as origin plus `displayPreviewPath` without a trailing slash, so a preview
|
|
110
|
+
* link and the entry `src` framing its page match, under the `previewPrefix` mount or not.
|
|
111
|
+
*/
|
|
112
|
+
export const previewPageKey = (url, mount, base) => {
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = new URL(url, base);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
// A relative mount is a path on the editor's origin, so a URL there is matched to it as a path.
|
|
121
|
+
const spelled = splitPreviewPrefix(parsed.href, mount) || parsed.origin !== new URL(base).origin
|
|
122
|
+
? parsed.href
|
|
123
|
+
: `${parsed.pathname}${parsed.search}`;
|
|
124
|
+
return `${parsed.origin}${normalizePreviewPath(displayPreviewPath(spelled, mount))}`;
|
|
125
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
2
|
export interface ActivityRailProps {
|
|
3
|
-
/** Rail buttons from the top: the
|
|
3
|
+
/** Rail buttons from the top: the activities. */
|
|
4
4
|
top: ReactNode;
|
|
5
5
|
/** Rail buttons pinned to the bottom, such as Settings. */
|
|
6
6
|
bottom?: ReactNode;
|
|
@@ -22,8 +22,6 @@ export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>
|
|
|
22
22
|
icon: ReactNode;
|
|
23
23
|
/** Highlights the button while its panel or drawer is open. */
|
|
24
24
|
active?: boolean;
|
|
25
|
-
/** Tinted at rest, as the site monogram is. */
|
|
26
|
-
tinted?: boolean;
|
|
27
25
|
/** Shown after the label in the tooltip, as the platform writes it ("⌘P"). */
|
|
28
26
|
shortcut?: string;
|
|
29
27
|
/** A count badge, hidden at zero; the label should say it too. */
|
|
@@ -75,11 +75,11 @@ export function ActivityRail({ top, bottom, account }) {
|
|
|
75
75
|
}, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
|
|
76
76
|
}
|
|
77
77
|
/** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
|
|
78
|
-
export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false,
|
|
78
|
+
export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
|
|
79
79
|
const reasonId = useId();
|
|
80
80
|
const button = (_jsxs(ActionIcon, { ref: ref,
|
|
81
|
-
// Filled
|
|
82
|
-
variant: active ? 'filled' :
|
|
81
|
+
// Filled when active: a tint alone would show the state by colour, under 3:1.
|
|
82
|
+
variant: active ? 'filled' : 'subtle', color: active ? undefined : 'gray', size: "lg", radius: "md", "aria-label": label, ...rest, "aria-disabled": disabledReason ? true : rest['aria-disabled'], "aria-describedby": disabledReason ? reasonId : rest['aria-describedby'], "data-disabled": disabledReason ? true : undefined, onClick: disabledReason ? undefined : onClick, [RAIL_ITEM_ATTR]: railId, children: [icon, disabledReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: disabledReason }))] }));
|
|
83
83
|
return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsx(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
|
|
84
84
|
indicator: {
|
|
85
85
|
pointerEvents: 'none',
|
|
@@ -8,6 +8,21 @@ export interface EditorShellProps {
|
|
|
8
8
|
/** Drawers, modals and palettes, each portalling itself into `<body>`. */
|
|
9
9
|
overlays?: ReactNode;
|
|
10
10
|
}
|
|
11
|
+
/** The line between two shell regions, as under the top bar and a shown banner. */
|
|
12
|
+
export declare const SHELL_REGION_BORDER = "1px solid var(--canopy-input-border)";
|
|
13
|
+
/**
|
|
14
|
+
* Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
|
|
15
|
+
* Its pointer target is wider, `DIVIDER_TARGET_PX`. A finger's reaches 6px past each side of the
|
|
16
|
+
* bar: under the panes' separator, the narrow window's overlay panel keeps its header's close
|
|
17
|
+
* button clear, as that header's top padding is 10px.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DIVIDER_WIDTH_PX = 4;
|
|
20
|
+
export declare const DIVIDER_TARGET_PX: {
|
|
21
|
+
readonly fine: 12;
|
|
22
|
+
readonly coarse: 16;
|
|
23
|
+
};
|
|
24
|
+
/** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
|
|
25
|
+
export declare const DIVIDER_COLOR = "var(--canopy-input-border)";
|
|
11
26
|
/**
|
|
12
27
|
* The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
|
|
13
28
|
* overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
|
|
@@ -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)',
|