Files
edentext/src/lib/editor/extensions/pageBreak.ts
T
Steffen Becker 4afd5cd1d5 feat: a paragraph can opt out of automatic hyphenation
LibreOffice's Format > Paragraph > Text Flow checkbox. Only "off"
travels (Word has no per-paragraph "on") and only under a document that
hyphenates, or it would say what is already true. ODF writes
fo:hyphenate="false" into the paragraph's text properties, DOCX gains
w:suppressAutoHyphens through a post-pack pass — the docx package
exposes no option for it. Both survive a LibreOffice re-save.
2026-08-15 01:09:55 +02:00

159 lines
6.4 KiB
TypeScript

import { Extension } from '@tiptap/core';
import { DEFAULT_SHORTCUTS } from '../shortcuts';
// The text-flow attrs of a paragraph/heading, null = default: breakBefore 'page', widow
// control, keepNext (a heading has it anyway, so the attr marks the other blocks),
// keepLines, and sectionBreak — which opens a section (storage/headerFooter.ts).
declare module '@tiptap/core' {
interface Commands<ReturnType> {
pageBreak: {
setPageBreakBefore: () => ReturnType;
unsetPageBreakBefore: () => ReturnType;
togglePageBreakBefore: () => ReturnType;
insertPageBreak: () => ReturnType;
};
}
}
export const PageBreak = Extension.create({
name: 'pageBreak',
addOptions() {
return {
types: ['paragraph', 'heading'] as string[],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
breakBefore: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-page-break-before') === 'page' ? 'page' : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.breakBefore !== 'page') return {};
return { 'data-page-break-before': 'page' };
},
},
widowControl: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-widow-control') === 'false' ? false : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.widowControl !== false) return {};
return { 'data-widow-control': 'false' };
},
},
keepNext: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-keep-next') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.keepNext !== true) return {};
return { 'data-keep-next': 'true' };
},
},
keepLines: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-keep-lines') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.keepLines !== true) return {};
return { 'data-keep-lines': 'true' };
},
},
// LibreOffice's Text Flow ▸ Hyphenation off for this paragraph, Word's
// w:suppressAutoHyphens. Only "off" travels: Word cannot turn hyphenation
// *on* for one paragraph, so the document switch is the only way in.
noHyphenation: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-no-hyphenation') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.noHyphenation === true ? { 'data-no-hyphenation': 'true' } : {},
},
// First block of a new section (w:sectPr, ODF style:master-page-name): what
// gives it its own header/footer. Ordinal, so editing can't desync an index.
sectionBreak: {
default: null,
keepOnSplit: false,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-section-break') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) => {
if (attributes.sectionBreak !== true) return {};
return { 'data-section-break': 'true' };
},
},
},
},
{
types: ['table'],
attributes: {
// ODF style:may-break-between-rows="false": no page break falls between two
// of the table's rows, so one too tall for the space left moves whole. A
// table taller than a page still breaks — the rule is then unsatisfiable.
keepRows: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-keep-rows') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.keepRows === true ? { 'data-keep-rows': 'true' } : {},
},
// ODF <table:table-header-rows>, Word w:trPr/w:tblHeader: the first row is
// repeated at the top of every page the table continues on. The structural
// header, as against the styling one in tableHeaderRow.ts.
repeatHeader: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-repeat-header') === 'true' ? true : null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.repeatHeader === true ? { 'data-repeat-header': 'true' } : {},
},
},
},
];
},
addCommands() {
return {
setPageBreakBefore: () => ({ commands }) =>
this.options.types
.map((type) => commands.updateAttributes(type, { breakBefore: 'page' }))
.some((r) => r),
unsetPageBreakBefore: () => ({ commands }) =>
this.options.types
.map((type) => commands.updateAttributes(type, { breakBefore: null }))
.some((r) => r),
togglePageBreakBefore: () => ({ editor, commands }) => {
const current =
editor.getAttributes('paragraph').breakBefore ?? editor.getAttributes('heading').breakBefore;
const next = current === 'page' ? null : 'page';
return this.options.types
.map((type) => commands.updateAttributes(type, { breakBefore: next }))
.some((r) => r);
},
// Ctrl+Enter: start a new page at the cursor. Splits the block (unless already at its
// start) and marks the following block. Top-level blocks only — breakBefore is ignored
// inside lists/table cells, so there it does nothing.
insertPageBreak: () => ({ state, chain }) => {
const { $from, empty } = state.selection;
if ($from.depth !== 1) return false;
const atBlockStart = empty && $from.parentOffset === 0;
const c = chain();
if (!atBlockStart) c.splitBlock();
return c.setPageBreakBefore().run();
},
};
},
addKeyboardShortcuts() {
return {
[DEFAULT_SHORTCUTS.pageBreak]: () => this.editor.commands.insertPageBreak(),
};
},
});