Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
91ce068
fix: when splitting paragraphs and runs compute correct inline run props
luccas-harbour Mar 16, 2026
0cb1ca0
fix: correctly compute marks for selection in empty paragraph
luccas-harbour Mar 16, 2026
1fed1ff
fix: compute run properties for new runs accounting for styles cascade
luccas-harbour Mar 16, 2026
c10a0bd
fix: apply formatting to empty paragraphs
luccas-harbour Mar 16, 2026
8b78fad
test: adjust unit tests
luccas-harbour Mar 16, 2026
208bd62
fix: handle encoding and decoding of a run's styleId
luccas-harbour Mar 17, 2026
47fa47c
feat: add selection formatting state helpers for resolved and inline …
luccas-harbour Mar 17, 2026
c3cb37c
feat: resolve non-empty selection formatting through the style cascade
luccas-harbour Mar 17, 2026
c2dcc1a
fix: make toggleMarkCascade distinguish direct marks from style-deriv…
luccas-harbour Mar 17, 2026
065046a
refactor: remove unused paragraph style override helpers from wrapTex…
luccas-harbour Mar 17, 2026
d848215
refactor: simplify toggleMarkCascade to rely on selection formatting …
luccas-harbour Mar 17, 2026
e6afc9d
refactor: split empty paragraph run property sync into add/remove hel…
luccas-harbour Mar 17, 2026
c1be8d7
fix: fall back to cursor marks when runs have no explicit run properties
luccas-harbour Mar 17, 2026
97a0c6c
fix: fail open when converter access throws during formatting resolution
luccas-harbour Mar 17, 2026
09969a9
fix: preserve table context when resolving selection formatting
luccas-harbour Mar 17, 2026
843fcae
fix: avoid serializing style-derived marks when wrapping new runs
luccas-harbour Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions packages/super-editor/src/core/commands/setMark.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Attribute } from '../Attribute.js';
import { getMarkType } from '../helpers/getMarkType.js';
import { isTextSelection } from '../helpers/isTextSelection.js';
import { addParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js';

function canSetMark(editor, state, tr, newMarkType) {
let { selection } = tr;
Expand Down Expand Up @@ -63,13 +64,15 @@ export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch,
if (dispatch) {
if (empty) {
const oldAttributes = Attribute.getMarkAttributes(state, type);
const newMark = type.create({
...oldAttributes,
...attributes,
});

tr.addStoredMark(
type.create({
...oldAttributes,
...attributes,
}),
newMark,
);
addParagraphRunProperty(tr, newMark);
} else {
ranges.forEach((range) => {
const from = range.$from.pos;
Expand Down
31 changes: 31 additions & 0 deletions packages/super-editor/src/core/commands/splitBlock.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ const ensureMarks = (state, splittableMarks) => {
}
};

/**
* Extracts runProperties from the run node at the cursor position.
* When the cursor is directly inside a paragraph (not inside a run), it
* looks at the node just before the cursor (which is typically a run node).
* @param {import('prosemirror-model').ResolvedPos} $from
* @returns {Record<string, unknown> | null}
*/
const getRunPropertiesAtCursor = ($from) => {
const runNode = $from.nodeBefore;
if (runNode?.type.name === 'run' && runNode.attrs.runProperties) {
return { ...runNode.attrs.runProperties };
}
return null;
};

/**
* Will split the current node into two nodes. If the selection is not
* splittable, the command will be ignored.
Expand Down Expand Up @@ -67,6 +82,22 @@ export const splitBlock =
if (dispatch) {
const atEnd = $to.parentOffset === $to.parent.content.size;
newAttrs = clearInheritedLinkedStyleId(newAttrs, editor, { emptyParagraph: atEnd });

// When splitting at the end (creating an empty new paragraph), store the
// current run's runProperties on the new paragraph so the toolbar and
// wrapTextInRunsPlugin know which inline formatting to inherit.
if (atEnd) {
const runProperties = getRunPropertiesAtCursor($from);
if (runProperties) {
newAttrs = {
...newAttrs,
paragraphProperties: {
...(newAttrs.paragraphProperties || {}),
runProperties,
},
};
}
}
if (selection instanceof TextSelection) tr.deleteSelection();
const deflt = $from.depth === 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)));

Expand Down
141 changes: 21 additions & 120 deletions packages/super-editor/src/core/commands/toggleMarkCascade.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { getMarksFromSelection } from '../helpers/getMarksFromSelection.js';
import { getSelectionFormattingState } from '../helpers/getMarksFromSelection.js';

/**
* Cascade-aware toggle for marks that may be provided by styles (e.g., rStyle in runProperties).
* Cascade-aware toggle for marks that may be provided by styles.
*
* Behavior:
* - If a negation mark is active → remove it (turn ON again)
* - Else if an inline mark is active → remove it (turn OFF)
* - Else if style provides the effect → add a negation mark (turn OFF style)
* - Else if direct inline formatting is active and style is also ON → remove inline and add negation
* - Else if only direct inline formatting is active → remove it (turn OFF)
* - Else if only style provides the effect → add a negation mark (turn OFF style)
* - Else → add regular inline mark (turn ON)
*
* @param {string} markName
* @param {{
* negationAttrs?: Object,
* isNegation?: (attrs:Object)=>boolean,
* styleDetector?: ({state: any, selectionMarks: any[], markName: string})=>boolean,
* extendEmptyMarkRange?: boolean,
* }} [options]
*/
Expand All @@ -22,143 +22,44 @@ export const toggleMarkCascade =
({ state, chain, editor }) => {
const {
negationAttrs = { value: '0' },
isNegation = (attrs) => attrs?.value === '0',
styleDetector = defaultStyleDetector,
isNegation = (attrs) => attrs?.value === '0' || attrs?.value === false,
extendEmptyMarkRange = false,
} = options;

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

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

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

// 3) If only inline is present, remove it (turn OFF)
if (hasInline) return cmdChain.unsetMark(markName, { extendEmptyMarkRange }).run();

// 4) If only style is present, add negation (turn OFF)
if (styleOn) return cmdChain.setMark(markName, negationAttrs, { extendEmptyMarkRange }).run();

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

/**
* Default style detector that checks run-level or paragraph-level styleId
* @param {Object} params
* @returns {boolean}
*/
export function defaultStyleDetector({ state, selectionMarks, markName, editor }) {
try {
const styleId = getEffectiveStyleId(state, selectionMarks);
if (!styleId || !editor?.converter?.linkedStyles) return false;
// Resolve styles with basedOn chain
const styles = editor.converter.linkedStyles;
const seen = new Set();
let current = styleId;
const key = mapMarkToStyleKey(markName);
while (current && !seen.has(current)) {
seen.add(current);
const style = styles.find((s) => s.id === current);
const def = style?.definition?.styles || {};
if (key in def) {
const raw = def[key];
// Some style parsers set the key with undefined value to indicate presence (ON)
if (raw === undefined) return true;
const val = raw?.value ?? raw;
return isStyleTokenEnabled(val);
}
current = style?.definition?.attrs?.basedOn || null;
function isRunPropertyEnabled(value) {
if (value == null) return false;
if (typeof value === 'object') {
if ('w:val' in value) {
return isStyleTokenEnabled(value['w:val']);
}
if ('val' in value) {
return isStyleTokenEnabled(value.val);
}
return false;
} catch {
return false;
}
}

/**
* Determines the effective style ID for the current selection/cursor position
* by checking multiple sources in priority order.
*
* Priority hierarchy:
* 1. Run-level rStyle from selection marks (highest priority)
* 2. Cursor-adjacent node marks (handles boundaries where selection marks omit run mark)
* 3. TextStyle styleId mark from selection marks
* 4. Paragraph ancestor styleId (lowest priority)
*
* @param {Object} state - The ProseMirror editor state
* @param {Array} selectionMarks - Array of marks from the current selection
* @returns {string|null} The effective style ID, or null if none found
*/
export function getEffectiveStyleId(state, selectionMarks) {
// 1) Run-level style resolved from the current mark set
const sidFromMarks = getStyleIdFromMarks(selectionMarks);
if (sidFromMarks) return sidFromMarks;

// 2) Cursor-adjacent marks (handles cursor at text boundaries where selection marks omit run mark)
const $from = state.selection.$from;
const before = $from.nodeBefore;
const after = $from.nodeAfter;
if (before && before.marks) {
const sid = getStyleIdFromMarks(before.marks);
if (sid) return sid;
}
if (after && after.marks) {
const sid = getStyleIdFromMarks(after.marks);
if (sid) return sid;
}

// 3) TextStyle styleId mark
const ts = selectionMarks.find((m) => m.type?.name === 'textStyle' && m.attrs?.styleId);
if (ts) return ts.attrs.styleId;

// 4) Paragraph ancestor styleId
const pos = state.selection.$from.pos;
const $pos = state.doc.resolve(pos);
for (let d = $pos.depth; d >= 0; d--) {
const n = $pos.node(d);
if (n?.type?.name === 'paragraph') return n.attrs?.styleId || null;
}
return null;
}

/**
* Get the style ID from an array of marks.
* @param {import('prosemirror-model').Mark[]} marks
* @returns {string|null}
*/
export function getStyleIdFromMarks(marks) {
if (!Array.isArray(marks)) return null;

const textStyleMark = marks.find((m) => m.type?.name === 'textStyle' && m.attrs?.styleId);
if (textStyleMark) return textStyleMark.attrs.styleId;

return null;
}

/**
* Maps a mark name to its corresponding style key.
* Special case: both 'textStyle' and 'color' marks map to the 'color' style key.
* All other mark names map directly to themselves.
*
* @param {string} markName - The name of the mark to map
* @returns {string} The corresponding style key
*/
export function mapMarkToStyleKey(markName) {
if (markName === 'textStyle' || markName === 'color') return 'color';
return markName;
return isStyleTokenEnabled(value);
}

export function isStyleTokenEnabled(val) {
Expand Down
Loading
Loading