From 859df860d80d0111ee4161929dc292fdd6e82ed5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 16:52:21 -0300 Subject: [PATCH 1/2] fix: read run properties from nodeAfter when cursor precedes a run (SD-2145) After document load the cursor can land at the paragraph boundary before the first run node, so the ancestor walk never finds a run. Fall back to $pos.nodeAfter to pick up the run's formatting and reflect it in the toolbar state. --- .../src/core/helpers/getMarksFromSelection.js | 7 + .../helpers/getMarksFromSelection.test.js | 126 ++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index fad706cf68..b6329e9d91 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -211,6 +211,13 @@ function getParagraphRunContext($pos, editor) { } } + if (runProperties == null) { + const nodeAfter = $pos.nodeAfter; + if (nodeAfter?.type.name === 'run') { + runProperties = normalizeRunProperties(nodeAfter.attrs?.runProperties); + } + } + if (!paragraphNode) { return null; } diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index f4a10a6f8c..0fa0738ae3 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -157,6 +157,132 @@ describe('getMarksFromSelection', () => { }); }); + describe('nodeAfter fallback — cursor before a run node', () => { + const runSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'inline*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + attrs: { runProperties: { default: null } }, + toDOM() { + return ['span', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + italic: { + attrs: { value: { default: true } }, + toDOM() { + return ['em', 0]; + }, + }, + }, + }); + + it('picks up runProperties from nodeAfter when cursor is before the first run', () => { + // doc(paragraph(run{bold:true}("Hello"))) + // pos 1 = inside paragraph, before run — nodeAfter is the run node + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node('run', { runProperties: { bold: true } }, [runSchema.text('Hello')]), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getSelectionFormattingState(cursorState); + + expect(result.inlineRunProperties).toEqual({ bold: true }); + expect(result.inlineMarks.some((mark) => mark.type.name === 'bold')).toBe(true); + }); + + it('does not use nodeAfter fallback when cursor is already inside a run', () => { + // Two adjacent runs: cursor inside the first run should use that run's properties, + // not the second run's (which would be nodeAfter at the boundary). + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node('run', { runProperties: { bold: true } }, [runSchema.text('AB')]), + runSchema.node('run', { runProperties: { italic: true } }, [runSchema.text('CD')]), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + // pos 3 = inside the first run ("AB"), specifically after "A" + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 3))); + + const result = getSelectionFormattingState(cursorState); + + expect(result.inlineRunProperties).toEqual({ bold: true }); + }); + + it('does not pick up run properties when nodeAfter is a text node, not a run', () => { + // Paragraph with only a text node (no run wrapper) — nodeAfter is a text node + const textOnlySchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + }, + }); + const testDoc = textOnlySchema.node('doc', null, [ + textOnlySchema.node('paragraph', null, [textOnlySchema.text('Hello')]), + ]); + const state = EditorState.create({ schema: textOnlySchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getSelectionFormattingState(cursorState); + + // No run node found via ancestor walk or nodeAfter, so no bold marks + expect(result.inlineMarks.some((mark) => mark.type.name === 'bold')).toBe(false); + }); + + it('normalizes empty nodeAfter runProperties to null and falls back to cursor marks', () => { + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [runSchema.node('run', { runProperties: {} }, [runSchema.text('Hello')])]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getSelectionFormattingState(cursorState); + + // Empty runProperties normalize to null, so the code falls back to cursor marks + // which produces no bold/italic marks + expect(result.inlineMarks.some((mark) => mark.type.name === 'bold')).toBe(false); + expect(result.inlineMarks.some((mark) => mark.type.name === 'italic')).toBe(false); + }); + }); + it('reads inline run properties from the surrounding run node instead of decoding visible marks', () => { const runSchema = new Schema({ nodes: { From 4e9353ef222994d4fee2ae7dce007421df083b84 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 18 Mar 2026 17:24:49 -0300 Subject: [PATCH 2/2] fix: prefer nodeBefore over nodeAfter for run property fallback (SD-2145) At an inter-run boundary the cursor resolves at paragraph depth, so the ancestor walk finds no run. Previously the fallback read from nodeAfter, which picked up the *following* run's formatting. Check nodeBefore first so the toolbar inherits from the preceding run, matching standard word processor behavior. --- .../src/core/helpers/getMarksFromSelection.js | 11 ++++++++--- .../helpers/getMarksFromSelection.test.js | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index b6329e9d91..d81ece8b1d 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -212,9 +212,14 @@ function getParagraphRunContext($pos, editor) { } if (runProperties == null) { - const nodeAfter = $pos.nodeAfter; - if (nodeAfter?.type.name === 'run') { - runProperties = normalizeRunProperties(nodeAfter.attrs?.runProperties); + const nodeBefore = $pos.nodeBefore; + if (nodeBefore?.type.name === 'run') { + runProperties = normalizeRunProperties(nodeBefore.attrs?.runProperties); + } else { + const nodeAfter = $pos.nodeAfter; + if (nodeAfter?.type.name === 'run') { + runProperties = normalizeRunProperties(nodeAfter.attrs?.runProperties); + } } } diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index 0fa0738ae3..bbcf5de954 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -267,6 +267,25 @@ describe('getMarksFromSelection', () => { expect(result.inlineMarks.some((mark) => mark.type.name === 'bold')).toBe(false); }); + it('prefers nodeBefore run at the inter-run boundary', () => { + // doc(paragraph(run{bold}("AB"), run{italic}("CD"))) + // Positions: 0=doc, 1=para, 2=run1, 3=A, 4=B, 5=boundary, 6=run2, 7=C, 8=D ... + // At pos 5: between the two runs at paragraph depth, nodeBefore=run1, nodeAfter=run2 + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node('run', { runProperties: { bold: true } }, [runSchema.text('AB')]), + runSchema.node('run', { runProperties: { italic: true } }, [runSchema.text('CD')]), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 5))); + + const result = getSelectionFormattingState(cursorState); + + // Should inherit from the preceding run (bold), not the following run (italic) + expect(result.inlineRunProperties).toEqual({ bold: true }); + }); + it('normalizes empty nodeAfter runProperties to null and falls back to cursor marks', () => { const testDoc = runSchema.node('doc', null, [ runSchema.node('paragraph', null, [runSchema.node('run', { runProperties: {} }, [runSchema.text('Hello')])]),