diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 6d58932723..3b2d455bb8 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -20,6 +20,7 @@ import { isList } from '@core/commands/list-helpers'; import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; import { twipsToLines } from '@converter/helpers'; import { parseSizeUnit } from '@core/utilities'; +import { encodeMarksFromRPr } from '@core/super-converter/styles.js'; import { NodeSelection } from 'prosemirror-state'; /** @@ -899,9 +900,16 @@ export class SuperToolbar extends EventEmitter { state.doc.resolve(paragraphParent.pos), ) : null; + const selectionIsCollapsed = selection.empty; + const paragraphIsEmpty = paragraphParent?.node?.content?.size === 0; + const paragraphFontFamily = getParagraphFontFamilyFromProperties( + paragraphProps, + this.activeEditor?.converter?.convertedXml ?? {}, + ); this.toolbarItems.forEach((item) => { item.resetDisabled(); + let activatedFromLinkedStyle = false; if (item.name.value === 'undo') { item.setDisabled(this.undoDepth === 0); @@ -966,12 +974,25 @@ export class SuperToolbar extends EventEmitter { [item.name.value]: linkedStylesItem, }; item.activate(value); + activatedFromLinkedStyle = true; } } if (item.name.value === 'textAlign' && paragraphProps?.justification) { item.activate({ textAlign: paragraphProps.justification }); } + if ( + item.name.value === 'fontFamily' && + selectionIsCollapsed && + paragraphIsEmpty && + !activeMark && + !markNegated && + !activatedFromLinkedStyle && + paragraphFontFamily + ) { + item.activate({ fontFamily: paragraphFontFamily }); + } + if (item.name.value === 'lineHeight') { if (paragraphProps?.spacing) { item.selectedValue.value = twipsToLines(paragraphProps.spacing.line); @@ -1358,3 +1379,10 @@ export class SuperToolbar extends EventEmitter { } } } + +function getParagraphFontFamilyFromProperties(paragraphProps, convertedXml = {}) { + const fontFamilyProps = paragraphProps?.runProperties?.fontFamily; + if (!fontFamilyProps) return null; + const [markDef] = encodeMarksFromRPr({ fontFamily: fontFamilyProps }, convertedXml); + return markDef?.attrs?.fontFamily ?? null; +} diff --git a/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js b/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js index 3735abe2e0..934f00dcee 100644 --- a/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js +++ b/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js @@ -40,13 +40,18 @@ describe('updateToolbarState', () => { let mockGetQuickFormatList; let mockCollectTrackedChanges; let mockIsTrackedChangeActionAllowed; + let mockFindParentNode; + let mockCalculateResolvedParagraphProperties; beforeEach(async () => { vi.clearAllMocks(); mockEditor = { state: { - selection: { from: 1, to: 1 }, + selection: { from: 1, to: 1, empty: true }, + doc: { + resolve: vi.fn().mockReturnValue({}), + }, }, commands: { setFieldAnnotationsFontSize: vi.fn(), @@ -60,6 +65,7 @@ describe('updateToolbarState', () => { getDocumentDefaultStyles: vi.fn(() => ({ typeface: 'Arial', fontSizePt: 12 })), linkedStyles: [], docHiglightColors: new Set(['#ff0000', '#00ff00']), + convertedXml: {}, }, options: { mode: 'docx', @@ -79,6 +85,13 @@ describe('updateToolbarState', () => { const { collectTrackedChanges, isTrackedChangeActionAllowed } = await import( '@extensions/track-changes/permission-helpers.js' ); + const helpersModule = await import('@helpers/index.js'); + mockFindParentNode = helpersModule.findParentNode; + mockFindParentNode.mockImplementation(() => vi.fn().mockReturnValue(null)); + const resolvedPropsModule = await import('@extensions/paragraph/resolvedPropertiesCache.js'); + mockCalculateResolvedParagraphProperties = vi + .spyOn(resolvedPropsModule, 'calculateResolvedParagraphProperties') + .mockReturnValue({}); getActiveFormatting.mockImplementation(mockGetActiveFormatting); isInTable.mockImplementation(mockIsInTable); @@ -154,6 +167,7 @@ describe('updateToolbarState', () => { setDisabled: vi.fn(), defaultLabel: { value: '' }, allowWithoutEditor: { value: false }, + active: { value: false }, }, { name: { value: 'lineHeight' }, @@ -195,6 +209,10 @@ describe('updateToolbarState', () => { toolbar.documentMode = 'editing'; }); + afterEach(() => { + mockCalculateResolvedParagraphProperties?.mockRestore?.(); + }); + describe('document mode dropdown sync', () => { let documentModeItem; @@ -508,6 +526,79 @@ describe('updateToolbarState', () => { expect(fontFamilyItem.activate).not.toHaveBeenCalledWith({ fontFamily: 'Arial' }); }); + it('falls back to paragraph runProperties font family for empty paragraph with collapsed selection', () => { + const paragraphParent = { + node: { + content: { size: 0 }, + attrs: { paragraphProperties: {} }, + }, + pos: 5, + }; + + mockFindParentNode.mockImplementation(() => () => paragraphParent); + const paragraphFontFamily = 'Fancy Font, serif'; + mockCalculateResolvedParagraphProperties.mockReturnValue({ + runProperties: { fontFamily: { 'w:ascii': paragraphFontFamily } }, + }); + mockGetActiveFormatting.mockReturnValue([]); + + toolbar.updateToolbarState(); + + const fontFamilyItem = toolbar.toolbarItems.find((item) => item.name.value === 'fontFamily'); + expect(mockCalculateResolvedParagraphProperties).toHaveBeenCalled(); + expect(fontFamilyItem.activate).toHaveBeenCalledWith({ fontFamily: paragraphFontFamily }); + }); + + it('does not fallback to paragraph font when paragraph already contains text', () => { + const paragraphParent = { + node: { + content: { size: 1 }, + attrs: { paragraphProperties: {} }, + }, + pos: 5, + }; + + mockFindParentNode.mockImplementation(() => () => paragraphParent); + mockCalculateResolvedParagraphProperties.mockReturnValue({ + runProperties: { fontFamily: { 'w:ascii': 'Never Used' } }, + }); + mockGetActiveFormatting.mockReturnValue([]); + + toolbar.updateToolbarState(); + + const fontFamilyItem = toolbar.toolbarItems.find((item) => item.name.value === 'fontFamily'); + expect(fontFamilyItem.activate).not.toHaveBeenCalled(); + }); + + it('keeps linked style font family over paragraph fallback in empty paragraphs', () => { + const paragraphParent = { + node: { + content: { size: 0 }, + attrs: { paragraphProperties: {} }, + }, + pos: 5, + }; + + mockFindParentNode.mockImplementation(() => () => paragraphParent); + mockCalculateResolvedParagraphProperties.mockReturnValue({ + styleId: 'test-style', + runProperties: { fontFamily: { 'w:ascii': 'Paragraph Font, serif' } }, + }); + mockEditor.converter.linkedStyles = [ + { + id: 'test-style', + definition: { styles: { 'font-family': 'Linked Style Font' } }, + }, + ]; + mockGetActiveFormatting.mockReturnValue([]); + + toolbar.updateToolbarState(); + + const fontFamilyItem = toolbar.toolbarItems.find((item) => item.name.value === 'fontFamily'); + expect(fontFamilyItem.activate).toHaveBeenCalledWith({ fontFamily: 'Linked Style Font' }); + expect(fontFamilyItem.activate).not.toHaveBeenCalledWith({ fontFamily: 'Paragraph Font, serif' }); + }); + it('should prioritize active mark over linked styles (font size)', () => { mockGetActiveFormatting.mockReturnValue([ { name: 'fontSize', attrs: { fontSize: '20pt' } },