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:
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user