diff --git a/.playwright/screenshots/custom-style-fonts-visual.png b/.playwright/screenshots/custom-style-fonts-visual.png new file mode 100644 index 000000000..60949a5db Binary files /dev/null and b/.playwright/screenshots/custom-style-fonts-visual.png differ 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..c4380ca41 100644 --- a/src/web/formats/EnrichedCustomStyle.ts +++ b/src/web/formats/EnrichedCustomStyle.ts @@ -1,12 +1,50 @@ import { Mark } from '@tiptap/core'; +import type { Attrs } from '@tiptap/pm/model'; + +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; +} + +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: { - setCustomStyle: (attrs: { - foregroundColor?: string | null; - backgroundColor?: string | null; - }) => ReturnType; + setCustomStyle: (attrs: CustomStyleAttrs) => ReturnType; }; } } @@ -28,6 +66,15 @@ export const EnrichedCustomStyle = Mark.create({ default: null, parseHTML: (el: HTMLElement) => el.style.backgroundColor || null, }, + fontSize: { + default: null, + parseHTML: (el: HTMLElement) => parseFontSize(el.style.fontSize), + }, + fontFamily: { + default: null, + parseHTML: (el: HTMLElement) => + normalizeFontFamily(el.style.fontFamily), + }, }; }, @@ -36,7 +83,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 +106,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 +152,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: Attrs | undefined + ): Required => ({ + foregroundColor: existing?.foregroundColor || null, + backgroundColor: existing?.backgroundColor || null, + fontSize: resolveFontSize(existing?.fontSize), + fontFamily: normalizeFontFamily(existing?.fontFamily), + ...patch, + }); - return chain() - .setMark('customStyle', { - foregroundColor: resolvedColor ?? null, - backgroundColor: resolvedBg ?? null, - }) - .run(); + 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; + } + + 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..257463a07 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 = escapeText(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(