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