Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const isLinkedParagraphStyleId = (editor, styleId) => {
if (!styleId) return false;

const translatedStyles = editor?.converter?.translatedLinkedStyles?.styles;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a shared helper for reading translated styles (readTranslatedLinkedStyles in core/parts/adapters/styles-read.ts) — worth using it here instead of accessing the converter directly?

const styleDefinition = translatedStyles?.[styleId];
return Boolean(styleDefinition?.type === 'paragraph' && styleDefinition?.link);
};

export const clearInheritedLinkedStyleId = (attrs, editor, { emptyParagraph = false } = {}) => {
if (!emptyParagraph) return attrs;
if (!attrs || typeof attrs !== 'object') return attrs;
const paragraphProperties = attrs.paragraphProperties;
const styleId = paragraphProperties?.styleId;
if (!isLinkedParagraphStyleId(editor, styleId)) return attrs;

return {
...attrs,
paragraphProperties: {
...paragraphProperties,
styleId: null,
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { clearInheritedLinkedStyleId, isLinkedParagraphStyleId } from './linkedStyleSplitHelpers.js';

describe('linkedStyleSplitHelpers', () => {
describe('isLinkedParagraphStyleId', () => {
it('returns true for linked paragraph styles from the converter', () => {
const editor = {
converter: {
translatedLinkedStyles: {
styles: {
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' },
},
},
},
};

expect(isLinkedParagraphStyleId(editor, 'Heading1')).toBe(true);
});

it('returns false for missing style ids, missing converter data, non-paragraph styles, and ordinary paragraph styles', () => {
expect(isLinkedParagraphStyleId({}, 'Heading1')).toBe(false);
expect(
isLinkedParagraphStyleId(
{
converter: {
translatedLinkedStyles: {
styles: {
Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' },
},
},
},
},
'Emphasis',
),
).toBe(false);
expect(
isLinkedParagraphStyleId(
{
converter: {
translatedLinkedStyles: {
styles: {
BodyText: { styleId: 'BodyText', type: 'paragraph' },
},
},
},
},
'BodyText',
),
).toBe(false);
expect(
isLinkedParagraphStyleId(
{
converter: {
translatedLinkedStyles: {
styles: {
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
},
},
},
},
null,
),
).toBe(false);
});
});

describe('clearInheritedLinkedStyleId', () => {
it('removes styleId when it belongs to a linked paragraph style', () => {
const editor = {
converter: {
translatedLinkedStyles: {
styles: {
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
},
},
},
};
const attrs = {
paragraphProperties: { styleId: 'Heading1', keep: true },
preserve: true,
};

const result = clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true });

expect(result).toEqual({
paragraphProperties: { styleId: null, keep: true },
preserve: true,
});
expect(attrs).toEqual({
paragraphProperties: { styleId: 'Heading1', keep: true },
preserve: true,
});
});

it('preserves linked paragraph styleId when the new paragraph is not empty', () => {
const editor = {
converter: {
translatedLinkedStyles: {
styles: {
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
},
},
},
};
const attrs = {
paragraphProperties: { styleId: 'Heading1', keep: true },
};

expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: false })).toBe(attrs);
});

it('leaves attrs unchanged for non-linked styles or missing paragraphProperties', () => {
const editor = {
converter: {
translatedLinkedStyles: {
styles: {
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
BodyText: { styleId: 'BodyText', type: 'paragraph' },
},
},
},
};
const attrs = {
paragraphProperties: { styleId: 'BodyText', keep: true },
};

expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: false })).toBe(attrs);
expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true })).toBe(attrs);
expect(clearInheritedLinkedStyleId({ preserve: true }, editor, { emptyParagraph: true })).toEqual({
preserve: true,
});
expect(clearInheritedLinkedStyleId(null, editor, { emptyParagraph: true })).toBe(null);
});
});
});
2 changes: 2 additions & 0 deletions packages/super-editor/src/core/commands/splitBlock.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NodeSelection, TextSelection } from 'prosemirror-state';
import { canSplit } from 'prosemirror-transform';
import { defaultBlockAt } from '../helpers/defaultBlockAt.js';
import { Attribute } from '../Attribute.js';
import { clearInheritedLinkedStyleId } from './linkedStyleSplitHelpers.js';

const isHeadingStyleId = (styleId) => typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim());

Expand Down Expand Up @@ -65,6 +66,7 @@ export const splitBlock =

