, idx, -1)`), which has different mark-
- // interaction semantics than the direct `posAtDOM(, 0)` path the
- // bare PM image node used. The opt-out preserves the pre-
- // existing clipboard behavior with zero risk surface. A regression
- // that drops the skip would silently re-introduce the routing change.
+ // click-to-enlarge works mid-prose. tiptap renders the node view as
+ // `.react-renderer.node-image > [data-node-view-wrapper ...]` — the
+ // opt-out must neutralize the WHOLE wrapper stack of that node view,
+ // not just the annotated wrapper: the outer `.react-renderer` belongs
+ // to the same node view and matching it would route clipboard
+ // serialization through the descriptor-parent codepath
+ // (`posAtDOM(
, idx, -1)`) instead of the direct
+ // `posAtDOM(, 0)` path the bare PM image node uses. This models
+ // the real production topology.
const proseMirror = makeDescriptorEl({ classes: ['ProseMirror'] });
const para = makeDescriptorEl();
+ const outerReactRenderer = makeDescriptorEl({ classes: ['react-renderer', 'node-image'] });
const inlineLeafWrapper = makeDescriptorEl({
attrs: ['data-node-view-wrapper', 'data-clipboard-inline-leaf'],
});
const img = makeDescriptorEl();
- const live = chainDescriptorEls(proseMirror, para, inlineLeafWrapper, img);
+ const live = chainDescriptorEls(proseMirror, para, outerReactRenderer, inlineLeafWrapper, img);
expect(findDescriptorRoot(wrapDescriptor(live))).toBeNull();
});
- test('(g) opt-out is wrapper-local — a real descriptor BEYOND the inline-leaf wrapper still matches (defense against accidental no-op for nested cases)', () => {
- // Pin that `data-clipboard-inline-leaf` only skips THAT wrapper, not
- // the rest of the climb. If a future schema nests `ImageInlineZoom`
- // inside a block descriptor (hypothetical, but the walker shouldn't
- // care), the outer block descriptor must still be found.
+ test("(g) opt-out neutralizes only the same node view's stack — a genuine descriptor ABOVE it still matches", () => {
+ // Pin that the opt-out skips exactly the inline-leaf node view's own
+ // wrapper pair (`.react-renderer` + annotated NodeViewWrapper), not
+ // the rest of the climb. A genuine enclosing descriptor always
+ // interposes its OWN NodeViewWrapper between its `.react-renderer`
+ // and nested content, so if a future schema nests `ImageInlineZoom`
+ // inside a block descriptor, the outer descriptor must still be found.
const proseMirror = makeDescriptorEl({ classes: ['ProseMirror'] });
const outerReactRenderer = makeDescriptorEl({ classes: ['react-renderer'] });
+ const outerWrapper = makeDescriptorEl({
+ attrs: ['data-node-view-wrapper', 'data-jsx-component'],
+ });
+ const innerReactRenderer = makeDescriptorEl({ classes: ['react-renderer', 'node-image'] });
const inlineLeafWrapper = makeDescriptorEl({
attrs: ['data-node-view-wrapper', 'data-clipboard-inline-leaf'],
});
const img = makeDescriptorEl();
- const live = chainDescriptorEls(proseMirror, outerReactRenderer, inlineLeafWrapper, img);
+ const live = chainDescriptorEls(
+ proseMirror,
+ outerReactRenderer,
+ outerWrapper,
+ innerReactRenderer,
+ inlineLeafWrapper,
+ img,
+ );
const root = findDescriptorRoot(wrapDescriptor(live));
expect(root).not.toBeNull();
- expect(root?.classList.contains('react-renderer')).toBe(true);
+ // Positive: the OUTERMOST genuine descriptor root is returned.
+ expect(root).toBe(wrapDescriptor(outerReactRenderer));
+ // Negative: the inline node view's own wrapper stack was neutralized.
+ expect(root).not.toBe(wrapDescriptor(innerReactRenderer));
+ expect(root).not.toBe(wrapDescriptor(inlineLeafWrapper));
});
});
diff --git a/packages/app/src/editor/clipboard/serialize.ts b/packages/app/src/editor/clipboard/serialize.ts
index 5abdfeb45..39fe65836 100644
--- a/packages/app/src/editor/clipboard/serialize.ts
+++ b/packages/app/src/editor/clipboard/serialize.ts
@@ -488,6 +488,13 @@ function sliceToMarkdown(slice: Slice, schema: Schema, mdManager: MarkdownManage
* mark-interaction semantics than the direct `posAtDOM(
, 0)` path
* the bare PM image node uses. Skipping these wrappers preserves
* the direct-leaf clipboard behavior while still mounting the Zoom UI.
+ * The opt-out neutralizes the node view's WHOLE wrapper stack: tiptap
+ * always renders a `.react-renderer` element immediately around the
+ * NodeViewWrapper, so when the annotated wrapper is skipped, its direct
+ * `.react-renderer` parent (the same node view's outer wrapper) is
+ * skipped too. A genuine enclosing descriptor always interposes its own
+ * NodeViewWrapper between its `.react-renderer` and any nested content,
+ * so nested descriptors above the stack still match.
*
* Exported only for unit-test reach; the production caller is
* `buildWalkerEnv` below.
@@ -495,6 +502,7 @@ function sliceToMarkdown(slice: Slice, schema: Schema, mdManager: MarkdownManage
export function findDescriptorRoot(live: Element): Element | null {
let descriptorRoot: Element | null = null;
let cur: Element | null = live;
+ let optedOutWrapper: Element | null = null;
while (cur && !cur.classList.contains('ProseMirror')) {
// Opt-out: live-editor render wrappers around bare inline PM atoms
// (e.g., `ImageInlineZoom`'s `
, 0)` stays the resolution path.
if (cur.hasAttribute('data-clipboard-inline-leaf')) {
+ optedOutWrapper = cur;
+ cur = cur.parentElement;
+ continue;
+ }
+ // tiptap always renders a `.react-renderer` immediately around the
+ // same node view's NodeViewWrapper. When that wrapper opted out, its
+ // DIRECT `.react-renderer` parent belongs to the same node view and
+ // must be neutralized too — a genuine enclosing descriptor always
+ // interposes its own NodeViewWrapper, so this containment check
+ // cannot swallow real nested descriptors.
+ if (optedOutWrapper?.parentElement === cur && cur.classList.contains('react-renderer')) {
cur = cur.parentElement;
continue;
}
@@ -567,7 +586,10 @@ function buildWalkerEnv(view: EditorView, mdManager: MarkdownManager): WalkerEnv
try {
const parent = descriptorRoot?.parentElement;
if (parent && descriptorRoot) {
- const idx = Array.from(parent.children).indexOf(descriptorRoot);
+ // posAtDOM's offset counts CHILDNODES (text nodes included), not
+ // element children — an element-only index misaddresses any
+ // descriptor with a non-element preceding sibling.
+ const idx = Array.prototype.indexOf.call(parent.childNodes, descriptorRoot);
pos = view.posAtDOM(parent, idx, -1);
} else {
pos = view.posAtDOM(live, 0);
diff --git a/packages/app/src/editor/extensions/ImageInlineZoomView.tsx b/packages/app/src/editor/extensions/ImageInlineZoomView.tsx
index bcb3df6ad..9ac4cca23 100644
--- a/packages/app/src/editor/extensions/ImageInlineZoomView.tsx
+++ b/packages/app/src/editor/extensions/ImageInlineZoomView.tsx
@@ -17,7 +17,9 @@
* `findDescriptorRoot` (see `clipboard/serialize.ts`): the PM node IS
* the bare `
` atom, not a descriptor, so position resolution must
* stay on the direct-leaf `posAtDOM(
, 0)` path that the un-wrapped
- * image used.
+ * image used. The opt-out neutralizes this node view's whole wrapper
+ * stack: the outer `.react-renderer` span tiptap renders directly around
+ * this wrapper is skipped too.
*
* Doc-relative `src` is resolved against the document's folder here, the
* same render-time normalization the block path applies in
diff --git a/packages/app/tests/stress/clipboard-relative-url-source-fallback.e2e.ts b/packages/app/tests/stress/clipboard-relative-url-source-fallback.e2e.ts
index d7477d940..6f797631d 100644
--- a/packages/app/tests/stress/clipboard-relative-url-source-fallback.e2e.ts
+++ b/packages/app/tests/stress/clipboard-relative-url-source-fallback.e2e.ts
@@ -143,6 +143,47 @@ test.describe('FR-2 walker URL classifier — WYSIWYG cross-app source-fallback'
expect(captured.html).not.toContain('src="./x.jpg"');
});
+ test('QA-005b inline image adjacent to marks resolves the image range and keeps the marks', async ({
+ page,
+ baseURL,
+ }) => {
+ // Behavioral pin for the direct-path resolution when the image sits
+ // inside marked text. The mark-interaction semantics of the direct
+ // `posAtDOM(
, 0)` path (vs the descriptor-parent path's -1 bias)
+ // only matter when marks surround the image, so this seed puts the
+ // image inside a strong run with prose on both sides.
+ await fetch(`${baseURL}/api/agent-write-md`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ docName,
+ markdown: 'Lead **bold  tail** end.\n',
+ position: 'replace',
+ }),
+ });
+ await expect(async () => {
+ expect(await getYText(page)).toContain('');
+ }).toPass({ timeout: 5_000 });
+ await page.click('.ProseMirror:not(.composer-prosemirror)');
+
+ const captured = await simulateCopyAndRead(page, 'wysiwyg');
+
+ // text/plain is canonical markdown; the serializer splits the strong
+ // run around the image atom, so assert the pieces rather than exact
+ // bytes (that emission path is untouched here).
+ expect(captured.plain).toContain('');
+ expect(captured.plain).toContain('bold');
+ expect(captured.plain).toContain('tail');
+ // The fallback span carries the image's own markdown, not a duplicate
+ // of the surrounding text run.
+ expect(captured.html).toContain('');
+ expect(captured.html).toContain('');
+ expect(captured.html).not.toMatch(/[^<]*bold/);
+ // The strong mark survives around the swap.
+ expect(captured.html).toMatch(/<(strong|b)[\s>]/);
+ expect(captured.html).not.toContain('src="./y.png"');
+ });
+
test('QA-009 all-portable selection: walker passes through unchanged (regression check)', async ({
page,
baseURL,