@jxsuite/studio 2.4.0 → 2.4.1

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.
Files changed (40) hide show
  1. package/dist/chunks/{content-source-jrvn5vwy.js → content-source-cn9te96r.js} +8 -8
  2. package/dist/chunks/{convert-to-repeater-109dwcj7.js → convert-to-repeater-65gjgdst.js} +7 -7
  3. package/dist/chunks/{extension-sections-2zjyty05.js → extension-sections-mdgmqrry.js} +11 -11
  4. package/dist/chunks/{files-jdbtammm.js → files-dsh5g8j4.js} +12 -12
  5. package/dist/chunks/{format-host-kq6963gs.js → format-host-m9h45a79.js} +2 -2
  6. package/dist/chunks/{git-panel-2stf3ahg.js → git-panel-rfs8pqjj.js} +7 -7
  7. package/dist/chunks/{layers-panel-a8wjsdjj.js → layers-panel-6cj5nf6a.js} +10 -10
  8. package/dist/chunks/{publish-panel-2we6smjv.js → publish-panel-b0xh7b4y.js} +5 -5
  9. package/dist/chunks/{quick-search-hmpwdgys.js → quick-search-a83fhnsh.js} +13 -13
  10. package/dist/chunks/{right-panel-hn3y3j0j.js → right-panel-hrw5kdh5.js} +9 -9
  11. package/dist/chunks/{studio-102jzb8r.js → studio-0svvdm0a.js} +4 -4
  12. package/dist/chunks/{studio-8x4399kc.js → studio-1d6bm7eg.js} +7 -7
  13. package/dist/chunks/{studio-6gcrk1bq.js → studio-40fm3hyr.js} +2 -2
  14. package/dist/chunks/{studio-0evba9z5.js → studio-4m39t01b.js} +5 -5
  15. package/dist/chunks/{studio-53dn6e6t.js → studio-675v08qj.js} +2 -2
  16. package/dist/chunks/{studio-kwavhmdr.js → studio-91sdrn9x.js} +2 -2
  17. package/dist/chunks/{studio-qd4k0h80.js → studio-9sxn0eaa.js} +4 -4
  18. package/dist/chunks/{studio-eqyybj2e.js → studio-d1f2rqxh.js} +4 -4
  19. package/dist/chunks/{studio-yz06mkfj.js → studio-gfzyxa0m.js} +5 -5
  20. package/dist/chunks/{studio-wxthr2x1.js → studio-gqpbrrj5.js} +8 -8
  21. package/dist/chunks/{studio-yk7037sg.js → studio-h6553a8h.js} +56 -17
  22. package/dist/chunks/{studio-1dgqagzz.js → studio-jand3kjg.js} +10 -10
  23. package/dist/chunks/{studio-ddgrc5hs.js → studio-kfq9ed73.js} +3 -3
  24. package/dist/chunks/{studio-ng5gbxkj.js → studio-n9k15qdg.js} +8 -8
  25. package/dist/chunks/{studio-377bbhtz.js → studio-qb6vy1z1.js} +8 -8
  26. package/dist/chunks/{studio-tqr02xaa.js → studio-qymkxbcq.js} +3 -3
  27. package/dist/chunks/{studio-ky68mp85.js → studio-rkr6pm84.js} +3 -3
  28. package/dist/chunks/{studio-rdsvb808.js → studio-rz861p2b.js} +5 -5
  29. package/dist/chunks/{studio-q07vj7p9.js → studio-tdt7b6qb.js} +6 -6
  30. package/dist/iframe-entry.js +67 -7
  31. package/dist/studio.js +31 -31
  32. package/package.json +4 -4
  33. package/src/canvas/editable-actions.ts +37 -1
  34. package/src/canvas/iframe-editable-root.ts +27 -0
  35. package/src/canvas/iframe-host.ts +118 -7
  36. package/src/canvas/iframe-inline-edit.ts +64 -2
  37. package/src/canvas/iframe-protocol.ts +7 -0
  38. package/src/canvas/iframe-render.ts +38 -1
  39. package/src/editor/inline-edit-apply.ts +10 -4
  40. package/src/editor/inline-edit.ts +62 -4
