mnfst 0.5.218 → 0.5.219

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.
@@ -47,7 +47,9 @@
47
47
  // those deltas neither travel to the source nor survive in the overlay. They stay
48
48
  // in the in-memory log, so undo still works for the session.
49
49
  const authoringRegion = (r) => { const a = r != null && areaByKey(r); return !!(a && a._edit.authoring); };
50
- const persistable = (d) => d.kind !== 'data-splice' && (d.region == null || authoringRegion(d.region));
50
+ const persistable = (d) => d.kind !== 'data-splice' && (d.kind === 'st-move'
51
+ ? authoringRegion(d.from) && authoringRegion(d.to)
52
+ : (d.region == null || authoringRegion(d.region)));
51
53
 
52
54
  const saveState = () => { if (log.length > HISTORY_CAP) { const n = log.length - HISTORY_CAP; log.splice(0, n); cursor = Math.max(0, cursor - n); } const keep = log.filter(persistable); localStorage.setItem(LS_KEY, JSON.stringify({ v: SCHEMA, log: keep, cursor: Math.min(cursor, keep.length) })); };
53
55
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -100,6 +102,10 @@
100
102
  }
101
103
  const areas = () => [...editEls].filter(el => { for (let n = el.parentElement; n; n = n.parentElement) if (n._edit) return false; return true; });
102
104
  const areaByKey = (k) => areas().find(a => a._edit.key === k);
105
+ const topArea = (a) => !!a && areas().includes(a); // only top-level regions can be move endpoints (nested ones can't persist)
106
+ // The top-level region an element belongs to NOW — resolved at commit time, not
107
+ // arm time, so a block moved to another region commits against its new home.
108
+ const regionOf = (el) => areas().find(a => a === el || a.contains(el));
103
109
  const key = (area) => area._edit.key;
104
110
  function editInfo(node) { for (let n = node; n; n = n.parentElement) if (n._edit) return n._edit; return null; }
105
111
  const capOf = (node, cap) => { const i = editInfo(node); return !!i && !i.lock && i.caps.has(cap); };
@@ -114,7 +120,11 @@
114
120
  }
115
121
  function dataSourceExpr(area) { const t = area.querySelector('template[x-for]'); const m = t && t.getAttribute('x-for').match(/\bin\s+(.+)$/); return m ? m[1].trim() : null; }
116
122
  const dataSourceName = (area) => { const e = dataSourceExpr(area); const m = e && e.match(/\$x\.(\w+)/); return m ? m[1] : (e || 'source'); };
117
- const sortableChildren = (c) => Array.from(c.children).filter(x => x.tagName !== 'TEMPLATE' && !x.hasAttribute('data-edit-handle'));
123
+ // Plugin-injected nodes (size handles, drag ghosts) are not authored children —
124
+ // keys and paths skip them on client AND server (serve.mjs childAt mirrors this).
125
+ const pluginNode = (x) => x.hasAttribute('data-edit-handle') || x.hasAttribute('data-edit-ghost');
126
+ const realChildren = (el) => Array.from(el.children).filter(x => !pluginNode(x));
127
+ const sortableChildren = (c) => realChildren(c).filter(x => x.tagName !== 'TEMPLATE');
118
128
  // Identity of a row in a data area. `data-key` is the explicit form; without it,
119
129
  // fall back to the x-for's own :key so a plain list needs no extra attribute.
120
130
  let _keyWarned = false;
@@ -243,18 +253,35 @@
243
253
  // instead of being left holding the edit. (applyThemeState works the same way.)
244
254
  function staticState() {
245
255
  const node = {}, order = {}, sigs = {}, base = {}, html = {};
246
- for (const d of log) {
256
+ // An applied move re-addresses every EARLIER delta it carried along (its remap:
257
+ // the moved keys plus any twins whose ordinal shifted), so ops and baselines
258
+ // land on the element's current region/key instead of its pre-move address.
259
+ const moves = [];
260
+ for (let i = 0; i < cursor; i++) if (log[i].kind === 'st-move' && log[i].remap) moves.push({ i, remap: log[i].remap });
261
+ const addr = (i, region, path) => {
262
+ for (const m of moves) {
263
+ if (m.i <= i) continue;
264
+ const hit = m.remap.find(r => r[0] === region && r[1] === path);
265
+ if (hit) { region = hit[2]; path = hit[3]; }
266
+ }
267
+ return [region, path];
268
+ };
269
+ for (let i = 0; i < log.length; i++) {
270
+ const d = log[i];
247
271
  if (d.kind === 'st-children' && d.html) Object.assign(html[d.region] = html[d.region] || {}, d.html);
272
+ if (d.kind === 'st-move' && d.html) { (html[d.from] = html[d.from] || {})[d.key] = d.html; (html[d.to] = html[d.to] || {})[d.toKey] = d.html; } // both sides can rebuild it
248
273
  if (d.kind !== 'st-node') continue;
249
- const bk = d.region + '|' + d.path;
274
+ const [region, path] = addr(i, d.region, d.path);
275
+ const bk = region + '|' + path;
250
276
  const b = base[bk] = base[bk] || {};
251
277
  if (!(d.prop in b)) b[d.prop] = d.before;
252
278
  if (d.sig) sigs[bk] = d.sig;
253
279
  }
254
280
  for (let i = 0; i < cursor; i++) {
255
281
  const d = log[i];
256
- if (d.kind === 'st-node') { (node[d.region] = node[d.region] || {}); (node[d.region][d.path] = node[d.region][d.path] || {})[d.prop] = d.value; }
282
+ if (d.kind === 'st-node') { const [region, path] = addr(i, d.region, d.path); (node[region] = node[region] || {}); (node[region][path] = node[region][path] || {})[d.prop] = d.value; }
257
283
  else if (d.kind === 'st-order' || d.kind === 'st-children') order[d.region] = d.order;
284
+ else if (d.kind === 'st-move') { order[d.from] = d.fromOrder; order[d.to] = d.toOrder; }
258
285
  }
259
286
  // The markup for anything added or removed comes from the WHOLE log, the same
260
287
  // way baselines do — undoing a delete has to be able to rebuild the element,
@@ -329,8 +356,10 @@
329
356
  kids.forEach((el, i) => { by[keys[i]] = el; });
330
357
  const markup = html[region] || {};
331
358
  const next = want.map(kk => by[kk] || materialize(markup[kk], kk)).filter(Boolean);
359
+ // Reinsert at the managed range — never scramble real children past plugin handles.
360
+ const anchor = kids.length ? kids[kids.length - 1].nextSibling : Array.from(area.children).find(pluginNode) || null;
332
361
  kids.forEach(el => { if (!next.includes(el)) el.remove(); });
333
- next.forEach(el => area.appendChild(el));
362
+ next.forEach(el => area.insertBefore(el, anchor));
334
363
  }
335
364
  });
