Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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;
Expand Down Expand Up @@ -78,21 +82,66 @@ onMounted(() => {
>
{{ style.definition.attrs.name }}
</div>
<button
type="button"
class="style-update"
:title="`Update ${style.definition.attrs.name} to match selection`"
:aria-label="`Update ${style.definition.attrs.name} to match selection`"
data-item="btn-linkedStyles-update"
@click.stop="update(style)"
>
</button>
</div>
</div>
</template>

<style scoped>
.style-item {
display: flex;
align-items: center;
justify-content: space-between;
}

.style-name {
flex: 1;
min-width: 0;
padding: 16px 10px;
color: var(--sd-ui-dropdown-text, #47484a);
}

.style-item:hover {
background-color: var(--sd-ui-dropdown-hover-bg, #d8dee5);
}

.style-name:hover {
background-color: var(--sd-ui-dropdown-hover-bg, #d8dee5);
color: var(--sd-ui-dropdown-hover-text, #47484a);
}

.style-update {
flex-shrink: 0;
margin-right: 8px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--sd-ui-dropdown-text, #47484a);
font-size: 14px;
line-height: 1;
cursor: pointer;
visibility: hidden;
}

.style-item:hover .style-update,
.style-update:focus {
visibility: visible;
}

.style-update:hover {
background-color: var(--sd-ui-active-bg, #c3cdd8);
}

.linked-style-buttons {
display: flex;
flex-direction: column;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1009,10 +1009,25 @@ export const makeDefaultItems = ({
selectedLinkedStyle.value = style.id;
};

// "Update <style> to match selection": redefine the named style's look
// from the current selection (Google-Docs behaviour). Goes straight to
// the editor command + helper rather than the toolbar's single-argument
// emitCommand path, since this takes (styleId, formatting).
const handleUpdate = (style) => {
closeDropdown(linkedStyles);
const editor = superToolbar.activeEditor;
if (!editor || !style?.id) return;
const formatting = editor.helpers?.linkedStyles?.getEffectiveFormattingAtSelection?.();
if (!formatting) return;
editor.commands?.updateLinkedStyle?.(style.id, formatting);
editor.view?.focus?.();
};

return h('div', {}, [
h(LinkedStyle, {
editor: superToolbar.activeEditor,
onSelect: handleSelect,
onUpdate: handleUpdate,
selectedOption: selectedLinkedStyle.value,
}),
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3569,6 +3569,19 @@ export class PresentationEditor extends EventEmitter {
this.#scheduleRerender();
}

/**
* Re-render after a linked style's definition was redefined in place
* (e.g. "Update Heading 1 to match"). The document did not change, so the
* FlowBlockCache — keyed on node identity — would otherwise return stale
* blocks resolved against the old style. Clear it and re-layout, mirroring
* other non-edit render changes (see {@link setShowBookmarks}).
*/
refreshLinkedStyles(): void {
this.#flowBlockCache?.clear();
this.#pendingDocChange = true;
this.#scheduleRerender();
}

setShowFormattingMarks(showFormattingMarks: boolean): void {
const next = !!showFormattingMarks;
if (this.#layoutOptions.showFormattingMarks === next) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { applyLinkedStyleToTransaction, generateLinkedStyleString } from './help
import { createLinkedStylesPlugin, LinkedStylesPluginKey } from './plugin.js';
import { findParentNodeClosestToPos } from '@core/helpers';
import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js';
import { updateLinkedStyleDefinition, readEffectiveRunFormatting } from './update-linked-style.js';
import { definitionStylesToFormatting } from './style-formatting.js';

/**
* Style definition from Word document
Expand Down Expand Up @@ -104,6 +106,17 @@ export const LinkedStyles = Extension.create({

return applyLinkedStyleToTransaction(tr, this.editor, style);
},

/**
* Redefine a named paragraph style's look (Google-Docs "Update to match").
* @category Command
* @param {string} styleId - The style ID to redefine (e.g., 'Heading1')
* @param {Object} formatting - CapturedFormatting (bold, italic, underline, fontSizePt, fontFamily, colorHex)
* @example
* editor.commands.updateLinkedStyle('Heading1', { bold: true, fontSizePt: 28, colorHex: '1F3864' });
* @note Updates every paragraph using the style; never throws.
*/
updateLinkedStyle: (styleId, formatting) => () => updateLinkedStyleDefinition(this.editor, styleId, formatting),
};
},

Expand Down Expand Up @@ -151,6 +164,30 @@ export const LinkedStyles = Extension.create({
if (!style) return '';
return generateLinkedStyleString(style);
},

/**
* Read a named style's current run formatting as CapturedFormatting.
* @category Helper
* @param {string} styleId - The style ID to read
* @returns {Object|null} CapturedFormatting, or null when the style is unknown
* @example
* const fmt = editor.helpers.linkedStyles.getLinkedStyleFormatting('Heading1');
*/
getLinkedStyleFormatting: (styleId) => {
const style = this.editor.helpers[this.name].getStyleById(styleId);
if (!style?.definition?.styles) return null;
return definitionStylesToFormatting(style.definition.styles);
},

/**
* Read the run formatting at the current selection as CapturedFormatting.
* @category Helper
* @returns {Object} CapturedFormatting describing the current selection
* @example
* const fmt = editor.helpers.linkedStyles.getEffectiveFormattingAtSelection();
* editor.commands.updateLinkedStyle('Heading1', fmt);
*/
getEffectiveFormattingAtSelection: () => readEffectiveRunFormatting(this.editor),
};
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export const createLinkedStylesPlugin = (editor) => {
if (editor.presentationEditor) {
return { ...prev, decorations: DecorationSet.empty };
}

// Redefinition signal: a style's definition was mutated in place (no doc
// change). Regenerate decorations from the current styles so the
// redefinition becomes visible without faking a document edit.
if (tr.getMeta(LinkedStylesPluginKey)?.stylesChanged) {
const styles = editor.converter?.linkedStyles || [];
return { ...prev, styles, decorations: generateDecorations(newEditorState, styles) };
}

let decorations = prev.decorations || DecorationSet.empty;

// Only regenerate decorations when styles are affected
Expand Down
Loading
Loading