From 4e1fd200663f84dfb177035f6fc43e88bc22f5ef Mon Sep 17 00:00:00 2001 From: Shri H Date: Fri, 19 Jun 2026 11:12:59 +0100 Subject: [PATCH 1/2] feat(linked-styles): redefine named paragraph styles ("Update to match") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a supported way to redefine a named paragraph style's run-level look (font family, size, weight, color) so it updates live and survives .docx export — the editor equivalent of Google Docs' "Update Heading 1 to match". - editor.commands.updateLinkedStyle(styleId, formatting) rewrites the style across all three converter representations (linkedStyles definition, translatedLinkedStyles, word/styles.xml) under one snapshot/rollback; never throws. - Read helpers: getLinkedStyleFormatting(styleId) and getEffectiveFormattingAtSelection(). - Repaint on every redefinition in both render modes — a linked-styles plugin meta signal (ProseMirror decorations) plus PresentationEditor.refreshLinkedStyles() (clears the FlowBlockCache and re-lays out the painted view). - Styles dropdown gains a per-row "Update to match" (pencil) action. Pure shape conversions are isolated in style-formatting.js. Covered by unit tests (pure mappers) and integration tests (engine, command, helpers, repaint, and export round-trip). --- .../v1/components/toolbar/LinkedStyle.test.js | 38 +++ .../v1/components/toolbar/LinkedStyle.vue | 51 +++- .../v1/components/toolbar/defaultItems.js | 15 + .../presentation-editor/PresentationEditor.ts | 13 + .../extensions/linked-styles/linked-styles.js | 37 +++ .../v1/extensions/linked-styles/plugin.js | 9 + .../linked-styles/style-formatting.js | 188 +++++++++++++ .../linked-styles/style-formatting.test.js | 145 ++++++++++ .../linked-styles/update-linked-style.js | 127 +++++++++ .../linked-styles/update-linked-style.test.js | 263 ++++++++++++++++++ 10 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/linked-styles/style-formatting.js create mode 100644 packages/super-editor/src/editors/v1/extensions/linked-styles/style-formatting.test.js create mode 100644 packages/super-editor/src/editors/v1/extensions/linked-styles/update-linked-style.js create mode 100644 packages/super-editor/src/editors/v1/extensions/linked-styles/update-linked-style.test.js diff --git a/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.test.js b/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.test.js new file mode 100644 index 0000000000..9ca4b21559 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; + +// Mock the extension helpers so the component can mount without a real editor. +vi.mock('@extensions/linked-styles/index.js', () => ({ + getQuickFormatList: () => [ + { id: 'Normal', definition: { attrs: { name: 'Normal' }, styles: {} } }, + { id: 'Heading1', definition: { attrs: { name: 'Heading 1' }, styles: {} } }, + ], + generateLinkedStyleString: () => '', +})); + +import LinkedStyle from './LinkedStyle.vue'; + +describe('LinkedStyle dropdown', () => { + let wrapper; + beforeEach(() => { + wrapper = mount(LinkedStyle, { props: { editor: {}, selectedOption: 'Normal' } }); + }); + + it('emits "select" with the style when a row name is clicked', async () => { + await wrapper.findAll('.style-name')[1].trigger('click'); + const events = wrapper.emitted('select'); + expect(events).toBeTruthy(); + expect(events[0][0].id).toBe('Heading1'); + }); + + it('emits "update" with the style when the row update action is clicked', async () => { + const updateBtn = wrapper.findAll('[data-item="btn-linkedStyles-update"]')[1]; + expect(updateBtn.exists()).toBe(true); + await updateBtn.trigger('click'); + const events = wrapper.emitted('update'); + expect(events).toBeTruthy(); + expect(events[0][0].id).toBe('Heading1'); + // Clicking update must NOT also apply the style. + expect(wrapper.emitted('select')).toBeFalsy(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.vue b/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.vue index 8487d98906..6745fa2bfb 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.vue +++ b/packages/super-editor/src/editors/v1/components/toolbar/LinkedStyle.vue @@ -3,7 +3,7 @@ import { computed, ref, onMounted } from 'vue'; import { toolbarIcons } from './toolbarIcons.js'; import { generateLinkedStyleString, getQuickFormatList } from '@extensions/linked-styles/index.js'; -const emit = defineEmits(['select']); +const emit = defineEmits(['select', 'update']); const styleRefs = ref([]); const props = defineProps({ editor: { @@ -19,6 +19,10 @@ const select = (style) => { emit('select', style); }; +const update = (style) => { + emit('update', style); +}; + const moveToNextStyle = (index) => { if (index === styleRefs.value.length - 1) { return; @@ -78,21 +82,66 @@ onMounted(() => { > {{ style.definition.attrs.name }} +