@drawnagency/primitives 0.1.108 → 0.1.110

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 (216) hide show
  1. package/dist/auth/cookies.d.ts +8 -0
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +1 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +11 -4
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-5IZ2KTMU.js → chunk-3GAG6ZXB.js} +55 -46
  9. package/dist/{chunk-PZJL2S3Z.js → chunk-3ZRQ4G5F.js} +1 -1
  10. package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
  11. package/dist/{chunk-LUAI5DWR.js → chunk-7MI3HCOF.js} +60 -23
  12. package/dist/{chunk-ZHHJVKU7.js → chunk-7WQJ57XG.js} +63 -204
  13. package/dist/{chunk-JIQBLG6Y.js → chunk-ALMEC3XN.js} +12 -13
  14. package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
  15. package/dist/{chunk-D6TLUVOP.js → chunk-ELQ4C677.js} +48 -2
  16. package/dist/chunk-F2LYQPXV.js +77 -0
  17. package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
  18. package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
  19. package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
  20. package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
  21. package/dist/{chunk-IPKZ5FU6.js → chunk-NXGIHSL5.js} +72 -55
  22. package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
  23. package/dist/chunk-OUYB2GCW.js +194 -0
  24. package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
  25. package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
  26. package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
  27. package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
  28. package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
  29. package/dist/collab/rebase.d.ts +6 -0
  30. package/dist/collab/rebase.d.ts.map +1 -1
  31. package/dist/collab/rebase.js +12 -3
  32. package/dist/collab/recovery.d.ts +0 -20
  33. package/dist/collab/recovery.d.ts.map +1 -1
  34. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  35. package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
  36. package/dist/components/brandguide/colors-shared.d.ts +10 -1
  37. package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
  38. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  39. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  40. package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
  41. package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
  42. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  43. package/dist/components/editor/NavGroupRow.d.ts +7 -2
  44. package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
  45. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  46. package/dist/components/editor/index.js +14 -13
  47. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  48. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  49. package/dist/components/primitives/index.d.ts +2 -0
  50. package/dist/components/primitives/index.d.ts.map +1 -1
  51. package/dist/components/primitives/index.js +22 -13
  52. package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
  53. package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
  54. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  55. package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
  56. package/dist/components/sections/brand-guide.js +10 -10
  57. package/dist/components/sections/register-schemas.js +16 -16
  58. package/dist/components/sections/register.js +16 -16
  59. package/dist/components/shared/FontPicker.d.ts.map +1 -1
  60. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
  61. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  62. package/dist/components/shared/Navigation.d.ts.map +1 -1
  63. package/dist/components/shared/Popover.d.ts.map +1 -1
  64. package/dist/components/shell/AudienceAddForm.d.ts +1 -5
  65. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  66. package/dist/components/shell/AudienceRow.d.ts +2 -3
  67. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  68. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  69. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  70. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  71. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  72. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  73. package/dist/components/shell/view-branch.d.ts.map +1 -1
  74. package/dist/document/components/DocRail.d.ts.map +1 -1
  75. package/dist/document/components/DocViewer.d.ts.map +1 -1
  76. package/dist/document/components/measure.d.ts.map +1 -1
  77. package/dist/document/components/useDocPages.d.ts.map +1 -1
  78. package/dist/document/engine/blocks.d.ts.map +1 -1
  79. package/dist/document/engine/masonry.d.ts.map +1 -1
  80. package/dist/document/engine/paginate.d.ts.map +1 -1
  81. package/dist/document/engine/settings.d.ts +3 -0
  82. package/dist/document/engine/settings.d.ts.map +1 -1
  83. package/dist/document/index.js +79 -28
  84. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  85. package/dist/hooks/useDocumentUploads.d.ts +1 -0
  86. package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
  87. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  88. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  89. package/dist/hooks/useFocusTrap.d.ts +6 -0
  90. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  91. package/dist/hooks/useMediaPipeline.d.ts +3 -2
  92. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  93. package/dist/hooks/useSanitizedHtml.d.ts +12 -0
  94. package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
  95. package/dist/index.js +20 -14
  96. package/dist/lib/block-move.d.ts.map +1 -1
  97. package/dist/lib/contrast.d.ts.map +1 -1
  98. package/dist/lib/dexie.d.ts +2 -8
  99. package/dist/lib/dexie.d.ts.map +1 -1
  100. package/dist/lib/dexie.js +21 -49
  101. package/dist/lib/index-ops.d.ts.map +1 -1
  102. package/dist/lib/index-ops.js +43 -23
  103. package/dist/lib/index.js +8 -8
  104. package/dist/lib/loader.d.ts.map +1 -1
  105. package/dist/lib/media-folders.d.ts +12 -4
  106. package/dist/lib/media-folders.d.ts.map +1 -1
  107. package/dist/lib/media-folders.js +12 -2
  108. package/dist/lib/migrate-sections-transform.js +1 -1
  109. package/dist/lib/nav-tree.d.ts.map +1 -1
  110. package/dist/lib/nav.d.ts.map +1 -1
  111. package/dist/lib/pages.d.ts.map +1 -1
  112. package/dist/lib/registry.d.ts.map +1 -1
  113. package/dist/lib/registry.js +1 -1
  114. package/dist/lib/sanitize.d.ts +6 -0
  115. package/dist/lib/sanitize.d.ts.map +1 -1
  116. package/dist/lib/timestamp.d.ts.map +1 -1
  117. package/dist/media/index.js +4 -4
  118. package/dist/media/queue.d.ts.map +1 -1
  119. package/dist/media/resolve.d.ts.map +1 -1
  120. package/dist/media/utils.d.ts.map +1 -1
  121. package/dist/media/videoPoster.d.ts.map +1 -1
  122. package/dist/schemas/audience.d.ts +5 -1
  123. package/dist/schemas/audience.d.ts.map +1 -1
  124. package/dist/schemas/audience.js +1 -1
  125. package/dist/schemas/index.js +9 -7
  126. package/dist/schemas/link.d.ts.map +1 -1
  127. package/dist/schemas/link.js +1 -1
  128. package/dist/schemas/sections.d.ts +12 -0
  129. package/dist/schemas/sections.d.ts.map +1 -1
  130. package/dist/storage/index.d.ts +1 -1
  131. package/dist/storage/index.d.ts.map +1 -1
  132. package/dist/storage/index.js +3 -1
  133. package/dist/storage/types.d.ts +9 -0
  134. package/dist/storage/types.d.ts.map +1 -1
  135. package/dist/types/database.d.ts +0 -3
  136. package/dist/types/database.d.ts.map +1 -1
  137. package/package.json +1 -1
  138. package/src/auth/cookies.ts +11 -0
  139. package/src/auth/index.ts +1 -1
  140. package/src/auth/types.ts +18 -4
  141. package/src/collab/rebase.ts +26 -1
  142. package/src/collab/recovery.ts +29 -3
  143. package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
  144. package/src/components/brandguide/ColorsEditable.tsx +7 -9
  145. package/src/components/brandguide/ColorsView.tsx +12 -11
  146. package/src/components/brandguide/colors-shared.ts +17 -1
  147. package/src/components/chat/ChatPanel.tsx +19 -0
  148. package/src/components/docfile/DocFilePane.tsx +11 -0
  149. package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
  150. package/src/components/docfile/doc-file-state.ts +7 -0
  151. package/src/components/editor/IndicatorPill.tsx +5 -1
  152. package/src/components/editor/NavGroupRow.tsx +8 -3
  153. package/src/components/editor/PagesModal.tsx +10 -2
  154. package/src/components/primitives/EditableList.tsx +10 -2
  155. package/src/components/primitives/EditableRichText.tsx +4 -3
  156. package/src/components/primitives/LinkPopover.tsx +23 -1
  157. package/src/components/primitives/index.ts +5 -0
  158. package/src/components/primitives/useEditableCollection.ts +61 -41
  159. package/src/components/primitives/useEditablePlainText.ts +24 -3
  160. package/src/components/sections/IconList/index.tsx +8 -1
  161. package/src/components/sections/Prose/Prose.tsx +4 -2
  162. package/src/components/shared/FontPicker.tsx +16 -4
  163. package/src/components/shared/HistoryChangesModal.tsx +12 -1
  164. package/src/components/shared/HistoryPopover.tsx +18 -4
  165. package/src/components/shared/LinkField.tsx +1 -1
  166. package/src/components/shared/Navigation.tsx +20 -2
  167. package/src/components/shared/Popover.tsx +9 -5
  168. package/src/components/shell/AudienceAddForm.tsx +6 -26
  169. package/src/components/shell/AudienceRow.tsx +29 -39
  170. package/src/components/shell/BugReportFAB.tsx +20 -2
  171. package/src/components/shell/EditorShell.tsx +200 -50
  172. package/src/components/shell/MediaLibraryModal.tsx +52 -10
  173. package/src/components/shell/SiteSettingsUsers.tsx +3 -0
  174. package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
  175. package/src/components/shell/view-branch.ts +4 -1
  176. package/src/document/components/DocRail.tsx +27 -2
  177. package/src/document/components/DocViewer.tsx +5 -1
  178. package/src/document/components/measure.tsx +22 -6
  179. package/src/document/components/useDocPages.ts +13 -3
  180. package/src/document/engine/blocks.ts +33 -4
  181. package/src/document/engine/masonry.ts +8 -2
  182. package/src/document/engine/paginate.ts +4 -1
  183. package/src/document/engine/settings.ts +40 -3
  184. package/src/hooks/useBuildStatus.ts +6 -0
  185. package/src/hooks/useDocumentUploads.ts +46 -3
  186. package/src/hooks/useEditorPersistence.ts +33 -19
  187. package/src/hooks/useEditorPublish.ts +83 -34
  188. package/src/hooks/useFocusTrap.ts +18 -0
  189. package/src/hooks/useMediaPipeline.ts +111 -38
  190. package/src/hooks/useSanitizedHtml.ts +29 -0
  191. package/src/lib/audience-secret.ts +1 -0
  192. package/src/lib/block-move.ts +13 -2
  193. package/src/lib/contrast.ts +14 -14
  194. package/src/lib/dexie.ts +46 -49
  195. package/src/lib/index-ops.ts +63 -20
  196. package/src/lib/loader.ts +41 -12
  197. package/src/lib/media-folders.ts +31 -5
  198. package/src/lib/migrate-sections-transform.ts +22 -2
  199. package/src/lib/nav-tree.ts +16 -2
  200. package/src/lib/nav.ts +10 -1
  201. package/src/lib/pages.ts +24 -6
  202. package/src/lib/registry.ts +3 -0
  203. package/src/lib/sanitize.ts +9 -0
  204. package/src/lib/timestamp.ts +5 -0
  205. package/src/media/queue.ts +4 -0
  206. package/src/media/resolve.ts +14 -1
  207. package/src/media/utils.ts +3 -1
  208. package/src/media/videoPoster.ts +14 -3
  209. package/src/schemas/audience.ts +10 -2
  210. package/src/schemas/link.ts +4 -1
  211. package/src/schemas/sections.ts +23 -1
  212. package/src/storage/index.ts +1 -1
  213. package/src/storage/types.ts +13 -0
  214. package/src/types/database.ts +0 -3
  215. package/dist/chunk-5THS4K5D.js +0 -123
  216. package/dist/chunk-ZSPCE6MZ.js +0 -70