336
365
  }
@@ -382,6 +411,62 @@
382
411
  if (eq(before, order)) return;
383
412
  log.splice(cursor); log.push({ kind: 'st-order', region, order, before }); cursor = log.length; lastOrder[region] = order; saveState(); refresh();
384
413
  }
414
+ // Positional re-key of a region: give every element the key a FRESH session would
415
+ // derive (markStatic document order, ordinals by position among same-base keys).
416
+ // That is what keeps client, server, and reload addressing the same elements after
417
+ // a move shifts membership. Returns the renames as [oldKey, newKey] pairs.
418
+ function rekeyRegion(area) {
419
+ const seen = Object.create(null), changes = [];
420
+ for (const el of [area, ...area.querySelectorAll('*')]) {
421
+ if (el.hasAttribute('data-edit-handle') || el.hasAttribute('data-edit-ghost')) continue;
422
+ if (el.parentElement && el.parentElement.closest('[data-text-edit]')) continue;
423
+ const cur = el.getAttribute('data-edit-key');
424
+ const base = cur ? cur.split('#')[0] : staticKey(el);
425
+ const n = seen[base] = (seen[base] || 0) + 1;
426
+ const k = n > 1 ? base + '#' + n : base;
427
+ if (cur !== k) { el.setAttribute('data-edit-key', k); if (cur) changes.push([cur, k]); }
428
+ }
429
+ return changes;
430
+ }
431
+ // Replay/undo/redo of a move's key renames. Resolve-then-set so swapped ordinals
432
+ // never collide mid-application; the moved element itself (region change) is
433
+ // handled by applyStaticState's materialization, not here.
434
+ function applyMoveKeys(d, invert) {
435
+ if (!d.remap) return;
436
+ const jobs = [];
437
+ for (const [r, o, r2, n] of d.remap) {
438
+ if (r !== r2) continue;
439
+ const a = areaByKey(r); if (!a) continue;
440
+ const findKey = invert ? n : o, setKey = invert ? o : n;
441
+ const el = [a, ...a.querySelectorAll('[data-edit-key]')].find(e => e.getAttribute('data-edit-key') === findKey);
442
+ if (el) jobs.push([el, setKey]);
443
+ }
444
+ jobs.forEach(([el, k]) => el.setAttribute('data-edit-key', k));
445
+ }
446
+ // Cross-region move (static → static): one delta carries both region orders, so a
447
+ // single undo puts the block back. The item must already sit in its new parent.
448
+ // Both regions are re-keyed positionally and every rename travels on the delta
449
+ // (remap), so earlier deltas on the moved block — and on ordinal-shifted twins —
450
+ // keep addressing the right elements.
451
+ function commitMove(fromArea, toArea, item) {
452
+ const from = key(fromArea), to = key(toArea), fromKey = item.getAttribute('data-edit-key') || staticKey(item);
453
+ const descOld = [...item.querySelectorAll('[data-edit-key]')].map(n => [n, n.getAttribute('data-edit-key')]);
454
+ item.removeAttribute('data-edit-key');
455
+ item.querySelectorAll('[data-edit-key]').forEach(n => n.removeAttribute('data-edit-key')); // arrival re-keys in the new region
456
+ const fromPairs = rekeyRegion(fromArea).map(([o, n]) => [from, o, from, n]);
457
+ const toPairs = rekeyRegion(toArea).map(([o, n]) => [to, o, to, n]);
458
+ const toKey = item.getAttribute('data-edit-key');
459
+ const remap = [[from, fromKey, to, toKey]];
460
+ descOld.forEach(([n, o]) => { const nk = n.getAttribute('data-edit-key'); if (nk) remap.push([from, o, to, nk]); });
461
+ remap.push(...fromPairs, ...toPairs);
462
+ const html = blockHTML(item);
463
+ log.splice(cursor);
464
+ log.push({ kind: 'st-move', from, to, key: fromKey, toKey, fromOrder: staticKeys(fromArea), toOrder: staticKeys(toArea), html, remap });
465
+ cursor = log.length;
466
+ lastOrder[from] = staticKeys(fromArea); lastOrder[to] = staticKeys(toArea);
467
+ markStatic(fromArea); armArea(toArea); // paths shift on both sides; the arrival needs baselines + affordances
468
+ saveState(); refresh();
469
+ }
385
470
 
386
471
  /* ---- Commit / undo / redo (data area snapshots) ---- */
