canopycms 0.1.0 → 0.2.0-int.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -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
- // the Clear button (`onRemove`): shown only for an optional field that has a value.
241
- // Reset to `undefined`, never `{}`, which `isEmptyForRequired` (entry-validator.ts) reads
242
- // as present. The label is "Clear" (docs/ux-guidelines.md, Vocabulary), and it offers Undo.
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
- return wrapWithComments(_jsx(ObjectField, { label: label, required: objectField.required, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear
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": "Connecting to the preview", style: {
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 { displayPreviewPath } from './preview-path.js';
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
- minHeight: 32,
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 || (showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [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: src, 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: [_jsx(PreviewFrame, { src: src, path: path, ...frameProps, onReload: () => {
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
- } }, reloads), resizing && width > 0 && (_jsx(Box, { "aria-hidden": true, "data-testid": "preview-resize-readout", style: {
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
- * Branch-level comments section displayed at the top of BranchManager.
24
- * Uses ThreadCarousel for navigation.
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, useState } from 'react';
4
- import { Box, Button } from '@mantine/core';
3
+ import { useMemo } from 'react';
5
4
  import { ThreadCarousel } from './ThreadCarousel.js';
6
5
  /**
7
- * Branch-level comments section displayed at the top of BranchManager.
8
- * Uses ThreadCarousel for navigation.
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, autoFocus, highlightThreadId, onGetUserMetadata, }) => {
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 (!shouldShowCarousel) {
18
- return (_jsx(Box, { children: _jsx(Button, { variant: "subtle", color: "gray", onClick: () => setShowCarousel(true), style: { fontSize: '0.75rem', height: '1.5rem' }, children: "New branch comment" }) }));
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 add a new comment (creates new thread or adds to existing) */
39
- onAddComment: (text: string, threadId?: string) => Promise<void>;
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, Button, Group, Paper, Stack, Text, Textarea, Tooltip } from '@mantine/core';
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
- import { CommentWriteAlert, toCommentWriteFailure, } from './CommentWriteAlert.js';
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"] }))] }), _jsxs(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 }) }) }), _jsx("div", { style: {
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
- } }) })] })), sortedThreads.length === 0 && !showNewThreadBox && (_jsx(Text, { size: "xs", c: "dimmed", ta: "center", py: "xs", children: "No comments yet. Click \"+ New\" to start a thread." }))] }) }));
164
+ } }) })] }))] }) }));
210
165
  };
@@ -76,8 +76,11 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
76
76
  setReplyText('');
77
77
  }
78
78
  await onResolve();
79
- if (focusAfterResolve)
80
- moveFocusIfUnmoved(origin, focusAfterResolve());
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));