Feature: Tiptap blockpicker (#2335)

* fix: editor is always available

* fix: remove deprecated v14 stuff

* fix: the block manager should not care about the editor

* fix: the block manager should not care about the editor

* feat: add new tiptap blockpicker extension

* fix: save valid content

* fix: disable white-space to conform blocks inside text

* fix: set block types back to TinyMCE until migration has been completed

* feat: define block content when inserting

* feat: make `getLayouts` available on the base class

* fix: remove unused parameter

* feat: cleanup blocks on change

* feat: adds inline blocks

* feat: set docs for typings and update the interfaces to match and add setEditor to get the editor instance

* feat: set docs for typings and update the interfaces to match and add setEditor to get the editor instance

* feat: adds blocks in rte

* chore: sonarcloud fix

* feat: remove delete button as components can be stripped away directly from the DOM

* feat: allow custom views for block-rte and filter the views based on conditions

* feat: mark tiptap blocks with an outline when active

* feat: export data content udi const

* fix: add block-rte to vite's importmap so that tinymce works on the dev server

* feat(tinymce): get the value from the event target

* feat: allow tinymce to insert blocks by listening to the context

* chore: mark styles as readonly

* chore: cleanup code

* fix: remove two fixed TODO comments

* feat: used named capturing group

* chore: import correct type in testing file

* Removed extra `originData` from Block List manager context

* Fixed issues in Tiptap toolbar button

* Corrected base class for Tiptap Image extension

* Fixed up the RTE package vite config

to export the Tiptap classes (for CMS build)

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
This commit is contained in:
Jacob Overgaard
2024-09-24 19:40:02 +02:00
committed by GitHub
parent bcb5b660a1
commit 7acf3d4b01
24 changed files with 397 additions and 144 deletions

View File

@@ -6,6 +6,13 @@
<link rel="icon" type="image/svg+xml" href="umbraco/backoffice/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Umbraco</title>
<script type="importmap">
{
"imports": {
"@umbraco-cms/backoffice/block-rte": "/src/packages/block/block-rte/index.ts"
}
}
</script>
<script src="node_modules/msw/lib/iife/index.js"></script>
<link rel="stylesheet" href="node_modules/@umbraco-ui/uui-css/dist/uui-css.css" />
<link rel="stylesheet" href="src/css/umb-css.css" />

View File

@@ -1792,13 +1792,7 @@ This is to test the default configuration of the TinyMCE editor.
Search for **dt-richTextEditorTinyMce** in the codebase to find the configuration and add configuration values.
**NB!** If this throws an error in console, go to \`input-tiny-mce.defaults.ts\` and comment out the script append on line 126:
\`\`\`js
script.text = \`import "@umbraco-cms/backoffice/extension-registry";\`;
script.text = \`import "\${UMB_BLOCK_ENTRY_WEB_COMPONENTS_ABSOLUTE_PATH}";\`;
//editor.dom.doc.head.appendChild(script);
\`\`\``,
**NB!** If this throws an error in console, make sure that \`@umbraco-cms/backoffice/block-rte\` is available in the importmap.`,
dataType: { id: 'dt-richTextEditorTinyMce' },
variesByCulture: false,
variesBySegment: false,

View File

@@ -14,7 +14,7 @@ export interface ManifestBlockEditorCustomView extends ManifestElement<UmbBlockE
* @property {string | Array<string> } forBlockEditor - Declare if this Custom View only must appear at specific Block Editors.
* @description Optional condition if you like this custom view to only appear at a specific type of Block Editor.
* @example 'block-list'
* @example ['block-list', 'block-grid']
* @example ['block-list', 'block-grid', 'block-rte']
*/
forBlockEditor?: string | Array<string>;
}

View File

@@ -171,7 +171,7 @@ export class UmbBlockGridManagerContext<
originData: UmbBlockGridWorkspaceOriginData,
) {
this.setOneLayout(layoutEntry, originData);
this.insertBlockData(layoutEntry, content, settings, originData);
this.insertBlockData(layoutEntry, content, settings);
return true;
}

View File

@@ -39,7 +39,7 @@ export class UmbBlockListManagerContext<
) {
this._layouts.appendOneAt(layoutEntry, originData.index ?? -1);
this.insertBlockData(layoutEntry, content, settings, originData);
this.insertBlockData(layoutEntry, content, settings);
return true;
}

