canopycms 0.9.0 → 0.11.0

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 (68) hide show
  1. package/CHANGELOG.md +74 -48
  2. package/dist/api/admin.d.ts +11 -0
  3. package/dist/api/admin.js +2 -0
  4. package/dist/api/archived-branches.d.ts +70 -0
  5. package/dist/api/archived-branches.js +146 -0
  6. package/dist/api/branch-merge.js +16 -1
  7. package/dist/api/branch-status.d.ts +11 -0
  8. package/dist/api/branch-status.js +2 -0
  9. package/dist/api/branch-withdraw.js +1 -1
  10. package/dist/api/branch.js +46 -108
  11. package/dist/api/client.d.ts +25 -21
  12. package/dist/api/client.js +46 -38
  13. package/dist/branch-archive.d.ts +19 -0
  14. package/dist/branch-archive.js +27 -0
  15. package/dist/branch-delete.d.ts +45 -0
  16. package/dist/branch-delete.js +97 -0
  17. package/dist/branch-metadata-file.d.ts +3 -0
  18. package/dist/branch-metadata-file.js +1 -0
  19. package/dist/branch-metadata.d.ts +6 -1
  20. package/dist/branch-metadata.js +15 -2
  21. package/dist/cli/cli.js +2 -0
  22. package/dist/cli/generate-ai-content.js +1 -0
  23. package/dist/editor/BranchManager.d.ts +20 -0
  24. package/dist/editor/BranchManager.js +92 -26
  25. package/dist/editor/Editor.js +49 -25
  26. package/dist/editor/PreviewFrame.d.ts +8 -1
  27. package/dist/editor/PreviewFrame.js +15 -2
  28. package/dist/editor/PreviewPane.d.ts +7 -2
  29. package/dist/editor/PreviewPane.js +6 -5
  30. package/dist/editor/PreviewToolbarTools.d.ts +6 -0
  31. package/dist/editor/PreviewToolbarTools.js +6 -1
  32. package/dist/editor/admin/SystemHealthPanel.js +61 -6
  33. package/dist/editor/admin/useSystemHealth.d.ts +3 -0
  34. package/dist/editor/admin/useSystemHealth.js +59 -0
  35. package/dist/editor/branch-status.js +9 -0
  36. package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
  37. package/dist/editor/comments/CommentThreadsPopover.js +9 -1
  38. package/dist/editor/comments/FieldWrapper.js +3 -1
  39. package/dist/editor/copy.d.ts +3 -0
  40. package/dist/editor/copy.js +3 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +5 -0
  42. package/dist/editor/hooks/useBranchManager.js +55 -4
  43. package/dist/editor/hooks/useBranchesData.d.ts +2 -0
  44. package/dist/editor/hooks/useBranchesData.js +3 -1
  45. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  46. package/dist/editor/hooks/useEditorLayout.js +5 -0
  47. package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
  48. package/dist/editor/hooks/usePreviewFollow.js +54 -0
  49. package/dist/editor/preview-bridge.d.ts +25 -0
  50. package/dist/editor/preview-bridge.js +175 -0
  51. package/dist/preview.d.ts +2 -2
  52. package/dist/preview.js +1 -1
  53. package/dist/types.d.ts +33 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/worker/archived-sweep.d.ts +23 -0
  57. package/dist/worker/archived-sweep.js +137 -0
  58. package/dist/worker/cms-worker.d.ts +6 -0
  59. package/dist/worker/cms-worker.js +7 -0
  60. package/dist/worker/git-sync.d.ts +4 -4
  61. package/dist/worker/git-sync.js +3 -0
  62. package/dist/worker/github-commits.d.ts +2 -2
  63. package/dist/worker/github-commits.js +5 -2
  64. package/dist/worker/github-gateway-wire.d.ts +2 -2
  65. package/dist/worker/rebase.js +6 -2
  66. package/dist/worker/task-runner.js +7 -4
  67. package/dist/worker/worker-context.d.ts +2 -0
  68. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { CommentThreadsPopover } from './CommentThreadsPopover.js';
6
6
  import { FieldCommentContext } from './field-comment-context.js';
7
7
  import { useFormComments } from './form-comments.js';
8
8
  import { whenRevealed } from '../field-target.js';
9
+ import { usePreviewFollowRequest } from '../hooks/usePreviewFollow.js';
9
10
  const FIELD_SCOPE_ATTR = 'data-field-comments';