@@ -38,6 +38,7 @@ import {
38
38
  tabOfContainer,
39
39
  } from "./canvas-surface";
40
40
  import { cloneSelection, primarySelection, toggleSelected } from "../tabs/selection";
41
+ import { getNodeAtPath } from "../state";
41
42
  import type { JxPath } from "../state";
42
43
  import { setLayoutSelection, shell } from "../shell";
43
44
  import { formatEditableVerdicts } from "../format/constraints";
@@ -198,7 +199,13 @@ interface HostState {
198
199
  * render acks (`renderComplete`) at a bumped one — both satisfy `gen >= minGen`. An immediate
199
200
  * `enterEdit` would race the escalated ASYNC render and silently fail to find the element.
200
201
  */
201
- pendingEnterEdit: { path: (string | number)[]; minGen: number; offset?: number } | null;
202
+ pendingEnterEdit: {
203
+ path: (string | number)[];
204
+ minGen: number;
205
+ offset?: number;
206
+ /** Set when the deferred re-entry is a PROP host rather than a block (see deferEnterEdit). */
207
+ prop?: string;
208
+ } | null;
202
209
  /**
203
210
  * Stylebook capability (set by {@link mountStylebookCanvas}, cleared by page mounts). The specimen
204
211
  * doc's paths decode to TAGS, not tab-document paths: hits route to the injected stylebook
@@ -1246,6 +1253,66 @@ function withEchoSuppressed(host: HostState, paths: (string | number)[][], fn: (
1246
1253
  }
1247
1254
 
1248
1255
  /** The tab whose document `state`'s iframe currently renders (null when unknown or closed). */
1256
+ /**
1257
+ * Serialized path of an in-place prop commit whose patch was echo-suppressed, or null.
1258
+ *
1259
+ * One value rather than a set: a plain session edits exactly one prop of one instance at a time,
1260
+ * and it is cleared on the release that follows.
1261
+ */
1262
+ let propEchoPending: string | null = null;
1263
+
1264
+ /**
1265
+ * Serialized instance path whose release commit just transacted, so a `$props` patch rebuilding it
1266
+ * is in flight. Read by the very next `editStart` and cleared there — the two messages arrive back
1267
+ * to back when the user clicks from one prop slot to another in the same component.
1268
+ */
1269
+ let propRebuildAt: string | null = null;
1270
+
1271
+ /**
1272
+ * Whether a release commit has to re-render the instance itself.
1273
+ *
1274
+ * Exported for its own test: the posting side ({@link postPatchToHosts}) needs a resolvable stage
1275
+ * and is covered elsewhere, but the STATE MACHINE here is the part that was wrong, and it is three
1276
+ * conditions that have to agree — the release must have declined to transact, an in-place commit
1277
+ * must have been suppressed, and it must have been for this same instance.
1278
+ *
1279
+ * @param {boolean} transacted - Whether the release commit wrote to the document.
1280
+ * @param {string | null} pending - Serialized path of a suppressed in-place commit, if any.
1281
+ * @param {string} path - Serialized path of the instance being released.
1282
+ * @returns {boolean}
1283
+ */
1284
+ export function needsReleaseReconcile(
1285
+ transacted: boolean,
1286
+ pending: string | null,
1287
+ path: string,
1288
+ ): boolean {
1289
+ return !transacted && pending === path;
1290
+ }
1291
+
1292
+ /**
1293
+ * Re-render a component instance from the document, WITHOUT transacting.
1294
+ *
1295
+ * Used when a release commit legitimately no-ops but the DOM is still showing what the suppressed
1296
+ * in-place commits rendered. The op is the same `set-key $props` the patcher would have produced,
1297
+ * so it takes the ordinary `replaceSubtree` path — this adds no rendering mechanism, it re-sends
1298
+ * the one that was deliberately dropped while the caret needed protecting.
1299
+ *
1300
+ * @param {HostState} state - The host whose tab owns the document.
1301
+ * @param {JxPath} path - The component instance's document path.
1302
+ * @returns {void}
1303
+ */
1304
+ function reconcileInstance(state: HostState, path: JxPath): void {
1305
+ const tab = hostTab(state);
1306
+ if (!tab) {
1307
+ return;
1308
+ }
1309
+ const node = getNodeAtPath(tab.doc.document, path);
1310
+ if (!node) {
1311
+ return;
1312
+ }
1313
+ postPatchToHosts([{ key: "$props", op: "set-key", path, value: node.$props }], tab.id);
1314
+ }
1315
+
1249
1316
  function hostTab(state: HostState): Tab | null {
1250
1317
  return state.tabId ? (workspace.tabs.get(state.tabId) ?? null) : null;
1251
1318
  }
@@ -2251,6 +2318,13 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
2251
2318
  activeEditHost.snapshot = null;
2252
2319
  }
