Skip to content

Commit 5a3318f

Browse files
fix: newline formatting inheritance without serializing style-derived formatting (SD-2228) (#2417)
* fix: when splitting paragraphs and runs compute correct inline run props * fix: correctly compute marks for selection in empty paragraph * fix: compute run properties for new runs accounting for styles cascade * fix: apply formatting to empty paragraphs * test: adjust unit tests * fix: handle encoding and decoding of a run's styleId * feat: add selection formatting state helpers for resolved and inline run properties * feat: resolve non-empty selection formatting through the style cascade * fix: make toggleMarkCascade distinguish direct marks from style-derived formatting * refactor: remove unused paragraph style override helpers from wrapTextInRunsPlugin * refactor: simplify toggleMarkCascade to rely on selection formatting state only * refactor: split empty paragraph run property sync into add/remove helpers * fix: fall back to cursor marks when runs have no explicit run properties * fix: fail open when converter access throws during formatting resolution * fix: preserve table context when resolving selection formatting * fix: avoid serializing style-derived marks when wrapping new runs
1 parent d3fc8fe commit 5a3318f

20 files changed

Lines changed: 1205 additions & 619 deletions

packages/super-editor/src/core/commands/setMark.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Attribute } from '../Attribute.js';
22
import { getMarkType } from '../helpers/getMarkType.js';
33
import { isTextSelection } from '../helpers/isTextSelection.js';
4+
import { addParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js';
45

56
function canSetMark(editor, state, tr, newMarkType) {
67
let { selection } = tr;
@@ -63,13 +64,15 @@ export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch,
6364
if (dispatch) {
6465
if (empty) {
6566
const oldAttributes = Attribute.getMarkAttributes(state, type);
67+
const newMark = type.create({
68+
...oldAttributes,
69+
...attributes,
70+
});
6671

6772
tr.addStoredMark(
68-
type.create({
69-
...oldAttributes,
70-
...attributes,
71-
}),
73+
newMark,
7274
);
75+
addParagraphRunProperty(tr, newMark);
7376
} else {
7477
ranges.forEach((range) => {
7578
const from = range.$from.pos;

packages/super-editor/src/core/commands/splitBlock.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ const ensureMarks = (state, splittableMarks) => {
2929
}
3030
};
3131

32+
/**
33+
* Extracts runProperties from the run node at the cursor position.
34+
* When the cursor is directly inside a paragraph (not inside a run), it
35+
* looks at the node just before the cursor (which is typically a run node).
36+
* @param {import('prosemirror-model').ResolvedPos} $from
37+
* @returns {Record<string, unknown> | null}
38+
*/
39+
const getRunPropertiesAtCursor = ($from) => {
40+
const runNode = $from.nodeBefore;
41+
if (runNode?.type.name === 'run' && runNode.attrs.runProperties) {
42+
return { ...runNode.attrs.runProperties };
43+
}
44+
return null;
45+
};
46+
3247
/**
3348
* Will split the current node into two nodes. If the selection is not
3449
* splittable, the command will be ignored.
@@ -67,6 +82,22 @@ export const splitBlock =
6782
if (dispatch) {
6883
const atEnd = $to.parentOffset === $to.parent.content.size;
6984
newAttrs = clearInheritedLinkedStyleId(newAttrs, editor, { emptyParagraph: atEnd });
85+
86+
// When splitting at the end (creating an empty new paragraph), store the
87+
// current run's runProperties on the new paragraph so the toolbar and
88+
// wrapTextInRunsPlugin know which inline formatting to inherit.
89+
if (atEnd) {
90+
const runProperties = getRunPropertiesAtCursor($from);
91+
if (runProperties) {
92+
newAttrs = {
93+
...newAttrs,
94+
paragraphProperties: {
95+
...(newAttrs.paragraphProperties || {}),
96+
runProperties,
97+
},
98+
};
99+
}
100+
}
70101
if (selection instanceof TextSelection) tr.deleteSelection();
71102
const deflt = $from.depth === 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)));
72103

packages/super-editor/src/core/commands/toggleMarkCascade.js

Lines changed: 21 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { getMarksFromSelection } from '../helpers/getMarksFromSelection.js';
1+
import { getSelectionFormattingState } from '../helpers/getMarksFromSelection.js';
22

33
/**
4-
* Cascade-aware toggle for marks that may be provided by styles (e.g., rStyle in runProperties).
4+
* Cascade-aware toggle for marks that may be provided by styles.
55
*
66
* Behavior:
77
* - If a negation mark is active → remove it (turn ON again)
8-
* - Else if an inline mark is active → remove it (turn OFF)
9-
* - Else if style provides the effect → add a negation mark (turn OFF style)
8+
* - Else if direct inline formatting is active and style is also ON → remove inline and add negation
9+
* - Else if only direct inline formatting is active → remove it (turn OFF)
10+
* - Else if only style provides the effect → add a negation mark (turn OFF style)
1011
* - Else → add regular inline mark (turn ON)
1112
*
1213
* @param {string} markName
1314
* @param {{
1415
* negationAttrs?: Object,
1516
* isNegation?: (attrs:Object)=>boolean,
16-
* styleDetector?: ({state: any, selectionMarks: any[], markName: string})=>boolean,
1717
* extendEmptyMarkRange?: boolean,
1818
* }} [options]
1919
*/
@@ -22,143 +22,44 @@ export const toggleMarkCascade =
2222
({ state, chain, editor }) => {
2323
const {
2424
negationAttrs = { value: '0' },
25-
isNegation = (attrs) => attrs?.value === '0',
26-
styleDetector = defaultStyleDetector,
25+
isNegation = (attrs) => attrs?.value === '0' || attrs?.value === false,
2726
extendEmptyMarkRange = false,
2827
} = options;
2928

30-
const selectionMarks = getMarksFromSelection(state) || [];
31-
const inlineMarks = selectionMarks.filter((m) => m.type?.name === markName);
32-
const hasNegation = inlineMarks.some((m) => isNegation(m.attrs || {}));
33-
const hasInline = inlineMarks.some((m) => !isNegation(m.attrs || {}));
34-
const styleOn = styleDetector({ state, selectionMarks, markName, editor });
29+
const formattingState = getSelectionFormattingState(state, editor);
30+
const directMarksForType = (formattingState?.inlineMarks || []).filter((m) => m.type?.name === markName);
31+
const hasNegation = directMarksForType.some((m) => isNegation(m.attrs || {}));
32+
const hasInline = directMarksForType.some((m) => !isNegation(m.attrs || {}));
33+
const styleValue = formattingState?.styleRunProperties?.[markName];
34+
const styleOn = isRunPropertyEnabled(styleValue);
3535

3636
const cmdChain = chain();
37-
// 1) If negation already present, remove it (turn back ON)
3837
if (hasNegation) return cmdChain.unsetMark(markName, { extendEmptyMarkRange }).run();
3938

40-
// 2) If inline is present and style is also ON, we must both remove inline AND add negation
4139
if (hasInline && styleOn) {
4240
return cmdChain
4341
.unsetMark(markName, { extendEmptyMarkRange })
4442
.setMark(markName, negationAttrs, { extendEmptyMarkRange })
4543
.run();
4644
}
4745

48-
// 3) If only inline is present, remove it (turn OFF)
4946
if (hasInline) return cmdChain.unsetMark(markName, { extendEmptyMarkRange }).run();
50-
51-
// 4) If only style is present, add negation (turn OFF)
5247
if (styleOn) return cmdChain.setMark(markName, negationAttrs, { extendEmptyMarkRange }).run();
5348

54-
// 5) Neither inline nor style is present; turn ON inline
5549
return cmdChain.setMark(markName, {}, { extendEmptyMarkRange }).run();
5650
};
5751

58-
/**
59-
* Default style detector that checks run-level or paragraph-level styleId
60-
* @param {Object} params
61-
* @returns {boolean}
62-
*/
63-
export function defaultStyleDetector({ state, selectionMarks, markName, editor }) {
64-
try {
65-
const styleId = getEffectiveStyleId(state, selectionMarks);
66-
if (!styleId || !editor?.converter?.linkedStyles) return false;
67-
// Resolve styles with basedOn chain
68-
const styles = editor.converter.linkedStyles;
69-
const seen = new Set();
70-
let current = styleId;
71-
const key = mapMarkToStyleKey(markName);
72-
while (current && !seen.has(current)) {
73-
seen.add(current);
74-
const style = styles.find((s) => s.id === current);
75-
const def = style?.definition?.styles || {};
76-
if (key in def) {
77-
const raw = def[key];
78-
// Some style parsers set the key with undefined value to indicate presence (ON)
79-
if (raw === undefined) return true;
80-
const val = raw?.value ?? raw;
81-
return isStyleTokenEnabled(val);
82-
}
83-
current = style?.definition?.attrs?.basedOn || null;
52+
function isRunPropertyEnabled(value) {
53+
if (value == null) return false;
54+
if (typeof value === 'object') {
55+
if ('w:val' in value) {
56+
return isStyleTokenEnabled(value['w:val']);
57+
}
58+
if ('val' in value) {
59+
return isStyleTokenEnabled(value.val);
8460
}
85-
return false;
86-
} catch {
87-
return false;
88-
}
89-
}
90-
91-
/**
92-
* Determines the effective style ID for the current selection/cursor position
93-
* by checking multiple sources in priority order.
94-
*
95-
* Priority hierarchy:
96-
* 1. Run-level rStyle from selection marks (highest priority)
97-
* 2. Cursor-adjacent node marks (handles boundaries where selection marks omit run mark)
98-
* 3. TextStyle styleId mark from selection marks
99-
* 4. Paragraph ancestor styleId (lowest priority)
100-
*
101-
* @param {Object} state - The ProseMirror editor state
102-
* @param {Array} selectionMarks - Array of marks from the current selection
103-
* @returns {string|null} The effective style ID, or null if none found
104-
*/
105-
export function getEffectiveStyleId(state, selectionMarks) {
106-
// 1) Run-level style resolved from the current mark set
107-
const sidFromMarks = getStyleIdFromMarks(selectionMarks);
108-
if (sidFromMarks) return sidFromMarks;
109-
110-
// 2) Cursor-adjacent marks (handles cursor at text boundaries where selection marks omit run mark)
111-
const $from = state.selection.$from;
112-
const before = $from.nodeBefore;
113-
const after = $from.nodeAfter;
114-
if (before && before.marks) {
115-
const sid = getStyleIdFromMarks(before.marks);
116-
if (sid) return sid;
117-
}
118-
if (after && after.marks) {
119-
const sid = getStyleIdFromMarks(after.marks);
120-
if (sid) return sid;
121-
}
122-
123-
// 3) TextStyle styleId mark
124-
const ts = selectionMarks.find((m) => m.type?.name === 'textStyle' && m.attrs?.styleId);
125-
if (ts) return ts.attrs.styleId;
126-
127-
// 4) Paragraph ancestor styleId
128-
const pos = state.selection.$from.pos;
129-
const $pos = state.doc.resolve(pos);
130-
for (let d = $pos.depth; d >= 0; d--) {
131-
const n = $pos.node(d);
132-
if (n?.type?.name === 'paragraph') return n.attrs?.styleId || null;
13361
}
134-
return null;
135-
}
136-
137-
/**
138-
* Get the style ID from an array of marks.
139-
* @param {import('prosemirror-model').Mark[]} marks
140-
* @returns {string|null}
141-
*/
142-
export function getStyleIdFromMarks(marks) {
143-
if (!Array.isArray(marks)) return null;
144-
145-
const textStyleMark = marks.find((m) => m.type?.name === 'textStyle' && m.attrs?.styleId);
146-
if (textStyleMark) return textStyleMark.attrs.styleId;
147-
148-
return null;
149-
}
150-
151-
/**
152-
* Maps a mark name to its corresponding style key.
153-
* Special case: both 'textStyle' and 'color' marks map to the 'color' style key.
154-
* All other mark names map directly to themselves.
155-
*
156-
* @param {string} markName - The name of the mark to map
157-
* @returns {string} The corresponding style key
158-
*/
159-
export function mapMarkToStyleKey(markName) {
160-
if (markName === 'textStyle' || markName === 'color') return 'color';
161-
return markName;
62+
return isStyleTokenEnabled(value);
16263
}
16364

16465
export function isStyleTokenEnabled(val) {

0 commit comments

Comments
 (0)