@drawnagency/primitives 0.1.109 → 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 (205) 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-WXBHP4JY.js → chunk-3GAG6ZXB.js} +55 -46
  9. package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
  10. package/dist/{chunk-5PVRACZY.js → chunk-7MI3HCOF.js} +60 -23
  11. package/dist/{chunk-WBDPDGFA.js → chunk-7WQJ57XG.js} +6 -31
  12. package/dist/{chunk-LQHYVPBS.js → chunk-ALMEC3XN.js} +11 -12
  13. package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
  14. package/dist/{chunk-ZKVKNUJC.js → chunk-ELQ4C677.js} +1 -1
  15. package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
  16. package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
  17. package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
  18. package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
  19. package/dist/{chunk-67ZOQCUD.js → chunk-NXGIHSL5.js} +67 -50
  20. package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
  21. package/dist/{chunk-XIDE4TDF.js → chunk-OUYB2GCW.js} +1 -1
  22. package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
  23. package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
  24. package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
  25. package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
  26. package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
  27. package/dist/collab/rebase.d.ts +6 -0
  28. package/dist/collab/rebase.d.ts.map +1 -1
  29. package/dist/collab/rebase.js +12 -3
  30. package/dist/collab/recovery.d.ts +0 -20
  31. package/dist/collab/recovery.d.ts.map +1 -1
  32. package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
  33. package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
  34. package/dist/components/brandguide/colors-shared.d.ts +10 -1
  35. package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
  36. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  37. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  38. package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
  39. package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
  40. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  41. package/dist/components/editor/NavGroupRow.d.ts +7 -2
  42. package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
  43. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  44. package/dist/components/editor/index.js +13 -12
  45. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  46. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  47. package/dist/components/primitives/index.js +14 -12
  48. package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
  49. package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
  50. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  51. package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
  52. package/dist/components/sections/brand-guide.js +8 -8
  53. package/dist/components/sections/register-schemas.js +14 -14
  54. package/dist/components/sections/register.js +14 -14
  55. package/dist/components/shared/FontPicker.d.ts.map +1 -1
  56. package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
  57. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  58. package/dist/components/shared/Navigation.d.ts.map +1 -1
  59. package/dist/components/shared/Popover.d.ts.map +1 -1
  60. package/dist/components/shell/AudienceAddForm.d.ts +1 -5
  61. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  62. package/dist/components/shell/AudienceRow.d.ts +2 -3
  63. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  64. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  65. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  66. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  67. package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
  68. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  69. package/dist/components/shell/view-branch.d.ts.map +1 -1
  70. package/dist/document/components/DocRail.d.ts.map +1 -1
  71. package/dist/document/components/DocViewer.d.ts.map +1 -1
  72. package/dist/document/components/measure.d.ts.map +1 -1
  73. package/dist/document/components/useDocPages.d.ts.map +1 -1
  74. package/dist/document/engine/blocks.d.ts.map +1 -1
  75. package/dist/document/engine/masonry.d.ts.map +1 -1
  76. package/dist/document/engine/paginate.d.ts.map +1 -1
  77. package/dist/document/engine/settings.d.ts +3 -0
  78. package/dist/document/engine/settings.d.ts.map +1 -1
  79. package/dist/document/index.js +79 -28
  80. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  81. package/dist/hooks/useDocumentUploads.d.ts +1 -0
  82. package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
  83. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  84. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  85. package/dist/hooks/useFocusTrap.d.ts +6 -0
  86. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  87. package/dist/hooks/useMediaPipeline.d.ts +3 -2
  88. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  89. package/dist/hooks/useSanitizedHtml.d.ts +12 -0
  90. package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
  91. package/dist/index.js +19 -13
  92. package/dist/lib/block-move.d.ts.map +1 -1
  93. package/dist/lib/contrast.d.ts.map +1 -1
  94. package/dist/lib/dexie.d.ts +2 -8
  95. package/dist/lib/dexie.d.ts.map +1 -1
  96. package/dist/lib/dexie.js +21 -49
  97. package/dist/lib/index-ops.d.ts.map +1 -1
  98. package/dist/lib/index-ops.js +43 -23
  99. package/dist/lib/index.js +8 -8
  100. package/dist/lib/loader.d.ts.map +1 -1
  101. package/dist/lib/media-folders.d.ts +12 -4
  102. package/dist/lib/media-folders.d.ts.map +1 -1
  103. package/dist/lib/media-folders.js +12 -2
  104. package/dist/lib/migrate-sections-transform.js +1 -1
  105. package/dist/lib/nav-tree.d.ts.map +1 -1
  106. package/dist/lib/nav.d.ts.map +1 -1
  107. package/dist/lib/pages.d.ts.map +1 -1
  108. package/dist/lib/registry.d.ts.map +1 -1
  109. package/dist/lib/registry.js +1 -1
  110. package/dist/lib/sanitize.d.ts +6 -0
  111. package/dist/lib/sanitize.d.ts.map +1 -1
  112. package/dist/lib/timestamp.d.ts.map +1 -1
  113. package/dist/media/index.js +4 -4
  114. package/dist/media/queue.d.ts.map +1 -1
  115. package/dist/media/resolve.d.ts.map +1 -1
  116. package/dist/media/utils.d.ts.map +1 -1
  117. package/dist/media/videoPoster.d.ts.map +1 -1
  118. package/dist/schemas/audience.d.ts +5 -1
  119. package/dist/schemas/audience.d.ts.map +1 -1
  120. package/dist/schemas/audience.js +1 -1
  121. package/dist/schemas/index.js +8 -6
  122. package/dist/schemas/sections.d.ts +12 -0
  123. package/dist/schemas/sections.d.ts.map +1 -1
  124. package/dist/storage/index.d.ts +1 -1
  125. package/dist/storage/index.d.ts.map +1 -1
  126. package/dist/storage/index.js +3 -1
  127. package/dist/storage/types.d.ts +9 -0
  128. package/dist/storage/types.d.ts.map +1 -1
  129. package/dist/types/database.d.ts +0 -3
  130. package/dist/types/database.d.ts.map +1 -1
  131. package/package.json +1 -1
  132. package/src/auth/cookies.ts +11 -0
  133. package/src/auth/index.ts +1 -1
  134. package/src/auth/types.ts +18 -4
  135. package/src/collab/rebase.ts +26 -1
  136. package/src/collab/recovery.ts +29 -3
  137. package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
  138. package/src/components/brandguide/ColorsEditable.tsx +7 -9
  139. package/src/components/brandguide/ColorsView.tsx +12 -11
  140. package/src/components/brandguide/colors-shared.ts +17 -1
  141. package/src/components/chat/ChatPanel.tsx +19 -0
  142. package/src/components/docfile/DocFilePane.tsx +11 -0
  143. package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
  144. package/src/components/docfile/doc-file-state.ts +7 -0
  145. package/src/components/editor/IndicatorPill.tsx +5 -1
  146. package/src/components/editor/NavGroupRow.tsx +8 -3
  147. package/src/components/editor/PagesModal.tsx +10 -2
  148. package/src/components/primitives/EditableList.tsx +10 -2
  149. package/src/components/primitives/EditableRichText.tsx +4 -3
  150. package/src/components/primitives/LinkPopover.tsx +23 -1
  151. package/src/components/primitives/useEditableCollection.ts +61 -41
  152. package/src/components/primitives/useEditablePlainText.ts +24 -3
  153. package/src/components/sections/IconList/index.tsx +8 -1
  154. package/src/components/sections/Prose/Prose.tsx +4 -2
  155. package/src/components/shared/FontPicker.tsx +16 -4
  156. package/src/components/shared/HistoryChangesModal.tsx +12 -1
  157. package/src/components/shared/HistoryPopover.tsx +18 -4
  158. package/src/components/shared/Navigation.tsx +20 -2
  159. package/src/components/shared/Popover.tsx +9 -5
  160. package/src/components/shell/AudienceAddForm.tsx +6 -26
  161. package/src/components/shell/AudienceRow.tsx +29 -39
  162. package/src/components/shell/BugReportFAB.tsx +20 -2
  163. package/src/components/shell/EditorShell.tsx +200 -50
  164. package/src/components/shell/MediaLibraryModal.tsx +52 -10
  165. package/src/components/shell/SiteSettingsUsers.tsx +3 -0
  166. package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
  167. package/src/components/shell/view-branch.ts +4 -1
  168. package/src/document/components/DocRail.tsx +27 -2
  169. package/src/document/components/DocViewer.tsx +5 -1
  170. package/src/document/components/measure.tsx +22 -6
  171. package/src/document/components/useDocPages.ts +13 -3
  172. package/src/document/engine/blocks.ts +33 -4
  173. package/src/document/engine/masonry.ts +8 -2
  174. package/src/document/engine/paginate.ts +4 -1
  175. package/src/document/engine/settings.ts +40 -3
  176. package/src/hooks/useBuildStatus.ts +6 -0
  177. package/src/hooks/useDocumentUploads.ts +46 -3
  178. package/src/hooks/useEditorPersistence.ts +33 -19
  179. package/src/hooks/useEditorPublish.ts +83 -34
  180. package/src/hooks/useFocusTrap.ts +18 -0
  181. package/src/hooks/useMediaPipeline.ts +111 -38
  182. package/src/hooks/useSanitizedHtml.ts +29 -0
  183. package/src/lib/audience-secret.ts +1 -0
  184. package/src/lib/block-move.ts +13 -2
  185. package/src/lib/contrast.ts +14 -14
  186. package/src/lib/dexie.ts +46 -49
  187. package/src/lib/index-ops.ts +63 -20
  188. package/src/lib/loader.ts +41 -12
  189. package/src/lib/media-folders.ts +31 -5
  190. package/src/lib/migrate-sections-transform.ts +22 -2
  191. package/src/lib/nav-tree.ts +16 -2
  192. package/src/lib/nav.ts +10 -1
  193. package/src/lib/pages.ts +24 -6
  194. package/src/lib/registry.ts +3 -0
  195. package/src/lib/sanitize.ts +9 -0
  196. package/src/lib/timestamp.ts +5 -0
  197. package/src/media/queue.ts +4 -0
  198. package/src/media/resolve.ts +14 -1
  199. package/src/media/utils.ts +3 -1
  200. package/src/media/videoPoster.ts +14 -3
  201. package/src/schemas/audience.ts +10 -2
  202. package/src/schemas/sections.ts +23 -1
  203. package/src/storage/index.ts +1 -1
  204. package/src/storage/types.ts +13 -0
  205. package/src/types/database.ts +0 -3