2253
2320
  activeEditHost = state;
2321
+ /* The session that just opened is inside an instance a release commit is about to rebuild —
2322
+ defer a re-entry so it survives the patch. Only for the SAME instance: moving to a slot in
2323
+ a different component is not disturbed by this rebuild. */
2324
+ if (msg.prop !== undefined && propRebuildAt === serializeJxPath(msg.path)) {
2325
+ deferEnterEdit(state, msg.path, undefined, msg.prop);
2326
+ }
2327
+ propRebuildAt = null;
2254
2328
  // Each visit to a block is one undoable edit. Bumping the run id here breaks the history
2255
2329
  // Coalescing run, so returning to the same paragraph later is a separate ⌘Z step.
2256
2330
  editRunSeq += 1;
@@ -2314,9 +2388,34 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
2314
2388
  const applyProp = () => applyInlinePropCommit(hostTab(state), msg.path, msg.prop, msg.value);
2315
2389
  if (msg.inPlace) {
2316
2390
  withEchoSuppressed(state, [msg.path], applyProp);
2317
- } else {
2318
- applyProp();
2391
+ propEchoPending = serializeJxPath(msg.path);
2392
+ return;
2393
+ }
2394
+ const transacted = applyProp();
2395
+ /*
2396
+ * "The instance re-renders for real on release" was only true when the release actually
2397
+ * transacted. After an idle tick the release posts the SAME string, the apply no-ops, and no
2398
+ * patch is ever generated — so the suppressed in-place render was the last word and the
2399
+ * canvas kept showing pre-edit output. Emptying a heading left it visibly empty while the
2400
+ * document had dropped the prop entirely, and any SECOND place the component renders that
2401
+ * value kept the old one.
2402
+ *
2403
+ * Reconciling here rather than lifting the no-op: that guard is load-bearing (it is what
2404
+ * stops a commit → patch → disturb → re-commit loop, and keeps undo clean), so the fix is to
2405
+ * re-render without transacting.
2406
+ */
2407
+ if (needsReleaseReconcile(transacted, propEchoPending, serializeJxPath(msg.path))) {
2408
+ reconcileInstance(state, msg.path);
2319
2409
  }
2410
+ propEchoPending = null;
2411
+ /*
2412
+ * A release that TRANSACTED rebuilds the whole instance — `set-key $props` at the instance
2413
+ * path becomes a `replaceSubtree`. If the user got here by clicking a SECOND prop slot in
2414
+ * that same instance, the frame has already adopted a marker inside the subtree about to be
2415
+ * replaced, so the session it just opened is dead on arrival: the click did nothing and they
2416
+ * had to click again. The next `editStart` tells us whether that is what happened.
2417
+ */
2418
+ propRebuildAt = transacted ? serializeJxPath(msg.path) : null;
2320
2419
  return;
2321
2420
  }
