feat: let a character style on the item's first portion reach the marker
The charStyle mark carries only a name, so resolving it needs the registry: the plugin takes it as a `sheet` option (like ParagraphStyle), the exporters read their own exportSheet. The style is the base, the portion's own marks stay on top. The decoration pass now also re-runs on FORCE_PAGE_RECALC, so editing a style repaints the markers. The ODF marker style moves to styles.xml as a named style — LibreOffice ignores a level's text:style-name when it resolves to an automatic style, which made the whole ODF leg a no-op (probed both ways).
This commit is contained in:
1 parent
943a885754
commit
a8a4a4c32f
5 files changed
+72
-30
No files matched your search
@@ -115,8 +115,9 @@ export const extensions = [
|
||||
BulletList,
|
||||
OrderedList,
|
||||
ListItem,
|
||||
// Bullet/number weight: it follows the item's first text portion, as in LibreOffice.
|
||||
ListMarker,
|
||||
// Bullet/number formatting: it follows the item's first text portion, as in
|
||||
// LibreOffice (the sheet resolves a character style on that portion).
|
||||
ListMarker.configure({ sheet: styleSheet }),
|
||||
// resizable:false keeps TipTap's columnResizing plugin off; TableView, the two
|
||||
// resize plugins, and ResizableTableRow supply Word-style drag instead. Table
|
||||
// (unlike TableKit) doesn't auto-add children, so ResizableTableRow is listed here.
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { cssFontFamily } from '../../styles/styleSheet';
|
||||
import { cssFontFamily, resolveStyle, type StyleSheet, type TextProps } from '../../styles/styleSheet';
|
||||
import { FORCE_PAGE_RECALC } from './pageBreaks';
|
||||
|
||||
// A list marker's direct formatting. CSS values, since that is what two of the three
|
||||
// consumers want; the DOCX exporter converts. The family is the plain name, not a CSS
|
||||
@@ -18,35 +19,48 @@ export interface MarkerFormat {
|
||||
type MarkLike = { type: string; attrs?: Record<string, unknown> | null };
|
||||
type JsonNode = { type?: string; attrs?: Record<string, unknown> | null; marks?: MarkLike[] | null; content?: JsonNode[] };
|
||||
|
||||
// A named character style on the portion resolves through the registry, which neither
|
||||
// the plugin nor an exporter owns — both hand in the lookup (`charProps` below).
|
||||
export type CharStyleProps = (name: string) => TextProps;
|
||||
|
||||
export const charStyleProps = (sheet: StyleSheet): CharStyleProps =>
|
||||
(name) => resolveStyle(sheet, name, 'character').text;
|
||||
|
||||
// The formatting a marker takes from the item's **first text portion** — the rule
|
||||
// LibreOffice numbering uses (Word takes the paragraph mark instead). Verified by
|
||||
// probe: bold-then-plain gets a bold number, plain-then-bold does not.
|
||||
export function markerFormat(marks: MarkLike[], blockFontSize: unknown): MarkerFormat | null {
|
||||
export function markerFormat(marks: MarkLike[], blockFontSize: unknown, charProps?: CharStyleProps): MarkerFormat | null {
|
||||
const ts = marks.find((m) => m.type === 'textStyle')?.attrs ?? {};
|
||||
const size = ts.fontSize ?? blockFontSize;
|
||||
// The portion's character style is the base; its own marks stay on top (as in applyRuns).
|
||||
const name = marks.find((m) => m.type === 'charStyle')?.attrs?.name;
|
||||
const cs: TextProps = charProps && typeof name === 'string' && name ? charProps(name) : {};
|
||||
const size = ts.fontSize ?? (cs.fontSizePt != null ? `${cs.fontSizePt}pt` : null) ?? blockFontSize;
|
||||
const bold = marks.some((m) => m.type === 'bold') || cs.bold;
|
||||
const format: MarkerFormat = {
|
||||
fontFamily: typeof ts.fontFamily === 'string' && ts.fontFamily ? ts.fontFamily : null,
|
||||
fontFamily: str(ts.fontFamily) ?? cs.fontFamily ?? null,
|
||||
// An explicit fontWeight is the un-bold channel, so it outranks the bold mark.
|
||||
fontWeight: ts.fontWeight != null ? String(ts.fontWeight) : marks.some((m) => m.type === 'bold') ? 'bold' : null,
|
||||
fontStyle: marks.some((m) => m.type === 'italic') ? 'italic' : null,
|
||||
fontSize: typeof size === 'string' && size ? size : null,
|
||||
color: typeof ts.color === 'string' && ts.color ? ts.color : null,
|
||||
fontWeight: ts.fontWeight != null ? String(ts.fontWeight) : bold ? 'bold' : null,
|
||||
fontStyle: marks.some((m) => m.type === 'italic') || cs.italic ? 'italic' : null,
|
||||
fontSize: str(size),
|
||||
color: str(ts.color) ?? cs.color ?? null,
|
||||
};
|
||||
return KEYS.some((key) => format[key]) ? format : null;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
|
||||
|
||||
const KEYS = ['fontFamily', 'fontWeight', 'fontStyle', 'fontSize', 'color'] as const;
|
||||
|
||||
// The format the whole list agrees on (TipTap JSON), else null — a file carries
|
||||
// marker formatting per level, so only a uniform list can carry it. Left out, both
|
||||
// LibreOffice (first portion) and Word (paragraph mark) fall back to their own rule.
|
||||
export function listMarkerFormat(list: JsonNode): MarkerFormat | null {
|
||||
export function listMarkerFormat(list: JsonNode, charProps?: CharStyleProps): MarkerFormat | null {
|
||||
let common: MarkerFormat | null = null;
|
||||
for (const item of list.content ?? []) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
const block = item.content?.[0];
|
||||
const first = block?.content?.[0];
|
||||
const format = block && first?.type === 'text' ? markerFormat(first.marks ?? [], block.attrs?.fontSize) : null;
|
||||
const format = block && first?.type === 'text' ? markerFormat(first.marks ?? [], block.attrs?.fontSize, charProps) : null;
|
||||
if (!format || (common && !sameFormat(common, format))) return null;
|
||||
common = format;
|
||||
}
|
||||
@@ -68,34 +82,42 @@ function markerStyle(format: MarkerFormat | null): string {
|
||||
+ `--marker-color:${format?.color ?? 'initial'}`;
|
||||
}
|
||||
|
||||
export function listMarkerDecos(doc: ProseMirrorNode): DecorationSet {
|
||||
export function listMarkerDecos(doc: ProseMirrorNode, charProps?: CharStyleProps): DecorationSet {
|
||||
const decos: Decoration[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'listItem') return;
|
||||
decos.push(Decoration.node(pos, pos + node.nodeSize, { style: markerStyle(itemMarkerFormat(node)) }));
|
||||
decos.push(Decoration.node(pos, pos + node.nodeSize, { style: markerStyle(itemMarkerFormat(node, charProps)) }));
|
||||
});
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
|
||||
function itemMarkerFormat(item: ProseMirrorNode): MarkerFormat | null {
|
||||
function itemMarkerFormat(item: ProseMirrorNode, charProps?: CharStyleProps): MarkerFormat | null {
|
||||
const block = item.firstChild;
|
||||
const first = block?.firstChild;
|
||||
if (!block || !first?.isText) return null;
|
||||
return markerFormat(first.marks.map((m) => ({ type: m.type.name, attrs: m.attrs })), block.attrs.fontSize);
|
||||
return markerFormat(first.marks.map((m) => ({ type: m.type.name, attrs: m.attrs })), block.attrs.fontSize, charProps);
|
||||
}
|
||||
|
||||
const listMarkerKey = new PluginKey<DecorationSet>('listMarkerFormat');
|
||||
|
||||
export const ListMarker = Extension.create({
|
||||
export const ListMarker = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
name: 'listMarker',
|
||||
|
||||
addOptions() {
|
||||
return { sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const props = () => charStyleProps(this.options.sheet());
|
||||
return [
|
||||
new Plugin({
|
||||
key: listMarkerKey,
|
||||
state: {
|
||||
init: (_, state) => listMarkerDecos(state.doc),
|
||||
apply: (tr, old) => (tr.docChanged ? listMarkerDecos(tr.doc) : old),
|
||||
init: (_, state) => listMarkerDecos(state.doc, props()),
|
||||
// An edited registry can change what a character style means, and that
|
||||
// arrives as FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect).
|
||||
apply: (tr, old) =>
|
||||
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listMarkerDecos(tr.doc, props()) : old,
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops';
|
||||
import { listMarkerFormat } from '../editor/extensions/listMarker';
|
||||
import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker';
|
||||
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
|
||||
@@ -107,7 +107,7 @@ function hexColor(input: string): string | undefined {
|
||||
// The level's number/bullet formatting (w:lvl/w:rPr) — Word carries it per level, so
|
||||
// only a list whose items agree has one (listMarkerFormat).
|
||||
function markerRunProps(node: TiptapNode): Writable<IRunStylePropertiesOptions> | undefined {
|
||||
const format = listMarkerFormat(node);
|
||||
const format = listMarkerFormat(node, charStyleProps(exportSheet));
|
||||
if (!format) return undefined;
|
||||
const props: Writable<IRunStylePropertiesOptions> = {};
|
||||
if (format.fontFamily) props.font = format.fontFamily === SCREEN_FONT ? DOC_FONT : format.fontFamily;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { parseTabStops } from '../editor/extensions/tabStops';
|
||||
import { listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
|
||||
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
|
||||
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
|
||||
@@ -1572,7 +1572,7 @@ function collectListMarkerFormats(doc: TiptapNode, result: (MarkerFormat | null)
|
||||
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
|
||||
const levels: (MarkerFormat | null)[] = [];
|
||||
const visit = (list: TiptapNode, depth: number) => {
|
||||
if (levels[depth - 1] === undefined) levels[depth - 1] = listMarkerFormat(list);
|
||||
if (levels[depth - 1] === undefined) levels[depth - 1] = listMarkerFormat(list, charStyleProps(exportSheet));
|
||||
for (const item of list.content ?? []) {
|
||||
for (const block of item.content ?? []) {
|
||||
if (block.type === 'bulletList' || block.type === 'orderedList') visit(block, depth + 1);
|
||||
@@ -1586,12 +1586,15 @@ function collectListMarkerFormats(doc: TiptapNode, result: (MarkerFormat | null)
|
||||
|
||||
// text:style-name on the level definition points at a style:family="text" style —
|
||||
// how ODF formats a number/bullet (a run inside the paragraph never reaches it).
|
||||
// The style has to be a **named** one in styles.xml: LibreOffice ignores the
|
||||
// reference when it resolves to an automatic style (verified by probe).
|
||||
function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | null)[][]): Uint8Array {
|
||||
if (!formats.some((levels) => levels.some(Boolean))) return odtBytes;
|
||||
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!contentBytes || !stylesBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
const minted: string[] = [];
|
||||
@@ -1626,7 +1629,9 @@ function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | n
|
||||
});
|
||||
|
||||
if (minted.length) {
|
||||
content = content.replace('</office:automatic-styles>', `${minted.join('\n')}\n</office:automatic-styles>`);
|
||||
files['styles.xml'] = strToU8(
|
||||
strFromU8(stylesBytes).replace('</office:styles>', `${minted.join('\n')}\n</office:styles>`),
|
||||
);
|
||||
}
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
|
||||
@@ -9,7 +9,7 @@ import Bold from '@tiptap/extension-bold';
|
||||
import Italic from '@tiptap/extension-italic';
|
||||
import ListItem from '@tiptap/extension-list-item';
|
||||
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
|
||||
import { listMarkerDecos, listMarkerFormat } from '../../src/lib/editor/extensions/listMarker';
|
||||
import { charStyleProps, listMarkerDecos, listMarkerFormat } from '../../src/lib/editor/extensions/listMarker';
|
||||
import { builtinStyleSheet, styleCss } from '../../src/lib/styles/styleSheet';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
@@ -81,6 +81,14 @@ describe('list marker formatting', () => {
|
||||
expect(listMarkerFormat({ type: 'orderedList', content: [bolded, plain] })).toBeNull();
|
||||
});
|
||||
|
||||
// A charStyle mark carries only a name, so the registry has to resolve it — the
|
||||
// caller hands in the lookup (the plugin from its option, the exporters from the sheet).
|
||||
it('resolves a character style on the portion', () => {
|
||||
const list = jsonList(text('x', [{ type: 'charStyle', attrs: { name: 'Strong Emphasis' } }]));
|
||||
expect(listMarkerFormat(list)).toBeNull();
|
||||
expect(listMarkerFormat(list, charStyleProps(builtinStyleSheet()))).toMatchObject({ fontWeight: 'bold' });
|
||||
});
|
||||
|
||||
// The marker inherits the item's font, so the style rule has to reach the item and
|
||||
// not only its paragraph — else an imported document's numbers keep the editor default.
|
||||
it('gives the list item its paragraph style\'s text half', () => {
|
||||
@@ -97,10 +105,13 @@ describe('list marker formatting', () => {
|
||||
} as never;
|
||||
let numbering: string;
|
||||
let content: string;
|
||||
let styles: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
numbering = strFromU8(unzipSync(await buildDocx(fixture))['word/numbering.xml']);
|
||||
content = strFromU8(unzipSync(await buildOdt(fixture))['content.xml']);
|
||||
const odt = unzipSync(await buildOdt(fixture));
|
||||
content = strFromU8(odt['content.xml']);
|
||||
styles = strFromU8(odt['styles.xml']);
|
||||
});
|
||||
|
||||
it('writes the DOCX level run properties (w:lvl/w:rPr)', () => {
|
||||
@@ -112,11 +123,14 @@ describe('list marker formatting', () => {
|
||||
expect(lvl).toMatch(/<w:rFonts[^>]*w:ascii="Arial"/);
|
||||
});
|
||||
|
||||
it('points the ODF level definition at a minted character style', () => {
|
||||
// The style has to land in styles.xml: LibreOffice ignores the level's reference
|
||||
// when it resolves to an automatic style (probed).
|
||||
it('points the ODF level definition at a named character style', () => {
|
||||
expect(content).toMatch(/<text:list-level-style-number text:level="1" text:style-name="MK1"/);
|
||||
expect(content).toContain('<style:style style:name="MK1" style:family="text">');
|
||||
expect(content).toContain('<style:text-properties fo:font-family="Arial" fo:font-weight="bold"'
|
||||
expect(styles).toContain('<style:style style:name="MK1" style:family="text">');
|
||||
expect(styles).toContain('<style:text-properties fo:font-family="Arial" fo:font-weight="bold"'
|
||||
+ ' fo:font-style="italic" fo:font-size="18pt" fo:color="#FF0000"/>');
|
||||
expect(content).not.toContain('style:name="MK1"');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user