feat: dropdown for text styles
This commit is contained in:
1 parent
18a8609926
commit
56f884a412
14 files changed
+267
-61
No files matched your search
@@ -16,11 +16,80 @@
|
||||
|
||||
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
|
||||
|
||||
// Word/LibreOffice-style style gallery. We have no named-style model, so a style
|
||||
// is a preset of direct formatting (node type + block attrs + marks).
|
||||
type ParagraphStyle = {
|
||||
key: string;
|
||||
level?: 1 | 2 | 3 | 4 | 5;
|
||||
fontSize?: string; // block font size (paragraph mark + inherited by its runs)
|
||||
align?: 'center';
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
indent?: number; // cm
|
||||
preview: string; // menu-entry font size
|
||||
};
|
||||
|
||||
const PARAGRAPH_STYLES: ParagraphStyle[] = [
|
||||
{ key: 'default', preview: '0.85rem' },
|
||||
{ key: 'title', fontSize: '28pt', align: 'center', bold: true, preview: '1.35rem' },
|
||||
{ key: 'subtitle', fontSize: '18pt', align: 'center', italic: true, preview: '1.05rem' },
|
||||
{ key: 'h1', level: 1, preview: '1.25rem' },
|
||||
{ key: 'h2', level: 2, preview: '1.1rem' },
|
||||
{ key: 'h3', level: 3, preview: '0.95rem' },
|
||||
{ key: 'h4', level: 4, preview: '0.9rem' },
|
||||
{ key: 'h5', level: 5, preview: '0.85rem' },
|
||||
{ key: 'quote', italic: true, indent: 1.25, preview: '0.85rem' },
|
||||
];
|
||||
|
||||
function styleLabel(key: string): string {
|
||||
const s = t().toolbar.styles;
|
||||
return ({
|
||||
default: s.default, title: s.docTitle, subtitle: s.subtitle, quote: s.quote,
|
||||
h1: t().toolbar.heading1, h2: t().toolbar.heading2, h3: t().toolbar.heading3,
|
||||
h4: t().toolbar.heading4, h5: t().toolbar.heading5,
|
||||
} as Record<string, string>)[key] ?? key;
|
||||
}
|
||||
|
||||
let stylesOpen = $state(false);
|
||||
|
||||
// $derived re-evaluates whenever `tick` changes (i.e. on every TipTap transaction)
|
||||
let isH1 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 1 }));
|
||||
let isH2 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 2 }));
|
||||
let isH3 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 3 }));
|
||||
let isHeading = $derived(isH1 || isH2 || isH3);
|
||||
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
|
||||
|
||||
// The style whose formatting the cursor's block currently carries.
|
||||
let currentStyle = $derived.by<string>(() => {
|
||||
if (tick < 0 || !editor) return 'default';
|
||||
if (editor.isActive('heading')) return `h${editor.getAttributes('heading').level}`;
|
||||
const { fontSize, indent } = editor.getAttributes('paragraph');
|
||||
const sized = PARAGRAPH_STYLES.find((s) => s.fontSize && s.fontSize === fontSize);
|
||||
if (sized) return sized.key;
|
||||
if ((indent ?? 0) >= 1.25 && editor.isActive('italic')) return 'quote';
|
||||
return 'default';
|
||||
});
|
||||
|
||||
// Applies to the whole block (Word applies a style without a selection); the
|
||||
// caret is restored afterwards.
|
||||
function applyStyle(s: ParagraphStyle) {
|
||||
stylesOpen = false;
|
||||
if (!editor) return;
|
||||
// The header/footer schema has no heading node.
|
||||
if (s.level && !editor.schema.nodes.heading) return;
|
||||
const { from, to } = editor.state.selection;
|
||||
const start = editor.state.selection.$from.start();
|
||||
const end = editor.state.selection.$to.end();
|
||||
const type = s.level ? 'heading' : 'paragraph';
|
||||
const chain = editor.chain().focus().setTextSelection({ from: start, to: end })
|
||||
// A style replaces conflicting direct formatting, as in Word/LibreOffice.
|
||||
.unsetBold().unsetItalic().unsetFontSize().unsetFontWeight().removeEmptyTextStyle()
|
||||
.setNode(type, s.level ? { level: s.level } : {})
|
||||
.setBlockFontSize(s.fontSize ?? null)
|
||||
.setTextAlign(s.align ?? 'left')
|
||||
.updateAttributes(type, { indent: s.indent ?? 0 });
|
||||
if (s.bold) chain.setBold();
|
||||
if (s.italic) chain.setItalic();
|
||||
// On an empty block the marks are stored marks — restoring the selection would drop them.
|
||||
if (start !== end) chain.setTextSelection({ from, to });
|
||||
chain.run();
|
||||
}
|
||||
// Header-row cells render bold by default (CSS), like headings — so the Bold button
|
||||
// toggles the fontWeight:'normal' override there too (keeps header bold editable).
|
||||
let inHeaderCell = $derived(tick >= 0 && !!editor && isInHeaderCell(editor.state));
|
||||
@@ -140,27 +209,37 @@
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class:active={isH1}
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
|
||||
title={t().toolbar.heading1}
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class:active={isH2}
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
|
||||
title={t().toolbar.heading2}
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class:active={isH3}
|
||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
|
||||
title={t().toolbar.heading3}
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<div class="ol-picker" use:menuClickOutside={() => (stylesOpen = false)}>
|
||||
<button
|
||||
class="style-button"
|
||||
onclick={() => (stylesOpen = !stylesOpen)}
|
||||
title={t().toolbar.styles.title}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={stylesOpen}
|
||||
>
|
||||
<span class="style-current">{styleLabel(currentStyle)}</span>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if stylesOpen}
|
||||
<div class="ol-dropdown" role="menu">
|
||||
<div class="ol-section-label">{t().toolbar.styles.title}</div>
|
||||
{#each PARAGRAPH_STYLES as s}
|
||||
<button
|
||||
class="ol-option style-option"
|
||||
class:active={currentStyle === s.key}
|
||||
onclick={() => applyStyle(s)}
|
||||
role="menuitemradio"
|
||||
aria-checked={currentStyle === s.key}
|
||||
style="font-size: {s.preview}; font-weight: {s.bold || s.level ? 600 : 400}; font-style: {s.italic ? 'italic' : 'normal'}"
|
||||
>
|
||||
{styleLabel(s.key)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
@@ -356,6 +435,29 @@
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Style gallery: a combobox-style button showing the block's current style. */
|
||||
.style-button {
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
min-width: 8.5rem;
|
||||
padding: 0 0.45rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.style-button:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.style-current {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.style-option {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Ordered-list split button: main toggle + chevron that opens the numbering
|
||||
style menu, rendered as one joined control. */
|
||||
.ol-picker {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
|
||||
import { HEADING_STYLE_OVERRIDES } from '../export/odt';
|
||||
import { listContext } from '../editor/extensions/indent';
|
||||
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
@@ -128,18 +129,29 @@
|
||||
return mixed ? '' : (font ?? DEFAULT_EDITOR_FONT);
|
||||
});
|
||||
|
||||
// Must match the common document default and editor.css heading sizes (in pt)
|
||||
// Must match the common document default; heading sizes come from the export overrides
|
||||
// (the single source editor.css and both importers agree with).
|
||||
const DEFAULT_FONT_SIZE = '12pt';
|
||||
const HEADING_SIZES: Record<number, string> = { 1: '20pt', 2: '16pt', 3: '14pt' };
|
||||
const HEADING_SIZES: Record<number, string> =
|
||||
Object.fromEntries(HEADING_STYLE_OVERRIDES.map((h, i) => [i + 1, h.fontSize]));
|
||||
const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48, 72];
|
||||
|
||||
function effectiveSize(node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record<string, string> }[] }, parent: { type: { name: string }; attrs: Record<string, number> } | null): string {
|
||||
const explicit = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
|
||||
if (explicit) return explicit;
|
||||
if (parent?.type.name === 'heading') return HEADING_SIZES[parent.attrs.level] ?? DEFAULT_FONT_SIZE;
|
||||
type SizedBlock = { type: { name: string }; attrs: Record<string, unknown> } | null;
|
||||
|
||||
// What a block renders its unmarked text at: its paragraph-mark size (blockFontSize,
|
||||
// set by the style gallery and by imported sized lines), else the heading/body default.
|
||||
function blockSize(parent: SizedBlock): string {
|
||||
const block = parent?.attrs?.fontSize;
|
||||
if (typeof block === 'string' && block) return block;
|
||||
if (parent?.type.name === 'heading') return HEADING_SIZES[Number(parent.attrs.level)] ?? DEFAULT_FONT_SIZE;
|
||||
return DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
function effectiveSize(node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record<string, string> }[] }, parent: SizedBlock): string {
|
||||
const explicit = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
|
||||
return explicit || blockSize(parent);
|
||||
}
|
||||
|
||||
let currentFontSize = $derived.by(() => {
|
||||
if (tick < 0 || !editor) return '';
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
@@ -148,9 +160,7 @@
|
||||
const marks = editor.state.storedMarks ?? head.marks();
|
||||
const explicit = marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
|
||||
if (explicit) return explicit;
|
||||
const parent = head.parent;
|
||||
if (parent.type.name === 'heading') return HEADING_SIZES[parent.attrs.level] ?? DEFAULT_FONT_SIZE;
|
||||
return DEFAULT_FONT_SIZE;
|
||||
return blockSize(head.parent as SizedBlock);
|
||||
}
|
||||
let size: string | undefined;
|
||||
let mixed = false;
|
||||
@@ -364,7 +374,16 @@
|
||||
const to = savedTo ?? editor.state.selection.to;
|
||||
savedFrom = null;
|
||||
savedTo = null;
|
||||
editor.chain().focus().setTextSelection({ from, to }).setFontSize(`${size}pt`).run();
|
||||
const chain = editor.chain().focus().setTextSelection({ from, to }).setFontSize(`${size}pt`);
|
||||
// An empty line or a fully covered block also gets its paragraph-mark size, so what
|
||||
// the block renders can't diverge from the shown size (and empty lines keep it).
|
||||
const rFrom = editor.state.doc.resolve(from);
|
||||
const rTo = editor.state.doc.resolve(to);
|
||||
const wholeBlock = from === to
|
||||
? rFrom.parent.content.size === 0
|
||||
: rFrom.parentOffset === 0 && rTo.parentOffset === rTo.parent.content.size;
|
||||
if (wholeBlock) chain.setBlockFontSize(`${size}pt`);
|
||||
chain.run();
|
||||
}
|
||||
|
||||
function onSizeInputFocus(e: FocusEvent) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export const extensions = [
|
||||
BlockFontSize,
|
||||
PageBreak,
|
||||
Indent,
|
||||
Heading.configure({ levels: [1, 2, 3] }),
|
||||
Heading.configure({ levels: [1, 2, 3, 4, 5] }),
|
||||
BulletList,
|
||||
OrderedList,
|
||||
ListItem,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { MAX_HEADING_LEVEL } from '../../export/odt';
|
||||
|
||||
// A generated table of contents: a block atom listing every heading (levels 1–3) with its
|
||||
// A generated table of contents: a block atom listing every heading (levels 1–5) with its
|
||||
// live page number. The node view regenerates entries from the headings + pagination and
|
||||
// caches them in `entries` (persisted like a Word/LO TOC field); round-trips to ODF/DOCX.
|
||||
|
||||
@@ -104,13 +105,13 @@ class TocView {
|
||||
});
|
||||
}
|
||||
|
||||
// Every heading (level ≤ 3) with non-empty text, in document order.
|
||||
// Every heading (level ≤ MAX_HEADING_LEVEL) with non-empty text, in document order.
|
||||
private headings(): HeadingRef[] {
|
||||
const out: HeadingRef[] = [];
|
||||
this.editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
const text = node.textContent.trim();
|
||||
if (text) out.push({ text, level: Math.min(3, (node.attrs.level as number) ?? 1), pos });
|
||||
if (text) out.push({ text, level: Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1), pos });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { normalizeColor, HEADING_STYLE_OVERRIDES, mergeJoinedParagraphsJson, type HfExport } from './odt';
|
||||
import { normalizeColor, HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt';
|
||||
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
|
||||
|
||||
// BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from
|
||||
@@ -407,7 +407,7 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
|
||||
const attrs = node.attrs ?? {};
|
||||
const pPr: string[] = [];
|
||||
if (node.type === 'heading') {
|
||||
const lvl = Math.min(3, Math.max(1, Number(attrs.level) || 1));
|
||||
const lvl = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1));
|
||||
pPr.push(`<w:pStyle w:val="Heading${lvl}"/>`);
|
||||
}
|
||||
if (indentTwip) pPr.push(`<w:ind w:left="${indentTwip}"/>`);
|
||||
@@ -575,6 +575,7 @@ function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
|
||||
|
||||
const HEADING_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3,
|
||||
4: HeadingLevel.HEADING_4, 5: HeadingLevel.HEADING_5,
|
||||
};
|
||||
|
||||
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; forceBold?: boolean };
|
||||
@@ -601,7 +602,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
|
||||
if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent);
|
||||
else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip;
|
||||
}
|
||||
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(3, Math.max(1, Number(attrs.level) || 1))] : undefined;
|
||||
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1))] : undefined;
|
||||
// Paragraph-mark run props carry an empty line's font size (see import/docx.ts).
|
||||
const markSize = typeof attrs.fontSize === 'string' ? fontSizeToHalfPoints(attrs.fontSize) : undefined;
|
||||
return new Paragraph({
|
||||
@@ -768,7 +769,7 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
|
||||
// + link it on field update (features.updateFields does this on open). Title is a
|
||||
// plain bold paragraph so it isn't itself listed; our importer regenerates the node.
|
||||
out.push(new Paragraph({ children: [new TextRun({ text: 'Table of Contents', bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } }));
|
||||
out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: '1-3' }));
|
||||
out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` }));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -841,6 +842,8 @@ function buildStyles(language?: { language: string; country: string } | null) {
|
||||
heading1: headingStyle(HEADING_STYLE_OVERRIDES[0]),
|
||||
heading2: headingStyle(HEADING_STYLE_OVERRIDES[1]),
|
||||
heading3: headingStyle(HEADING_STYLE_OVERRIDES[2]),
|
||||
heading4: headingStyle(HEADING_STYLE_OVERRIDES[3]),
|
||||
heading5: headingStyle(HEADING_STYLE_OVERRIDES[4]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+10
-6
@@ -128,8 +128,14 @@ export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTo
|
||||
{ name: 'Heading_20_1', fontSize: '20pt', marginTop: '1.058cm', marginBottom: '0.353cm' },
|
||||
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.847cm', marginBottom: '0.282cm' },
|
||||
{ name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.741cm', marginBottom: '0.247cm' },
|
||||
{ name: 'Heading_20_4', fontSize: '12pt', marginTop: '0.635cm', marginBottom: '0.212cm' },
|
||||
{ name: 'Heading_20_5', fontSize: '11pt', marginTop: '0.582cm', marginBottom: '0.194cm' },
|
||||
];
|
||||
|
||||
// Highest heading level the editor offers (extensions.ts, both importers, TOC).
|
||||
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
|
||||
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
|
||||
|
||||
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
|
||||
if (!attrs) return false;
|
||||
if (attrs.lineHeight) return true;
|
||||
@@ -464,7 +470,7 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode
|
||||
.filter(e => e && typeof e.text === 'string')
|
||||
.map(e => ({
|
||||
text: String(e.text),
|
||||
level: Math.min(3, Math.max(1, Number(e.level) || 1)),
|
||||
level: Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(e.level) || 1)),
|
||||
page: Math.max(1, Number(e.page) || 1),
|
||||
}));
|
||||
tocs.push({ entries });
|
||||
@@ -2129,9 +2135,9 @@ function tocXml(toc: TocExport, index: number): string {
|
||||
const title = 'Table of Contents';
|
||||
const name = `${title}${index + 1}`;
|
||||
const source =
|
||||
`<text:table-of-content-source text:outline-level="3" text:use-index-marks="false" text:use-index-source-styles="false">` +
|
||||
`<text:table-of-content-source text:outline-level="${MAX_HEADING_LEVEL}" text:use-index-marks="false" text:use-index-source-styles="false">` +
|
||||
`<text:index-title-template text:style-name="Contents_20_Heading">${escapeXml(title)}</text:index-title-template>` +
|
||||
[1, 2, 3]
|
||||
HEADING_LEVELS
|
||||
.map(
|
||||
l =>
|
||||
`<text:table-of-content-entry-template text:outline-level="${l}" text:style-name="Contents_20_${l}">` +
|
||||
@@ -2180,9 +2186,7 @@ function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: numbe
|
||||
const tabPosCm = Math.max(1, Math.round(contentWidthCm * 1000) / 1000);
|
||||
const styles =
|
||||
contentsHeadingStyle() +
|
||||
contentsEntryStyle('Contents_20_1', 1, tabPosCm) +
|
||||
contentsEntryStyle('Contents_20_2', 2, tabPosCm) +
|
||||
contentsEntryStyle('Contents_20_3', 3, tabPosCm);
|
||||
HEADING_LEVELS.map(l => contentsEntryStyle(`Contents_20_${l}`, l, tabPosCm)).join('');
|
||||
content = injectAutomaticStyles(content, styles);
|
||||
|
||||
files['content.xml'] = strToU8(content);
|
||||
|
||||
@@ -189,7 +189,7 @@ function materializeListMarkers(root: HTMLElement): void {
|
||||
const glyph = listMarkerGlyph(li, root);
|
||||
if (!glyph) continue;
|
||||
li.style.listStyleType = 'none';
|
||||
const target = li.querySelector(':scope > p, :scope > h1, :scope > h2, :scope > h3') ?? li;
|
||||
const target = li.querySelector(':scope > p, :scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5') ?? li;
|
||||
const slot = document.createElement('span');
|
||||
slot.style.cssText = 'display:inline-block;width:0;overflow:visible;vertical-align:baseline';
|
||||
const label = document.createElement('span');
|
||||
@@ -402,7 +402,7 @@ function buildBodyHtml(json: Json): string {
|
||||
});
|
||||
// generateHTML omits ProseMirror's trailing <br>, so empty textblocks collapse to zero
|
||||
// height and blank lines vanish in print. Re-add a break so each keeps a line.
|
||||
host.querySelectorAll('p, h1, h2, h3').forEach((el) => {
|
||||
host.querySelectorAll('p, h1, h2, h3, h4, h5').forEach((el) => {
|
||||
if (!el.firstChild) el.appendChild(document.createElement('br'));
|
||||
});
|
||||
return host.innerHTML;
|
||||
|
||||
@@ -41,6 +41,15 @@ const de: Messages = {
|
||||
heading1: 'Überschrift 1',
|
||||
heading2: 'Überschrift 2',
|
||||
heading3: 'Überschrift 3',
|
||||
heading4: 'Überschrift 4',
|
||||
heading5: 'Überschrift 5',
|
||||
styles: {
|
||||
title: 'Formatvorlagen',
|
||||
default: 'Standard',
|
||||
docTitle: 'Titel',
|
||||
subtitle: 'Untertitel',
|
||||
quote: 'Zitat',
|
||||
},
|
||||
bulletList: 'Aufzählung',
|
||||
bulletSymbol: 'Aufzählungszeichen',
|
||||
bulletDefault: 'Standard (nach Ebene)',
|
||||
|
||||
@@ -43,6 +43,16 @@ const en = {
|
||||
heading1: 'Heading 1',
|
||||
heading2: 'Heading 2',
|
||||
heading3: 'Heading 3',
|
||||
heading4: 'Heading 4',
|
||||
heading5: 'Heading 5',
|
||||
// Style gallery (Word/LibreOffice "Styles"): presets of direct formatting.
|
||||
styles: {
|
||||
title: 'Styles',
|
||||
default: 'Default text',
|
||||
docTitle: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
quote: 'Quote',
|
||||
},
|
||||
bulletList: 'Bullet list',
|
||||
bulletSymbol: 'Bullet symbol',
|
||||
bulletDefault: 'Default (by level)',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
|
||||
import { lengthToPt } from './styleResolver';
|
||||
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
|
||||
@@ -530,7 +530,7 @@ function emptyLineFontSize(ppr: Element | null, ctx: Ctx, baseRun: RunProps, lev
|
||||
// files / English Word), then the style's resolved outline level (locale-independent,
|
||||
// catches LibreOffice/localized heading styles), then a direct paragraph outline level.
|
||||
function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null {
|
||||
const clamp = (n: number) => Math.min(3, Math.max(1, n));
|
||||
const clamp = (n: number) => Math.min(MAX_HEADING_LEVEL, Math.max(1, n));
|
||||
if (!ppr) return null;
|
||||
const ps = fc(ppr, 'pStyle');
|
||||
const id = ps ? wVal(ps) : null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
|
||||
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
|
||||
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
|
||||
@@ -588,7 +588,7 @@ function convertToc(el: Element): Node {
|
||||
if (p.namespaceURI !== NS.text || p.localName !== 'p') continue; // skip index-title
|
||||
const style = p.getAttributeNS(NS.text, 'style-name') ?? '';
|
||||
const m = /Contents_20_(\d+)/.exec(style);
|
||||
const level = m ? Math.min(3, Math.max(1, parseInt(m[1], 10))) : 1;
|
||||
const level = m ? Math.min(MAX_HEADING_LEVEL, Math.max(1, parseInt(m[1], 10))) : 1;
|
||||
const { text, page } = tocEntryTextAndPage(p);
|
||||
if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) });
|
||||
}
|
||||
@@ -625,7 +625,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
|
||||
let level = 1;
|
||||
if (isHeading) {
|
||||
const raw = parseInt(el.getAttributeNS(NS.text, 'outline-level') ?? '1', 10);
|
||||
level = Math.min(3, Math.max(1, Number.isFinite(raw) ? raw : 1));
|
||||
level = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number.isFinite(raw) ? raw : 1));
|
||||
}
|
||||
|
||||
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
|
||||
|
||||
+26
-4
@@ -204,6 +204,8 @@
|
||||
}
|
||||
.paper .tiptap .toc-level-2 { padding-left: 0.6cm; }
|
||||
.paper .tiptap .toc-level-3 { padding-left: 1.2cm; }
|
||||
.paper .tiptap .toc-level-4 { padding-left: 1.8cm; }
|
||||
.paper .tiptap .toc-level-5 { padding-left: 2.4cm; }
|
||||
.paper .tiptap .toc-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -379,7 +381,9 @@
|
||||
|
||||
.paper .tiptap h1,
|
||||
.paper .tiptap h2,
|
||||
.paper .tiptap h3 {
|
||||
.paper .tiptap h3,
|
||||
.paper .tiptap h4,
|
||||
.paper .tiptap h5 {
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
margin-top: 1.5em;
|
||||
@@ -399,6 +403,14 @@
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.paper .tiptap h4 {
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
||||
.paper .tiptap h5 {
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.paper .tiptap ul,
|
||||
.paper .tiptap ol {
|
||||
padding-left: 1.5rem;
|
||||
@@ -579,7 +591,9 @@
|
||||
.paper .tiptap p::after,
|
||||
.paper .tiptap h1::after,
|
||||
.paper .tiptap h2::after,
|
||||
.paper .tiptap h3::after {
|
||||
.paper .tiptap h3::after,
|
||||
.paper .tiptap h4::after,
|
||||
.paper .tiptap h5::after {
|
||||
content: '\00A0¶';
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
@@ -598,7 +612,9 @@
|
||||
.paper.show-formatting-marks .tiptap p::after,
|
||||
.paper.show-formatting-marks .tiptap h1::after,
|
||||
.paper.show-formatting-marks .tiptap h2::after,
|
||||
.paper.show-formatting-marks .tiptap h3::after {
|
||||
.paper.show-formatting-marks .tiptap h3::after,
|
||||
.paper.show-formatting-marks .tiptap h4::after,
|
||||
.paper.show-formatting-marks .tiptap h5::after {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -609,6 +625,8 @@
|
||||
.paper .tiptap h1:has(> br.ProseMirror-trailingBreak:only-child),
|
||||
.paper .tiptap h2:has(> br.ProseMirror-trailingBreak:only-child),
|
||||
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak:only-child),
|
||||
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak:only-child),
|
||||
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak:only-child),
|
||||
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
@@ -617,6 +635,8 @@
|
||||
.paper .tiptap h1:has(> br.ProseMirror-trailingBreak:only-child)::after,
|
||||
.paper .tiptap h2:has(> br.ProseMirror-trailingBreak:only-child)::after,
|
||||
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak:only-child)::after,
|
||||
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak:only-child)::after,
|
||||
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak:only-child)::after,
|
||||
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak:only-child)::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -722,7 +742,9 @@
|
||||
.paper.show-formatting-marks .tiptap p::after,
|
||||
.paper.show-formatting-marks .tiptap h1::after,
|
||||
.paper.show-formatting-marks .tiptap h2::after,
|
||||
.paper.show-formatting-marks .tiptap h3::after {
|
||||
.paper.show-formatting-marks .tiptap h3::after,
|
||||
.paper.show-formatting-marks .tiptap h4::after,
|
||||
.paper.show-formatting-marks .tiptap h5::after {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ describe('DOCX export → import round trip', () => {
|
||||
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
|
||||
{ type: 'tableRow', content: [cell('2')] },
|
||||
] },
|
||||
heading(4, 'Fourth'),
|
||||
heading(5, 'Fifth'),
|
||||
],
|
||||
} as any;
|
||||
|
||||
@@ -159,6 +161,13 @@ describe('DOCX export → import round trip', () => {
|
||||
expect(p.attrs.indent).toBe(1);
|
||||
});
|
||||
|
||||
it('round-trips heading levels 4 and 5', () => {
|
||||
const heads = walk(doc, 'heading').filter((h) => h.attrs.level >= 4);
|
||||
expect(heads.map((h) => h.attrs.level)).toEqual([4, 5]);
|
||||
// Their style sizes are the level defaults, so no explicit size mark survives.
|
||||
expect(heads.every((h) => !(h.content![0].marks ?? []).length)).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips an empty line\'s font size (paragraph-mark size)', () => {
|
||||
const empty = doc.content!.find((n) => n.type === 'paragraph' && !n.content && n.attrs?.fontSize);
|
||||
expect(empty).toBeTruthy();
|
||||
|
||||
+28
-1
@@ -1212,7 +1212,7 @@ describe('Leg 5: table of contents (text:table-of-content)', () => {
|
||||
const bytes = await buildOdt(tocDoc, margins, 'portrait');
|
||||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
check('emits <text:table-of-content>', content.includes('<text:table-of-content '), content.slice(0, 200));
|
||||
check('source spans outline-level 3', content.includes('text:outline-level="3"'));
|
||||
check('source spans all heading levels', content.includes('<text:table-of-content-source text:outline-level="5"'));
|
||||
check('mints Contents_20_1 style', content.includes('style:name="Contents_20_1"'));
|
||||
check('cached entry carries a tab + page', /Contents_20_1">Introduction<text:tab\/>1<\/text:p>/.test(content));
|
||||
check('ampersand in entry text is escaped', content.includes('Background & Aims'));
|
||||
@@ -1278,3 +1278,30 @@ describe('Leg 10: date/time fields (text:date / text:time)', () => {
|
||||
(res.content.content ?? [])[0]?.content?.[0]?.text === 'Signed on ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 11: heading levels 4 and 5', () => {
|
||||
const doc: N = {
|
||||
type: 'doc',
|
||||
content: [H({ level: 4 }, T('Fourth')), H({ level: 5 }, T('Fifth'))],
|
||||
};
|
||||
|
||||
it('exports Heading_20_4/5 at the editor sizes and re-imports the levels', async () => {
|
||||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||||
const files = unzipSync(bytes);
|
||||
const content = strFromU8(files['content.xml']);
|
||||
const styles = strFromU8(files['styles.xml']);
|
||||
check('h4 outline level', content.includes('text:outline-level="4"'), content.slice(0, 400));
|
||||
check('h5 outline level', content.includes('text:outline-level="5"'));
|
||||
check('Heading_20_4 sized 12pt',
|
||||
/style:name="Heading_20_4"[\s\S]*?fo:font-size="12pt"/.test(styles));
|
||||
check('Heading_20_5 sized 11pt',
|
||||
/style:name="Heading_20_5"[\s\S]*?fo:font-size="11pt"/.test(styles));
|
||||
|
||||
const blocks = importOdt(bytes).content.content ?? [];
|
||||
check('h4 round-trips', blocks[0]?.type === 'heading' && blocks[0]?.attrs?.level === 4, blocks[0]);
|
||||
check('h5 round-trips', blocks[1]?.type === 'heading' && blocks[1]?.attrs?.level === 5, blocks[1]);
|
||||
// Sizes equal to the level defaults must not land as explicit fontSize marks.
|
||||
check('no explicit size mark on h4', !(blocks[0]?.content?.[0]?.marks ?? []).length, blocks[0]?.content);
|
||||
check('no explicit size mark on h5', !(blocks[1]?.content?.[0]?.marks ?? []).length, blocks[1]?.content);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user