387
472
  function commit(area) {
@@ -394,6 +479,7 @@
394
479
  const dispatchApply = (d) => { if (d.kind === 'cmp-main' || d.kind === 'cmp-inst') applyComponentState(); else if (d.kind === 'data-val') applyDataValues(); else if (d.kind === 'theme') applyThemeState(); else applyStaticState(); };
395
480
  async function undo() {
396
481
  if (cursor === 0) return; const d = log[--cursor];
482
+ if (d.kind === 'st-move') applyMoveKeys(d, true); // ordinal renames roll back before state re-applies
397
483
  if (d.kind === 'data-splice') applySplice(d, true);
398
484
  else if (d.kind === 'data') { await applySnap(areaByKey(d.region), d.kind, d.before); lastSnap[d.region] = d.before; }
399
485
  else dispatchApply(d);
@@ -401,6 +487,7 @@
401
487
  }
402
488
  async function redo() {
403
489
  if (cursor >= log.length) return; const d = log[cursor++];
490
+ if (d.kind === 'st-move') applyMoveKeys(d, false);
404
491
  if (d.kind === 'data-splice') applySplice(d, false);
405
492
  else if (d.kind === 'data') { await applySnap(areaByKey(d.region), d.kind, d.after); lastSnap[d.region] = d.after; }
406
493
  else dispatchApply(d);
@@ -444,7 +531,12 @@
444
531
  return el && el.parentElement === area ? el : null;
445
532
  }
446
533
 
447
- const blockArea = (el) => el && el.closest('[data-edit-area]');
534
+ // The region a block BELONGS TO — from the parent, so a block that is itself an
535
+ // x-edit region resolves to its outer region, never to itself.
536
+ const blockArea = (el) => el && el.parentElement && el.parentElement.closest('[data-edit-area]');
537
+ // A block that is itself an x-edit region owns its own delta addressing; moving
538
+ // it across regions is refused (reordering it within its parent still works).
539
+ const isRegionBlock = (el) => !!el && (!!el._edit || el.hasAttribute('data-edit-area'));
448
540
 
449
541
  // Markup without the plugin's own affordances, so a duplicate re-arms cleanly
450
542
  // rather than inheriting half-initialised state from its original.
@@ -488,6 +580,7 @@
488
580
  const block = el || target;
489
581
  if (op === 'paste') return !!clipboard && !!(block ? blockArea(block) : areas().find(isActive));
490
582
  if (!block || locked(block)) return false;
583
+ if (op === 'move' && isRegionBlock(block)) return false;
491
584
  const area = blockArea(block);
492
585
  if (!area || !isActive(area)) return false;
493
586
  if (classify(area) === 'component') return false; // instances are overridden, not restructured
@@ -496,7 +589,9 @@
496
589
 
497
590
  function copyBlock(el) {
498
591
  const block = el || target; if (!block) return false;
499
- clipboard = { html: blockHTML(block), source: key(blockArea(block)) };
592
+ const area = blockArea(block);
593
+ if (!area) return false; // a whole region is a container, not a copyable block
594
+ clipboard = { html: blockHTML(block), source: key(area) };
500
595
  bump();
501
596
  return true;
502
597
  }
@@ -542,6 +637,23 @@
542
637
 
543
638
  const cutBlock = (el) => copyBlock(el) && removeBlock(el);
544
639
 
640
+ // Programmatic cross-region move (what a pointer drag commits): insert the block
641
+ // into `dest` (area element or x-edit key) before `ref`, or at the end.
642
+ function moveBlock(el, dest, ref) {
643
+ const block = el ? blockOf(el) || el : target; if (!block || locked(block) || isRegionBlock(block)) return false;
644
+ const from = blockArea(block);
645
+ const to = typeof dest === 'string' ? areaByKey(dest) : dest;
646
+ if (!from || !to || to === from || !to._edit) return false;
647
+ if (!topArea(from) || !topArea(to)) return false; // a nested region can't persist a move — refuse, never drop it silently
648
+ if (!isActive(from) || !isActive(to) || locked(to)) return false;
649
+ if (classify(from) !== 'static' || classify(to) !== 'static' || !capOf(to, 'sort')) return false;
650
+ if (block === to || block.contains(to)) return false;
651
+ if (ref && ref.parentElement !== to) ref = null;
652
+ if (ref) to.insertBefore(block, ref); else to.appendChild(block);
653
+ commitMove(from, to, block);
654
+ return true;
655
+ }
656
+
545
657
  function pasteBlock(el) {
546
658
  if (!clipboard) return false;
547
659
  const block = el || target;
@@ -645,10 +757,13 @@
645
757
  }
646
758
  }
647
759
  function onPointerDown(e) {
648
- const item = this, area = item.closest('[data-edit-area]');
760
+ const item = this, area = item.parentElement && item.parentElement.closest('[data-edit-area]'); // origin = the OUTER region, never the item itself
649
761
  if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0)) return;
650
762
  if (e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return; // text/size win
763
+ if (e._editDrag) return; e._editDrag = true; // one drag per gesture — the innermost sortable item claims it
651
764
  const container = item.parentElement;
765
+ const crossOK = classify(area) === 'static' && !isRegionBlock(item) && topArea(area); // cross-region: top-level static → static only; a region-block only reorders in place
766
+ let hovered = container;
652
767
  const homeNext = item.nextElementSibling; // where to put it back if cancelled
653
768
  const start = item.getBoundingClientRect();
654
769
  const preStyle = item.getAttribute('style');
@@ -694,7 +809,13 @@
694
809
  active = true; lift(ev); paint();
695
810
  }
696
811
  ev.preventDefault();
697
- reorderOver(container, px, py);
812
+ const over = (crossOK && dropArea(px, py)) || ghost.parentElement; // cross-region drop search
813
+ if (over !== hovered) {
814
+ if (hovered !== container) hovered.removeAttribute('data-edit-dragging-in');
815
+ hovered = over;
816
+ if (hovered !== container) hovered.setAttribute('data-edit-dragging-in', '');
817
+ }
818
+ reorderOver(over, px, py);
698
819
  if (!frame) frame = requestAnimationFrame(paint);
699
820
  };
700
821
 
@@ -711,8 +832,17 @@
711
832
  if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
712
833
  item.removeAttribute('data-edit-dragging');
713
834
  area.removeAttribute('data-edit-dragging-in');
835
+ if (hovered !== container) hovered.removeAttribute('data-edit-dragging-in');
714
836
  ghost = null; dragged = null;
715
- if (cancelled) announce('Cancelled'); else finishReorder(area);
837
+ if (cancelled) { announce('Cancelled'); return; }
838
+ const dest = item.parentElement;
839
+ if (crossOK && dest !== container && dest._edit) {
840
+ // Re-validate at drop: a region locked or deactivated mid-drag
841
+ // (reached through the ghost fallback) must not take the block.
842
+ if (isActive(dest) && !locked(dest) && capOf(dest, 'sort') && topArea(dest)) { commitMove(area, dest, item); announce('Moved to ' + key(dest)); }
843
+ else { if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item); announce('Cancelled'); }
844
+ }
845
+ else finishReorder(area);
716
846
  };