10
11
  /**
11
12
  * Gives a form field its comment control. The control shows while the pointer or focus is on
@@ -23,6 +24,7 @@ export const FieldWrapper = ({ children, canopyPath, entryPath, threads, autoFoc
23
24
  const highlightHere = !!highlightThreadId && threads.some((t) => t.id === highlightThreadId);
24
25
  const [openRequest, setOpenRequest] = useState(undefined);
25
26
  const formComments = useFormComments();
27
+ const followInPreview = usePreviewFollowRequest();
26
28
  useEffect(() => formComments?.register(canopyPath, {
27
29
  subject: fieldLabel,
28
30
  blockedReason: commentBlockedReason,
@@ -51,7 +53,7 @@ export const FieldWrapper = ({ children, canopyPath, entryPath, threads, autoFoc
51
53
  const ownsTarget = (target) => target instanceof Element &&
52
54
  wrapperRef.current !== null &&
53
55
  target.closest(`[${FIELD_SCOPE_ATTR}]`) === wrapperRef.current;
54
- const control = (_jsx(CommentThreadsPopover, { threads: threads, subject: fieldLabel, variant: "field", revealed: pointerOn || focusOn, anchors: anchors, openRequest: openRequest, currentUserId: currentUserId, canResolve: canResolve, onAddComment: (text, threadId) => onAddComment(text, 'field', entryPath, canopyPath, threadId), onResolveThread: onResolveThread, onGetUserMetadata: onGetUserMetadata, highlightThreadId: highlightThreadId, composeBlockedReason: commentBlockedReason, testId: `field-comment-${canopyPath}` }, resetKey));
56
+ const control = (_jsx(CommentThreadsPopover, { threads: threads, subject: fieldLabel, variant: "field", revealed: pointerOn || focusOn, anchors: anchors, openRequest: openRequest, currentUserId: currentUserId, canResolve: canResolve, onAddComment: (text, threadId) => onAddComment(text, 'field', entryPath, canopyPath, threadId), onResolveThread: onResolveThread, onGetUserMetadata: onGetUserMetadata, highlightThreadId: highlightThreadId, composeBlockedReason: commentBlockedReason, onOpened: () => followInPreview?.(canopyPath), testId: `field-comment-${canopyPath}` }, resetKey));
55
57
  return (_jsxs(Box, { ref: wrapperRef, "data-canopy-field": canopyPath, [FIELD_SCOPE_ATTR]: '', pos: "relative", style: { width: '100%' }, onMouseOver: (event) => setPointerOn(ownsTarget(event.target)), onMouseLeave: () => setPointerOn(false), onFocus: (event) => {
56
58
  const owned = ownsTarget(event.target);
57
59
  setFocusOn(owned);
@@ -14,6 +14,7 @@ export declare const EDITOR_ACTIONS: {
14
14
  readonly requestChanges: "Request changes";
15
15
  readonly switchBranch: "Switch branch";
16
16
  readonly newBranch: "New branch";
17
+ readonly archiveBranch: "Archive branch";
17
18
  readonly remove: "Remove";
18
19
  readonly restore: "Restore";
19
20
  readonly clear: "Clear";
@@ -34,5 +35,7 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
34
35
  * says how to finish the submit, where it can be retried at all.
35
36
  */
36
37
  export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
38
+ /** Why only an admin may delete a branch that may hold commits nothing else has. */
39
+ export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
37
40
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
38
41
  export declare const CLOSED_PULL_REQUEST_REASON = "The pull request was closed on GitHub. Use \"Withdraw from review\", then submit it again to open a new one.";
