Tiptap RTE: Style Menu action toggles (#19520)

* Tiptap style menu toggles (for classes and IDs)

Fixes #19244

* Tiptap style menu toggles (for font/color)

Fixes #19508

* Tiptap "Clear Formatting" remove classes and styles

* Tiptap font sizes, removes trailing semicolon

as the API handles the delimiter

* Tiptap global attrs: adds set/unset styles commands
This commit is contained in:
Lee Kelleher
2025-06-11 08:21:21 +01:00
committed by GitHub
parent fe7f0558c1
commit 919b65ea19
9 changed files with 135 additions and 34 deletions

View File

@@ -64,6 +64,14 @@ export const HtmlGlobalAttributes = Extension.create<HtmlGlobalAttributesOptions
.map((type) => commands.updateAttributes(type, { class: className }))
.every((response) => response);
},
toggleClassName:
(className, type) =>
({ commands, editor }) => {
if (!className) return false;
const types = type ? [type] : this.options.types;
const existing = types.map((type) => editor.getAttributes(type)?.class as string).filter((x) => x);
return existing.length ? commands.unsetClassName(type) : commands.setClassName(className, type);
},
unsetClassName:
(type) =>
({ commands }) => {
@@ -77,12 +85,41 @@ export const HtmlGlobalAttributes = Extension.create<HtmlGlobalAttributesOptions
const types = type ? [type] : this.options.types;
return types.map((type) => commands.updateAttributes(type, { id })).every((response) => response);
},
toggleId:
(id, type) =>
({ commands, editor }) => {
if (!id) return false;
const types = type ? [type] : this.options.types;
const existing = types.map((type) => editor.getAttributes(type)?.id as string).filter((x) => x);
return existing.length ? commands.unsetId(type) : commands.setId(id, type);
},
unsetId:
(type) =>
({ commands }) => {
const types = type ? [type] : this.options.types;
return types.map((type) => commands.resetAttributes(type, 'id')).every((response) => response);
},
setStyles:
(style, type) =>
({ commands }) => {
if (!style) return false;
const types = type ? [type] : this.options.types;
return types.map((type) => commands.updateAttributes(type, { style })).every((response) => response);
},
toggleStyles:
(style, type) =>
({ commands, editor }) => {
if (!style) return false;
const types = type ? [type] : this.options.types;
const existing = types.map((type) => editor.getAttributes(type)?.style as string).filter((x) => x);
return existing.length ? commands.unsetStyles(type) : commands.setStyles(style, type);
},
unsetStyles:
(type) =>
({ commands }) => {
const types = type ? [type] : this.options.types;
return types.map((type) => commands.resetAttributes(type, 'style')).every((response) => response);
},
};
},
});
@@ -91,9 +128,14 @@ declare module '@tiptap/core' {
interface Commands<ReturnType> {
htmlGlobalAttributes: {
setClassName: (className?: string, type?: string) => ReturnType;
toggleClassName: (className?: string, type?: string) => ReturnType;
unsetClassName: (type?: string) => ReturnType;
setId: (id?: string, type?: string) => ReturnType;
toggleId: (id?: string, type?: string) => ReturnType;
unsetId: (type?: string) => ReturnType;
setStyles: (style?: string, type?: string) => ReturnType;
toggleStyles: (style?: string, type?: string) => ReturnType;
unsetStyles: (type?: string) => ReturnType;
};
}
}

View File

@@ -9,6 +9,31 @@ export interface SpanOptions {
HTMLAttributes: Record<string, any>;
}
function parseStyles(style: string | undefined): Record<string, string> {
const items: Record<string, string> = {};
(style ?? '')
.split(';')
.map((x) => x.trim())
.filter((x) => x)
.forEach((rule) => {
const [key, value] = rule.split(':');
if (key && value) {
items[key.trim()] = value.trim();
}
});
return items;
}
function serializeStyles(items: Record<string, string>): string {
return (
Object.entries(items)
.map(([key, value]) => `${key}: ${value}`)
.join(';') + ';'
);
}
export const Span = Mark.create<SpanOptions>({
name: 'span',
@@ -32,29 +57,61 @@ export const Span = Mark.create<SpanOptions>({
if (!styles) return false;
const existing = editor.getAttributes(this.name)?.style as string;
if (!existing && !editor.isActive(this.name)) {
return commands.setMark(this.name, { style: styles });
}
const rules = ((existing ?? '') + ';' + styles).split(';');
const items: Record<string, string> = {};
const items = {
...parseStyles(existing),
...parseStyles(styles),
};
rules
.filter((x) => x)
.forEach((rule) => {
if (rule.trim() !== '') {
const [key, value] = rule.split(':');
items[key.trim()] = value.trim();
}
});
const style = Object.entries(items)
.map(([key, value]) => `${key}: ${value}`)
.join(';');
const style = serializeStyles(items);
if (style === ';') return false;
return commands.updateAttributes(this.name, { style });
},
toggleSpanStyle:
(styles) =>
({ commands, editor }) => {
if (!styles) return false;
const existing = editor.getAttributes(this.name)?.style as string;
return existing?.includes(styles) === true ? commands.unsetSpanStyle(styles) : commands.setSpanStyle(styles);
},
unsetSpanStyle:
(styles) =>
({ commands, editor }) => {
if (!styles) return false;
parseStyles(styles);
const toBeRemoved = new Set<string>();
styles
.split(';')
.map((x) => x.trim())
.filter((x) => x)
.forEach((rule) => {
const [key] = rule.split(':');
if (key) toBeRemoved.add(key.trim());
});
if (toBeRemoved.size === 0) return false;
const existing = editor.getAttributes(this.name)?.style as string;
const items = parseStyles(existing);
// Remove keys
for (const key of toBeRemoved) {
delete items[key];
}
const style = serializeStyles(items);
return style === ';'
? commands.resetAttributes(this.name, 'style')
: commands.updateAttributes(this.name, { style });
},
};
},
});
@@ -63,6 +120,8 @@ declare module '@tiptap/core' {
interface Commands<ReturnType> {
span: {
setSpanStyle: (styles?: string) => ReturnType;
toggleSpanStyle: (styles?: string) => ReturnType;
unsetSpanStyle: (styles?: string) => ReturnType;
};
}
}

