Merge remote-tracking branch 'origin/v15/feature/tiptap' into tiptap/feat/toolbar

This commit is contained in:
JesmoDev
2024-09-16 21:12:00 +02:00
12 changed files with 242 additions and 107 deletions

View File

@@ -42,6 +42,7 @@ import type { ManifestSectionView } from './section-view.model.js';
import type { ManifestStore, ManifestTreeStore, ManifestItemStore } from './store.model.js';
import type { ManifestTheme } from './theme.model.js';
import type { ManifestTinyMcePlugin } from './tinymce-plugin.model.js';
import type { ManifestTiptapExtension } from './tiptap-extension.model.js';
import type { ManifestTree } from './tree.model.js';
import type { ManifestTreeItem } from './tree-item.model.js';
import type { ManifestUfmComponent } from './ufm-component.model.js';
@@ -113,6 +114,7 @@ export type * from './section.model.js';
export type * from './store.model.js';
export type * from './theme.model.js';
export type * from './tinymce-plugin.model.js';
export type * from './tiptap-extension.model.js';
export type * from './tree-item.model.js';
export type * from './tree.model.js';
export type * from './ufm-component.model.js';
@@ -206,6 +208,7 @@ export type ManifestTypes =
| ManifestStore
| ManifestTheme
| ManifestTinyMcePlugin
| ManifestTiptapExtension
| ManifestTree
| ManifestTreeItem
| ManifestTreeStore

View File

@@ -0,0 +1,6 @@
import type { UmbTiptapExtensionBase } from '@umbraco-cms/backoffice/tiptap';
import type { ManifestApi } from '@umbraco-cms/backoffice/extension-api';
export interface ManifestTiptapExtension extends ManifestApi<UmbTiptapExtensionBase> {
type: 'tiptapExtension';
}

View File

@@ -1,3 +1,4 @@
import { manifests as tiptapManifests } from './tiptap/manifests.js';
import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
export const manifests = [...tiptapManifests];
export const manifests: Array<ManifestTypes> = [...tiptapManifests];

View File

@@ -1 +1,2 @@
export * from './input-tiptap.element.js';
export * from './tiptap-extension.js';

View File