717
847
  const onUp = () => settle(false);
718
848
  // Escape puts it back, the way every drag is expected to be escapable.
@@ -722,11 +852,19 @@
722
852
  document.addEventListener('keydown', onKey, true);
723
853
  }
724
854
  function reorderOver(container, x, y) {
725
- if (!dragged || dragged.parentElement !== container) return;
855
+ if (!dragged) return;
726
856
  const ref = afterElement(container, x, y);
727
- if (ref === dragged || ref === dragged.nextElementSibling) return;
857
+ if (dragged.parentElement === container && (ref === dragged || ref === dragged.nextElementSibling)) return;
728
858
  if (ref == null) container.appendChild(dragged); else container.insertBefore(dragged, ref);
729
859
  }
860
+ // Cross-region drop target: the innermost active static sort region under the pointer.
861
+ function dropArea(x, y) {
862
+ const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
863
+ for (let a = hit && hit.closest ? hit.closest('[data-edit-area]') : null; a; a = a.parentElement && a.parentElement.closest('[data-edit-area]')) {
864
+ if (a._edit && isActive(a) && !locked(a) && capOf(a, 'sort') && classify(a) === 'static' && topArea(a)) return a;
865
+ }
866
+ return null;
867
+ }
730
868
  function finishReorder(area) { if (classify(area) === 'data') { applyDataOrder(area, sortableChildren(area).map(c => itemKey(c, area))); commit(area); } else commitStaticOrder(area); }
731
869
  // Reading-order insertion: block, inline, flex row/col/wrap, grid auto-flow.
732
870
  // The FIRST sibling past the pointer, not the nearest — nearest can pick an
@@ -899,6 +1037,15 @@
899
1037
 
900
1038
 
901
1039
  /* ---- Inline text editing ---- */
1040
+ // Focusing a text leaf selects its content, so the common case — replace the
1041
+ // text — is one click and type. Deferred a frame so it wins over the click's
1042
+ // own caret placement; a second click still collapses to a caret for fine edits.
1043
+ function selectAllIn(el) {
1044
+ requestAnimationFrame(() => {
1045
+ if (document.activeElement !== el) return;
1046
+ try { const r = document.createRange(); r.selectNodeContents(el); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } catch { }
1047
+ });
1048
+ }
902
1049
  // Where x-text-edit is declared, it owns the element: this plugin neither makes
903
1050
  // its leaves contenteditable nor competes for the caret. If the rich editor has
904
1051
  // no expression of its own, we capture what it produces as an ordinary text
@@ -909,8 +1056,9 @@
909
1056
  if (el._richBound) return; el._richBound = true;
910
1057
  el.addEventListener('focusin', () => { el._preEdit = el.innerHTML.trim(); });
911
1058
  el.addEventListener('blur', () => {
912
- const commit = classify(area) === 'component' ? commitComponentNode : commitStaticNode;
913
- commit(area, el, 'text', el.innerHTML.trim());
1059
+ const a = regionOf(el) || area; // the region the element sits in NOW, not where it was armed
1060
+ const commit = classify(a) === 'component' ? commitComponentNode : commitStaticNode;
1061
+ commit(a, el, 'text', el.innerHTML.trim());
914
1062
  }, true);
915
1063
  });
916
1064
  }
@@ -924,8 +1072,8 @@
924
1072
  if (!capOf(el, 'text') || el.hasAttribute('x-text') || el.hasAttribute('x-html')) return;
925
1073
  el.setAttribute('contenteditable', 'true');
926
1074
  if (el._textBound) return; el._textBound = true;
927
- el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); const d = el.closest('[draggable="true"]'); if (d) d.setAttribute('draggable', 'false'); });
928
- el.addEventListener('blur', () => { const d = el.closest('[data-edit-area] [draggable="false"]'); if (d) d.setAttribute('draggable', 'true'); commitStaticNode(area, el, 'text', el.innerHTML.trim()); });
1075
+ el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); const d = el.closest('[draggable="true"]'); if (d) d.setAttribute('draggable', 'false'); selectAllIn(el); });
1076
+ el.addEventListener('blur', () => { const d = el.closest('[data-edit-area] [draggable="false"]'); if (d) d.setAttribute('draggable', 'true'); commitStaticNode(regionOf(el) || area, el, 'text', el.innerHTML.trim()); }); // live region: the element may have moved since arming
929
1077
  });
930
1078
  }
931
1079
 
@@ -957,7 +1105,7 @@
957
1105
  if (!el.hasAttribute('data-text-edit')) el.setAttribute('contenteditable', 'true');
958
1106
  if (el._dvBound) return; el._dvBound = true;
959
1107
  const read = () => rich ? el.innerHTML.trim() : el.textContent;
960
- el.addEventListener('focus', () => { el._preEdit = read(); });
1108
+ el.addEventListener('focus', () => { el._preEdit = read(); selectAllIn(el); });
961
1109
  el.addEventListener('focusin', () => { el._preEdit = read(); });
962
1110
  el.addEventListener('blur', () => commitDataValue(area, source, id, field, read(), el), true);
963
1111
  });
@@ -967,8 +1115,9 @@
967
1115
  /* ---- COMPONENT editing: text leaves addressed by structural path. Right-click an
968
1116
  instance for the scope (this instance vs all) + per-element classes + revert. ---- */
969
1117
  const componentName = (area) => { const r = area.querySelector('[data-component]'); return (r?.getAttribute('data-component') || '').replace(/-\d+$/, ''); };
970
- function pathOf(node, root) { const idx = []; let n = node; while (n && n !== root && n.parentElement) { idx.unshift(Array.from(n.parentElement.children).indexOf(n)); n = n.parentElement; } return idx.join('.'); }
971
- function nodeByPath(root, path) { let el = root; for (const i of path.split('.').map(Number)) { el = el.children[i]; if (!el) return null; } return el; }
1118
+ // Paths index authored children only (realChildren): plugin-injected nodes never count.
1119
+ function pathOf(node, root) { const idx = []; let n = node; while (n && n !== root && n.parentElement) { idx.unshift(realChildren(n.parentElement).indexOf(n)); n = n.parentElement; } return idx.join('.'); }
1120
+ function nodeByPath(root, path) { let el = root; for (const i of path.split('.').map(Number)) { el = realChildren(el)[i]; if (!el) return null; } return el; }
972
1121
  // While editing in 'All' scope, mirror the edit to every OTHER instance live — but
