From bb3c2769bfb529ad94e900e9841c4a02a854d5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 12 Aug 2026 10:22:08 +0200 Subject: [PATCH 1/3] feat(web): add fontSize and font Family to customStyle --- .../screenshots/custom-style-fonts-visual.png | Bin 0 -> 17764 bytes .playwright/tests/customStyleFonts.spec.ts | 330 ++++++++++++++++++ apps/example-web/src/components/Toolbar.css | 47 +++ apps/example-web/src/components/Toolbar.tsx | 149 +++++++- src/web/EnrichedTextInput.tsx | 6 + src/web/formats/EnrichedCustomStyle.ts | 168 +++++++-- src/web/normalization/htmlNormalizer.ts | 4 + src/web/useOnChangeState.ts | 6 +- 8 files changed, 681 insertions(+), 29 deletions(-) create mode 100644 .playwright/screenshots/custom-style-fonts-visual.png create mode 100644 .playwright/tests/customStyleFonts.spec.ts diff --git a/.playwright/screenshots/custom-style-fonts-visual.png b/.playwright/screenshots/custom-style-fonts-visual.png new file mode 100644 index 0000000000000000000000000000000000000000..60949a5dbceb6e31d50fade0baf3d4280413fe83 GIT binary patch literal 17764 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 literal 0 HcmV?d00001 diff --git a/.playwright/tests/customStyleFonts.spec.ts b/.playwright/tests/customStyleFonts.spec.ts new file mode 100644 index 000000000..4b4afc701 --- /dev/null +++ b/.playwright/tests/customStyleFonts.spec.ts @@ -0,0 +1,330 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { + editorLocator, + focusEnrichedEditable, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; +import { toolbarButton } from '../helpers/toolbar'; + +function fontSizeButton(page: Page) { + return page.locator('[data-testid="toolbar-font-size"]'); +} + +function fontFamilyButton(page: Page) { + return page.locator('[data-testid="toolbar-font-family"]'); +} + +function fontSizeOption(page: Page, size: number) { + return page.locator(`[data-testid="font-size-${size}"]`); +} + +function fontFamilyOption(page: Page, value: string) { + return page.locator(`[data-testid="font-family-${value}"]`); +} + +function fontSizeClear(page: Page) { + return page.locator('[data-testid="font-size-clear"]'); +} + +function fontFamilyClear(page: Page) { + return page.locator('[data-testid="font-family-clear"]'); +} + +async function applyFontSize(page: Page, size: number) { + await fontSizeButton(page).click(); + await fontSizeOption(page, size).click(); +} + +async function clearFontSize(page: Page) { + await fontSizeButton(page).click(); + await fontSizeClear(page).click(); +} + +async function applyFontFamily(page: Page, value: string) { + await fontFamilyButton(page).click(); + await fontFamilyOption(page, value).click(); +} + +async function clearFontFamily(page: Page) { + await fontFamilyButton(page).click(); + await fontFamilyClear(page).click(); +} + +const ROUND_TRIP_CASES: { name: string; input: string; expected: string }[] = [ + { + name: 'font size only', + input: + '

Sized text

', + expected: + '

Sized text

', + }, + { + name: 'font family only', + input: + '

Courier text

', + expected: + '

Courier text

', + }, + { + name: 'font family with a comma-separated fallback is reduced to first family', + input: + '

Courier text

', + expected: + '

Courier text

', + }, + { + name: 'font size and font family together', + input: + '

Both

', + expected: + '

Both

', + }, + { + name: 'font size and color together', + input: + '

Red big

', + expected: + '

Red big

', + }, + { + name: 'font size inside heading', + input: + '
Big heading
', + expected: + '
Big heading
', + }, + { + name: 'font size wraps bold mark', + input: + '

Bold big

', + expected: + '

Bold big

', + }, + { + name: 'multiple sized spans in one paragraph', + input: + '

Small plain Big

', + expected: + '

Small plain Big

', + }, +]; + +test.describe('custom style fonts - HTML serialization', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + for (const { name, input, expected } of ROUND_TRIP_CASES) { + test(name, async ({ page }) => { + await setEditorHtml(page, input); + await expect.poll(async () => getSerializedHtml(page)).toBe(expected); + }); + } +}); + +test('custom style fonts visual regression', async ({ page }) => { + await gotoVisualRegression(page); + + const html = [ + '', + '

Regular family

', + '

24px plain

', + '

Sans family

', + '

32 Courier

', + '

Bold 20px ', + 'Italic family ', + 'Code mono

', + '
H5 40px
', + '

Quote mono family

', + '', + ].join(''); + + await setEditorHtml(page, html); + + const editor = editorLocator(page); + await expect(editor).toHaveScreenshot('custom-style-fonts-visual.png'); +}); + +test.describe('custom style fonts - toolbar interaction', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('apply font size then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + await editor.pressSequentially('Sized text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Sized text

' + ); + }); + + test('clear font size stops sizing new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + await editor.pressSequentially('Sized', { delay: 80 }); + await clearFontSize(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Sized plain

' + ); + }); + + test('apply font family then type text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Serif text', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Serif text

' + ); + }); + + test('clear font family stops styling new text', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Serif', { delay: 80 }); + await clearFontFamily(page); + await editor.pressSequentially(' plain', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Serif plain

' + ); + }); + + test('apply font size and font family together', async ({ page }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 24); + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Big serif', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Big serif

' + ); + }); + + test('font size with bold', async ({ page }) => { + const editor = editorLocator(page); + const boldBtn = toolbarButton(page, 'bold'); + await editor.click(); + + await boldBtn.click(); + await applyFontSize(page, 32); + await editor.pressSequentially('Bold big', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Bold big

' + ); + }); + + test('font family with italic', async ({ page }) => { + const editor = editorLocator(page); + const italicBtn = toolbarButton(page, 'italic'); + await editor.click(); + + await italicBtn.click(); + await applyFontFamily(page, 'Georgia'); + await editor.pressSequentially('Italic serif', { delay: 80 }); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + '

Italic serif

' + ); + }); + + test('changing font size on a selection preserves per-run font family', async ({ + page, + }) => { + // Two runs with different families, then re-size the whole line and ensure + // each run keeps its own family (per-range merge, not a flattened span). + await setEditorHtml( + page, + [ + '

', + 'One', + 'Two', + '

', + ].join('') + ); + + await focusEnrichedEditable(page); + await page.keyboard.press('ControlOrMeta+a'); + await applyFontSize(page, 20); + + await expect + .poll(async () => getSerializedHtml(page)) + .toBe( + [ + '

', + 'One', + 'Two', + '

', + ].join('') + ); + }); + + test('toolbar font-size button shows active size when set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontSize(page, 20); + + await expect(fontSizeButton(page)).toContainText('20'); + await expect(fontSizeButton(page)).toHaveClass(/toolbar-btn--active/); + + // Re-open the picker – the chosen size should be marked as active + await fontSizeButton(page).click(); + await expect(fontSizeOption(page, 20)).toHaveClass( + /toolbar-font-option--active/ + ); + }); + + test('toolbar font-family button shows active family when set', async ({ + page, + }) => { + const editor = editorLocator(page); + await editor.click(); + + await applyFontFamily(page, 'Georgia'); + + // The button shows the family's label ("Serif" maps to "Georgia"). + await expect(fontFamilyButton(page)).toContainText('Serif'); + await expect(fontFamilyButton(page)).toHaveClass(/toolbar-btn--active/); + + // Re-open the picker – the chosen family should be marked as active + await fontFamilyButton(page).click(); + await expect(fontFamilyOption(page, 'Georgia')).toHaveClass( + /toolbar-font-option--active/ + ); + }); +}); diff --git a/apps/example-web/src/components/Toolbar.css b/apps/example-web/src/components/Toolbar.css index 2186d24d8..77bb06a03 100644 --- a/apps/example-web/src/components/Toolbar.css +++ b/apps/example-web/src/components/Toolbar.css @@ -139,3 +139,50 @@ .toolbar-color-swatch:focus-visible { outline: none; } + +.toolbar-font-picker { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + padding: 8px; + gap: 8px; + background: rgba(0, 26, 114, 0.95); + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +.toolbar-font-option { + min-width: 36px; + height: 28px; + padding: 0 8px; + border: 2px solid transparent; + border-radius: 14px; + background: rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 14px; + font-weight: 600; + line-height: 1; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.toolbar-font-option--clear { + width: 28px; + min-width: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-color: rgba(255, 255, 255, 0.4); + background: transparent; +} + +.toolbar-font-option--active { + background: rgb(0, 26, 114); + border-color: #fff; +} + +.toolbar-font-option:focus-visible { + outline: none; +} diff --git a/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index 26c979aa1..7fc8dbef5 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -23,7 +23,22 @@ const COLORS = [ '#ADD8E6', ]; -type OpenPicker = 'text-color' | 'bg-color' | null; +const FONT_SIZES = [12, 16, 20, 24, 28, 32, 36, 40]; + +const FONT_FAMILIES = [ + { label: 'Sans', value: 'Arial' }, + { label: 'Serif', value: 'Georgia' }, + { label: 'Mono', value: 'Courier New' }, + { label: 'System', value: 'system-ui' }, + { label: 'Cursive', value: 'cursive' }, +] as const; + +type OpenPicker = + | 'text-color' + | 'bg-color' + | 'font-size' + | 'font-family' + | null; interface ToolbarProps { editorRef: RefObject; @@ -84,6 +99,11 @@ export function Toolbar({ const activeFgColor = s?.customStyle.foregroundColor ?? ''; const activeBgColor = s?.customStyle.backgroundColor ?? ''; + const activeFontSize = s?.customStyle.fontSize ?? 0; + const activeFontFamily = s?.customStyle.fontFamily ?? ''; + const activeFontFamilyLabel = + FONT_FAMILIES.find((family) => family.value === activeFontFamily)?.label ?? + ''; const handleSelectFgColor = (color: string) => { editorRef.current?.setStyle({ foregroundColor: color }); @@ -101,6 +121,22 @@ export function Toolbar({ editorRef.current?.setStyle({ backgroundColor: null }); setOpenPicker(null); }; + const handleSelectFontSize = (size: number) => { + editorRef.current?.setStyle({ fontSize: size }); + setOpenPicker(null); + }; + const handleClearFontSize = () => { + editorRef.current?.setStyle({ fontSize: null }); + setOpenPicker(null); + }; + const handleSelectFontFamily = (family: string) => { + editorRef.current?.setStyle({ fontFamily: family }); + setOpenPicker(null); + }; + const handleClearFontFamily = () => { + editorRef.current?.setStyle({ fontFamily: null }); + setOpenPicker(null); + }; const toolbarItems = [ { @@ -332,10 +368,52 @@ export function Toolbar({ }} /> + + - {openPicker !== null && ( + {(openPicker === 'text-color' || openPicker === 'bg-color') && (
+ {FONT_SIZES.map((size) => ( + + ))} +
+ )} + {openPicker === 'font-family' && ( +
+ + {FONT_FAMILIES.map(({ label, value }) => ( + + ))} +
+ )} ); } diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 0fea130e2..6ac51e9ee 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -425,6 +425,12 @@ export const EnrichedTextInput = ({ ...('backgroundColor' in customStyle && { backgroundColor: normalizeColorValue(customStyle.backgroundColor), }), + ...('fontSize' in customStyle && { + fontSize: customStyle.fontSize, + }), + ...('fontFamily' in customStyle && { + fontFamily: customStyle.fontFamily, + }), }) ); }, diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index 98a71c1fe..e526f6e03 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -1,12 +1,40 @@ import { Mark } from '@tiptap/core'; +type CustomStyleAttrs = { + foregroundColor?: string | null; + backgroundColor?: string | null; + fontSize?: number | null; + fontFamily?: string | null; +}; + +function normalizeFontFamily(value: string | null | undefined): string | null { + if (!value) return null; + + let fontFamily = value.trim(); + const commaIndex = fontFamily.indexOf(','); + if (commaIndex !== -1) { + fontFamily = fontFamily.slice(0, commaIndex).trim(); + } + + if ( + (fontFamily.startsWith("'") && fontFamily.endsWith("'")) || + (fontFamily.startsWith('"') && fontFamily.endsWith('"')) + ) { + fontFamily = fontFamily.slice(1, -1); + } + + return fontFamily.length > 0 ? fontFamily : null; +} + +function resolveFontSize(value: number | null | undefined): number | null { + if (value == null || value <= 0) return null; + return value; +} + declare module '@tiptap/core' { interface Commands { customStyle: { - setCustomStyle: (attrs: { - foregroundColor?: string | null; - backgroundColor?: string | null; - }) => ReturnType; + setCustomStyle: (attrs: CustomStyleAttrs) => ReturnType; }; } } @@ -28,6 +56,19 @@ export const EnrichedCustomStyle = Mark.create({ default: null, parseHTML: (el: HTMLElement) => el.style.backgroundColor || null, }, + fontSize: { + default: null, + parseHTML: (el: HTMLElement) => { + const raw = el.style.fontSize; + const n = parseFloat(raw); + return raw && !Number.isNaN(n) && n > 0 ? n : null; + }, + }, + fontFamily: { + default: null, + parseHTML: (el: HTMLElement) => + normalizeFontFamily(el.style.fontFamily), + }, }; }, @@ -36,7 +77,12 @@ export const EnrichedCustomStyle = Mark.create({ { tag: 'span', getAttrs: (el: HTMLElement) => { - if (!el.style.color && !el.style.backgroundColor) { + if ( + !el.style.color && + !el.style.backgroundColor && + !el.style.fontSize && + !el.style.fontFamily + ) { return false; } // let addAttributes handle the actual parsing @@ -54,6 +100,18 @@ export const EnrichedCustomStyle = Mark.create({ if (mark.attrs.backgroundColor) { parts.push(`background-color: ${mark.attrs.backgroundColor}`); } + if (mark.attrs.fontSize) { + parts.push(`font-size: ${mark.attrs.fontSize}px`); + } + if (mark.attrs.fontFamily) { + const fontFamily = mark.attrs.fontFamily as string; + // if the font family contains a space, wrap it in quotes + parts.push( + /\s/.test(fontFamily) + ? `font-family: '${fontFamily}'` + : `font-family: ${fontFamily}` + ); + } return ['span', { style: parts.join('; ') }, 0]; }, @@ -88,27 +146,89 @@ export const EnrichedCustomStyle = Mark.create({ return { setCustomStyle: (attrs) => - ({ chain, editor }) => { - const current = editor.getAttributes('customStyle'); - const resolvedColor = - 'foregroundColor' in attrs - ? attrs.foregroundColor - : current.foregroundColor; - const resolvedBg = - 'backgroundColor' in attrs - ? attrs.backgroundColor - : current.backgroundColor; - - if (!resolvedColor && !resolvedBg) { - return chain().unsetMark('customStyle').run(); + ({ state, tr, dispatch }) => { + const markType = state.schema.marks.customStyle; + if (!markType) return false; + + // Only the fields explicitly present in `attrs` should override. + // Everything else must be preserved per existing inline run, so a + // selection spanning multiple fonts/colors keeps its differences. + const patch: CustomStyleAttrs = {}; + if ('foregroundColor' in attrs) { + patch.foregroundColor = attrs.foregroundColor ?? null; + } + if ('backgroundColor' in attrs) { + patch.backgroundColor = attrs.backgroundColor ?? null; + } + if ('fontSize' in attrs) { + patch.fontSize = resolveFontSize(attrs.fontSize); + } + if ('fontFamily' in attrs) { + patch.fontFamily = normalizeFontFamily(attrs.fontFamily); + } + + const mergeAttrs = ( + existing: { [key: string]: any } | undefined + ): Required => ({ + foregroundColor: existing?.foregroundColor || null, + backgroundColor: existing?.backgroundColor || null, + fontSize: resolveFontSize(existing?.fontSize), + fontFamily: normalizeFontFamily(existing?.fontFamily), + ...patch, + }); + + const isEmpty = (a: Required) => + !a.foregroundColor && + !a.backgroundColor && + !a.fontSize && + !a.fontFamily; + + const { selection } = state; + + if (selection.empty) { + // Cursor only: merge into the stored (typing) mark. + const existing = markType.isInSet( + state.storedMarks ?? selection.$from.marks() + ); + const merged = mergeAttrs(existing?.attrs); + if (dispatch) { + if (isEmpty(merged)) { + tr.removeStoredMark(markType); + } else { + tr.addStoredMark(markType.create(merged)); + } + dispatch(tr); + } + return true; } - return chain() - .setMark('customStyle', { - foregroundColor: resolvedColor ?? null, - backgroundColor: resolvedBg ?? null, - }) - .run(); + if (dispatch) { + selection.ranges.forEach((range) => { + const rFrom = range.$from.pos; + const rTo = range.$to.pos; + state.doc.nodesBetween(rFrom, rTo, (node, pos) => { + // Only inline runs carry the mark; block nodes are skipped so + // per-run attributes are preserved. ProseMirror's addMark step + // itself skips any inline node that disallows the mark type. + if (!node.isInline) { + return; + } + const start = Math.max(pos, rFrom); + const end = Math.min(pos + node.nodeSize, rTo); + if (start >= end) return; + + const existing = markType.isInSet(node.marks); + const merged = mergeAttrs(existing?.attrs); + + tr.removeMark(start, end, markType); + if (!isEmpty(merged)) { + tr.addMark(start, end, markType.create(merged)); + } + }); + }); + dispatch(tr); + } + return true; }, }; }, diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index cdef19dc7..bf66db64a 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -599,6 +599,8 @@ function walkNode(node: Node, out: { buf: string }): void { const fg = htmlNode.style.color; const bg = htmlNode.style.backgroundColor; + const fontSize = htmlNode.style.fontSize; + const fontFamily = htmlNode.style.fontFamily; // build the preserved span if colors exist let spanOpen = ''; @@ -607,6 +609,8 @@ function walkNode(node: Node, out: { buf: string }): void { if (fg) styleParts.push(`color: ${fg}`); if (bg) styleParts.push(`background-color: ${bg}`); + if (fontSize) styleParts.push(`font-size: ${fontSize}`); + if (fontFamily) styleParts.push(`font-family: ${fontFamily}`); if (styleParts.length > 0) { spanOpen = ``; diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts index 2015f86e2..c5f315803 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -104,8 +104,8 @@ function buildState( editor.getAttributes('customStyle').foregroundColor ?? '', backgroundColor: editor.getAttributes('customStyle').backgroundColor ?? '', - fontSize: 0, - fontFamily: '', + fontSize: editor.getAttributes('customStyle').fontSize ?? 0, + fontFamily: editor.getAttributes('customStyle').fontFamily ?? '', }, }; } @@ -125,7 +125,7 @@ function hashState(state: OnChangeStateEvent): string { }) .join(''); - return `${formatHash}|${state.alignment}|${state.customStyle.foregroundColor}|${state.customStyle.backgroundColor}`; + return `${formatHash}|${state.alignment}|${state.customStyle.foregroundColor}|${state.customStyle.backgroundColor}|${state.customStyle.fontSize}|${state.customStyle.fontFamily}`; } function getFormatHash( From 3b8f768a3dbee8853a6cb768e1a119b16dca7473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 12 Aug 2026 10:50:34 +0200 Subject: [PATCH 2/3] fix: fonSize parsing --- src/web/formats/EnrichedCustomStyle.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index e526f6e03..3e9adde90 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -31,6 +31,15 @@ function resolveFontSize(value: number | null | undefined): number | null { return value; } +function parseFontSize(value: string | null | undefined): number | null { + if (!value) return null; + const trimmed = value.trim(); + const match = /^([0-9.]+)\s*(?:px)?$/i.exec(trimmed); + if (!match) return null; + const n = parseFloat(match[1]!); + return !Number.isNaN(n) && n > 0 ? n : null; +} + declare module '@tiptap/core' { interface Commands { customStyle: { @@ -58,11 +67,7 @@ export const EnrichedCustomStyle = Mark.create({ }, fontSize: { default: null, - parseHTML: (el: HTMLElement) => { - const raw = el.style.fontSize; - const n = parseFloat(raw); - return raw && !Number.isNaN(n) && n > 0 ? n : null; - }, + parseHTML: (el: HTMLElement) => parseFontSize(el.style.fontSize), }, fontFamily: { default: null, From 0bbf97ecf95f4bb990868e1776cb3fd1ed0f3ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Thu, 13 Aug 2026 10:12:24 +0200 Subject: [PATCH 3/3] fix: parsing fontFamily --- src/web/formats/EnrichedCustomStyle.ts | 3 ++- src/web/normalization/htmlNormalizer.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/web/formats/EnrichedCustomStyle.ts b/src/web/formats/EnrichedCustomStyle.ts index 3e9adde90..c4380ca41 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -1,4 +1,5 @@ import { Mark } from '@tiptap/core'; +import type { Attrs } from '@tiptap/pm/model'; type CustomStyleAttrs = { foregroundColor?: string | null; @@ -173,7 +174,7 @@ export const EnrichedCustomStyle = Mark.create({ } const mergeAttrs = ( - existing: { [key: string]: any } | undefined + existing: Attrs | undefined ): Required => ({ foregroundColor: existing?.foregroundColor || null, backgroundColor: existing?.backgroundColor || null, diff --git a/src/web/normalization/htmlNormalizer.ts b/src/web/normalization/htmlNormalizer.ts index bf66db64a..257463a07 100644 --- a/src/web/normalization/htmlNormalizer.ts +++ b/src/web/normalization/htmlNormalizer.ts @@ -600,7 +600,7 @@ function walkNode(node: Node, out: { buf: string }): void { const fg = htmlNode.style.color; const bg = htmlNode.style.backgroundColor; const fontSize = htmlNode.style.fontSize; - const fontFamily = htmlNode.style.fontFamily; + const fontFamily = escapeText(htmlNode.style.fontFamily); // build the preserved span if colors exist let spanOpen = '';