if (dispatch) {
const atEnd = $to.parentOffset === $to.parent.content.size;
newAttrs = clearInheritedLinkedStyleId(newAttrs, editor, { emptyParagraph: atEnd });
if (selection instanceof TextSelection) tr.deleteSelection();
const deflt = $from.depth === 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)));

Expand Down
161 changes: 161 additions & 0 deletions packages/super-editor/src/core/commands/splitBlock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,167 @@ describe('splitBlock', () => {
expect(attrs.paragraphProperties?.styleId).toBeUndefined();
});

it('does not inherit linked paragraph styles onto the newly created paragraph', () => {
mockEditor.converter = {
translatedLinkedStyles: {
styles: {
Heading2: { styleId: 'Heading2', type: 'paragraph', link: 'Heading2Char' },
},
},
};
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
const parentNode = {
contentMatchAt: vi.fn(() => ({
edgeCount: 1,
edge: vi.fn(() => ({ type: paragraphType })),
})),
};

const sourceAttrs = {
paragraphProperties: { styleId: 'Heading2', keep: true },
};
const $from = createMockResolvedPos({
depth: 1,
parent: {
isBlock: true,
content: { size: 10 },
type: { name: 'paragraph' },
inlineContent: true,
attrs: sourceAttrs,
},
parentOffset: 5,
node: vi.fn((depth) => {
if (depth === -1) return parentNode;
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
}),
});
const $to = createMockResolvedPos({
pos: 5,
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
parentOffset: 10,
});

mockTr.selection = { $from, $to };
mockState.selection = mockTr.selection;
mockTr.doc = {
resolve: vi.fn(() => $from),
};

const command = splitBlock();
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });

const splitTypes = mockTr.split.mock.calls[0][2];
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeNull();
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.keep).toBe(true);
expect(sourceAttrs.paragraphProperties.styleId).toBe('Heading2');
});

it('preserves linked paragraph styles when the split creates a non-empty following paragraph', () => {
mockEditor.converter = {
translatedLinkedStyles: {
styles: {
Heading2: { styleId: 'Heading2', type: 'paragraph', link: 'Heading2Char' },
},
},
};
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
const parentNode = {
contentMatchAt: vi.fn(() => ({
edgeCount: 1,
edge: vi.fn(() => ({ type: paragraphType })),
})),
};

const sourceAttrs = {
paragraphProperties: { styleId: 'Heading2', keep: true },
};
const $from = createMockResolvedPos({
depth: 1,
parent: {
isBlock: true,
content: { size: 10 },
type: { name: 'paragraph' },
inlineContent: true,
attrs: sourceAttrs,
},
parentOffset: 5,
node: vi.fn((depth) => {
if (depth === -1) return parentNode;
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
}),
});
const $to = createMockResolvedPos({
pos: 5,
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
parentOffset: 5,
});

mockTr.selection = { $from, $to };
mockState.selection = mockTr.selection;
mockTr.doc = {
resolve: vi.fn(() => $from),
};

const command = splitBlock();
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });

const splitTypes = mockTr.split.mock.calls[0][2];
expect(splitTypes).toBeUndefined();
});

it('preserves ordinary paragraph styles on the newly created paragraph', () => {
mockEditor.converter = {
translatedLinkedStyles: {
styles: {
BodyText: { styleId: 'BodyText', type: 'paragraph' },
},
},
};
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
const parentNode = {
contentMatchAt: vi.fn(() => ({
edgeCount: 1,
edge: vi.fn(() => ({ type: paragraphType })),
})),
};

const sourceAttrs = {
paragraphProperties: { styleId: 'BodyText', keep: true },
};
const $from = createMockResolvedPos({
depth: 1,
parent: {
isBlock: true,
content: { size: 10 },
type: { name: 'paragraph' },
inlineContent: true,
attrs: sourceAttrs,
},
parentOffset: 5,
node: vi.fn((depth) => {
if (depth === -1) return parentNode;
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
}),
});
const $to = createMockResolvedPos({
pos: 5,
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
parentOffset: 10,
});

mockTr.selection = { $from, $to };
mockState.selection = mockTr.selection;
mockTr.doc = {
resolve: vi.fn(() => $from),
};

const command = splitBlock();
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });

const splitTypes = mockTr.split.mock.calls[0][2];
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBe('BodyText');
});

it('does not mutate source attrs when removing nested override attributes', () => {
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
const parentNode = {
Expand Down
Loading
Loading