973
1122
  // skip the source element (don't fight the caret) and skip any instance that has its
974
1123
  // OWN committed override for this node/prop (instance overrides always win).
@@ -1002,7 +1151,7 @@
1002
1151
  if (!(p in area._baseText)) area._baseText[p] = el.innerHTML.trim();
1003
1152
  el.setAttribute('contenteditable', 'true');
1004
1153
  if (el._textBound) return; el._textBound = true;
1005
- el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); });
1154
+ el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); selectAllIn(el); });
1006
1155
  el.addEventListener('input', () => liveMainPropagate(area, el, 'text', el.innerHTML));
1007
1156
  el.addEventListener('blur', () => commitComponentNode(area, el, 'text', el.innerHTML.trim()));
1008
1157
  });
@@ -1202,6 +1351,10 @@
1202
1351
  // being edited. The attributes are all CSS asks for — what is shown, and
1203
1352
  // whether it is shown at all, is decided in the stylesheet.
1204
1353
  area.toggleAttribute('data-edit-authoring', !!area._edit.authoring);
1354
+ // .authoring regions get the UI scope by default; anything else opts in
1355
+ // by putting data-edit-ui on the region or an ancestor. Only remove what
1356
+ // the plugin itself added, never an author's attribute.
1357
+ if (area._edit.authoring && !area.hasAttribute('data-edit-ui')) { area.setAttribute('data-edit-ui', ''); area._uiByPlugin = true; }
1205
1358
  area.setAttribute('data-edit-label', kind === 'component' ? `${key(area)} · component · ${area._editScope || 'instance'}` : `${key(area)} · ${kind}`);
1206
1359
  if (kind === 'static') markStatic(area);
1207
1360
  [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (capOf(c, 'sort') && !locked(c)) makeSortable(c); });
@@ -1228,6 +1381,7 @@
1228
1381
  area.querySelectorAll('[data-edit-sortable]').forEach(e => { ['data-edit-sortable', 'data-edit-grabbed', 'data-edit-dragging', 'tabindex', 'role', 'draggable'].forEach(a => e.removeAttribute(a)); e._dragBound = false; });
1229
1382
  [area, ...area.querySelectorAll('[data-edit-sizable],[data-edit-movable]')].forEach(e => { e.removeAttribute('data-edit-sizable'); e.removeAttribute('data-edit-movable'); e._sizeBound = false; });
1230
1383
  area.removeAttribute('data-edit-armed'); area.removeAttribute('data-edit-label');
1384
+ if (area._uiByPlugin) { area.removeAttribute('data-edit-ui'); area._uiByPlugin = false; }
1231
1385
  }
1232
1386
 
1233
1387
  /* ---- Activation: per-area, always-on by default (.gated needs $edit.on()) ---- */
@@ -1263,24 +1417,44 @@
1263
1417
  // already auto-persists to localStorage on commit).