2322
2421
  case "editMerge": {
@@ -2434,7 +2533,7 @@ function onDomUpdated(state: HostState, gen: number): void {
2434
2533
  requestPresence(state);
2435
2534
  hideInsertZoneNow(state);
2436
2535
  if (state.pendingEnterEdit && gen >= state.pendingEnterEdit.minGen) {
2437
- const { offset, path } = state.pendingEnterEdit;
2536
+ const { offset, path, prop } = state.pendingEnterEdit;
2438
2537
  state.pendingEnterEdit = null;
2439
2538
  /* Re-enter only when this host STILL SHOWS THE TAB IT OWES THE CARET TO — which is a question
2440
2539
  about this host's pane, not about the focus. A background tab's iframe renders a different
@@ -2444,7 +2543,7 @@ function onDomUpdated(state: HostState, gen: number): void {
2444
2543
  there is one stage. With two, the side pane could be displaying its tab, holding a caret it
2445
2544
  owed, and drop it because the PRIMARY had focus. */
2446
2545
  if (state.tabId !== null && state.tabId === tabOfContainer(state.canvasEl)?.id) {
2447
- reenterEdit(state, path, offset);
2546
+ reenterEdit(state, path, offset, prop);
2448
2547
  }
2449
2548
  }
2450
2549
  }
@@ -2455,20 +2554,32 @@ function onDomUpdated(state: HostState, gen: number): void {
2455
2554
  * that same gen; an escalated full render acks at a bumped one — both satisfy `gen >= minGen`,
2456
2555
  * while a stale ack cannot. Latest-wins on overwrite.
2457
2556
  */
2458
- function deferEnterEdit(state: HostState, path: (string | number)[], offset?: number): void {
2557
+ function deferEnterEdit(
2558
+ state: HostState,
2559
+ path: (string | number)[],
2560
+ offset?: number,
2561
+ prop?: string,
2562
+ ): void {
2459
2563
  state.pendingEnterEdit = {
2460
2564
  minGen: state.lastRenderedGen,
2461
2565
  path: [...path],
2462
2566
  ...(offset === undefined ? {} : { offset }),
2567
+ ...(prop === undefined ? {} : { prop }),
2463
2568
  };
2464
2569
  }
2465
2570
 
2466
2571
  /** Ask the host's iframe to (re-)enter inline editing on `path` (a plain copy crosses the bridge). */
2467
- function reenterEdit(state: HostState, path: (string | number)[], offset?: number): void {
2572
+ function reenterEdit(
2573
+ state: HostState,
2574
+ path: (string | number)[],
2575
+ offset?: number,
2576
+ prop?: string,
2577
+ ): void {
2468
2578
  state.channel.post({
2469
2579
  kind: "enterEdit",
2470
2580
  path: [...path],
2471
2581
  ...(offset === undefined ? {} : { offset }),
2582
+ ...(prop === undefined ? {} : { prop }),
2472
2583
  });
2473
2584
  }
2474
2585
 
@@ -25,6 +25,7 @@ import {
25
25
  handleSlashTrigger,
26
26
  isEditableBlock,
27
27
  isEditing,
28
+ isPlainSession,
28
29
  isSlashActive,
29
30
  openSlashMenu,
30
31
  refreshSlashMenu,
@@ -277,14 +278,44 @@ export function startIframeInlineEdit(
277
278
  return true;
278
279
  }
279
280
  const node = getNodeAtPath(shadowDoc, hostPath) as JxMutableNode | undefined;
281
+ /*
282
+ * `$props` IS NOT THE ONLY PLACE A PROP VALUE LIVES, and "absent from $props" is therefore not
283
+ * the same question as "unset". Two shapes deliver one through `attributes` instead, and both
284
+ * were reproduced against the real runtime:
285
+ *
286
+ * 1. The JSON shorthand `attributes: { "props.text": "…" }`. `connectedCallback` collects
287
+ * `props.*` attributes into state, so the marker renders that text while `$props` is
288
+ * empty. This repo's own site carries 95 of them.
289
+ * 2. A prop whose name collides with a REFLECTED HTMLElement property — `title`, `role`,
290
+ * `id`, `lang`, `dir`, `slot`, `hidden`. `setAttribute("title", …)` writes the JS property
291
+ * as a side effect, and the merge `key in this && this[key] !== undefined` picks it up.
292
+ * 41 shipped starter components declare `title` or `role` as state.
293
+ *
294
+ * Editing either would write `$props[prop]` and leave the attribute standing — two sources for
295
+ * one rendered value. In case 2 the attribute WINS (applyAttributes runs after the $props
296
+ * assignment), so the edit is simply invisible; in case 1 clearing the prop later resurrects
297
+ * the old text. Refusing keeps the properties panel, which edits the attribute itself, as the
298
+ * one way in.
299
+ */
300
+ const attrs = node?.attributes as Record<string, unknown> | undefined;
301
+ if (attrs?.[`props.${prop}`] !== undefined || attrs?.[prop] !== undefined) {
302
+ return false;
303
+ }
280
304
  const raw = (node?.$props as Record<string, unknown> | undefined)?.[prop];
281
305
  if (raw == null) {
282
306
  return true; // Unset — editing ADDS the prop, overriding the definition default.
283
307
  }
284
- if (typeof raw === "object") {
308
+ /*
309
+ * Only a STRING is text. A number or a boolean renders as text and reads as editable, but this
310
+ * session commits `textContent` — so editing `count: 3` wrote the string `"3"` and silently
311
+ * retyped the prop, after which `${count * 2}` is `"33"` and `${enabled ? …}` is always truthy.
312
+ * Objects were already excluded; the scalar cases were not, and are the ones a component
313
+ * actually declares. They stay editable in the properties panel, which knows their type.
314
+ */
315
+ if (typeof raw !== "string") {
285
316
  return false;
286
317
  }
287
- return !(typeof raw === "string" && raw.includes("${"));
318
+ return !raw.includes("${");
288
319
  };
289
320
 
290
321
  /**
@@ -309,6 +340,13 @@ export function startIframeInlineEdit(
309
340
  onEnd: () => {
310
341
  clearHighlight();
311
342
  lastNonEmptyRange = null;
343
+ /*
344
+ * Enter and Escape end the session inside the engine, which knows nothing about the
345
+ * editing host that adopted this marker. Without this the host kept pointing at the dead
346
+ * session, so the very next click on the same text was swallowed by the
347
+ * `el !== activeEl` re-entry guard — you had to click something else and come back.
348
+ */
349
+ root.forget();
312
350
  channel.post({ kind: "editEnd" });
313
351
  },
314
352
  // Prop values are single plain strings — no split, no slash-insert.
@@ -325,6 +363,15 @@ export function startIframeInlineEdit(
325
363
  if (!el) {
326
364
  return; // Session not active → no-op.
327
365
  }
366
+ /*
367
+ * A prop value carries no formatting — "formatting is off" is the documented rule for this
368
+ * session. The chord is inertified inside the frame, but a caret-scoped ⌘B is ALSO forwarded to
369
+ * the parent, matched by the shortcut registry, and posted straight back as an intent that
370
+ * lands here. This is the one chokepoint both routes pass through.
371
+ */
372
+ if (isPlainSession()) {
373
+ return;
374
+ }
328
375
  // Restore the cached range ONLY if it's still usable (the DOM may have re-rendered).
329
376
  if (
330
377
  lastNonEmptyRange &&
@@ -511,6 +558,21 @@ export function startIframeInlineEdit(
511
558
  if (!editingAllowed()) {
512
559
  return;
513
560
  }
561
+ if (msg.prop !== undefined) {
562
+ /*
563
+ * A prop host cannot be re-entered by placing a caret: the marker has no path, and the
564
+ * `$props` patch that prompted this REPLACED the instance element, so the node the old
565
+ * session held is detached. Find the marker again inside the fresh instance and open it.
566
+ */
567
+ const instance = elementForPath(container, msg.path);
568
+ const marker = instance?.querySelector<HTMLElement>(
569
+ `[data-jx-bound-prop="${CSS.escape(msg.prop)}"]`,
570
+ );
571
+ if (marker) {
572
+ activateProp(marker);
573
+ }
574
+ return;
575
+ }
514
576
  // Follow-the-caret after a split or slash-insert: the parent re-renders and then names the path
515
577
  // The caret belongs in. Placing the caret is the whole job — activation follows from where the
516
578
  // Caret is, so there is nothing else to "enter".
@@ -190,6 +190,13 @@ export type ParentToIframe =
190
190
  path: (string | number)[];
191
191
  /** Character offset for the caret; defaults to the block's start. */
192
192
  offset?: number;
193
+ /**
194
+ * Re-enter the PROP-BOUND host for this prop of the instance at `path`, rather than placing a
195
+ * caret in a block. A prop host is identified by (instance, prop) — the marker element
196
+ * carries no path of its own — and it is re-entered rather than caret-placed because a
197
+ * `$props` patch rebuilds the whole instance, replacing the element the session was on.
198
+ */
199
+ prop?: string;
193
200
  }
194
201
  // Commit and end the inline-edit session if one is live (a no-op otherwise). Posted by the parent
195
202
  // When focus/intent leaves the edit surface in the PARENT realm (tab switch, layers-panel click,
@@ -480,6 +480,17 @@ export function injectHead(doc: JxDocument): void {
480
480
  * + `data-jx-layout-file` (layout-originated nodes) on rendered nodes.
481
481
  */
482
482
  export function makeStamper(ctx: PathMapCtx) {
483
+ /*
484
+ * Paths of the component instances frozen below. Nodes arrive parent-first and `onNodeCreated`
485
+ * fires straight after `createElement`, BEFORE the node is attached — so there is no ancestor to
486
+ * walk at stamp time, and the path is the only way to know a node landed inside an island.
487
+ */
488
+ const islands: (string | number)[][] = [];
489
+ const insideIsland = (path: (string | number)[]): boolean =>
490
+ islands.some(
491
+ (island) => path.length > island.length && island.every((seg, i) => path[i] === seg),
492
+ );
493
+
483
494
  return (created: Node, path: (string | number)[], def: unknown) => {
484
495
  if (!(created instanceof HTMLElement)) {
485
496
  return;
@@ -503,8 +514,11 @@ export function makeStamper(ctx: PathMapCtx) {
503
514
  //
504
515
  // The opened document's own root is excluded: when a component definition is the file being
505
516
  // Edited, its subtree IS the document and must stay editable.
506
- if (!isDefinitionRoot && created.tagName.includes("-") && isEditableMode(ctx.canvasMode)) {
517
+ const isIsland =
518
+ !isDefinitionRoot && created.tagName.includes("-") && isEditableMode(ctx.canvasMode);
519
+ if (isIsland) {
507
520
  created.contentEditable = "false";
521
+ islands.push(path);
508
522
  }
509
523
  const classified = classifyRenderNode(path, def, ctx);
510
524
  if (classified.kind === "layout") {
@@ -530,6 +544,29 @@ export function makeStamper(ctx: PathMapCtx) {
530
544
  return;
531
545
  }
532
546
  created.dataset.jxPath = serializeJxPath(classified.path);
547
+ /*
548
+ * SLOTTED PAGE CONTENT IS NOT COMPONENT INTERNALS.
549
+ *
550
+ * The island above exists to keep the caret out of what a component renders for itself. But a
551
+ * component's CHILDREN are the author's own document — in jx-markdown,
552
+ *
553
+ * :::eer-intro
554
+ * If you need reliable rental equipment fast, request a quote today!
555
+ * :::
556
+ *
557
+ * is a paragraph the author typed, stamped here with its own `data-jx-path`. Inheriting the
558
+ * island's `contenteditable="false"` made every such paragraph uneditable: on a page written
559
+ * this way — which is most component-using pages — the caret could not be placed anywhere at
560
+ * all, and the only route to the text was the properties sidebar.
561
+ *
562
+ * Re-opening on the STAMPED node alone is what keeps the distinction. Component internals are
563
+ * created by the component's own `connectedCallback` in another document and never pass through
564
+ * this stamper, so they stay frozen; prop-bound internals keep their nested-host activation
565
+ * (see iframe-editable-root's `onPointerDownCapture`).
566
+ */
567
+ if (!isIsland && isEditableMode(ctx.canvasMode) && insideIsland(path)) {
568
+ created.contentEditable = "true";
569
+ }
533
570
  };
534
571
  }
535
572
 
@@ -107,27 +107,33 @@ export function applyInlineCommit(
107
107
  * disturb→re-commit cycle loops (and pollutes undo history). An empty value deletes the prop
108
108
  * (mutateUpdateProp), reverting the instance to the definition default — same as clearing the
109
109
  * sidebar field.
110
+ *
111
+ * @returns Whether it transacted. The caller needs to know: an in-place commit's patch is
112
+ * echo-suppressed so the caret survives, and the release that follows is expected to re-render
113
+ * the instance for real. When the release posts the same value it no-ops here, nothing
114
+ * re-renders, and the canvas keeps showing pre-edit output — see the reconcile in iframe-host.
110
115
  */
111
116
  export function applyInlinePropCommit(
112
117
  tab: Tab | null,
113
118
  path: JxPath,
114
119
  prop: string,
115
120
  value: string,
116
- ): void {
121
+ ): boolean {
117
122
  if (!tab) {
118
- return;
123
+ return false;
119
124
  }
120
125
  const node = getNodeAtPath(tab.doc.document, path);
121
126
  if (!node) {
122
- return;
127
+ return false;
123
128
  }
124
129
  const current = (node.$props as Record<string, unknown> | undefined)?.[prop];
125
130
  if (`${current ?? ""}` === value) {
126
- return;
131
+ return false;
127
132
  }
128
133
  transactDoc(tab, (t) => {
129
134
  mutateUpdateProp(t, path, prop, value);
130
135
  });
136
+ return true;
131
137
  }
132
138
 
133
139
  /**
@@ -182,11 +182,19 @@ let endFn: (() => void) | null = null; // Function() called when editing stops
182
182
  /**
183
183
  * Plain (plaintext-only) session state — used for prop-bound text, where the committed value is a
184
184
  * plain string (a directive attribute), so rich formatting, Enter-split, and the slash menu are all
185
- * disabled: Enter commits, Escape restores `_plainOriginal` and commits unchanged (the host no-ops
186
- * an unchanged prop value).
185
+ * disabled: Enter commits, Escape restores `_plainOriginal`.
186
+ *
187
+ * Escape is a genuine cancel, and `_plainPosted` is what makes it one. Restoring the text is not
188
+ * enough on its own: if an idle tick already wrote during the session, the document holds the typed
189
+ * value and the restored text must be written back over it. Conversely a session that never posted
190
+ * and ends matching its original must write NOTHING — the host's "unchanged value" guard cannot be
191
+ * relied on for that, because it compares against the STORED prop and an unset prop's stored value
192
+ * is `undefined` while the marker renders the definition's default.
187
193
  */
188
194
  let _plainMode = false;
189
195
  let _plainOriginal = "";
196
+ /** Whether this plain session has posted a commit — see the note above on Escape. */
197
+ let _plainPosted = false;
190
198
 
191
199
  /**
192
200
  * The live document's format overrides for which tags hold a caret. Injected per render (the same
@@ -286,6 +294,7 @@ export function startEditing(
286
294
  endFn = callbacks.onEnd;
287
295
  _plainMode = opts?.plainText === true;
288
296
  _plainOriginal = _plainMode ? (el.textContent ?? "") : "";
297
+ _plainPosted = false;
289
298
 
290
299
  // Mark the caret's block so the empty-block slash hint can target it. The editing host itself is
291
300
  // The canvas container (see syncEditableRoot) — activating a block does NOT make it a separate
@@ -345,6 +354,7 @@ export function stopEditing(silent = false) {
345
354
  insertFn = null;
346
355
  _plainMode = false;
347
356
  _plainOriginal = "";
357
+ _plainPosted = false;
348
358
 
349
359
  if (silent) {
350
360
  endFn = null;
@@ -370,6 +380,19 @@ export function commitActiveBlock(): void {
370
380
  commitChanges(true);
371
381
  }
372
382
 
383
+ /**
384
+ * Whether the live session is PLAIN — a prop value rather than a block of the document.
385
+ *
386
+ * Exported because the format bridge is in another module and `_plainMode` is file-local, so
387
+ * `applyFormatIntent` could not even ask: a ⌘B forwarded out to the parent came back as an
388
+ * `applyFormat` intent and ran `toggleInlineFormat` on a `plaintext-only` host, wrapping the prop's
389
+ * text in a `<strong>` that its next commit then flattened away — losing nothing visible, but
390
+ * mutating the DOM under a session whose whole contract is that it holds one plain string.
391
+ */
392
+ export function isPlainSession(): boolean {
393
+ return _plainMode && activeEl !== null;
394
+ }
395
+
373
396
  /**
374
397
  * Whether inline editing is currently active.
375
398
  *
@@ -416,7 +439,9 @@ function handleKeydown(e: KeyboardEvent) {
416
439
  e.preventDefault();
417
440
  e.stopPropagation();
418
441
  if (activeEl) {
419
- // Cancel: restore the original text; the commit posts the unchanged value (host no-ops).
442
+ /* Cancel: restore the original text. `commitChanges` then posts it ONLY if this session had
443
+ already written (an idle tick), which is what makes Escape undo the tick instead of
444
+ leaving it standing — and what stops a bare Escape from writing anything at all. */
420
445
  activeEl.textContent = _plainOriginal;
421
446
  }
422
447
  stopEditing();
@@ -532,6 +557,7 @@ function releaseWithoutCommit(): void {
532
557
  insertFn = null;
533
558
  _plainMode = false;
534
559
  _plainOriginal = "";
560
+ _plainPosted = false;
535
561
 
536
562
  if (endFn) {
537
563
  const fn = endFn;
@@ -557,7 +583,26 @@ function commitChanges(inPlace: boolean) {
557
583
  if (_plainMode) {
558
584
  // A prop value is a plain single-line string (a directive attribute) — flatten any newline
559
585
  // That survived plaintext editing and skip the rich DOM→Jx serialization entirely.
560
- commitFn(activePath, null, (activeEl.textContent ?? "").replaceAll("\n", " "), inPlace);
586
+ const text = (activeEl.textContent ?? "").replaceAll("\n", " ");
587
+ /*
588
+ * A SESSION THAT CHANGED NOTHING MUST WRITE NOTHING.
589
+ *
590
+ * This branch used to post unconditionally, and the only guard was the host's
591
+ * "same value, do nothing" check against the STORED prop — which does not fire when the prop is
592
+ * unset, because the marker renders the DEFINITION's default and the stored value is
593
+ * `undefined`. So merely clicking a component's text and clicking away wrote
594
+ * `$props.<name> = "<the default>"`: the tab went dirty, an undo entry appeared, and that
595
+ * instance was silently detached from its definition forever. Escape did the same thing, which
596
+ * made "cancel" a way to modify the document.
597
+ *
598
+ * `_plainPosted` is what keeps Escape honest AFTER an idle tick has already written: the
599
+ * restored text equals the original, but something WAS written, so it must be written back.
600
+ */
601
+ if (text === _plainOriginal.replaceAll("\n", " ") && !_plainPosted) {
602
+ return;
603
+ }
604
+ _plainPosted = true;
605
+ commitFn(activePath, null, text, inPlace);
561
606
  return;
562
607
  }
563
608
 
@@ -775,6 +820,19 @@ export function openSlashMenu(opts?: { anchored?: boolean }): void {
775
820
  if (!activeEl || !insertFn || !activePath) {
776
821
  return;
777
822
  }
823
+ /*
824
+ * Never in a plain session. A prop value is one string — there is nowhere to insert a block, and
825
+ * `enterPropEditAt` says so by passing `onInsert: () => {}`. That no-op is TRUTHY, so the
826
+ * precondition above let the menu open anyway, and choosing an item then ran the rich select
827
+ * path: it deleted the typed "/…" run and called `releaseWithoutCommit`, so the edit was
828
+ * discarded and no block was inserted either. The menu also captures Enter and Escape
829
+ * document-wide, so it took the two keys the docs promise for this session.
830
+ *
831
+ * Guarded here rather than at the trigger so a programmatic `openSlashMenu()` is covered too.
832
+ */
833
+ if (_plainMode) {
834
+ return;
835
+ }
778
836
 
779
837
  const sel = window.getSelection();
780
838
  if (!sel || !sel.rangeCount) {