@avocadostudio-ai/preview-adapter 0.29.0 → 0.30.0

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.
@@ -417,7 +417,13 @@ export type BridgeState = {
417
417
  serverVersion: number;
418
418
  liveDraftActiveBlockId: string | null;
419
419
  liveDraftBadgeTimer: number | null;
420
- liveDraftOriginals: Map<HTMLElement, string>;
420
+ liveDraftOriginals: Map<HTMLElement, LiveDraftOriginal>;
421
+ /**
422
+ * React's own child nodes for fields whose streamed content was committed
423
+ * while foreign nodes were still on screen. Put back immediately before the
424
+ * next refresh, so React reconciles nodes it owns. See `LiveDraftOriginal`.
425
+ */
426
+ liveDraftDetached: Map<HTMLElement, ChildNode[]>;
421
427
  knownBlockIds: Set<string>;
422
428
  expectingNewBlocks: boolean;
423
429
  hoveredItemRoot: HTMLElement | null;
@@ -437,6 +443,21 @@ export type BridgeState = {
437
443
  fixedBlockTypes: Set<string>;
438
444
  };
439
445
  export declare function createBridgeState(): BridgeState;
446
+ export type LiveDraftOriginal = {
447
+ mode: "text";
448
+ textNode: Text;
449
+ value: string;
450
+ } | {
451
+ mode: "replaced";
452
+ nodes: ChildNode[];
453
+ };
454
+ /** The text an HTML string renders, when it is only text (no elements). */
455
+ export declare function plainTextOfHtml(html: string): string | null;
456
+ /**
457
+ * Set an element's text without replacing React's text node when it has
458
+ * exactly one; `textContent =` would. Falls back to `textContent` otherwise.
459
+ */
460
+ export declare function setTextKeepingNode(node: HTMLElement, text: string): void;
440
461
  export type BridgeFunctions = ReturnType<typeof createBridgeFunctions>;
441
462
  /**
442
463
  * Creates all stateful DOM manipulation functions bound to the given state and callbacks.
@@ -905,6 +905,7 @@ export function createBridgeState() {
905
905
  liveDraftActiveBlockId: null,
906
906
  liveDraftBadgeTimer: null,
907
907
  liveDraftOriginals: new Map(),
908
+ liveDraftDetached: new Map(),
908
909
  knownBlockIds: new Set(Array.from(document.querySelectorAll("[data-block-id]"))
909
910
  .map((node) => node.getAttribute("data-block-id"))
910
911
  .filter((id) => Boolean(id))),
@@ -916,6 +917,27 @@ export function createBridgeState() {
916
917
  fixedBlockTypes: new Set(),
917
918
  };
918
919
  }
920
+ /** The text an HTML string renders, when it is only text (no elements). */
921
+ export function plainTextOfHtml(html) {
922
+ if (/<[a-zA-Z!/]/.test(html))
923
+ return null;
924
+ const scratch = document.createElement("textarea");
925
+ scratch.innerHTML = html;
926
+ return scratch.value;
927
+ }
928
+ /**
929
+ * Set an element's text without replacing React's text node when it has
930
+ * exactly one; `textContent =` would. Falls back to `textContent` otherwise.
931
+ */
932
+ export function setTextKeepingNode(node, text) {
933
+ const only = node.childNodes.length === 1 ? node.firstChild : null;
934
+ if (only && only.nodeType === Node.TEXT_NODE) {
935
+ if (only.nodeValue !== text)
936
+ only.nodeValue = text;
937
+ return;
938
+ }
939
+ node.textContent = text;
940
+ }
919
941
  /**
920
942
  * Creates all stateful DOM manipulation functions bound to the given state and callbacks.
921
943
  * Used by both PreviewBridgeCore (iframe/postMessage) and the immersive widget (direct).
@@ -955,31 +977,56 @@ export function createBridgeFunctions(state, callbacks, config) {
955
977
  badge.append(status);
956
978
  }, 600);
957
979
  };
958
- const restoreLiveDraftOriginals = () => {
959
- for (const [node, html] of state.liveDraftOriginals) {
960
- node.innerHTML = html;
961
- node.classList.remove("editor-live-typing");
980
+ /** Put React's content back exactly as it was: its own nodes, its own text. */
981
+ const putBackOriginal = (node, original) => {
982
+ if (original.mode === "text") {
983
+ original.textNode.nodeValue = original.value;
984
+ if (original.textNode.parentNode !== node)
985
+ node.replaceChildren(original.textNode);
962
986
  }
987
+ else {
988
+ node.replaceChildren(...original.nodes);
989
+ }
990
+ node.classList.remove("editor-live-typing");
991
+ };
992
+ const restoreLiveDraftOriginals = () => {
993
+ for (const [node, original] of state.liveDraftOriginals)
994
+ putBackOriginal(node, original);
963
995
  state.liveDraftOriginals.clear();
964
996
  };
965
- /** Discard stored originals without restoring them (use after content is committed). */
997
+ /**
998
+ * The streamed content was committed: keep it on screen. A field edited in
999
+ * place needs nothing more — React's node holds the committed text and the
1000
+ * refresh reconciles it. A replaced field's React nodes wait in
1001
+ * `liveDraftDetached` and go back in just before that refresh.
1002
+ */
966
1003
  const discardLiveDraftOriginals = () => {
967
- for (const [node] of state.liveDraftOriginals) {
1004
+ for (const [node, original] of state.liveDraftOriginals) {
968
1005
  node.classList.remove("editor-live-typing");
1006
+ if (original.mode === "replaced" && !state.liveDraftDetached.has(node)) {
1007
+ state.liveDraftDetached.set(node, original.nodes);
1008
+ }
969
1009
  }
970
1010
  state.liveDraftOriginals.clear();
971
1011
  state.liveDraftActiveBlockId = null;
972
1012
  };
1013
+ /** Hand every committed field's nodes back to React before it re-renders. */
1014
+ const reattachLiveDraftNodes = () => {
1015
+ for (const [node, nodes] of state.liveDraftDetached) {
1016
+ if (node.isConnected)
1017
+ node.replaceChildren(...nodes);
1018
+ }
1019
+ state.liveDraftDetached.clear();
1020
+ };
973
1021
  // Write each streamed field value into its editable node (plain-text fields
974
1022
  // only; rich fields stay under React's control — see isRichEditableNode).
975
1023
  // Used by both the active streaming path and the final commit post.
976
1024
  const applyLiveDraftFields = (block, fields) => {
977
1025
  const nextPaths = new Set(Object.keys(fields));
978
- for (const [node, html] of [...state.liveDraftOriginals.entries()]) {
1026
+ for (const [node, original] of [...state.liveDraftOriginals.entries()]) {
979
1027
  const path = editablePathOf(node);
980
1028
  if (!nextPaths.has(path)) {
981
- node.innerHTML = html;
982
- node.classList.remove("editor-live-typing");
1029
+ putBackOriginal(node, original);
983
1030
  state.liveDraftOriginals.delete(node);
984
1031
  }
985
1032
  }
@@ -998,10 +1045,32 @@ export function createBridgeFunctions(state, callbacks, config) {
998
1045
  node.classList.add("editor-live-typing");
999
1046
  continue;
1000
1047
  }
1001
- if (!state.liveDraftOriginals.has(node)) {
1002
- state.liveDraftOriginals.set(node, node.innerHTML);
1048
+ const html = markdownToHtml(value);
1049
+ let original = state.liveDraftOriginals.get(node);
1050
+ if (!original) {
1051
+ const only = node.childNodes.length === 1 ? node.firstChild : null;
1052
+ original = only && only.nodeType === Node.TEXT_NODE
1053
+ ? { mode: "text", textNode: only, value: only.nodeValue ?? "" }
1054
+ : { mode: "replaced", nodes: Array.from(node.childNodes) };
1055
+ state.liveDraftOriginals.set(node, original);
1056
+ }
1057
+ const text = original.mode === "text" ? plainTextOfHtml(html) : null;
1058
+ if (original.mode === "text" && text !== null) {
1059
+ // React's own node, new text: nothing for React to lose track of.
1060
+ if (original.textNode.parentNode !== node)
1061
+ node.replaceChildren(original.textNode);
1062
+ original.textNode.nodeValue = text;
1063
+ }
1064
+ else {
1065
+ if (original.mode === "text") {
1066
+ // The stream gained markup (bold, a link): from here it replaces, and
1067
+ // React's text node is what goes back.
1068
+ original.textNode.nodeValue = original.value;
1069
+ original = { mode: "replaced", nodes: [original.textNode] };
1070
+ state.liveDraftOriginals.set(node, original);
1071
+ }
1072
+ node.innerHTML = html;
1003
1073
  }
1004
- node.innerHTML = markdownToHtml(value);
1005
1074
  node.classList.add("editor-live-typing");
1006
1075
  }
1007
1076
  };
@@ -1163,7 +1232,8 @@ export function createBridgeFunctions(state, callbacks, config) {
1163
1232
  const s = state.inlineEditing;
1164
1233
  if (!s)
1165
1234
  return;
1166
- s.node.textContent = s.initialValue;
1235
+ // Not `textContent =`: that replaces React's text node (see LiveDraftOriginal).
1236
+ setTextKeepingNode(s.node, s.initialValue);
1167
1237
  s.node.setAttribute("contenteditable", "false");
1168
1238
  s.node.classList.remove("editor-inline-editing");
1169
1239
  s.node.append(...s.liftedControls);
@@ -1706,6 +1776,7 @@ export function createBridgeFunctions(state, callbacks, config) {
1706
1776
  state.pendingScrollRestore = null;
1707
1777
  }
1708
1778
  cancelInlineEdit();
1779
+ reattachLiveDraftNodes();
1709
1780
  const restoreAndFocus = () => {
1710
1781
  if (state.pendingScrollRestore) {
1711
1782
  const { x, y } = state.pendingScrollRestore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/preview-adapter",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -39,7 +39,7 @@
39
39
  "NOTICE"
40
40
  ],
41
41
  "dependencies": {
42
- "@avocadostudio-ai/shared": "^0.29.0"
42
+ "@avocadostudio-ai/shared": "^0.30.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "next": ">=15.0.0",