From 67e2a00bf0c036ab99e1203c499215d3467f816a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 16:34:42 +0000 Subject: [PATCH] feat: add a right paragraph indent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror of the left indent (fo:margin-right, w:ind w:right) with its own marker on the ruler. ODF export rides the PBX sentinel: odf-kit has a paragraph option for the left indent but none for the right one. Clearing direct formatting now resets the first-line and right indents as well — no named style governs either, so both are always direct formatting. --- CHANGELOG.md | 2 +- src/lib/components/Ruler.svelte | 21 +++++++++++++++++---- src/lib/editor/extensions/indent.ts | 17 ++++++++++++++++- src/lib/editor/extensions/paragraphStyle.ts | 7 +++++-- src/lib/editor/extensions/tabStops.ts | 10 +++++++--- src/lib/export/docx.ts | 3 ++- src/lib/export/odt.ts | 12 ++++++++---- src/lib/i18n/locales/de.ts | 1 + src/lib/i18n/locales/en.ts | 1 + src/lib/import/docx.ts | 2 ++ src/lib/import/odt.ts | 2 ++ tests/unit/indent-first.test.ts | 15 +++++++++++++-- 12 files changed, 75 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40fe444..4578a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ - Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols` - Headers & footers: page-number / page-count fields, configurable edge distances, and variants — different first page and different odd & even pages (edit a page's zone directly; blank first/even zones supported). Press Enter to add blank lines that grow a zone into the page while body text reflows so the page break sits above the footer / below the header, never overlapping. Inline images/logos can be inserted in a zone. Round-trips to ODF (`style:header-first`/`-left`, `draw:frame`) and DOCX (`w:titlePg`/`w:evenAndOddHeaders`, `ImageRun`) - Zoom (20–300 %) -- Horizontal ruler above the page: click to place a tab stop of the selected type, drag to move it, drag it off to remove it; two markers set the paragraph's first-line and left indent. Toggle in the extended toolbar +- Horizontal ruler above the page: click to place a tab stop of the selected type, drag to move it, drag it off to remove it; three markers set the paragraph's first-line, left and right indent. Toggle in the extended toolbar - Show formatting marks (spaces, tabs, paragraph marks) **Editing aids** diff --git a/src/lib/components/Ruler.svelte b/src/lib/components/Ruler.svelte index 3831865..4c87caa 100644 --- a/src/lib/components/Ruler.svelte +++ b/src/lib/components/Ruler.svelte @@ -47,6 +47,7 @@ type Drag = | { kind: 'stop'; index: number } | { kind: 'indent' } + | { kind: 'indentRight' } | { kind: 'first' }; let drag = $state<{ target: Drag; cm: number; off: boolean } | null>(null); @@ -80,6 +81,11 @@ } else if (target.kind === 'indent') { // Moves the whole block: the first line keeps its offset from the indent. editor?.chain().focus().setIndent(Math.max(0, cm)).run(); + } else if (target.kind === 'indentRight') { + // Measured from the right text edge inwards, mirroring the left indent. Snapped + // again: the pointer's own snap doesn't survive the subtraction from textCm. + const snapped = Math.round(Math.max(0, textCm - cm) / SNAP_CM) * SNAP_CM; + editor?.chain().focus().setIndentRight(snapped).run(); } else { editor?.chain().focus().setIndentFirst(cm - info.indent).run(); } @@ -148,19 +154,26 @@ {/each}
start(e, { kind: 'first' })} role="presentation" title={t().ruler.firstLineIndent} >
start(e, { kind: 'indent' })} role="presentation" title={t().ruler.leftIndent} >
+
start(e, { kind: 'indentRight' })} + role="presentation" + title={t().ruler.rightIndent} + >
{/if} @@ -294,12 +307,12 @@ cursor: ew-resize; } - .indent.first { + .indent.top { top: 0; border-top: 6px solid var(--color-primary); } - .indent.left { + .indent.bottom { bottom: 0; border-bottom: 6px solid var(--color-primary); } diff --git a/src/lib/editor/extensions/indent.ts b/src/lib/editor/extensions/indent.ts index f27880b..aa64da2 100644 --- a/src/lib/editor/extensions/indent.ts +++ b/src/lib/editor/extensions/indent.ts @@ -90,6 +90,7 @@ declare module '@tiptap/core' { indentMore: () => ReturnType; indentLess: () => ReturnType; setIndent: (cm: number) => ReturnType; + setIndentRight: (cm: number) => ReturnType; setIndentFirst: (cm: number) => ReturnType; indentListMore: () => ReturnType; indentListLess: () => ReturnType; @@ -153,6 +154,19 @@ export const Indent = Extension.create({ }; }, }, + // Right indent in cm (fo:margin-right / w:ind w:right), the mirror of `indent`. + indentRight: { + default: null, + parseHTML: (element: HTMLElement) => parseIndent(element.getAttribute('data-indent-right')), + renderHTML: (attributes: Record) => { + if (attributes.indentRight == null) return {}; + const cm = Number(attributes.indentRight); + return { + 'data-indent-right': String(cm), + style: `margin-right: ${cm}cm`, + }; + }, + }, // First-line indent in cm; negative is a hanging indent. Independent of // `indent`, which is the whole block's left margin. indentFirst: { @@ -179,7 +193,7 @@ export const Indent = Extension.create({ // Rewrite one indent attr on every paragraph/heading in the selection; 0 clears it. // `next` gets the block's own value, so a relative step keeps each block's offset in // a mixed selection. List-item paragraphs are skipped (lists indent by nesting). - const write = (attr: 'indent' | 'indentFirst', next: (current: number) => number) => + const write = (attr: 'indent' | 'indentRight' | 'indentFirst', next: (current: number) => number) => ({ state, tr, dispatch }: CommandProps) => { const { from, to } = state.selection; let changed = false; @@ -222,6 +236,7 @@ export const Indent = Extension.create({ indentLess: () => step(-INDENT_STEP_CM), // Absolute, as the ruler drags them; the buttons above step relatively. setIndent: (cm: number) => write('indent', () => clampIndent(cm)), + setIndentRight: (cm: number) => write('indentRight', () => clampIndent(cm)), setIndentFirst: (cm: number) => write('indentFirst', () => clampFirst(cm)), indentListMore: () => stepList(INDENT_STEP_CM), indentListLess: () => stepList(-INDENT_STEP_CM), diff --git a/src/lib/editor/extensions/paragraphStyle.ts b/src/lib/editor/extensions/paragraphStyle.ts index e321eb4..ff29108 100644 --- a/src/lib/editor/extensions/paragraphStyle.ts +++ b/src/lib/editor/extensions/paragraphStyle.ts @@ -6,9 +6,12 @@ import { DEFAULT_SHORTCUTS } from '../shortcuts'; // Rendered as data-style so the generated document stylesheet (styleCss) can target it; // direct formatting (attrs/marks) still overrides it, as in Word/LibreOffice. -// Block attrs the styles govern — reset by clearDirectFormatting, kept by setParagraphStyle. +// Block attrs reset by clearDirectFormatting and kept by setParagraphStyle. Most are +// style-governed; the first-line and right indents never are, so they are always +// direct formatting. const DIRECT_ATTRS = [ - 'textAlign', 'lineHeight', 'spaceBefore', 'spaceAfter', 'indent', 'fontSize', + 'textAlign', 'lineHeight', 'spaceBefore', 'spaceAfter', 'fontSize', + 'indent', 'indentRight', 'indentFirst', 'backgroundColor', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft', ]; diff --git a/src/lib/editor/extensions/tabStops.ts b/src/lib/editor/extensions/tabStops.ts index 98f8c50..cba44b2 100644 --- a/src/lib/editor/extensions/tabStops.ts +++ b/src/lib/editor/extensions/tabStops.ts @@ -37,17 +37,21 @@ export function formatTabStops(stops: TabStop[]): string | null { return out.length ? out.join(';') : null; } +export type BlockRuler = { stops: TabStop[]; indent: number; indentRight: number; indentFirst: number }; + // The stops of the block holding the cursor, plus the block's own indents (cm) — // everything the ruler needs to draw and edit one paragraph. -export function activeTabStops(state: EditorState): { stops: TabStop[]; indent: number; indentFirst: number } | null { +export function activeTabStops(state: EditorState): BlockRuler | null { const $from = state.selection.$from; for (let d = $from.depth; d >= 0; d--) { const node = $from.node(d); if (!node.isTextblock) continue; + const cm = (v: unknown) => (typeof v === 'number' ? v : 0); return { stops: parseTabStops(node.attrs.tabStops), - indent: typeof node.attrs.indent === 'number' ? node.attrs.indent : 0, - indentFirst: typeof node.attrs.indentFirst === 'number' ? node.attrs.indentFirst : 0, + indent: cm(node.attrs.indent), + indentRight: cm(node.attrs.indentRight), + indentFirst: cm(node.attrs.indentFirst), }; } return null; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index b39502d..7b161a2 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -613,6 +613,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { if (!opts.numbering) { if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent); else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip; + if (typeof attrs.indentRight === 'number' && attrs.indentRight > 0) indent.right = cmToTwip(attrs.indentRight); // Word splits the first-line indent into two exclusive attributes by sign. if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) { if (attrs.indentFirst < 0) indent.hanging = cmToTwip(-attrs.indentFirst); @@ -630,7 +631,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { style, alignment: alignOf(attrs), spacing: spacingOf(attrs), - indent: indent.left != null || indent.hanging != null || indent.firstLine != null ? indent : undefined, + indent: Object.keys(indent).length ? indent : undefined, pageBreakBefore: attrs.breakBefore === 'page' || undefined, widowControl: attrs.widowControl === false ? false : undefined, tabStops: stops.length diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index feba5cf..4c3bf90 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -225,6 +225,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true; if (typeof attrs.indent === 'number' && attrs.indent > 0) return true; if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) return true; + if (typeof attrs.indentRight === 'number' && attrs.indentRight > 0) return true; if (typeof attrs.tabStops === 'string' && attrs.tabStops) return true; if (typeof attrs.backgroundColor === 'string' && attrs.backgroundColor) return true; if (attrs.widowControl === false) return true; @@ -1039,17 +1040,19 @@ function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array { // A top-level paragraph's box spec for the PBX sentinel: bg|borderTop|Right|Bottom|Left, // each the raw value (canonical border 'pt solid #RRGGBB' is a valid fo:border) or '', -// plus a widow-control flag. '' overall when the paragraph needs none of them. +// then a widow flag and the right indent. '' overall when the paragraph needs none. function paraBoxSpec(attrs: TiptapNode['attrs']): string { const s = paraStyleFromAttrs(attrs); const noWidow = attrs?.widowControl === false; - if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow) return ''; + // odf-kit has a paragraph option for the left indent but none for the right one. + const right = typeof attrs?.indentRight === 'number' && attrs.indentRight > 0 ? attrs.indentRight : 0; + if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right) return ''; return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft] - .map((v) => v ?? '').concat(noWidow ? 'w0' : '').join('|'); + .map((v) => v ?? '').concat(noWidow ? 'w0' : '', right ? `${right}cm` : '').join('|'); } function boxSpecToProps(spec: string): string { - const [bg, bt, br, bb, bl, widow] = spec.split('|'); + const [bg, bt, br, bb, bl, widow, marginRight] = spec.split('|'); const props: string[] = []; if (bg) props.push(`fo:background-color="${bg}"`); if (bt) props.push(`fo:border-top="${bt}"`); @@ -1058,6 +1061,7 @@ function boxSpecToProps(spec: string): string { if (bl) props.push(`fo:border-left="${bl}"`); // LibreOffice writes 0/0 for "off"; absent means the XSL-FO default of 2. if (widow === 'w0') props.push('fo:orphans="0"', 'fo:widows="0"'); + if (marginRight) props.push(`fo:margin-right="${marginRight}"`); return props.join(' '); } diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 4e25a60..d3be9b4 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -240,6 +240,7 @@ const de: Messages = { show: 'Lineal', firstLineIndent: 'Erstzeileneinzug', leftIndent: 'Linker Einzug', + rightIndent: 'Rechter Einzug', tabType: { left: 'Linker Tabstopp', center: 'Zentrierter Tabstopp', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 6283f1c..3e4ac74 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -244,6 +244,7 @@ const en = { show: 'Ruler', firstLineIndent: 'First line indent', leftIndent: 'Left indent', + rightIndent: 'Right indent', tabType: { left: 'Left tab', center: 'Centre tab', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 19013db..d2af5ad 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -777,6 +777,8 @@ function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | const ind = fc(ppr, 'ind'); const left = ind ? intAttr(ind, W, 'left') ?? intAttr(ind, W, 'start') : null; if (left != null) { const cm = round2(twipToCm(left)); if (cm > LIST_INDENT_EPS_CM) attrs.indent = cm; } + const right = ind ? intAttr(ind, W, 'right') ?? intAttr(ind, W, 'end') : null; + if (right != null) { const cm = round2(twipToCm(right)); if (cm > LIST_INDENT_EPS_CM) attrs.indentRight = cm; } // w:hanging outdents the first line, w:firstLine indents it; they are exclusive. const hanging = ind ? intAttr(ind, W, 'hanging') : null; const firstLine = ind ? intAttr(ind, W, 'firstLine') : null; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 2260c04..ae75943 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -937,6 +937,8 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefau // fo:text-indent is the first line only; negative is a hanging indent. const ti = lengthToCm(paraProps['fo:text-indent']); if (ti != null && Math.abs(ti) > 0.02) attrs.indentFirst = Math.round(ti * 100) / 100; + const mr = lengthToCm(paraProps['fo:margin-right']); + if (mr != null && mr > 0.02) attrs.indentRight = Math.round(mr * 100) / 100; } // Manual page break (fo:break-before). Honored for top-level blocks only diff --git a/tests/unit/indent-first.test.ts b/tests/unit/indent-first.test.ts index 5a5bad9..2daa2a7 100644 --- a/tests/unit/indent-first.test.ts +++ b/tests/unit/indent-first.test.ts @@ -1,5 +1,6 @@ -// First-line indent: Word splits it into w:firstLine / w:hanging by sign, ODF keeps -// one signed fo:text-indent. jsdom (vitest `environment`) supplies the global DOMParser. +// Paragraph indents: the first line (split into w:firstLine / w:hanging by sign in +// OOXML, one signed fo:text-indent in ODF) and the right indent. jsdom (vitest +// `environment`) supplies the global DOMParser. import { describe, it, expect } from 'vitest'; import { unzipSync, strFromU8 } from 'fflate'; import { buildOdt } from '../../src/lib/export/odt'; @@ -18,21 +19,28 @@ const doc: N = { P('plain'), P('hanging', { indent: 2.5, indentFirst: -2.5 }), P('first line in', { indentFirst: 1.25 }), + P('narrowed', { indent: 1, indentRight: 3 }), ], }; const margins = { top: 2, bottom: 2, left: 2, right: 2 }; const firstOf = (d: N, i: number) => d.content[i].attrs?.indentFirst ?? null; +const rightOf = (d: N, i: number) => d.content[i].attrs?.indentRight ?? null; describe('first-line indent', () => { it('round-trips through ODF', async () => { const bytes = await buildOdt(doc, margins, 'portrait'); expect(strFromU8(unzipSync(bytes)['content.xml'])).toMatch(/fo:text-indent="-2\.5cm"/); + // odf-kit has no right-indent option, so it rides the PBX sentinel's minted style. + expect(strFromU8(unzipSync(bytes)['content.xml'])).toMatch(/fo:margin-right="3cm"/); + const back = importOdt(bytes).content as N; expect(firstOf(back, 0)).toBe(null); expect(firstOf(back, 1)).toBe(-2.5); expect(firstOf(back, 2)).toBe(1.25); + expect(rightOf(back, 3)).toBe(3); + expect(rightOf(back, 0)).toBe(null); }); it('round-trips through DOCX', async () => { @@ -40,10 +48,13 @@ describe('first-line indent', () => { const xml = strFromU8(unzipSync(bytes)['word/document.xml']); expect(xml).toMatch(/w:hanging="1417"/); expect(xml).toMatch(/w:firstLine="709"/); + expect(xml).toMatch(/w:right="1701"/); const back = importDocx(bytes).content as N; expect(firstOf(back, 0)).toBe(null); expect(firstOf(back, 1)).toBe(-2.5); expect(firstOf(back, 2)).toBe(1.25); + expect(rightOf(back, 3)).toBe(3); + expect(rightOf(back, 0)).toBe(null); }); });