@drawnagency/primitives 0.1.109 → 0.1.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/cookies.d.ts +8 -0
- package/dist/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/types.d.ts +11 -4
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{chunk-WXBHP4JY.js → chunk-3GAG6ZXB.js} +55 -46
- package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
- package/dist/{chunk-5PVRACZY.js → chunk-7MI3HCOF.js} +60 -23
- package/dist/{chunk-WBDPDGFA.js → chunk-7WQJ57XG.js} +6 -31
- package/dist/{chunk-LQHYVPBS.js → chunk-ALMEC3XN.js} +11 -12
- package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
- package/dist/{chunk-ZKVKNUJC.js → chunk-ELQ4C677.js} +1 -1
- package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
- package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
- package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
- package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
- package/dist/{chunk-67ZOQCUD.js → chunk-NXGIHSL5.js} +67 -50
- package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
- package/dist/{chunk-XIDE4TDF.js → chunk-OUYB2GCW.js} +1 -1
- package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
- package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
- package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
- package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
- package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
- package/dist/collab/rebase.d.ts +6 -0
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +12 -3
- package/dist/collab/recovery.d.ts +0 -20
- package/dist/collab/recovery.d.ts.map +1 -1
- package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
- package/dist/components/brandguide/colors-shared.d.ts +10 -1
- package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
- package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/NavGroupRow.d.ts +7 -2
- package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +13 -12
- package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
- package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
- package/dist/components/primitives/index.js +14 -12
- package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
- package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +8 -8
- package/dist/components/sections/register-schemas.js +14 -14
- package/dist/components/sections/register.js +14 -14
- package/dist/components/shared/FontPicker.d.ts.map +1 -1
- package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
- package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts.map +1 -1
- package/dist/components/shell/AudienceAddForm.d.ts +1 -5
- package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
- package/dist/components/shell/AudienceRow.d.ts +2 -3
- package/dist/components/shell/AudienceRow.d.ts.map +1 -1
- package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
- package/dist/components/shell/view-branch.d.ts.map +1 -1
- package/dist/document/components/DocRail.d.ts.map +1 -1
- package/dist/document/components/DocViewer.d.ts.map +1 -1
- package/dist/document/components/measure.d.ts.map +1 -1
- package/dist/document/components/useDocPages.d.ts.map +1 -1
- package/dist/document/engine/blocks.d.ts.map +1 -1
- package/dist/document/engine/masonry.d.ts.map +1 -1
- package/dist/document/engine/paginate.d.ts.map +1 -1
- package/dist/document/engine/settings.d.ts +3 -0
- package/dist/document/engine/settings.d.ts.map +1 -1
- package/dist/document/index.js +79 -28
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +1 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.d.ts +6 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +3 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useSanitizedHtml.d.ts +12 -0
- package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
- package/dist/index.js +19 -13
- package/dist/lib/block-move.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +2 -8
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +21 -49
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +43 -23
- package/dist/lib/index.js +8 -8
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-folders.d.ts +12 -4
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +12 -2
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/lib/nav-progress.d.ts +14 -0
- package/dist/lib/nav-progress.d.ts.map +1 -0
- package/dist/lib/nav-tree.d.ts.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +1 -1
- package/dist/lib/sanitize.d.ts +6 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/lib/timestamp.d.ts.map +1 -1
- package/dist/media/index.js +4 -4
- package/dist/media/queue.d.ts.map +1 -1
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/audience.d.ts +5 -1
- package/dist/schemas/audience.d.ts.map +1 -1
- package/dist/schemas/audience.js +1 -1
- package/dist/schemas/index.js +8 -6
- package/dist/schemas/sections.d.ts +12 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +9 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +0 -3
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/auth/cookies.ts +11 -0
- package/src/auth/index.ts +1 -1
- package/src/auth/types.ts +18 -4
- package/src/collab/rebase.ts +26 -1
- package/src/collab/recovery.ts +29 -3
- package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
- package/src/components/brandguide/ColorsEditable.tsx +7 -9
- package/src/components/brandguide/ColorsView.tsx +12 -11
- package/src/components/brandguide/colors-shared.ts +17 -1
- package/src/components/chat/ChatPanel.tsx +19 -0
- package/src/components/docfile/DocFilePane.tsx +11 -0
- package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
- package/src/components/docfile/doc-file-state.ts +7 -0
- package/src/components/editor/IndicatorPill.tsx +5 -1
- package/src/components/editor/NavGroupRow.tsx +8 -3
- package/src/components/editor/PagesModal.tsx +10 -2
- package/src/components/primitives/EditableList.tsx +10 -2
- package/src/components/primitives/EditableRichText.tsx +4 -3
- package/src/components/primitives/LinkPopover.tsx +23 -1
- package/src/components/primitives/useEditableCollection.ts +61 -41
- package/src/components/primitives/useEditablePlainText.ts +24 -3
- package/src/components/sections/IconList/index.tsx +8 -1
- package/src/components/sections/Prose/Prose.tsx +4 -2
- package/src/components/shared/FontPicker.tsx +16 -4
- package/src/components/shared/HistoryChangesModal.tsx +12 -1
- package/src/components/shared/HistoryPopover.tsx +18 -4
- package/src/components/shared/Navigation.tsx +20 -2
- package/src/components/shared/Popover.tsx +9 -5
- package/src/components/shell/AudienceAddForm.tsx +6 -26
- package/src/components/shell/AudienceRow.tsx +29 -39
- package/src/components/shell/BugReportFAB.tsx +20 -2
- package/src/components/shell/EditorShell.tsx +200 -50
- package/src/components/shell/MediaLibraryModal.tsx +52 -10
- package/src/components/shell/SiteSettingsUsers.tsx +3 -0
- package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
- package/src/components/shell/view-branch.ts +4 -1
- package/src/document/components/DocRail.tsx +27 -2
- package/src/document/components/DocViewer.tsx +5 -1
- package/src/document/components/measure.tsx +22 -6
- package/src/document/components/useDocPages.ts +13 -3
- package/src/document/engine/blocks.ts +33 -4
- package/src/document/engine/masonry.ts +8 -2
- package/src/document/engine/paginate.ts +4 -1
- package/src/document/engine/settings.ts +40 -3
- package/src/hooks/useBuildStatus.ts +6 -0
- package/src/hooks/useDocumentUploads.ts +46 -3
- package/src/hooks/useEditorPersistence.ts +33 -19
- package/src/hooks/useEditorPublish.ts +83 -34
- package/src/hooks/useFocusTrap.ts +18 -0
- package/src/hooks/useMediaPipeline.ts +111 -38
- package/src/hooks/useSanitizedHtml.ts +29 -0
- package/src/lib/audience-secret.ts +1 -0
- package/src/lib/block-move.ts +13 -2
- package/src/lib/contrast.ts +14 -14
- package/src/lib/dexie.ts +46 -49
- package/src/lib/index-ops.ts +63 -20
- package/src/lib/loader.ts +41 -12
- package/src/lib/media-folders.ts +31 -5
- package/src/lib/migrate-sections-transform.ts +22 -2
- package/src/lib/nav-progress.ts +108 -0
- package/src/lib/nav-tree.ts +16 -2
- package/src/lib/nav.ts +10 -1
- package/src/lib/pages.ts +24 -6
- package/src/lib/registry.ts +3 -0
- package/src/lib/sanitize.ts +9 -0
- package/src/lib/timestamp.ts +5 -0
- package/src/media/queue.ts +4 -0
- package/src/media/resolve.ts +14 -1
- package/src/media/utils.ts +3 -1
- package/src/media/videoPoster.ts +14 -3
- package/src/schemas/audience.ts +10 -2
- package/src/schemas/sections.ts +23 -1
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +13 -0
- 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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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[
|
|
103
|
-
nextW[
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
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(
|
|
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"
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
}
|