feat: add autocorrect for horizontal lines

This commit is contained in:
Steffen Becker committed 2026-07-19 16:35:54 +02:00
1 parent f1ba14c948
commit 2cbc69bfd1
2 files changed
+41 -1

No files matched your search

+25 -1
View File
@@ -1,4 +1,4 @@
import { Extension } from '@tiptap/core';
import { Extension, InputRule } from '@tiptap/core';
import type { CommandProps } from '@tiptap/core';
import type { EditorState } from '@tiptap/pm/state';
import type { Node as PMNode } from '@tiptap/pm/model';
@@ -77,6 +77,8 @@ export const ParagraphBox = Extension.create({
attributes: {
backgroundColor: {
default: null,
// Decorative box shouldn't cascade onto the next paragraph on Enter.
keepOnSplit: false,
parseHTML: (element: HTMLElement) => element.getAttribute('data-bg') || null,
renderHTML: (attributes: Record<string, unknown>) => {
const c = attributes.backgroundColor as string | null;
@@ -89,6 +91,7 @@ export const ParagraphBox = Extension.create({
side,
{
default: null,
keepOnSplit: false,
parseHTML: (element: HTMLElement) => element.getAttribute(SIDE_META[side].data),
renderHTML: (attributes: Record<string, unknown>) => {
const v = attributes[side] as string | null;
@@ -138,6 +141,27 @@ export const ParagraphBox = Extension.create({
},
};
},
// Word/LibreOffice "border lines" AutoCorrect: a paragraph whose only text is 3+ of
// -, _ or = becomes an empty bottom-rule line (thin / thick / thick — no double style).
addInputRules() {
const types = this.options.types;
const WIDTH_PT: Record<string, number> = { '-': 0.5, _: 1.5, '=': 1.5 };
return [
new InputRule({
find: /^(-{3,}|_{3,}|={3,})$/,
handler: ({ state, range, match }) => {
const value = `${WIDTH_PT[match[1][0]] ?? 0.5}pt solid #000000`;
const $start = state.doc.resolve(range.from);
const node = $start.parent;
if (!types.includes(node.type.name)) return null;
const paraPos = $start.before();
state.tr.delete(range.from, range.to);
state.tr.setNodeMarkup(paraPos, undefined, { ...node.attrs, borderBottom: value });
},
}),
];
},
});
// Word-like active states for the paragraph border picker: a preset is active when every
+16
View File
@@ -70,6 +70,22 @@ describe('paragraph background + borders round trip', () => {
expect(hfIsEmpty({ type: 'doc', content: [{ type: 'paragraph' }] } as any)).toBe(true);
});
it('round-trips an empty bottom-rule paragraph (the --- autocorrect output)', async () => {
const doc: N = { type: 'doc', content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'above' }] },
{ type: 'paragraph', attrs: { borderBottom: '0.5pt solid #000000' } },
{ type: 'paragraph', content: [{ type: 'text', text: 'below' }] },
]};
const findRule = (d: any) => d.content.find((n: any) => n.type === 'paragraph' && !n.content?.length && n.attrs?.borderBottom);
const odtBack: any = importOdt(await buildOdt(doc as any, DEFAULT_MARGINS, 'portrait'));
const docxBack: any = importDocx(await buildDocx(doc as any, DEFAULT_MARGINS, 'portrait'));
for (const back of [odtBack.content, docxBack.content]) {
const rule = findRule(back);
expect(rule, 'empty bottom-rule paragraph survives').toBeTruthy();
expect(rule.attrs.borderBottom).toMatch(/pt solid #000000/i);
}
});
it('drops background/borders that are absent (no accretion)', async () => {
const plain: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] };
const odt = await buildOdt(plain as any, DEFAULT_MARGINS, 'portrait');