@@ -1,8 +1,8 @@
1
- import { useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { Eye, EyeOff } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { containerGridClass } from "../../lib/container-grid";
5
- import { getContrastClass } from "./colors-shared";
5
+ import { getContrastClass, orderedSpaceEntries } from "./colors-shared";
6
6
  import type { ColorItem } from "../sections/Colors/schema";
7
7
 
8
8
  export default function ColorsView({
@@ -20,6 +20,8 @@ export default function ColorsView({
20
20
  }) {
21
21
  const [expanded, setExpanded] = useState(!collapsing);
22
22
  const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
23
+ const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24
+ useEffect(() => () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }, []);
23
25
 
24
26
  const handleCopy = (value: string, colorIndex: number) => {
25
27
  if (navigator.clipboard?.writeText) {
@@ -35,7 +37,8 @@ export default function ColorsView({
35
37
  document.body.removeChild(ta);
36
38
  }
37
39
  setCopiedIndex(colorIndex);
38
- setTimeout(() => setCopiedIndex(null), 1500);
40
+ if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
41
+ copiedTimerRef.current = setTimeout(() => setCopiedIndex(null), 1500);
39
42
  };
40
43
 
41
44
  return (
@@ -79,14 +82,12 @@ export default function ColorsView({
79
82
  // flex-1 so every swatch's label block grows to the tallest in its row.
80
83
  <div className="flex-1 space-y-1 bg-base-accent p-3 text-sm">
81
84
  {color.spaces.map((space, j) =>
82
- Object.entries(space).map(([key, value]) =>
83
- value ? (
84
- <button key={`${j}-${key}`} onClick={() => handleCopy(value, i)} className="cursor-pointer flex w-full justify-between hover:text-primary">
85
- <span className="font-medium uppercase">{key}</span>
86
- <span>{value}</span>
87
- </button>
88
- ) : null
89
- )
85
+ orderedSpaceEntries(space).map(([key, value]) => (
86
+ <button key={`${j}-${key}`} onClick={() => handleCopy(value, i)} className="cursor-pointer flex w-full justify-between hover:text-primary">
87
+ <span className="font-medium uppercase">{key}</span>
88
+ <span>{value}</span>
89
+ </button>
90
+ ))
90
91
  )}
91
92
  </div>
92
93
  )}
@@ -1,4 +1,20 @@
1
- import type { ColorItem } from "../sections/Colors/schema";
1
+ import type { ColorItem, ColorSpace } from "../sections/Colors/schema";
2
+
3
+ /**
4
+ * Canonical display order for a colour space's values. Rendering
5
+ * `Object.entries(space)` instead put them in whatever order the JSON happened
6
+ * to carry, so two swatches on the same page could list their values
7
+ * differently — and the settings form, which is always hex/rgb/cmyk/pantone,
8
+ * disagreed with the swatch it edits.
9
+ */
10
+ export const COLOR_SPACE_KEYS = ["hex", "rgb", "cmyk", "pantone"] as const;
11
+
12
+ export function orderedSpaceEntries(space: ColorSpace): [string, string][] {
13
+ return COLOR_SPACE_KEYS.flatMap((key) => {
14
+ const value = space[key];
15
+ return value ? [[key, value] as [string, string]] : [];
16
+ });
17
+ }
2
18
 
3
19
  export function getContrastClass(hex: string | undefined): string {
4
20
  if (!hex) return "text-white";
@@ -195,6 +195,13 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
195
195
  return convoIdRef.current;
196
196
  }
197
197
  const abortRef = useRef<AbortController | null>(null);
198
+ // Bumped by every run() and by anything that replaces the transcript out from
199
+ // under an in-flight one (New conversation, restore-from-history). run()'s
200
+ // post-await writes are all keyed to the generation it started with, so an
201
+ // aborted request can no longer resurrect the transcript the user just
202
+ // cleared — abort resolves `{ok: true}`, and the tail wrote back the history
203
+ // captured before the request.
204
+ const runGenRef = useRef(0);
198
205
  const composerRef = useRef<HTMLTextAreaElement>(null);
199
206
  const panelRef = useRef<HTMLDivElement>(null);
200
207
  const listRef = useRef<HTMLDivElement>(null);
@@ -355,6 +362,8 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
355
362
  }
356
363
 
357
364
  async function run(history: ChatMessage[]) {
365
+ const gen = ++runGenRef.current;
366
+ const superseded = () => runGenRef.current !== gen;
358
367
  setNotice(null);
359
368
  setCanRetry(false);
360
369
  setBusy(true);
@@ -367,6 +376,7 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
367
376
  const result = await streamChat(
368
377
  { messages: history, sessionId: sessionId(), ...(taskId ? { taskId } : {}), ...(draftChatbot !== undefined ? { draftChatbot } : {}), ...(simulateAudience ? { simulateAudience } : {}) },
369
378
  (delta) => {
379
+ if (superseded()) return;
370
380
  streamingRef.current += delta;
371
381
  const streamed = streamingRef.current;
372
382
  setMessages([...history, { role: "assistant", content: streamed }]);
@@ -375,6 +385,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
375
385
  );
376
386
  abortRef.current = null;
377
387
  setBusy(false);
388
+ // Everything below writes the transcript captured before the request. If it
389
+ // has since been replaced, leave it alone.
390
+ if (superseded()) return;
378
391
  if (!result.ok) {
379
392
  setMessages(history); // drop the empty assistant stub
380
393
  setNotice(ERROR_COPY[result.error] ?? ERROR_COPY.upstream_error);
@@ -404,6 +417,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
404
417
  // Starts fresh; the previous conversation stays in the history archive.
405
418
  function clearConversation() {
406
419
  if (busy) stop();
420
+ // Invalidate any in-flight run, so its tail can't write the old transcript
421
+ // back over the empty one.
422
+ runGenRef.current++;
407
423
  setMessages([]);
408
424
  setNotice(null);
409
425
  setCanRetry(false);
@@ -423,6 +439,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
423
439
 
424
440
  function restoreConversation(c: StoredConversation) {
425
441
  if (busy) stop();
442
+ // Same as clearConversation: this replaces the transcript, so an in-flight
443
+ // run must not finish by restoring the one it started from.
444
+ runGenRef.current++;
426
445
  convoIdRef.current = c.id;
427
446
  try { window.sessionStorage.setItem(CONVO_ID_KEY, c.id); } catch {}
428
447
  setMessages(c.messages);
@@ -8,6 +8,7 @@ import {
8
8
  ZoomIn,
9
9
  ZoomOut,
10
10
  } from "lucide-react";
11
+ import { useInitialFocus } from "../../hooks/useFocusTrap";
11
12
  import type { SiteDocument } from "../../lib/documents";
12
13
  import { documentFileUrl } from "../../schemas/documents";
13
14
  import { lockBodyScroll } from "../../lib/scroll-lock";
@@ -48,13 +49,22 @@ export default function DocFilePane({
48
49
  const [page, setPage] = useState(1);
49
50
  const [numPages, setNumPages] = useState(0);
50
51
  const frameRef = useRef<HTMLIFrameElement | null>(null);
52
+ const paneRef = useRef<HTMLDivElement | null>(null);
51
53
  const viewerRef = useRef<PdfCanvasViewerHandle | null>(null);
52
54
 
53
55
  useEffect(() => lockBodyScroll(), []);
54
56
 
57
+ // Focus moves into the pane on open and back to the trigger on close. It is
58
+ // aria-modal-less by design (the page behind stays reachable), so this is
59
+ // initial focus only, not a trap.
60
+ useInitialFocus(paneRef, true);
61
+
55
62
  useEffect(() => {
56
63
  const onKey = (e: KeyboardEvent) => {
57
64
  if (e.key !== "Escape") return;
65
+ // A modal opened on top of the pane owns Escape. Without this one keypress
66
+ // dismissed both — the same stacking rule Popover applies.
67
+ if (document.querySelector("[data-modal-layer]")) return;
58
68
  e.preventDefault();
59
69
  onClose();
60
70
  };
@@ -87,6 +97,7 @@ export default function DocFilePane({
87
97
 
88
98
  return (
89
99
  <div
100
+ ref={paneRef}
90
101
  role="dialog"
91
102
  aria-label={doc.title}
92
103
  data-testid="doc-file-pane"
@@ -65,6 +65,8 @@ interface PageSlot {
65
65
  pendingKey: string | null;
66
66
  task: RenderTask | null;
67
67
  textLayerStarted: boolean;
68
+ /** Kept so a page leaving the visible band can cancel it, like `task`. */
69
+ textLayer: { cancel: () => void } | null;
68
70
  }
69
71
 
70
72
  const emptySlot = (): PageSlot => ({
@@ -72,6 +74,7 @@ const emptySlot = (): PageSlot => ({
72
74
  pendingKey: null,
73
75
  task: null,
74
76
  textLayerStarted: false,
77
+ textLayer: null,
75
78
  });
76
79
 
77
80
  /**
@@ -111,6 +114,11 @@ export default function PdfCanvasViewer({
111
114
  useEffect(() => {
112
115
  let cancelled = false;
113
116
  let loadingTask: ReturnType<PdfjsModule["getDocument"]> | null = null;
117
+ // Page indices carry no identity across documents, so a set left over from
118
+ // the previous one makes the new document's first render believe those pages
119
+ // are on screen — it renders canvases for a band nobody is looking at, and
120
+ // pages that really are visible wait for the observer's first callback.
121
+ setVisible(new Set());
114
122
  void (async () => {
115
123
  try {
116
124
  const lib = await loadPdfjs();
@@ -203,7 +211,9 @@ export default function PdfCanvasViewer({
203
211
 
204
212
  const fitScale = useMemo(() => {
205
213
  if (!dims?.length || !containerWidth) return null;
206
- const maxW = Math.max(...dims.map((d) => d.w));
214
+ // reduce, not Math.max(...): the spread is an argument list, and a document
215
+ // with enough pages would blow the call-stack limit outright.
216
+ const maxW = dims.reduce((m, d) => (d.w > m ? d.w : m), 0);
207
217
  return Math.max(0.1, (containerWidth - GUTTER_PX) / maxW);
208
218
  }, [dims, containerWidth]);
209
219
  const scale = fitScale == null ? null : fitScale * zoom;
@@ -277,6 +287,7 @@ export default function PdfCanvasViewer({
277
287
  container: textDiv,
278
288
  viewport: page.getViewport({ scale: renderScale }),
279
289
  });
290
+ slot.textLayer = textLayer;
280
291
  textLayer.render().catch(() => {
281
292
  slot.textLayerStarted = false;
282
293
  });
@@ -289,6 +300,9 @@ export default function PdfCanvasViewer({
289
300
  slots.current.forEach((slot, i) => {
290
301
  if (visible.has(i)) return;
291
302
  slot.task?.cancel();
303
+ // The text layer went on streaming into a container React had already
304
+ // unmounted, and a return trip started a second one on top of it.
305
+ slot.textLayer?.cancel();
292
306
  Object.assign(slot, emptySlot());
293
307
  });
294
308
  }, [doc, dims, renderScale, visible]);
@@ -382,19 +396,46 @@ export default function PdfCanvasViewer({
382
396
  }, [doc]);
383
397
 
384
398
  const scrollRaf = useRef(0);
399
+ // Wrapper tops, snapshotted once per layout instead of read inside the rAF:
400
+ // scrolling a 300-page document forced 300 layout reads every frame. They only
401
+ // move when the page geometry does, which is `scale` and `dims`.
402
+ const offsetsRef = useRef<number[] | null>(null);
403
+ useEffect(() => {
404
+ offsetsRef.current = null;
405
+ }, [scale, dims]);
406
+
385
407
  const onScroll = () => {
386
408
  if (scrollRaf.current) return;
387
409
  scrollRaf.current = requestAnimationFrame(() => {
388
410
  scrollRaf.current = 0;
389
411
  const container = containerRef.current;
390
412
  if (!container) return;
413
+ if (!offsetsRef.current) {
414
+ // A wrapper that hasn't mounted inherits its predecessor's top, keeping
415
+ // the list non-decreasing so the search below stays valid.
416
+ let last = -1;
417
+ offsetsRef.current = wrapperRefs.current.map((el) => {
418
+ last = el?.offsetTop ?? last;
419
+ return last;
420
+ });
421
+ }
422
+ const offsets = offsetsRef.current;
391
423
  // Current page = the last one whose top has passed the upper 40% of the
392
424
  // viewport — matches how the native viewer flips its indicator.
393
425
  const threshold = container.scrollTop + container.clientHeight * 0.4;
394
- let current = 1;
395
- wrapperRefs.current.forEach((el, i) => {
396
- if (el && el.offsetTop <= threshold) current = i + 1;
397
- });
426
+ let lo = 0;
427
+ let hi = offsets.length - 1;
428
+ let found = 0;
429
+ while (lo <= hi) {
430
+ const mid = (lo + hi) >> 1;
431
+ if (offsets[mid] <= threshold) {
432
+ found = mid;
433
+ lo = mid + 1;
434
+ } else {
435
+ hi = mid - 1;
436
+ }
437
+ }
438
+ const current = found + 1;
398
439
  if (current !== currentPageRef.current) {
399
440
  currentPageRef.current = current;
400
441
  callbacks.current.onPageChange(current);
@@ -159,6 +159,13 @@ export function subscribeDocFile(cb: (sectionId: string | null) => void): () =>
159
159
  window.removeEventListener("hashchange", onHash);
160
160
  window.removeEventListener("popstate", onPop);
161
161
  unlistenRequest();
162
+ // The return-glide timer would otherwise outlive the last subscriber and
163
+ // strip a class off a document nothing is watching any more.
164
+ if (s.closingTimer !== null) {
165
+ clearTimeout(s.closingTimer);
166
+ s.closingTimer = null;
167
+ document.documentElement.classList.remove(DOC_CLOSING_CLASS);
168
+ }
162
169
  };
163
170
  // Deep link: the hash is an INPUT, read once when the first listener wires up.
164
171
  syncFromHash();
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, type ReactNode } from "react";
1
+ import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
2
2
  import { cn } from "../../lib/cn";
3
3
  import { Popover } from "../shared/Popover";
4
4
 
@@ -43,6 +43,10 @@ export function IndicatorPill({
43
43
  }
44
44
  }, []);
45
45
 
46
+ // The close-delay timer outlives the pill otherwise — a hover-then-navigate
47
+ // leaves it pending with nothing to update.
48
+ useEffect(() => clearHoverTimeout, [clearHoverTimeout]);
49
+
46
50
  function handleMouseEnter() {
47
51
  clearHoverTimeout();
48
52
  if (!disabled && !clickOpen && hoverContent) {
@@ -25,8 +25,13 @@ interface Props {
25
25
  onMove: (from: NavLocation, to: NavLocation) => void;
26
26
  /** Fires when THIS group starts/stops being dragged, so the modal can collapse
27
27
  * it for the duration. Without that, its members stay rendered in place and
28
- * the drag reads as moving only the header. */
29
- onDragStateChange?: (dragging: boolean) => void;
28
+ * the drag reads as moving only the header.
29
+ *
30
+ * Takes the group id (rather than closing over it in the parent) so the
31
+ * handler can be a single stable callback: an identity that changed every
32
+ * parent render re-fired this effect in EVERY mounted row, and the
33
+ * non-dragging ones then cleared the dragging row's state. */
34
+ onDragStateChange?: (groupId: string, dragging: boolean) => void;
30
35
  }
31
36
 
32
37
  export function NavGroupRow({
@@ -44,7 +49,7 @@ export function NavGroupRow({
44
49
 
45
50
  // Surface the hook's drag state upward so the modal can collapse this group
46
51
  // while it is the one being dragged.
47
- useEffect(() => { onDragStateChange?.(isDragging); }, [isDragging, onDragStateChange]);
52
+ useEffect(() => { onDragStateChange?.(group.id, isDragging); }, [group.id, isDragging, onDragStateChange]);
48
53
 
49
54
  const label = groupDisplayLabel(group.label);
50
55
  const labelError = group.label.trim() === "" ? "Label is required." : null;
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useCallback, useState, useEffect } from "react";
2
2
  import { Check, ChevronDown, Home } from "lucide-react";
3
3
  import type { SiteIndex, Page } from "../../schemas/site-config";
4
4
  import type { Audience } from "../../auth/types";
@@ -54,6 +54,14 @@ export function PagesModal(props: PagesModalProps) {
54
54
  // Groups are otherwise always expanded — there is no editor-side collapse, so
55
55
  // no collapsed-group rescue (spring-load, reveal-on-drop) is needed.
56
56
  const [draggingGroupId, setDraggingGroupId] = useState<string | null>(null);
57
+ // Stable identity, and a guarded clear. An inline arrow per row re-fired every
58
+ // NavGroupRow's drag effect on every render of this modal, and the rows that
59
+ // weren't dragging then reset the dragging one's id — so with two or more
60
+ // groups the collapse never engaged (and A/B fought over the state for the
61
+ // drag's duration). Only the row that claimed the id may release it.
62
+ const handleGroupDragState = useCallback((groupId: string, dragging: boolean) => {
63
+ setDraggingGroupId((cur) => (dragging ? groupId : cur === groupId ? null : cur));
64
+ }, []);
57
65
 
58
66
  if (deletingId) {
59
67
  const page = index.pages.find((p) => p.id === deletingId);
@@ -132,7 +140,7 @@ export function PagesModal(props: PagesModalProps) {
132
140
  onSetStartCollapsed={(v) => props.onSetGroupStartCollapsed(entry.id, v)}
133
141
  onDelete={() => props.onDeleteGroup(entry.id)}
134
142
  onMove={props.onMoveNavEntry}
135
- onDragStateChange={(dragging) => setDraggingGroupId(dragging ? entry.id : null)}
143
+ onDragStateChange={handleGroupDragState}
136
144
  />
137
145
  {expanded && (
138
146
  <div data-testid={`nav-group-members-${entry.id}`} className="ml-6 flex flex-col gap-2">
@@ -89,6 +89,14 @@ function ListRow({
89
89
  const rowRef = useRef<HTMLDivElement>(null);
90
90
  const handleRef = useRef<HTMLButtonElement>(null);
91
91
 
92
+ // onReorder's identity chains back to the editor shell's inline onChange, so it
93
+ // changes on every editor render. Listing it in the deps below re-registered the
94
+ // draggable — re-awaiting the dynamic import — on every one of those, and a
95
+ // re-registration landing mid-drag cancels the drag. Read it through a ref, as
96
+ // IconList's monitor and EditableGrid already do.
97
+ const onReorderRef = useRef(onReorder);
98
+ onReorderRef.current = onReorder;
99
+
92
100
  useEffect(() => {
93
101
  const row = rowRef.current;
94
102
  const handle = handleRef.current;
@@ -116,7 +124,7 @@ function ListRow({
116
124
  onDrop: ({ source }) => {
117
125
  const fromIndex = source.data.index as number;
118
126
  if (fromIndex !== index) {
119
- onReorder(fromIndex, index);
127
+ onReorderRef.current(fromIndex, index);
120
128
  }
121
129
  },
122
130
  });
@@ -131,7 +139,7 @@ function ListRow({
131
139
  cancelled = true;
132
140
  cleanup?.();
133
141
  };
134
- }, [id, index, onReorder, setDragState]);
142
+ }, [id, index, setDragState]);
135
143
 
136
144
  const isDropTarget = dragState.targetId === id && dragState.sourceId !== id;
137
145
 
@@ -1,7 +1,7 @@
1
1
  import { useRef, useCallback, useEffect } from "react";
2
2
  import { EditorContent } from "@tiptap/react";
3
3
  import { BubbleMenu } from "@tiptap/react/menus";
4
- import { sanitizeHtml } from "../../lib/sanitize";
4
+ import { useSanitizedHtml } from "../../hooks/useSanitizedHtml";
5
5
  import { useEditableRichText } from "./useEditableRichText";
6
6
  import { RichTextToolbar } from "./RichTextToolbar";
7
7
  import type { PresetName } from "./tiptap-presets";
@@ -32,6 +32,7 @@ export function EditableRichText({
32
32
  const { isEditorActive, editor, activate, deactivate } =
33
33
  useEditableRichText({ value, onChange, preset, placeholder });
34
34
  const wrapperRef = useRef<HTMLDivElement>(null);
35
+ const safeHtml = useSanitizedHtml(value);
35
36
  const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
36
37
  const clickCoordsRef = useRef<{ left: number; top: number } | null>(null);
37
38
 
@@ -100,7 +101,7 @@ export function EditableRichText({
100
101
  return (
101
102
  <div
102
103
  className={className}
103
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(value) }}
104
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
104
105
  />
105
106
  );
106
107
  }
@@ -122,7 +123,7 @@ export function EditableRichText({
122
123
  onClick={handleClick}
123
124
  // Content is sanitized via DOMPurify — safe for trusted project JSON.
124
125
  // eslint-disable-next-line react/no-danger
125
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(value) }}
126
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
126
127
  />
127
128
  );
128
129
  }
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import type { Editor } from "@tiptap/core";
3
3
  import { Button } from "../shared/Button";
4
4
  import { usePages } from "../shared/PagesContext";
@@ -27,6 +27,27 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
27
27
  );
28
28
  const hasLink = Boolean(existingAttrs.href);
29
29
  const headings = pageId ? getPageHeadings(pageId) : [];
30
+ const panelRef = useRef<HTMLDivElement>(null);
31
+
32
+ // Every sibling picker (IconPicker, FontPicker, SectionTypePicker) dismisses on
33
+ // an outside mousedown; this one only closed through its own buttons, so clicking
34
+ // back into the prose left it floating over the document. Escape is bound at the
35
+ // document too, because focus can be anywhere by the time the editor wants out —
36
+ // the container's own keydown only fires while focus is inside the panel.
37
+ useEffect(() => {
38
+ function onMouseDown(e: MouseEvent) {
39
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
40
+ }
41
+ function onKeyDown(e: KeyboardEvent) {
42
+ if (e.key === "Escape") onClose();
43
+ }
44
+ document.addEventListener("mousedown", onMouseDown);
45
+ document.addEventListener("keydown", onKeyDown);
46
+ return () => {
47
+ document.removeEventListener("mousedown", onMouseDown);
48
+ document.removeEventListener("keydown", onKeyDown);
49
+ };
50
+ }, [onClose]);
30
51
 
31
52
  const handleApply = () => {
32
53
  if (mode === "internal") {
@@ -66,6 +87,7 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
66
87
 
67
88
  return (
68
89
  <div
90
+ ref={panelRef}
69
91
  className="flex w-64 flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
70
92
  onMouseDown={(e) => e.stopPropagation()}
71
93
  onKeyDown={handleKeyDown}
@@ -9,3 +9,8 @@ export { ResolvedMedia } from "./ResolvedMedia";
9
9
  export { LinkPopover } from "./LinkPopover";
10
10
  export { defineHeadingSection } from "./defineHeadingSection";
11
11
  export { MediaBlock } from "../sections/Media/MediaBlock";
12
+ // Link selector (external URL / internal page + section) plus the pages context
13
+ // that feeds its dropdowns, so custom sections can reuse the same link picker the
14
+ // Button/Media sections use and derive labels from the live page/heading list.
15
+ export { LinkField } from "../shared/LinkField";
16
+ export { usePages, PagesContext, type PagesContextValue } from "../shared/PagesContext";
@@ -40,79 +40,99 @@ export function useEditableCollection<T>({
40
40
  wrapItems(items)
41
41
  );
42
42
  const prevItemsRef = useRef(items);
43
+ // State updaters must be pure. Every mutator below used to call the parent's
44
+ // onChange from inside setWrappedItems, so React 18/19 StrictMode's dev
45
+ // double-invoke fired it twice (deleting one icon-list row emitted two
46
+ // changes), and React is free to re-run or discard an updater at any time.
47
+ // Mirroring the list here lets each mutator compute `next` outside the
48
+ // updater; the eager write keeps two mutations in one tick consistent.
49
+ const wrappedRef = useRef(wrappedItems);
50
+ wrappedRef.current = wrappedItems;
51
+
52
+ const commit = useCallback(
53
+ (next: WrappedItem<T>[]) => {
54
+ wrappedRef.current = next;
55
+ setWrappedItems(next);
56
+ onChange(next.map((w) => w.data));
57
+ },
58
+ [onChange],
59
+ );
43
60
 
44
61
  useEffect(() => {
45
62
  if (items === prevItemsRef.current) return;
46
63
  prevItemsRef.current = items;
47
64
 
48
65
  setWrappedItems((prev) => {
49
- return items.map((data, i) => {
50
- if (i < prev.length) {
51
- return { id: prev[i].id, data };
52
- }
53
- return { id: uid(), data };
66
+ // Identity first, position only for the remainder. Keyed purely by index,
67
+ // a parent-side removal or reorder re-pointed every id one slot over:
68
+ // React reused the wrong row's DOM, so the focused input, the in-progress
69
+ // edit and the drag registration all belonged to a different item than
70
+ // the one now rendered there. Pure identity would be worse in the other
71
+ // direction — an in-place edit hands back a fresh object for that row, and
72
+ // minting a new id there remounts the field the user is typing into.
73
+ const taken = new Array<boolean>(prev.length).fill(false);
74
+ const slotsByData = new Map<unknown, number[]>();
75
+ prev.forEach((w, i) => {
76
+ const bucket = slotsByData.get(w.data);
77
+ if (bucket) bucket.push(i);
78
+ else slotsByData.set(w.data, [i]);
54
79
  });
80
+
81
+ const matched = items.map((data) => {
82
+ const slot = slotsByData.get(data)?.shift();
83
+ if (slot === undefined) return null;
84
+ taken[slot] = true;
85
+ return prev[slot].id;
86
+ });
87
+
88
+ // Unmatched rows (edited in place, or genuinely new) take the surviving
89
+ // ids in their original order, which for a pure edit is the same id the
90
+ // row already had.
91
+ const spare = prev.filter((_, i) => !taken[i]).map((w) => w.id);
92
+ let next = 0;
93
+ return items.map((data, i) => ({ id: matched[i] ?? spare[next++] ?? uid(), data }));
55
94
  });
56
95
  }, [items]);
57
96
 
58
97
  const onReorder = useCallback(
59
98
  (fromIndex: number, toIndex: number) => {
60
- setWrappedItems((prev) => {
61
- const next = [...prev];
62
- const [moved] = next.splice(fromIndex, 1);
63
- next.splice(toIndex, 0, moved);
64
- onChange(next.map((w) => w.data));
65
- return next;
66
- });
99
+ const next = [...wrappedRef.current];
100
+ const [moved] = next.splice(fromIndex, 1);
101
+ next.splice(toIndex, 0, moved);
102
+ commit(next);
67
103
  },
68
- [onChange]
104
+ [commit]
69
105
  );
70
106
 
71
107
  const onAdd = useCallback(() => {
72
- const newItem = createItem();
73
- setWrappedItems((prev) => {
74
- const next = [...prev, { id: uid(), data: newItem }];
75
- onChange(next.map((w) => w.data));
76
- return next;
77
- });
78
- }, [createItem, onChange]);
108
+ commit([...wrappedRef.current, { id: uid(), data: createItem() }]);
109
+ }, [createItem, commit]);
79
110
 
80
111
  const onInsert = useCallback(
81
112
  (index: number) => {
82
- const newItem = createItem();
83
- setWrappedItems((prev) => {
84
- const next = [...prev];
85
- next.splice(index, 0, { id: uid(), data: newItem });
86
- onChange(next.map((w) => w.data));
87
- return next;
88
- });
113
+ const next = [...wrappedRef.current];
114
+ next.splice(index, 0, { id: uid(), data: createItem() });
115
+ commit(next);
89
116
  },
90
- [createItem, onChange],
117
+ [createItem, commit],
91
118
  );
92
119
 
93
120
  // Insert an externally-provided item (e.g. one dragged in from another grid)
94
121
  // at a specific index. Distinct from onInsert, which creates a blank item.
95
122
  const onInsertItem = useCallback(
96
123
  (index: number, data: T) => {
97
- setWrappedItems((prev) => {
98
- const next = [...prev];
99
- next.splice(index, 0, { id: uid(), data });
100
- onChange(next.map((w) => w.data));
101
- return next;
102
- });
124
+ const next = [...wrappedRef.current];
125
+ next.splice(index, 0, { id: uid(), data });
126
+ commit(next);
103
127
  },
104
- [onChange]
128
+ [commit]
105
129
  );
106
130
 
107
131
  const onRemove = useCallback(
108
132
  (id: string) => {
109
- setWrappedItems((prev) => {
110
- const next = prev.filter((w) => w.id !== id);
111
- onChange(next.map((w) => w.data));
112
- return next;
113
- });
133
+ commit(wrappedRef.current.filter((w) => w.id !== id));
114
134
  },
115
- [onChange]
135
+ [commit]
116
136
  );
117
137
 
118
138
  return { wrappedItems, onReorder, onAdd, onInsert, onInsertItem, onRemove };