@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
|
@@ -253,11 +253,27 @@ export function useEditorPublish({
|
|
|
253
253
|
async function gatherDocumentPayload(
|
|
254
254
|
pending: { id: string; entry: DocumentManifestEntry }[],
|
|
255
255
|
manifest: DocumentManifest,
|
|
256
|
-
): Promise<{
|
|
256
|
+
): Promise<{
|
|
257
|
+
uploads: DocumentUploadPayload[];
|
|
258
|
+
entries: { id: string; entry: DocumentManifestEntry }[];
|
|
259
|
+
/** Staged here, but already on the branch — nothing to send, yet still to
|
|
260
|
+
* be promoted out of `pendingDocuments`. */
|
|
261
|
+
alreadyCommitted: string[];
|
|
262
|
+
}> {
|
|
257
263
|
const uploads: DocumentUploadPayload[] = [];
|
|
258
264
|
const entries: { id: string; entry: DocumentManifestEntry }[] = [];
|
|
265
|
+
const alreadyCommitted: string[] = [];
|
|
259
266
|
for (const { id, entry } of pending) {
|
|
260
|
-
if (manifest[id])
|
|
267
|
+
if (manifest[id]) {
|
|
268
|
+
// Committed by another writer (a peer, or the MCP connector) while it
|
|
269
|
+
// was staged locally. Dropping it outright meant it never reached
|
|
270
|
+
// committedDocumentIds, so onDocumentsPublished never fired for it and
|
|
271
|
+
// pendingDocuments kept it — hasPendingEdits() then stayed true
|
|
272
|
+
// FOREVER: every Save posted a no-op and the dirty indicator never
|
|
273
|
+
// cleared. It is committed, so treat it as committed.
|
|
274
|
+
alreadyCommitted.push(id);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
261
277
|
if (entry.storage === "git") {
|
|
262
278
|
const blob = await getPendingDocumentBlob(id);
|
|
263
279
|
if (!blob) continue;
|
|
@@ -265,7 +281,7 @@ export function useEditorPublish({
|
|
|
265
281
|
}
|
|
266
282
|
entries.push({ id, entry });
|
|
267
283
|
}
|
|
268
|
-
return { uploads, entries };
|
|
284
|
+
return { uploads, entries, alreadyCommitted };
|
|
269
285
|
}
|
|
270
286
|
|
|
271
287
|
async function performSave(args: {
|
|
@@ -501,7 +517,10 @@ export function useEditorPublish({
|
|
|
501
517
|
// replace nav.json) — the save succeeded, but part of it was refused, and
|
|
502
518
|
// the editor believing it landed is exactly the silence the guard exists
|
|
503
519
|
// to break.
|
|
504
|
-
|
|
520
|
+
// Always reported, empty included: a later clean save left the previous
|
|
521
|
+
// save's banner on screen, so a warning the user had already dealt with
|
|
522
|
+
// read as still true.
|
|
523
|
+
args.onSaveWarnings?.(responseData.warnings ?? []);
|
|
505
524
|
|
|
506
525
|
return {
|
|
507
526
|
sha: responseData.sha,
|
|
@@ -512,7 +531,11 @@ export function useEditorPublish({
|
|
|
512
531
|
// Read AFTER the final request: committedDocuments is complete and the
|
|
513
532
|
// deletions (which ride only that request) have landed.
|
|
514
533
|
committedDocumentManifest: documentManifestToSend(),
|
|
515
|
-
|
|
534
|
+
// Merged only into the RETURN value, never into the local
|
|
535
|
+
// committedDocumentIds — that array's length gates leadChunksCommitted,
|
|
536
|
+
// and an already-committed document advanced no draft head this save,
|
|
537
|
+
// so counting it there would wrongly suppress 409 auto-recovery.
|
|
538
|
+
committedDocumentIds: [...gatheredDocs.alreadyCommitted, ...committedDocumentIds],
|
|
516
539
|
committedDocumentDeletions: args.pendingDocumentDeletions,
|
|
517
540
|
};
|
|
518
541
|
} catch (error) {
|
|
@@ -719,6 +742,11 @@ export function useEditorPublish({
|
|
|
719
742
|
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, manifestDirty, clearManifestDirty, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, recoverConflict, onBaselineCommitted, saveChunkBudget, getEditGeneration, pendingDocumentDeletions]);
|
|
720
743
|
|
|
721
744
|
const handlePublish = useCallback(async () => {
|
|
745
|
+
// Same in-flight guard every save path has. Without it a double-click fired
|
|
746
|
+
// two promotions: the second raced the first's branch delete and came back
|
|
747
|
+
// as a bare "Publish failed" for a publish that had actually succeeded.
|
|
748
|
+
if (inFlightRef.current) return;
|
|
749
|
+
inFlightRef.current = true;
|
|
722
750
|
setPublishAction("publishing");
|
|
723
751
|
setPublishFeedback(null);
|
|
724
752
|
|
|
@@ -749,6 +777,7 @@ export function useEditorPublish({
|
|
|
749
777
|
console.error("Publish failed:", error);
|
|
750
778
|
showFeedback("Publish failed", 5000);
|
|
751
779
|
} finally {
|
|
780
|
+
inFlightRef.current = false;
|
|
752
781
|
setPublishAction("idle");
|
|
753
782
|
}
|
|
754
783
|
}, [onShasUpdated, showFeedback, onPublishComplete]);
|
|
@@ -762,15 +791,51 @@ export function useEditorPublish({
|
|
|
762
791
|
|
|
763
792
|
const hasLocalEdits = await hasPendingEdits();
|
|
764
793
|
|
|
765
|
-
let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
|
|
766
|
-
let uploadedItems: MediaItem[] = [];
|
|
767
794
|
let sentIndex: SiteIndex | null = null;
|
|
768
795
|
let committedManifest: MediaManifest | null = null;
|
|
769
796
|
let committedDocumentManifest: DocumentManifest | null = null;
|
|
770
|
-
let
|
|
771
|
-
let committedDocumentDeletions: string[] = [];
|
|
797
|
+
let baselineSections: LoadedSection[] | null = null;
|
|
772
798
|
if (hasLocalEdits) {
|
|
773
|
-
|
|
799
|
+
const saved = await saveWithConflictRecovery();
|
|
800
|
+
sentIndex = saved.sentIndex;
|
|
801
|
+
committedManifest = saved.committedManifest;
|
|
802
|
+
committedDocumentManifest = saved.committedDocumentManifest;
|
|
803
|
+
|
|
804
|
+
// Local save bookkeeping runs HERE, before the publish request — not
|
|
805
|
+
// after it. When it sat below the publish, a publish failure left
|
|
806
|
+
// savedShaRef at the pre-save head with every dirty row still queued:
|
|
807
|
+
// the next save re-sent everything against a stale baseVersion (a
|
|
808
|
+
// guaranteed 409) and re-uploaded the media lead chunks.
|
|
809
|
+
onShasUpdated(saved.sha, null);
|
|
810
|
+
try {
|
|
811
|
+
// C1: read the previous clean baseline BEFORE commitSavedRows clears
|
|
812
|
+
// the content cache, then re-prime it with the COMMITTED snapshot.
|
|
813
|
+
const prevBaseline = await getCachedContent();
|
|
814
|
+
await commitSavedRows(saved.savedSections, saved.uploadedItems);
|
|
815
|
+
baselineSections = buildCommittedBaseline({
|
|
816
|
+
baselineSections: prevBaseline?.sections ?? null,
|
|
817
|
+
fallbackSections: sections,
|
|
818
|
+
savedRows: saved.savedSections,
|
|
819
|
+
sentIndex: saved.sentIndex,
|
|
820
|
+
});
|
|
821
|
+
// Both sidecars ride the same write as the sha they belong to —
|
|
822
|
+
// omitting them is what blanked freshly uploaded images ("No Image")
|
|
823
|
+
// and freshly committed documents ("File missing") on a cache hit.
|
|
824
|
+
await cacheContent(saved.sha, baselineSections, saved.sentIndex, siteConfig!, {
|
|
825
|
+
media: saved.committedManifest,
|
|
826
|
+
documents: saved.committedDocumentManifest,
|
|
827
|
+
});
|
|
828
|
+
finishSave(saved.uploadedItems, saved.savedSections.map((r) => r.sectionId));
|
|
829
|
+
if (saved.committedDocumentIds.length > 0) onDocumentsPublished?.(saved.committedDocumentIds);
|
|
830
|
+
if (saved.committedDocumentDeletions.length > 0) onDocumentsDeleted?.(saved.committedDocumentDeletions);
|
|
831
|
+
} catch (cleanupError) {
|
|
832
|
+
// The save COMMITTED; only the local bookkeeping failed. Publishing on
|
|
833
|
+
// top of unknown local state would compound it — stop and tell the
|
|
834
|
+
// editor to reload.
|
|
835
|
+
console.error("Save succeeded but local cleanup failed:", cleanupError);
|
|
836
|
+
showFeedback("Saved, but the local cache couldn't be updated — reload the editor before publishing.", 8000);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
774
839
|
}
|
|
775
840
|
|
|
776
841
|
setPublishAction("publishing");
|
|
@@ -791,35 +856,19 @@ export function useEditorPublish({
|
|
|
791
856
|
}
|
|
792
857
|
const { sha } = publishData;
|
|
793
858
|
|
|
794
|
-
if (hasLocalEdits) {
|
|
859
|
+
if (hasLocalEdits && baselineSections && sentIndex) {
|
|
795
860
|
try {
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
await commitSavedRows(savedSections, uploadedItems);
|
|
803
|
-
const baselineSections = buildCommittedBaseline({
|
|
804
|
-
baselineSections: prevBaseline?.sections ?? null,
|
|
805
|
-
fallbackSections: sections,
|
|
806
|
-
savedRows: savedSections,
|
|
807
|
-
sentIndex: sentIndex!,
|
|
808
|
-
});
|
|
809
|
-
// Both sidecars ride the same write as the sha they belong to: publish
|
|
810
|
-
// deleted the draft, so main's head IS this sha and the next load hits
|
|
811
|
-
// this row. Omitting them here is what blanked freshly uploaded images
|
|
812
|
-
// ("No Image", while the published site was correct) and freshly
|
|
813
|
-
// committed documents ("File missing").
|
|
814
|
-
await cacheContent(sha, baselineSections, sentIndex!, siteConfig!, {
|
|
861
|
+
// Re-key the SAME committed snapshot at the published sha: the draft
|
|
862
|
+
// branch is gone after publish, so this is what a later cache hit
|
|
863
|
+
// restores. Everything else was already committed above — but both
|
|
864
|
+
// sidecars have to ride this write too, or the re-keyed row is the
|
|
865
|
+
// one that blanks images and documents.
|
|
866
|
+
await cacheContent(sha, baselineSections, sentIndex, siteConfig!, {
|
|
815
867
|
media: committedManifest!,
|
|
816
868
|
documents: committedDocumentManifest!,
|
|
817
869
|
});
|
|
818
|
-
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
819
|
-
if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
|
|
820
|
-
if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
|
|
821
870
|
} catch (cleanupError) {
|
|
822
|
-
console.error("Publish succeeded but
|
|
871
|
+
console.error("Publish succeeded but the cache re-key failed:", cleanupError);
|
|
823
872
|
onShasUpdated(null, sha);
|
|
824
873
|
onPublishComplete?.();
|
|
825
874
|
showFeedback("Published, but the local cache couldn't be updated — reload the editor.", 8000);
|
|
@@ -55,3 +55,21 @@ export function useFocusTrap(ref: RefObject<HTMLElement | null>, active: boolean
|
|
|
55
55
|
};
|
|
56
56
|
}, [active, ref]);
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Focus the first control on open, hand focus back on close — and nothing else.
|
|
61
|
+
* For non-modal surfaces (`aria-modal="false"`), where trapping Tab contradicts
|
|
62
|
+
* the declaration and leaves a keyboard user with no way out.
|
|
63
|
+
*/
|
|
64
|
+
export function useInitialFocus(ref: RefObject<HTMLElement | null>, active: boolean) {
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!active || !ref.current) return;
|
|
67
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
68
|
+
ref.current.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus();
|
|
69
|
+
return () => {
|
|
70
|
+
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
|
71
|
+
previouslyFocused.focus();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, [active, ref]);
|
|
75
|
+
}
|
|
@@ -87,12 +87,29 @@ export function useMediaPipeline({
|
|
|
87
87
|
// the ref the error branch below would close over the first render's handler.
|
|
88
88
|
const onUploadErrorRef = useRef(onUploadError);
|
|
89
89
|
onUploadErrorRef.current = onUploadError;
|
|
90
|
+
const onUploadNoticeRef = useRef(onUploadNotice);
|
|
91
|
+
onUploadNoticeRef.current = onUploadNotice;
|
|
90
92
|
|
|
91
93
|
// Read inside uploadDocument's async body, where a state snapshot taken at
|
|
92
94
|
// render time would be stale across the hash await.
|
|
93
95
|
const pendingDocumentDeletionsRef = useRef<string[]>(pendingDocumentDeletions);
|
|
94
96
|
pendingDocumentDeletionsRef.current = pendingDocumentDeletions;
|
|
95
97
|
|
|
98
|
+
// The two delete handlers below used to run their reference-repair pass INSIDE
|
|
99
|
+
// a setSections updater, calling markSectionDirty (a Dexie write) and
|
|
100
|
+
// setDirtySectionIds from it. State updaters must be pure — StrictMode
|
|
101
|
+
// double-invokes them in dev, and React may re-run or discard them at any
|
|
102
|
+
// time. Reading the list from a ref lets the pass run outside the updater.
|
|
103
|
+
const sectionsRef = useRef<LoadedSection[]>(sections);
|
|
104
|
+
sectionsRef.current = sections;
|
|
105
|
+
|
|
106
|
+
// handleMediaUpload's dedup check runs inside an `await` loop, where the
|
|
107
|
+
// `pendingMediaItems` state captured at render time goes stale. Mirroring the
|
|
108
|
+
// hashes here means the check reads whatever the latest render committed
|
|
109
|
+
// rather than the snapshot the batch started with.
|
|
110
|
+
const pendingHashesRef = useRef<Set<string>>(new Set());
|
|
111
|
+
pendingHashesRef.current = new Set(pendingMediaItems.map((i) => i.hash));
|
|
112
|
+
|
|
96
113
|
/** Re-adding a document that is deleted-but-unsaved un-dooms it rather than
|
|
97
114
|
* letting the upload dedup swallow it — the twin of the image pipeline's
|
|
98
115
|
* `isDeleted` escape below. The ref is updated eagerly so two re-adds in one
|
|
@@ -118,6 +135,9 @@ export function useMediaPipeline({
|
|
|
118
135
|
const uploadTargetsRef = useRef<Map<string, string | null>>(new Map());
|
|
119
136
|
const hiddenUploadsRef = useRef<Map<string, boolean>>(new Map());
|
|
120
137
|
const destroyedRef = useRef(false);
|
|
138
|
+
// Queue-teardown bookkeeping for the media-config-change notice below.
|
|
139
|
+
const prevMediaConfigKeyRef = useRef<string | null>(null);
|
|
140
|
+
const droppedOnTeardownRef = useRef(0);
|
|
121
141
|
|
|
122
142
|
// Shared persistence path for every pending media item — the processing
|
|
123
143
|
// queue's small-file finalize AND the direct-to-bucket large-video upload
|
|
@@ -167,6 +187,25 @@ export function useMediaPipeline({
|
|
|
167
187
|
useEffect(() => {
|
|
168
188
|
if (!siteConfig) return;
|
|
169
189
|
destroyedRef.current = false;
|
|
190
|
+
// Changing the media settings (sizes/quality/max size) rebuilds the queue and
|
|
191
|
+
// everything still in it is dropped on the floor. Silently, until now: the
|
|
192
|
+
// files just stopped existing, mid-progress-bar. Decided HERE rather than in
|
|
193
|
+
// the cleanup because only this side can see whether the key actually
|
|
194
|
+
// changed — the effect also re-runs on an incidental identity change, and
|
|
195
|
+
// that teardown drops nothing the user cares about.
|
|
196
|
+
if (
|
|
197
|
+
prevMediaConfigKeyRef.current !== null &&
|
|
198
|
+
prevMediaConfigKeyRef.current !== mediaConfigKey &&
|
|
199
|
+
droppedOnTeardownRef.current > 0
|
|
200
|
+
) {
|
|
201
|
+
const dropped = droppedOnTeardownRef.current;
|
|
202
|
+
onUploadNoticeRef.current?.(
|
|
203
|
+
`${dropped} upload${dropped === 1 ? "" : "s"} stopped because the image settings changed — add ${dropped === 1 ? "it" : "them"} again.`,
|
|
204
|
+
);
|
|
205
|
+
setProcessingItems([]);
|
|
206
|
+
}
|
|
207
|
+
droppedOnTeardownRef.current = 0;
|
|
208
|
+
prevMediaConfigKeyRef.current = mediaConfigKey;
|
|
170
209
|
const mediaConfig = siteConfig.media;
|
|
171
210
|
const queue = new ProcessingQueue({
|
|
172
211
|
sizes: mediaConfig.sizes,
|
|
@@ -258,7 +297,20 @@ export function useMediaPipeline({
|
|
|
258
297
|
void finalize(localUrls, blobsMap, width, height);
|
|
259
298
|
},
|
|
260
299
|
() => {
|
|
261
|
-
|
|
300
|
+
// The worker cannot decode a video, so it emits width/height 0 and
|
|
301
|
+
// poster generation is the ONLY source of real dimensions. Falling
|
|
302
|
+
// back to result.width/height therefore persisted a 0x0 item, which
|
|
303
|
+
// MediaItemSchema (positive ints) then rejected on every subsequent
|
|
304
|
+
// save — the editor could save nothing at all until the pending item
|
|
305
|
+
// was removed by hand, and the error named the manifest, not the
|
|
306
|
+
// video. Drop the file with a per-file error instead.
|
|
307
|
+
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
308
|
+
uploadTargetsRef.current.delete(event.item.hash);
|
|
309
|
+
hiddenUploadsRef.current.delete(event.item.hash);
|
|
310
|
+
uploadCallbacksRef.current.delete(event.item.hash);
|
|
311
|
+
onUploadErrorRef.current?.(
|
|
312
|
+
`"${event.item.originalName}" couldn't be read — the browser can't decode this video. Try re-encoding it as H.264 MP4.`,
|
|
313
|
+
);
|
|
262
314
|
},
|
|
263
315
|
);
|
|
264
316
|
} else {
|
|
@@ -274,6 +326,8 @@ export function useMediaPipeline({
|
|
|
274
326
|
queueRef.current = queue;
|
|
275
327
|
return () => {
|
|
276
328
|
destroyedRef.current = true;
|
|
329
|
+
const { active, queued } = queue.getStatus();
|
|
330
|
+
droppedOnTeardownRef.current = active + queued;
|
|
277
331
|
queue.destroy();
|
|
278
332
|
uploadCallbacksRef.current = new Map();
|
|
279
333
|
uploadTargetsRef.current = new Map();
|
|
@@ -409,6 +463,8 @@ export function useMediaPipeline({
|
|
|
409
463
|
if (!queue) return;
|
|
410
464
|
|
|
411
465
|
let alreadyExisting = 0;
|
|
466
|
+
// Hashes enqueued by THIS batch — see the dedup check below.
|
|
467
|
+
const enqueuedThisBatch = new Set<string>();
|
|
412
468
|
for (const file of files) {
|
|
413
469
|
// Documents never touch the rasterizer: their bytes must reach the repo
|
|
414
470
|
// or the bucket untransformed. Branch before every image-shaped gate
|
|
@@ -444,12 +500,17 @@ export function useMediaPipeline({
|
|
|
444
500
|
|
|
445
501
|
const isDeleted = pendingDeletions.includes(hash);
|
|
446
502
|
const existsInManifest = hash in mediaManifest.images;
|
|
447
|
-
|
|
503
|
+
// `enqueuedThisBatch` is what catches the same file dropped twice in ONE
|
|
504
|
+
// batch: nothing has re-rendered yet, so neither the state snapshot nor
|
|
505
|
+
// the ref has heard about the first copy, and both used to reach
|
|
506
|
+
// queue.add — producing duplicate React keys in MediaLibraryModal.
|
|
507
|
+
const existsInPending = pendingHashesRef.current.has(hash) || enqueuedThisBatch.has(hash);
|
|
448
508
|
if ((existsInManifest || existsInPending) && !isDeleted) {
|
|
449
509
|
alreadyExisting++;
|
|
450
510
|
continue;
|
|
451
511
|
}
|
|
452
512
|
|
|
513
|
+
enqueuedThisBatch.add(hash);
|
|
453
514
|
uploadTargetsRef.current.set(hash, targetFolderId);
|
|
454
515
|
|
|
455
516
|
let kind: "image" | "animated" | "video" = "image";
|
|
@@ -465,7 +526,7 @@ export function useMediaPipeline({
|
|
|
465
526
|
: `${alreadyExisting} files already exist in the library and were left where they are.`,
|
|
466
527
|
);
|
|
467
528
|
}
|
|
468
|
-
}, [siteConfig, mediaManifest,
|
|
529
|
+
}, [siteConfig, mediaManifest, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo, documents]);
|
|
469
530
|
|
|
470
531
|
const handleMediaDelete = useCallback(async (ids: string[]) => {
|
|
471
532
|
const idSet = new Set(ids);
|
|
@@ -498,18 +559,21 @@ export function useMediaPipeline({
|
|
|
498
559
|
});
|
|
499
560
|
setPendingDeletions((prev) => [...prev, ...ids]);
|
|
500
561
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
);
|
|
562
|
+
const clearedIds: string[] = [];
|
|
563
|
+
const repaired = sectionsRef.current.map((loaded) => {
|
|
564
|
+
const referenced = collectImageIds(loaded.section);
|
|
565
|
+
const hasRef = ids.some((id) => referenced.has(id));
|
|
566
|
+
if (!hasRef) return loaded;
|
|
567
|
+
|
|
568
|
+
const cleared = clearImageIds(loaded.section, idSet);
|
|
569
|
+
clearedIds.push(loaded.section.id);
|
|
570
|
+
markSectionDirty(loaded.section.id, cleared as SectionContent);
|
|
571
|
+
return { ...loaded, section: { id: loaded.section.id, ...(cleared as Record<string, unknown>) } as typeof loaded.section };
|
|
572
|
+
});
|
|
573
|
+
setSections(repaired);
|
|
574
|
+
if (clearedIds.length > 0) {
|
|
575
|
+
setDirtySectionIds((prev) => new Set([...prev, ...clearedIds]));
|
|
576
|
+
}
|
|
513
577
|
|
|
514
578
|
if (siteConfig?.logo && onSiteConfigChange) {
|
|
515
579
|
const logoIds = collectImageIds(siteConfig.logo);
|
|
@@ -541,17 +605,20 @@ export function useMediaPipeline({
|
|
|
541
605
|
setPendingDocumentDeletions((prev) => [...prev, ...committedIds.filter((id) => !prev.includes(id))]);
|
|
542
606
|
}
|
|
543
607
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
);
|
|
608
|
+
const repairedIds: string[] = [];
|
|
609
|
+
const repaired = sectionsRef.current.map((loaded) => {
|
|
610
|
+
const { changed, section } = clearDocumentRefs(loaded.section, idSet);
|
|
611
|
+
if (!changed) return loaded;
|
|
612
|
+
// A nested hit returns the rewritten CONTAINER — the section that owns
|
|
613
|
+
// the child's bytes, and so the one that gets dirtied and saved.
|
|
614
|
+
repairedIds.push(section.id);
|
|
615
|
+
markSectionDirty(section.id, section as SectionContent);
|
|
616
|
+
return { ...loaded, section };
|
|
617
|
+
});
|
|
618
|
+
setSections(repaired);
|
|
619
|
+
if (repairedIds.length > 0) {
|
|
620
|
+
setDirtySectionIds((dirty) => new Set([...dirty, ...repairedIds]));
|
|
621
|
+
}
|
|
555
622
|
|
|
556
623
|
setLocalChangesExist(true);
|
|
557
624
|
}, [documents, documentManifest, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
|
|
@@ -695,22 +762,27 @@ export function useMediaPipeline({
|
|
|
695
762
|
// instead of stringifying every section and regex-scanning it per known id.
|
|
696
763
|
// Counts every referenced imageId; the consumer (MediaLibraryModal) only looks
|
|
697
764
|
// up manifest/pending ids, so extra keys for any dangling references are inert.
|
|
698
|
-
|
|
765
|
+
//
|
|
766
|
+
// Deliberately a THUNK, not a useMemo. As a memo it recursed through every
|
|
767
|
+
// section plus the whole siteConfig on every keystroke, for a consumer (the
|
|
768
|
+
// media modal) that is closed almost all of the time. EditorShell calls this
|
|
769
|
+
// when the modal opens.
|
|
770
|
+
const computeReferenceCountMap = useCallback((): Record<string, number> => {
|
|
699
771
|
const counts = new Map<string, number>();
|
|
700
|
-
for (const { section } of
|
|
772
|
+
for (const { section } of sectionsRef.current) tallyImageIds(section, counts);
|
|
701
773
|
if (siteConfig) tallyImageIds(siteConfig, counts);
|
|
702
774
|
return Object.fromEntries(counts);
|
|
703
|
-
}, [
|
|
775
|
+
}, [siteConfig]);
|
|
704
776
|
|
|
705
|
-
// The document twin
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
const
|
|
777
|
+
// The document twin, feeding the media modal's document usage pills and its
|
|
778
|
+
// delete confirmation. Unlike images, documents are never referenced by
|
|
779
|
+
// siteConfig — only by `document` sections, nested ones included
|
|
780
|
+
// (tallyDocumentIds recurses).
|
|
781
|
+
const computeDocumentReferenceCountMap = useCallback((): Record<string, number> => {
|
|
710
782
|
const counts = new Map<string, number>();
|
|
711
|
-
for (const { section } of
|
|
783
|
+
for (const { section } of sectionsRef.current) tallyDocumentIds(section, counts);
|
|
712
784
|
return Object.fromEntries(counts);
|
|
713
|
-
}, [
|
|
785
|
+
}, []);
|
|
714
786
|
|
|
715
787
|
// --- Unified enqueue ---
|
|
716
788
|
|
|
@@ -820,8 +892,8 @@ export function useMediaPipeline({
|
|
|
820
892
|
handleFolderDelete,
|
|
821
893
|
handleMediaMove,
|
|
822
894
|
handleFolderUpload,
|
|
823
|
-
|
|
824
|
-
|
|
895
|
+
computeReferenceCountMap,
|
|
896
|
+
computeDocumentReferenceCountMap,
|
|
825
897
|
contextValue,
|
|
826
898
|
enqueueFile,
|
|
827
899
|
pendingDocuments: documents.pendingDocuments,
|
|
@@ -829,6 +901,7 @@ export function useMediaPipeline({
|
|
|
829
901
|
documentUploadProgress: documents.documentUploadProgress,
|
|
830
902
|
uploadDocument: documents.uploadDocument,
|
|
831
903
|
publishDocuments: documents.publishDocuments,
|
|
904
|
+
unstageAllDocuments: documents.unstageAllDocuments,
|
|
832
905
|
hydratePendingDocuments: documents.hydratePendingDocuments,
|
|
833
906
|
};
|
|
834
907
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { ensureSanitizer, isSanitizerReady, sanitizeHtml } from "../lib/sanitize";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitized HTML for `dangerouslySetInnerHTML`, withheld until DOMPurify has
|
|
6
|
+
* loaded. `sanitizeHtml` is synchronous and returns its input unchanged while
|
|
7
|
+
* the dynamic import is in flight, so calling it directly injects raw stored
|
|
8
|
+
* markup on first paint — the editor loads section JSON straight from GitHub and
|
|
9
|
+
* never passes it through core's server-side sanitizer. Returning "" until the
|
|
10
|
+
* purifier resolves is the same gate ChatPanel applies to streamed markdown.
|
|
11
|
+
*
|
|
12
|
+
* On the server this is a straight pass-through (see `isSanitizerReady`).
|
|
13
|
+
*/
|
|
14
|
+
export function useSanitizedHtml(html: string): string {
|
|
15
|
+
const [ready, setReady] = useState(isSanitizerReady);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (ready) return;
|
|
19
|
+
let alive = true;
|
|
20
|
+
void ensureSanitizer().then(() => {
|
|
21
|
+
if (alive) setReady(true);
|
|
22
|
+
});
|
|
23
|
+
return () => {
|
|
24
|
+
alive = false;
|
|
25
|
+
};
|
|
26
|
+
}, [ready]);
|
|
27
|
+
|
|
28
|
+
return ready ? sanitizeHtml(html) : "";
|
|
29
|
+
}
|
package/src/lib/block-move.ts
CHANGED
|
@@ -51,7 +51,15 @@ function defaultNewId(): string {
|
|
|
51
51
|
: `block-${Math.random().toString(36).slice(2)}`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// A container's children carry no status/access of their own — they inherit the
|
|
55
|
+
// container's. Promoting one to the page root therefore inherits that container's
|
|
56
|
+
// meta: defaulting to `status: "draft"` silently pulled a block that viewers
|
|
57
|
+
// could see out of the published site, with nothing in the editor saying so.
|
|
58
|
+
const promotedChildMeta = (type: string, source: SectionMeta | undefined): SectionMeta => ({
|
|
59
|
+
type,
|
|
60
|
+
status: source?.status ?? "draft",
|
|
61
|
+
access: [...(source?.access ?? [])],
|
|
62
|
+
});
|
|
55
63
|
|
|
56
64
|
function isContainerSection(section: Section): boolean {
|
|
57
65
|
return Array.isArray((section.content as { children?: unknown })?.children);
|
|
@@ -141,7 +149,10 @@ export function applyBlockMove(
|
|
|
141
149
|
|
|
142
150
|
if (toContainerId === ROOT_CONTAINER_ID) {
|
|
143
151
|
const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
|
|
144
|
-
const meta =
|
|
152
|
+
const meta = promotedChildMeta(
|
|
153
|
+
rootBlock.type,
|
|
154
|
+
sections.find((l) => l.section.id === fromContainerId)?.meta,
|
|
155
|
+
);
|
|
145
156
|
const rootOrder = nextIndex.pages.find((p) => p.id === rootPageId)?.order ?? [];
|
|
146
157
|
nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta,
|
|
147
158
|
visibleInsertPosition(rootOrder, visibleRootIds(nextIndex, rootPageId, nextSections), toIndex));
|
package/src/lib/contrast.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Hex → sRGB bytes, expanding the 3-digit form. */
|
|
2
|
+
function toRgb(hex: string): [number, number, number] {
|
|
3
|
+
const h = hex.replace("#", "");
|
|
4
|
+
const x = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
|
|
5
|
+
return [
|
|
6
|
+
parseInt(x.slice(0, 2), 16),
|
|
7
|
+
parseInt(x.slice(2, 4), 16),
|
|
8
|
+
parseInt(x.slice(4, 6), 16),
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
export function deriveContrast(hex: string): string {
|
|
2
13
|
const h = hex.replace("#", "");
|
|
3
14
|
const expanded = h.length === 3
|
|
@@ -15,25 +26,14 @@ export function deriveContrast(hex: string): string {
|
|
|
15
26
|
* uses). Returns near-black for light backgrounds, white for dark ones.
|
|
16
27
|
*/
|
|
17
28
|
export function contrastColor(hex: string): "#1a1a1a" | "#ffffff" {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const g =
|
|
21
|
-
const b = parseInt(h.slice(4, 6), 16);
|
|
29
|
+
// Through toRgb so a 3-digit hex expands first. Slicing "fff" directly gave
|
|
30
|
+
// b = NaN, the comparison came out false, and #fff got white text on white.
|
|
31
|
+
const [r, g, b] = toRgb(hex);
|
|
22
32
|
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1a1a1a" : "#ffffff";
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
// --- WCAG 2.x contrast (relative-luminance based) -------------------------
|
|
26
36
|
|
|
27
|
-
function toRgb(hex: string): [number, number, number] {
|
|
28
|
-
const h = hex.replace("#", "");
|
|
29
|
-
const x = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
|
|
30
|
-
return [
|
|
31
|
-
parseInt(x.slice(0, 2), 16),
|
|
32
|
-
parseInt(x.slice(2, 4), 16),
|
|
33
|
-
parseInt(x.slice(4, 6), 16),
|
|
34
|
-
];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
37
|
/** WCAG relative luminance of an sRGB hex color (0 = black, 1 = white). */
|
|
38
38
|
export function relativeLuminance(hex: string): number {
|
|
39
39
|
const [r, g, b] = toRgb(hex).map((c) => {
|