1264
1418
  function buildPatches() {
1265
1419
  const patches = Object.entries(fold()).filter(([k]) => authoringRegion(k)).map(([k, v]) => patchFor(k, v.kind, v.snap)); // data
1266
- const toEdits = (paths, area) => {
1420
+ const toEdits = (paths, area, fileOrder) => {
1267
1421
  const e = [];
1268
1422
  Object.entries(paths).forEach(([k, props]) => {
1269
1423
  // Keys address across a session; the source writer navigates by
1270
- // position, so resolve to where the element actually sits now. An
1271
- // element that has since been deleted has nothing to write.
1424
+ // position, so resolve to where the element actually sits now (the
1425
+ // server applies order before edits, so current position is the
1426
+ // right one). A deleted element has nothing to write.
1272
1427
  const el = area && (area.getAttribute('data-edit-key') === k ? area : area.querySelector(`[data-edit-key="${CSS.escape(k)}"]`));
1273
- const path = el ? el.getAttribute('data-edit-path') : k;
1428
+ let path = el ? pathOf(el, area) : k;
1274
1429
  if (area && !el) return;
1430
+ // RULE: paths must be valid against the file state the server will reach —
1431
+ // a pending st-children change can't be applied there (a short order is
1432
+ // skipped), so address by the block's position in the UNMODIFIED file.
1433
+ if (el && el !== area && fileOrder) {
1434
+ let block = el; while (block.parentElement && block.parentElement !== area) block = block.parentElement;
1435
+ const fi = fileOrder.indexOf(block.getAttribute('data-edit-key'));
1436
+ if (fi < 0) return; // block not in the file (added this session) — nothing to address
1437
+ const segs = path.split('.'); segs[0] = String(fi); path = segs.join('.');
1438
+ }
1275
1439
  Object.entries(props).forEach(([prop, value]) => e.push({ path, key: k, prop, value }));
1276
1440
  });
1277
1441
  return e;
1278
1442
  };
1443
+ for (let i = 0; i < cursor; i++) { // cross-region moves travel in log order, ahead of per-region ops
1444
+ const d = log[i];
1445
+ if (d.kind !== 'st-move' || !authoringRegion(d.from) || !authoringRegion(d.to)) continue;
1446
+ patches.push({ kind: 'static-move', from: d.from, to: d.to, key: d.key, toKey: d.toKey, toOrder: d.toOrder });
1447
+ }
1279
1448
  const ss = staticState(); // static: per-node ops + reorder permutation (no whole HTML)
1280
1449
  new Set([...Object.keys(ss.node), ...Object.keys(ss.order)]).forEach(region => {
1281
1450
  if (!authoringRegion(region)) return;
1282
- const edits = toEdits(ss.node[region] || {}, areaByKey(region));
1283
- if (edits.length || ss.order[region]) patches.push({ kind: 'static', region, edits, order: ss.order[region] || null });
1451
+ const area = areaByKey(region);
1452
+ const structural = log.slice(0, cursor).some(d => d.kind === 'st-children' && d.region === region);
1453
+ const edits = toEdits(ss.node[region] || {}, area, structural ? (area && area._baseOrder) || [] : null);
1454
+ // A moved region always gets its folded order (pre-move reorders are NOT
1455
+ // in the move splice); the server skips a reorder that is already true.
1456
+ const order = ss.order[region] || null;
1457
+ if (edits.length || order) patches.push({ kind: 'static', region, edits, order });
1284
1458
  });
1285
1459
  const dv = dataValueState(); // data-value edits → field writes (local file / cloud $update)
1286
1460
  Object.entries(dv).forEach(([source, recs]) => Object.entries(recs).forEach(([id, fields]) => Object.entries(fields).forEach(([field, value]) => patches.push({ kind: 'data-val', source, id, field, value }))));
@@ -1353,7 +1527,17 @@
1353
1527
  function restore() {
1354
1528
  if (!log.length) return;
1355
1529
  for (const [k, v] of Object.entries(fold())) { const area = areaByKey(k); if (area && v.kind === 'data') waitForData(area, () => applySnap(area, 'data', v.snap)); }
1356
- if (log.some(d => d.kind === 'st-node' || d.kind === 'st-order')) applyStaticState();
1530
+ // A replayed move must first converge live keys to the recorded ones (ordinal
1531
+ // renames from collisions). Skipped when the source already holds the move
1532
+ // (post-save reload): fresh derivation then matches the recorded order.
1533
+ for (let i = 0; i < cursor; i++) {
1534
+ const d = log[i];
1535
+ if (d.kind !== 'st-move' || !d.remap) continue;
1536
+ const ta = areaByKey(d.to);
1537
+ if (!ta || eq(staticKeys(ta), d.toOrder)) continue;
1538
+ applyMoveKeys(d, false);
1539
+ }
1540
+ if (log.some(d => d.kind === 'st-node' || d.kind === 'st-order' || d.kind === 'st-move')) applyStaticState();
1357
1541
  if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
1358
1542
  if (log.some(d => d.kind === 'data-val')) { const da = areas().find(a => classify(a) === 'data'); if (da) waitForData(da, applyDataValues); }
1359
1543
  if (log.some(d => d.kind === 'theme')) applyThemeState();
@@ -1383,6 +1567,7 @@
1383
1567
  paste(el) { return pasteBlock(el); },
1384
1568
  duplicate(el) { return duplicateBlock(el); },
1385
1569
  remove(el) { return removeBlock(el); },
1570
+ move(el, dest, before) { return moveBlock(el, dest, before); },
1386
1571
  block(node) { return blockOf(node); },
1387
1572
  patches() { return buildPatches(); }, // resolved B-side source patches
1388
1573
  export() { return JSON.parse(JSON.stringify({ log, cursor })); } // A-side overlay (e.g. push to Appwrite)
@@ -1,41 +1,41 @@
1
1
  {
2
2
  "manifest.appwrite.auth.js": "sha384-X30RFvbwT9qEIaMsMUumnW54ZzcnY5aY9QQeXPG8/0sI6VAbAw3+FZ6iuZb6b4cu",
3
- "manifest.appwrite.data.js": "sha384-+dYwOuI1ZABrPXnEgWeAlTYwRKeHC2a0dzyigfRWKFGHbX3OUyO6iHefAtB+E9YT",
3
+ "manifest.appwrite.data.js": "sha384-K0TiCzUFBML9Vf/deb/hrzMGQVpN5OZRcMNA/oUIkvmsmuwnVs/Vl+CiEkIxBswM",
4
4
  "manifest.appwrite.presences.js": "sha384-JyVDWFT69b7Svq8J8p9tNWe2v9PZNQBY5UyhtOl1vnuHFAX0CRQ3MA9SiKq5wzNh",
5
5
  "manifest.bindings.js": "sha384-lPtMf8Ykaic9gNVZKq8Scia1881ZeMAyxer3WCIkUNwwrgzGBWto1OXADUwjvORF",
6
6
  "manifest.charts.js": "sha384-gEAv/98+29RbhvNkiNWTNyBPbLTFr2Ld15wLu29FLCL57j1cwGChRrL9Pfk7dBtT",
7
7
  "manifest.chat.js": "sha384-88DfMfJLoi2oxclVD2iIMDkIGAExMXq5k47mekUredlicLaP3oZY5bwtwqs+TqDF",
8
- "manifest.code.js": "sha384-aoQ4E26vrhNe/+tL7K0h90c8/0ies7e8oIR6Ay92he2X3dnWZ+OPcn2Dna8fmc+c",
8
+ "manifest.code.js": "sha384-5Ki7YogsJza+eVpEU9L8wq/WwdBFIbXzrc3+TFgiDB9skA1V+EW9Tnse8gkxSyej",
9
9
  "manifest.color.js": "sha384-6Rv3LxyTcZNjrhtayQfqRdCx0uSZ4BiEbgEI98I62eTvp8Aw7LBIoNJ0Je1oktwL",
10
10
  "manifest.colorpicker.js": "sha384-BhcCe8cGcd0DTJNwzXWWY+FQNRfSFFl6XqfHIFP8yHg/aXFks1NIC6lVx0Sr1VkM",
11
11
  "manifest.combobox.js": "sha384-MILTkrgsTzsYUjGo4ZuADqV85Xs4GmfB4xexfe9vbJMIoQTZ42W4zs+DTaVSPEsx",
12
12
  "manifest.components.js": "sha384-4bc3HgPvF9DHMiOG08w+nWXI7Hho01RuAPAUL9xyLxOiS2igeZpulCVcvr5aT9v6",
13
13
  "manifest.computed.js": "sha384-GYML/e81gQfN+iDbqhPUgny8bINCyL+SrbTofoGk4hlCnLLjv51mCv42JMqq+ku5",
14
- "manifest.data.js": "sha384-MFjjZDomLJEqI9Jj5lJBPq13A1B5CWpsLxS2YB5FVb+AZ0uVbFIuJ3I4lvgXEeEC",
14
+ "manifest.data.js": "sha384-jXsslu3l0d7Sw2M3XEbiD8bBLM7bX1tzlqYCNgMJeKhIx3DGRl1UKNd2WKwKxfr9",
15
15
  "manifest.datepicker.js": "sha384-XnbeURni2X91ozVzatQcSjYYIS4MoW12dBRJnYAGBr0nCQ624heH5s+xtQSpLZX6",
16
- "manifest.defer.js": "sha384-wXa60M2OHigi5rBurXQ7Oa8eyr5WTKs7EUn2GOru62KMdfs/LDyNegxCc9MNJFSe",
16
+ "manifest.defer.js": "sha384-WcCf8YQ1bKIXCLSZ24a0bJwepbYntYc8TzQM50hklo5VMIH8AtzDnwl6WU4prRv1",
17
17
  "manifest.device.js": "sha384-g+txmrEszYtuOvRelTp/c1wSNR2AtllVI9rx5NPPD7DzhVUnMNughonZykv2xlBW",
18
18
  "manifest.dropdowns.js": "sha384-0ilj9AvRph0LacIBJSqwpu1fvPRn5gcWbhIG1SOpaoM/Q8idMbKBLupwM+5O0UaX",
19
- "manifest.edit.js": "sha384-oLsrH7/UkJ/KEl3UO77hexcf2rYCGgzXZd0fe2EMq3hKRE7aUqLiEnLJhSV7zTaY",
19
+ "manifest.edit.js": "sha384-X3FVmNQvDMV8RqVYxAt1TDb6BTsf/qiWOPnFEM1L67m1LpeW7K+m9mCgOO8TsFPB",
20
20
  "manifest.export.js": "sha384-Rx6XvqBZc4apzP/FwcDr6yIln4/zNa08Q4ZqBZcEqUz1aNZQfl4h0+Ae7mqCQyuG",
21
21
  "manifest.icons.js": "sha384-kKOINpX70YzNO3FvkV6umrmO3f0KIu0DNbwB+ej55a7y/lhU7v/myQtXL0bwCTbt",
22
22
  "manifest.localization.js": "sha384-EImsIw8Z3l13V5eH9ZJF/yfsOfIvR1oPRNSArANkPji7V8O3IRf6zMwsNiAGj3Hk",
23
- "manifest.markdown.js": "sha384-fzyRaD7LwPOYcUeJZCMh6aWgk1ChxIVgy3eUTEjSwZ3DvAHrsl5EMvfxlvOKXD5U",
23
+ "manifest.markdown.js": "sha384-Wix51VpAtzCPo43dM6eHa633+W/Xtn/xRN4QBSK8Hx7s4fP1kequ8ecaSMyacNvG",
24
24
  "manifest.native.js": "sha384-g+txmrEszYtuOvRelTp/c1wSNR2AtllVI9rx5NPPD7DzhVUnMNughonZykv2xlBW",
25
25
  "manifest.payments.js": "sha384-8pofk2zhxtapPMpwnFyTelPMrYhShNhDCE722uQh/EYFvZFuPLmSdgunqsarVQjv",
26
26
  "manifest.resize.js": "sha384-+0IsyrGP/4gZmNbD6oU8dCgEyhpBsXMm74NIenYeUGuGRWyJDurcG27xE/CA0/aA",
27
- "manifest.router.js": "sha384-4qmGEkRpgAo9xoQkW6+hHIhowvcyAggKB2J3Wf8tJtXp+L0hufoVAqzfhnqw4cNR",
27
+ "manifest.router.js": "sha384-xkQaHO60lMQwtnqGPxPJJ646eyqBjINs4AFesrQbe2dWqPIoIcJ9RD5wsjqjak7w",
28
28
  "manifest.slides.js": "sha384-3uRTkyK9XPLmnxI2+igZlpi4EyPlU/7IHj5j3BZJJ2KN455vXyk99fiXV3feO/XY",
29
29
  "manifest.status.js": "sha384-MBO345ab7H6Uq1X2dZLt4Ge1Gcra+Z2qYApvlAeIV8CJhDDJrz2FPXpp+Kcp8zcM",
30
30
  "manifest.svg.js": "sha384-qr/8H9+2cIhSXNbRWRtIdrDlTOxKlNJqwEefHvCYoiBNW/kKhPTFa109Orv/EBnb",
31
- "manifest.sw.js": "sha384-T9s7as2eRcR0TFaUp4H+Mn5dXp1n/yK3/jjUYo1MEEERIz5mKkwCyjA0Nht2htNR",
31
+ "manifest.sw.js": "sha384-ZHAcZayIy8b/fuajad/c6RA3zNLMjxb7Gw+JAIKJz/N5PvFBvk9M8o1XOAMWW9da",
32
32
  "manifest.tabs.js": "sha384-OIIcyaQnGS0eNYk4zgAFEDboNYect7THvUd9D0V6nqCeDOxQcoiqLrBEmmWVTTet",
33
33
  "manifest.tailwind.js": "sha384-r/Jvqb+Piz21jUts10BbVAJoKkShOdJRVFpywyi4HPIi/OfOJq5CtPyWAPbdkMgt",
34
- "manifest.text.edit.js": "sha384-pfhc4IPKNJokj6ldlgD+KwGLN4UoM21CNeVf2sVq/34X1Un2PUhEuyTg5itmtsXS",
34
+ "manifest.text.edit.js": "sha384-AZsUx2nwCoEUl1SxFkuf6WjskKc70ve20tsS47oVxlGSRH+Zy0ICEx5cCMY2n7d8",
35
35
  "manifest.toasts.js": "sha384-/OV32LLMJl0Iw8NoNQJ+5lgntLxjIJq/YNj5JAKS/+nUhVx4MMB9h1FOqP7zuj2M",
36
36
  "manifest.tooltips.js": "sha384-uXVVQADl6pY2Pj9i/UaSclZN7dzbKhLkmevv7Ad0+wXR8LgqodALNFrc5jZJjlwm",
37
37
  "manifest.url.parameters.js": "sha384-J2aSVBOPsoqsbafHndrWLHEDpKb4AKJAoQnVmKA4gZh5P/8HHjFWHO/lkusmG84l",
38
- "manifest.utilities.js": "sha384-pIKcTvk9lrlXZK0OXld6SA6wrmlaFTCmTpLn67nOCE+XhNw4fuGSDX4Z3C2eR6CJ",
38
+ "manifest.utilities.js": "sha384-L5uXMLFBIF6LUM1haRjd4eGjHSWuqsCYSpPZRPYosNfJsMC5U5TnmjBxIhrZRe2d",
39
39
  "manifest.virtual.js": "sha384-ZDNoF9QVhVcVpkjIK6NH15S6I5c6OBshiJpXSOYjNsIB+298I0a7g3aFO+jyq4Vb",
40
- "manifest.js": "sha384-OMEyd7TUB5EfAhabW58OgEn6AkZgOuH5IQ85dHZf7ciI0jmXXrsCp2Eh74iVnJ62"
40
+ "manifest.js": "sha384-CyrcEV95hOBffjZUh4GaqktqzEp0oHK9F4xgbMKt4OuoNbCj41C6eNEfv7YM7Ho/"
41
41
  }
package/lib/manifest.js CHANGED
@@ -127,19 +127,61 @@
127
127
  * `_x_currentIfEl` (avoids re-rendering heavy content); x-for clones are removed.
128
128
  * Runs on alpine:init so a page without Alpine keeps its baked content.
129
129
  */
130
+ function adoptIfClone(tpl, el) {
131
+ const A = window.Alpine;
132
+ // Mirrors Alpine's x-if show() bookkeeping.
133
+ const undo = () => {
134
+ A.mutateDom(() => {
135
+ A.destroyTree(el);
136
+ el.remove();
137
+ });
138
+ delete tpl._x_currentIfEl;
139
+ delete tpl._x_lastRenderedEl;
140
+ };
141
+ // Boot-time false (e.g. data still loading) keeps the clone until
142
+ // manifest:ready, then removes it only if still false — no blank frame.
143
+ let pending = false;
144
+ const adoptedUndo = () => {
145
+ if (window.__manifestReady || !window.__manifestReadyCoordinator) return undo();
146
+ queueMicrotask(() => { if (tpl._x_currentIfEl === el) tpl._x_undoIf = adoptedUndo; });
147
+ if (pending) return;
148
+ pending = true;
149
+ window.addEventListener('manifest:ready', () => {
150
+ if (tpl._x_currentIfEl !== el) return;
151
+ if (!tpl.isConnected || !A.evaluate(tpl, tpl.getAttribute('x-if'))) {
152
+ undo();
153
+ delete tpl._x_undoIf;
154
+ }
155
+ }, { once: true });
156
+ };
157
+ tpl._x_currentIfEl = el;
158
+ tpl._x_lastRenderedEl = el;
159
+ tpl._x_undoIf = adoptedUndo;
160
+ }
130
161
  function reconcilePrerenderClones() {
131
162
  if (typeof document === 'undefined' || !document.querySelectorAll) return;
163
+ const adopted = [];
132
164
  document.querySelectorAll('[data-mnfst-prerender-clone]').forEach((el) => {
133
165
  if (el.closest && el.closest('[data-hydrate]')) return;
134
166
  el.removeAttribute('data-mnfst-prerender-clone');
135
167
  const tpl = el.previousElementSibling;
136
168
  if (tpl && tpl.tagName === 'TEMPLATE' && tpl.hasAttribute('x-if')) {
137
169
  // Alpine's x-if show() returns early when _x_currentIfEl is set.
138
- tpl._x_currentIfEl = el;
170
+ adoptIfClone(tpl, el);
171
+ adopted.push([tpl, el]);
139
172
  } else {
140
173
  el.remove();
141
174
  }
142
175
  });
176
+ if (!adopted.length) return;
177
+ // Clones the start walk missed: init once (initTree skips marked nodes).
178
+ document.addEventListener('alpine:initialized', () => {
179
+ for (const [tpl, el] of adopted) {
180
+ if (tpl._x_marker && tpl._x_currentIfEl === el && el.isConnected && !el._x_marker) {
181
+ window.Alpine.initTree(el);
182
+ }
183
+ }
184
+ }, { once: true });
143
185
  }
144
186
  if (typeof document !== 'undefined') {
145
187
  document.addEventListener('alpine:init', reconcilePrerenderClones, { once: true });
@@ -236,7 +278,6 @@
236
278
  'tabs',
237
279
  'text-edit',
238
280
  'slides',
239
- 'resize',
240
281
  'colorpicker',
241
282
  'datepicker',
242
283
  'charts',
@@ -516,9 +516,11 @@ async function initializeMarkdownPlugin() {
516
516
  markdownSource = normalizeContent(newContent);
517
517
  }
518
518
 
519
- // Skip if no content
519
+ // Skip if no content. Baked content stays visible: a reactive
520
+ // expression can evaluate empty at boot (data still loading),
521
+ // and hiding then would blank prerendered output.
520
522
  if (!markdownSource || markdownSource.trim() === '') {
521
- element.style.opacity = '0';
523
+ if (!hasContent) element.style.opacity = '0';
522
524
  return;
523
525
  }
524
526
 
@@ -607,11 +609,13 @@ async function initializeMarkdownPlugin() {
607
609
  return;
608
610
  }
609
611
 
610
- // Prerendered MPA pages with baked content are already correct — skip
611
- // the effect (MPA navigation is full page loads).
612
- if (hasBakedContent) {
613
- return;
614
- }
612
+ // Prerendered MPA pages with baked content are already correct on
613
+ // FIRST paint, but the effect still has to register: a reactive
614
+ // expression (x-markdown="note") must keep re-rendering when its
615
+ // value changes. File-backed markdown never reaches here — the
616
+ // prerender strips x-markdown from those (data-mnfst-md-src), so
617
+ // registering the effect cannot re-fetch baked articles.
618
+ // updateContent already no-ops when the render equals the baked HTML.
615
619
 
616
620
  // Classify the source: real expressions evaluate; bare markdown/paths
617
621
  // are quoted so Alpine doesn't choke on them.