feat: add a right paragraph indent
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.
This commit is contained in:
1 parent
7c09727cda
commit
67e2a00bf0
12 files changed
+75
-18
No files matched your search
+1
-1
@@ -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**
|
||||
|
||||
@@ -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}
|
||||
|
||||
<div
|
||||
class="indent first"
|
||||
class="indent top"
|
||||
style="left: {px(at({ kind: 'first' }, info.indent + info.indentFirst))}px"
|
||||
onpointerdown={(e) => start(e, { kind: 'first' })}
|
||||
role="presentation"
|
||||
title={t().ruler.firstLineIndent}
|
||||
></div>
|
||||
<div
|
||||
class="indent left"
|
||||
class="indent bottom"
|
||||
style="left: {px(at({ kind: 'indent' }, info.indent))}px"
|
||||
onpointerdown={(e) => start(e, { kind: 'indent' })}
|
||||
role="presentation"
|
||||
title={t().ruler.leftIndent}
|
||||
></div>
|
||||
<div
|
||||
class="indent bottom"
|
||||
style="left: {px(at({ kind: 'indentRight' }, textCm - info.indentRight))}px"
|
||||
onpointerdown={(e) => start(e, { kind: 'indentRight' })}
|
||||
role="presentation"
|
||||
title={t().ruler.rightIndent}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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),
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '<W>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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ const de: Messages = {
|
||||
show: 'Lineal',
|
||||
firstLineIndent: 'Erstzeileneinzug',
|
||||
leftIndent: 'Linker Einzug',
|
||||
rightIndent: 'Rechter Einzug',
|
||||
tabType: {
|
||||
left: 'Linker Tabstopp',
|
||||
center: 'Zentrierter Tabstopp',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user