@pramen/cms-editor 0.0.27 → 0.0.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -184,8 +184,21 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
184
184
 
185
185
  const addBlock = async (region: string, slug: string) => {
186
186
  try {
187
- await api.call("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
188
- await reload();
187
+ // addBlock echoes the created block + placement, so append it locally instead of a
188
+ // full getContentType+getPage reload — one round trip instead of three.
189
+ const { block, placement } = await api.call<{
190
+ block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
191
+ placement: { id: string; isShared?: boolean | number };
192
+ }>("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
193
+ const rb: RenderedBlock = {
194
+ id: String(placement.id),
195
+ block_id: String(block.id),
196
+ block_type: slug,
197
+ title: block.title ?? null,
198
+ fields: block.fields ?? {},
199
+ is_shared: Boolean(placement.isShared),
200
+ };
201
+ setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: [...(prev.regions[region] ?? []), rb] } } : prev));
189
202
  flash("block added");
190
203
  } catch (e) {
191
204
  setErr(errMsg(e));
@@ -219,6 +232,22 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
219
232
  });
220
233
  }, []);
221
234
 
235
+ // Drag-to-reorder within a region. `from`/`over` are indices in the region's block list;
236
+ // hovering updates `over` live (for the drop-target highlight), and the drop commits the
237
+ // new order via reorderRegion. Dragging is confined to the region it started in.
238
+ const [drag, setDrag] = useState<{ region: string; from: number; over: number } | null>(null);
239
+ const beginDrag = (region: string, i: number) => setDrag({ region, from: i, over: i });
240
+ const hoverDrag = (region: string, i: number) => setDrag((d) => (d && d.region === region && d.over !== i ? { ...d, over: i } : d));
241
+ const cancelDrag = () => setDrag(null);
242
+ const dropDrag = (region: string) => {
243
+ setDrag(null);
244
+ if (!drag || drag.region !== region || drag.from === drag.over) return;
245
+ const ids = (assembled?.regions[region] ?? []).map((b) => b.id);
246
+ const [moved] = ids.splice(drag.from, 1);
247
+ ids.splice(drag.over, 0, moved);
248
+ reorder(region, ids);
249
+ };
250
+
222
251
  return (
223
252
  <div className="grid min-h-[calc(100vh-68px)] grid-cols-[260px_1fr_400px] gap-5 px-7 pb-7 pt-2 max-[820px]:grid-cols-1">
224
253
  <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
@@ -259,6 +288,12 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
259
288
  onRemove={() => removeBlock(b)}
260
289
  onPatch={patchBlockFields}
261
290
  onError={setErr}
291
+ dragging={drag?.region === r.name && drag.from === i}
292
+ isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
293
+ onDragStartBlock={() => beginDrag(r.name, i)}
294
+ onDragOverBlock={() => hoverDrag(r.name, i)}
295
+ onDropBlock={() => dropDrag(r.name)}
296
+ onDragEndBlock={cancelDrag}
262
297
  />
263
298
  ))}
264
299
  <AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
@@ -288,7 +323,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
288
323
  // as the WYSIWYG, media as a thumbnail picker), and edits autosave — debounced while typing,
289
324
  // flushed on blur and on unmount — so there's no separate "save block" step and no full page
290
325
  // reload that would drop the caret. Collapse folds it to a one-line plain-text preview.
291
- function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError }: {
326
+ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
292
327
  api: Api;
293
328
  block: RenderedBlock;
294
329
  blockType: BlockType | undefined;
@@ -298,12 +333,19 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
298
333
  onRemove: () => void;
299
334
  onPatch: (placementId: string, fields: Record<string, unknown>) => void;
300
335
  onError: (s: string) => void;
336
+ dragging: boolean;
337
+ isOver: boolean;
338
+ onDragStartBlock: () => void;
339
+ onDragOverBlock: () => void;
340
+ onDropBlock: () => void;
341
+ onDragEndBlock: () => void;
301
342
  }) {
302
343
  const [fields, setFields] = useState<Record<string, unknown> | null>(null);
303
344
  const [collapsed, setCollapsed] = useState(false);
304
345
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
305
346
  const pending = useRef<Record<string, unknown> | null>(null);
306
347
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
348
+ const cardRef = useRef<HTMLDivElement>(null);
307
349
  const blockId = block.block_id;
308
350
  const placementId = block.id;
309
351
 
@@ -345,8 +387,28 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
345
387
  const name = blockType?.name ?? block.block_type;
346
388
 
347
389
  return (
348
- <div className="mb-2.5 rounded-panel border border-border bg-surface-card">
390
+ // The card is the drop target; the ⠿ handle is the only draggable element, so
391
+ // dragging never fights the inline contentEditable text selection. The handle's drag
392
+ // image is set to the whole card so you drag a preview of the block, not just the grip.
393
+ <div
394
+ ref={cardRef}
395
+ className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""}`}
396
+ onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
397
+ onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
398
+ >
349
399
  <div className="flex items-center gap-2.5 px-3.5 py-2.5">
400
+ <span
401
+ draggable
402
+ onDragStart={(e) => {
403
+ if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
404
+ e.dataTransfer.effectAllowed = "move";
405
+ e.dataTransfer.setData("text/plain", "");
406
+ onDragStartBlock();
407
+ }}
408
+ onDragEnd={onDragEndBlock}
409
+ className="shrink-0 cursor-grab select-none px-0.5 text-fg-subtle hover:text-fg active:cursor-grabbing"
410
+ title="Drag to reorder"
411
+ >⠿</span>
350
412
  <button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
351
413
  <span className="font-medium text-fg">{name}</span>
352
414
  {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}