From 2cbc69bfd1c49df42a254567a85b709cc2ddc2a8 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 19 Jul 2026 16:35:54 +0200 Subject: [PATCH] feat: add autocorrect for horizontal lines --- src/lib/editor/extensions/paragraphBox.ts | 26 ++++++++++++++++++++++- tests/paragraph-box.test.ts | 16 ++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/lib/editor/extensions/paragraphBox.ts b/src/lib/editor/extensions/paragraphBox.ts index ea7d0f3..16c1d2b 100644 --- a/src/lib/editor/extensions/paragraphBox.ts +++ b/src/lib/editor/extensions/paragraphBox.ts @@ -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) => { 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) => { 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 = { '-': 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 diff --git a/tests/paragraph-box.test.ts b/tests/paragraph-box.test.ts index e54e5b2..1ce3275 100644 --- a/tests/paragraph-box.test.ts +++ b/tests/paragraph-box.test.ts @@ -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');