From d54bd43cea9992bfe1aae77a440e0b227efd8a49 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 22:32:19 -0300 Subject: [PATCH 1/4] feat(font-system): document-specific toolbar fonts with support status --- .../toolbar/ToolbarDropdown.test.js | 39 ++++ .../v1/components/toolbar/ToolbarDropdown.vue | 16 ++ .../v1/components/toolbar/constants.js | 48 ++++- .../v1/components/toolbar/constants.test.js | 54 +++++- .../super-toolbar-font-rebuild.test.js | 94 ++++++++++ .../v1/components/toolbar/super-toolbar.js | 83 ++++++++- .../presentation-editor/PresentationEditor.ts | 11 ++ .../fonts/FontReadinessGate.ts | 20 +++ packages/super-editor/src/index.ts | 2 + packages/superdoc/src/core/SuperDoc.ts | 1 + packages/superdoc/src/core/types/index.ts | 6 + packages/superdoc/src/public/index.ts | 1 + .../src/document-font-options.test.ts | 129 +++++++++++++ .../font-system/src/document-font-options.ts | 170 ++++++++++++++++++ shared/font-system/src/index.ts | 3 + .../snapshots/superdoc-root-exports.json | 14 +- .../snapshots/superdoc-root-exports.md | 18 +- .../snapshots/superdoc-super-editor.txt | 2 + 18 files changed, 693 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js create mode 100644 shared/font-system/src/document-font-options.test.ts create mode 100644 shared/font-system/src/document-font-options.ts diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js index ae1f67b05d..0154665d38 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js @@ -57,3 +57,42 @@ describe('ToolbarDropdown keyboard focus', () => { expect(document.activeElement).toBe(trigger.element); }); }); + +describe('ToolbarDropdown secondary label (document font support status)', () => { + it('renders the status separately from the pure label and folds it into the accessible name', async () => { + const Harness = defineComponent({ + components: { ToolbarDropdown }, + setup() { + const show = ref(false); + const options = [ + { key: 'Calibri', label: 'Calibri', props: {} }, // a plain default + { key: 'Aptos', label: 'Aptos', secondaryLabel: 'Needs font', props: {} }, // a document font + ]; + return { options, show }; + }, + template: ` + + + + `, + }); + + wrapper = mount(Harness, { attachTo: document.body }); + wrapper.vm.show = true; + await nextTick(); + await nextTick(); + + const options = document.body.querySelectorAll('.toolbar-dropdown-option'); + expect(options).toHaveLength(2); + + // Default: label only, no status span, no extra aria name. + expect(options[0].querySelector('.toolbar-dropdown-option__label').textContent.trim()).toBe('Calibri'); + expect(options[0].querySelector('.toolbar-dropdown-option__secondary')).toBeNull(); + + // Document font: label stays pure, the status is a separate visible span AND in the accessible name. + const aptos = options[1]; + expect(aptos.querySelector('.toolbar-dropdown-option__label').textContent.trim()).toBe('Aptos'); + expect(aptos.querySelector('.toolbar-dropdown-option__secondary').textContent.trim()).toBe('Needs font'); + expect(aptos.getAttribute('aria-label')).toBe('Aptos Needs font'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue index 7ac0030fe5..f579ce5d8a 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue @@ -127,6 +127,9 @@ const onOptionClick = (option) => { const isRenderOption = (option) => option?.type === 'render'; const isOptionNavigable = (option) => !option?.disabled && option?.type !== 'render'; const hasIcon = (option) => typeof option?.icon === 'function' || Boolean(option?.icon); +// Fold the visible status (secondaryLabel) into the option's accessible name so screen readers don't +// miss it, while option.label stays pure for active-state matching. Undefined leaves the default name. +const ariaLabelFor = (option) => (option?.secondaryLabel ? `${option.label} ${option.secondaryLabel}` : undefined); const renderIcon = (option) => { if (typeof option?.icon === 'function') return option.icon(option); return option?.icon || null; @@ -403,6 +406,7 @@ onBeforeUnmount(() => { tabindex="-1" @click="onOptionClick(option)" v-bind="{ ...option.props, ...getNodeProps(option) }" + :aria-label="ariaLabelFor(option)" > @@ -470,6 +477,15 @@ onBeforeUnmount(() => { height: 12px; } +/* Secondary annotation (e.g. a document font's support status). Pushed right, muted, never the label. */ +.toolbar-dropdown-option__secondary { + margin-left: auto; + padding-left: 12px; + font-size: var(--sd-ui-font-size-300, 12px); + color: var(--sd-ui-dropdown-text-muted, #8a8b8d); + white-space: nowrap; +} + .toolbar-dropdown-option:hover { background: var(--sd-ui-dropdown-hover-bg, #d8dee5); color: var(--sd-ui-dropdown-hover-text, #47484a); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js index fac666f16d..21ff770a29 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js @@ -1,4 +1,9 @@ -import { getDefaultFontOfferings, fontOfferingStack, fontOfferingRenderStack } from '@superdoc/font-system'; +import { + getDefaultFontOfferings, + fontOfferingStack, + fontOfferingRenderStack, + fontSupportStatusText, +} from '@superdoc/font-system'; /** * Built-in toolbar font dropdown options, DERIVED from the shared font-offering registry @@ -20,6 +25,47 @@ export const TOOLBAR_FONTS = getDefaultFontOfferings().map((offering) => ({ }, })); +/** + * The single seam that composes the font dropdown options: it turns the active document's + * {@link import('@superdoc/font-system').DocumentFontOption}s into toolbar font options and unions them + * with the bundled defaults. The toolbar only asks for the result; it does not know how a status becomes + * a `secondaryLabel`, or how a font previews. + * + * - A consumer-provided `configFonts` list is returned UNCHANGED (custom toolbars own their list). + * - With no document options, returns `undefined` so the caller keeps its fallback to {@link TOOLBAR_FONTS}. + * - Otherwise: the bundled defaults FIRST, then the document's own fonts appended, deduped by normalized + * logical family. `label`/`key` stay the pure logical family (active-state matching + the stored value), + * the preview renders in `previewFamily`, and a `secondaryLabel` is added only for a non-`available` + * status (`fontSupportStatusText` returns '' for available, so faithful fonts read as plain names). + * + * @param {ReadonlyArray} documentOptions + * @param {Array} [configFonts] - the consumer's `fonts` config, if any + * @returns {Array|undefined} + */ +export function composeToolbarFontOptions(documentOptions, configFonts) { + if (configFonts) return configFonts; + if (!documentOptions?.length) return undefined; + const seen = new Set(TOOLBAR_FONTS.map((option) => String(option.label).trim().toLowerCase())); + const appended = []; + for (const option of documentOptions) { + const dedupeKey = option.logicalFamily.trim().toLowerCase(); + if (seen.has(dedupeKey)) continue; // already a bundled default (e.g. Calibri) -> not duplicated + seen.add(dedupeKey); + const statusText = fontSupportStatusText(option.status); + appended.push({ + label: option.logicalFamily, // pure logical name: stored / exported + active-state matched + key: option.logicalFamily, // the logical family applied to the selection + fontWeight: 400, + ...(statusText ? { secondaryLabel: statusText } : {}), + props: { + style: { fontFamily: option.previewFamily || option.logicalFamily }, // preview in what paints + 'data-item': 'btn-fontFamily-option', + }, + }); + } + return appended.length ? [...TOOLBAR_FONTS, ...appended] : undefined; +} + export const TOOLBAR_FONT_SIZES = [ { label: '8', key: '8pt', props: { 'data-item': 'btn-fontSize-option' } }, { label: '9', key: '9pt', props: { 'data-item': 'btn-fontSize-option' } }, diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js index b5d54374a3..b79ad4c8fa 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js @@ -1,9 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { TOOLBAR_FONTS } from './constants'; +import { TOOLBAR_FONTS, composeToolbarFontOptions } from './constants'; describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering registry)', () => { it('advertises only the metric-safe bundled defaults, in order', () => { - expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual(['Calibri', 'Arial', 'Courier New', 'Times New Roman', 'Helvetica']); + expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual([ + 'Calibri', + 'Arial', + 'Courier New', + 'Times New Roman', + 'Helvetica', + ]); }); it('does not leak non-bundled or qualified fonts into the default dropdown', () => { @@ -32,3 +38,47 @@ describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering } }); }); + +describe('composeToolbarFontOptions (document fonts unioned with the bundled defaults)', () => { + const doc = (logicalFamily, status, previewFamily) => ({ + logicalFamily, + status, + previewFamily: previewFamily ?? logicalFamily, + }); + + it('returns a consumer-provided fonts list unchanged (custom toolbars own their list)', () => { + const custom = [{ label: 'My Font', key: 'My Font' }]; + expect(composeToolbarFontOptions([doc('Aptos', 'needs_font')], custom)).toBe(custom); + }); + + it('returns undefined with no document fonts, so the caller keeps the bundled defaults', () => { + expect(composeToolbarFontOptions([], undefined)).toBeUndefined(); + expect(composeToolbarFontOptions(undefined, undefined)).toBeUndefined(); + }); + + it('puts defaults first, appends document fonts, and dedupes one already in the defaults', () => { + const options = composeToolbarFontOptions( + [doc('Calibri', 'available', 'Carlito'), doc('Aptos', 'needs_font'), doc('Georgia', 'pending')], + undefined, + ); + // Defaults in their order, then the NON-default document fonts; Calibri (a default) is not duplicated. + expect(options.map((o) => o.label)).toEqual([...TOOLBAR_FONTS.map((f) => f.label), 'Aptos', 'Georgia']); + expect(options.filter((o) => o.label === 'Calibri')).toHaveLength(1); + }); + + it('maps a document font: pure logical label/key, preview in previewFamily, status as secondaryLabel', () => { + const options = composeToolbarFontOptions([doc('Aptos', 'needs_font', 'Aptos')], undefined); + expect(options.at(-1)).toMatchObject({ + label: 'Aptos', // pure logical name (active-state match + the stored/exported value) + key: 'Aptos', + secondaryLabel: 'Needs font', + props: { style: { fontFamily: 'Aptos' }, 'data-item': 'btn-fontFamily-option' }, + }); + }); + + it('omits secondaryLabel for an available document font (it reads as a plain name)', () => { + const options = composeToolbarFontOptions([doc('BrandSans', 'available', 'BrandSans')], undefined); + expect(options.at(-1).label).toBe('BrandSans'); + expect(options.at(-1).secondaryLabel).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js new file mode 100644 index 0000000000..16e3cd4e60 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'eventemitter3'; +import { SuperToolbar } from './super-toolbar.js'; + +// super-toolbar.js pulls a broad import graph; mock the same heavy leaves the sibling super-toolbar.test.js +// does, so the module imports cleanly in jsdom. +vi.mock('prosemirror-history', () => ({ undoDepth: () => 0, redoDepth: () => 0 })); +vi.mock('@core/helpers/getActiveFormatting.js', () => ({ getActiveFormatting: vi.fn(() => []) })); +vi.mock('@helpers/isInTable.js', () => ({ isInTable: vi.fn(() => false) })); +vi.mock('@extensions/linked-styles/index.js', () => ({ getQuickFormatList: vi.fn(() => []) })); +vi.mock('@extensions/track-changes/permission-helpers.js', () => ({ + collectTrackedChanges: vi.fn(() => []), + isTrackedChangeActionAllowed: vi.fn(() => true), +})); + +// The font dropdown is rebuilt by re-running makeDefaultItems with the current document fonts. Replace it +// with a light factory whose fontFamily item simply carries the toolbarFonts it was built with, so a +// rebuild is observable as "the dropdown now lists this font". The composition (dedupe, ordering, status +// labels) is covered in constants.test.js; here we only prove the TRIGGER threads the CURRENT document +// options into a fresh build - the original bug was fonts-changed refreshing state without rebuilding. +const { makeDefaultItemsSpy } = vi.hoisted(() => ({ makeDefaultItemsSpy: vi.fn() })); +vi.mock('./defaultItems', () => ({ makeDefaultItems: makeDefaultItemsSpy })); + +const fontFamilyItem = (toolbarFonts) => ({ + name: { value: 'fontFamily' }, + options: { value: toolbarFonts }, + resetDisabled: vi.fn(), + activate: vi.fn(), + deactivate: vi.fn(), + setDisabled: vi.fn(), + allowWithoutEditor: { value: false }, +}); + +const aptos = { logicalFamily: 'Aptos', previewFamily: 'Aptos', status: 'needs_font' }; + +describe('SuperToolbar font dropdown rebuild trigger', () => { + let toolbar; + let editor; + let documentOptions; + + beforeEach(() => { + vi.clearAllMocks(); + makeDefaultItemsSpy.mockImplementation(({ toolbarFonts }) => ({ + defaultItems: [fontFamilyItem(toolbarFonts)], + overflowItems: [], + })); + + documentOptions = []; // a blank document: no document-specific fonts yet + // A non-resolving selector makes the constructor early-return after building the initial items, so + // there is no Vue mount or headless controller to stand up (the sibling toolbar tests use the same trick). + toolbar = new SuperToolbar({ selector: '#nope', role: 'editor' }); + // The toolbar reads document fonts from the public read API; stand in a controllable one. + toolbar.superdoc = { fonts: { getDocumentFontOptions: () => documentOptions } }; + // Isolate the rebuild from state refresh: updateToolbarState only re-reads existing item state, which is + // exactly what the original bug relied on. Stubbing it proves the rebuild path stands on its own. + vi.spyOn(toolbar, 'updateToolbarState').mockImplementation(() => {}); + // A real emitter editor: setActiveEditor binds 'fonts-changed' on it, which each test fires. + editor = new EventEmitter(); + toolbar.setActiveEditor(editor); + }); + + const fontOptions = () => toolbar.getToolbarItemByName('fontFamily').options.value ?? []; + + it('rebuilds the dropdown options when fonts-changed reports a newly-resolved document font', () => { + // Before resolution the dropdown carries only the bundled defaults (no document font). + expect(fontOptions().some((o) => o.label === 'Aptos')).toBe(false); + + // Fonts settle asynchronously after load: the document now uses Aptos (no open substitute). + documentOptions = [aptos]; + editor.emit('fonts-changed'); + + // The dropdown was rebuilt (not merely state-refreshed): Aptos now appears, carrying its support status. + const option = fontOptions().find((o) => o.label === 'Aptos'); + expect(option).toBeTruthy(); + expect(option.secondaryLabel).toBe('Needs font'); + }); + + it('rebuilds on active-editor change so a document that already resolved its fonts is reflected', () => { + // The next document already knows its fonts before it becomes active. + documentOptions = [aptos]; + toolbar.setActiveEditor(new EventEmitter()); + + expect(fontOptions().some((o) => o.label === 'Aptos')).toBe(true); + }); + + it('does not rebuild when fonts-changed fires with the same options (signature guard)', () => { + documentOptions = [aptos]; + editor.emit('fonts-changed'); // first change -> rebuild + const buildsAfterChange = makeDefaultItemsSpy.mock.calls.length; + + editor.emit('fonts-changed'); // identical options -> no rebuild + expect(makeDefaultItemsSpy.mock.calls.length).toBe(buildsAfterChange); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 0478506e6e..450275ee6c 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -8,13 +8,12 @@ import { vClickOutside } from '@superdoc/common'; import Toolbar from './Toolbar.vue'; import { toolbarIcons } from './toolbarIcons.js'; import { toolbarTexts } from './toolbarTexts.js'; -import { +import { composeToolbarFontOptions, HEADLESS_TOOLBAR_COMMANDS, HEADLESS_ITEM_MAP, HEADLESS_EXECUTE_ITEMS, TABLE_ACTION_COMMAND_IDS, - TABLE_ACTION_COMMAND_MAP, -} from './constants.js'; + TABLE_ACTION_COMMAND_MAP } from './constants.js'; import { getAvailableColorOptions, makeColorOption, renderColorOptions } from './color-dropdown-helpers.js'; import { useToolbarItem } from '@components/toolbar/use-toolbar-item'; import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; @@ -295,8 +294,17 @@ export class SuperToolbar extends EventEmitter { transaction: null, selectionUpdate: null, focus: null, + fontsChanged: null, }; + /** + * Signature of the last-built document font options. Fonts resolve in several steps after a document + * opens, so `fonts-changed` fires repeatedly; this skips rebuilding the dropdown when the options did + * not actually change. + * @private + */ + this._lastFontOptionsSignature = ''; + /** * Timeout ID for restoring editor focus after toolbar command execution. * Tracked for cleanup on destroy to prevent callbacks firing after toolbar is unmounted. @@ -402,10 +410,12 @@ export class SuperToolbar extends EventEmitter { this.activeEditor.off('transaction', this._boundEditorHandlers.transaction); this.activeEditor.off('selectionUpdate', this._boundEditorHandlers.selectionUpdate); this.activeEditor.off('focus', this._boundEditorHandlers.focus); + this.activeEditor.off('fonts-changed', this._boundEditorHandlers.fontsChanged); // Clear bound handlers when removing editor this._boundEditorHandlers.transaction = null; this._boundEditorHandlers.selectionUpdate = null; this._boundEditorHandlers.focus = null; + this._boundEditorHandlers.fontsChanged = null; } this.activeEditor = editor; @@ -416,11 +426,21 @@ export class SuperToolbar extends EventEmitter { this._boundEditorHandlers.transaction = this.onEditorTransaction.bind(this); this._boundEditorHandlers.selectionUpdate = this.onEditorSelectionUpdate.bind(this); this._boundEditorHandlers.focus = this.onEditorFocus.bind(this); + // Document fonts resolve asynchronously after load (the report can settle with no transaction), so + // the font list must rebuild on `fonts-changed`, not only on edits - otherwise status reads stale. + this._boundEditorHandlers.fontsChanged = this.onEditorFontsChanged.bind(this); this.activeEditor.on('transaction', this._boundEditorHandlers.transaction); this.activeEditor.on('selectionUpdate', this._boundEditorHandlers.selectionUpdate); this.activeEditor.on('focus', this._boundEditorHandlers.focus); + this.activeEditor.on('fonts-changed', this._boundEditorHandlers.fontsChanged); } + + // Recompute on active-editor change: the new document has its own fonts, so rebuild the dropdown + // OPTIONS (updateToolbarState alone only refreshes item state), then refresh state. + this.#rebuildToolbarItems(); + this._lastFontOptionsSignature = this.#fontOptionsSignature(); + this.updateToolbarState(); } /** @@ -478,7 +498,7 @@ export class SuperToolbar extends EventEmitter { superToolbar, toolbarIcons: icons, toolbarTexts: texts, - toolbarFonts: fonts, + toolbarFonts: this.#resolveToolbarFonts(fonts), hideButtons, availableWidth, role: this.role, @@ -504,6 +524,47 @@ export class SuperToolbar extends EventEmitter { this.overflowItems = overflowItems.filter((item) => allConfigItems.includes(item.name.value)); } + /** + * The font dropdown options. Lifecycle only: get the active document's font options from the read API + * and hand the composition (dedupe, ordering, status labels, preview styling) to the pure + * {@link composeToolbarFontOptions} seam. A consumer-provided `configFonts` list is returned untouched. + * @private + * @param {Array|undefined} configFonts - the consumer's `fonts` config, if any + * @returns {Array|undefined} the toolbar font options, or undefined to fall back to the bundled defaults + */ + #resolveToolbarFonts(configFonts) { + return composeToolbarFontOptions(this.superdoc?.fonts?.getDocumentFontOptions?.() ?? [], configFonts); + } + + /** + * Rebuild the toolbar items (and so the font dropdown OPTIONS) from current config + document fonts. + * `updateToolbarState()` only refreshes existing item state; this re-creates the items, which is what + * surfaces a newly-resolved document font in the dropdown. + * @private + * @returns {void} + */ + #rebuildToolbarItems() { + this.#makeToolbarItems({ + superToolbar: this, + icons: this.config.icons, + texts: this.config.texts, + fonts: this.config.fonts, + hideButtons: this.config.hideButtons, + isDev: this.isDev, + }); + } + + /** + * A stable signature of the active document's font options, to detect when a `fonts-changed` event + * actually changed the dropdown (vs the same options resolving again). + * @private + * @returns {string} + */ + #fontOptionsSignature() { + const options = this.superdoc?.fonts?.getDocumentFontOptions?.() ?? []; + return options.map((option) => `${option.logicalFamily}:${option.status}:${option.previewFamily}`).join('|'); + } + /** * Initialize default fonts from the editor * @private @@ -977,6 +1038,20 @@ export class SuperToolbar extends EventEmitter { if (restored) this.updateToolbarState(); } + /** + * Rebuild the toolbar (and so the font dropdown) when the active document's font picture resolves. + * Fonts load asynchronously after a document opens, so support statuses settle with no edit/transaction. + * @returns {void} + */ + onEditorFontsChanged() { + const signature = this.#fontOptionsSignature(); + if (signature !== this._lastFontOptionsSignature) { + this._lastFontOptionsSignature = signature; + this.#rebuildToolbarItems(); // the document's fonts/statuses changed -> rebuild the dropdown options + } + this.updateToolbarState(); + } + /** * Handles editor focus events by flushing any pending mark commands. * This is triggered by the editor's 'focus' event. diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 1766f961b4..c0eaff48b2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -182,6 +182,7 @@ import { measureBlock } from '@superdoc/measuring-dom'; import { createFontResolver, type FontResolutionRecord, + type DocumentFontOption, type FontLoadSummary, type ResolvePhysicalFamily, } from '@superdoc/font-system'; @@ -3114,6 +3115,16 @@ export class PresentationEditor extends EventEmitter { return this.#fontGate?.getReport() ?? []; } + /** + * The document's own fonts for the toolbar's document-specific picker: one option per LOGICAL family + * the document renders, each with the family to preview it in and a user-facing support status. + * DOCUMENT fonts only - the toolbar composes them with its defaults. Surfaced publicly as + * `superdoc.fonts.getDocumentFontOptions()`. + */ + getDocumentFontOptions(): DocumentFontOption[] { + return this.#fontGate?.getDocumentFontOptions() ?? []; + } + /** * Declared families with no faithful render font loaded (substitution-aware): the * subset of {@link getFontReport} where `missing` is true - genuinely absent fonts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index 4cc3f057bf..484e93dd1c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -3,6 +3,7 @@ import { bumpFontConfigVersion, buildFontReport, buildFaceReport, + buildDocumentFontOptions, DEFAULT_FONT_LOAD_TIMEOUT_MS, type FontRegistry, type FontLoadResult, @@ -10,6 +11,7 @@ import { type FontFaceLoadResult, type FontLoadSummary, type UsedFace, + type DocumentFontOption, type FontLoadStatus, type FontResolutionRecord, type FontResolver, @@ -210,6 +212,24 @@ export class FontReadinessGate { return [...faceRows, ...declaredRows]; } + /** + * The document's own fonts for the toolbar's document-specific picker: one option per LOGICAL family + * the document RENDERS, each with the family to preview it in and a user-facing support status. These + * are DOCUMENT fonts only - the toolbar composes them with its defaults. Built through the same + * registry + resolver + used faces as {@link getReport}, so the status reflects exactly what SuperDoc + * renders. Never throws (font UI must not break layout): returns [] before the plan exists. + */ + getDocumentFontOptions(): DocumentFontOption[] { + try { + const usedFaces = this.#getUsedFaces?.() ?? []; + if (!usedFaces.length) return []; + const { registry } = this.#resolveContext(); + return buildDocumentFontOptions(usedFaces, registry, this.#fontResolver ?? undefined); + } catch { + return []; + } + } + /** * Await the faces the current document needs, then return their outcomes. Safe and * cheap to call on every render: when the required set is unchanged and already fully diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index b41384e5b2..bf541e76a1 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -73,6 +73,8 @@ export type { // Font report types (used to type `fonts-changed` payloads + the fonts read API) export type { FontResolutionRecord, + DocumentFontOption, + FontSupportStatus, FontResolutionReason, FontLoadStatus, FontLoadSummary, diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index d4c8579b77..ff9a70d0a1 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -1639,6 +1639,7 @@ export class SuperDoc extends EventEmitter { this.#fontsApi = { getReport: () => this.activeEditor?.presentationEditor?.getFontReport() ?? [], getMissingFonts: () => this.activeEditor?.presentationEditor?.getMissingFonts() ?? [], + getDocumentFontOptions: () => this.activeEditor?.presentationEditor?.getDocumentFontOptions() ?? [], getDocumentFonts: () => [ // Deduped by logical family: the report can now carry multiple FACE rows per family. ...new Set( diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 15a4ea8df5..1ec93c84bd 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -34,6 +34,7 @@ import type { FontsResolvedPayload, FontsChangedPayload, FontResolutionRecord, + DocumentFontOption, FontAssetUrlContext, FontAssetUrlResolver, ListDefinitionsPayload, @@ -105,6 +106,11 @@ export interface SuperDocFontsApi { getMissingFonts(): string[]; /** The document's declared logical font families, deduped. */ getDocumentFonts(): string[]; + /** + * The document's own fonts as toolbar options: one per logical family the document renders, each with + * a preview family and a user-facing support status. Document fonts only - compose with the defaults. + */ + getDocumentFontOptions(): DocumentFontOption[]; /** * Observe the font report: replays the current report immediately if one has already * resolved, then invokes `callback` on every future change. Use this rather than diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index e954dff67b..1ed3255f62 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -182,6 +182,7 @@ export type { FontConfig } from '@superdoc/super-editor'; export type { FontFaceConfig } from '@superdoc/super-editor'; export type { FontFamilyConfig } from '@superdoc/super-editor'; export type { FontResolutionRecord } from '@superdoc/super-editor'; +export type { DocumentFontOption, FontSupportStatus } from '@superdoc/super-editor'; export type { FontsChangedPayload } from '@superdoc/super-editor'; export type { FontsConfig } from '@superdoc/super-editor'; export type { FontsResolvedPayload } from '@superdoc/super-editor'; diff --git a/shared/font-system/src/document-font-options.test.ts b/shared/font-system/src/document-font-options.test.ts new file mode 100644 index 0000000000..59743b989a --- /dev/null +++ b/shared/font-system/src/document-font-options.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + buildDocumentFontOptions, + fontSupportStatusText, + type FontSupportStatus, + type FontFaceRequest, + type FontLoadStatus, + type FontRegistry, + type UsedFace, +} from './index'; + +/** Face-aware fake registry: per-face load status + which faces are registered (hasFace). */ +class FaceRegistry { + readonly faceStatuses = new Map(); + readonly registered = new Set(); + #key(family: string, weight: string, style: string): string { + return `${family.toLowerCase()}|${weight}|${style}`; + } + getStatus(): FontLoadStatus { + return 'unloaded'; + } + getFaceStatus(req: FontFaceRequest): FontLoadStatus { + return this.faceStatuses.get(this.#key(req.family, req.weight, req.style)) ?? 'unloaded'; + } + hasFace(family: string, weight: '400' | '700', style: 'normal' | 'italic'): boolean { + return this.registered.has(this.#key(family, weight, style)); + } + setFace(family: string, weight: '400' | '700', style: 'normal' | 'italic', status: FontLoadStatus): void { + this.registered.add(this.#key(family, weight, style)); + this.faceStatuses.set(this.#key(family, weight, style), status); + } + asRegistry(): FontRegistry { + return this as unknown as FontRegistry; + } +} + +const regular = (logicalFamily: string): UsedFace => ({ logicalFamily, weight: '400', style: 'normal' }); + +/** A registry with the bundled clone pack loaded (so substitutes resolve) + one customer/embedded face. */ +function loadedRegistry(): FaceRegistry { + const reg = new FaceRegistry(); + const faces = [ + ['400', 'normal'], + ['700', 'normal'], + ['400', 'italic'], + ['700', 'italic'], + ] as const; + for (const clone of ['Carlito', 'Caladea', 'Liberation Sans', 'Liberation Serif', 'Liberation Mono']) { + for (const [w, s] of faces) reg.setFace(clone, w, s, 'loaded'); // the bundled clones are four-face fonts + } + reg.setFace('BrandSans', '400', 'normal', 'loaded'); // a document-embedded / customer-added real face + return reg; +} + +describe('buildDocumentFontOptions (document-specific toolbar fonts + support status)', () => { + it('classifies each font the document uses by how faithfully SuperDoc renders it', () => { + const reg = loadedRegistry().asRegistry(); + const options = buildDocumentFontOptions( + [ + regular('Calibri'), // metric-safe bundled clone (Carlito) + regular('Cambria'), // bundled clone (Caladea) but qualified (visual_only) + regular('Calibri Light'), // category fallback (Carlito, non-metric) + regular('Georgia'), // candidate Gelasio exists but is not bundled yet + regular('Aptos'), // no open clone - the customer must supply it + regular('Cambria Math'), // math font - preserve, never substitute + regular('BrandSans'), // a real face the document embeds / adds + regular('Wingdings'), // docfonts has no record at all + ], + reg, + ); + const byName = Object.fromEntries(options.map((o) => [o.logicalFamily, o.status])) as Record< + string, + FontSupportStatus + >; + expect(byName).toEqual({ + Calibri: 'available', + Cambria: 'fallback', + 'Calibri Light': 'fallback', + Georgia: 'pending', + Aptos: 'needs_font', + 'Cambria Math': 'preserve_only', + BrandSans: 'available', + Wingdings: 'needs_font', + }); + }); + + it('renders an available font through its bundled clone, and preserves the logical name', () => { + const reg = loadedRegistry().asRegistry(); + const [calibri] = buildDocumentFontOptions([regular('Calibri')], reg); + expect(calibri).toMatchObject({ logicalFamily: 'Calibri', previewFamily: 'Carlito', status: 'available' }); + }); + + it('dedupes a family used at multiple faces into one option (regular face represents it)', () => { + const reg = loadedRegistry().asRegistry(); + const options = buildDocumentFontOptions( + [ + { logicalFamily: 'Calibri', weight: '700', style: 'normal' }, + { logicalFamily: 'Calibri', weight: '400', style: 'normal' }, + { logicalFamily: 'Calibri', weight: '400', style: 'italic' }, + ], + reg, + ); + expect(options).toHaveLength(1); + expect(options[0]).toMatchObject({ logicalFamily: 'Calibri', status: 'available' }); + }); + + it('reports the WORST status across used faces (available Regular + non-substitutable Bold = fallback)', () => { + const reg = new FaceRegistry(); + reg.setFace('Carlito', '400', 'normal', 'loaded'); // ONLY Carlito Regular registered - no Bold + const options = buildDocumentFontOptions( + [ + { logicalFamily: 'Calibri', weight: '400', style: 'normal' }, // available via Carlito Regular + { logicalFamily: 'Calibri', weight: '700', style: 'normal' }, // no Carlito Bold -> fallback_face_absent + ], + reg.asRegistry(), + ); + expect(options).toHaveLength(1); + // Regular alone reads `available`; aggregating the unsubstitutable Bold honestly drops it to `fallback`. + expect(options[0]).toMatchObject({ logicalFamily: 'Calibri', previewFamily: 'Carlito', status: 'fallback' }); + }); + + it('status text is user-facing and blank for available (faithful fonts read as plain names)', () => { + expect(fontSupportStatusText('available')).toBe(''); + expect(fontSupportStatusText('fallback')).toBe('Fallback'); + expect(fontSupportStatusText('pending')).toBe('Pending font'); + expect(fontSupportStatusText('needs_font')).toBe('Needs font'); + expect(fontSupportStatusText('preserve_only')).toBe('Preserve only'); + }); +}); diff --git a/shared/font-system/src/document-font-options.ts b/shared/font-system/src/document-font-options.ts new file mode 100644 index 0000000000..30288fd68f --- /dev/null +++ b/shared/font-system/src/document-font-options.ts @@ -0,0 +1,170 @@ +/** + * Document-scoped font support for the toolbar: classify each font a loaded document actually uses by + * how faithfully SuperDoc can render it RIGHT NOW. Distinct from the static {@link ./font-offerings} + * (the bundled DEFAULTS): this is runtime + document-scoped (it needs the document's registry + + * resolver) and covers fonts beyond the defaults - embedded/customer fonts the document supplies, + * un-bundled candidates (Georgia), customer-supplied (Aptos), and preserve-only (math/symbol). + * + * The {@link FontSupportStatus} is a USER-FACING summary ("will SuperDoc render this faithfully, + * approximate it, or do I need to supply it?"), deliberately NOT the docfonts `FallbackDecision`: the + * toolbar must not expose evidence internals. It is derived by combining the runtime FACE report (does + * the document render this with a real / embedded / clone face?) with the docfonts evidence decision + * (for fonts with no runtime render: is a candidate pending an asset, customer-supplied, or preserve- + * only?). The evidence package is consulted by VALUE only, so this module's public types stay local and + * the published facade carries no `@docfonts/fallbacks` reference. + */ +import { getFallbackDecision } from '@docfonts/fallbacks'; +import { buildFaceReport, type FontResolutionRecord, type UsedFace } from './report'; +import type { FontRegistry } from './registry'; +import type { FontResolver } from './resolver'; +import { BUNDLED_MANIFEST } from './bundled-manifest'; + +/** + * The user-facing answer to "how well can SuperDoc render this font?". Five tiers, no docfonts + * internals: a document font is shown in the toolbar with one of these so it never reads as a clean + * default when it is not. + */ +export type FontSupportStatus = + | 'available' // renderable now: the document's own embedded/customer face, a metric-safe clone, or an explicit mapping + | 'fallback' // an approved substitute renders, but not a faithful match (reflows / wrong weight) + | 'pending' // an open candidate exists, but SuperDoc does not bundle its asset yet (Georgia -> Gelasio) + | 'needs_font' // no open substitute: the real font must come from the customer / system (Aptos) + | 'preserve_only'; // math / symbol font: keep the name, never substitute (Cambria Math) + +/** + * One document font for the toolbar: the logical name (the dropdown label + the value stored and + * exported), the family to preview it in, and the support status. + */ +export interface DocumentFontOption { + /** The Word-facing logical family: the dropdown label and the value stored + exported (e.g. "Aptos"). */ + logicalFamily: string; + /** + * The physical family to render the dropdown PREVIEW in (Calibri previews as Carlito; a document- + * provided font previews as itself). The REGULAR face's representative ONLY - NOT what every face of + * the family renders as, since {@link DocumentFontOption.status} is the worst across all used faces. + */ + previewFamily: string; + status: FontSupportStatus; +} + +const BUNDLED_FAMILIES: ReadonlySet = new Set(BUNDLED_MANIFEST.map((f) => f.family)); +/** A docfonts candidate activates only if SuperDoc ships its physical clone (matches the resolver gate). */ +const canRenderFamily = (family: string): boolean => BUNDLED_FAMILIES.has(family); + +/** Normalize a family for dedupe: trim, strip surrounding quotes, lowercase (matches the resolver key). */ +function normalizeKey(family: string): string { + return family + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase(); +} + +/** Derive the user-facing status for one family from its representative face record + the evidence. */ +function statusFor(rec: FontResolutionRecord): FontSupportStatus { + switch (rec.reason) { + case 'registered_face': // the document embeds it, or a customer `fonts.add` provides the real face + case 'custom_mapping': // an explicit `fonts.map` to a loadable family + // Available through a provider the app/customer supplied. "Available" means renderable, NOT + // necessarily a faithful match: a custom mapping's fidelity is unknown unless docfonts covers it. + return 'available'; + case 'category_fallback': // non-metric family fallback (Calibri Light -> Carlito) + case 'fallback_face_absent': // a substitute exists, but not for this face + return 'fallback'; + case 'bundled_substitute': { + // A bundled clone renders it. Faithful (metric_safe / near_metric, loaded) is `available`; a + // qualified substitute (visual_only / cell_width_only, e.g. Cambria) reads honestly as `fallback`. + const decision = getFallbackDecision(rec.logicalFamily, { canRenderFamily }); + return decision.kind === 'fallback' && decision.fallback.lineBreakSafe && !rec.missing ? 'available' : 'fallback'; + } + case 'as_requested': + default: { + // No runtime render - the evidence classifies why. + const decision = getFallbackDecision(rec.logicalFamily, { canRenderFamily }); + switch (decision.kind) { + case 'asset_missing': // candidate exists, SuperDoc has not bundled it yet + return 'pending'; + case 'face_missing': // a substitute exists but lacks the requested face - partial, not faithful + return 'fallback'; + case 'preserve_only': // math / symbol - never a normal Latin fallback + return 'preserve_only'; + case 'fallback': // evidence says renderable but the clone has not loaded; report the honest tier + return decision.fallback.lineBreakSafe ? 'available' : 'fallback'; + case 'customer_supplied': // no open clone; the real font must come from the customer (Aptos) + case 'no_recommended_fallback': // a row exists but no recommended open family (Verdana, Tahoma) + case 'unknown': // docfonts has no record - a system/unknown font the user must supply + default: + return 'needs_font'; + } + } + } +} + +/** + * Best-to-worst severity for aggregating a family's used faces. The toolbar shows the MOST concerning + * status across the faces the document renders, so a family with a faithful Regular but an + * unsubstitutable Bold does not read as fully available. + */ +const STATUS_SEVERITY: Record = { + available: 0, + fallback: 1, + pending: 2, + needs_font: 3, + preserve_only: 4, +}; + +/** + * The document-specific font options for the toolbar: one per LOGICAL family the document renders, + * deduped, each with the family that actually paints and a user-facing {@link FontSupportStatus}. + * Resolved FACE-aware (via {@link buildFaceReport}) so an embedded / customer font the document supplies + * is detected (`registered_face` -> `available`) even when no bundled clone exists. The regular + * (400/normal) face is the family's representative; per-face weight/style nuance is a later pass. + */ +export function buildDocumentFontOptions( + usedFaces: Iterable, + registry: FontRegistry, + resolver?: FontResolver, +): DocumentFontOption[] { + const faceRecords = buildFaceReport(usedFaces, registry, resolver); + // Aggregate the per-face rows by logical family: the status is the WORST across every used face (so a + // family whose Bold lacks a substitute is not advertised as available on the strength of its Regular), + // while the regular (400/normal) face is the render representative for the dropdown preview. + const agg = new Map(); + for (const rec of faceRecords) { + const key = normalizeKey(rec.logicalFamily); + const status = statusFor(rec); + const isRegular = rec.face?.weight === '400' && rec.face?.style === 'normal'; + const existing = agg.get(key); + if (!existing) { + agg.set(key, { rep: rec, worst: status }); + continue; + } + if (STATUS_SEVERITY[status] > STATUS_SEVERITY[existing.worst]) existing.worst = status; + // Prefer the regular face as the render representative; otherwise keep the first seen. + const existingIsRegular = existing.rep.face?.weight === '400' && existing.rep.face?.style === 'normal'; + if (isRegular && !existingIsRegular) existing.rep = rec; + } + const options: DocumentFontOption[] = []; + for (const { rep, worst } of agg.values()) { + options.push({ logicalFamily: rep.logicalFamily, previewFamily: rep.physicalFamily, status: worst }); + } + return options; +} + +/** + * The short inline status text for the toolbar dropdown - "" for `available`, so faithful fonts read as + * plain names. Kept here so the Vue and headless toolbars word the status identically. + */ +export function fontSupportStatusText(status: FontSupportStatus): string { + switch (status) { + case 'available': + return ''; + case 'fallback': + return 'Fallback'; + case 'pending': + return 'Pending font'; + case 'needs_font': + return 'Needs font'; + case 'preserve_only': + return 'Preserve only'; + } +} diff --git a/shared/font-system/src/index.ts b/shared/font-system/src/index.ts index dcfcf612d8..063e95f4a1 100644 --- a/shared/font-system/src/index.ts +++ b/shared/font-system/src/index.ts @@ -76,3 +76,6 @@ export { fontOfferingStack, fontOfferingRenderStack, } from './font-offerings'; + +export type { FontSupportStatus, DocumentFontOption } from './document-font-options'; +export { buildDocumentFontOptions, fontSupportStatusText } from './document-font-options'; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index b55d8d7dae..7bdf62aaa5 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-05T18:14:02.432Z", + "generatedAt": "2026-06-06T01:57:50.052Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -55,6 +55,7 @@ "DocRange", "Document", "DocumentApi", + "DocumentFontOption", "DocumentMode", "DocumentProtectionState", "DocxFileEntry", @@ -94,6 +95,7 @@ "FontFaceConfig", "FontFamilyConfig", "FontResolutionRecord", + "FontSupportStatus", "FontsChangedPayload", "FontsConfig", "FontsResolvedPayload", @@ -297,6 +299,7 @@ "DocRange", "Document", "DocumentApi", + "DocumentFontOption", "DocumentMode", "DocumentProtectionState", "DocxFileEntry", @@ -336,6 +339,7 @@ "FontFaceConfig", "FontFamilyConfig", "FontResolutionRecord", + "FontSupportStatus", "FontsChangedPayload", "FontsConfig", "FontsResolvedPayload", @@ -591,11 +595,11 @@ } }, "counts": { - "types.import": 236, - "types.require": 236, + "types.import": 238, + "types.require": 238, "import": 41, "require": 41, - "union": 236 + "union": 238 }, "divergences": { "typesImportVsRequire": { @@ -643,6 +647,7 @@ "DocRange", "Document", "DocumentApi", + "DocumentFontOption", "DocumentMode", "DocumentProtectionState", "DocxFileEntry", @@ -679,6 +684,7 @@ "FontFaceConfig", "FontFamilyConfig", "FontResolutionRecord", + "FontSupportStatus", "FontsChangedPayload", "FontsConfig", "FontsResolvedPayload", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index 01ec7ff81a..f5bccd6a68 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,17 +1,17 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-06-05T18:14:02.432Z +Generated: 2026-06-06T01:57:50.052Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts | Source | Path | Count | |---|---|---| -| types.import | `./dist/superdoc/src/public/index.d.ts` | 236 | -| types.require | `./dist/superdoc/src/public/index.d.cts` | 236 | +| types.import | `./dist/superdoc/src/public/index.d.ts` | 238 | +| types.require | `./dist/superdoc/src/public/index.d.cts` | 238 | | import | `./dist/superdoc.es.js` | 41 | | require | `./dist/superdoc.cjs` | 41 | -| **union** | | **236** | +| **union** | | **238** | ## Divergences @@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - types.require only (not in types.import): 0 - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 -- typed but no runtime export (phantom risk): 195 +- typed but no runtime export (phantom risk): 197 - runtime export but not typed (silent shadow on root): 0 ### Type-only names (no runtime) @@ -59,6 +59,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `DocRange` - `Document` - `DocumentApi` +- `DocumentFontOption` - `DocumentMode` - `DocumentProtectionState` - `DocxFileEntry` @@ -95,6 +96,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `FontFaceConfig` - `FontFamilyConfig` - `FontResolutionRecord` +- `FontSupportStatus` - `FontsChangedPayload` - `FontsConfig` - `FontsResolvedPayload` @@ -260,11 +262,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `ContextMenuItem` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 5 | | | `ContextMenuSection` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `CoreCommandMap` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | ✓ | -| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 157 | 24 | 55 | ✓ | +| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 160 | 25 | 55 | ✓ | | `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `Document` | ✓ | ✓ | | | 2 | | 291 | 56 | 110 | ✓ | | `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ | +| `DocumentFontOption` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | | `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | | | `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | | | `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -304,6 +307,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `FontFaceConfig` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `FontFamilyConfig` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `FontResolutionRecord` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `FontSupportStatus` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | | `FontsChangedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `FontsConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `FontsResolvedPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | @@ -385,7 +389,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | | `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | | | `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | -| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 187 | 250 | ✓ | +| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 190 | 250 | ✓ | | `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | diff --git a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt index 2b917a6d41..6448bf5baa 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt +++ b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt @@ -30,6 +30,7 @@ ContextMenu CoreCommandMap CreateHeadlessToolbarOptions DocumentApi +DocumentFontOption DocumentProtectionState DocxEncryptionError DocxEncryptionErrorCode @@ -63,6 +64,7 @@ FontLoadStatus FontLoadSummary FontResolutionReason FontResolutionRecord +FontSupportStatus FontsChangedPayload FontsConfig FontsResolvedPayload From 5d4d1cf882953786d60ffa9a3fbd04179a45a764 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 5 Jun 2026 23:44:02 -0300 Subject: [PATCH 2/4] fix(toolbar): re-render the dropdown when toolbar items rebuild Rebuilt toolbarItems/overflowItems are plain instance fields, not a Vue reactive source, so swapping them on fonts-changed or active-editor change did not reach the mounted Toolbar.vue; the toolbar stayed on the initial disabled items until a resize bumped the render key. SuperToolbar now emits toolbar-items-changed on rebuild and Toolbar.vue bumps toolbarKey to re-read. --- .../v1/components/toolbar/Toolbar.test.js | 50 +++++++++++++++++-- .../editors/v1/components/toolbar/Toolbar.vue | 26 +++++++--- .../super-toolbar-font-rebuild.test.js | 13 +++++ .../v1/components/toolbar/super-toolbar.js | 13 ++++- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js index 9858bfe498..9cd9e21092 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { mount } from '@vue/test-utils'; -import { defineComponent, ref, KeepAlive } from 'vue'; +import { defineComponent, ref, nextTick, KeepAlive } from 'vue'; +import { EventEmitter } from 'eventemitter3'; import Toolbar from './Toolbar.vue'; const ToolbarKeepAliveHost = defineComponent({ @@ -12,8 +13,10 @@ const ToolbarKeepAliveHost = defineComponent({ template: '', }); +// The real SuperToolbar is an EventEmitter; model that so Toolbar.vue can subscribe to +// `toolbar-items-changed` (and tests can dispatch it). function createMockToolbar() { - return { + return Object.assign(new EventEmitter(), { config: { toolbarGroups: ['left', 'center', 'right'], toolbarButtonsExclude: [], @@ -26,7 +29,7 @@ function createMockToolbar() { emitCommand: vi.fn(), overflowItems: [], activeEditor: null, - }; + }); } describe('Toolbar', () => { @@ -203,4 +206,45 @@ describe('Toolbar', () => { expect(disconnect).toHaveBeenCalledTimes(1); }); + + it('re-renders the toolbar DOM when SuperToolbar reports rebuilt items (toolbar-items-changed)', async () => { + // toolbarItems is a plain field SuperToolbar swaps on rebuild; only a re-render re-reads it. The mock + // returns whatever `center.items` currently holds, so a "rebuild" is just reassigning that array. + const center = { items: [{ name: { value: 'fontFamily' } }] }; + const mockToolbar = createMockToolbar(); + mockToolbar.config.toolbarGroups = ['center']; // render only the center group: one unambiguous ButtonGroup + mockToolbar.getToolbarItemByGroup = (position) => (position === 'center' ? center.items : []); + + const ButtonGroupStub = defineComponent({ + props: ['toolbarItems', 'overflowItems', 'compactSideGroups', 'uiFontFamily', 'position'], + template: + '
{{ i.name.value }}
', + }); + + const wrapper = mount(Toolbar, { + global: { + stubs: { ButtonGroup: ButtonGroupStub }, + plugins: [ + (app) => { + app.config.globalProperties.$toolbar = mockToolbar; + }, + ], + }, + }); + + const renderedItems = () => wrapper.findAll('.bg-item').map((w) => w.text()); + expect(renderedItems()).toEqual(['fontFamily']); + + // A rebuild swaps in a new array (e.g. a document font resolved). The swap alone is not reactive... + center.items = [{ name: { value: 'fontFamily' } }, { name: { value: 'Aptos' } }]; + await nextTick(); + expect(renderedItems()).toEqual(['fontFamily']); // ...so the DOM still shows the previously-built items. + + // The notify event forces the re-render that re-reads the rebuilt array - the actual fix. + mockToolbar.emit('toolbar-items-changed'); + await nextTick(); + expect(renderedItems()).toEqual(['fontFamily', 'Aptos']); + + wrapper.unmount(); + }); }); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue index 82e5165afa..2825f1df11 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue @@ -76,17 +76,29 @@ const onWindowResized = async () => { }; const onResizeThrottled = throttle(onWindowResized, 300); -function teardownWindowListeners() { +/** + * Force a re-render when the toolbar's item arrays are rebuilt. `toolbarItems` / `overflowItems` are plain + * fields on the SuperToolbar instance, not a reactive source this component tracks, so a rebuild (a new + * active editor, or document fonts resolving via `fonts-changed`) is invisible until the render key changes. + * SuperToolbar emits `toolbar-items-changed` on rebuild; bumping the key re-reads the new items into the DOM. + */ +const onToolbarItemsChanged = () => { + toolbarKey.value += 1; +}; + +function teardownListeners() { window.removeEventListener('resize', onResizeThrottled); window.removeEventListener('keydown', onKeyDown); + proxy.$toolbar.off?.('toolbar-items-changed', onToolbarItemsChanged); containerResizeObserver?.disconnect(); containerResizeObserver = null; } -function setupWindowListeners() { - teardownWindowListeners(); +function setupListeners() { + teardownListeners(); window.addEventListener('resize', onResizeThrottled); window.addEventListener('keydown', onKeyDown); + proxy.$toolbar.on?.('toolbar-items-changed', onToolbarItemsChanged); if ( typeof ResizeObserver !== 'undefined' && proxy.$toolbar.config?.responsiveToContainer && @@ -100,10 +112,10 @@ function setupWindowListeners() { updateCompactSideGroups(); } -onMounted(setupWindowListeners); -onActivated(setupWindowListeners); -onDeactivated(teardownWindowListeners); -onBeforeUnmount(teardownWindowListeners); +onMounted(setupListeners); +onActivated(setupListeners); +onDeactivated(teardownListeners); +onBeforeUnmount(teardownListeners); const handleCommand = ({ item, argument, option }) => { proxy.$toolbar.emitCommand({ item, argument, option }); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js index 16e3cd4e60..0619096be2 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js @@ -91,4 +91,17 @@ describe('SuperToolbar font dropdown rebuild trigger', () => { editor.emit('fonts-changed'); // identical options -> no rebuild expect(makeDefaultItemsSpy.mock.calls.length).toBe(buildsAfterChange); }); + + it('emits toolbar-items-changed on rebuild (and not on the guarded no-op) so the view re-renders', () => { + // The rebuilt arrays are plain fields; Toolbar.vue only re-reads them on this event. Prove it fires. + const changed = vi.fn(); + toolbar.on('toolbar-items-changed', changed); + + documentOptions = [aptos]; + editor.emit('fonts-changed'); // a real change -> one rebuild -> one notify + expect(changed).toHaveBeenCalledTimes(1); + + editor.emit('fonts-changed'); // identical options -> guard skips both the rebuild and the notify + expect(changed).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js index 450275ee6c..d00ef52bd6 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js @@ -8,12 +8,14 @@ import { vClickOutside } from '@superdoc/common'; import Toolbar from './Toolbar.vue'; import { toolbarIcons } from './toolbarIcons.js'; import { toolbarTexts } from './toolbarTexts.js'; -import { composeToolbarFontOptions, +import { + composeToolbarFontOptions, HEADLESS_TOOLBAR_COMMANDS, HEADLESS_ITEM_MAP, HEADLESS_EXECUTE_ITEMS, TABLE_ACTION_COMMAND_IDS, - TABLE_ACTION_COMMAND_MAP } from './constants.js'; + TABLE_ACTION_COMMAND_MAP, +} from './constants.js'; import { getAvailableColorOptions, makeColorOption, renderColorOptions } from './color-dropdown-helpers.js'; import { useToolbarItem } from '@components/toolbar/use-toolbar-item'; import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; @@ -540,6 +542,12 @@ export class SuperToolbar extends EventEmitter { * Rebuild the toolbar items (and so the font dropdown OPTIONS) from current config + document fonts. * `updateToolbarState()` only refreshes existing item state; this re-creates the items, which is what * surfaces a newly-resolved document font in the dropdown. + * + * `toolbarItems` / `overflowItems` are plain instance fields, not a Vue reactive source, so swapping them + * is invisible to the mounted `Toolbar.vue` until it re-renders. Emit `toolbar-items-changed` so the view + * forces that re-render (it bumps its render key). Without it the rebuilt items never reach the DOM and the + * toolbar keeps showing the previously-built set - e.g. the initial disabled items before an editor attaches. + * The resize path rebuilds and bumps the key from the view itself, so it does not go through this method. * @private * @returns {void} */ @@ -552,6 +560,7 @@ export class SuperToolbar extends EventEmitter { hideButtons: this.config.hideButtons, isDev: this.isDev, }); + this.emit('toolbar-items-changed'); } /** From 676a0e2d5f394d7db566bf5cf4ba8c7b7287feed Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 6 Jun 2026 09:18:50 -0300 Subject: [PATCH 3/4] test(toolbar): cover document font option regressions --- .../super-toolbar-font-rebuild.test.js | 8 +++ .../fonts/FontReadinessGate.test.ts | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js index 0619096be2..ac3bf7e9c0 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js @@ -83,6 +83,14 @@ describe('SuperToolbar font dropdown rebuild trigger', () => { expect(fontOptions().some((o) => o.label === 'Aptos')).toBe(true); }); + it('emits toolbar-items-changed on active-editor change so a freshly attached editor is not left locked', () => { + const changed = vi.fn(); + toolbar.on('toolbar-items-changed', changed); + + toolbar.setActiveEditor(new EventEmitter()); + expect(changed).toHaveBeenCalledTimes(1); + }); + it('does not rebuild when fonts-changed fires with the same options (signature guard)', () => { documentOptions = [aptos]; editor.emit('fonts-changed'); // first change -> rebuild diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index 014ea1a580..15a0e0de51 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -6,6 +6,7 @@ import type { FontLoadResult, FontLoadStatus, FontRegistry, + UsedFace, } from '@superdoc/font-system'; import { FontReadinessGate, type FontEnvironment } from './FontReadinessGate'; @@ -502,3 +503,55 @@ describe('FontReadinessGate', () => { }); }); }); + +describe('FontReadinessGate.getDocumentFontOptions (document-used fonts, public read API)', () => { + // The full public chain needs a painted render plan, so this covers the gate seam that owns used-face wiring. + class FaceRegistry { + getStatus(): FontLoadStatus { + return 'unloaded'; + } + getFaceStatus(): FontLoadStatus { + return 'unloaded'; + } + hasFace(): boolean { + return false; + } + asRegistry(): FontRegistry { + return this as unknown as FontRegistry; + } + } + + const regular = (logicalFamily: string): UsedFace => ({ logicalFamily, weight: '400', style: 'normal' }); + + function makeOptionsGate(getUsedFaces: () => UsedFace[], documentFonts: string[] = []) { + return new FontReadinessGate({ + registry: new FaceRegistry().asRegistry(), + getDocumentFonts: () => documentFonts, + getUsedFaces, + requestReflow: vi.fn(), + invalidateCaches: vi.fn(), + getFontEnvironment: () => null, + }); + } + + it('returns one option per RENDERED font, ignoring declared-but-unused font-table rows and the defaults', () => { + const gate = makeOptionsGate(() => [regular('Aptos')], ['Calibri', 'Georgia', 'Aptos']); + + const options = gate.getDocumentFontOptions(); + + expect(options.map((o) => o.logicalFamily)).toEqual(['Aptos']); + expect(options[0].status).toBe('needs_font'); + }); + + it('returns [] when the document renders no fonts, even with declared families (no defaults leak in)', () => { + const gate = makeOptionsGate(() => [], ['Calibri', 'Arial']); + expect(gate.getDocumentFontOptions()).toEqual([]); + }); + + it('never throws (font UI must not break layout): returns [] when the used-faces read fails', () => { + const gate = makeOptionsGate(() => { + throw new Error('render plan unavailable'); + }); + expect(gate.getDocumentFontOptions()).toEqual([]); + }); +}); From 52027e884f6a77f3ffd87cd6433f2acb587262d8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 6 Jun 2026 09:38:54 -0300 Subject: [PATCH 4/4] test(toolbar): add font dropdown behavior coverage --- .../font-dropdown-document-options.spec.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts diff --git a/tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts b/tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts new file mode 100644 index 0000000000..bd83717da1 --- /dev/null +++ b/tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts @@ -0,0 +1,118 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +const FONT_OPTION = '[data-item="btn-fontFamily-option"]'; +const OPTION_LABEL = `${FONT_OPTION} .toolbar-dropdown-option__label`; + +async function openFontFamilyDropdown(superdoc: SuperDocFixture): Promise { + await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + await superdoc.page.locator(FONT_OPTION).first().waitFor({ state: 'visible', timeout: 5000 }); + await superdoc.waitForStable(); +} + +async function fontOptionLabels(superdoc: SuperDocFixture): Promise { + return (await superdoc.page.locator(OPTION_LABEL).allInnerTexts()).map((label) => label.trim()); +} + +async function expectFontFamilyDropdownClosed(superdoc: SuperDocFixture): Promise { + await expect(superdoc.page.locator(`${FONT_OPTION}:visible`)).toHaveCount(0); +} + +async function selectFontOption(superdoc: SuperDocFixture, label: string): Promise { + await superdoc.page + .locator(FONT_OPTION) + .filter({ has: superdoc.page.getByText(label, { exact: true }) }) + .click(); + await superdoc.waitForStable(); + await superdoc.page + .locator('.presentation-editor__viewport') + .first() + .click({ position: { x: 50, y: 50 } }); + await superdoc.waitForStable(); +} + +async function stubDocumentFontsAndNotify( + superdoc: SuperDocFixture, + options: Array<{ logicalFamily: string; previewFamily: string; status: string }>, +): Promise { + await superdoc.page.evaluate((opts) => { + const sd = (window as any).superdoc; + sd.fonts.getDocumentFontOptions = () => opts; + sd.toolbar.activeEditor.emit('fonts-changed'); + }, options); + await superdoc.waitForStable(); +} + +test('font dropdown opens immediately with the clean default list and an enabled control', async ({ superdoc }) => { + const fontFamily = superdoc.page.locator('[data-item="btn-fontFamily"]'); + await expect(fontFamily).not.toHaveClass(/sd-disabled/); + + await openFontFamilyDropdown(superdoc); + + const labels = await fontOptionLabels(superdoc); + for (const expected of ['Calibri', 'Arial', 'Courier New', 'Times New Roman', 'Helvetica']) { + expect(labels).toContain(expected); + } + for (const absent of ['Aptos', 'Georgia', 'Cambria', 'Calibri Light']) { + expect(labels).not.toContain(absent); + } +}); + +test('selecting a default font applies its logical Word-facing family to the selection', async ({ superdoc }) => { + await superdoc.type('Default font sample'); + await superdoc.waitForStable(); + + const pos = await superdoc.findTextPos('Default font sample'); + await superdoc.setTextSelection(pos, pos + 'Default font sample'.length); + await superdoc.waitForStable(); + + await openFontFamilyDropdown(superdoc); + await selectFontOption(superdoc, 'Helvetica'); + + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Helvetica'); + await superdoc.assertTextMarkAttrs('Default font sample', 'textStyle', { fontFamily: 'Helvetica' }); +}); + +test('a document-specific font reaches the live dropdown with its status and applies the logical family', async ({ + superdoc, +}) => { + await superdoc.type('Document font sample'); + await superdoc.waitForStable(); + + await stubDocumentFontsAndNotify(superdoc, [ + { logicalFamily: 'Aptos', previewFamily: 'Aptos', status: 'needs_font' }, + ]); + + const pos = await superdoc.findTextPos('Document font sample'); + await superdoc.setTextSelection(pos, pos + 'Document font sample'.length); + await superdoc.waitForStable(); + + await openFontFamilyDropdown(superdoc); + + const aptosOption = superdoc.page + .locator(FONT_OPTION) + .filter({ has: superdoc.page.getByText('Aptos', { exact: true }) }); + await expect(aptosOption.locator('.toolbar-dropdown-option__label')).toHaveText('Aptos'); + await expect(aptosOption.locator('.toolbar-dropdown-option__secondary')).toHaveText('Needs font'); + + await selectFontOption(superdoc, 'Aptos'); + + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Aptos'); + await superdoc.assertTextMarkAttrs('Document font sample', 'textStyle', { fontFamily: 'Aptos' }); +}); + +test('the dropdown refreshes on fonts-changed without a resize', async ({ superdoc }) => { + await openFontFamilyDropdown(superdoc); + expect(await fontOptionLabels(superdoc)).not.toContain('Aptos'); + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + await expectFontFamilyDropdownClosed(superdoc); + + await stubDocumentFontsAndNotify(superdoc, [ + { logicalFamily: 'Aptos', previewFamily: 'Aptos', status: 'needs_font' }, + ]); + + await openFontFamilyDropdown(superdoc); + expect(await fontOptionLabels(superdoc)).toContain('Aptos'); +});