@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.
@@ -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 { wrappedItems, onReorder, onAdd, onRemove } = useEditableCollection({
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
- getInitialData: () => ({ dragType: "icon-list-row", id, index }),
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, onReorder, setDragState]);
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, onReorder, setDragState]);
622
+ }, [index, listId, setDragState]);
572
623
 
573
- const isDropTarget = dragState.targetId === trailingId && dragState.sourceId !== null;
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
- { savedBranchSha: remote.savedBranchSha, changedSectionIds: remote.changedSectionIds, mainIndex: remote.mainIndex },
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
- { savedBranchSha: remote.savedBranchSha, changedSectionIds: remote.changedSectionIds, mainIndex: remote.mainIndex },
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(sha, baselineSections, index, (siteConfig ?? {}) as Record<string, unknown>, {
957
- savedBranchSha: sha,
958
- changedSectionIds: [...changed],
959
- mainIndex: mainIndexRef.current,
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
- const savedManifest = await getMediaManifest();
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
- setDocumentManifest(cached.documentManifest ?? {});
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
- await cacheContent(data.sha, data.sections, data.index, data.siteConfig, {
1172
- savedBranchSha: data.savedBranchSha,
1173
- changedSectionIds: data.changedSectionIds,
1174
- mainIndex: data.mainIndex,
1175
- documentManifest: data.documentManifest ?? {},
1176
- });
1177
- if (data.mediaManifest) {
1178
- loadedManifest = data.mediaManifest;
1179
- await persistMediaManifest(loadedManifest);
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
- <div>
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 ? toggleSelect(doc.id) : onSelect(doc.id))}
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
- toggleSelect(item.id);
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
- {types.map(({ type, label, icon }) => (
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="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"
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
- await cacheContent(sha, baselineSections, sentIndex!, siteConfig!);
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);
@@ -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 getAllSections()
44
- .filter((d) => d.type !== "container" && d.type !== "spacer")
45
- .map((d) => ({ type: d.type, label: d.label, icon: d.icon }));
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 {