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
|
@@ -35,6 +35,7 @@ import { FormExpansionContext } from './fields/form-expansion.js';
|
|
|
35
35
|
import { FormHeader } from './FormHeader.js';
|
|
36
36
|
import { buildFormOutline, hasCollapsibleList } from './form-outline.js';
|
|
37
37
|
import { findFieldTarget, focusFieldTarget } from './field-target.js';
|
|
38
|
+
import { canRestoreRemoval, movedThreadsNote, openThreadsWithin, removalAnchors, restoreRemoval, } from './undo-removal.js';
|
|
38
39
|
import { schemaLabel } from './schema-label.js';
|
|
39
40
|
/**
|
|
40
41
|
* How an Undo toast names a removed object-list item: by its title field or first non-empty
|
|
@@ -94,7 +95,7 @@ const FieldBoundary = ({ canopyPath, resetKey, update, build, fallback }) => {
|
|
|
94
95
|
setGeneration(liveGeneration.current);
|
|
95
96
|
}, fallback: fallback, children: _jsx(FieldControl, { build: () => build(guardedUpdate) }) }));
|
|
96
97
|
};
|
|
97
|
-
export const FormRenderer = ({ fields, value, onChange, customRenderers, branch = 'main', comments = [], currentEntryPath, currentUserId, canResolve = false, focusedFieldPath, highlightThreadId, onAddComment, onResolveThread, conflictNotice = false, fieldErrors, format, readOnly = false, onRemoved, onGetUserMetadata, header, notice, commentBlockedReason, removedItemFieldLabel, }) => {
|
|
98
|
+
export const FormRenderer = ({ fields, value, onChange, customRenderers, branch = 'main', comments = [], currentEntryPath, currentUserId, canResolve = false, focusedFieldPath, highlightThreadId, onAddComment, onResolveThread, conflictNotice = false, fieldErrors, format, readOnly = false, onRemoved, savedValue, onGetUserMetadata, header, notice, commentBlockedReason, removedItemFieldLabel, }) => {
|
|
98
99
|
const emit = readOnly ? noop : onChange;
|
|
99
100
|
const formRef = useRef(null);
|
|
100
101
|
const customTypes = new Set(Object.keys(customRenderers ?? {}));
|
|
@@ -109,6 +110,50 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
109
110
|
return Object.keys(fieldErrors ?? {}).some((key) => key === row || key.startsWith(`${row}.`) || key.startsWith(`${row}[`));
|
|
110
111
|
};
|
|
111
112
|
const [formComments] = useState(createFormComments);
|
|
113
|
+
// An object's Undo clear lasts while it can put the object back where it was (checked by
|
|
114
|
+
// `canRestoreRemoval`), and ends with the entry, the branch, a save or a discard. It has no
|
|
115
|
+
// timer: it stays in the form for as long as the object is left unedited.
|
|
116
|
+
const [cleared, setCleared] = useState({
|
|
117
|
+
scope: boundaryResetKey,
|
|
118
|
+
savedValue,
|
|
119
|
+
byPath: {},
|
|
120
|
+
});
|
|
121
|
+
const clearScope = !readOnly && value !== savedValue ? boundaryResetKey : undefined;
|
|
122
|
+
const clearsLive = cleared.scope === clearScope && cleared.savedValue === savedValue;
|
|
123
|
+
const offeredClears = Object.fromEntries(Object.entries(clearsLive ? cleared.byPath : {}).filter(([, clear]) => canRestoreRemoval(fields, value, clear.removal)));
|
|
124
|
+
if (Object.keys(offeredClears).length !== Object.keys(cleared.byPath).length) {
|
|
125
|
+
setCleared({ scope: boundaryResetKey, savedValue, byPath: offeredClears });
|
|
126
|
+
}
|
|
127
|
+
const clearObject = (path, previous, label, update) => {
|
|
128
|
+
const removal = { kind: 'value', path, value: previous, label: `"${label}"` };
|
|
129
|
+
// Taken from the value this render shows, which the clear has not reached yet.
|
|
130
|
+
const anchors = removalAnchors(fields, value, removal);
|
|
131
|
+
// Open threads keyed to items inside it leave their fields for the entry's thread list.
|
|
132
|
+
const moved = currentEntryPath ? openThreadsWithin(comments, currentEntryPath, path) : 0;
|
|
133
|
+
update(undefined);
|
|
134
|
+
setCleared((prev) => ({
|
|
135
|
+
scope: boundaryResetKey,
|
|
136
|
+
savedValue,
|
|
137
|
+
byPath: {
|
|
138
|
+
...(prev.scope === boundaryResetKey && prev.savedValue === savedValue ? prev.byPath : {}),
|
|
139
|
+
[normalizeCanopyPath(path)]: {
|
|
140
|
+
removal: { ...removal, anchors },
|
|
141
|
+
note: movedThreadsNote(moved),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
};
|
|
146
|
+
// Puts the object back into the value as it is now, so edits made elsewhere since are kept.
|
|
147
|
+
const undoClear = (canopyPath) => {
|
|
148
|
+
const clear = offeredClears[canopyPath];
|
|
149
|
+
const restored = clear ? restoreRemoval(fields, value, clear.removal) : undefined;
|
|
150
|
+
setCleared((prev) => {
|
|
151
|
+
const { [canopyPath]: _undone, ...rest } = prev.byPath;
|
|
152
|
+
return { ...prev, byPath: rest };
|
|
153
|
+
});
|
|
154
|
+
if (restored)
|
|
155
|
+
emit(restored);
|
|
156
|
+
};
|
|
112
157
|
// The field last focused on one entry is never offered on the next.
|
|
113
158
|
useEffect(() => formComments.noteFocus(undefined), [formComments, boundaryResetKey]);
|
|
114
159
|
// Wraps the rendered control in an error boundary, and with an inline validation message
|
|
@@ -237,23 +282,13 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
237
282
|
}) }, fieldKey(path)));
|
|
238
283
|
}
|
|
239
284
|
// A non-list object has no list item to remove, so its only way back to `undefined` is
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
285
|
+
// Clear (`onClear`): offered only for an optional field that has a value. Reset to
|
|
286
|
+
// `undefined`, never `{}`, which `isEmptyForRequired` (entry-validator.ts) reads as
|
|
287
|
+
// present. The label is "Clear" (docs/ux-guidelines.md, Vocabulary), and it offers Undo.
|
|
243
288
|
const hasValue = currentValue !== undefined && currentValue !== null;
|
|
244
289
|
const canClear = !readOnly && objectField.required !== true && hasValue;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
update(undefined);
|
|
248
|
-
onRemoved?.({
|
|
249
|
-
kind: 'value',
|
|
250
|
-
path,
|
|
251
|
-
value: currentValue,
|
|
252
|
-
label: `"${label}"`,
|
|
253
|
-
action: 'clear',
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
: undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
290
|
+
const clear = offeredClears[canopyPath];
|
|
291
|
+
return wrapWithComments(_jsx(ObjectField, { label: label, required: objectField.required, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onClear: canClear ? () => clearObject(path, currentValue, label, update) : undefined, onUndoClear: clear ? () => undoClear(canopyPath) : undefined, clearNote: clear?.note, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
|
|
257
292
|
}
|
|
258
293
|
case 'code':
|
|
259
294
|
return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
@@ -4,13 +4,18 @@ export interface PreviewMarks {
|
|
|
4
4
|
count: number;
|
|
5
5
|
paths?: string[];
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* What a page's handshake says of the site's header and footer: its ready message says it renders
|
|
9
|
+
* them for `chrome=1` or that it does not, or no ready message came in `READY_TIMEOUT_MS`.
|
|
10
|
+
*/
|
|
11
|
+
export type SiteChromeSupport = 'supported' | 'unsupported' | 'unanswered';
|
|
7
12
|
/**
|
|
8
13
|
* Lightweight iframe wrapper to keep the preview in sync with form state.
|
|
9
14
|
* It posts the latest draft data to the iframe after load and when data changes. A progress bar
|
|
10
15
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
11
|
-
* to reload the preview.
|
|
16
|
+
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
12
17
|
*/
|
|
13
|
-
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, }: {
|
|
18
|
+
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, busy, }: {
|
|
14
19
|
src: string;
|
|
15
20
|
path: string;
|
|
16
21
|
data?: unknown;
|
|
@@ -37,4 +42,8 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
|
|
|
37
42
|
iframeStyle?: CSSProperties;
|
|
38
43
|
/** Replaces the chip's Retry, for a host whose own reload control remounts the frame. */
|
|
39
44
|
onReload?: () => void;
|
|
45
|
+
/** Called with what the page's handshake says of the site's header and footer. */
|
|
46
|
+
onSiteChrome?: (support: SiteChromeSupport) => void;
|
|
47
|
+
/** The editor is loading the next page's data: this page stays in view, dimmed and inert. */
|
|
48
|
+
busy?: boolean;
|
|
40
49
|
}) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
4
|
import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
|
|
5
5
|
/**
|
|
6
6
|
* How long after the iframe's `load` the preview has to send its ready message before the frame
|
|
@@ -34,9 +34,9 @@ const sendDraftUpdate = (iframe, message, targetOrigin) => {
|
|
|
34
34
|
* Lightweight iframe wrapper to keep the preview in sync with form state.
|
|
35
35
|
* It posts the latest draft data to the iframe after load and when data changes. A progress bar
|
|
36
36
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
37
|
-
* to reload the preview.
|
|
37
|
+
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
38
38
|
*/
|
|
39
|
-
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, }) => {
|
|
39
|
+
export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, busy = false, }) => {
|
|
40
40
|
const iframeRef = useRef(null);
|
|
41
41
|
// Pin the preview origin from the src prop: outbound messages target it (never '*'),
|
|
42
42
|
// and inbound messages must come from it. An iframe that navigates cross-origin
|
|
@@ -71,6 +71,10 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
71
71
|
const hasDraft = data !== undefined;
|
|
72
72
|
const syncPending = hasDraft && handshake === 'waiting';
|
|
73
73
|
const liveUpdatesOff = hasDraft && handshake === 'missing';
|
|
74
|
+
// React 18 has no `inert` prop, so it is set on the DOM.
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
iframeRef.current?.toggleAttribute('inert', busy);
|
|
77
|
+
}, [busy, retries, src]);
|
|
74
78
|
// Inject the progress bar keyframe animation once per page.
|
|
75
79
|
useEffect(() => {
|
|
76
80
|
const styleId = 'canopycms-preview-sync-style';
|
|
@@ -109,12 +113,18 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
109
113
|
const postHighlightRef = useRef(postHighlight);
|
|
110
114
|
const onPreviewErrorRef = useRef(onPreviewError);
|
|
111
115
|
const onMarksRef = useRef(onMarks);
|
|
116
|
+
const onSiteChromeRef = useRef(onSiteChrome);
|
|
112
117
|
useEffect(() => {
|
|
113
118
|
postRef.current = post;
|
|
114
119
|
postHighlightRef.current = postHighlight;
|
|
115
120
|
onPreviewErrorRef.current = onPreviewError;
|
|
116
121
|
onMarksRef.current = onMarks;
|
|
122
|
+
onSiteChromeRef.current = onSiteChrome;
|
|
117
123
|
});
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (handshake === 'missing')
|
|
126
|
+
onSiteChromeRef.current?.('unanswered');
|
|
127
|
+
}, [handshake]);
|
|
118
128
|
useEffect(() => {
|
|
119
129
|
post();
|
|
120
130
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- post is tracked via ref
|
|
@@ -138,6 +148,9 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
138
148
|
postRef.current();
|
|
139
149
|
postHighlightRef.current();
|
|
140
150
|
setHandshake('ready');
|
|
151
|
+
onSiteChromeRef.current?.(event.data.siteChrome === true
|
|
152
|
+
? 'supported'
|
|
153
|
+
: 'unsupported');
|
|
141
154
|
}
|
|
142
155
|
else if (type === CANOPY_PREVIEW_ERROR) {
|
|
143
156
|
const msg = event.data;
|
|
@@ -160,7 +173,7 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
160
173
|
window.addEventListener('message', handleMessage);
|
|
161
174
|
return () => window.removeEventListener('message', handleMessage);
|
|
162
175
|
}, [previewOrigin]);
|
|
163
|
-
return (_jsxs("div", { className: className, style: { position: 'relative', overflow: 'hidden', ...style }, children: [syncPending && (_jsx("div", { role: "progressbar", "aria-label":
|
|
176
|
+
return (_jsxs("div", { className: className, "aria-busy": busy || undefined, style: { position: 'relative', overflow: 'hidden', ...style }, children: [(syncPending || busy) && (_jsx("div", { role: "progressbar", "aria-label": busy ? 'Loading the entry' : 'Connecting to the preview', style: {
|
|
164
177
|
position: 'absolute',
|
|
165
178
|
top: 0,
|
|
166
179
|
left: 0,
|
|
@@ -207,6 +220,9 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
|
|
|
207
220
|
height: '100%',
|
|
208
221
|
border: 'none',
|
|
209
222
|
...iframeStyle,
|
|
223
|
+
// Eased, so a page that arrives at once barely dims.
|
|
224
|
+
transition: 'opacity 150ms',
|
|
225
|
+
...(busy ? { opacity: 0.5 } : {}),
|
|
210
226
|
}, onLoad: () => {
|
|
211
227
|
post();
|
|
212
228
|
postHighlight();
|
|
@@ -5,9 +5,16 @@ import type { InexactMark } from './preview-marks.js';
|
|
|
5
5
|
import type { PaneArrangement } from './shell/PaneLayout.js';
|
|
6
6
|
import { type PreviewMount } from './preview-path.js';
|
|
7
7
|
export interface PreviewPaneProps {
|
|
8
|
+
/** The open entry's page. */
|
|
8
9
|
src: string;
|
|
9
10
|
/** The page's path for the bridge and the toolbar; defaults to `src`. */
|
|
10
11
|
path?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The open entry's data is still loading, so `data` is not its draft yet. The page framed last
|
|
14
|
+
* stays in view, dimmed, until it arrives; with none framed yet, the viewport holds a skeleton.
|
|
15
|
+
* The toolbar already names the open entry.
|
|
16
|
+
*/
|
|
17
|
+
pending?: boolean;
|
|
11
18
|
/** Where the preview routes are mounted; the toolbar's path leaves out the `previewPrefix`. */
|
|
12
19
|
mount?: PreviewMount;
|
|
13
20
|
data?: unknown;
|
|
@@ -38,10 +45,17 @@ export interface PreviewPaneProps {
|
|
|
38
45
|
onMaximizedChange?: (maximized: boolean) => void;
|
|
39
46
|
/** A drag of the preview/form divider is under way: the preview shows its size over itself. */
|
|
40
47
|
resizing?: boolean;
|
|
48
|
+
/** The site's header and footer are asked for, by `chrome=1` on a `previewPrefix` page. */
|
|
49
|
+
siteChrome?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* With it, the toolbar offers the site header and footer toggle once the page says it can show
|
|
52
|
+
* them, and whenever `siteChrome` is on, so it can always be turned off.
|
|
53
|
+
*/
|
|
54
|
+
onSiteChromeChange?: (siteChrome: boolean) => void;
|
|
41
55
|
}
|
|
42
56
|
/**
|
|
43
57
|
* The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
|
|
44
58
|
* fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
|
|
45
59
|
* down to fit a narrower pane.
|
|
46
60
|
*/
|
|
47
|
-
export declare const PreviewPane: ({ src, path, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
|
|
61
|
+
export declare const PreviewPane: ({ src, path, pending, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, siteChrome, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Box, Group, Select, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Box, Group, Select, Skeleton, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { useClipboard, useElementSize, useWindowEvent } from '@mantine/hooks';
|
|
6
6
|
import { IconCheck, IconExternalLink, IconRefresh } from '@tabler/icons-react';
|
|
7
7
|
import { PreviewFrame } from './PreviewFrame.js';
|
|
8
|
-
import { HighlightToggle, MaximizeToggle, UnmappedMarksChip } from './PreviewToolbarTools.js';
|
|
9
|
-
import {
|
|
8
|
+
import { HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
|
|
9
|
+
import { PANE_HEADER_HEIGHT } from './pane-header.js';
|
|
10
|
+
import { displayPreviewPath, siteChromeSrc } from './preview-path.js';
|
|
10
11
|
import { computePreviewViewport, formatPreviewWidthReadout, PREVIEW_DEVICE_WIDTHS, } from './preview-scale.js';
|
|
11
12
|
const WIDTH_OPTIONS = [
|
|
12
13
|
{ value: 'fit', label: 'Fit', detail: 'Pane width' },
|
|
@@ -27,7 +28,7 @@ const OPEN_SAVED_LABEL = 'Open saved version in a new tab';
|
|
|
27
28
|
* fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
|
|
28
29
|
* down to fit a narrower pane.
|
|
29
30
|
*/
|
|
30
|
-
export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, ...frameProps }) => {
|
|
31
|
+
export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }) => {
|
|
31
32
|
const { ref: viewportRef, width, height } = useElementSize();
|
|
32
33
|
const rootRef = useRef(null);
|
|
33
34
|
const maximizeRef = useRef(null);
|
|
@@ -74,13 +75,51 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
74
75
|
}
|
|
75
76
|
hadNotice.current = Boolean(notice);
|
|
76
77
|
}, [notice]);
|
|
78
|
+
// Pages asked for the header and footer stopped connecting, such as when the site's layout
|
|
79
|
+
// throws: for the rest of the session pages are framed without them, so drafts still show.
|
|
80
|
+
// `trying` until a page without them connects, which confirms the header and footer were the
|
|
81
|
+
// cause; a site that never connects at all is not blamed on them. The toggle and Reload retry.
|
|
82
|
+
const [chromeFallback, setChromeFallback] = useState('off');
|
|
77
83
|
// Reloading remounts the frame for a fresh handshake. It drops a reported error too: a page
|
|
78
84
|
// that reports draft errors reports it again once the reloaded page renders the draft.
|
|
79
85
|
const [reloads, setReloads] = useState(0);
|
|
80
86
|
const reload = () => {
|
|
81
87
|
setReloads((count) => count + 1);
|
|
88
|
+
setChromeFallback('off');
|
|
82
89
|
frameProps.onPreviewError?.(null);
|
|
83
90
|
};
|
|
91
|
+
// Whether the page framed last said it renders the site's header and footer. Kept across
|
|
92
|
+
// pages, so the toggle stays put while the next page connects.
|
|
93
|
+
const [chromeSupported, setChromeSupported] = useState(false);
|
|
94
|
+
const chromeSrc = siteChromeSrc(src, mount);
|
|
95
|
+
// Also what "Open saved version" opens, so it shows what the frame shows.
|
|
96
|
+
const liveSrc = siteChrome && chromeSrc && chromeFallback === 'off' ? chromeSrc : src;
|
|
97
|
+
// The frame changes page only once the open entry's data is in, so one render hands the new
|
|
98
|
+
// page its own draft: no draft is ever posted to the page of another entry. A reload or the
|
|
99
|
+
// toggle waits for that page too, since the held page has no draft to render.
|
|
100
|
+
const live = { page: src, src: liveSrc, path, key: reloads };
|
|
101
|
+
const [framed, setFramed] = useState(pending ? null : live);
|
|
102
|
+
if (!pending &&
|
|
103
|
+
(framed?.src !== live.src ||
|
|
104
|
+
framed.page !== src ||
|
|
105
|
+
framed.path !== path ||
|
|
106
|
+
framed.key !== reloads)) {
|
|
107
|
+
setFramed(live);
|
|
108
|
+
}
|
|
109
|
+
const frame = pending ? framed : live;
|
|
110
|
+
const onSiteChrome = (support) => {
|
|
111
|
+
setChromeSupported(support === 'supported');
|
|
112
|
+
if (!frame)
|
|
113
|
+
return;
|
|
114
|
+
const withChrome = frame.src !== frame.page;
|
|
115
|
+
if (support === 'unanswered' && withChrome)
|
|
116
|
+
setChromeFallback('trying');
|
|
117
|
+
if (support !== 'unanswered' && !withChrome) {
|
|
118
|
+
setChromeFallback((current) => (current === 'trying' ? 'confirmed' : current));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// Shown while on even without support, so a page that never connects can still turn it off.
|
|
122
|
+
const showChromeToggle = Boolean(onSiteChromeChange && chromeSrc && (chromeSupported || siteChrome));
|
|
84
123
|
const shownPath = displayPreviewPath(path, mount);
|
|
85
124
|
const viewport = computePreviewViewport(previewWidth, width, height);
|
|
86
125
|
const iframeStyle = previewWidth === 'fit'
|
|
@@ -109,8 +148,9 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
109
148
|
height: '100%',
|
|
110
149
|
border: '1px solid var(--mantine-color-default-border)',
|
|
111
150
|
}, children: [_jsxs(Group, { role: "group", "aria-label": "Preview toolbar", "data-testid": "preview-toolbar", gap: 6, px: 6, style: {
|
|
112
|
-
// Wraps to a second row, rather than clipping, in a pane too narrow for one.
|
|
113
|
-
|
|
151
|
+
// Wraps to a second row, rather than clipping, in a pane too narrow for one. The pane's
|
|
152
|
+
// own top border makes up the last pixel, so this bar ends level with the form header.
|
|
153
|
+
minHeight: PANE_HEADER_HEIGHT - 1,
|
|
114
154
|
rowGap: 2,
|
|
115
155
|
paddingBlock: 2,
|
|
116
156
|
flex: '0 0 auto',
|
|
@@ -119,7 +159,14 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
119
159
|
}, children: [_jsx(Tooltip, { label: copyStatus || copyLabel, multiline: true, maw: 360, style: { overflowWrap: 'anywhere' }, children: _jsx(UnstyledButton, { onClick: () => clipboard.copy(shownPath), "aria-label": copyLabel, style: { flex: '1 1 80px', minWidth: 0, borderRadius: 4, padding: '2px 4px' }, children: _jsx(Text, { component: "span", size: "xs", ff: "monospace", truncate: "end", display: "block", children: shownPath }) }) }), _jsx(VisuallyHidden, { role: "status", children: copyStatus }), _jsx(Select, { "aria-label": "Preview width", data: WIDTH_OPTIONS, value: previewWidth, onChange: (value) => {
|
|
120
160
|
if (isPreviewWidth(value))
|
|
121
161
|
onPreviewWidthChange(value);
|
|
122
|
-
}, allowDeselect: false, size: "xs", w: 96, style: { flexShrink: 0 }, styles: { input: { height: 24, minHeight: 24 } }, comboboxProps: { width: 170, position: 'bottom-end' }, renderOption: ({ option, checked }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1 }, children: option.label }), _jsx(Text, { component: "span", size: "xs", children: WIDTH_OPTIONS.find((o) => o.value === option.value)?.detail })] })) }), width > 0 && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "preview-width-readout", style: { flexShrink: 0, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, children: formatPreviewWidthReadout(viewport) })), (onHighlightToggle ||
|
|
162
|
+
}, allowDeselect: false, size: "xs", w: 96, style: { flexShrink: 0 }, styles: { input: { height: 24, minHeight: 24 } }, comboboxProps: { width: 170, position: 'bottom-end' }, renderOption: ({ option, checked }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1 }, children: option.label }), _jsx(Text, { component: "span", size: "xs", children: WIDTH_OPTIONS.find((o) => o.value === option.value)?.detail })] })) }), width > 0 && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "preview-width-readout", style: { flexShrink: 0, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, children: formatPreviewWidthReadout(viewport) })), (onHighlightToggle ||
|
|
163
|
+
showChromeToggle ||
|
|
164
|
+
(showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [showChromeToggle && onSiteChromeChange && (_jsx(SiteChromeToggle, { enabled: siteChrome, failed: chromeFallback === 'confirmed', onToggle: () => {
|
|
165
|
+
setChromeFallback('off');
|
|
166
|
+
onSiteChromeChange(!siteChrome);
|
|
167
|
+
} })), onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: liveSrc, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
|
|
168
|
+
// A held page gets no draft and its reports go nowhere: it belongs to another entry.
|
|
169
|
+
data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
|
|
123
170
|
// Retry goes with the frame it sits in; the toolbar's reload offers the same action.
|
|
124
171
|
reload();
|
|
125
172
|
reloadButtonRef.current?.focus();
|
|
@@ -127,7 +174,7 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
|
|
|
127
174
|
position: 'absolute',
|
|
128
175
|
inset: 0,
|
|
129
176
|
background: previewWidth === 'fit' ? undefined : 'var(--canopy-surface-muted)',
|
|
130
|
-
} },
|
|
177
|
+
} }, frame.key)) : (_jsx(Box, { "aria-busy": "true", pos: "absolute", inset: 0, "data-testid": "preview-loading-skeleton", children: _jsx(Skeleton, { h: "100%", radius: 0 }) })), resizing && width > 0 && (_jsx(Box, { "aria-hidden": true, "data-testid": "preview-resize-readout", style: {
|
|
131
178
|
position: 'absolute',
|
|
132
179
|
top: '50%',
|
|
133
180
|
left: '50%',
|
|
@@ -8,6 +8,14 @@ export interface HighlightToggleProps {
|
|
|
8
8
|
}
|
|
9
9
|
/** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
|
|
10
10
|
export declare function HighlightToggle({ enabled, onToggle, marksNothing }: HighlightToggleProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export interface SiteChromeToggleProps {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
onToggle: () => void;
|
|
14
|
+
/** On, but pages with the header and footer don't connect while pages without them do. */
|
|
15
|
+
failed?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Shows and hides the site's header and footer; says so when the site couldn't show them. */
|
|
18
|
+
export declare function SiteChromeToggle({ enabled, onToggle, failed }: SiteChromeToggleProps): import("react/jsx-runtime").JSX.Element;
|
|
11
19
|
/** The admin's count of preview marks that name no field of the entry; see `findInexactMarks`. */
|
|
12
20
|
export declare function UnmappedMarksChip({ marks }: {
|
|
13
21
|
marks: readonly InexactMark[];
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useRef, useState } from 'react';
|
|
4
4
|
import { ActionIcon, Button, Group, Popover, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
|
-
import { IconArrowsMaximize, IconArrowsMinimize, IconMarquee } from '@tabler/icons-react';
|
|
5
|
+
import { IconArrowsMaximize, IconArrowsMinimize, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
|
|
6
6
|
const HIGHLIGHT_LABEL = 'Highlight fields in the preview';
|
|
7
7
|
const NOTHING_MARKED_NOTE = "This page marks no editable elements, so nothing can be highlighted or clicked to focus. The site's developer can mark them with fieldProps.";
|
|
8
8
|
/** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
|
|
@@ -11,6 +11,14 @@ export function HighlightToggle({ enabled, onToggle, marksNothing = false }) {
|
|
|
11
11
|
const showNote = enabled && marksNothing;
|
|
12
12
|
return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: HIGHLIGHT_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": HIGHLIGHT_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-highlights-button", onClick: onToggle, children: _jsx(IconMarquee, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: NOTHING_MARKED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Nothing to highlight", _jsx(VisuallyHidden, { id: noteId, children: NOTHING_MARKED_NOTE })] }) }))] }));
|
|
13
13
|
}
|
|
14
|
+
const SITE_CHROME_LABEL = 'Show site header and footer';
|
|
15
|
+
const SITE_CHROME_FAILED_NOTE = "The site couldn't show its header and footer, so the preview shows pages without them. Turn this off and on, or reload the preview, to try again.";
|
|
16
|
+
/** Shows and hides the site's header and footer; says so when the site couldn't show them. */
|
|
17
|
+
export function SiteChromeToggle({ enabled, onToggle, failed = false }) {
|
|
18
|
+
const noteId = useId();
|
|
19
|
+
const showNote = enabled && failed;
|
|
20
|
+
return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: SITE_CHROME_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": SITE_CHROME_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-site-chrome-button", onClick: onToggle, children: _jsx(IconLayoutNavbar, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: SITE_CHROME_FAILED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Header and footer unavailable", _jsx(VisuallyHidden, { id: noteId, children: SITE_CHROME_FAILED_NOTE })] }) }))] }));
|
|
21
|
+
}
|
|
14
22
|
const LISTED = 3;
|
|
15
23
|
// Module-level, so React never re-calls it on a re-render and pulls focus back while closing.
|
|
16
24
|
const focusOnMount = (node) => node?.focus();
|
|
@@ -15,3 +15,9 @@ export declare const BRANCH_STATUS_PRESENTATION: Record<BranchStatus, BranchStat
|
|
|
15
15
|
* Returns undefined when there is nothing to show.
|
|
16
16
|
*/
|
|
17
17
|
export declare function branchStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* The status the top bar shows: only one that tells the user something, so not 'editing', the
|
|
20
|
+
* state every branch starts in. The Branches drawer shows every status through
|
|
21
|
+
* `branchStatusPresentation`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function branchBarStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
|
|
@@ -27,3 +27,13 @@ export function branchStatusPresentation(status, isProtected = false) {
|
|
|
27
27
|
return BRANCH_STATUS_PRESENTATION[status];
|
|
28
28
|
return { label: status, color: 'gray', variant: 'light' };
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The status the top bar shows: only one that tells the user something, so not 'editing', the
|
|
32
|
+
* state every branch starts in. The Branches drawer shows every status through
|
|
33
|
+
* `branchStatusPresentation`.
|
|
34
|
+
*/
|
|
35
|
+
export function branchBarStatusPresentation(status, isProtected = false) {
|
|
36
|
+
if (!isProtected && status === 'editing')
|
|
37
|
+
return undefined;
|
|
38
|
+
return branchStatusPresentation(status, isProtected);
|
|
39
|
+
}
|
|
@@ -12,15 +12,13 @@ export interface BranchCommentsProps {
|
|
|
12
12
|
onAddComment: (text: string, type: 'field' | 'entry' | 'branch', entryPath?: string, canopyPath?: string, threadId?: string) => Promise<void>;
|
|
13
13
|
/** Handler to resolve a thread */
|
|
14
14
|
onResolveThread: (threadId: string) => Promise<void>;
|
|
15
|
-
/** Auto-focus and expand */
|
|
16
|
-
autoFocus?: boolean;
|
|
17
15
|
/** Thread ID to highlight and scroll to */
|
|
18
16
|
highlightThreadId?: string;
|
|
19
17
|
/** Optional function to fetch user metadata for displaying user badges */
|
|
20
18
|
onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
|
|
21
19
|
}
|
|
22
20
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* The branch's existing discussion threads, at the top of BranchManager, in a ThreadCarousel.
|
|
22
|
+
* It renders nothing without one: a new branch comment starts in the Comments drawer.
|
|
25
23
|
*/
|
|
26
24
|
export declare const BranchComments: React.FC<BranchCommentsProps>;
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useMemo
|
|
4
|
-
import { Box, Button } from '@mantine/core';
|
|
3
|
+
import { useMemo } from 'react';
|
|
5
4
|
import { ThreadCarousel } from './ThreadCarousel.js';
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The branch's existing discussion threads, at the top of BranchManager, in a ThreadCarousel.
|
|
7
|
+
* It renders nothing without one: a new branch comment starts in the Comments drawer.
|
|
9
8
|
*/
|
|
10
|
-
export const BranchComments = ({ comments, currentUserId, canResolve, onAddComment, onResolveThread,
|
|
11
|
-
const [showCarousel, setShowCarousel] = useState(false);
|
|
9
|
+
export const BranchComments = ({ comments, currentUserId, canResolve, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
|
|
12
10
|
const branchThreads = useMemo(() => comments.filter((t) => t.type === 'branch'), [comments]);
|
|
13
|
-
const shouldShowCarousel = branchThreads.length > 0 || showCarousel || autoFocus;
|
|
14
11
|
const handleAddComment = async (text, threadId) => {
|
|
15
12
|
await onAddComment(text, 'branch', undefined, undefined, threadId);
|
|
16
13
|
};
|
|
17
|
-
if (
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
return (_jsx(ThreadCarousel, { threads: branchThreads, label: "Branch discussion", contextType: "branch", currentUserId: currentUserId, canResolve: canResolve, onAddComment: handleAddComment, onResolveThread: onResolveThread, autoFocus: autoFocus, autoOpenNewThread: showCarousel && branchThreads.length === 0, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }));
|
|
14
|
+
if (branchThreads.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsx(ThreadCarousel, { threads: branchThreads, label: "Branch discussion", contextType: "branch", currentUserId: currentUserId, canResolve: canResolve, onAddComment: handleAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }));
|
|
21
17
|
};
|
|
@@ -35,19 +35,13 @@ export interface ThreadCarouselProps {
|
|
|
35
35
|
currentUserId: string;
|
|
36
36
|
/** Whether the user may resolve any thread; a thread's own author always may resolve it. */
|
|
37
37
|
canResolve: boolean;
|
|
38
|
-
/** Handler to
|
|
39
|
-
onAddComment: (text: string, threadId
|
|
38
|
+
/** Handler to reply to a thread; new threads start in the Comments drawer. */
|
|
39
|
+
onAddComment: (text: string, threadId: string) => Promise<void>;
|
|
40
40
|
/** Handler to resolve a thread */
|
|
41
41
|
onResolveThread: (threadId: string) => Promise<void>;
|
|
42
|
-
/** Auto-focus and expand (from preview click) */
|
|
43
|
-
autoFocus?: boolean;
|
|
44
|
-
/** Auto-open new thread box */
|
|
45
|
-
autoOpenNewThread?: boolean;
|
|
46
42
|
/** Thread ID to highlight and scroll to */
|
|
47
43
|
highlightThreadId?: string;
|
|
48
44
|
/** Optional function to fetch user metadata for displaying user badges */
|
|
49
45
|
onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
|
|
50
|
-
/** Called when the user cancels the new-thread box (e.g. so a wrapper can collapse an empty carousel) */
|
|
51
|
-
onCancelNewThread?: () => void;
|
|
52
46
|
}
|
|
53
47
|
export declare const ThreadCarousel: React.FC<ThreadCarouselProps>;
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
4
|
-
import { ActionIcon,
|
|
4
|
+
import { ActionIcon, Group, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
|
5
5
|
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
|
6
6
|
import { InlineCommentThread } from './InlineCommentThread.js';
|
|
7
7
|
import { useTrackedWrites } from './use-tracked-writes.js';
|
|
8
|
-
|
|
9
|
-
export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, currentUserId, canResolve, onAddComment, onResolveThread, autoFocus, autoOpenNewThread, highlightThreadId, onGetUserMetadata, onCancelNewThread, }) => {
|
|
8
|
+
export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, currentUserId, canResolve, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
|
|
10
9
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
11
|
-
const [showNewThreadBox, setShowNewThreadBox] = useState(false);
|
|
12
|
-
const [newThreadText, setNewThreadText] = useState('');
|
|
13
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
14
10
|
const [carouselHeight, setCarouselHeight] = useState(400);
|
|
15
11
|
const [isResizing, setIsResizing] = useState(false);
|
|
16
12
|
const [highlightedThreadId, setHighlightedThreadId] = useState(undefined);
|
|
17
|
-
const [error, setError] = useState(null);
|
|
18
13
|
const scrollContainerRef = useRef(null);
|
|
19
14
|
const resizeStartY = useRef(0);
|
|
20
15
|
const resizeStartHeight = useRef(0);
|
|
@@ -24,17 +19,6 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
24
19
|
}
|
|
25
20
|
return a.resolved ? 1 : -1;
|
|
26
21
|
}), [threads]);
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
if (autoFocus && sortedThreads.length > 0) {
|
|
29
|
-
const firstUnresolved = sortedThreads.find((t) => !t.resolved);
|
|
30
|
-
if (firstUnresolved) {
|
|
31
|
-
const index = sortedThreads.findIndex((t) => t.id === firstUnresolved.id);
|
|
32
|
-
setCurrentIndex(index);
|
|
33
|
-
scrollToIndex(index);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollToIndex is stable
|
|
37
|
-
}, [autoFocus, sortedThreads]);
|
|
38
22
|
useEffect(() => {
|
|
39
23
|
if (highlightThreadId && sortedThreads.length > 0) {
|
|
40
24
|
const threadIndex = sortedThreads.findIndex((t) => t.id === highlightThreadId);
|
|
@@ -50,11 +34,6 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
50
34
|
}
|
|
51
35
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollToIndex is stable
|
|
52
36
|
}, [highlightThreadId, sortedThreads]);
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (autoOpenNewThread) {
|
|
55
|
-
setShowNewThreadBox(true);
|
|
56
|
-
}
|
|
57
|
-
}, [autoOpenNewThread]);
|
|
58
37
|
useEffect(() => {
|
|
59
38
|
const handleMouseMove = (e) => {
|
|
60
39
|
if (!isResizing)
|
|
@@ -119,32 +98,8 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
119
98
|
const resolveThread = track(onResolveThread);
|
|
120
99
|
// A resolved thread's controls unmount with the focused button, so focus lands here.
|
|
121
100
|
const labelRef = useRef(null);
|
|
122
|
-
const handleCreateNewThread = async () => {
|
|
123
|
-
if (!newThreadText.trim())
|
|
124
|
-
return;
|
|
125
|
-
setIsSubmitting(true);
|
|
126
|
-
setError(null);
|
|
127
|
-
try {
|
|
128
|
-
await addComment(newThreadText);
|
|
129
|
-
setNewThreadText('');
|
|
130
|
-
setShowNewThreadBox(false);
|
|
131
|
-
}
|
|
132
|
-
catch (err) {
|
|
133
|
-
setError(toCommentWriteFailure(err));
|
|
134
|
-
}
|
|
135
|
-
finally {
|
|
136
|
-
setIsSubmitting(false);
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
101
|
const unresolvedCount = sortedThreads.filter((t) => !t.resolved).length;
|
|
140
|
-
return (_jsx(Paper, { p: "sm", bg: "var(--mantine-color-default-hover)", style: { marginTop: 8 }, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { ref: labelRef, size: "sm", fw: 600, tabIndex: -1, children: [label, sortedThreads.length > 0 && ` (${sortedThreads.length})`] }), unresolvedCount > 0 && (_jsxs(Text, { size: "xs", c: "orange", fw: 600, children: [unresolvedCount, " unresolved"] }))] }),
|
|
141
|
-
width: 1,
|
|
142
|
-
height: 16,
|
|
143
|
-
background: 'var(--mantine-color-default-border)',
|
|
144
|
-
} })] })), _jsx(Button, { variant: "light", onClick: () => setShowNewThreadBox(!showNewThreadBox), children: "+ New" })] })] }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), showNewThreadBox && (_jsx(Paper, { withBorder: true, p: "sm", bg: "var(--mantine-color-body)", children: _jsxs(Stack, { gap: "xs", children: [_jsx(Textarea, { placeholder: "Start a new thread...", value: newThreadText, onChange: (e) => setNewThreadText(e.target.value), minRows: 2, disabled: isSubmitting, autoFocus: true, "data-testid": "new-thread-textarea" }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { onClick: handleCreateNewThread, loading: isSubmitting, disabled: !newThreadText.trim(), "data-testid": "create-thread-button", children: "Create Thread" }), _jsx(Button, { variant: "subtle", onClick: () => {
|
|
145
|
-
setShowNewThreadBox(false);
|
|
146
|
-
onCancelNewThread?.();
|
|
147
|
-
}, children: "Cancel" })] })] }) })), sortedThreads.length > 0 && (_jsxs("div", { style: {
|
|
102
|
+
return (_jsx(Paper, { p: "sm", bg: "var(--mantine-color-default-hover)", style: { marginTop: 8 }, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { ref: labelRef, size: "sm", fw: 600, tabIndex: -1, children: [label, sortedThreads.length > 0 && ` (${sortedThreads.length})`] }), unresolvedCount > 0 && (_jsxs(Text, { size: "xs", c: "orange", fw: 600, children: [unresolvedCount, " unresolved"] }))] }), _jsx(Group, { gap: "xs", children: sortedThreads.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: "Previous thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handlePrevious, disabled: currentIndex === 0, "aria-label": "Previous thread", children: _jsx(IconChevronLeft, { size: 16, "aria-hidden": true }) }) }), _jsxs(Text, { size: "xs", fw: 500, children: [currentIndex + 1, "/", sortedThreads.length] }), _jsx(Tooltip, { label: "Next thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handleNext, disabled: currentIndex === sortedThreads.length - 1, "aria-label": "Next thread", children: _jsx(IconChevronRight, { size: 16, "aria-hidden": true }) }) })] })) })] }), sortedThreads.length > 0 && (_jsxs("div", { style: {
|
|
148
103
|
position: 'relative',
|
|
149
104
|
width: '100%',
|
|
150
105
|
}, children: [_jsxs("div", { ref: scrollContainerRef, style: {
|
|
@@ -206,5 +161,5 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
|
|
|
206
161
|
height: 3,
|
|
207
162
|
borderRadius: 2,
|
|
208
163
|
background: 'var(--mantine-color-dimmed)',
|
|
209
|
-
} }) })] }))
|
|
164
|
+
} }) })] }))] }) }));
|
|
210
165
|
};
|
|
@@ -76,8 +76,11 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
|
|
|
76
76
|
setReplyText('');
|
|
77
77
|
}
|
|
78
78
|
await onResolve();
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// After the commit that settles the write, so focus never lands on a control that still
|
|
80
|
+
// reads as busy.
|
|
81
|
+
if (focusAfterResolve) {
|
|
82
|
+
requestAnimationFrame(() => setTimeout(() => moveFocusIfUnmoved(origin, focusAfterResolve()), 0));
|
|
83
|
+
}
|
|
81
84
|
}
|
|
82
85
|
catch (err) {
|
|
83
86
|
setError(toCommentWriteFailure(err));
|