@@ -1,41 +1,65 @@
import type { UmbTiptapFixedMenuElement } from './tiptap-fixed-menu.element.js';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, property, query, state } from '@umbraco-cms/backoffice/external/lit';
import type { UmbTiptapExtensionBase } from './tiptap-extension.js';
import { css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import { loadManifestApi } from '@umbraco-cms/backoffice/extension-api';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { Editor, Link, StarterKit, TextAlign, Underline } from '@umbraco-cms/backoffice/external/tiptap';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
import './tiptap-fixed-menu.element.js';
import './tiptap-hover-menu.element.js';
import { Editor, Link, StarterKit, TextAlign, Underline } from '@umbraco-cms/backoffice/external/tiptap';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
@customElement('umb-input-tiptap')
export class UmbInputTiptapElement extends UUIFormControlMixin(UmbLitElement, '') {
@query('umb-tiptap-fixed-menu') _fixedMenuElement!: UmbTiptapFixedMenuElement;
export class UmbInputTiptapElement extends UmbFormControlMixin(UmbLitElement, '') {
@state()
private _extensions: Array<UmbTiptapExtensionBase> = [];
@property({ attribute: false })
configuration?: UmbPropertyEditorConfigCollection;
@state()
_editor!: Editor;
private _editor!: Editor;
protected override firstUpdated(): void {
const editor = this.shadowRoot?.querySelector('#editor');
protected override async firstUpdated() {
await Promise.all([await this.#loadExtensions(), await this.#loadEditor()]);
}
if (!editor) return;
async #loadExtensions() {
await new Promise<void>((resolve) => {
this.observe(umbExtensionsRegistry.byType('tiptapExtension'), async (manifests) => {
this._extensions = [];
for (const manifest of manifests) {
if (manifest.api) {
const extension = await loadManifestApi(manifest.api);
if (extension) {
this._extensions.push(new extension(this));
}
}
}
this.requestUpdate('_extensions');
resolve();
});
});
}
async #loadEditor() {
const element = this.shadowRoot?.querySelector('#editor');
if (!element) return;
const toolbar = this.configuration?.getValueByAlias<string[]>('toolbar');
const maxWidth = this.configuration?.getValueByAlias<number>('maxWidth');
const maxHeight = this.configuration?.getValueByAlias<number>('maxHeight');
const mode = this.configuration?.getValueByAlias<string>('mode');
this.setAttribute('style', `max-width: ${maxWidth}px;`);
editor.setAttribute('style', `max-height: ${maxHeight}px;`);
element.setAttribute('style', `max-height: ${maxHeight}px;`);
if (!editor) return;
const extensions = this._extensions.map((ext) => ext.getExtensions()).flat();
this._editor = new Editor({
element: editor,
element: element,
extensions: [
StarterKit,
TextAlign.configure({
@@ -43,6 +67,7 @@ export class UmbInputTiptapElement extends UUIFormControlMixin(UmbLitElement, ''
}),
Link.configure({ openOnClick: false }),
Underline,
...extensions,
],
content: this.value.toString(),
onUpdate: ({ editor }) => {
@@ -52,13 +77,10 @@ export class UmbInputTiptapElement extends UUIFormControlMixin(UmbLitElement, ''
});
}
protected getFormElement() {
return null;
}
override render() {
if (!this._extensions?.length) return html`<uui-loader></uui-loader>`;
return html`
<umb-tiptap-fixed-menu .editor=${this._editor}></umb-tiptap-fixed-menu>
<umb-tiptap-fixed-menu .editor=${this._editor} .extensions=${this._extensions}></umb-tiptap-fixed-menu>
<div id="editor"></div>
`;
}

View File

@@ -0,0 +1,22 @@
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { Editor, Extension, Mark, Node } from '@umbraco-cms/backoffice/external/tiptap';
import type { TemplateResult } from '@umbraco-cms/backoffice/external/lit';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
export abstract class UmbTiptapExtensionBase extends UmbControllerBase implements UmbApi {
constructor(host: UmbControllerHost) {
super(host);
}
abstract getExtensions(): Array<Extension | Mark | Node>;
abstract getToolbarButtons(): Array<UmbTiptapToolbarButton>;
}
export interface UmbTiptapToolbarButton {
name: string;
icon: string | TemplateResult;
isActive: (editor?: Editor) => boolean | undefined;
command: (editor?: Editor) => boolean | undefined | void | Promise<boolean> | Promise<undefined> | Promise<void>;
}

View File

@@ -1,29 +1,13 @@
import {
alignCenter,
alignJustify,
alignLeft,
alignRight,
blockquote,
bold,
bulletList,
code,
heading1,
heading2,
heading3,
horizontalRule,
italic,
link,
orderedList,
strikethrough,
underline,
} from './icons.js';
import { LitElement, css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import * as icons from './icons.js';
import type { UmbTiptapExtensionBase, UmbTiptapToolbarButton } from './tiptap-extension.js';
import { css, customElement, html, property, state, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
@customElement('umb-tiptap-fixed-menu')
export class UmbTiptapFixedMenuElement extends LitElement {
export class UmbTiptapFixedMenuElement extends UmbLitElement {
@state()
actions = [
actions: Array<UmbTiptapToolbarButton> = [
// TODO: I don't think we need a paragraph button. It's the default state.
// {
// name: 'paragraph',
@@ -31,107 +15,108 @@ export class UmbTiptapFixedMenuElement extends LitElement {
// <path fill="none" d="M0 0h24v24H0z" />
// <path d="M12 6v15h-2v-5a6 6 0 1 1 0-12h10v2h-3v15h-2V6h-3zm-2 0a4 4 0 1 0 0 8V6z" fill="currentColor" />
// </svg>`,
// command: () => this.editor?.chain().focus().setParagraph().run(),
// command: (editor) => editor?.chain().focus().setParagraph().run(),
// },
{
name: 'bold',
icon: bold,
isActive: () => this.editor?.isActive('bold'),
command: () => this.editor?.chain().focus().toggleBold().run(),
icon: icons.bold,
isActive: (editor) => editor?.isActive('bold'),
command: (editor) => editor?.chain().focus().toggleBold().run(),
},
{
name: 'italic',
icon: italic,
isActive: () => this.editor?.isActive('italic'),
command: () => this.editor?.chain().focus().toggleItalic().run(),
icon: icons.italic,
isActive: (editor) => editor?.isActive('italic'),
command: (editor) => editor?.chain().focus().toggleItalic().run(),
},
{
name: 'underline',
icon: underline,
isActive: () => this.editor?.isActive('underline'),
command: () => this.editor?.chain().focus().toggleUnderline().run(),
icon: icons.underline,
isActive: (editor) => editor?.isActive('underline'),
command: (editor) => editor?.chain().focus().toggleUnderline().run(),
},
{
name: 'strikethrough',
icon: strikethrough,
isActive: () => this.editor?.isActive('strike'),
command: () => this.editor?.chain().focus().toggleStrike().run(),
icon: icons.strikethrough,
isActive: (editor) => editor?.isActive('strike'),
command: (editor) => editor?.chain().focus().toggleStrike().run(),
},
{
name: 'h1',
icon: heading1,
isActive: () => this.editor?.isActive('heading', { level: 1 }),
command: () => this.editor?.chain().focus().toggleHeading({ level: 1 }).run(),
icon: icons.heading1,
isActive: (editor) => editor?.isActive('heading', { level: 1 }),
command: (editor) => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
name: 'h2',
icon: heading2,
isActive: () => this.editor?.isActive('heading', { level: 2 }),
command: () => this.editor?.chain().focus().toggleHeading({ level: 2 }).run(),
icon: icons.heading2,
isActive: (editor) => editor?.isActive('heading', { level: 2 }),
command: (editor) => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
name: 'h3',
icon: heading3,
isActive: () => this.editor?.isActive('heading', { level: 3 }),
command: () => this.editor?.chain().focus().toggleHeading({ level: 3 }).run(),
icon: icons.heading3,
isActive: (editor) => editor?.isActive('heading', { level: 3 }),
command: (editor) => editor?.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
name: 'blockquote',
icon: blockquote,
isActive: () => this.editor?.isActive('blockquote'),
command: () => this.editor?.chain().focus().toggleBlockquote().run(),
icon: icons.blockquote,
isActive: (editor) => editor?.isActive('blockquote'),
command: (editor) => editor?.chain().focus().toggleBlockquote().run(),
},
{
name: 'code',
icon: code,
isActive: () => this.editor?.isActive('codeBlock'),
command: () => this.editor?.chain().focus().toggleCodeBlock().run(),
icon: icons.code,
isActive: (editor) => editor?.isActive('codeBlock'),
command: (editor) => editor?.chain().focus().toggleCodeBlock().run(),
},
{
name: 'bullet-list',
icon: bulletList,
isActive: () => this.editor?.isActive('bulletList'),
command: () => this.editor?.chain().focus().toggleBulletList().run(),
icon: icons.bulletList,
isActive: (editor) => editor?.isActive('bulletList'),
command: (editor) => editor?.chain().focus().toggleBulletList().run(),
},
{
name: 'ordered-list',
icon: orderedList,
isActive: () => this.editor?.isActive('orderedList'),
command: () => this.editor?.chain().focus().toggleOrderedList().run(),
icon: icons.orderedList,
isActive: (editor) => editor?.isActive('orderedList'),
command: (editor) => editor?.chain().focus().toggleOrderedList().run(),
},
{
name: 'horizontal-rule',
icon: horizontalRule,
isActive: () => this.editor?.isActive('horizontalRule'),
command: () => this.editor?.chain().focus().setHorizontalRule().run(),
icon: icons.horizontalRule,
isActive: (editor) => editor?.isActive('horizontalRule'),
command: (editor) => editor?.chain().focus().setHorizontalRule().run(),
},
{
name: 'align-left',
icon: alignLeft,
isActive: () => this.editor?.isActive({ textAlign: 'left' }),
command: () => this.editor?.chain().focus().setTextAlign('left').run(),
icon: icons.alignLeft,
isActive: (editor) => editor?.isActive({ textAlign: 'left' }),
command: (editor) => editor?.chain().focus().setTextAlign('left').run(),
},
{
name: 'align-center',
icon: alignCenter,
isActive: () => this.editor?.isActive({ textAlign: 'center' }),
command: () => this.editor?.chain().focus().setTextAlign('center').run(),
icon: icons.alignCenter,
isActive: (editor) => editor?.isActive({ textAlign: 'center' }),
command: (editor) => editor?.chain().focus().setTextAlign('center').run(),
},
{
name: 'align-right',
icon: alignRight,
isActive: () => this.editor?.isActive({ textAlign: 'right' }),
command: () => this.editor?.chain().focus().setTextAlign('right').run(),
icon: icons.alignRight,
isActive: (editor) => editor?.isActive({ textAlign: 'right' }),
command: (editor) => editor?.chain().focus().setTextAlign('right').run(),
},
{
name: 'align-justify',
icon: alignJustify,
isActive: () => this.editor?.isActive({ textAlign: 'justify' }),
command: () => this.editor?.chain().focus().setTextAlign('justify').run(),
icon: icons.alignJustify,
isActive: (editor) => editor?.isActive({ textAlign: 'justify' }),
command: (editor) => editor?.chain().focus().setTextAlign('justify').run(),
},
{
name: 'link',
icon: link,
icon: icons.link,
isActive: (editor) => editor?.isActive('link'),
command: () => {
const text = prompt('Enter the text');
const url = prompt('Enter the URL');
@@ -151,9 +136,6 @@ export class UmbTiptapFixedMenuElement extends LitElement {
];
@property({ attribute: false })
get editor() {
return this.#editor;
}
set editor(value) {
const oldValue = this.#editor;
if (value === oldValue) {
@@ -163,18 +145,36 @@ export class UmbTiptapFixedMenuElement extends LitElement {
this.#editor?.on('selectionUpdate', this.#onUpdate);
this.#editor?.on('update', this.#onUpdate);
}
get editor() {
return this.#editor;
}
#editor?: Editor;
@property({ attribute: false })
extensions: Array<UmbTiptapExtensionBase> = [];
#onUpdate = () => {
this.requestUpdate();
};
protected override firstUpdated() {
const buttons = this.extensions.flatMap((ext) => ext.getToolbarButtons());
this.actions.push(...buttons);
}
override render() {
return html`
${this.actions.map(
(action) => html`
<button class=${action.isActive?.() ? 'active' : ''} @click=${action.command} title=${action.name}>
${action.icon}
<button
class=${action.isActive?.(this.editor) ? 'active' : ''}
title=${action.name}
@click=${() => action.command(this.editor)}>
${when(
typeof action.icon === 'string',
() => html`<umb-icon name=${action.icon}></umb-icon>`,
() => action.icon,
)}
</button>
`,
)}

View File

@@ -0,0 +1,10 @@
import type { ManifestTiptapExtension } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<ManifestTiptapExtension> = [
{
type: 'tiptapExtension',
alias: 'Umb.Tiptap.MediaPicker',
name: 'Media Picker Tiptap Extension',
api: () => import('./tiptap-mediapicker.extension.js'),
},
];

View File

@@ -0,0 +1,68 @@
import { UmbTiptapExtensionBase } from '../components/input-tiptap/tiptap-extension.js';
import { mergeAttributes, Node } from '@umbraco-cms/backoffice/external/tiptap';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
import { UMB_MEDIA_PICKER_MODAL } from '@umbraco-cms/backoffice/media';
export default class UmbTiptapMediaPickerPlugin extends UmbTiptapExtensionBase {
getExtensions() {
return [
Node.create({
name: 'umbMediaPicker',
group: 'block',
marks: '',
draggable: true,
addNodeView() {
return () => {
//console.log('umb-media.addNodeView');
const dom = document.createElement('umb-debug');
dom.attributes.setNamedItem(document.createAttribute('visible'));
dom.attributes.setNamedItem(document.createAttribute('dialog'));
return { dom };
};
},
parseHTML() {
//console.log('umb-media.parseHTML');
return [{ tag: 'umb-media' }];
},
renderHTML({ HTMLAttributes }) {
//console.log('umb-media.renderHTML');
return ['umb-media', mergeAttributes(HTMLAttributes)];
},
}),
];
}
getToolbarButtons() {
return [
{
name: 'umb-media',
icon: 'icon-picture',
isActive: (editor?: Editor) => editor?.isActive('umbMediaPicker'),
command: async (editor?: Editor) => {
//console.log('umb-media.command', editor);
const selection = await this.#openMediaPicker();
if (!selection || !selection.length) return;
editor?.chain().focus().insertContent(`<umb-media>${selection}</umb-media>`).run();
},
},
];
}
async #openMediaPicker() {
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
const modalHandler = modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, {
data: { multiple: false },
value: { selection: [] },
});
if (!modalHandler) return;
const { selection } = await modalHandler.onSubmit().catch(() => ({ selection: undefined }));
//console.log('umb-media.selection', selection);
return selection;
}
}

View File

@@ -0,0 +1 @@
export * from './components/index.js';

View File

@@ -1,4 +1,5 @@
import { manifests as extensions } from './extensions/manifests.js';
import { manifests as propertyEditors } from './property-editors/manifests.js';
import type { ManifestTypes } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<ManifestTypes> = [...propertyEditors];
export const manifests: Array<ManifestTypes> = [...extensions, ...propertyEditors];

View File

@@ -4,13 +4,13 @@ export const manifests: Array<ManifestTypes> = [
{
type: 'propertyEditorUi',
alias: 'Umb.PropertyEditorUi.Tiptap',
name: 'Tiptap Property Editor UI',
name: 'Rich Text Editor [Tiptap] Property Editor UI',
element: () => import('./property-editor-ui-tiptap.element.js'),
meta: {
label: 'Tiptap Editor',
propertyEditorSchemaAlias: 'Umbraco.Plain.Json',
icon: 'icon-document',
group: 'richText',
label: 'Rich Text Editor [Tiptap]',
propertyEditorSchemaAlias: 'Umbraco.RichText',
icon: 'icon-browser-window',
group: 'richContent',
settings: {
properties: [
{