Files
edentext/src/lib/editor/extensions/textEffects.ts
T
Steffen Becker b61fadca48 feat: the character effects Word and LO carry beyond bold and italic
Letter case, the underline's shape and colour, a double strikethrough and a
freely raised run all had no home in the model, so every one of them was
dropped on import. They ride the marks now, render in CSS, and round-trip.

odf-kit's run formatting has a field for none of them, so a sentinel folds
them into the run's own automatic style — the route the character styles
already take, and it reaches every path odf-kit serializes.
2026-08-08 10:06:31 +00:00

104 lines
3.6 KiB
TypeScript

import Underline from '@tiptap/extension-underline';
import Strike from '@tiptap/extension-strike';
import { Extension } from '@tiptap/core';
import '@tiptap/extension-text-style';
// The character effects beyond bold/italic that both Word and LibreOffice carry and
// CSS can draw: letter case, a raised or lowered run, and the line styles of the
// underline and strikethrough marks.
export type CapsMode = 'uppercase' | 'lowercase' | 'capitalize' | 'smallCaps';
// ODF style:text-underline-style / Word w:u w:val, in the CSS spelling.
export type LineStyle = 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy';
const CAPS_CSS: Record<CapsMode, string> = {
uppercase: 'text-transform: uppercase',
lowercase: 'text-transform: lowercase',
capitalize: 'text-transform: capitalize',
smallCaps: 'font-variant-caps: small-caps',
};
const isCaps = (v: unknown): v is CapsMode => typeof v === 'string' && v in CAPS_CSS;
const isLineStyle = (v: unknown): v is LineStyle =>
v === 'solid' || v === 'double' || v === 'dotted' || v === 'dashed' || v === 'wavy';
function capsFromStyle(el: HTMLElement): CapsMode | null {
if (el.style.fontVariantCaps === 'small-caps' || el.style.fontVariant === 'small-caps') return 'smallCaps';
const t = el.style.textTransform;
return isCaps(t) ? t : null;
}
// Letter case and vertical offset ride the TextStyle mark, like colour and size:
// they are properties of the run, not lines drawn over it.
export const TextEffects = Extension.create({
name: 'textEffects',
addOptions() {
return { types: ['textStyle'] };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
caps: {
default: null,
parseHTML: element => capsFromStyle(element as HTMLElement),
renderHTML: attributes =>
isCaps(attributes.caps) ? { style: CAPS_CSS[attributes.caps] } : {},
},
// pt above the baseline (negative = below), independent of sub/superscript.
textPosition: {
default: null,
parseHTML: element => {
const v = parseFloat((element as HTMLElement).style.verticalAlign);
return Number.isFinite(v) && v !== 0 ? v : null;
},
renderHTML: attributes =>
typeof attributes.textPosition === 'number' && attributes.textPosition
? { style: `vertical-align: ${attributes.textPosition}pt` }
: {},
},
},
},
];
},
});
// The <u> element draws the line, so its style and colour belong on that mark.
export const UnderlineStyled = Underline.extend({
addAttributes() {
return {
lineStyle: {
default: null,
parseHTML: el => {
const s = (el as HTMLElement).style.textDecorationStyle;
return isLineStyle(s) && s !== 'solid' ? s : null;
},
renderHTML: attrs => (isLineStyle(attrs.lineStyle) ? { style: `text-decoration-style: ${attrs.lineStyle}` } : {}),
},
lineColor: {
default: null,
parseHTML: el => (el as HTMLElement).style.textDecorationColor || null,
renderHTML: attrs => (attrs.lineColor ? { style: `text-decoration-color: ${attrs.lineColor}` } : {}),
},
};
},
});
export const StrikeStyled = Strike.extend({
addAttributes() {
return {
lineStyle: {
default: null,
parseHTML: el => {
const s = (el as HTMLElement).style.textDecorationStyle;
return isLineStyle(s) && s !== 'solid' ? s : null;
},
renderHTML: attrs => (isLineStyle(attrs.lineStyle) ? { style: `text-decoration-style: ${attrs.lineStyle}` } : {}),
},
};
},
});