View File

@@ -15,12 +15,12 @@ export default class UmbTiptapToolbarStyleMenuApi extends UmbTiptapToolbarElemen
code: { type: 'code', command: (chain) => chain.toggleCode() },
codeBlock: { type: 'codeBlock', command: (chain) => chain.toggleCodeBlock() },
div: { type: 'div', command: (chain) => chain.toggleNode('div', 'paragraph') },
em: { type: 'italic', command: (chain) => chain.setItalic() },
em: { type: 'italic', command: (chain) => chain.toggleItalic() },
ol: { type: 'orderedList', command: (chain) => chain.toggleOrderedList() },
strong: { type: 'bold', command: (chain) => chain.setBold() },
s: { type: 'strike', command: (chain) => chain.setStrike() },
strong: { type: 'bold', command: (chain) => chain.toggleBold() },
s: { type: 'strike', command: (chain) => chain.toggleStrike() },
span: { type: 'span', command: (chain) => chain.toggleMark('span') },
u: { type: 'underline', command: (chain) => chain.setUnderline() },
u: { type: 'underline', command: (chain) => chain.toggleUnderline() },
ul: { type: 'bulletList', command: (chain) => chain.toggleBulletList() },
};
@@ -29,6 +29,6 @@ export default class UmbTiptapToolbarStyleMenuApi extends UmbTiptapToolbarElemen
const { tag, id, class: className } = item.data;
const focus = editor.chain().focus();
const ext = tag ? this.#commands[tag] : null;
(ext?.command?.(focus) ?? focus).setId(id, ext?.type).setClassName(className, ext?.type).run();
(ext?.command?.(focus) ?? focus).toggleId(id, ext?.type).toggleClassName(className, ext?.type).run();
}
}

View File

@@ -611,15 +611,15 @@ const toolbarExtensions: Array<UmbExtensionManifest> = [
name: 'Font Size Tiptap Extension',
api: () => import('./toolbar/font-size.tiptap-toolbar-api.js'),
items: [
{ label: '8pt', data: '8pt;' },
{ label: '10pt', data: '10pt;' },
{ label: '12pt', data: '12pt;' },
{ label: '14pt', data: '14pt;' },
{ label: '16pt', data: '16pt;' },
{ label: '18pt', data: '18pt;' },
{ label: '24pt', data: '24pt;' },
{ label: '26pt', data: '26pt;' },
{ label: '48pt', data: '48pt;' },
{ label: '8pt', data: '8pt' },
{ label: '10pt', data: '10pt' },
{ label: '12pt', data: '12pt' },
{ label: '14pt', data: '14pt' },
{ label: '16pt', data: '16pt' },
{ label: '18pt', data: '18pt' },
{ label: '24pt', data: '24pt' },
{ label: '26pt', data: '26pt' },
{ label: '48pt', data: '48pt' },
],
meta: {
alias: 'umbFontSize',

View File

@@ -3,6 +3,6 @@ import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarClearFormattingExtensionApi extends UmbTiptapToolbarElementApiBase {
override execute(editor?: Editor) {
editor?.chain().focus().unsetAllMarks().run();
editor?.chain().focus().unsetAllMarks().unsetClassName().unsetStyles().run();
}
}

View File

@@ -5,6 +5,6 @@ import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarFontFamilyExtensionApi extends UmbTiptapToolbarElementApiBase {
override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
if (!item?.data) return;
editor?.chain().focus().setSpanStyle(`font-family: ${item.data};`).run();
editor?.chain().focus().toggleSpanStyle(`font-family: ${item.data};`).run();
}
}

View File

@@ -5,6 +5,6 @@ import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarFontFamilyExtensionApi extends UmbTiptapToolbarElementApiBase {
override execute(editor?: Editor, item?: MetaTiptapToolbarMenuItem) {
if (!item?.data) return;
editor?.chain().focus().setSpanStyle(`font-size: ${item.data};`).run();
editor?.chain().focus().toggleSpanStyle(`font-size: ${item.data};`).run();
}
}

View File

@@ -3,6 +3,6 @@ import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarTextColorBackgroundExtensionApi extends UmbTiptapToolbarElementApiBase {
override execute(editor?: Editor, selectedColor?: string) {
editor?.chain().focus().setSpanStyle(`background-color: ${selectedColor};`).run();
editor?.chain().focus().toggleSpanStyle(`background-color: ${selectedColor};`).run();
}
}

View File

@@ -3,6 +3,6 @@ import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapToolbarTextColorForegroundExtensionApi extends UmbTiptapToolbarElementApiBase {
override execute(editor?: Editor, selectedColor?: string) {
editor?.chain().focus().setSpanStyle(`color: ${selectedColor};`).run();
editor?.chain().focus().toggleSpanStyle(`color: ${selectedColor};`).run();
}
}