@@ -13,12 +13,6 @@ interface Props {
13
13
  onAudiencesChange: (audiences: Audience[]) => void;
14
14
  }
15
15
 
16
- /** 32 random hex chars — long enough that it can never be guessed or typed. */
17
- function unusablePassword(): string {
18
- const bytes = crypto.getRandomValues(new Uint8Array(16));
19
- return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
20
- }
21
-
22
16
  export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, onAudiencesChange }: Props) {
23
17
  const [audiences, setAudiences] = useState<Audience[]>([]);
24
18
  // Null until we know: undefined-vs-empty matters, because an empty ARRAY means
@@ -29,6 +23,7 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
29
23
  const [loading, setLoading] = useState(true);
30
24
  const [showAddForm, setShowAddForm] = useState(false);
31
25
  const [toggleError, setToggleError] = useState<string | null>(null);
26
+ const [loadError, setLoadError] = useState<string | null>(null);
32
27
 
33
28
  const loadAudiences = useCallback(async () => {
34
29
  try {
@@ -36,13 +31,23 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
36
31
  fetch("/api/auth/audiences"),
37
32
  fetch("/api/auth/credentials"),
38
33
  ]);
34
+ // A 4xx/5xx body that happens to parse as JSON used to yield `[]`, which
35
+ // was pushed up through onAudiencesChange — emptying every section's
36
+ // audience picker and making filterAccessToKnown strip the configured
37
+ // default access from every newly-added section. An unreadable response
38
+ // must leave the known audiences alone, not assert there are none.
39
+ if (!audienceRes.ok) throw new Error(`audiences ${audienceRes.status}`);
39
40
  const data = await audienceRes.json();
40
- const list = data.audiences ?? [];
41
+ if (!Array.isArray(data?.audiences)) throw new Error("malformed audiences response");
42
+ const list = data.audiences as Audience[];
41
43
  setAudiences(list);
42
44
  onAudiencesChange(list);
43
45
  setPasswordEnabled(data.passwordEnabled ?? true);
44
46
  // 501 = no credential support (`.env` mode) — stay in audience-password UI.
45
47
  setCredentials(credentialRes.ok ? (await credentialRes.json()).credentials ?? [] : null);
48
+ } catch (err) {
49
+ console.error("[SiteSettingsViewerAccess] Failed to load audiences:", err);
50
+ setLoadError("Couldn't load viewer access — reload to try again.");
46
51
  } finally {
47
52
  setLoading(false);
48
53
  }
@@ -73,16 +78,11 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
73
78
  }
