@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
package/src/collab/recovery.ts
CHANGED
|
@@ -34,6 +34,24 @@ export interface RecoveryBaseline {
|
|
|
34
34
|
* nothing). This is deliberately NOT derived from the published mainIndex: a
|
|
35
35
|
* main-based baseline would silently local-win every dirty section that already
|
|
36
36
|
* exists on main — the exact lost-update this path exists to prevent. */
|
|
37
|
+
/** Key-order-insensitive serialization, for comparing two snapshots of the same
|
|
38
|
+
* section that took different routes to memory. Plain `JSON.stringify` is
|
|
39
|
+
* order-sensitive, and the two sides are built differently — the baseline by
|
|
40
|
+
* `buildCommittedBaseline` from what the save sent, the remote by the loader's
|
|
41
|
+
* Zod parse of the file on the branch. Any drift between those orderings marks
|
|
42
|
+
* an untouched section as remote-changed, which surfaces as a keep-mine /
|
|
43
|
+
* take-theirs dialog for a section nobody edited. */
|
|
44
|
+
function canonicalize(value: unknown): string {
|
|
45
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
46
|
+
if (value && typeof value === "object") {
|
|
47
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
48
|
+
.filter(([, v]) => v !== undefined)
|
|
49
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
50
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(value) ?? "null";
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
export function deriveRecoveryInputs({ baseline, remote, dirtyRows, deletedIds }: {
|
|
38
56
|
baseline: RecoveryBaseline | null;
|
|
39
57
|
remote: { sections: LoadedSection[]; index: SiteIndex };
|
|
@@ -51,10 +69,10 @@ export function deriveRecoveryInputs({ baseline, remote, dirtyRows, deletedIds }
|
|
|
51
69
|
};
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
const baselineById = new Map(baseline.sections.map((s) => [s.section.id,
|
|
72
|
+
const baselineById = new Map(baseline.sections.map((s) => [s.section.id, canonicalize(s.section)]));
|
|
55
73
|
const remoteChangedIds = new Set(
|
|
56
74
|
remote.sections
|
|
57
|
-
.filter((s) => baselineById.has(s.section.id) && baselineById.get(s.section.id) !==
|
|
75
|
+
.filter((s) => baselineById.has(s.section.id) && baselineById.get(s.section.id) !== canonicalize(s.section))
|
|
58
76
|
.map((s) => s.section.id),
|
|
59
77
|
);
|
|
60
78
|
// Safety: a dirty id that exists remotely but was never in the baseline means
|
|
@@ -137,7 +155,15 @@ export function buildCommittedBaseline({ baselineSections, fallbackSections, sav
|
|
|
137
155
|
.filter((s) => s.section.id in sentIndex.sections)
|
|
138
156
|
.map((s) => {
|
|
139
157
|
const sent = sentById.get(s.section.id);
|
|
140
|
-
|
|
158
|
+
// REPLACE the content, never merge it over the old section: a save that
|
|
159
|
+
// dropped a key (clearing a section's options, say) left the stale key on
|
|
160
|
+
// the baseline, so the next fetch of that section differed from the
|
|
161
|
+
// baseline and the section reported as remote-changed — a keep-mine /
|
|
162
|
+
// take-theirs dialog for an edit this editor had just made itself.
|
|
163
|
+
// `{ id, ...content }` is the shell's own canonical section shape
|
|
164
|
+
// (onSectionChange, onAddSection).
|
|
165
|
+
if (!sent) return s;
|
|
166
|
+
return { ...s, section: { id: s.section.id, ...sent }, meta: sentIndex.sections[s.section.id] ?? s.meta };
|
|
141
167
|
});
|
|
142
168
|
|
|
143
169
|
const present = new Set(out.map((s) => s.section.id));
|
|
@@ -1,31 +1,53 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Input } from "../shared/Input";
|
|
3
3
|
import type { ColorItem, ColorSpace } from "../sections/Colors/schema";
|
|
4
|
+
import { COLOR_SPACE_KEYS } from "./colors-shared";
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
color: ColorItem;
|
|
7
8
|
onChange: (color: ColorItem) => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
const KEYS = COLOR_SPACE_KEYS;
|
|
12
|
+
|
|
13
|
+
function hasAnyValue(color: ColorItem): boolean {
|
|
14
|
+
return color.spaces.some((s) => KEYS.some((k) => s[k]));
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export default function ColorSwatchSettings({ color, onChange }: Props) {
|
|
15
|
-
const [localColor, setLocalColor] = useState(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
})
|
|
19
|
-
|
|
18
|
+
const [localColor, setLocalColor] = useState(color);
|
|
19
|
+
const spaces = localColor.spaces.length > 0 ? localColor.spaces : [{} as ColorSpace];
|
|
20
|
+
|
|
21
|
+
// A colour can be authored as several grouped spaces (e.g. [{hex,rgb},{cmyk,pantone}])
|
|
22
|
+
// and the swatch renders those groups. The form shows one input per key, sourced
|
|
23
|
+
// from whichever space defines it, and writes back into that same space — flattening
|
|
24
|
+
// everything into spaces[0] would silently destroy the grouping.
|
|
25
|
+
const valueOf = (key: (typeof KEYS)[number]) => spaces.find((s) => s[key])?.[key] ?? "";
|
|
20
26
|
|
|
21
27
|
const update = (updated: ColorItem) => {
|
|
22
28
|
setLocalColor(updated);
|
|
23
|
-
|
|
29
|
+
// ColorSpaceSchema requires at least one populated space. Emitting an all-empty
|
|
30
|
+
// colour passes here and fails much later as an unattributable save error, so
|
|
31
|
+
// hold it locally and tell the editor why instead.
|
|
32
|
+
if (hasAnyValue(updated)) onChange(updated);
|
|
24
33
|
};
|
|
25
34
|
|
|
26
|
-
const updateSpace = (key:
|
|
27
|
-
const
|
|
28
|
-
|
|
35
|
+
const updateSpace = (key: (typeof KEYS)[number], value: string) => {
|
|
36
|
+
const owner = spaces.findIndex((s) => s[key] !== undefined);
|
|
37
|
+
const target = owner === -1 ? 0 : owner;
|
|
38
|
+
const next = spaces
|
|
39
|
+
.map((s, i) => {
|
|
40
|
+
if (i !== target) return s;
|
|
41
|
+
if (!value) {
|
|
42
|
+
const { [key]: _dropped, ...rest } = s;
|
|
43
|
+
return rest;
|
|
44
|
+
}
|
|
45
|
+
return { ...s, [key]: value };
|
|
46
|
+
})
|
|
47
|
+
// Drop a group the last of its values just left, but always keep one space
|
|
48
|
+
// so the inputs stay bound to something.
|
|
49
|
+
.filter((s, i) => i === 0 || Object.keys(s).length > 0);
|
|
50
|
+
update({ ...localColor, spaces: next });
|
|
29
51
|
};
|
|
30
52
|
|
|
31
53
|
return (
|
|
@@ -35,14 +57,19 @@ export default function ColorSwatchSettings({ color, onChange }: Props) {
|
|
|
35
57
|
value={localColor.name || ""}
|
|
36
58
|
onChange={(name) => update({ ...localColor, name })}
|
|
37
59
|
/>
|
|
38
|
-
{
|
|
60
|
+
{KEYS.map((key) => (
|
|
39
61
|
<Input
|
|
40
62
|
key={key}
|
|
41
63
|
label={key.toUpperCase()}
|
|
42
|
-
value={
|
|
64
|
+
value={valueOf(key)}
|
|
43
65
|
onChange={(value) => updateSpace(key, value)}
|
|
44
66
|
/>
|
|
45
67
|
))}
|
|
68
|
+
{!hasAnyValue(localColor) && (
|
|
69
|
+
<p role="alert" className="mt-1 text-xs text-red-600">
|
|
70
|
+
Enter at least one value — this color won’t be saved until you do.
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
46
73
|
</div>
|
|
47
74
|
);
|
|
48
75
|
}
|
|
@@ -5,7 +5,7 @@ import { EditableGrid } from "../primitives/EditableGrid";
|
|
|
5
5
|
import { EditablePlainText } from "../primitives/EditablePlainText";
|
|
6
6
|
import type { ReactNode } from "react";
|
|
7
7
|
import ColorSwatchSettings from "./ColorSwatchSettings";
|
|
8
|
-
import { getContrastClass, createColorItem } from "./colors-shared";
|
|
8
|
+
import { getContrastClass, createColorItem, orderedSpaceEntries } from "./colors-shared";
|
|
9
9
|
import type { ColorItem } from "../sections/Colors/schema";
|
|
10
10
|
import type { SectionContent } from "../../schemas/sections";
|
|
11
11
|
import type { OpenModal } from "../../lib/registry";
|
|
@@ -140,14 +140,12 @@ function ColorSwatchEditable({
|
|
|
140
140
|
// flex-1 so every swatch's label block grows to the tallest in its row.
|
|
141
141
|
<div className="flex-1 space-y-1 bg-base-accent p-3 text-sm">
|
|
142
142
|
{color.spaces.map((space, j) =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
) : null
|
|
150
|
-
)
|
|
143
|
+
orderedSpaceEntries(space).map(([key, value]) => (
|
|
144
|
+
<div key={`${j}-${key}`} className="flex w-full justify-between">
|
|
145
|
+
<span className="font-medium uppercase">{key}</span>
|
|
146
|
+
<span>{value}</span>
|
|
147
|
+
</div>
|
|
148
|
+
))
|
|
151
149
|
)}
|
|
152
150
|
</div>
|
|
153
151
|
)}
|
|
@@ -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}
|