View File

@@ -1,10 +1,14 @@
import type { UmbBlockRteLayoutModel } from '../../types.js';
import { UMB_BLOCK_RTE, type UmbBlockRteLayoutModel } from '../../types.js';
import { UmbBlockRteEntryContext } from '../../context/block-rte-entry.context.js';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html, css, property, state, customElement } from '@umbraco-cms/backoffice/external/lit';
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/extension-registry';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbBlockEditorCustomViewProperties } from '@umbraco-cms/backoffice/block-custom-view';
import type {
ManifestBlockEditorCustomView,
UmbBlockEditorCustomViewProperties,
} from '@umbraco-cms/backoffice/block-custom-view';
import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
import '../ref-rte-block/index.js';
@@ -13,22 +17,22 @@ import '../ref-rte-block/index.js';
*/
@customElement('umb-rte-block')
export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropertyEditorUiElement {
//
@property({ type: String, attribute: 'data-content-udi', reflect: true })
public get contentUdi(): string | undefined {
return this._contentUdi;
return this.#contentUdi;
}
public set contentUdi(value: string | undefined) {
if (!value) return;
this._contentUdi = value;
this.#contentUdi = value;
this.#context.setContentUdi(value);
}
private _contentUdi?: string | undefined;
#contentUdi?: string;
#context = new UmbBlockRteEntryContext(this);
@state()
_showContentEdit = false;
@state()
_hasSettings = false;
@@ -44,6 +48,9 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
@state()
_workspaceEditSettingsPath?: string;
@state()
_contentElementTypeAlias?: string;
@state()
_blockViewProps: UmbBlockEditorCustomViewProperties<UmbBlockRteLayoutModel> = {
contentUdi: undefined!,
@@ -69,6 +76,9 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
this._hasSettings = !!key;
this.#updateBlockViewProps({ config: { ...this._blockViewProps.config, showSettingsEdit: !!key } });
});
this.observe(this.#context.contentElementTypeAlias, (alias) => {
this._contentElementTypeAlias = alias;
});
this.observe(
this.#context.blockType,
(blockType) => {
@@ -142,16 +152,26 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
return html`<umb-ref-rte-block .label=${this._label} .icon=${this._icon}></umb-ref-rte-block>`;
}
#filterBlockCustomViews = (manifest: ManifestBlockEditorCustomView) => {
const elementTypeAlias = this._contentElementTypeAlias ?? '';
const isForBlockEditor =
!manifest.forBlockEditor || stringOrStringArrayContains(manifest.forBlockEditor, UMB_BLOCK_RTE);
const isForContentTypeAlias =
!manifest.forContentTypeAlias || stringOrStringArrayContains(manifest.forContentTypeAlias, elementTypeAlias);
return isForBlockEditor && isForContentTypeAlias;
};
#renderBlock() {
return html`
<div class="uui-text uui-font">
<umb-extension-slot
type="blockEditorCustomView"
default-element=${'umb-ref-rte-block'}
default-element="umb-ref-rte-block"
.props=${this._blockViewProps}
single
>${this.#renderRefBlock()}</umb-extension-slot
>
.filter=${this.#filterBlockCustomViews}
single>
${this.#renderRefBlock()}
</umb-extension-slot>
<uui-action-bar>
${this._showContentEdit && this._workspaceEditContentPath
? html`<uui-button label="edit" compact href=${this._workspaceEditContentPath}>
@@ -163,9 +183,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
<uui-icon name="icon-settings"></uui-icon>
</uui-button>`
: ''}
<uui-button label="delete" compact @click=${() => this.#context.requestDelete()}>
<uui-icon name="icon-remove"></uui-icon>
</uui-button>
</uui-action-bar>
</div>
`;
@@ -175,7 +192,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
return this.#renderBlock();
}
static override styles = [
static override readonly styles = [
UmbTextStyles,
css`
:host {
@@ -183,6 +200,13 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
display: block;
user-select: none;
user-drag: auto;
white-space: nowrap;
}
:host(.ProseMirror-selectednode) {
umb-ref-rte-block {
cursor: not-allowed;
outline: 3px solid #b4d7ff;
}
}
uui-action-bar {
position: absolute;

View File

@@ -32,13 +32,16 @@ export class UmbRefRteBlockElement extends UmbLitElement {
}
override render() {
return html`<uui-ref-node standalone .name=${this.label ?? ''} href=${this._workspaceEditPath ?? '#'}
><uui-icon slot="icon" .name=${this.icon ?? null}></uui-icon
></uui-ref-node>`;
return html`<uui-ref-node standalone .name=${this.label ?? ''} href=${this._workspaceEditPath ?? '#'}>
<uui-icon slot="icon" .name=${this.icon ?? null}></uui-icon>
</uui-ref-node>`;
}
static override styles = [
static override readonly styles = [
css`
:host {
display: block;
}
uui-ref-node {
min-height: var(--uui-size-16);
}

View File

@@ -50,15 +50,9 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext<
value.create.contentElementTypeKey,
// We can parse an empty object, cause the rest will be filled in by others.
{} as any,
data.originData as UmbBlockRteWorkspaceOriginData,
);
if (created) {
this.insert(
created.layout,
created.content,
created.settings,
data.originData as UmbBlockRteWorkspaceOriginData,
);
this.insert(created.layout, created.content, created.settings);
} else {
throw new Error('Failed to create block');
}
@@ -131,25 +125,16 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext<
this._manager?.setLayouts(layouts);
}
async create(
contentElementTypeKey: string,
partialLayoutEntry?: Omit<UmbBlockRteLayoutModel, 'contentUdi'>,
originData?: UmbBlockRteWorkspaceOriginData,
) {
async create(contentElementTypeKey: string, partialLayoutEntry?: Omit<UmbBlockRteLayoutModel, 'contentUdi'>) {
await this._retrieveManager;
return this._manager?.create(contentElementTypeKey, partialLayoutEntry, originData);
return this._manager?.create(contentElementTypeKey, partialLayoutEntry);
}
// insert Block?
async insert(
layoutEntry: UmbBlockRteLayoutModel,
content: UmbBlockDataType,
settings: UmbBlockDataType | undefined,
originData: UmbBlockRteWorkspaceOriginData,
) {
async insert(layoutEntry: UmbBlockRteLayoutModel, content: UmbBlockDataType, settings: UmbBlockDataType | undefined) {
await this._retrieveManager;
return this._manager?.insert(layoutEntry, content, settings, originData) ?? false;
return this._manager?.insert(layoutEntry, content, settings) ?? false;
}
// create Block?

View File

@@ -1,7 +1,5 @@
import type { UmbBlockRteLayoutModel, UmbBlockRteTypeModel } from '../types.js';
import type { UmbBlockRteWorkspaceOriginData } from '../index.js';
import type { UmbBlockDataType } from '../../block/types.js';
import type { Editor } from '@umbraco-cms/backoffice/external/tinymce';
import { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
import '../components/block-rte-entry/index.js';
@@ -12,32 +10,11 @@ import '../components/block-rte-entry/index.js';
export class UmbBlockRteManagerContext<
BlockLayoutType extends UmbBlockRteLayoutModel = UmbBlockRteLayoutModel,
> extends UmbBlockManagerContext<UmbBlockRteTypeModel, BlockLayoutType> {
//
#editor?: Editor;
setTinyMceEditor(editor: Editor) {
this.#editor = editor;
}
getTinyMceEditor() {
return this.#editor;
}
removeOneLayout(contentUdi: string) {
this._layouts.removeOne(contentUdi);
}
getLayouts(): Array<BlockLayoutType> {
return this._layouts.getValue();
}
create(
contentElementTypeKey: string,
partialLayoutEntry?: Omit<BlockLayoutType, 'contentUdi'>,
// This property is used by some implementations, but not used in this.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
originData?: UmbBlockRteWorkspaceOriginData,
) {
create(contentElementTypeKey: string, partialLayoutEntry?: Omit<BlockLayoutType, 'contentUdi'>) {
const data = super.createBlockData(contentElementTypeKey, partialLayoutEntry);
// Find block type.
@@ -53,29 +30,10 @@ export class UmbBlockRteManagerContext<
return data;
}
insert(
layoutEntry: BlockLayoutType,
content: UmbBlockDataType,
settings: UmbBlockDataType | undefined,
originData: UmbBlockRteWorkspaceOriginData,
) {
if (!this.#editor) return false;
insert(layoutEntry: BlockLayoutType, content: UmbBlockDataType, settings: UmbBlockDataType | undefined) {
this._layouts.appendOne(layoutEntry);
this.insertBlockData(layoutEntry, content, settings, originData);
if (layoutEntry.displayInline) {
this.#editor.selection.setContent(
`<umb-rte-block-inline data-content-udi="${layoutEntry.contentUdi}"><!--Umbraco-Block--></umb-rte-block-inline>`,
);
} else {
this.#editor.selection.setContent(
`<umb-rte-block data-content-udi="${layoutEntry.contentUdi}"><!--Umbraco-Block--></umb-rte-block>`,
);
}
this.#editor.fire('change');
this.insertBlockData(layoutEntry, content, settings);
return true;
}
@@ -85,13 +43,6 @@ export class UmbBlockRteManagerContext<
* @internal
*/
public deleteLayoutElement(contentUdi: string) {
if (!this.#editor) return;
const blockElementsOfThisUdi = this.#editor.dom.select(
`umb-rte-block[data-content-udi='${contentUdi}'], umb-rte-block-inline[data-content-udi='${contentUdi}']`,
);
blockElementsOfThisUdi.forEach((blockElement) => {
this.#editor?.dom.remove(blockElement);
});
this.removeBlockUdi(contentUdi);
}
}

View File

@@ -1,4 +1,9 @@
import { manifests as tinyMcePluginManifests } from './tiny-mce-plugin/manifests.js';
import { manifests as tiptapExtensionManifests } from './tiptap-extension/manifests.js';
import { manifests as workspaceManifests } from './workspace/manifests.js';
export const manifests: Array<UmbExtensionManifest> = [...tinyMcePluginManifests, ...workspaceManifests];
export const manifests: Array<UmbExtensionManifest> = [
...tinyMcePluginManifests,
...tiptapExtensionManifests,
...workspaceManifests,
];

View File

@@ -1,18 +1,23 @@
import type { UmbBlockDataType } from '../../block/types.js';
import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '../context/block-rte-manager.context-token.js';
import { UMB_BLOCK_RTE_ENTRIES_CONTEXT } from '../context/block-rte-entries.context-token.js';
import { UMB_DATA_CONTENT_UDI, type UmbBlockRteLayoutModel } from '../types.js';
import { type TinyMcePluginArguments, UmbTinyMcePluginBase } from '@umbraco-cms/backoffice/tiny-mce';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type';
import type { Editor } from '@umbraco-cms/backoffice/external/tinymce';
export default class UmbTinyMceMultiUrlPickerPlugin extends UmbTinyMcePluginBase {
#localize = new UmbLocalizationController(this._host);
private _blocks?: Array<UmbBlockTypeBaseModel>;
#editor: Editor;
#blocks?: Array<UmbBlockTypeBaseModel>;
#entriesContext?: typeof UMB_BLOCK_RTE_ENTRIES_CONTEXT.TYPE;
constructor(args: TinyMcePluginArguments) {
super(args);
this.#editor = args.editor;
args.editor.ui.registry.addToggleButton('umbblockpicker', {
icon: 'visualblocks',
tooltip: this.#localize.term('blockEditor_insertBlock'),
@@ -27,15 +32,21 @@ export default class UmbTinyMceMultiUrlPickerPlugin extends UmbTinyMcePluginBase
});
this.consumeContext(UMB_BLOCK_RTE_MANAGER_CONTEXT, (context) => {
context.setTinyMceEditor(args.editor);
this.observe(
context.blockTypes,
(blockTypes) => {
this._blocks = blockTypes;
this.#blocks = blockTypes;
},
'blockType',
);
this.observe(
context.contents,
(contents) => {
this.#updateBlocks(contents, context.getLayouts());
},
'contents',
);
});
this.consumeContext(UMB_BLOCK_RTE_ENTRIES_CONTEXT, (context) => {
this.#entriesContext = context;
@@ -64,11 +75,10 @@ export default class UmbTinyMceMultiUrlPickerPlugin extends UmbTinyMcePluginBase
return;
}
// TODO: Missing solution to skip catalogue if only one type available. [NL]
let createPath: string | undefined = undefined;
if (this._blocks?.length === 1) {
const elementKey = this._blocks[0].contentElementTypeKey;
if (this.#blocks?.length === 1) {
const elementKey = this.#blocks[0].contentElementTypeKey;
createPath = this.#entriesContext.getPathForCreateBlock() + 'modal/umb-modal-workspace/create/' + elementKey;
} else {
createPath = this.#entriesContext.getPathForCreateBlock();
@@ -78,4 +88,28 @@ export default class UmbTinyMceMultiUrlPickerPlugin extends UmbTinyMcePluginBase
window.history.pushState({}, '', createPath);
}
}
#updateBlocks(blocks: UmbBlockDataType[], layouts: Array<UmbBlockRteLayoutModel>) {
const editor = this.#editor;
if (!editor?.dom) return;
const existingBlocks = editor.dom
.select('umb-rte-block, umb-rte-block-inline')
.map((x) => x.getAttribute(UMB_DATA_CONTENT_UDI));
const newBlocks = blocks.filter((x) => !existingBlocks.find((contentUdi) => contentUdi === x.udi));
newBlocks.forEach((block) => {
// Find layout for block
const layout = layouts.find((x) => x.contentUdi === block.udi);
const inline = layout?.displayInline ?? false;
let blockTag = 'umb-rte-block';
if (inline) {
blockTag = 'umb-rte-block-inline';
}
editor.insertContent(`<${blockTag} data-content-udi="${block.udi}"></${blockTag}>`);
});
}
}

View File

@@ -0,0 +1,176 @@
import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '../context/block-rte-manager.context-token.js';
import { UMB_BLOCK_RTE_ENTRIES_CONTEXT } from '../context/block-rte-entries.context-token.js';
import type { UmbBlockDataType } from '../../block/types.js';
import { UMB_DATA_CONTENT_UDI, type UmbBlockRteLayoutModel } from '../types.js';
import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type';
import { UmbTiptapToolbarElementApiBase } from '@umbraco-cms/backoffice/tiptap';
import { Node, type Editor } from '@umbraco-cms/backoffice/external/tiptap';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { distinctUntilChanged } from '@umbraco-cms/backoffice/external/rxjs';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
umbRteBlock: {
setBlock: (options: { contentUdi: string }) => ReturnType;
};
umbRteBlockInline: {
setBlockInline: (options: { contentUdi: string }) => ReturnType;
};
}
}
const umbRteBlock = Node.create({
name: 'umbRteBlock',
group: 'block',
content: undefined, // The block does not have any content, it is just a wrapper.
atom: true, // The block is an atom, meaning it is a single unit that cannot be split.
marks: '', // We do not allow marks on the block
draggable: true,
selectable: true,
addAttributes() {
return {
[UMB_DATA_CONTENT_UDI]: {
isRequired: true,
},
};
},
parseHTML() {
return [{ tag: 'umb-rte-block' }];
},
renderHTML({ HTMLAttributes }) {
return ['umb-rte-block', HTMLAttributes];
},
addCommands() {
return {
setBlock:
(options) =>
({ commands }) => {
const attrs = { [UMB_DATA_CONTENT_UDI]: options.contentUdi };
return commands.insertContent({
type: this.name,
attrs,
});
},
};
},
});
const umbRteBlockInline = umbRteBlock.extend({
name: 'umbRteBlockInline',
group: 'inline',
inline: true,
parseHTML() {
return [{ tag: 'umb-rte-block-inline' }];
},
renderHTML({ HTMLAttributes }) {
return ['umb-rte-block-inline', HTMLAttributes];
},
addCommands() {
return {
setBlockInline:
(options) =>
({ commands }) => {
const attrs = { [UMB_DATA_CONTENT_UDI]: options.contentUdi };
return commands.insertContent({
type: this.name,
attrs,
});
},
};
},
});
export default class UmbTiptapBlockPickerExtension extends UmbTiptapToolbarElementApiBase {
#blocks?: Array<UmbBlockTypeBaseModel>;
#entriesContext?: typeof UMB_BLOCK_RTE_ENTRIES_CONTEXT.TYPE;
constructor(host: UmbControllerHost) {
super(host);
this.consumeContext(UMB_BLOCK_RTE_MANAGER_CONTEXT, (context) => {
this.observe(
context.blockTypes,
(blockTypes) => {
this.#blocks = blockTypes;
},
'blockType',
);
this.observe(
context.contents.pipe(
distinctUntilChanged((prev, curr) => prev.map((y) => y.udi).join() === curr.map((y) => y.udi).join()),
),
(contents) => {
this.#updateBlocks(contents, context.getLayouts());
},
'contents',
);
});
this.consumeContext(UMB_BLOCK_RTE_ENTRIES_CONTEXT, (context) => {
this.#entriesContext = context;
});
}
getTiptapExtensions() {
return [umbRteBlock, umbRteBlockInline];
}
override isActive(editor: Editor) {
return (
editor.isActive(`umb-rte-block[${UMB_DATA_CONTENT_UDI}]`) ||
editor.isActive(`umb-rte-block-inline[${UMB_DATA_CONTENT_UDI}]`)
);
}
override async execute() {
return this.#createBlock();
}
#createBlock() {
if (!this.#entriesContext) {
console.error('[Block Picker] No entries context available.');
return;
}
let createPath: string | undefined = undefined;
if (this.#blocks?.length === 1) {
const elementKey = this.#blocks[0].contentElementTypeKey;
createPath = this.#entriesContext.getPathForCreateBlock() + 'modal/umb-modal-workspace/create/' + elementKey;
} else {
createPath = this.#entriesContext.getPathForCreateBlock();
}
if (createPath) {
window.history.pushState({}, '', createPath);
}
}
#updateBlocks(blocks: UmbBlockDataType[], layouts: Array<UmbBlockRteLayoutModel>) {
const editor = this._editor;
if (!editor) return;
const existingBlocks = Array.from(editor.view.dom.querySelectorAll('umb-rte-block, umb-rte-block-inline')).map(
(x) => x.getAttribute(UMB_DATA_CONTENT_UDI),
);
const newBlocks = blocks.filter((x) => !existingBlocks.find((contentUdi) => contentUdi === x.udi));
newBlocks.forEach((block) => {
// Find layout for block
const layout = layouts.find((x) => x.contentUdi === block.udi);
const inline = layout?.displayInline ?? false;
if (inline) {
editor.commands.setBlockInline({ contentUdi: block.udi });
} else {
editor.commands.setBlock({ contentUdi: block.udi });
}
});
}
}

View File

@@ -0,0 +1,16 @@
import type { ManifestTiptapExtensionButtonKind } from '@umbraco-cms/backoffice/tiptap';
export const manifests: ManifestTiptapExtensionButtonKind[] = [
{
type: 'tiptapExtension',
kind: 'button',
alias: 'Umb.TiptapExtension.BlockPicker',
name: 'Block Picker Tiptap Extension Button',
api: () => import('./block-picker.extension.js'),
meta: {
alias: 'umbblockpicker',
icon: 'icon-plugin',
label: '#blockEditor_insertBlock',
},
},
];

View File

@@ -2,6 +2,8 @@ import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type';
import type { UmbBlockLayoutBaseModel, UmbBlockValueType } from '@umbraco-cms/backoffice/block';
export const UMB_BLOCK_RTE_TYPE = 'block-rte-type';
export const UMB_BLOCK_RTE = 'block-rte';
export const UMB_DATA_CONTENT_UDI = 'data-content-udi';
export interface UmbBlockRteTypeModel extends UmbBlockTypeBaseModel {
displayInline: boolean;

View File

@@ -86,6 +86,9 @@ export abstract class UmbBlockManagerContext<
setLayouts(layouts: Array<BlockLayoutType>) {
this._layouts.setValue(layouts);
}
getLayouts() {
return this._layouts.getValue();
}
setContents(contents: Array<UmbBlockDataType>) {
this.#contents.setValue(contents);
}
@@ -276,16 +279,12 @@ export abstract class UmbBlockManagerContext<
layoutEntry: BlockLayoutType,
content: UmbBlockDataType,
settings: UmbBlockDataType | undefined,
// TODO: [v15]: ignoring unused var here here to prevent a breaking change
// eslint-disable-next-line @typescript-eslint/no-unused-vars
originData: BlockOriginDataType,
) {
// Create content entry:
if (layoutEntry.contentUdi) {
this.#contents.appendOne(content);
} else {
throw new Error('Cannot create block, missing contentUdi');
return false;
}
//Create settings entry:
@@ -293,4 +292,8 @@ export abstract class UmbBlockManagerContext<
this.#settings.appendOne(settings);
}
}
protected removeBlockUdi(contentUdi: string) {
this.#contents.removeOne(contentUdi);
}
}