74
79
  }
75
80
 
76
- async function handleAdd(data: { displayName: string; color: string | null; password: string }) {
77
- // In credential mode the audience-level password is vestigial but the column
78
- // is NOT NULL, so it gets an unguessable value nobody is ever told. Writing
79
- // an empty one would leave the audience walk-in-able through the legacy
80
- // dropdown login if this site were ever rolled back to an older deployment.
81
- const password = data.password.length > 0 ? data.password : unusablePassword();
81
+ async function handleAdd(data: { displayName: string; color: string | null }) {
82
82
  const res = await fetch("/api/auth/audiences", {
83
83
  method: "POST",
84
84
  headers: { "Content-Type": "application/json" },
85
- body: JSON.stringify({ ...data, password }),
85
+ body: JSON.stringify(data),
86
86
  });
87
87
  if (!res.ok) {
88
88
  const err = await res.json().catch(() => ({}));
@@ -94,7 +94,7 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
94
94
 
95
95
  async function handleSave(
96
96
  name: string,
97
- patch: { displayName?: string; color?: string | null; password?: string },
97
+ patch: { displayName?: string; color?: string | null },
98
98
  ) {
99
99
  const res = await fetch("/api/auth/audiences", {
100
100
  method: "PATCH",
@@ -150,6 +150,12 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
150
150
  return <p className="text-sm text-base-contrast-light">Loading audiences...</p>;
151
151
  }
152
152
 
153
+ // Deliberately a hard stop rather than an empty list: rendering the normal UI
154
+ // here would show "no audiences" for a site that has them.
155
+ if (loadError) {
156
+ return <p className="text-sm text-red-600">{loadError}</p>;
157
+ }
158
+
153
159
  return (
154
160
  <div className="space-y-4">
155
161
  {!audienceManagement && (
@@ -209,7 +215,6 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
209
215
  <AudienceAddForm
210
216
  onSubmit={handleAdd}
211
217
  onCancel={() => setShowAddForm(false)}
212
- requirePassword={credentials === null}
213
218
  />
214
219
  ) : (
215
220
  <Button variant="secondary" onClick={() => setShowAddForm(true)}>
@@ -41,7 +41,10 @@ export function computeDisplaySections<T extends { section: { id: string }; meta
41
41
  }
42
42
 
43
43
  if (viewSections === null) return [];
44
+ // Indexed, not a `find` per id: this runs on every render of the published
45
+ // view, and a 200-section page against a 200-section fetch is 40k comparisons.
46
+ const byId = new Map(viewSections.map((s) => [s.section.id, s]));
44
47
  return activePageSectionIds
45
- .map((id) => viewSections.find((s) => s.section.id === id))
48
+ .map((id) => byId.get(id))
46
49
  .filter((s): s is T => !!s && isSectionVisibleInViewBranch(s.meta.status, "live"));
47
50
  }
@@ -42,8 +42,33 @@ function ThumbButton({
42
42
  onSelect: (id: string) => void;
43
43
  children: ReactNode;
44
44
  }) {
45
+ // Each thumbnail is a REAL page subtree — every image at print resolution,
46
+ // scaled to ~11%. Mounting all of them cost a 40-page document 40 subtrees and
47
+ // a fresh request for every image before the rail was even scrolled. The
48
+ // aspect-ratio box below holds its size whether or not the page is mounted, so
49
+ // deferring costs nothing in layout. Sticky once shown: re-unmounting would
50
+ // re-request the images on every scroll back.
51
+ const liRef = useRef<HTMLLIElement>(null);
52
+ const [shown, setShown] = useState(typeof IntersectionObserver === "undefined");
53
+ useEffect(() => {
54
+ if (shown) return;
55
+ const el = liRef.current;
56
+ if (!el) return;
57
+ const obs = new IntersectionObserver(
58
+ (entries) => {
59
+ if (entries.some((e) => e.isIntersecting)) {
60
+ setShown(true);
61
+ obs.disconnect();
62
+ }
63
+ },
64
+ { rootMargin: "200% 0px" },
65
+ );
66
+ obs.observe(el);
67
+ return () => obs.disconnect();
68
+ }, [shown]);
69
+
45
70
  return (
46
- <li className={indentClass(indentLevel ?? 0)}>
71
+ <li ref={liRef} className={indentClass(indentLevel ?? 0)}>
47
72
  <button
48
73
  type="button"
49
74
  onClick={() => onSelect(id)}
@@ -56,7 +81,7 @@ function ThumbButton({
56
81
  className="block origin-top-left"
57
82
  style={{ transform: `scale(${scale})`, width: PAGE_W, height: PAGE_H }}
58
83
  >
59
- {children}
84
+ {shown ? children : null}
60
85
  </span>
61
86
  </span>
62
87
  <span className="absolute bottom-0.5 left-1 max-w-[90%] truncate text-[10px] text-neutral-400">
@@ -130,6 +130,10 @@ export function DocViewer({
130
130
  const activeIdRef = useRef(activeId);
131
131
  activeIdRef.current = activeId;
132
132
  useEffect(() => {
133
+ // Present mode only. In the editor overlay the same keys belong to the
134
+ // surface the author is working in, and a window-level listener stole them
135
+ // to flip the previewed page instead.
136
+ if (mode !== "present") return;
133
137
  const onKey = (e: KeyboardEvent) => {
134
138
  const t = e.target as HTMLElement | null;
135
139
  if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
@@ -145,7 +149,7 @@ export function DocViewer({
145
149
  };
146
150
  window.addEventListener("keydown", onKey);
147
151
  return () => window.removeEventListener("keydown", onKey);
148
- }, [entries, selectEntry]);
152
+ }, [entries, selectEntry, mode]);
149
153
 
150
154
  const active = entries.find((e) => entryId(e) === activeId) ?? null;
151
155
 
@@ -1,4 +1,4 @@
1
- import { useEffect, useLayoutEffect, useState } from "react";
1
+ import { useEffect, useLayoutEffect, useMemo, useState } from "react";
2
2
  import { DOC_GEOM } from "../engine/paginate";
3
3
  import type { SectionSettings } from "../engine/settings";
4
4
  import type { Chunk, TextBlock } from "../engine/types";
@@ -60,7 +60,10 @@ export function useTextMeasurements(
60
60
  };
61
61
  }, []);
62
62
 
63
- const signature = signatureFor(chunks, settingsMap);
63
+ // Memoized: this walks every chunk's full prose HTML through JSON.stringify,
64
+ // and it ran on every DocViewer render. Both inputs are memoized upstream
65
+ // (useDocPages), so the identity check actually holds.
66
+ const signature = useMemo(() => signatureFor(chunks, settingsMap), [chunks, settingsMap]);
64
67
 
65
68
  useLayoutEffect(() => {
66
69
  if (!fontsReady) return;
@@ -68,6 +71,10 @@ export function useTextMeasurements(
68
71
  const next: Record<string, number[]> = {};
69
72
  const nextW: Record<string, number> = {};
70
73
 
74
+ // WRITE PASS. Every wrapper is appended before anything is read: interleaving
75
+ // the two forced a synchronous reflow per text block, so a 200-block document
76
+ // flushed layout ~400 times inside this one layout effect.
77
+ const pending: { chunkId: string; wrappers: HTMLDivElement[] }[] = [];
71
78
  for (const chunk of chunks) {
72
79
  if (chunk.textBlocks.length === 0) {
73
80
  next[chunk.id] = [];
@@ -80,8 +87,7 @@ export function useTextMeasurements(
80
87
  document.body.appendChild(container);
81
88
  containers.push(container);
82
89
 
83
- const measured: number[] = [];
84
- let wMax = 0;
90
+ const wrappers: HTMLDivElement[] = [];
85
91
  for (const block of chunk.textBlocks) {
86
92
  const wrapper = document.createElement("div");
87
93
  wrapper.className = blockClassName(block);
@@ -96,11 +102,21 @@ export function useTextMeasurements(
96
102
  wrapper.style.width = "max-content";
97
103
  wrapper.style.maxWidth = "100%";
98
104
  container.appendChild(wrapper);
105
+ wrappers.push(wrapper);
106
+ }
107
+ pending.push({ chunkId: chunk.id, wrappers });
108
+ }
109
+
110
+ // READ PASS. One layout for the whole document.
111
+ for (const { chunkId, wrappers } of pending) {
112
+ const measured: number[] = [];
113
+ let wMax = 0;
114
+ for (const wrapper of wrappers) {
99
115
  measured.push(wrapper.offsetHeight / PX_PER_IN);
100
116
  wMax = Math.max(wMax, wrapper.getBoundingClientRect().width / PX_PER_IN);
101
117
  }
102
- next[chunk.id] = measured;
103
- nextW[chunk.id] = wMax;
118
+ next[chunkId] = measured;
119
+ nextW[chunkId] = wMax;
104
120
  }
105
121
 
106
122
  setHeights(next);
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import { harvest } from "../engine/harvest";
3
3
  import { DOC_GEOM, paginateChunk, type DerivedPage } from "../engine/paginate";
4
- import { settingsFor, type SectionSettings } from "../engine/settings";
4
+ import { settingsFor, settingsIssuesFor, type SectionSettings } from "../engine/settings";
5
5
  import type { Chunk, DocSourceContent, SplitAt } from "../engine/types";
6
6
  import { useTextMeasurements } from "./measure";
7
7
 
@@ -75,11 +75,21 @@ export function useDocPages(input: {
75
75
  for (const c of chunks) {
76
76
  const noContent = c.textBlocks.length === 0 && c.images.length === 0;
77
77
  const pageWarnings = pages.filter((p) => p.chunkId === c.id).flatMap((p) => p.warnings);
78
- const warnings = noContent ? ["Section has no content", ...pageWarnings] : pageWarnings;
78
+ // A stored setting the schema no longer recognises is reset to its
79
+ // default. Silent, that reads as the document ignoring an explicit
80
+ // choice — most visibly `excluded`, whose default puts the section back
81
+ // in the output.
82
+ const rejected = settingsIssuesFor(sections, c.id);
83
+ const settingWarnings = rejected.length > 0
84
+ ? [`Unrecognised value reset to default: ${rejected.join(", ")}`]
85
+ : [];
86
+ const warnings = noContent
87
+ ? ["Section has no content", ...settingWarnings, ...pageWarnings]
88
+ : [...settingWarnings, ...pageWarnings];
79
89
  if (warnings.length > 0) map[c.id] = warnings;
80
90
  }
81
91
  return map;
82
- }, [chunks, pages]);
92
+ }, [chunks, pages, sections]);
83
93
 
84
94
  return { chunks, pages, measuring, warningsByChunk };
85
95
  }
@@ -23,8 +23,22 @@ const DISALLOWED_TAGS = new Set([
23
23
  ]);
24
24
  const DISALLOWED_SELECTOR = Array.from(DISALLOWED_TAGS).join(",");
25
25
 
26
- // Attributes that can carry a URL and therefore need scheme scrutiny.
27
- const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction"]);
26
+ // Attributes that can carry a URL and therefore need scheme scrutiny. `poster`
27
+ // and `srcset` matter because <video>/<img>/<source> survive the scrub; the
28
+ // rest belong to elements DISALLOWED_TAGS already removes, and are listed so a
29
+ // future relaxation of that set doesn't quietly open a hole.
30
+ const URL_ATTRS = new Set([
31
+ "href",
32
+ "src",
33
+ "srcset",
34
+ "poster",
35
+ "xlink:href",
36
+ "formaction",
37
+ "action",
38
+ "ping",
39
+ "data",
40
+ "background",
41
+ ]);
28
42
 
29
43
  function isDangerousUrl(attrName: string, rawValue: string): boolean {
30
44
  // Browsers strip ASCII tab/CR/LF anywhere in a URL (and ignore leading
@@ -36,6 +50,16 @@ function isDangerousUrl(attrName: string, rawValue: string): boolean {
36
50
  return false;
37
51
  }
38
52
 
53
+ // srcset is a comma-separated candidate list, so a startsWith check over the
54
+ // whole value misses "a.png 1x, javascript:alert(1) 2x". Scrutinize each
55
+ // candidate's URL part instead.
56
+ function hasDangerousUrl(attrName: string, rawValue: string): boolean {
57
+ if (attrName !== "srcset") return isDangerousUrl(attrName, rawValue);
58
+ return rawValue
59
+ .split(",")
60
+ .some((candidate) => isDangerousUrl("src", candidate.trim().split(/\s+/)[0] ?? ""));
61
+ }
62
+
39
63
  // Defense-in-depth scrub applied at the dangerouslySetInnerHTML/innerHTML
40
64
  // boundary. Upstream portal content is sanitized at authoring time, but
41
65
  // /print runs in an authed headless browser, so this engine (kept
@@ -52,13 +76,18 @@ function scrubElement(root: Element): void {
52
76
  el.removeAttribute(attr.name);
53
77
  continue;
54
78
  }
55
- if (URL_ATTRS.has(name) && isDangerousUrl(name, attr.value)) {
79
+ if (URL_ATTRS.has(name) && hasDangerousUrl(name, attr.value)) {
56
80
  el.removeAttribute(attr.name);
57
81
  }
58
82
  }
59
83
  }
60
84
  }
61
85
 
86
+ // Elements that carry meaning with no text of their own. The keep test used to
87
+ // name <img> alone, so a rule, a video, an inline SVG diagram or a <picture>
88
+ // was silently dropped from the printed document.
89
+ const VISIBLE_EMPTY_SELECTOR = "img, hr, video, svg, picture, canvas";
90
+
62
91
  // Split sanitized rich-text HTML into atomic top-level blocks. Pagination
63
92
  // never splits inside a block — a block that doesn't fit moves wholesale to
64
93
  // the next page (spec: "pagination by block measurement").
@@ -71,7 +100,7 @@ export function splitProseBlocks(html: string): string[] {
71
100
  const el = node as Element;
72
101
  if (DISALLOWED_TAGS.has(el.tagName.toLowerCase())) continue;
73
102
  scrubElement(el);
74
- if ((el.textContent ?? "").trim() || el.matches("img") || el.querySelector("img")) {
103
+ if ((el.textContent ?? "").trim() || el.matches(VISIBLE_EMPTY_SELECTOR) || el.querySelector(VISIBLE_EMPTY_SELECTOR)) {
75
104
  blocks.push(el.outerHTML);
76
105
  }
77
106
  } else if (node.nodeType === Node.TEXT_NODE) {
@@ -24,8 +24,14 @@ export interface MasonryBox<T extends Sized = Sized> {
24
24
 
25
25
  // A "cover" photo is laid out in a SQUARE cell (effective aspect 1) and the
26
26
  // image crops to fill it; "contain" (default) uses the photo's natural aspect.
27
- const effectiveAspect = (im: Sized, fit: "cover" | "contain" | undefined) =>
28
- fit === "cover" ? 1 : im.width / im.height;
27
+ const effectiveAspect = (im: Sized, fit: "cover" | "contain" | undefined) => {
28
+ if (fit === "cover") return 1;
29
+ const aspect = im.width / im.height;
30
+ // A 0-height item (a legacy manifest row from before the decode guard) made
31
+ // this Infinity or NaN, and every downstream width went with it — the whole
32
+ // page laid out blank. Square is the same fallback the cover path uses.
33
+ return Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
34
+ };
29
35
 
30
36
  function layoutRows<T extends Sized>(images: T[], o: MasonryOpts, fits: ("cover" | "contain")[]): { boxes: MasonryBox<T>[]; used: number; endY: number } {
31
37
  const asp = images.map((im, i) => effectiveAspect(im, fits[i]));
@@ -134,8 +134,11 @@ export function paginateChunk(
134
134
 
135
135
  // 1. Greedy text fill. Top text is capped at half the body so a text-heavy
136
136
  // section still leaves room for images below; leftColumn keeps full height.
137
+ // The cap only earns its keep while images are still waiting for room. Held
138
+ // on every page, it paginated a text-only section (or the text-only tail of
139
+ // one) at half utilisation and roughly doubled its page count.
137
140
  const capY =
138
- settings.textPlacement === "top"
141
+ settings.textPlacement === "top" && ii < chunk.images.length
139
142
  ? geom.titleBandIn + (geom.bodyHIn - geom.titleBandIn) * geom.topTextMaxFraction
140
143
  : geom.bodyHIn;
141
144
  const placed: TextBlock[] = [];
@@ -45,21 +45,58 @@ export type SectionSettings = z.infer<typeof sectionSettingsSchema>;
45
45
 
46
46
  const DEFAULTS: SectionSettings = sectionSettingsSchema.parse({});
47
47
 
48
+ /**
49
+ * Field-by-field salvage. The whole-entry `safeParse` fallback was
50
+ * all-or-nothing: ONE unrecognised value discarded every sibling setting, and
51
+ * since the defaults have `excluded: false`, a section deliberately excluded
52
+ * from the printed document silently reappeared in the output. `splitAt` was
53
+ * lost the same way. An index written by a newer package version — or by an MCP
54
+ * agent — is the realistic source of such a value.
55
+ */
56
+ function salvage(raw: unknown): { settings: SectionSettings; rejected: string[] } {
57
+ const fast = sectionSettingsSchema.safeParse(raw ?? {});
58
+ if (fast.success) return { settings: fast.data, rejected: [] };
59
+ // Not an object at all (stale scalar, null): nothing to salvage.
60
+ if (!raw || typeof raw !== "object") return { settings: DEFAULTS, rejected: [] };
61
+
62
+ const src = raw as Record<string, unknown>;
63
+ const out: Record<string, unknown> = {};
64
+ const rejected: string[] = [];
65
+ for (const [key, field] of Object.entries(sectionSettingsSchema.shape)) {
66
+ // Every field carries a .default(), so an absent key parses to its default.
67
+ const parsed = (field as z.ZodTypeAny).safeParse(src[key]);
68
+ if (parsed.success) {
69
+ out[key] = parsed.data;
70
+ } else {
71
+ rejected.push(key);
72
+ out[key] = DEFAULTS[key as keyof SectionSettings];
73
+ }
74
+ }
75
+ return { settings: out as SectionSettings, rejected };
76
+ }
77
+
48
78
  // Overlay lookup: unknown/stale/malformed entries fall back to defaults —
49
79
  // settings for a deleted portal section must be inert, never fatal.
50
80
  export function settingsFor(
51
81
  all: Record<string, unknown> | undefined,
52
82
  chunkId: string,
53
83
  ): SectionSettings {
54
- const raw = all?.[chunkId];
55
- const parsed = sectionSettingsSchema.safeParse(raw ?? {});
56
- const s = parsed.success ? parsed.data : DEFAULTS;
84
+ const s = salvage(all?.[chunkId]).settings;
57
85
  if (s.textPlacement === "top" && s.imagePlacement === "right") {
58
86
  return { ...s, imagePlacement: "fullWidth" };
59
87
  }
60
88
  return s;
61
89
  }
62
90
 
91
+ /** Setting names for `chunkId` that failed to parse and were reset to their
92
+ * default. Surfaced as document warnings so a silent reset is visible. */
93
+ export function settingsIssuesFor(
94
+ all: Record<string, unknown> | undefined,
95
+ chunkId: string,
96
+ ): string[] {
97
+ return salvage(all?.[chunkId]).rejected;
98
+ }
99
+
63
100
  const documentMetaSchema = z.object({
64
101
  client: z.string().default(""),
65
102
  project: z.string().default(""),
@@ -141,6 +141,12 @@ export function useBuildStatus(): BuildStatusResult {
141
141
  // Initial fetch on mount
142
142
  useEffect(() => {
143
143
  let cancelled = false;
144
+ // Re-arm on every mount. The cleanup below sets this false and nothing ever
145
+ // set it back, so under StrictMode's dev mount->unmount->remount every
146
+ // mountedRef guard stayed closed for the whole session and the publish
147
+ // indicator never left its initial state — and the same happened on any
148
+ // real remount in production.
149
+ mountedRef.current = true;
144
150
 
145
151
  async function check() {
146
152
  const data = await fetchStatus();
@@ -61,6 +61,15 @@ export function useDocumentUploads({
61
61
  const manifestRef = useRef<DocumentManifest>(documentManifest);
62
62
  useEffect(() => { manifestRef.current = documentManifest; }, [documentManifest]);
63
63
 
64
+ /** Git-tier ids claimed by an in-flight uploadDocument. `pendingRef` only
65
+ * catches a duplicate once React has re-rendered and the sync effect has
66
+ * run, so two rapid drops of the same file both cleared the dedup check
67
+ * below and staged duplicate rows. Updated eagerly, like undeleteDocument's
68
+ * ref in useMediaPipeline. Bucket-tier ids are server-minted after the
69
+ * upload, so they cannot be reserved — that re-pick case stays accepted
70
+ * (documented at the dedup check). */
71
+ const reservedIdsRef = useRef<Set<string>>(new Set());
72
+
64
73
  /** Restore documents staged in a previous session (Dexie survives reload). */
65
74
  const hydratePendingDocuments = useCallback(async () => {
66
75
  // Never throws: it runs from a mount effect, before/independently of the
@@ -105,6 +114,9 @@ export function useDocumentUploads({
105
114
  onUploadError?.(`"${filename}" couldn't be stored locally — try again.`);
106
115
  return false;
107
116
  }
117
+ // Eager, so the dedup check sees it the moment staging lands rather than
118
+ // one render later (the reservation above is released immediately after).
119
+ pendingRef.current = [...pendingRef.current, { id, entry }];
108
120
  setPendingDocuments((prev) => [...prev, { id, entry }]);
109
121
  setPendingDocumentUrls((prev) => ({ ...prev, [id]: localUrl }));
110
122
  setLocalChangesExist(true);
@@ -135,7 +147,11 @@ export function useDocumentUploads({
135
147
  // bucket-tier ids are server-minted, so re-picking a large file stages a
136
148
  // second entry. Accepted: the manifest is correct either way, and the
137
149
  // publish-time docs GC collects whichever object stops being referenced.
138
- if (manifestRef.current[hash] || pendingRef.current.some((d) => d.id === hash)) {
150
+ if (
151
+ manifestRef.current[hash] ||
152
+ pendingRef.current.some((d) => d.id === hash) ||
153
+ reservedIdsRef.current.has(hash)
154
+ ) {
139
155
  // A committed document deleted but not yet saved is only DOOMED, never
140
156
  // gone: its bytes and its manifest entry are still on the branch, and
141
157
  // deletion deliberately leaves documentManifest alone. So re-adding one
@@ -158,7 +174,14 @@ export function useDocumentUploads({
158
174
  path: documentGitPath(file.name, hash, plan.ext),
159
175
  };
160
176
  const localUrl = URL.createObjectURL(file);
161
- if (await stage(hash, entry, file, localUrl, file.name)) onComplete?.(hash);
177
+ // Claim the id across the staging await; released either way, so a
178
+ // failed stage leaves the file re-addable.
179
+ reservedIdsRef.current.add(hash);
180
+ try {
181
+ if (await stage(hash, entry, file, localUrl, file.name)) onComplete?.(hash);
182
+ } finally {
183
+ reservedIdsRef.current.delete(hash);
184
+ }
162
185
  return;
163
186
  }
164
187
 
@@ -317,6 +340,25 @@ export function useDocumentUploads({
317
340
  }
318
341
  }, []);
319
342
 
343
+ /** Discard-path counterpart to `unstageDocuments`: drops every staged document
344
+ * and releases its blob URL. Reads `pendingRef` rather than state so a
345
+ * memoized caller (handleDiscard) can't act on a stale snapshot. */
346
+ const unstageAllDocuments = useCallback(async () => {
347
+ const ids = pendingRef.current.map((d) => d.id);
348
+ pendingRef.current = [];
349
+ setPendingDocuments([]);
350
+ setPendingDocumentUrls((prev) => {
351
+ for (const url of Object.values(prev)) URL.revokeObjectURL(url);
352
+ return {};
353
+ });
354
+ if (ids.length === 0) return;
355
+ try {
356
+ await clearPendingDocumentsByIds(ids);
357
+ } catch (err) {
358
+ console.error("[useDocumentUploads] Failed to clear staged documents:", err);
359
+ }
360
+ }, []);
361
+
320
362
  // Memoized: useMediaPipeline lists this object in handleMediaUpload's and
321
363
  // enqueueFile's dependency arrays, and those two callbacks sit in
322
364
  // contextValue's deps — a fresh literal every render would defeat all three
@@ -329,8 +371,9 @@ export function useDocumentUploads({
329
371
  uploadDocument,
330
372
  publishDocuments,
331
373
  unstageDocuments,
374
+ unstageAllDocuments,
332
375
  hydratePendingDocuments,
333
376
  }),
334
- [pendingDocuments, pendingDocumentUrls, documentUploadProgress, uploadDocument, publishDocuments, unstageDocuments, hydratePendingDocuments],
377
+ [pendingDocuments, pendingDocumentUrls, documentUploadProgress, uploadDocument, publishDocuments, unstageDocuments, unstageAllDocuments, hydratePendingDocuments],
335
378
  );
336
379
  }
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useEffect } from "react";
1
+ import { useCallback, useMemo, useRef, useEffect } from "react";
2
2
  import type { SectionContent } from "../schemas/sections";
3
3
  import type { SiteIndex, SiteConfig } from "../schemas/site-config";
4
4
  import { persistAll, persistSiteConfig, deleteSectionRows } from "../lib/dexie";
@@ -236,22 +236,36 @@ export function useEditorPersistence(
236
236
  [],
237
237
  );
238
238
 
239
- return {
240
- markSectionDirty,
241
- markIndexDirty,
242
- removeSection,
243
- dropPendingSection,
244
- hasPendingSection,
245
- persistConfig,
246
- flushNow: flushToDexie,
247
- cancelPendingFlush,
248
- isConfigDirty,
249
- clearConfigDirty,
250
- isIndexDirty,
251
- hasUnsavedIndex,
252
- clearUnsavedIndex,
253
- noteUnsavedIndex,
254
- currentGeneration,
255
- sectionDirtiedAfter,
256
- };
239
+ // Memoized, and every member above is already identity-stable, so this object
240
+ // is too. A fresh literal per render changed `persistence`'s identity on every
241
+ // EditorShell render — which churned onBlockMove, getEditIntent,
242
+ // onSectionChange, onAddSection, performDeleteSection, onReorderSections and
243
+ // all twelve page/nav handlers, and made EditorContent tear down and
244
+ // re-register its atlaskit drag monitors on every keystroke.
245
+ return useMemo(
246
+ () => ({
247
+ markSectionDirty,
248
+ markIndexDirty,
249
+ removeSection,
250
+ dropPendingSection,
251
+ hasPendingSection,
252
+ persistConfig,
253
+ flushNow: flushToDexie,
254
+ cancelPendingFlush,
255
+ isConfigDirty,
256
+ clearConfigDirty,
257
+ isIndexDirty,
258
+ hasUnsavedIndex,
259
+ clearUnsavedIndex,
260
+ noteUnsavedIndex,
261
+ currentGeneration,
262
+ sectionDirtiedAfter,
263
+ }),
264
+ [
265
+ markSectionDirty, markIndexDirty, removeSection, dropPendingSection, hasPendingSection,
266
+ persistConfig, flushToDexie, cancelPendingFlush, isConfigDirty, clearConfigDirty,
267
+ isIndexDirty, hasUnsavedIndex, clearUnsavedIndex, noteUnsavedIndex, currentGeneration,
268
+ sectionDirtiedAfter,
269
+ ],
270
+ );
257
271
  }