Skip to content

Commit a8c8586

Browse files
fix: inherit run formatting when inserting inline structured content (SD-1421) (#2501)
* fix: inherit formatting when inserting inline structured content inside a run * fix: do not wrap inline sdt in runs * fix: handle cross-run selections and stored marks when inserting inline SDT - Fall back to plain replaceWith when the selection spans multiple runs, instead of only replacing the first run and leaving the rest untouched - Pass state.storedMarks to getFormattingStateAtPos so pending toolbar formatting (e.g. toggling bold before inserting) is respected * docs: clarify why options.json skips formatting inference in inline SDT insertion * fix: place cursor after inserted SDT when splitting a run * test: add case for inline SDT insertion at the start of a run * test: add end-of-run and cursor position tests for inline SDT insertion --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
1 parent 9e0d3ce commit a8c8586

2 files changed

Lines changed: 321 additions & 2 deletions

File tree

packages/super-editor/src/extensions/structured-content/structured-content-commands.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { DOMParser as PMDOMParser } from 'prosemirror-model';
2+
import { TextSelection } from 'prosemirror-state';
23
import { Extension } from '@core/Extension.js';
34
import { htmlHandler } from '@core/InputRule.js';
45
import { findParentNode } from '@helpers/findParentNode.js';
6+
import { getFormattingStateAtPos } from '@core/helpers/getMarksFromSelection.js';
57
import { generateRandomSigned32BitIntStrId } from '@core/helpers/generateDocxRandomId.js';
68
import { getStructuredContentTagsById } from './structuredContentHelpers/getStructuredContentTagsById.js';
79
import { getStructuredContentByGroup } from './structuredContentHelpers/getStructuredContentByGroup.js';
@@ -134,6 +136,31 @@ export const StructuredContentCommands = Extension.create({
134136
content = schema.text(' ');
135137
}
136138

139+
// When content was not provided as structured JSON, wrap the text
140+
// in a formatted run inside the SDT so it visually matches the
141+
// surrounding text. The run-split logic below prevents an outer run
142+
// from wrapping the SDT itself.
143+
const runType = schema.nodes.run;
144+
// When `options.json` is used the caller already controls the full
145+
// node structure, so we skip formatting inference intentionally.
146+
if (runType && !options.json && content.isText) {
147+
const formattingState = getFormattingStateAtPos(state, from, editor, {
148+
storedMarks: state.storedMarks || null,
149+
});
150+
const runProperties = formattingState.inlineRunProperties || null;
151+
152+
// Apply resolved marks so calculateInlineRunPropertiesPlugin can diff correctly
153+
if (formattingState.resolvedMarks?.length) {
154+
const mergedMarks = formattingState.resolvedMarks.reduce(
155+
(set, mark) => mark.addToSet(set),
156+
content.marks,
157+
);
158+
content = content.mark(mergedMarks);
159+
}
160+
161+
content = runType.create({ runProperties }, content);
162+
}
163+
137164
// Handle group parameter: convert to JSON tag
138165
let tag = options.attrs?.tag || 'inline_text_sdt';
139166
if (options.attrs?.group) {
@@ -157,7 +184,42 @@ export const StructuredContentCommands = Extension.create({
157184
from = to = insertPos;
158185
}
159186

160-
tr.replaceWith(from, to, node);
187+
// If the cursor is inside a run, split the run first so the SDT
188+
// is inserted at paragraph level rather than becoming a child of the run.
189+
const $from = state.doc.resolve(from);
190+
const $to = from === to ? $from : state.doc.resolve(to);
191+
const selectionWithinSameRun = runType && $from.parent.type === runType && $from.parent === $to.parent;
192+
193+
if (selectionWithinSameRun) {
194+
const runDepth = $from.depth;
195+
const runStart = $from.before(runDepth);
196+
const runEnd = $from.after(runDepth);
197+
const parentRun = $from.parent;
198+
const startOffset = $from.parentOffset;
199+
const endOffset = $to.parentOffset;
200+
201+
const leftContent = parentRun.content.cut(0, startOffset);
202+
const rightContent = parentRun.content.cut(endOffset);
203+
204+
const fragments = [];
205+
if (leftContent.size > 0) {
206+
fragments.push(runType.create(parentRun.attrs, leftContent, parentRun.marks));
207+
}
208+
fragments.push(node);
209+
if (rightContent.size > 0) {
210+
fragments.push(runType.create(parentRun.attrs, rightContent, parentRun.marks));
211+
}
212+
213+
tr.replaceWith(runStart, runEnd, fragments);
214+
215+
// Place the cursor right after the inserted SDT so subsequent
216+
// typing lands in the correct position.
217+
const sdtStart = runStart + (leftContent.size > 0 ? leftContent.size + 2 : 0);
218+
const cursorPos = sdtStart + node.nodeSize;
219+
tr.setSelection(TextSelection.create(tr.doc, cursorPos));
220+
} else {
221+
tr.replaceWith(from, to, node);
222+
}
161223
}
162224

163225
return true;

packages/super-editor/src/extensions/structured-content/structured-content-commands.test.js

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2-
import { EditorState } from 'prosemirror-state';
2+
import { EditorState, TextSelection } from 'prosemirror-state';
33
import { initTestEditor } from '@tests/helpers/helpers.js';
44
import { createTable } from '../table/tableHelpers/createTable.js';
55

@@ -556,6 +556,263 @@ describe('updateStructuredContentByGroup', () => {
556556
});
557557
});
558558

559+
describe('insertStructuredContentInline formatting', () => {
560+
let editor;
561+
let schema;
562+
563+
beforeEach(() => {
564+
({ editor } = initTestEditor());
565+
({ schema } = editor);
566+
});
567+
568+
afterEach(() => {
569+
editor?.destroy();
570+
editor = null;
571+
schema = null;
572+
});
573+
574+
it('does not wrap structuredContent in a run when inserted inside a run', () => {
575+
const fontFamily = {
576+
ascii: 'Courier New',
577+
eastAsia: 'Courier New',
578+
hAnsi: 'Courier New',
579+
cs: 'Courier New',
580+
};
581+
const textStyleMark = schema.marks.textStyle.create({
582+
fontFamily: 'Courier New',
583+
fontSize: '12pt',
584+
});
585+
const styledText = schema.text('This is some text', [textStyleMark]);
586+
const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText);
587+
const paragraph = schema.nodes.paragraph.create(null, [run]);
588+
const doc = schema.nodes.doc.create(null, [paragraph]);
589+
590+
// run content starts at position 2 (doc > paragraph > run), so cursor after "This is " is at 2 + 8 = 10
591+
const cursorPos = 2 + 'This is '.length; // 10
592+
const nextState = EditorState.create({
593+
schema,
594+
doc,
595+
plugins: editor.state.plugins,
596+
selection: TextSelection.create(doc, cursorPos),
597+
});
598+
editor.setState(nextState);
599+
600+
editor.commands.insertStructuredContentInline({
601+
text: 'Inline Header',
602+
attrs: { group: 'header' },
603+
});
604+
605+
const updatedParagraph = editor.state.doc.firstChild;
606+
607+
// The paragraph's direct children should be: run, structuredContent, run
608+
// The structuredContent must NOT be wrapped in a run
609+
const childTypes = [];
610+
updatedParagraph.forEach((child) => {
611+
childTypes.push(child.type.name);
612+
613+
// If a run contains a structuredContent as a child, that's the bug
614+
if (child.type.name === 'run') {
615+
child.forEach((grandchild) => {
616+
if (grandchild.type.name === 'structuredContent') {
617+
throw new Error('structuredContent should not be wrapped inside a run');
618+
}
619+
});
620+
}
621+
});
622+
623+
expect(childTypes).toContain('structuredContent');
624+
expect(updatedParagraph.textContent).toBe('This is Inline Headersome text');
625+
626+
// The SDT's inner content should be a run with the inherited formatting
627+
let sdt = null;
628+
updatedParagraph.forEach((child) => {
629+
if (child.type.name === 'structuredContent') sdt = child;
630+
});
631+
const innerRun = sdt.firstChild;
632+
expect(innerRun.type.name).toBe('run');
633+
expect(innerRun.attrs.runProperties).toMatchObject({ fontFamily });
634+
});
635+
636+
it('does not produce an empty left run when cursor is at the start of a run', () => {
637+
const fontFamily = {
638+
ascii: 'Courier New',
639+
eastAsia: 'Courier New',
640+
hAnsi: 'Courier New',
641+
cs: 'Courier New',
642+
};
643+
const textStyleMark = schema.marks.textStyle.create({
644+
fontFamily: 'Courier New',
645+
fontSize: '12pt',
646+
});
647+
const styledText = schema.text('Hello', [textStyleMark]);
648+
const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText);
649+
const paragraph = schema.nodes.paragraph.create(null, [run]);
650+
const doc = schema.nodes.doc.create(null, [paragraph]);
651+
652+
// Cursor at the very start of the run content: doc(1) + paragraph(1) = 2
653+
const cursorPos = 2;
654+
const nextState = EditorState.create({
655+
schema,
656+
doc,
657+
plugins: editor.state.plugins,
658+
selection: TextSelection.create(doc, cursorPos),
659+
});
660+
editor.setState(nextState);
661+
662+
editor.commands.insertStructuredContentInline({
663+
text: 'Field',
664+
attrs: { group: 'header' },
665+
});
666+
667+
const updatedParagraph = editor.state.doc.firstChild;
668+
669+
// Should be: structuredContent, run — no empty run before the SDT
670+
const childTypes = [];
671+
updatedParagraph.forEach((child) => {
672+
childTypes.push(child.type.name);
673+
if (child.type.name === 'run') {
674+
expect(child.content.size).toBeGreaterThan(0);
675+
}
676+
});
677+
678+
expect(childTypes).toEqual(['structuredContent', 'run']);
679+
expect(updatedParagraph.textContent).toBe('FieldHello');
680+
});
681+
682+
it('removes selected text when inserting with a ranged selection inside a run', () => {
683+
const fontFamily = {
684+
ascii: 'Courier New',
685+
eastAsia: 'Courier New',
686+
hAnsi: 'Courier New',
687+
cs: 'Courier New',
688+
};
689+
const textStyleMark = schema.marks.textStyle.create({
690+
fontFamily: 'Courier New',
691+
fontSize: '12pt',
692+
});
693+
const styledText = schema.text('This is some text', [textStyleMark]);
694+
const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText);
695+
const paragraph = schema.nodes.paragraph.create(null, [run]);
696+
const doc = schema.nodes.doc.create(null, [paragraph]);
697+
698+
// Select "some" (positions 10..14 inside run content starting at 2: "some" = chars 8..12)
699+
const selFrom = 2 + 'This is '.length; // 10
700+
const selTo = 2 + 'This is some'.length; // 14
701+
const nextState = EditorState.create({
702+
schema,
703+
doc,
704+
plugins: editor.state.plugins,
705+
selection: TextSelection.create(doc, selFrom, selTo),
706+
});
707+
editor.setState(nextState);
708+
709+
editor.commands.insertStructuredContentInline({
710+
text: 'Inline Header',
711+
attrs: { group: 'header' },
712+
});
713+
714+
const updatedParagraph = editor.state.doc.firstChild;
715+
716+
// "some" should be removed; remaining text is "This is " + "Inline Header" + " text"
717+
expect(updatedParagraph.textContent).toBe('This is Inline Header text');
718+
});
719+
720+
it('does not produce an empty right run when cursor is at the end of a run', () => {
721+
const fontFamily = {
722+
ascii: 'Courier New',
723+
eastAsia: 'Courier New',
724+
hAnsi: 'Courier New',
725+
cs: 'Courier New',
726+
};
727+
const textStyleMark = schema.marks.textStyle.create({
728+
fontFamily: 'Courier New',
729+
fontSize: '12pt',
730+
});
731+
const styledText = schema.text('Hello', [textStyleMark]);
732+
const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText);
733+
const paragraph = schema.nodes.paragraph.create(null, [run]);
734+
const doc = schema.nodes.doc.create(null, [paragraph]);
735+
736+
// Cursor at the very end of the run content: doc(1) + paragraph(1) + "Hello"(5) = 7
737+
const cursorPos = 2 + 'Hello'.length; // 7
738+
const nextState = EditorState.create({
739+
schema,
740+
doc,
741+
plugins: editor.state.plugins,
742+
selection: TextSelection.create(doc, cursorPos),
743+
});
744+
editor.setState(nextState);
745+
746+
editor.commands.insertStructuredContentInline({
747+
text: 'Field',
748+
attrs: { group: 'header' },
749+
});
750+
751+
const updatedParagraph = editor.state.doc.firstChild;
752+
753+
// Should be: run, structuredContent — no empty run after the SDT
754+
const childTypes = [];
755+
updatedParagraph.forEach((child) => {
756+
childTypes.push(child.type.name);
757+
if (child.type.name === 'run') {
758+
expect(child.content.size).toBeGreaterThan(0);
759+
}
760+
});
761+
762+
expect(childTypes).toEqual(['run', 'structuredContent']);
763+
expect(updatedParagraph.textContent).toBe('HelloField');
764+
});
765+
766+
it('places the cursor right after the inserted SDT', () => {
767+
const fontFamily = {
768+
ascii: 'Courier New',
769+
eastAsia: 'Courier New',
770+
hAnsi: 'Courier New',
771+
cs: 'Courier New',
772+
};
773+
const textStyleMark = schema.marks.textStyle.create({
774+
fontFamily: 'Courier New',
775+
fontSize: '12pt',
776+
});
777+
const styledText = schema.text('Hello World', [textStyleMark]);
778+
const run = schema.nodes.run.create({ runProperties: { fontFamily } }, styledText);
779+
const paragraph = schema.nodes.paragraph.create(null, [run]);
780+
const doc = schema.nodes.doc.create(null, [paragraph]);
781+
782+
// Cursor after "Hello " (6 chars): doc(1) + paragraph(1) + 6 = 8
783+
const cursorPos = 2 + 'Hello '.length; // 8
784+
const nextState = EditorState.create({
785+
schema,
786+
doc,
787+
plugins: editor.state.plugins,
788+
selection: TextSelection.create(doc, cursorPos),
789+
});
790+
editor.setState(nextState);
791+
792+
editor.commands.insertStructuredContentInline({
793+
text: 'Field',
794+
attrs: { group: 'header' },
795+
});
796+
797+
const updatedState = editor.state;
798+
const updatedParagraph = updatedState.doc.firstChild;
799+
800+
// Find the SDT node and compute where the cursor should be (right after it)
801+
let sdtEnd = null;
802+
let offset = 1; // paragraph opens at pos 1
803+
updatedParagraph.forEach((child) => {
804+
if (child.type.name === 'structuredContent') {
805+
sdtEnd = offset + child.nodeSize;
806+
}
807+
offset += child.nodeSize;
808+
});
809+
810+
expect(sdtEnd).not.toBeNull();
811+
expect(updatedState.selection.from).toBe(sdtEnd);
812+
expect(updatedState.selection.to).toBe(sdtEnd);
813+
});
814+
});
815+
559816
describe('StructuredContent ID Validation', () => {
560817
let editor;
561818

0 commit comments

Comments
 (0)