@@ -14,6 +14,7 @@ export const EDITOR_ACTIONS = {
14
14
  requestChanges: 'Request changes',
15
15
  switchBranch: 'Switch branch',
16
16
  newBranch: 'New branch',
17
+ archiveBranch: 'Archive branch',
17
18
  remove: 'Remove',
18
19
  restore: 'Restore',
19
20
  clear: 'Clear',
@@ -36,5 +37,7 @@ export const rewrittenSubmitReason = (isAdmin) => isAdmin
36
37
  * says how to finish the submit, where it can be retried at all.
37
38
  */
38
39
  export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
40
+ /** Why only an admin may delete a branch that may hold commits nothing else has. */
41
+ export const DELETE_NEEDS_ADMIN_REASON = 'This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.';
39
42
  /** Why Approve and Request changes can't act: the pull request they act on is gone. */
40
43
  export const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Use "Withdraw from review", then submit it again to open a new one.';
@@ -28,8 +28,11 @@ export declare const CREATED_BRANCH_GRACE_MS = 120000;
28
28
  interface BranchSummary {
29
29
  name: string;
30
30
  shortName?: string;
31
+ title?: string;
31
32
  status: string;
32
33
  createdBy?: string;
34
+ /** The creator's display name, when the listing carried it. */
35
+ ownerName?: string;
33
36
  updatedAt?: string;
34
37
  access: {
35
38
  users: string[] | undefined;
@@ -126,6 +129,8 @@ export interface UseBranchManagerReturn {
126
129
  /** Confirms first, taking an optional note; focus returns as for `handleApprove`. */
127
130
  handleRequestChanges: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
128
131
  handleDelete: (branchName: string) => Promise<void>;
132
+ /** Admin only: confirms first, then archives a branch whose pull request closed unmerged. */
133
+ handleArchive: (branchName: string) => Promise<void>;
129
134
  handleReloadBranchData: () => Promise<void>;
130
135
  loadBranches: () => Promise<void>;
131
136
  }
@@ -201,7 +201,7 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
201
201
  : undefined,
202
202
  'Discard any unsaved or unmerged changes',
203
203
  mayHoldUnpublished
204
- ? 'Remove the only copy of any changes that never reached GitHub, and the warning about them'
204
+ ? 'Remove the only copy of any changes missing from GitHub, and the warning about them'
205
205
  : undefined,
206
206
  ].filter((b) => b !== undefined);
207
207
  openConfirm({
@@ -217,6 +217,16 @@ const showDeleteConfirmation = (branchName, pr, mayHoldUnpublished, onConfirm, o
217
217
  ...confirmModalHandlers(onConfirm, onDismiss),
218
218
  });
219
219
  };
220
+ /** Archiving is a one-way status change on a branch whose review is over. */
221
+ const showArchiveConfirmation = (branchName, inReview, onConfirm, onDismiss) => {
222
+ openConfirm({
223
+ title: EDITOR_ACTIONS.archiveBranch,
224
+ children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Archive “${branchName}”?\n\nIts pull request was closed without merging. This will:\n• Make the branch read-only and move it to “Archived”\n• Keep it marked as not merged\n\nAn archived branch can't go back to editing. This one stays until someone deletes it: automatic and bulk deletes only take merged branches.${inReview ? ` To keep working on it, use “${EDITOR_ACTIONS.withdrawFromReview}” instead.` : ''}` })),
225
+ labels: { confirm: EDITOR_ACTIONS.archiveBranch, cancel: 'Cancel' },
226
+ confirmProps: { color: 'orange', 'data-testid': 'confirm-archive-branch' },
227
+ ...confirmModalHandlers(onConfirm, onDismiss),
228
+ });
229
+ };
220
230
  /**
221
231
  * How long a branch this session created stays overlaid on listings. Each warm
222
232
  * server container caches the shared filesystem separately, so for its
@@ -381,8 +391,10 @@ export function useBranchManager(options) {
381
391
  return {
382
392
  name: b.name,
383
393
  shortName: b.shortName,
394
+ title: b.title,
384
395
  status: b.status,
385
396
  createdBy: b.createdBy,
397
+ ownerName: branchesData?.ownerNames?.[b.createdBy],
386
398
  updatedAt: b.updatedAt,
387
399
  access: {
388
400
  users: b.access.allowedUsers,
@@ -423,7 +435,7 @@ export function useBranchManager(options) {
423
435
  submitBlocked: b.submitBlocked ?? true,
424
436
  };
425
437
  });
426
- }, [branches, currentBranchName, options.comments]);
438
+ }, [branches, branchesData?.ownerNames, currentBranchName, options.comments]);
427
439
  // Explicit reload: always issues a fresh, independent fetch (raw call, not
428
440
  // SWR's `mutate()` revalidate path) so callers requesting a reload right
429
441
  // after mount aren't coalesced against the still-in-flight automatic load
@@ -730,6 +742,45 @@ export function useBranchManager(options) {
730
742
  }, () => resolve(), returnFocusTo);
731
743
  }));
732
744
  };
745
+ const handleArchive = (requested) => {
746
+ const branchNameToArchive = listedName(requested);
747
+ return singleFlight('archive', branchNameToArchive, () => new Promise((resolve, reject) => {
748
+ const status = latest.current.branches.find((b) => b.name === branchNameToArchive)?.status;
749
+ showArchiveConfirmation(shownName(branchNameToArchive), status === 'submitted' || status === 'approved', async () => {
750
+ options.setBusy(true);
751
+ try {
752
+ const result = await apiClient.workflow.archive({
753
+ branch: sendName(branchNameToArchive),
754
+ });
755
+ if (!result.ok)
756
+ throw actionError(result, 'Failed to archive branch');
757
+ notifications.show({ message: 'Branch archived', color: 'blue' });
758
+ forgetCreatedBranch(branchNameToArchive);
759
+ await loadBranches();
760
+ resolve();
761
+ }
762
+ catch (err) {
763
+ if (err instanceof RefusedError && err.status === 409) {
764
+ // Nothing is lost, and the list says what changed.
765
+ notifications.show({
766
+ message: err.message,
767
+ color: 'yellow',
768
+ autoClose: false,
769
+ withCloseButton: true,
770
+ });
771
+ await reloadBranches();
772
+ resolve();
773
+ return;
774
+ }
775
+ await reportActionFailure(err, 'Failed to archive branch', EDITOR_ACTIONS.archiveBranch);
776
+ reject(err);
777
+ }
778
+ finally {
779
+ options.setBusy(false);
780
+ }
781
+ }, () => resolve());
782
+ }));
783
+ };
733
784
  const handleApprove = (requested, returnFocusTo) => {
734
785
  const branchNameToApprove = listedName(requested);
735
786
  return singleFlight('approve', branchNameToApprove, () => new Promise((resolve, reject) => {
@@ -838,8 +889,7 @@ export function useBranchManager(options) {
838
889
  const branchNameToDelete = listedName(requested);
839
890
  const listedBefore = latest.current.branches;
840
891
  return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
841
- showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind ===
842
- 'unpublished-at-merge', async () => {
892
+ showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), ['unpublished-at-merge', 'rewritten-at-merge'].includes(listedBefore.find((b) => b.name === branchNameToDelete)?.hold?.kind ?? ''), async () => {
843
893
  options.setBusy(true);
844
894
  try {
845
895
  const result = await apiClient.branches.delete({
@@ -960,6 +1010,7 @@ export function useBranchManager(options) {
960
1010
  handleApprove,
961
1011
  handleRequestChanges,
962
1012
  handleDelete,
1013
+ handleArchive,
963
1014
  handleReloadBranchData,
964
1015
  loadBranches,
965
1016
  };
@@ -19,6 +19,8 @@ export declare const BRANCHES_KEY = "canopy:branches";
19
19
  export interface BranchesData {
20
20
  branches: BranchListItem[];
21
21
  defaultBranch?: string;
22
+ /** Display names of the listed branches' creators, by user id. */
23
+ ownerNames?: Readonly<Record<string, string>>;
22
24
  /**
23
25
  * Client clock when this listing arrived. It makes listings fetched at
24
26
  * different moments distinct `data` values even when their branches match,
@@ -33,10 +33,12 @@ export async function fetchBranches(apiClient, swr) {
33
33
  if (!result.ok) {
34
34
  throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
35
35
  }
36
- await seedUserProfiles(swr, result.data?.users ?? []);
36
+ const users = result.data?.users ?? [];
37
+ await seedUserProfiles(swr, users);
37
38
  return {
38
39
  branches: result.data?.branches ?? [],
39
40
  defaultBranch: result.data?.defaultBranch,
41
+ ownerNames: Object.fromEntries(users.map((user) => [user.id, user.name])),
40
42
  receivedAt: Date.now(),
41
43
  };
42
44
  }
@@ -8,6 +8,8 @@ interface EditorLayoutPrefs {
8
8
  /** Unset until the user picks one, so it follows `defaultLayout`; JSON drops it while unset. */
9
9
  layout?: PaneArrangement;
10
10
  highlightEnabled: boolean;
11
+ /** The preview scrolls to the field the form is on; see `usePreviewFollow`. */
12
+ previewFollowsForm: boolean;
11
13
  sideSplitPercent: number;
12
14
  stackedSplitPercent: number;
13
15
  previewWidth: PreviewWidth;
@@ -42,6 +44,8 @@ export interface UseEditorLayoutReturn {
42
44
  setLayout: (layout: PaneArrangement) => void;
43
45
  highlightEnabled: boolean;
44
46
  setHighlightEnabled: (enabled: boolean) => void;
47
+ previewFollowsForm: boolean;
48
+ setPreviewFollowsForm: (follows: boolean) => void;
45
49
  sideSplitPercent: number;
46
50
  stackedSplitPercent: number;
47
51
  /** Stores the clamped primary-pane size for one layout; a non-finite percent is ignored. */
@@ -12,6 +12,7 @@ export const CONTENT_PANEL_WIDTH_MAX = 400;
12
12
  */
13
13
  export const DEFAULT_EDITOR_LAYOUT_PREFS = {
14
14
  highlightEnabled: false,
15
+ previewFollowsForm: true,
15
16
  sideSplitPercent: DEFAULT_SIDE_SPLIT_PERCENT,
16
17
  stackedSplitPercent: DEFAULT_STACKED_SPLIT_PERCENT,
17
18
  previewWidth: 'fit',
@@ -48,6 +49,7 @@ export function parseEditorLayoutPrefs(raw) {
48
49
  return {
49
50
  layout: PANE_LAYOUTS.find((layout) => layout === parsed.layout),
50
51
  highlightEnabled: boolOr(parsed.highlightEnabled, defaults.highlightEnabled),
52
+ previewFollowsForm: boolOr(parsed.previewFollowsForm, defaults.previewFollowsForm),
51
53
  sideSplitPercent: numberOr(parsed.sideSplitPercent, defaults.sideSplitPercent, clampSplitPercent),
52
54
  stackedSplitPercent: numberOr(parsed.stackedSplitPercent, defaults.stackedSplitPercent, clampSplitPercent),
53
55
  previewWidth: oneOf(PREVIEW_WIDTHS, parsed.previewWidth, defaults.previewWidth),
@@ -73,6 +75,7 @@ export function useEditorLayout(options) {
73
75
  const loaded = useMounted();
74
76
  const setLayout = useCallback((layout) => setPrefs((prev) => ({ ...prev, layout })), [setPrefs]);
75
77
  const setHighlightEnabled = useCallback((highlightEnabled) => setPrefs((prev) => ({ ...prev, highlightEnabled })), [setPrefs]);
78
+ const setPreviewFollowsForm = useCallback((previewFollowsForm) => setPrefs((prev) => ({ ...prev, previewFollowsForm })), [setPrefs]);
76
79
  const setSplitPercent = useCallback((layout, percent) => {
77
80
  if (!Number.isFinite(percent))
78
81
  return;
@@ -96,6 +99,8 @@ export function useEditorLayout(options) {
96
99
  setLayout,
97
100
  highlightEnabled: prefs.highlightEnabled,
98
101
  setHighlightEnabled,
102
+ previewFollowsForm: prefs.previewFollowsForm,
103
+ setPreviewFollowsForm,
99
104
  sideSplitPercent: prefs.sideSplitPercent,
100
105
  stackedSplitPercent: prefs.stackedSplitPercent,
101
106
  setSplitPercent,
@@ -0,0 +1,26 @@
1
+ import type { PreviewReveal } from '../PreviewFrame.js';
2
+ /**
3
+ * How long the form rests on one field before the preview follows, so tabbing through doesn't.
4
+ * @internal Exported for tests.
5
+ */
6
+ export declare const PREVIEW_FOLLOW_DELAY_MS = 250;
7
+ /** Has the preview follow the form to a field; null while it doesn't follow. */
8
+ export declare const PreviewFollowContext: import("react").Context<((canopyPath: string) => void) | null>;
9
+ export declare const usePreviewFollowRequest: () => ((canopyPath: string) => void) | null;
10
+ export interface UsePreviewFollowReturn {
11
+ /** The latest field to bring into view in the preview, for `PreviewFrame`'s `reveal`. */
12
+ reveal: PreviewReveal | undefined;
13
+ /** Asks for a field, as a comment opening on it does; undefined while the preview doesn't follow. */
14
+ follow: ((canopyPath: string) => void) | undefined;
15
+ }
16
+ /**
17
+ * The preview follows the form: focus moving to another form field, or `follow` for one, asks the
18
+ * preview to bring that field's element into view, once the form has rested on it for
19
+ * `PREVIEW_FOLLOW_DELAY_MS`. A reveal belongs to one page, which the branch names too, so a change
20
+ * of either drops it.
21
+ */
22
+ export declare function usePreviewFollow({ enabled, branch, entryId, }: {
23
+ enabled: boolean;
24
+ branch: string;
25
+ entryId: string | undefined;
26
+ }): UsePreviewFollowReturn;
@@ -0,0 +1,54 @@
1
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
2
+ import { normalizeCanopyPath } from '../canopy-path.js';
3
+ import { FORM_PANE_ID } from '../shell/PaneLayout.js';
4
+ /**
5
+ * How long the form rests on one field before the preview follows, so tabbing through doesn't.
6
+ * @internal Exported for tests.
7
+ */
8
+ export const PREVIEW_FOLLOW_DELAY_MS = 250;
9
+ /** Has the preview follow the form to a field; null while it doesn't follow. */
10
+ export const PreviewFollowContext = createContext(null);
11
+ export const usePreviewFollowRequest = () => useContext(PreviewFollowContext);
12
+ /**
13
+ * The preview follows the form: focus moving to another form field, or `follow` for one, asks the
14
+ * preview to bring that field's element into view, once the form has rested on it for
15
+ * `PREVIEW_FOLLOW_DELAY_MS`. A reveal belongs to one page, which the branch names too, so a change
16
+ * of either drops it.
17
+ */
18
+ export function usePreviewFollow({ enabled, branch, entryId, }) {
19
+ const [reveal, setReveal] = useState(undefined);
20
+ const timer = useRef(undefined);
21
+ const lastId = useRef(0);
22
+ const lastPath = useRef(undefined);
23
+ const follow = useCallback((canopyPath) => {
24
+ lastPath.current = normalizeCanopyPath(canopyPath);
25
+ window.clearTimeout(timer.current);
26
+ timer.current = window.setTimeout(() => {
27
+ lastId.current += 1;
28
+ setReveal({ path: normalizeCanopyPath(canopyPath), id: lastId.current });
29
+ }, PREVIEW_FOLLOW_DELAY_MS);
30
+ }, []);
31
+ useEffect(() => {
32
+ window.clearTimeout(timer.current);
33
+ lastPath.current = undefined;
34
+ setReveal(undefined);
35
+ }, [branch, entryId, enabled]);
36
+ useEffect(() => {
37
+ if (!enabled)
38
+ return;
39
+ const onFocusIn = (event) => {
40
+ const target = event.target;
41
+ if (!(target instanceof Element) || !target.closest(`#${FORM_PANE_ID}`))
42
+ return;
43
+ const path = target.closest('[data-canopy-field]')?.getAttribute('data-canopy-field');
44
+ // Focus coming back to the field, after a popover or the window, leaves the preview as the
45
+ // user scrolled it; a comment opening still asks, through `follow`.
46
+ if (path && normalizeCanopyPath(path) !== lastPath.current)
47
+ follow(path);
48
+ };
49
+ document.addEventListener('focusin', onFocusIn);
50
+ return () => document.removeEventListener('focusin', onFocusIn);
51
+ }, [enabled, follow]);
52
+ useEffect(() => () => window.clearTimeout(timer.current), []);
53
+ return { reveal, follow: enabled ? follow : undefined };
54
+ }
@@ -6,6 +6,7 @@ export declare const CANOPY_PREVIEW_READY = "canopycms:preview:ready";
6
6
  export declare const CANOPY_PREVIEW_ERROR = "canopycms:preview:error";
7
7
  export declare const CANOPY_PREVIEW_MARKS = "canopycms:preview:marks";
8
8
  export declare const CANOPY_PREVIEW_NAVIGATE = "canopycms:preview:navigate";
9
+ export declare const CANOPY_PREVIEW_REVEAL = "canopycms:preview:reveal";
9
10
  /**
10
11
  * The shape of the preview's `isLoading` for data of type `T`: a `boolean` at each reference
11
12
  * position, `true` while the editor is still resolving that reference, under the same keys and
@@ -85,6 +86,15 @@ export interface PreviewMarksMessage {
85
86
  count: number;
86
87
  paths?: string[];
87
88
  }
89
+ /**
90
+ * Editor → preview: bring the element marked for this field into view, else the marks inside it,
91
+ * else its nearest marked ancestor's (`findRevealTargets`). A bridge older than the message
92
+ * ignores it.
93
+ */
94
+ export interface PreviewRevealMessage {
95
+ type: typeof CANOPY_PREVIEW_REVEAL;
96
+ path: string;
97
+ }
88
98
  /** Preview → editor: a plain click on a link to another page of this origin, left unfollowed. */
89
99
  export interface PreviewNavigateMessage {
90
100
  type: typeof CANOPY_PREVIEW_NAVIGATE;
@@ -146,6 +156,21 @@ export declare const usePreviewAssetBaseGate: (assetBase: unknown) => boolean;
146
156
  export declare const usePreviewHighlight: (opts?: {
147
157
  editorOrigin?: string;
148
158
  }) => boolean;
159
+ /**
160
+ * The rendered elements marked for `path`, else those inside it in page order, such as a list
161
+ * marked item by item, else those for its nearest marked ancestor: a list item, then its list or
162
+ * block. Compares values, never builds a selector: the path is untrusted.
163
+ * @internal Exported for tests.
164
+ */
165
+ export declare const findRevealTargets: (path: string, root?: ParentNode) => HTMLElement[];
166
+ /**
167
+ * Hook for preview pages to bring the element the editor's form is on into view and mark it
168
+ * briefly, whether or not highlighting is on. It leaves focus where it is, and an element already
169
+ * as much in view as it can be where it is.
170
+ */
171
+ export declare const usePreviewReveal: (opts?: {
172
+ editorOrigin?: string;
173
+ }) => void;
149
174
  /**
150
175
  * Hook for preview pages to emit focus messages when elements with data-canopy-path are clicked.
151
176
  * A click that follows a link, ours or the browser's, goes to another page, not to a field.
@@ -5,6 +5,7 @@
5
5
  * (`pnpm lint:bundle`); the editor's side is `PreviewFrame.tsx`.
6
6
  */
7
7
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
8
+ import { formatCanopyPath, normalizeCanopyPath, parseCanopyPath } from './canopy-path.js';
8
9
  import { createFieldProps } from './field-props.js';
9
10
  import { setPreviewAssetBase } from './preview-asset-base.js';
10
11
  import { isSamePreviewPath } from './preview-path.js';
@@ -16,6 +17,7 @@ export const CANOPY_PREVIEW_READY = 'canopycms:preview:ready';
16
17
  export const CANOPY_PREVIEW_ERROR = 'canopycms:preview:error';
17
18
  export const CANOPY_PREVIEW_MARKS = 'canopycms:preview:marks';
18
19
  export const CANOPY_PREVIEW_NAVIGATE = 'canopycms:preview:navigate';
20
+ export const CANOPY_PREVIEW_REVEAL = 'canopycms:preview:reveal';
19
21
  /**
20
22
  * Resolve a (possibly relative) URL to an origin for postMessage targeting.
21
23
  * Falls back to the page's own origin, so same-origin editor/preview setups
@@ -80,6 +82,7 @@ export const useCanopyPreview = (opts) => {
80
82
  });
81
83
  const highlightEnabled = usePreviewHighlight(bridgeOpts);
82
84
  usePreviewFocusEmitter(resolvedPath, bridgeOpts);
85
+ usePreviewReveal(bridgeOpts);
83
86
  usePreviewLinkNavigation({ editorOrigin, highlighting: highlightEnabled });
84
87
  const fieldProps = createFieldProps();
85
88
  /**
@@ -273,6 +276,178 @@ export const usePreviewHighlight = (opts) => {
273
276
  }, [enabled, editorOrigin]);
274
277
  return enabled;
275
278
  };
279
+ const REVEALED_ATTR = 'data-canopy-revealed';
280
+ /** How long a revealed element stays marked. */
281
+ const REVEAL_MARK_MS = 1500;
282
+ /** A field just opened may render after its reveal arrives, so a miss is tried once more. */
283
+ const REVEAL_RETRY_MS = 300;
284
+ /** How long the page's scrolling must pause before a smooth scroll counts as arrived. */
285
+ const REVEAL_SCROLL_SETTLE_MS = 150;
286
+ /** The longest a smooth scroll is waited for before the mark's time starts anyway. */
287
+ const REVEAL_SCROLL_WAIT_MS = 3000;
288
+ /**
289
+ * The rendered elements marked for `path`, else those inside it in page order, such as a list
290
+ * marked item by item, else those for its nearest marked ancestor: a list item, then its list or
291
+ * block. Compares values, never builds a selector: the path is untrusted.
292
+ * @internal Exported for tests.
293
+ */
294
+ export const findRevealTargets = (path, root = document) => {
295
+ const byPath = new Map();
296
+ for (const element of root.querySelectorAll('[data-canopy-path]')) {
297
+ if (element.getClientRects().length === 0)
298
+ continue;
299
+ const key = normalizeCanopyPath(element.getAttribute('data-canopy-path') ?? '');
300
+ byPath.set(key, [...(byPath.get(key) ?? []), element]);
301
+ }
302
+ const segments = parseCanopyPath(path);
303
+ const field = formatCanopyPath(segments);
304
+ if (!field)
305
+ return [];
306
+ const exact = byPath.get(field);
307
+ if (exact)
308
+ return exact;
309
+ const inside = [...byPath]
310
+ .filter(([key]) => key.startsWith(`${field}.`) || key.startsWith(`${field}[`))
311
+ .flatMap(([, elements]) => elements)
312
+ .sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
313
+ if (inside.length > 0)
314
+ return inside;
315
+ for (let length = segments.length - 1; length > 0; length--) {
316
+ const found = byPath.get(formatCanopyPath(segments.slice(0, length)));
317
+ if (found)
318
+ return found;
319
+ }
320
+ return [];
321
+ };
322
+ const CLIPPING = new Set(['hidden', 'clip', 'auto', 'scroll']);
323
+ /** Where `element` can show: the window, clipped by each ancestor whose overflow clips. */
324
+ const shownRect = (element) => {
325
+ const shown = { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };
326
+ for (let at = element.parentElement; at && at !== document.body; at = at.parentElement) {
327
+ const style = getComputedStyle(at);
328
+ const overflow = [style.overflowX, style.overflowY, ...style.overflow.split(' ')];
329
+ if (!overflow.some((value) => CLIPPING.has(value)))
330
+ continue;
331
+ const clip = at.getBoundingClientRect();
332
+ shown.top = Math.max(shown.top, clip.top);
333
+ shown.bottom = Math.min(shown.bottom, clip.bottom);
334
+ shown.left = Math.max(shown.left, clip.left);
335
+ shown.right = Math.min(shown.right, clip.right);
336
+ }
337
+ return shown;
338
+ };
339
+ /** As much of `element` shows as can: all of it, or all the room there is for a larger one. */
340
+ const isInView = (element) => {
341
+ const rect = element.getBoundingClientRect();
342
+ const room = shownRect(element);
343
+ // A clipping box off the page has no room at all, so nothing in it shows.
344
+ if (room.bottom <= room.top || room.right <= room.left)
345
+ return false;
346
+ const tall = Math.min(rect.bottom, room.bottom) - Math.max(rect.top, room.top);
347
+ const wide = Math.min(rect.right, room.right) - Math.max(rect.left, room.left);
348
+ return (tall >= Math.min(rect.height, room.bottom - room.top) - 1 &&
349
+ wide >= Math.min(rect.width, room.right - room.left) - 1);
350
+ };
351
+ /**
352
+ * Hook for preview pages to bring the element the editor's form is on into view and mark it
353
+ * briefly, whether or not highlighting is on. It leaves focus where it is, and an element already
354
+ * as much in view as it can be where it is.
355
+ */
356
+ export const usePreviewReveal = (opts) => {
357
+ const editorOrigin = opts?.editorOrigin;
358
+ useEffect(() => {
359
+ if (window.parent === window)
360
+ return;
361
+ const styleId = 'canopycms-preview-reveal-style';
362
+ if (!document.getElementById(styleId)) {
363
+ const styleEl = document.createElement('style');
364
+ styleEl.id = styleId;
365
+ // Two attributes, so it outranks the highlight outline.
366
+ styleEl.textContent = `[data-canopy-path][${REVEALED_ATTR}] { outline: 3px solid #4338ca; outline-offset: 3px; }`;
367
+ document.head.appendChild(styleEl);
368
+ }
369
+ let marked;
370
+ let unmarkTimer;
371
+ let retryTimer;
372
+ let stopWaiting;
373
+ const unmark = () => {
374
+ stopWaiting?.();
375
+ clearTimeout(unmarkTimer);
376
+ marked?.removeAttribute(REVEALED_ATTR);
377
+ marked = undefined;
378
+ };
379
+ // The mark's time starts once the scrolling that holds `target` pauses, so a slow smooth
380
+ // scroll can't use it up; another scroller on the page, such as a carousel, doesn't count.
381
+ const unmarkAfterScroll = (target, smooth) => {
382
+ if (!smooth) {
383
+ unmarkTimer = setTimeout(unmark, REVEAL_MARK_MS);
384
+ return;
385
+ }
386
+ const arrived = () => {
387
+ stopWaiting?.();
388
+ unmarkTimer = setTimeout(unmark, REVEAL_MARK_MS);
389
+ };
390
+ let settle = setTimeout(arrived, REVEAL_SCROLL_SETTLE_MS);
391
+ const cap = setTimeout(arrived, REVEAL_SCROLL_WAIT_MS);
392
+ const onScroll = (event) => {
393
+ const from = event.target;
394
+ if (from !== document && !(from instanceof Node && from.contains(target)))
395
+ return;
396
+ clearTimeout(settle);
397
+ settle = setTimeout(arrived, REVEAL_SCROLL_SETTLE_MS);
398
+ };
399
+ document.addEventListener('scroll', onScroll, true);
400
+ stopWaiting = () => {
401
+ clearTimeout(settle);
402
+ clearTimeout(cap);
403
+ document.removeEventListener('scroll', onScroll, true);
404
+ stopWaiting = undefined;
405
+ };
406
+ };
407
+ const reveal = (path) => {
408
+ const targets = findRevealTargets(path);
409
+ if (targets.length === 0)
410
+ return false;
411
+ // Mid-scroll, a mark passed on the way only looks in view.
412
+ if (!stopWaiting && targets.some(isInView))
413
+ return true;
414
+ const target = targets[0];
415
+ const smooth = !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
416
+ target.scrollIntoView({
417
+ // One taller than the page shows its top.
418
+ block: target.getBoundingClientRect().height > window.innerHeight ? 'start' : 'center',
419
+ inline: 'nearest',
420
+ // `instant`, not `auto`, which a page's `scroll-behavior: smooth` animates.
421
+ behavior: smooth ? 'smooth' : 'instant',
422
+ });
423
+ unmark();
424
+ // An attribute and a stylesheet only: the page's own styles are never written to.
425
+ marked = target;
426
+ target.setAttribute(REVEALED_ATTR, '');
427
+ unmarkAfterScroll(target, smooth);
428
+ return true;
429
+ };
430
+ const handler = (event) => {
431
+ if (!isTrustedEditorMessage(event, editorOrigin))
432
+ return;
433
+ const msg = event.data;
434
+ if (msg?.type !== CANOPY_PREVIEW_REVEAL)
435
+ return;
436
+ const path = msg.path;
437
+ if (typeof path !== 'string' || path.length > MARK_REPORT_LIMITS.pathLength)
438
+ return;
439
+ clearTimeout(retryTimer);
440
+ if (!reveal(path))
441
+ retryTimer = setTimeout(() => reveal(path), REVEAL_RETRY_MS);
442
+ };
443
+ window.addEventListener('message', handler);
444
+ return () => {
445
+ window.removeEventListener('message', handler);
446
+ clearTimeout(retryTimer);
447
+ unmark();
448
+ };
449
+ }, [editorOrigin]);
450
+ };
276
451
  const isPlainClick = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
277
452
  const linkClick = (event) => {
278
453
  // The composed path reaches a link inside a shadow root, which the retargeted `target` hides.
package/dist/preview.d.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  * CSS, `@mantine/*` and editor UI (`pnpm lint:bundle`): a page that imports it renders in the
4
4
  * adopter's own styles inside the editor's preview, and ships no editor code to the public.
5
5
  */
6
- export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, } from './editor/preview-bridge.js';
7
- export type { DraftUpdateMessage, HighlightMessage, PreviewErrorMessage, PreviewFocusMessage, PreviewLoadingState, } from './editor/preview-bridge.js';
6
+ export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, CANOPY_PREVIEW_REVEAL, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, usePreviewReveal, } from './editor/preview-bridge.js';
7
+ export type { DraftUpdateMessage, HighlightMessage, PreviewErrorMessage, PreviewFocusMessage, PreviewLoadingState, PreviewRevealMessage, } from './editor/preview-bridge.js';
8
8
  export type { CanopyPathSegment } from './editor/canopy-path.js';
package/dist/preview.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * CSS, `@mantine/*` and editor UI (`pnpm lint:bundle`): a page that imports it renders in the
5
5
  * adopter's own styles inside the editor's preview, and ships no editor code to the public.
6
6
  */
7
- export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, } from './editor/preview-bridge.js';
7
+ export { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_FOCUS, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, CANOPY_PREVIEW_REVEAL, isTrustedEditorMessage, resolveMessageOrigin, useCanopyPreview, usePreviewAssetBaseGate, usePreviewData, usePreviewFocusEmitter, usePreviewHighlight, usePreviewReveal, } from './editor/preview-bridge.js';