@pramen/cms-editor 0.0.27 → 0.0.28

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.28",
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": {
@@ -219,6 +219,22 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
219
219
  });
220
220
  }, []);
221
221
 
222
+ // Drag-to-reorder within a region. `from`/`over` are indices in the region's block list;
223
+ // hovering updates `over` live (for the drop-target highlight), and the drop commits the
224
+ // new order via reorderRegion. Dragging is confined to the region it started in.
225
+ const [drag, setDrag] = useState<{ region: string; from: number; over: number } | null>(null);
226
+ const beginDrag = (region: string, i: number) => setDrag({ region, from: i, over: i });
227
+ const hoverDrag = (region: string, i: number) => setDrag((d) => (d && d.region === region && d.over !== i ? { ...d, over: i } : d));
228
+ const cancelDrag = () => setDrag(null);
229
+ const dropDrag = (region: string) => {
230
+ setDrag(null);
231
+ if (!drag || drag.region !== region || drag.from === drag.over) return;
232
+ const ids = (assembled?.regions[region] ?? []).map((b) => b.id);
233
+ const [moved] = ids.splice(drag.from, 1);
234
+ ids.splice(drag.over, 0, moved);
235
+ reorder(region, ids);
236
+ };
237
+
222
238
  return (
223
239
  <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
240
  <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
@@ -259,6 +275,12 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
259
275
  onRemove={() => removeBlock(b)}
260
276
  onPatch={patchBlockFields}
261
277
  onError={setErr}
278
+ dragging={drag?.region === r.name && drag.from === i}
279
+ isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
280
+ onDragStartBlock={() => beginDrag(r.name, i)}
281
+ onDragOverBlock={() => hoverDrag(r.name, i)}
282
+ onDropBlock={() => dropDrag(r.name)}
283
+ onDragEndBlock={cancelDrag}
262
284
  />
263
285
  ))}
264
286
  <AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
@@ -288,7 +310,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
288
310
  // as the WYSIWYG, media as a thumbnail picker), and edits autosave — debounced while typing,
289
311
  // flushed on blur and on unmount — so there's no separate "save block" step and no full page
290
312
  // 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 }: {
313
+ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
292
314
  api: Api;
293
315
  block: RenderedBlock;
294
316
  blockType: BlockType | undefined;
@@ -298,12 +320,19 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
298
320
  onRemove: () => void;
299
321
  onPatch: (placementId: string, fields: Record<string, unknown>) => void;
300
322
  onError: (s: string) => void;
323
+ dragging: boolean;
324
+ isOver: boolean;
325
+ onDragStartBlock: () => void;
326
+ onDragOverBlock: () => void;
327
+ onDropBlock: () => void;
328
+ onDragEndBlock: () => void;
301
329
  }) {
302
330
  const [fields, setFields] = useState<Record<string, unknown> | null>(null);
303
331
  const [collapsed, setCollapsed] = useState(false);
304
332
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
305
333
  const pending = useRef<Record<string, unknown> | null>(null);
306
334
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
335
+ const cardRef = useRef<HTMLDivElement>(null);
307
336
  const blockId = block.block_id;
308
337
  const placementId = block.id;
309
338
 
@@ -345,8 +374,28 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
345
374
  const name = blockType?.name ?? block.block_type;
346
375
 
347
376
  return (
348
- <div className="mb-2.5 rounded-panel border border-border bg-surface-card">
377
+ // The card is the drop target; the ⠿ handle is the only draggable element, so
378
+ // dragging never fights the inline contentEditable text selection. The handle's drag
379
+ // image is set to the whole card so you drag a preview of the block, not just the grip.
380
+ <div
381
+ ref={cardRef}
382
+ className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""}`}
383
+ onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
384
+ onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
385
+ >
349
386
  <div className="flex items-center gap-2.5 px-3.5 py-2.5">
387
+ <span
388
+ draggable
389
+ onDragStart={(e) => {
390
+ if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
391
+ e.dataTransfer.effectAllowed = "move";
392
+ e.dataTransfer.setData("text/plain", "");
393
+ onDragStartBlock();
394
+ }}
395
+ onDragEnd={onDragEndBlock}
396
+ className="shrink-0 cursor-grab select-none px-0.5 text-fg-subtle hover:text-fg active:cursor-grabbing"
397
+ title="Drag to reorder"
398
+ >⠿</span>
350
399
  <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
400
  <span className="font-medium text-fg">{name}</span>
352
401
  {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}