View File

@@ -1,4 +1,6 @@
export const manifest: UmbExtensionManifest = {
import type { ManifestBlockEditorCustomView } from '../block-custom-view/block-editor-custom-view.extension.js';
export const manifest: ManifestBlockEditorCustomView = {
type: 'blockEditorCustomView',
alias: 'Umb.blockEditorCustomView.TestView',
name: 'Block Editor Custom View Test',

View File

@@ -95,6 +95,9 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
editable: !this.readonly,
extensions: [...this.#requiredExtensions, ...extensions],
content: this.#markup,
onBeforeCreate: ({ editor }) => {
this._extensions.forEach((ext) => ext.setEditor(editor));
},
onUpdate: ({ editor }) => {
this.#markup = editor.getHTML();
this.dispatchEvent(new UmbChangeEvent());

View File

@@ -45,8 +45,8 @@ export class UmbTiptapToolbarButtonElement extends UmbLitElement {
compact
look=${this._isActive ? 'outline' : 'default'}
label=${ifDefined(this.manifest?.meta.label)}
title=${this.manifest?.meta.label ? this.localize.term(this.manifest.meta.label) : ''}
@click=${() => this.api?.execute(this.editor)}>
title=${this.manifest?.meta.label ? this.localize.string(this.manifest.meta.label) : ''}
@click=${() => (this.api && this.editor ? this.api.execute(this.editor) : null)}>
${when(
this.manifest?.meta.icon,
() => html`<umb-icon name=${this.manifest!.meta.icon}></umb-icon>`,

View File

@@ -1,7 +1,7 @@
import { UmbTiptapToolbarElementApiBase } from '../types.js';
import { UmbTiptapExtensionApiBase } from '../types.js';
import { UmbImage } from '@umbraco-cms/backoffice/external/tiptap';
export default class UmbTiptapImageExtensionApi extends UmbTiptapToolbarElementApiBase {
export default class UmbTiptapImageExtensionApi extends UmbTiptapExtensionApiBase {
getTiptapExtensions() {
return [UmbImage.configure({ inline: true })];
}

View File

@@ -5,12 +5,38 @@ import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
export interface UmbTiptapExtensionApi extends UmbApi {
/**
* Sets the editor instance to the extension.
*/
setEditor(editor: Editor): void;
/**
* Gets the Tiptap extensions for the editor.
*/
getTiptapExtensions(args?: UmbTiptapExtensionArgs): Array<Extension | Mark | Node>;
}
export abstract class UmbTiptapExtensionApiBase extends UmbControllerBase implements UmbTiptapExtensionApi {
public manifest?: ManifestTiptapExtension;
/**
* The manifest for the extension.
*/
protected _manifest?: ManifestTiptapExtension;
/**
* The editor instance.
*/
protected _editor?: Editor;
/**
* @inheritdoc
*/
setEditor(editor: Editor): void {
this._editor = editor;
}
/**
* @inheritdoc
*/
abstract getTiptapExtensions(args?: UmbTiptapExtensionArgs): Array<Extension | Mark | Node>;
}
@@ -24,18 +50,31 @@ export interface UmbTiptapExtensionArgs {
}
export interface UmbTiptapToolbarElementApi extends UmbTiptapExtensionApi {
execute(editor?: Editor): void;
isActive(editor?: Editor): boolean;
/**
* Executes the toolbar element action.
*/
execute(editor: Editor): void;
/**
* Checks if the toolbar element is active.
*/
isActive(editor: Editor): boolean;
}
export abstract class UmbTiptapToolbarElementApiBase
extends UmbTiptapExtensionApiBase
implements UmbTiptapToolbarElementApi
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public execute(editor?: Editor) {}
/**
* A method to execute the toolbar element action.
*/
public abstract execute(editor: Editor): void;
/**
* Informs the toolbar element if it is active or not. It uses the manifest meta alias to check if the toolbar element is active.
* @see {ManifestTiptapExtension}
*/
public isActive(editor?: Editor) {
return editor && this.manifest?.meta.alias ? editor?.isActive(this.manifest.meta.alias) : false;
return editor && this._manifest?.meta.alias ? editor?.isActive(this._manifest.meta.alias) : false;
}
}

View File

@@ -10,13 +10,12 @@ import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/extensi
import '../../components/input-tiptap/input-tiptap.element.js';
import type { UmbBlockValueType } from '@umbraco-cms/backoffice/block';
// Look at Tiny for correct types
export interface UmbRichTextEditorValueType {
markup: string;
blocks: UmbBlockValueType<UmbBlockRteLayoutModel>;
}
const UMB_BLOCK_RTE_BLOCK_LAYOUT_ALIAS = 'Umbraco.RichText';
const UMB_BLOCK_RTE_BLOCK_LAYOUT_ALIAS = 'Umbraco.TinyMCE';
const elementName = 'umb-property-editor-ui-tiptap';
@@ -128,21 +127,24 @@ export class UmbPropertyEditorUiTiptapElement extends UmbLitElement implements U
markup: this._latestMarkup,
};
// TODO: Validate blocks
// Loop through used, to remove the classes on these.
/*const blockEls = div.querySelectorAll(`umb-rte-block, umb-rte-block-inline`);
blockEls.forEach((blockEl) => {
blockEl.removeAttribute('contenteditable');
blockEl.removeAttribute('class');
});
// Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup.
//const blockElements = editor.dom.select(`umb-rte-block, umb-rte-block-inline`);
const usedContentUdis = Array.from(blockEls).map((blockElement) => blockElement.getAttribute('data-content-udi'));
const usedContentUdis: string[] = [];
// Regex matching all block elements in the markup, and extracting the content UDI. It's the same as the one used on the backend.
const regex = new RegExp(
/<umb-rte-block(?:-inline)?(?: class="(?:.[^"]*)")? data-content-udi="(?<udi>.[^"]*)">(?:<!--Umbraco-Block-->)?<\/umb-rte-block(?:-inline)?>/gi,
);
let blockElement: RegExpExecArray | null;
while ((blockElement = regex.exec(this._latestMarkup)) !== null) {
if (blockElement.groups?.udi) {
usedContentUdis.push(blockElement.groups.udi);
}
}
const unusedBlocks = this.#managerContext.getLayouts().filter((x) => usedContentUdis.indexOf(x.contentUdi) === -1);
unusedBlocks.forEach((blockLayout) => {
this.#managerContext.removeOneLayout(blockLayout.contentUdi);
});*/
});
this.#fireChangeEvent();
}

View File

@@ -8,5 +8,12 @@ const dist = '../../../dist-cms/packages/rte';
rmSync(dist, { recursive: true, force: true });
export default defineConfig({
...getDefaultConfig({ dist }),
...getDefaultConfig({
dist,
entry: {
'tiptap/index': 'tiptap/index.ts',
manifests: 'manifests.ts',
'umbraco-package': 'umbraco-package.ts',
},
}),
});

View File

@@ -380,7 +380,7 @@ export class UmbInputTinyMceElement extends UUIFormControlMixin(UmbLitElement, '
return html`<div class="editor"></div>`;
}
static override styles = [
static override readonly styles = [
css`
.tox-tinymce {
position: relative;

View File

@@ -1,3 +1,4 @@
import type { UmbInputTinyMceElement } from '../../components/input-tiny-mce/input-tiny-mce.element.js';
import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbPropertyValueChangeEvent } from '@umbraco-cms/backoffice/property-editor';
@@ -115,13 +116,12 @@ export class UmbPropertyEditorUITinyMceElement extends UmbLitElement implements
this.dispatchEvent(new UmbPropertyValueChangeEvent());
}
#onChange() {
const editor = this.#managerContext.getTinyMceEditor();
if (!editor) return;
#onChange(event: CustomEvent & { target: UmbInputTinyMceElement }) {
const value = event.target.value;
// Clone the DOM, to remove the classes and attributes on the original:
const div = document.createElement('div');
div.innerHTML = editor.getContent();
div.innerHTML = value.toString();
// Loop through used, to remove the classes on these.
const blockEls = div.querySelectorAll(`umb-rte-block, umb-rte-block-inline`);