From 7fac7275c6ad1c2ba83409555e53d51bf3e15ffe Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 11:46:59 -0300 Subject: [PATCH 1/2] fix(toolbar): show correct paragraph font family for empty selection --- .../src/components/toolbar/super-toolbar.js | 26 ++++++++ .../tests/toolbar/updateToolbarState.test.js | 64 ++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 6d58932723..7adbfde554 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,6 +900,12 @@ 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(); @@ -972,6 +979,18 @@ export class SuperToolbar extends EventEmitter { item.activate({ textAlign: paragraphProps.justification }); } + if ( + item.name.value === 'fontFamily' && + selectionIsCollapsed && + paragraphIsEmpty && + !activeMark && + !markNegated && + !item.active.value && + paragraphFontFamily + ) { + item.activate({ fontFamily: paragraphFontFamily }); + } + if (item.name.value === 'lineHeight') { if (paragraphProps?.spacing) { item.selectedValue.value = twipsToLines(paragraphProps.spacing.line); @@ -1358,3 +1377,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..bd9ef22216 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,50 @@ 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('should prioritize active mark over linked styles (font size)', () => { mockGetActiveFormatting.mockReturnValue([ { name: 'fontSize', attrs: { fontSize: '20pt' } }, From c405a196249c73c1ed5a0459573998fee085a81c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 12:52:03 -0300 Subject: [PATCH 2/2] fix(toolbar): preserve linked style font over empty paragraph fallback --- .../src/components/toolbar/super-toolbar.js | 4 ++- .../tests/toolbar/updateToolbarState.test.js | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/components/toolbar/super-toolbar.js b/packages/super-editor/src/components/toolbar/super-toolbar.js index 7adbfde554..3b2d455bb8 100644 --- a/packages/super-editor/src/components/toolbar/super-toolbar.js +++ b/packages/super-editor/src/components/toolbar/super-toolbar.js @@ -909,6 +909,7 @@ export class SuperToolbar extends EventEmitter { this.toolbarItems.forEach((item) => { item.resetDisabled(); + let activatedFromLinkedStyle = false; if (item.name.value === 'undo') { item.setDisabled(this.undoDepth === 0); @@ -973,6 +974,7 @@ export class SuperToolbar extends EventEmitter { [item.name.value]: linkedStylesItem, }; item.activate(value); + activatedFromLinkedStyle = true; } } if (item.name.value === 'textAlign' && paragraphProps?.justification) { @@ -985,7 +987,7 @@ export class SuperToolbar extends EventEmitter { paragraphIsEmpty && !activeMark && !markNegated && - !item.active.value && + !activatedFromLinkedStyle && paragraphFontFamily ) { item.activate({ fontFamily: paragraphFontFamily }); diff --git a/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js b/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js index bd9ef22216..934f00dcee 100644 --- a/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js +++ b/packages/super-editor/src/tests/toolbar/updateToolbarState.test.js @@ -570,6 +570,35 @@ describe('updateToolbarState', () => { 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' } },