@drawnagency/primitives 0.1.79 → 0.1.81
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/{chunk-RWMSDDQM.js → chunk-DKXISNJ2.js} +79 -26
- package/dist/{chunk-RSITF24B.js → chunk-EVNEMJZ6.js} +9 -3
- package/dist/{chunk-DR22HM44.js → chunk-FDBZZ4AT.js} +1 -1
- package/dist/{chunk-QB5Q23G2.js → chunk-FN4NKUZU.js} +1 -1
- package/dist/{chunk-EMGLZQCM.js → chunk-MBJUWOM2.js} +1 -1
- package/dist/{chunk-FRTJNS5F.js → chunk-P4ZNHZMP.js} +6 -6
- package/dist/{chunk-UNUJPLZV.js → chunk-PJOHAWNV.js} +1 -1
- package/dist/{chunk-2DDWWTRX.js → chunk-Q6LZOH5M.js} +1 -1
- package/dist/chunk-WP5UX4MI.js +63 -0
- package/dist/{chunk-3LNKOLZP.js → chunk-YKOLOZB2.js} +1 -1
- package/dist/{chunk-4XXLH5CL.js → chunk-ZEXR7JYQ.js} +19 -12
- package/dist/components/brandguide/Colors.d.ts +2 -1
- package/dist/components/brandguide/Colors.d.ts.map +1 -1
- package/dist/components/editor/PagesModal.d.ts +2 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +6 -5
- package/dist/components/primitives/EditableGrid.d.ts +11 -1
- package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
- package/dist/components/primitives/gridDrop.d.ts +18 -0
- package/dist/components/primitives/gridDrop.d.ts.map +1 -0
- package/dist/components/primitives/index.js +7 -6
- package/dist/components/primitives/useEditableCollection.d.ts +1 -0
- package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +7 -6
- package/dist/components/sections/register-schemas.js +10 -9
- package/dist/components/sections/register.js +10 -9
- package/dist/components/shell/DocInspector.d.ts +17 -0
- package/dist/components/shell/DocInspector.d.ts.map +1 -0
- package/dist/components/shell/DocumentEditorOverlay.d.ts +29 -0
- package/dist/components/shell/DocumentEditorOverlay.d.ts.map +1 -0
- package/dist/components/shell/DocumentPresentOverlay.d.ts +30 -0
- package/dist/components/shell/DocumentPresentOverlay.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +2 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts +44 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/HistoryToolbar.d.ts +8 -1
- package/dist/components/shell/HistoryToolbar.d.ts.map +1 -1
- package/dist/document/assets/fonts/albra-grotesk-bold.woff +0 -0
- package/dist/document/assets/fonts/albra-grotesk-regular.woff +0 -0
- package/dist/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
- package/dist/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
- package/dist/document/components/DocCoverPage.d.ts +11 -0
- package/dist/document/components/DocCoverPage.d.ts.map +1 -0
- package/dist/document/components/DocPage.d.ts +10 -0
- package/dist/document/components/DocPage.d.ts.map +1 -0
- package/dist/document/components/DocRail.d.ts +27 -0
- package/dist/document/components/DocRail.d.ts.map +1 -0
- package/dist/document/components/DocSectionPage.d.ts +12 -0
- package/dist/document/components/DocSectionPage.d.ts.map +1 -0
- package/dist/document/components/DocViewer.d.ts +18 -0
- package/dist/document/components/DocViewer.d.ts.map +1 -0
- package/dist/document/components/Wordmark.d.ts +4 -0
- package/dist/document/components/Wordmark.d.ts.map +1 -0
- package/dist/document/components/measure.d.ts +6 -0
- package/dist/document/components/measure.d.ts.map +1 -0
- package/dist/document/components/useDocPages.d.ts +12 -0
- package/dist/document/components/useDocPages.d.ts.map +1 -0
- package/dist/document/engine/blocks.d.ts +2 -0
- package/dist/document/engine/blocks.d.ts.map +1 -0
- package/dist/document/engine/harvest.d.ts +3 -0
- package/dist/document/engine/harvest.d.ts.map +1 -0
- package/dist/document/engine/masonry.d.ts +25 -0
- package/dist/document/engine/masonry.d.ts.map +1 -0
- package/dist/document/engine/paginate.d.ts +40 -0
- package/dist/document/engine/paginate.d.ts.map +1 -0
- package/dist/document/engine/settings.d.ts +70 -0
- package/dist/document/engine/settings.d.ts.map +1 -0
- package/dist/document/engine/types.d.ts +40 -0
- package/dist/document/engine/types.d.ts.map +1 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +881 -0
- package/dist/document/styles/document.css +208 -0
- package/dist/index.js +29 -28
- package/dist/lib/document-input.d.ts +25 -0
- package/dist/lib/document-input.d.ts.map +1 -0
- package/dist/lib/index.js +10 -9
- package/dist/lib/pages.d.ts +2 -0
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/registry.d.ts +3 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/index.js +4 -3
- package/dist/schemas/index.js +3 -2
- package/dist/schemas/site-config.d.ts +223 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/brandguide/Colors.tsx +15 -5
- package/src/components/editor/PagesModal.tsx +16 -2
- package/src/components/primitives/EditableGrid.tsx +86 -24
- package/src/components/primitives/gridDrop.ts +26 -0
- package/src/components/primitives/useEditableCollection.ts +16 -1
- package/src/components/sections/Colors/index.tsx +2 -1
- package/src/components/shell/DocInspector.tsx +278 -0
- package/src/components/shell/DocumentEditorOverlay.tsx +169 -0
- package/src/components/shell/DocumentPresentOverlay.tsx +87 -0
- package/src/components/shell/EditorContext.tsx +6 -1
- package/src/components/shell/EditorShell.tsx +171 -10
- package/src/components/shell/HistoryToolbar.tsx +24 -2
- package/src/document/assets/fonts/albra-grotesk-bold.woff +0 -0
- package/src/document/assets/fonts/albra-grotesk-regular.woff +0 -0
- package/src/document/assets/fonts/kapra-neue-expanded-medium.woff +0 -0
- package/src/document/assets/fonts/kapra-neue-expanded-regular.woff +0 -0
- package/src/document/components/DocCoverPage.tsx +45 -0
- package/src/document/components/DocPage.tsx +40 -0
- package/src/document/components/DocRail.tsx +160 -0
- package/src/document/components/DocSectionPage.tsx +45 -0
- package/src/document/components/DocViewer.tsx +285 -0
- package/src/document/components/Wordmark.tsx +18 -0
- package/src/document/components/measure.tsx +105 -0
- package/src/document/components/useDocPages.ts +67 -0
- package/src/document/engine/blocks.ts +87 -0
- package/src/document/engine/harvest.ts +80 -0
- package/src/document/engine/masonry.ts +89 -0
- package/src/document/engine/paginate.ts +209 -0
- package/src/document/engine/settings.ts +77 -0
- package/src/document/engine/types.ts +54 -0
- package/src/document/index.ts +18 -0
- package/src/document/styles/document.css +208 -0
- package/src/lib/document-input.ts +57 -0
- package/src/lib/pages.ts +22 -0
- package/src/lib/registry.ts +3 -0
- package/src/schemas/site-config.ts +22 -7
- /package/dist/{chunk-4YEOTFVU.js → chunk-YBVK254P.js} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a drop target (the cell being dropped onto + the closest edge) into
|
|
3
|
+
* the destination index for the moved item.
|
|
4
|
+
*
|
|
5
|
+
* Same-collection reorder: the dragged item is spliced OUT of the array before
|
|
6
|
+
* being re-inserted, so a destination past its original position shifts down by
|
|
7
|
+
* one — hence the `fromIndex < toIndex` decrement.
|
|
8
|
+
*
|
|
9
|
+
* Cross-collection insert: nothing is removed from the destination array, so no
|
|
10
|
+
* shift adjustment applies.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveDropIndex({
|
|
13
|
+
fromIndex,
|
|
14
|
+
targetIndex,
|
|
15
|
+
edge,
|
|
16
|
+
sameCollection,
|
|
17
|
+
}: {
|
|
18
|
+
fromIndex: number;
|
|
19
|
+
targetIndex: number;
|
|
20
|
+
edge: "left" | "right" | null;
|
|
21
|
+
sameCollection: boolean;
|
|
22
|
+
}): number {
|
|
23
|
+
let toIndex = targetIndex + (edge === "right" ? 1 : 0);
|
|
24
|
+
if (sameCollection && fromIndex < toIndex) toIndex--;
|
|
25
|
+
return toIndex;
|
|
26
|
+
}
|
|
@@ -16,6 +16,7 @@ interface UseEditableCollectionReturn<T> {
|
|
|
16
16
|
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
17
17
|
onAdd: () => void;
|
|
18
18
|
onInsert: (index: number) => void;
|
|
19
|
+
onInsertItem: (index: number, data: T) => void;
|
|
19
20
|
onRemove: (id: string) => void;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -89,6 +90,20 @@ export function useEditableCollection<T>({
|
|
|
89
90
|
[createItem, onChange],
|
|
90
91
|
);
|
|
91
92
|
|
|
93
|
+
// Insert an externally-provided item (e.g. one dragged in from another grid)
|
|
94
|
+
// at a specific index. Distinct from onInsert, which creates a blank item.
|
|
95
|
+
const onInsertItem = useCallback(
|
|
96
|
+
(index: number, data: T) => {
|
|
97
|
+
setWrappedItems((prev) => {
|
|
98
|
+
const next = [...prev];
|
|
99
|
+
next.splice(index, 0, { id: uid(), data });
|
|
100
|
+
onChange(next.map((w) => w.data));
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
[onChange]
|
|
105
|
+
);
|
|
106
|
+
|
|
92
107
|
const onRemove = useCallback(
|
|
93
108
|
(id: string) => {
|
|
94
109
|
setWrappedItems((prev) => {
|
|
@@ -100,5 +115,5 @@ export function useEditableCollection<T>({
|
|
|
100
115
|
[onChange]
|
|
101
116
|
);
|
|
102
117
|
|
|
103
|
-
return { wrappedItems, onReorder, onAdd, onInsert, onRemove };
|
|
118
|
+
return { wrappedItems, onReorder, onAdd, onInsert, onInsertItem, onRemove };
|
|
104
119
|
}
|
|
@@ -30,7 +30,7 @@ export default defineSection({
|
|
|
30
30
|
schema,
|
|
31
31
|
authoringNote:
|
|
32
32
|
"A brand color palette. Each color may carry hex/rgb/cmyk/pantone values. Use to document brand colors with swatches.",
|
|
33
|
-
component: ({ content, options, onChange, openModal }) => (
|
|
33
|
+
component: ({ content, options, onChange, openModal, onDelete }) => (
|
|
34
34
|
<Colors
|
|
35
35
|
colors={content.content.colors}
|
|
36
36
|
columns={options?.columns as number}
|
|
@@ -39,6 +39,7 @@ export default defineSection({
|
|
|
39
39
|
showLabel={options?.showLabel as boolean}
|
|
40
40
|
onChange={onChange ? (c) => onChange(c as typeof content) : undefined}
|
|
41
41
|
openModal={openModal}
|
|
42
|
+
onDelete={onDelete}
|
|
42
43
|
/>
|
|
43
44
|
),
|
|
44
45
|
defaults: () => ({
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { settingsFor, type DocumentOptions } from "../../document";
|
|
2
|
+
import { Input } from "../shared/Input";
|
|
3
|
+
import { Select } from "../shared/Select";
|
|
4
|
+
import { Checkbox } from "../shared/Checkbox";
|
|
5
|
+
import { RadioGroup } from "../shared/RadioGroup";
|
|
6
|
+
import { ColorPicker } from "../shared/ColorPicker";
|
|
7
|
+
import { FormLabel } from "../shared/FormLabel";
|
|
8
|
+
import { useMediaLibrary } from "./MediaLibraryContext";
|
|
9
|
+
import { Button } from "../shared/Button";
|
|
10
|
+
import type { MediaItem } from "../../media/types";
|
|
11
|
+
|
|
12
|
+
// Smallest generated variant for the inspector thumbnail (same file naming as
|
|
13
|
+
// docImagesFrom in lib/document-input.ts); null when the item can't render.
|
|
14
|
+
function thumbSrc(imageId: string, item: MediaItem | undefined): string | null {
|
|
15
|
+
if (!item || item.kind !== "image") return null;
|
|
16
|
+
if (item.mimeType === "image/svg+xml") return `/api/media/${imageId}/original.svg`;
|
|
17
|
+
const widths = item.variants.map((v) => v.width).sort((a, b) => a - b);
|
|
18
|
+
if (widths.length === 0) return null;
|
|
19
|
+
return `/api/media/${imageId}/${widths[0]}.webp`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DocInspectorProps {
|
|
23
|
+
document: DocumentOptions;
|
|
24
|
+
selection: { kind: "cover" } | { kind: "chunk"; chunkId: string; title: string };
|
|
25
|
+
warnings: string[]; // warningsByChunk for the selected chunk
|
|
26
|
+
onUpdateCover: (patch: Partial<DocumentOptions["cover"]>) => void;
|
|
27
|
+
onUpdateMeta: (patch: Partial<DocumentOptions["meta"]>) => void;
|
|
28
|
+
// Caller merges the patch via sectionSettingsSchema.parse.
|
|
29
|
+
onUpdateSection: (chunkId: string, patch: Record<string, unknown>) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Drawn palette — mirrors the swatch row in the harness inspector.
|
|
33
|
+
const SWATCHES = [
|
|
34
|
+
"#FFFFFF", "#EBEBEB", "#999999", "#333333", "#1F1F1F", // neutrals: white → near-black
|
|
35
|
+
"#009FAD", // teal (brand primary)
|
|
36
|
+
"#3D7F7F", // forest
|
|
37
|
+
"#099A6F", // green
|
|
38
|
+
"#5F81AB", // blue
|
|
39
|
+
"#7C97BB", // sky
|
|
40
|
+
"#6864A0", // violet
|
|
41
|
+
"#D3A978", // beige
|
|
42
|
+
"#EB9500", // gold
|
|
43
|
+
"#F07C00", // orange
|
|
44
|
+
"#A24E16", // brown / rust
|
|
45
|
+
"#A65982", // indigo
|
|
46
|
+
"#E64165", // pink
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function ColorControl({ label, value, onPick }: { label: string; value: string; onPick: (v: string) => void }) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="mb-4">
|
|
52
|
+
<FormLabel>{label}</FormLabel>
|
|
53
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
54
|
+
{SWATCHES.map((s) => (
|
|
55
|
+
<button
|
|
56
|
+
key={s}
|
|
57
|
+
type="button"
|
|
58
|
+
aria-label={s}
|
|
59
|
+
onClick={() => onPick(s)}
|
|
60
|
+
style={{ background: s }}
|
|
61
|
+
className={`h-6 w-6 rounded border ${s === value ? "border-primary ring-1 ring-primary" : "border-base-200"}`}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
<ColorPicker value={value} onChange={onPick} label="Custom color" className="h-6 w-6" />
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function DocInspector({
|
|
71
|
+
document: doc,
|
|
72
|
+
selection,
|
|
73
|
+
warnings,
|
|
74
|
+
onUpdateCover,
|
|
75
|
+
onUpdateMeta,
|
|
76
|
+
onUpdateSection,
|
|
77
|
+
}: DocInspectorProps) {
|
|
78
|
+
return (
|
|
79
|
+
<aside data-testid="doc-inspector" className="w-64 shrink-0 overflow-y-auto border-l border-base-200 bg-base p-4">
|
|
80
|
+
{selection.kind === "cover" ? (
|
|
81
|
+
<CoverPanel doc={doc} onUpdateCover={onUpdateCover} onUpdateMeta={onUpdateMeta} />
|
|
82
|
+
) : (
|
|
83
|
+
<ChunkPanel
|
|
84
|
+
key={selection.chunkId}
|
|
85
|
+
chunkId={selection.chunkId}
|
|
86
|
+
title={selection.title}
|
|
87
|
+
doc={doc}
|
|
88
|
+
warnings={warnings}
|
|
89
|
+
onUpdateSection={onUpdateSection}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</aside>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function CoverPanel({
|
|
97
|
+
doc,
|
|
98
|
+
onUpdateCover,
|
|
99
|
+
onUpdateMeta,
|
|
100
|
+
}: {
|
|
101
|
+
doc: DocumentOptions;
|
|
102
|
+
onUpdateCover: (patch: Partial<DocumentOptions["cover"]>) => void;
|
|
103
|
+
onUpdateMeta: (patch: Partial<DocumentOptions["meta"]>) => void;
|
|
104
|
+
}) {
|
|
105
|
+
const { cover, meta } = doc;
|
|
106
|
+
const media = useMediaLibrary();
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-base-contrast-light">Cover</h2>
|
|
110
|
+
<Input label="Title" value={cover.title} onChange={(title) => onUpdateCover({ title })} className="mb-2" />
|
|
111
|
+
<Input label="Label" value={cover.label} onChange={(label) => onUpdateCover({ label })} className="mb-2" />
|
|
112
|
+
<Input label="Date" value={cover.date} onChange={(date) => onUpdateCover({ date })} className="mb-2" />
|
|
113
|
+
|
|
114
|
+
{media ? (
|
|
115
|
+
<div className="mb-4">
|
|
116
|
+
<FormLabel>Cover image</FormLabel>
|
|
117
|
+
{cover.imageId ? (
|
|
118
|
+
(() => {
|
|
119
|
+
const src = thumbSrc(cover.imageId, media.manifest.images[cover.imageId]);
|
|
120
|
+
return src ? (
|
|
121
|
+
<img src={src} alt="Cover image preview" className="mb-2 max-h-24 rounded border border-base-200" />
|
|
122
|
+
) : null;
|
|
123
|
+
})()
|
|
124
|
+
) : null}
|
|
125
|
+
<div className="flex gap-2">
|
|
126
|
+
<Button
|
|
127
|
+
variant="secondary"
|
|
128
|
+
size="sm"
|
|
129
|
+
onClick={() => media.openSelectModal((imageId) => onUpdateCover({ imageId }))}
|
|
130
|
+
>
|
|
131
|
+
{cover.imageId ? "Replace image" : "Choose image"}
|
|
132
|
+
</Button>
|
|
133
|
+
{cover.imageId ? (
|
|
134
|
+
<Button variant="ghost" size="sm" onClick={() => onUpdateCover({ imageId: null })}>
|
|
135
|
+
Remove image
|
|
136
|
+
</Button>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
) : null}
|
|
141
|
+
|
|
142
|
+
<ColorControl label="Background" value={cover.bgColor} onPick={(bgColor) => onUpdateCover({ bgColor })} />
|
|
143
|
+
<ColorControl label="Accent" value={cover.accentColor} onPick={(accentColor) => onUpdateCover({ accentColor })} />
|
|
144
|
+
|
|
145
|
+
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-base-contrast-light">Document</h2>
|
|
146
|
+
<Input label="Client" value={meta.client} onChange={(client) => onUpdateMeta({ client })} className="mb-2" />
|
|
147
|
+
<Input label="Project" value={meta.project} onChange={(project) => onUpdateMeta({ project })} className="mb-2" />
|
|
148
|
+
<Input label="Date label" value={meta.dateLabel} onChange={(dateLabel) => onUpdateMeta({ dateLabel })} className="mb-2" />
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ChunkPanel({
|
|
154
|
+
chunkId,
|
|
155
|
+
title,
|
|
156
|
+
doc,
|
|
157
|
+
warnings,
|
|
158
|
+
onUpdateSection,
|
|
159
|
+
}: {
|
|
160
|
+
chunkId: string;
|
|
161
|
+
title: string;
|
|
162
|
+
doc: DocumentOptions;
|
|
163
|
+
warnings: string[];
|
|
164
|
+
onUpdateSection: (chunkId: string, patch: Record<string, unknown>) => void;
|
|
165
|
+
}) {
|
|
166
|
+
const settings = settingsFor(doc.sections, chunkId);
|
|
167
|
+
const layout = settings.imageLayout;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-base-contrast-light">{title}</h2>
|
|
172
|
+
|
|
173
|
+
<Checkbox
|
|
174
|
+
className="mb-4"
|
|
175
|
+
checked={!settings.excluded}
|
|
176
|
+
onChange={(checked) => onUpdateSection(chunkId, { excluded: !checked })}
|
|
177
|
+
label="Include in document"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{/* Image placement is linked, not independent (Drew, 2026-07-19): text
|
|
181
|
+
left → images right of the column, text top → images below full
|
|
182
|
+
width. One control drives both fields. */}
|
|
183
|
+
<RadioGroup
|
|
184
|
+
className="mb-4"
|
|
185
|
+
label="Text placement"
|
|
186
|
+
value={settings.textPlacement}
|
|
187
|
+
options={[
|
|
188
|
+
{ label: "Top", value: "top" },
|
|
189
|
+
{ label: "Left column", value: "leftColumn" },
|
|
190
|
+
]}
|
|
191
|
+
onChange={(textPlacement) =>
|
|
192
|
+
onUpdateSection(chunkId, {
|
|
193
|
+
textPlacement,
|
|
194
|
+
imagePlacement: textPlacement === "leftColumn" ? "right" : "fullWidth",
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
<Checkbox
|
|
200
|
+
className="mb-4"
|
|
201
|
+
checked={settings.dropTextOverflow}
|
|
202
|
+
onChange={(dropTextOverflow) => onUpdateSection(chunkId, { dropTextOverflow })}
|
|
203
|
+
label="Drop text overflow"
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
<Select
|
|
207
|
+
className="mb-4"
|
|
208
|
+
label="Image layout"
|
|
209
|
+
value={layout.mode}
|
|
210
|
+
options={[
|
|
211
|
+
{ value: "grid", label: "Grid" },
|
|
212
|
+
{ value: "masonry", label: "Masonry" },
|
|
213
|
+
]}
|
|
214
|
+
onChange={(v) =>
|
|
215
|
+
onUpdateSection(
|
|
216
|
+
chunkId,
|
|
217
|
+
v === "grid"
|
|
218
|
+
? { imageLayout: { mode: "grid", rows: 2, columns: 4 } }
|
|
219
|
+
: { imageLayout: { mode: "masonry", flow: "rows", columns: 3 } },
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
{layout.mode === "grid" ? (
|
|
225
|
+
<>
|
|
226
|
+
<Select
|
|
227
|
+
className="mb-4"
|
|
228
|
+
label="Rows"
|
|
229
|
+
value={String(layout.rows)}
|
|
230
|
+
options={[1, 2, 3].map((n) => ({ value: String(n), label: String(n) }))}
|
|
231
|
+
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, rows: Number(v) } })}
|
|
232
|
+
/>
|
|
233
|
+
<Select
|
|
234
|
+
className="mb-4"
|
|
235
|
+
label="Columns"
|
|
236
|
+
value={String(layout.columns)}
|
|
237
|
+
options={[2, 3, 4].map((n) => ({ value: String(n), label: String(n) }))}
|
|
238
|
+
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, columns: Number(v) } })}
|
|
239
|
+
/>
|
|
240
|
+
</>
|
|
241
|
+
) : (
|
|
242
|
+
<>
|
|
243
|
+
<Select
|
|
244
|
+
className="mb-4"
|
|
245
|
+
label="Flow"
|
|
246
|
+
value={layout.flow}
|
|
247
|
+
options={[
|
|
248
|
+
{ value: "rows", label: "Rows" },
|
|
249
|
+
{ value: "columns", label: "Columns" },
|
|
250
|
+
]}
|
|
251
|
+
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, flow: v } })}
|
|
252
|
+
/>
|
|
253
|
+
{/* Column count only drives the columns flow (layoutRows never reads
|
|
254
|
+
it) — hide it when irrelevant. */}
|
|
255
|
+
{layout.flow === "columns" ? (
|
|
256
|
+
<Select
|
|
257
|
+
className="mb-4"
|
|
258
|
+
label="Columns"
|
|
259
|
+
value={String(layout.columns)}
|
|
260
|
+
options={[2, 3, 4, 5, 6].map((n) => ({ value: String(n), label: String(n) }))}
|
|
261
|
+
onChange={(v) => onUpdateSection(chunkId, { imageLayout: { ...layout, columns: Number(v) } })}
|
|
262
|
+
/>
|
|
263
|
+
) : null}
|
|
264
|
+
</>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{warnings.length > 0 ? (
|
|
268
|
+
<div className="mt-2">
|
|
269
|
+
{warnings.map((w, i) => (
|
|
270
|
+
<p key={i} className="mb-1 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
|
|
271
|
+
{w}
|
|
272
|
+
</p>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
) : null}
|
|
276
|
+
</>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { Page } from "../../schemas/site-config";
|
|
3
|
+
import type { MediaManifest } from "../../media/types";
|
|
4
|
+
import type { LoadedSection } from "../../lib/loader";
|
|
5
|
+
import {
|
|
6
|
+
DocViewer,
|
|
7
|
+
documentOptionsSchema,
|
|
8
|
+
sectionSettingsSchema,
|
|
9
|
+
settingsFor,
|
|
10
|
+
type Chunk,
|
|
11
|
+
type DocHost,
|
|
12
|
+
type DocumentOptions,
|
|
13
|
+
} from "../../document";
|
|
14
|
+
import { DocInspector, type DocInspectorProps } from "./DocInspector";
|
|
15
|
+
import { buildDraftDocumentInput } from "../../lib/document-input";
|
|
16
|
+
import { Button } from "../shared/Button";
|
|
17
|
+
// The sealed document stylesheet ships with its editor consumer (Task 6 decided
|
|
18
|
+
// the present route imports it from its island; this is the editor's island).
|
|
19
|
+
// Vitest stubs CSS imports, so it stays out of the jsdom test graph regardless.
|
|
20
|
+
import "../../document/styles/document.css";
|
|
21
|
+
|
|
22
|
+
export interface DocumentEditorOverlayProps {
|
|
23
|
+
/** The active page — its `document` block must be enabled for the FAB to mount. */
|
|
24
|
+
page: Page;
|
|
25
|
+
/**
|
|
26
|
+
* The page's draft sections, in `page.order` order. The overlay drops
|
|
27
|
+
* archived ones itself — /present renders only live sections, so a chunk the
|
|
28
|
+
* published document will never show must not influence the proofed layout.
|
|
29
|
+
*/
|
|
30
|
+
sections: LoadedSection[];
|
|
31
|
+
manifest: MediaManifest;
|
|
32
|
+
/** Wired by the shell to `setPageDocument` + the local-changes dirty flag. */
|
|
33
|
+
onUpdateDocument: (patch: Partial<DocumentOptions>) => void;
|
|
34
|
+
onClose: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Full-viewport editing surface: DocViewer (edit mode, ghosts + selection) on
|
|
39
|
+
* the left, DocInspector (settings for the current selection) on the right.
|
|
40
|
+
*
|
|
41
|
+
* The engine input is rebuilt from the live draft (page + in-memory sections +
|
|
42
|
+
* manifest) on every prop change, so unsaved edits repaginate immediately —
|
|
43
|
+
* the pagination hook's stale-heights guard absorbs the shape changes.
|
|
44
|
+
*/
|
|
45
|
+
export function DocumentEditorOverlay({
|
|
46
|
+
page,
|
|
47
|
+
sections,
|
|
48
|
+
manifest,
|
|
49
|
+
onUpdateDocument,
|
|
50
|
+
onClose,
|
|
51
|
+
}: DocumentEditorOverlayProps) {
|
|
52
|
+
// page.document is optional on the type; the caller only mounts this when it
|
|
53
|
+
// is enabled, but parse-with-defaults keeps us total either way.
|
|
54
|
+
const doc = useMemo(() => documentOptionsSchema.parse(page.document ?? {}), [page.document]);
|
|
55
|
+
|
|
56
|
+
const source = useMemo(
|
|
57
|
+
() =>
|
|
58
|
+
buildDraftDocumentInput({
|
|
59
|
+
page,
|
|
60
|
+
sections: sections.filter((s) => s.meta.status !== "archived"),
|
|
61
|
+
manifest,
|
|
62
|
+
}),
|
|
63
|
+
[page, sections, manifest],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Titles (for the inspector header) + per-chunk warnings come straight from
|
|
67
|
+
// DocViewer's single pagination pass via onDocState — no second useDocPages.
|
|
68
|
+
const [docState, setDocState] = useState<{ chunks: Chunk[]; warningsByChunk: Record<string, string[]> }>({
|
|
69
|
+
chunks: [],
|
|
70
|
+
warningsByChunk: {},
|
|
71
|
+
});
|
|
72
|
+
const { chunks, warningsByChunk } = docState;
|
|
73
|
+
|
|
74
|
+
// null = cover selected.
|
|
75
|
+
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null);
|
|
76
|
+
|
|
77
|
+
// All media bytes come from /api/media (draft branch bytes are served to
|
|
78
|
+
// editor sessions) — the same host shape the present route uses.
|
|
79
|
+
const host: DocHost = useMemo(
|
|
80
|
+
() => ({
|
|
81
|
+
variant: "preview",
|
|
82
|
+
imageSrc: (image, variant) =>
|
|
83
|
+
`/api/media/${image.imageId}/${variant === "print" ? image.printFile : image.previewFile}`,
|
|
84
|
+
}),
|
|
85
|
+
[],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Escape closes (the close button is the other exit). DocViewer owns arrow
|
|
89
|
+
// paging on its own window listener; Escape doesn't collide with it. An
|
|
90
|
+
// Escape aimed at an inspector control is the cancel-the-field gesture, not
|
|
91
|
+
// "leave the editor": text fields blur (so the next Escape closes), and a
|
|
92
|
+
// select is left alone entirely — the browser uses Escape to dismiss its
|
|
93
|
+
// popup and there's no way to tell whether one is open.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const onKey = (e: KeyboardEvent) => {
|
|
96
|
+
if (e.key !== "Escape") return;
|
|
97
|
+
const t = e.target as HTMLElement | null;
|
|
98
|
+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
|
|
99
|
+
t.blur();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (t && t.tagName === "SELECT") return;
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
onClose();
|
|
105
|
+
};
|
|
106
|
+
window.addEventListener("keydown", onKey);
|
|
107
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
108
|
+
}, [onClose]);
|
|
109
|
+
|
|
110
|
+
const selection: DocInspectorProps["selection"] = useMemo(() => {
|
|
111
|
+
if (selectedChunkId === null) return { kind: "cover" };
|
|
112
|
+
const chunk = chunks.find((c) => c.id === selectedChunkId);
|
|
113
|
+
return { kind: "chunk", chunkId: selectedChunkId, title: chunk?.title ?? selectedChunkId };
|
|
114
|
+
}, [selectedChunkId, chunks]);
|
|
115
|
+
|
|
116
|
+
const warnings = selectedChunkId ? warningsByChunk[selectedChunkId] ?? [] : [];
|
|
117
|
+
|
|
118
|
+
// Merge the inspector's partial patch onto the chunk's CURRENT settings and
|
|
119
|
+
// ship the whole validated block — setPageDocument merges sections shallowly
|
|
120
|
+
// by key, so we must hand it a complete SectionSettings, not a fragment.
|
|
121
|
+
const handleUpdateSection = useCallback(
|
|
122
|
+
(chunkId: string, patch: Record<string, unknown>) => {
|
|
123
|
+
const merged = sectionSettingsSchema.parse({ ...settingsFor(doc.sections, chunkId), ...patch });
|
|
124
|
+
onUpdateDocument({ sections: { [chunkId]: merged } });
|
|
125
|
+
},
|
|
126
|
+
[doc.sections, onUpdateDocument],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Cover/meta are single objects on the document; merge onto the current value
|
|
130
|
+
// so a one-field edit doesn't blank the siblings before setPageDocument runs.
|
|
131
|
+
const handleUpdateCover = useCallback(
|
|
132
|
+
(patch: Partial<DocumentOptions["cover"]>) => onUpdateDocument({ cover: { ...doc.cover, ...patch } }),
|
|
133
|
+
[doc.cover, onUpdateDocument],
|
|
134
|
+
);
|
|
135
|
+
const handleUpdateMeta = useCallback(
|
|
136
|
+
(patch: Partial<DocumentOptions["meta"]>) => onUpdateDocument({ meta: { ...doc.meta, ...patch } }),
|
|
137
|
+
[doc.meta, onUpdateDocument],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-base" role="dialog" aria-modal="true" aria-label="Document editor">
|
|
142
|
+
<div className="flex items-center justify-between border-b border-base-200 bg-base px-4 py-2">
|
|
143
|
+
<span className="text-sm font-medium text-base-contrast">Document · {page.title}</span>
|
|
144
|
+
<Button variant="secondary" size="sm" aria-label="Close document editor" onClick={onClose}>
|
|
145
|
+
Close
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="flex min-h-0 flex-1">
|
|
149
|
+
<DocViewer
|
|
150
|
+
source={source}
|
|
151
|
+
document={doc}
|
|
152
|
+
host={host}
|
|
153
|
+
mode="edit"
|
|
154
|
+
selectedChunkId={selectedChunkId}
|
|
155
|
+
onSelectChunk={setSelectedChunkId}
|
|
156
|
+
onDocState={setDocState}
|
|
157
|
+
/>
|
|
158
|
+
<DocInspector
|
|
159
|
+
document={doc}
|
|
160
|
+
selection={selection}
|
|
161
|
+
warnings={warnings}
|
|
162
|
+
onUpdateCover={handleUpdateCover}
|
|
163
|
+
onUpdateMeta={handleUpdateMeta}
|
|
164
|
+
onUpdateSection={handleUpdateSection}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import type { Page } from "../../schemas/site-config";
|
|
3
|
+
import type { MediaManifest } from "../../media/types";
|
|
4
|
+
import type { LoadedSection } from "../../lib/loader";
|
|
5
|
+
import { DocViewer, documentOptionsSchema, type DocHost } from "../../document";
|
|
6
|
+
import { buildDraftDocumentInput } from "../../lib/document-input";
|
|
7
|
+
// Same sealed document stylesheet the editor overlay ships (see
|
|
8
|
+
// DocumentEditorOverlay). Vitest stubs CSS imports, so it stays out of the jsdom
|
|
9
|
+
// test graph regardless.
|
|
10
|
+
import "../../document/styles/document.css";
|
|
11
|
+
|
|
12
|
+
export interface DocumentPresentOverlayProps {
|
|
13
|
+
/** The active page — its `document` block supplies cover/meta/section settings. */
|
|
14
|
+
page: Page;
|
|
15
|
+
/**
|
|
16
|
+
* The sections currently on the canvas (already page-ordered and filtered to
|
|
17
|
+
* the branch being previewed). The shell derives these from the same
|
|
18
|
+
* `computeDisplaySections` call the history/branch canvas renders.
|
|
19
|
+
*/
|
|
20
|
+
sections: LoadedSection[];
|
|
21
|
+
manifest: MediaManifest;
|
|
22
|
+
onClose: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read-only, full-viewport document PRESENTATION of the content currently being
|
|
27
|
+
* viewed — used when the shell is previewing a non-draft version (the published
|
|
28
|
+
* branch). It is `DocViewer` in `present` mode: no inspector, no update path.
|
|
29
|
+
*
|
|
30
|
+
* Product decision (Drew): history/branch views are the record — we present them
|
|
31
|
+
* but never open the editor over them. Document SETTINGS come from the current
|
|
32
|
+
* draft's `page.document`; the previewed branch's own document index is not
|
|
33
|
+
* carried in the shell's `viewSections`, so cover/meta/exclusions here reflect
|
|
34
|
+
* the draft, not the historical commit. This is an accepted approximation: we
|
|
35
|
+
* are rendering historical *content* with the draft's presentation settings.
|
|
36
|
+
*/
|
|
37
|
+
export function DocumentPresentOverlay({
|
|
38
|
+
page,
|
|
39
|
+
sections,
|
|
40
|
+
manifest,
|
|
41
|
+
onClose,
|
|
42
|
+
}: DocumentPresentOverlayProps) {
|
|
43
|
+
// page.document is optional on the type; the caller only mounts this when it
|
|
44
|
+
// is enabled, but parse-with-defaults keeps us total either way.
|
|
45
|
+
const doc = useMemo(() => documentOptionsSchema.parse(page.document ?? {}), [page.document]);
|
|
46
|
+
|
|
47
|
+
const source = useMemo(
|
|
48
|
+
() => buildDraftDocumentInput({ page, sections, manifest }),
|
|
49
|
+
[page, sections, manifest],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// All media bytes come from /api/media — the same host shape the present route
|
|
53
|
+
// and the editor overlay use.
|
|
54
|
+
const host: DocHost = useMemo(
|
|
55
|
+
() => ({
|
|
56
|
+
variant: "preview",
|
|
57
|
+
imageSrc: (image, variant) =>
|
|
58
|
+
`/api/media/${image.imageId}/${variant === "print" ? image.printFile : image.previewFile}`,
|
|
59
|
+
}),
|
|
60
|
+
[],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Escape closes, matching the editor overlay. DocViewer owns arrow paging on
|
|
64
|
+
// its own window listener; Escape doesn't collide with it.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const onKey = (e: KeyboardEvent) => {
|
|
67
|
+
if (e.key !== "Escape") return;
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
onClose();
|
|
70
|
+
};
|
|
71
|
+
window.addEventListener("keydown", onKey);
|
|
72
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
73
|
+
}, [onClose]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
className="fixed inset-0 z-50 flex flex-col bg-base"
|
|
78
|
+
role="dialog"
|
|
79
|
+
aria-modal="true"
|
|
80
|
+
aria-label="Document presentation"
|
|
81
|
+
>
|
|
82
|
+
<div className="flex min-h-0 flex-1">
|
|
83
|
+
<DocViewer source={source} document={doc} host={host} mode="present" onClose={onClose} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -16,10 +16,12 @@ interface EditorContextValue {
|
|
|
16
16
|
showAllChrome: boolean;
|
|
17
17
|
viewBranch: "saved" | "live";
|
|
18
18
|
historyState: HistoryState;
|
|
19
|
+
historyPageId: string | null;
|
|
19
20
|
toggleEditMode: () => void;
|
|
20
21
|
toggleShowAllChrome: () => void;
|
|
21
22
|
setViewBranch: (branch: "saved" | "live") => void;
|
|
22
23
|
setHistoryState: (state: HistoryState) => void;
|
|
24
|
+
setHistoryPageId: (pageId: string | null) => void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const EditorContext = createContext<EditorContextValue | null>(null);
|
|
@@ -33,7 +35,10 @@ export function EditorProvider({ children }: EditorProviderProps) {
|
|
|
33
35
|
const [showAllChrome, setShowAllChrome] = useState(false);
|
|
34
36
|
const [viewBranch, setViewBranchState] = useState<"saved" | "live">("saved");
|
|
35
37
|
const [historyState, setHistoryStateRaw] = useState<HistoryState>(null);
|
|
38
|
+
const [historyPageId, setHistoryPageId] = useState<string | null>(null);
|
|
36
39
|
const setHistoryState = useCallback((state: HistoryState) => {
|
|
40
|
+
// null = "auto": resolved to the current page (or home) at render time
|
|
41
|
+
setHistoryPageId(null);
|
|
37
42
|
setHistoryStateRaw(state);
|
|
38
43
|
}, []);
|
|
39
44
|
|
|
@@ -49,7 +54,7 @@ export function EditorProvider({ children }: EditorProviderProps) {
|
|
|
49
54
|
const setViewBranch = useCallback((b: "saved" | "live") => setViewBranchState(b), []);
|
|
50
55
|
|
|
51
56
|
return (
|
|
52
|
-
<EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, historyState, toggleEditMode, toggleShowAllChrome, setViewBranch, setHistoryState }}>
|
|
57
|
+
<EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, historyState, historyPageId, toggleEditMode, toggleShowAllChrome, setViewBranch, setHistoryState, setHistoryPageId }}>
|
|
53
58
|
{children}
|
|
54
59
|
</EditorContext.Provider>
|
|
55
60
|
);
|