@drawnagency/primitives 0.1.106 → 0.1.108
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-QLBHERK6.js → chunk-IPKZ5FU6.js} +74 -24
- package/dist/{chunk-Z7VXESB6.js → chunk-JIQBLG6Y.js} +6 -3
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +1 -1
- package/dist/components/sections/register-schemas.js +2 -2
- package/dist/components/sections/register.js +2 -2
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SectionTypePicker.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +2 -0
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/lib/container-ops.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +25 -8
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +19 -17
- package/dist/lib/insert-menu.d.ts +7 -3
- package/dist/lib/insert-menu.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/sections/Container/Container.tsx +12 -2
- package/src/components/sections/IconList/IconList.tsx +75 -22
- package/src/components/shell/EditorShell.tsx +68 -30
- package/src/components/shell/MediaLibraryModal.tsx +42 -5
- package/src/components/shell/SectionTypePicker.tsx +28 -2
- package/src/hooks/useEditorPublish.ts +29 -4
- package/src/lib/container-ops.ts +6 -3
- package/src/lib/dexie.ts +62 -28
- package/src/lib/insert-menu.ts +38 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useEffect, useState, useCallback } from "react";
|
|
1
|
+
import { useRef, useEffect, useState, useCallback, useId } from "react";
|
|
2
2
|
import type { SectionContent } from "../../../schemas/sections";
|
|
3
3
|
import { cn } from "../../../lib/cn";
|
|
4
4
|
import { getIcon } from "../../../lib/icons";
|
|
@@ -200,7 +200,8 @@ function EditIconList({
|
|
|
200
200
|
iconClassName: string;
|
|
201
201
|
onItemsChange: (items: IconListItem[]) => void;
|
|
202
202
|
}) {
|
|
203
|
-
const
|
|
203
|
+
const listId = useId();
|
|
204
|
+
const { wrappedItems, onReorder, onAdd, onInsertItem, onRemove } = useEditableCollection({
|
|
204
205
|
items,
|
|
205
206
|
onChange: onItemsChange,
|
|
206
207
|
createItem: () => ({ label: "", text: "" }),
|
|
@@ -211,6 +212,57 @@ function EditIconList({
|
|
|
211
212
|
targetId: string | null;
|
|
212
213
|
}>({ sourceId: null, targetId: null });
|
|
213
214
|
|
|
215
|
+
// The drop transaction lives here, not in the row/trailing drop targets,
|
|
216
|
+
// because this monitor is GLOBAL: every icon list on the page sees the same
|
|
217
|
+
// drop and acts on its own collection — the source removes, the destination
|
|
218
|
+
// inserts, and a same-list drop reorders. Rows only advertise where they are.
|
|
219
|
+
// Handlers are read through a ref so the monitor subscribes exactly once.
|
|
220
|
+
const cfgRef = useRef({ listId, onReorder, onRemove, onInsertItem });
|
|
221
|
+
cfgRef.current = { listId, onReorder, onRemove, onInsertItem };
|
|
222
|
+
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
let cleanup: (() => void) | undefined;
|
|
225
|
+
let cancelled = false;
|
|
226
|
+
|
|
227
|
+
import("@atlaskit/pragmatic-drag-and-drop/element/adapter").then(({ monitorForElements }) => {
|
|
228
|
+
if (cancelled) return;
|
|
229
|
+
cleanup = monitorForElements({
|
|
230
|
+
onDrop: ({ source, location }) => {
|
|
231
|
+
if (source.data.dragType !== "icon-list-row") return;
|
|
232
|
+
// Every list clears its own indicator on any drop — this is what stops
|
|
233
|
+
// the destination's drop line sticking after a cross-list transfer.
|
|
234
|
+
setDragState({ sourceId: null, targetId: null });
|
|
235
|
+
|
|
236
|
+
const { listId: my, onReorder, onRemove, onInsertItem } = cfgRef.current;
|
|
237
|
+
const target = location.current.dropTargets[0];
|
|
238
|
+
if (!target) return;
|
|
239
|
+
const srcListId = source.data.listId as string;
|
|
240
|
+
const dstListId = target.data.listId as string;
|
|
241
|
+
const fromIndex = source.data.index as number;
|
|
242
|
+
const targetIndex = target.data.index as number;
|
|
243
|
+
|
|
244
|
+
if (srcListId === my && dstListId === my) {
|
|
245
|
+
// The trailing zone reports the post-list index; splicing the row out
|
|
246
|
+
// first shifts everything after it down one, hence the -1.
|
|
247
|
+
const toIndex = target.data.trailing ? targetIndex - 1 : targetIndex;
|
|
248
|
+
if (fromIndex !== toIndex) onReorder(fromIndex, toIndex);
|
|
249
|
+
} else if (srcListId === my) {
|
|
250
|
+
onRemove(source.data.id as string);
|
|
251
|
+
} else if (dstListId === my) {
|
|
252
|
+
// Nothing is spliced out of this list, so the target index is the
|
|
253
|
+
// insert position as-is (the trailing zone's index appends).
|
|
254
|
+
onInsertItem(targetIndex, source.data.item as IconListItem);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return () => {
|
|
261
|
+
cancelled = true;
|
|
262
|
+
cleanup?.();
|
|
263
|
+
};
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
214
266
|
const updateItem = useCallback(
|
|
215
267
|
(index: number, patch: Partial<IconListItem>) => {
|
|
216
268
|
const updated = wrappedItems.map((w, i) =>
|
|
@@ -244,6 +296,7 @@ function EditIconList({
|
|
|
244
296
|
<EditableRow
|
|
245
297
|
key={wrapped.id}
|
|
246
298
|
id={wrapped.id}
|
|
299
|
+
listId={listId}
|
|
247
300
|
index={index}
|
|
248
301
|
item={wrapped.data}
|
|
249
302
|
defaultIcon={defaultIcon}
|
|
@@ -256,7 +309,6 @@ function EditIconList({
|
|
|
256
309
|
iconClassName={iconClassName}
|
|
257
310
|
dragState={dragState}
|
|
258
311
|
setDragState={setDragState}
|
|
259
|
-
onReorder={onReorder}
|
|
260
312
|
onRemove={onRemove}
|
|
261
313
|
onUpdateItem={(patch) => updateItem(index, patch)}
|
|
262
314
|
onDuplicate={() => duplicateItem(index)}
|
|
@@ -264,10 +316,10 @@ function EditIconList({
|
|
|
264
316
|
))}
|
|
265
317
|
|
|
266
318
|
<TrailingDropZone
|
|
319
|
+
listId={listId}
|
|
267
320
|
index={wrappedItems.length}
|
|
268
321
|
dragState={dragState}
|
|
269
322
|
setDragState={setDragState}
|
|
270
|
-
onReorder={onReorder}
|
|
271
323
|
/>
|
|
272
324
|
|
|
273
325
|
{/* Add button — centered below list, visible on list hover */}
|
|
@@ -285,6 +337,7 @@ function EditIconList({
|
|
|
285
337
|
|
|
286
338
|
function EditableRow({
|
|
287
339
|
id,
|
|
340
|
+
listId,
|
|
288
341
|
index,
|
|
289
342
|
item,
|
|
290
343
|
defaultIcon,
|
|
@@ -297,12 +350,12 @@ function EditableRow({
|
|
|
297
350
|
iconClassName,
|
|
298
351
|
dragState,
|
|
299
352
|
setDragState,
|
|
300
|
-
onReorder,
|
|
301
353
|
onRemove,
|
|
302
354
|
onUpdateItem,
|
|
303
355
|
onDuplicate,
|
|
304
356
|
}: {
|
|
305
357
|
id: string;
|
|
358
|
+
listId: string;
|
|
306
359
|
index: number;
|
|
307
360
|
item: IconListItem;
|
|
308
361
|
defaultIcon: string | null | undefined;
|
|
@@ -315,13 +368,14 @@ function EditableRow({
|
|
|
315
368
|
iconClassName: string;
|
|
316
369
|
dragState: { sourceId: string | null; targetId: string | null };
|
|
317
370
|
setDragState: React.Dispatch<React.SetStateAction<{ sourceId: string | null; targetId: string | null }>>;
|
|
318
|
-
onReorder: (from: number, to: number) => void;
|
|
319
371
|
onRemove: (id: string) => void;
|
|
320
372
|
onUpdateItem: (patch: Partial<IconListItem>) => void;
|
|
321
373
|
onDuplicate: () => void;
|
|
322
374
|
}) {
|
|
323
375
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
324
376
|
const handleRef = useRef<HTMLButtonElement>(null);
|
|
377
|
+
const itemRef = useRef(item);
|
|
378
|
+
itemRef.current = item;
|
|
325
379
|
const [iconHover, setIconHover] = useState(false);
|
|
326
380
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
327
381
|
const [dodontOpen, setDodontOpen] = useState(false);
|
|
@@ -344,24 +398,25 @@ function EditableRow({
|
|
|
344
398
|
|
|
345
399
|
const cleanupDraggable = draggable({
|
|
346
400
|
element: handle,
|
|
347
|
-
|
|
401
|
+
// `item` rides along so a destination list can insert it verbatim —
|
|
402
|
+
// persisted icon-list items have no ids, only positions. Read from a ref
|
|
403
|
+
// so typing in the row doesn't re-register the drag mid-gesture.
|
|
404
|
+
getInitialData: () => ({ dragType: "icon-list-row", listId, id, index, item: itemRef.current }),
|
|
348
405
|
onDragStart: () => setDragState({ sourceId: id, targetId: null }),
|
|
349
406
|
onDrop: () => setDragState({ sourceId: null, targetId: null }),
|
|
350
407
|
});
|
|
351
408
|
|
|
409
|
+
// Advertise only — EditIconList's monitor performs the move, so that one
|
|
410
|
+
// handler can see both sides of a cross-list transfer.
|
|
352
411
|
const cleanupDropTarget = dropTargetForElements({
|
|
353
412
|
element: row,
|
|
354
413
|
canDrop: ({ source }) => source.data.dragType === "icon-list-row",
|
|
355
|
-
getData: () => ({ id, index }),
|
|
414
|
+
getData: () => ({ listId, id, index }),
|
|
356
415
|
onDragEnter: () => setDragState((prev) => ({ ...prev, targetId: id })),
|
|
357
416
|
onDragLeave: () =>
|
|
358
417
|
setDragState((prev) =>
|
|
359
418
|
prev.targetId === id ? { ...prev, targetId: null } : prev,
|
|
360
419
|
),
|
|
361
|
-
onDrop: ({ source }) => {
|
|
362
|
-
const fromIndex = source.data.index as number;
|
|
363
|
-
if (fromIndex !== index) onReorder(fromIndex, index);
|
|
364
|
-
},
|
|
365
420
|
});
|
|
366
421
|
|
|
367
422
|
cleanup = () => {
|
|
@@ -374,7 +429,7 @@ function EditableRow({
|
|
|
374
429
|
cancelled = true;
|
|
375
430
|
cleanup?.();
|
|
376
431
|
};
|
|
377
|
-
}, [id, index,
|
|
432
|
+
}, [id, index, listId, setDragState]);
|
|
378
433
|
|
|
379
434
|
const isDropTarget = dragState.targetId === id && dragState.sourceId !== id;
|
|
380
435
|
|
|
@@ -525,15 +580,15 @@ function EditableRow({
|
|
|
525
580
|
}
|
|
526
581
|
|
|
527
582
|
function TrailingDropZone({
|
|
583
|
+
listId,
|
|
528
584
|
index,
|
|
529
585
|
dragState,
|
|
530
586
|
setDragState,
|
|
531
|
-
onReorder,
|
|
532
587
|
}: {
|
|
588
|
+
listId: string;
|
|
533
589
|
index: number;
|
|
534
590
|
dragState: { sourceId: string | null; targetId: string | null };
|
|
535
591
|
setDragState: React.Dispatch<React.SetStateAction<{ sourceId: string | null; targetId: string | null }>>;
|
|
536
|
-
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
537
592
|
}) {
|
|
538
593
|
const ref = useRef<HTMLDivElement>(null);
|
|
539
594
|
const trailingId = "__trailing__";
|
|
@@ -551,16 +606,12 @@ function TrailingDropZone({
|
|
|
551
606
|
cleanup = dropTargetForElements({
|
|
552
607
|
element: el,
|
|
553
608
|
canDrop: ({ source }) => source.data.dragType === "icon-list-row",
|
|
554
|
-
getData: () => ({ id: trailingId, index }),
|
|
609
|
+
getData: () => ({ listId, id: trailingId, index, trailing: true }),
|
|
555
610
|
onDragEnter: () => setDragState((prev) => ({ ...prev, targetId: trailingId })),
|
|
556
611
|
onDragLeave: () =>
|
|
557
612
|
setDragState((prev) =>
|
|
558
613
|
prev.targetId === trailingId ? { ...prev, targetId: null } : prev,
|
|
559
614
|
),
|
|
560
|
-
onDrop: ({ source }) => {
|
|
561
|
-
const fromIndex = source.data.index as number;
|
|
562
|
-
if (fromIndex !== index - 1) onReorder(fromIndex, index - 1);
|
|
563
|
-
},
|
|
564
615
|
});
|
|
565
616
|
});
|
|
566
617
|
|
|
@@ -568,9 +619,11 @@ function TrailingDropZone({
|
|
|
568
619
|
cancelled = true;
|
|
569
620
|
cleanup?.();
|
|
570
621
|
};
|
|
571
|
-
}, [index,
|
|
622
|
+
}, [index, listId, setDragState]);
|
|
572
623
|
|
|
573
|
-
|
|
624
|
+
// targetId is only ever set by an icon-list drag entering this zone, so its
|
|
625
|
+
// presence is the whole condition — a cross-list drag leaves sourceId null here.
|
|
626
|
+
const isDropTarget = dragState.targetId === trailingId;
|
|
574
627
|
|
|
575
628
|
return (
|
|
576
629
|
<div
|
|
@@ -67,8 +67,6 @@ import {
|
|
|
67
67
|
discardLocalChanges,
|
|
68
68
|
getCachedContent,
|
|
69
69
|
cacheContent,
|
|
70
|
-
persistMediaManifest,
|
|
71
|
-
getMediaManifest,
|
|
72
70
|
getPendingMediaItems,
|
|
73
71
|
getPendingMediaBlobs,
|
|
74
72
|
getPendingMediaDeletions,
|
|
@@ -311,6 +309,13 @@ export default function EditorShell({
|
|
|
311
309
|
// immediate 409 retry assembles the saved manifest against the rebased copy.
|
|
312
310
|
const mediaManifestRef = useRef<MediaManifest>(mediaManifest);
|
|
313
311
|
useEffect(() => { mediaManifestRef.current = mediaManifest; }, [mediaManifest]);
|
|
312
|
+
// Same reason, for the document sidecar: the collab handlers cache it against a
|
|
313
|
+
// remote sha, and reading the state directly there would either capture a stale
|
|
314
|
+
// closure copy (writing an outdated sidecar for that sha — "File missing" on the
|
|
315
|
+
// next cache hit) or force documentManifest into their dep arrays, churning the
|
|
316
|
+
// callback identity the collab wiring subscribes with.
|
|
317
|
+
const documentManifestRef = useRef<DocumentManifest | null>(documentManifest);
|
|
318
|
+
useEffect(() => { documentManifestRef.current = documentManifest; }, [documentManifest]);
|
|
314
319
|
// Guards the debounced preview-image composition effect (below): skips
|
|
315
320
|
// recomposing on the very first run when a preview already exists, and
|
|
316
321
|
// dedupes concurrent runs against the same composition signature.
|
|
@@ -603,13 +608,22 @@ export default function EditorShell({
|
|
|
603
608
|
// snapshot, never the rebased result. Caching the rebase (local dirty
|
|
604
609
|
// overlays folded in) would poison the next recovery's diff: dirty
|
|
605
610
|
// sections would read as remote-unchanged (silent local-wins) and a
|
|
606
|
-
// locally-added section would read as "deleted remotely".
|
|
611
|
+
// locally-added section would read as "deleted remotely". The manifest
|
|
612
|
+
// follows the same rule: the remote one, not the rebased view.
|
|
607
613
|
await cacheContent(
|
|
608
614
|
remote.sha ?? remote.savedBranchSha ?? headSha,
|
|
609
615
|
remote.sections,
|
|
610
616
|
remote.index,
|
|
611
617
|
remote.siteConfig,
|
|
612
|
-
{
|
|
618
|
+
{
|
|
619
|
+
media: remote.mediaManifest ?? mediaManifestRef.current,
|
|
620
|
+
documents: remote.documentManifest ?? documentManifestRef.current ?? {},
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
savedBranchSha: remote.savedBranchSha,
|
|
624
|
+
changedSectionIds: remote.changedSectionIds,
|
|
625
|
+
mainIndex: remote.mainIndex,
|
|
626
|
+
},
|
|
613
627
|
);
|
|
614
628
|
return true;
|
|
615
629
|
} catch (err) {
|
|
@@ -698,13 +712,21 @@ export default function EditorShell({
|
|
|
698
712
|
// ...but the CLEAN fetched remote snapshot is what gets cached as the
|
|
699
713
|
// baseline (amendment 1: caching the rebased result would poison the next
|
|
700
714
|
// 409 recovery — dirty sections would read as remote-unchanged and a ghost
|
|
701
|
-
// race would never surface).
|
|
715
|
+
// race would never surface). Same for the manifest: remote, not rebased.
|
|
702
716
|
await cacheContent(
|
|
703
717
|
remote.sha ?? remote.savedBranchSha ?? headSha,
|
|
704
718
|
remote.sections,
|
|
705
719
|
remote.index,
|
|
706
720
|
remote.siteConfig,
|
|
707
|
-
{
|
|
721
|
+
{
|
|
722
|
+
media: remote.mediaManifest ?? mediaManifestRef.current,
|
|
723
|
+
documents: remote.documentManifest ?? documentManifestRef.current ?? {},
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
savedBranchSha: remote.savedBranchSha,
|
|
727
|
+
changedSectionIds: remote.changedSectionIds,
|
|
728
|
+
mainIndex: remote.mainIndex,
|
|
729
|
+
},
|
|
708
730
|
);
|
|
709
731
|
} catch (err) {
|
|
710
732
|
console.error("Remote saved-event sync failed:", err);
|
|
@@ -947,17 +969,24 @@ export default function EditorShell({
|
|
|
947
969
|
// just-saved sections now differ from main, so fold them into
|
|
948
970
|
// changedSectionIds (state + ref + cache stay consistent, and a reload that
|
|
949
971
|
// hits this cache shows the same badges the server would report).
|
|
950
|
-
onBaselineCommitted: async ({ sha, sections: baselineSections, index, savedIds }) => {
|
|
972
|
+
onBaselineCommitted: async ({ sha, sections: baselineSections, index, savedIds, mediaManifest: committedManifest, documentManifest: committedDocumentManifest }) => {
|
|
951
973
|
const changed = new Set(
|
|
952
974
|
[...changedSectionIdsRef.current, ...savedIds].filter((id) => id in index.sections),
|
|
953
975
|
);
|
|
954
976
|
changedSectionIdsRef.current = changed;
|
|
955
977
|
setChangedSectionIds(changed);
|
|
956
|
-
await cacheContent(
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
978
|
+
await cacheContent(
|
|
979
|
+
sha, baselineSections, index, (siteConfig ?? {}) as Record<string, unknown>,
|
|
980
|
+
// What the save actually committed — NOT the refs/state, which the
|
|
981
|
+
// onMediaPublished / onDocumentsPublished callbacks only refresh through a
|
|
982
|
+
// state effect and can still lag by a render at this point.
|
|
983
|
+
{ media: committedManifest, documents: committedDocumentManifest },
|
|
984
|
+
{
|
|
985
|
+
savedBranchSha: sha,
|
|
986
|
+
changedSectionIds: [...changed],
|
|
987
|
+
mainIndex: mainIndexRef.current,
|
|
988
|
+
},
|
|
989
|
+
);
|
|
961
990
|
},
|
|
962
991
|
savingRef: inFlightSaveRef,
|
|
963
992
|
getEditGeneration: persistence.currentGeneration,
|
|
@@ -1148,36 +1177,40 @@ export default function EditorShell({
|
|
|
1148
1177
|
// deliberately don't block every load on a freshness fetch to validate this.
|
|
1149
1178
|
const cacheKeySha = draftHeadSha ?? headSha;
|
|
1150
1179
|
|
|
1180
|
+
// A row missing either sidecar predates them moving onto it (v11); it is
|
|
1181
|
+
// not a usable snapshot — serving it would render every image as "No Image"
|
|
1182
|
+
// and every committed document as "File missing" — so treat it as a miss
|
|
1183
|
+
// and re-fetch. An EMPTY manifest is a legitimate value, not an absence.
|
|
1151
1184
|
const cached = await getCachedContent();
|
|
1152
|
-
if (cached && cached.sha === cacheKeySha) {
|
|
1185
|
+
if (cached && cached.sha === cacheKeySha && cached.mediaManifest && cached.documentManifest) {
|
|
1153
1186
|
loadedSections = cached.sections;
|
|
1154
1187
|
loadedIndex = cached.index;
|
|
1155
1188
|
loadedConfig = SiteConfigSchema.parse(cached.siteConfig);
|
|
1156
|
-
|
|
1157
|
-
if (savedManifest) loadedManifest = savedManifest;
|
|
1189
|
+
loadedManifest = cached.mediaManifest;
|
|
1158
1190
|
setSavedSha(cached.savedBranchSha ?? null);
|
|
1159
1191
|
setMainSha(headSha);
|
|
1160
1192
|
setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
|
|
1161
1193
|
setMainIndex(cached.mainIndex ?? loadedIndex);
|
|
1162
1194
|
// Without this, a cache-hit load left documentManifest null forever —
|
|
1163
1195
|
// the siteDocuments memo's null-guard then never lifted and every
|
|
1164
|
-
// document row (committed or staged) read "File missing".
|
|
1165
|
-
|
|
1196
|
+
// document row (committed or staged) read "File missing". The row's copy
|
|
1197
|
+
// is authoritative: the hit gate above rejected a row without one.
|
|
1198
|
+
setDocumentManifest(cached.documentManifest);
|
|
1166
1199
|
} else {
|
|
1167
1200
|
const data = await fetchSavedContent();
|
|
1168
1201
|
loadedSections = data.sections;
|
|
1169
1202
|
loadedIndex = data.index;
|
|
1170
1203
|
loadedConfig = SiteConfigSchema.parse(data.siteConfig);
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1204
|
+
if (data.mediaManifest) loadedManifest = data.mediaManifest;
|
|
1205
|
+
await cacheContent(
|
|
1206
|
+
data.sha, data.sections, data.index, data.siteConfig,
|
|
1207
|
+
{ media: loadedManifest, documents: data.documentManifest ?? {} },
|
|
1208
|
+
{
|
|
1209
|
+
savedBranchSha: data.savedBranchSha,
|
|
1210
|
+
changedSectionIds: data.changedSectionIds,
|
|
1211
|
+
mainIndex: data.mainIndex,
|
|
1212
|
+
},
|
|
1213
|
+
)
|
|
1181
1214
|
setDocumentManifest(data.documentManifest ?? {});
|
|
1182
1215
|
setSavedSha(data.savedBranchSha);
|
|
1183
1216
|
setMainSha(headSha);
|
|
@@ -1343,9 +1376,11 @@ export default function EditorShell({
|
|
|
1343
1376
|
setSiteConfig(parsed.data);
|
|
1344
1377
|
applySiteConfigPreview(parsed.data);
|
|
1345
1378
|
}
|
|
1379
|
+
// Discard restores the cached snapshot, so the sidecars must come from that
|
|
1380
|
+
// same row — anything else could disagree with the sections we just set.
|
|
1381
|
+
if (cached.mediaManifest) setMediaManifest(cached.mediaManifest);
|
|
1382
|
+
if (cached.documentManifest) setDocumentManifest(cached.documentManifest);
|
|
1346
1383
|
}
|
|
1347
|
-
const savedManifest = await getMediaManifest();
|
|
1348
|
-
setMediaManifest(savedManifest ?? { images: {}, folders: {} });
|
|
1349
1384
|
persistence.clearConfigDirty();
|
|
1350
1385
|
// Discard wiped the Dexie siteIndex row — no unsaved structure remains.
|
|
1351
1386
|
// Tokenless: discard is unconditional, unlike the save-success clear.
|
|
@@ -2451,7 +2486,10 @@ function EditorContent({
|
|
|
2451
2486
|
}, [onBlockMove]);
|
|
2452
2487
|
|
|
2453
2488
|
return (
|
|
2454
|
-
|
|
2489
|
+
// `.section-wrapper:first-child` zeroes the 2.25rem that every other section
|
|
2490
|
+
// reserves above itself for its hover chrome, so the first section's controls
|
|
2491
|
+
// would sit under the fixed toolbar. Re-reserve it on the list instead.
|
|
2492
|
+
<div className="pt-9">
|
|
2455
2493
|
{displaySections.map(({ section, meta }, index) => {
|
|
2456
2494
|
const definition = getSection(section.type);
|
|
2457
2495
|
if (!definition) {
|
|
@@ -94,7 +94,7 @@ function DocumentTile({ doc, manage, isSelected, refCount, showFolderChip, onCli
|
|
|
94
94
|
isSelected: boolean;
|
|
95
95
|
refCount: number;
|
|
96
96
|
showFolderChip: boolean;
|
|
97
|
-
onClick: () => void;
|
|
97
|
+
onClick: (e: React.MouseEvent) => void;
|
|
98
98
|
}) {
|
|
99
99
|
return (
|
|
100
100
|
<button
|
|
@@ -262,7 +262,7 @@ function UploadZone({ onUpload, onFolderUpload }: {
|
|
|
262
262
|
|
|
263
263
|
function MediaCard({ item, manage, isSelected, dragIds, onClick, children }: {
|
|
264
264
|
item: MediaItem; manage: boolean; isSelected: boolean;
|
|
265
|
-
dragIds: string[]; onClick: () => void; children: React.ReactNode;
|
|
265
|
+
dragIds: string[]; onClick: (e: React.MouseEvent) => void; children: React.ReactNode;
|
|
266
266
|
}) {
|
|
267
267
|
const drag = useMediaDraggable<HTMLDivElement>({
|
|
268
268
|
enabled: manage,
|
|
@@ -446,7 +446,18 @@ export function MediaLibraryModal({
|
|
|
446
446
|
})
|
|
447
447
|
: [];
|
|
448
448
|
|
|
449
|
+
// Visual order of the item grid: documents render before images, and a CSS
|
|
450
|
+
// grid lays both out in source order, so an index range here is exactly the
|
|
451
|
+
// range a shift-click spans on screen — rows included.
|
|
452
|
+
const orderedIds = [...filteredDocuments.map((doc) => doc.id), ...filtered.map((item) => item.id)];
|
|
453
|
+
|
|
454
|
+
// Anchor for shift-click range selection. Stored as an id, not an index:
|
|
455
|
+
// `filtered`/`filteredDocuments` are recomputed every render, so an index
|
|
456
|
+
// would go stale on search, filter, or folder navigation.
|
|
457
|
+
const rangeAnchorId = useRef<string | null>(null);
|
|
458
|
+
|
|
449
459
|
function toggleSelect(id: string) {
|
|
460
|
+
rangeAnchorId.current = id;
|
|
450
461
|
setSelected((prev) => {
|
|
451
462
|
const next = new Set(prev);
|
|
452
463
|
if (next.has(id)) {
|
|
@@ -458,6 +469,32 @@ export function MediaLibraryModal({
|
|
|
458
469
|
});
|
|
459
470
|
}
|
|
460
471
|
|
|
472
|
+
function selectRangeTo(id: string) {
|
|
473
|
+
const anchor = rangeAnchorId.current;
|
|
474
|
+
const from = anchor === null ? -1 : orderedIds.indexOf(anchor);
|
|
475
|
+
const to = orderedIds.indexOf(id);
|
|
476
|
+
// No usable anchor (never clicked, or it was filtered/deleted away) — fall
|
|
477
|
+
// back to a plain toggle so the click is never a no-op.
|
|
478
|
+
if (from === -1 || to === -1) {
|
|
479
|
+
toggleSelect(id);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const range = orderedIds.slice(Math.min(from, to), Math.max(from, to) + 1);
|
|
483
|
+
// The anchor stays put so the range can be re-extended by shift-clicking
|
|
484
|
+
// again, and the range adds rather than toggles (matching file explorers).
|
|
485
|
+
setSelected((prev) => new Set([...prev, ...range]));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function handleItemClick(id: string, e: React.MouseEvent) {
|
|
489
|
+
if (e.shiftKey) {
|
|
490
|
+
// Shift-click otherwise drags a native text selection across the grid.
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
selectRangeTo(id);
|
|
493
|
+
} else {
|
|
494
|
+
toggleSelect(id);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
461
498
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
462
499
|
|
|
463
500
|
// One selection, two kinds. Everything downstream — delete dispatch, usage
|
|
@@ -837,7 +874,7 @@ export function MediaLibraryModal({
|
|
|
837
874
|
isSelected={selected.has(doc.id)}
|
|
838
875
|
refCount={documentReferenceCountMap[doc.id] ?? 0}
|
|
839
876
|
showFolderChip={filtering && documentsFolderEnabled}
|
|
840
|
-
onClick={() => (manage ?
|
|
877
|
+
onClick={(e) => (manage ? handleItemClick(doc.id, e) : onSelect(doc.id))}
|
|
841
878
|
/>
|
|
842
879
|
))}
|
|
843
880
|
{filtered.map((item) => {
|
|
@@ -853,11 +890,11 @@ export function MediaLibraryModal({
|
|
|
853
890
|
manage={manage}
|
|
854
891
|
isSelected={isSelected}
|
|
855
892
|
dragIds={dragIds}
|
|
856
|
-
onClick={() => {
|
|
893
|
+
onClick={(e) => {
|
|
857
894
|
if (mode === "select") {
|
|
858
895
|
onSelect(item.id);
|
|
859
896
|
} else {
|
|
860
|
-
|
|
897
|
+
handleItemClick(item.id, e);
|
|
861
898
|
}
|
|
862
899
|
}}
|
|
863
900
|
>
|
|
@@ -13,10 +13,19 @@ interface SectionTypePickerProps {
|
|
|
13
13
|
containerRef?: RefObject<HTMLElement | null>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const BUTTON_CLASS =
|
|
17
|
+
"cursor-pointer flex items-center gap-3 rounded-lg border border-base-200 bg-base px-4 py-3 text-left text-sm text-base-contrast-light transition-colors hover:border-base-300 hover:bg-base-accent hover:text-base-contrast";
|
|
18
|
+
|
|
16
19
|
export function SectionTypePicker({ types, onSelect, onClose, containerRef }: SectionTypePickerProps) {
|
|
17
20
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
18
21
|
const dismissRef = containerRef ?? panelRef;
|
|
19
22
|
|
|
23
|
+
// Container is a layout choice rather than a content block, so it gets its own
|
|
24
|
+
// full-width row above a divider. It's absent from the container child picker
|
|
25
|
+
// (one level of nesting), in which case there's no lead row and no divider.
|
|
26
|
+
const lead = types.find((t) => t.type === "container");
|
|
27
|
+
const rest = types.filter((t) => t !== lead);
|
|
28
|
+
|
|
20
29
|
useEffect(() => {
|
|
21
30
|
function handleMouseDown(e: MouseEvent) {
|
|
22
31
|
if (dismissRef.current && !dismissRef.current.contains(e.target as Node)) {
|
|
@@ -32,10 +41,27 @@ export function SectionTypePicker({ types, onSelect, onClose, containerRef }: Se
|
|
|
32
41
|
ref={panelRef}
|
|
33
42
|
className="z-50 mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"
|
|
34
43
|
>
|
|
35
|
-
{
|
|
44
|
+
{lead && (
|
|
45
|
+
<>
|
|
46
|
+
<button
|
|
47
|
+
key={lead.type}
|
|
48
|
+
className={`${BUTTON_CLASS} col-span-full`}
|
|
49
|
+
onClick={() => onSelect(lead.type)}
|
|
50
|
+
>
|
|
51
|
+
{lead.icon && (
|
|
52
|
+
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-base-contrast-light/60">
|
|
53
|
+
{lead.icon}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
<span>{lead.label}</span>
|
|
57
|
+
</button>
|
|
58
|
+
<hr className="col-span-full border-base-200" />
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
{rest.map(({ type, label, icon }) => (
|
|
36
62
|
<button
|
|
37
63
|
key={type}
|
|
38
|
-
className=
|
|
64
|
+
className={BUTTON_CLASS}
|
|
39
65
|
onClick={() => onSelect(type)}
|
|
40
66
|
>
|
|
41
67
|
{icon && (
|
|
@@ -98,7 +98,7 @@ interface PublishDeps {
|
|
|
98
98
|
* baseline that recoverConflict diffs against. Without this, the baseline
|
|
99
99
|
* dies at the first save (discardSavedChanges clears it) and later 409
|
|
100
100
|
* recoveries would silently merge local-wins. */
|
|
101
|
-
onBaselineCommitted?: (args: { sha: string; sections: LoadedSection[]; index: SiteIndex; savedIds: string[] }) => Promise<void>;
|
|
101
|
+
onBaselineCommitted?: (args: { sha: string; sections: LoadedSection[]; index: SiteIndex; savedIds: string[]; mediaManifest: MediaManifest; documentManifest: DocumentManifest }) => Promise<void>;
|
|
102
102
|
/** Injected by EditorShell. Receives non-fatal server warnings from a
|
|
103
103
|
* successful save (e.g. the nav.json replacement was declined by the
|
|
104
104
|
* degraded-nav guard) so the shell can surface them instead of the save
|
|
@@ -291,6 +291,15 @@ export function useEditorPublish({
|
|
|
291
291
|
uploadedItems: MediaItem[];
|
|
292
292
|
// The index actually sent (deletions applied) — the committed index state.
|
|
293
293
|
sentIndex: SiteIndex;
|
|
294
|
+
// The media manifest as committed by this save: the assembled manifest when
|
|
295
|
+
// media changed, otherwise the (unchanged) manifest we saved against. The
|
|
296
|
+
// content-cache re-prime writes it alongside the new sha, so the two can
|
|
297
|
+
// never drift.
|
|
298
|
+
committedManifest: MediaManifest;
|
|
299
|
+
// The document sidecar as committed by this save — the view we saved against,
|
|
300
|
+
// plus the entries this save committed, minus the ids it deleted. Same
|
|
301
|
+
// contract as committedManifest: it rides the cache write for this sha.
|
|
302
|
+
committedDocumentManifest: DocumentManifest;
|
|
294
303
|
committedDocumentIds: string[];
|
|
295
304
|
// The deleted ids the final request actually carried — empty when nothing
|
|
296
305
|
// was staged, and never populated on a failure (see the catch below).
|
|
@@ -499,6 +508,10 @@ export function useEditorPublish({
|
|
|
499
508
|
savedSections: dirty.map(({ sectionId, updatedAt, content }) => ({ sectionId, updatedAt, content })),
|
|
500
509
|
uploadedItems: [...uploadedItems, ...finalUploads.map((u) => u.item)],
|
|
501
510
|
sentIndex: siteIndex,
|
|
511
|
+
committedManifest: gathered.updatedManifest ?? args.mediaManifest,
|
|
512
|
+
// Read AFTER the final request: committedDocuments is complete and the
|
|
513
|
+
// deletions (which ride only that request) have landed.
|
|
514
|
+
committedDocumentManifest: documentManifestToSend(),
|
|
502
515
|
committedDocumentIds,
|
|
503
516
|
committedDocumentDeletions: args.pendingDocumentDeletions,
|
|
504
517
|
};
|
|
@@ -659,7 +672,7 @@ export function useEditorPublish({
|
|
|
659
672
|
return;
|
|
660
673
|
}
|
|
661
674
|
|
|
662
|
-
const { sha, savedSections, uploadedItems, sentIndex, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery();
|
|
675
|
+
const { sha, savedSections, uploadedItems, sentIndex, committedManifest, committedDocumentManifest, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery();
|
|
663
676
|
onShasUpdated(sha, null);
|
|
664
677
|
|
|
665
678
|
try {
|
|
@@ -681,6 +694,8 @@ export function useEditorPublish({
|
|
|
681
694
|
sections: baselineSections,
|
|
682
695
|
index: sentIndex,
|
|
683
696
|
savedIds: savedSections.map((s) => s.sectionId),
|
|
697
|
+
mediaManifest: committedManifest,
|
|
698
|
+
documentManifest: committedDocumentManifest,
|
|
684
699
|
});
|
|
685
700
|
}
|
|
686
701
|
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
@@ -750,10 +765,12 @@ export function useEditorPublish({
|
|
|
750
765
|
let savedSections: { sectionId: string; updatedAt: string; content: SectionContent }[] = [];
|
|
751
766
|
let uploadedItems: MediaItem[] = [];
|
|
752
767
|
let sentIndex: SiteIndex | null = null;
|
|
768
|
+
let committedManifest: MediaManifest | null = null;
|
|
769
|
+
let committedDocumentManifest: DocumentManifest | null = null;
|
|
753
770
|
let committedDocumentIds: string[] = [];
|
|
754
771
|
let committedDocumentDeletions: string[] = [];
|
|
755
772
|
if (hasLocalEdits) {
|
|
756
|
-
({ savedSections, uploadedItems, sentIndex, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery());
|
|
773
|
+
({ savedSections, uploadedItems, sentIndex, committedManifest, committedDocumentManifest, committedDocumentIds, committedDocumentDeletions } = await saveWithConflictRecovery());
|
|
757
774
|
}
|
|
758
775
|
|
|
759
776
|
setPublishAction("publishing");
|
|
@@ -789,7 +806,15 @@ export function useEditorPublish({
|
|
|
789
806
|
savedRows: savedSections,
|
|
790
807
|
sentIndex: sentIndex!,
|
|
791
808
|
});
|
|
792
|
-
|
|
809
|
+
// Both sidecars ride the same write as the sha they belong to: publish
|
|
810
|
+
// deleted the draft, so main's head IS this sha and the next load hits
|
|
811
|
+
// this row. Omitting them here is what blanked freshly uploaded images
|
|
812
|
+
// ("No Image", while the published site was correct) and freshly
|
|
813
|
+
// committed documents ("File missing").
|
|
814
|
+
await cacheContent(sha, baselineSections, sentIndex!, siteConfig!, {
|
|
815
|
+
media: committedManifest!,
|
|
816
|
+
documents: committedDocumentManifest!,
|
|
817
|
+
});
|
|
793
818
|
finishSave(uploadedItems, savedSections.map((s) => s.sectionId));
|
|
794
819
|
if (committedDocumentIds.length > 0) onDocumentsPublished?.(committedDocumentIds);
|
|
795
820
|
if (committedDocumentDeletions.length > 0) onDocumentsDeleted?.(committedDocumentDeletions);
|
package/src/lib/container-ops.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { Section } from "../schemas/sections";
|
|
3
3
|
import { getAllSections } from "./registry";
|
|
4
|
+
import { sortInsertMenuOptions } from "./insert-menu";
|
|
4
5
|
|
|
5
6
|
interface ContainerContent {
|
|
6
7
|
columns: number;
|
|
@@ -40,9 +41,11 @@ export function reorderChild(container: Section, from: number, to: number): Sect
|
|
|
40
41
|
* single, testable source of truth (the container toolbar's "+" picker uses it).
|
|
41
42
|
*/
|
|
42
43
|
export function childInsertableTypes(): { type: string; label: string; icon: ReactNode }[] {
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
return sortInsertMenuOptions(
|
|
45
|
+
getAllSections()
|
|
46
|
+
.filter((d) => d.type !== "container" && d.type !== "spacer")
|
|
47
|
+
.map((d) => ({ type: d.type, label: d.label, icon: d.icon })),
|
|
48
|
+
);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function insertChildAt(container: Section, child: Section, index: number): Section {
|