Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/docs/modules/toolbar/built-in.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -328,21 +328,24 @@ The toolbar dropdown lists only what you register in `fonts`. Imported documents
<ParamField path="fonts" type="Array">
<Expandable title="Font object properties">
<ParamField path="label" type="string" required>
Display name shown in the dropdown
Display name shown in the dropdown, and the value applied to the selected text. Must match the first family name in `key` — otherwise the dropdown won't light up the current font when the cursor is inside a run that uses it.
</ParamField>
<ParamField path="key" type="string" required>
Font-family CSS value applied to text
Stable identity for the option. Also used as the row's preview `font-family` so each row renders in its own typeface. Typically a full CSS stack (e.g. `'Cambria, serif'`).
</ParamField>
<ParamField path="fontWeight" type="number">
Font weight
</ParamField>
<ParamField path="props" type="object">
Optional. Overrides per-row rendering. Use `props.style.fontFamily` to preview the row in a different font than `key`.
</ParamField>
</Expandable>
</ParamField>

```javascript
fonts: [
{ label: 'Arial', key: 'Arial' },
{ label: 'Times', key: 'Times New Roman' },
{ label: 'Arial', key: 'Arial, sans-serif' },
{ label: 'Times New Roman', key: 'Times New Roman, serif' },
{ label: 'Brand Font', key: 'BrandFont, sans-serif', fontWeight: 400 }
]
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { h, ref } from 'vue';

import { sanitizeNumber } from './helpers';
import { normalizeFontOption } from './helpers/font-options.js';
import { useToolbarItem } from './use-toolbar-item';
import AIWriter from './AIWriter.vue';
import AlignmentButtons from './AlignmentButtons.vue';
Expand Down Expand Up @@ -44,7 +45,7 @@ export const makeDefaultItems = ({
});

// font
const fontOptions = [...(toolbarFonts ? toolbarFonts : TOOLBAR_FONTS)];
const fontOptions = (toolbarFonts ?? TOOLBAR_FONTS).map(normalizeFontOption);
const fontButton = useToolbarItem({
type: 'dropdown',
name: 'fontFamily',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Normalize a font dropdown option so each row renders in its own typeface.
*
* Consumers can pass a minimal `{ label, key }` shape. The toolbar dropdown
* spreads `option.props` onto each rendered `<li>`, so without an inline
* `style.fontFamily` every row inherits the toolbar's UI font and the
* dropdown loses its visual font preview. This fills that in from
* `props.style.fontFamily` → `key` → `label` and keeps the existing
* `data-item` hook that e2e selectors rely on.
*
* Idempotent: if `props.style.fontFamily` and `data-item` are already set,
* the option is returned with those values preserved.
*/
export const normalizeFontOption = (option) => {
if (!option) return option;
const fontFamily = option.props?.style?.fontFamily ?? option.key ?? option.label;
return {
...option,
props: {
...option.props,
style: {
...option.props?.style,
fontFamily,
},
'data-item': option.props?.['data-item'] ?? 'btn-fontFamily-option',
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';

import { makeDefaultItems } from '../defaultItems.js';
import { normalizeFontOption } from './font-options.js';

describe('normalizeFontOption', () => {
it('derives props.style.fontFamily from key when props are missing', () => {
const result = normalizeFontOption({ label: 'Cambria', key: 'Cambria, serif' });
expect(result.props.style.fontFamily).toBe('Cambria, serif');
expect(result.props['data-item']).toBe('btn-fontFamily-option');
});

it('falls back to label when key is absent', () => {
const result = normalizeFontOption({ label: 'Aptos' });
expect(result.props.style.fontFamily).toBe('Aptos');
});

it('preserves an explicitly-set props.style.fontFamily', () => {
const result = normalizeFontOption({
label: 'Calibri',
key: 'Calibri',
props: { style: { fontFamily: 'Calibri, sans-serif' } },
});
expect(result.props.style.fontFamily).toBe('Calibri, sans-serif');
});

it('preserves an explicitly-set data-item attribute', () => {
const result = normalizeFontOption({
label: 'Arial',
key: 'Arial',
props: { 'data-item': 'custom-hook' },
});
expect(result.props['data-item']).toBe('custom-hook');
});

it('does not lose unrelated option properties or props', () => {
const result = normalizeFontOption({
label: 'Georgia',
key: 'Georgia, serif',
fontWeight: 400,
props: { style: { color: 'red' }, 'data-custom': 'x' },
});
expect(result.fontWeight).toBe(400);
expect(result.props.style.color).toBe('red');
expect(result.props.style.fontFamily).toBe('Georgia, serif');
expect(result.props['data-custom']).toBe('x');
});

it('is idempotent', () => {
const input = { label: 'Verdana', key: 'Verdana, sans-serif' };
const once = normalizeFontOption(input);
const twice = normalizeFontOption(once);
expect(twice).toEqual(once);
});

it('passes nullish entries through without throwing', () => {
expect(normalizeFontOption(null)).toBeNull();
expect(normalizeFontOption(undefined)).toBeUndefined();
});
});

describe('makeDefaultItems font wiring', () => {
const stubProxy = new Proxy(
{},
{
get: () => 'stub',
},
);
const superToolbar = {
config: { mode: 'docx' },
activeEditor: null,
emitCommand: () => {},
};

it('normalizes custom fonts passed via toolbarFonts', () => {
const { defaultItems, overflowItems } = makeDefaultItems({
superToolbar,
toolbarIcons: stubProxy,
toolbarTexts: stubProxy,
toolbarFonts: [{ label: 'Inter', key: 'Inter, sans-serif' }],
hideButtons: false,
availableWidth: Infinity,
});

const allItems = [...defaultItems, ...overflowItems];
const fontItem = allItems.find((i) => i.name.value === 'fontFamily');
expect(fontItem).toBeDefined();

const inter = fontItem.nestedOptions.value.find((o) => o.label === 'Inter');
expect(inter.props.style.fontFamily).toBe('Inter, sans-serif');
expect(inter.props['data-item']).toBe('btn-fontFamily-option');
});
});
21 changes: 17 additions & 4 deletions packages/super-editor/src/editors/v1/core/types/EditorConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,33 @@ export type LinkPopoverResolver = (ctx: LinkPopoverContext) => LinkPopoverResolu
* Configuration for a font option in the toolbar font picker.
*
* Each entry represents a selectable font that appears in the toolbar dropdown.
* The `props.style.fontFamily` value is applied to text when the font is selected.
* `label` is the value applied to the selected text and used for active-state
* matching, so it must equal the first family name in `key`. `key` is the
* stable option identity and drives the row's preview `font-family` so each
* row renders in its own typeface.
*/
export interface FontConfig {
/** Unique key identifying this font */
/**
* Stable identity for the option. Used as the preview font-family for the
* dropdown row. Typically a full CSS stack (e.g. `'Cambria, serif'`).
*/
key: string;
/** Display label shown in the font picker dropdown */
/**
* Display name shown in the dropdown, and the value applied to the selected
* text. Must match the first family name in `key` for active-state tracking.
*/
label: string;
/** Font weight (e.g. 400 for normal, 700 for bold) */
fontWeight?: number;
/** CSS properties applied when this font is selected */
/**
* Optional per-row render overrides. `props.style.fontFamily` overrides the
* row's preview font independently of `key`.
*/
props?: {
style?: {
fontFamily?: string;
};
'data-item'?: string;
};
}

Expand Down
Loading