diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-bubble-menu.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-bubble-menu.extension.ts new file mode 100644 index 000000000000..aab466bf6a29 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-bubble-menu.extension.ts @@ -0,0 +1,109 @@ +import type { UUIPopoverContainerElement } from '../../uui/index.js'; +import { Extension } from '@tiptap/core'; +import { Editor } from '@tiptap/core'; +import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'; +import { EditorView } from '@tiptap/pm/view'; +import type { PluginView } from '@tiptap/pm/state'; + +export interface UmbTiptapBubbleMenuElement extends HTMLElement { + editor?: Editor; +} + +export type UmbBubbleMenuPluginProps = { + unique: string; + placement?: UUIPopoverContainerElement['placement']; + elementName?: string | null; + shouldShow?: + | ((props: { editor: Editor; view: EditorView; state: EditorState; from: number; to: number }) => boolean) + | null; +}; + +export type UmbBubbleMenuOptions = UmbBubbleMenuPluginProps; + +export const UmbBubbleMenu = Extension.create({ + name: 'umbBubbleMenu', + + addOptions() { + return { + unique: 'umb-tiptap-menu', + placement: 'top', + elementName: null, + shouldShow: null, + }; + }, + + addProseMirrorPlugins() { + if (!this.options.unique || !this.options.elementName) { + return []; + } + + return [ + UmbBubbleMenuPlugin(this.editor, { + unique: this.options.unique, + placement: this.options.placement, + elementName: this.options.elementName, + shouldShow: this.options.shouldShow, + }), + ]; + }, +}); + +class UmbBubbleMenuPluginView implements PluginView { + #editor: Editor; + + #popover: UUIPopoverContainerElement; + + #shouldShow: UmbBubbleMenuPluginProps['shouldShow']; + + constructor(editor: Editor, view: EditorView, props: UmbBubbleMenuPluginProps) { + this.#editor = editor; + + this.#shouldShow = props.shouldShow ?? null; + + this.#popover = document.createElement('uui-popover-container') as UUIPopoverContainerElement; + this.#popover.id = props.unique; + this.#popover.setAttribute('placement', props.placement ?? 'top'); + this.#popover.setAttribute('popover', 'manual'); + + if (props.elementName) { + const menu = document.createElement(props.elementName) as UmbTiptapBubbleMenuElement; + menu.editor = editor; + this.#popover.appendChild(menu); + } + + view.dom.parentNode?.appendChild(this.#popover); + + this.update(view, null); + } + + update(view: EditorView, prevState: EditorState | null) { + const editor = this.#editor; + + const { state } = view; + const { selection } = state; + + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + const shouldShow = this.#shouldShow?.({ editor, view, state, from, to }); + + if (!shouldShow) { + this.#popover.hidePopover(); + } else { + this.#popover.showPopover(); + } + } + + destroy() { + this.#popover.remove(); + } +} + +export const UmbBubbleMenuPlugin = (editor: Editor, props: UmbBubbleMenuPluginProps) => { + return new Plugin({ + view(editorView) { + return new UmbBubbleMenuPluginView(editor, editorView, props); + }, + }); +}; diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts index 86794bc96ad0..e015be74e252 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts @@ -1,11 +1,9 @@ +import { UmbBubbleMenuPlugin } from './tiptap-umb-bubble-menu.extension.js'; import { CellSelection, TableMap } from '@tiptap/pm/tables'; -import { Decoration, DecorationSet } from '@tiptap/pm/view'; -import { EditorState } from '@tiptap/pm/state'; -import { EditorView } from '@tiptap/pm/view'; -import { findParentNode, mergeAttributes, Editor, Node } from '@tiptap/core'; +import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'; +import { EditorState, Plugin, Selection, Transaction } from '@tiptap/pm/state'; +import { findParentNode, Editor } from '@tiptap/core'; import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'; -import { Plugin } from '@tiptap/pm/state'; -import { Selection, Transaction } from '@tiptap/pm/state'; import { Table } from '@tiptap/extension-table'; import { TableCell } from '@tiptap/extension-table-cell'; import { TableHeader } from '@tiptap/extension-table-header'; @@ -43,7 +41,16 @@ export const UmbTableHeader = TableHeader.extend({ }, addProseMirrorPlugins() { + const { editor } = this; return [ + UmbBubbleMenuPlugin(this.editor, { + unique: 'table-column-menu', + placement: 'top', + elementName: 'umb-tiptap-table-column-menu', + shouldShow(props) { + return isColumnGripSelected(props); + }, + }), new Plugin({ props: { decorations: (state) => { @@ -62,16 +69,16 @@ export const UmbTableHeader = TableHeader.extend({ decorations.push( Decoration.widget(pos + 1, () => { const colSelected = isColumnSelected(index)(selection); - const className = colSelected ? 'grip-column selected' : 'grip-column'; const grip = document.createElement('a'); grip.appendChild(document.createElement('uui-symbol-more')); - grip.className = className; + grip.className = colSelected ? 'grip-column selected' : 'grip-column'; + grip.setAttribute('popovertarget', colSelected ? 'table-column-menu' : ''); + grip.addEventListener('mousedown', (event) => { event.preventDefault(); event.stopImmediatePropagation(); - this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr)); }); @@ -126,7 +133,16 @@ export const UmbTableCell = TableCell.extend({ }, addProseMirrorPlugins() { + const { editor } = this; return [ + UmbBubbleMenuPlugin(this.editor, { + unique: 'table-row-menu', + placement: 'left', + elementName: 'umb-tiptap-table-row-menu', + shouldShow(props) { + return isRowGripSelected(props); + }, + }), new Plugin({ props: { decorations: (state) => { @@ -145,12 +161,13 @@ export const UmbTableCell = TableCell.extend({ decorations.push( Decoration.widget(pos + 1, () => { const rowSelected = isRowSelected(index)(selection); - const className = rowSelected ? 'grip-row selected' : 'grip-row'; const grip = document.createElement('a'); grip.appendChild(document.createElement('uui-symbol-more')); - grip.className = className; + grip.className = rowSelected ? 'grip-row selected' : 'grip-row'; + grip.setAttribute('popovertarget', rowSelected ? 'table-row-menu' : ''); + grip.addEventListener('mousedown', (event) => { event.preventDefault(); event.stopImmediatePropagation(); @@ -449,7 +466,7 @@ const isColumnGripSelected = ({ return !!gripColumn; }; -export const isRowGripSelected = ({ +const isRowGripSelected = ({ editor, view, state, diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts index 16f06c87da1e..607acb91f89d 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts @@ -37,6 +37,7 @@ export * from './extensions/tiptap-html-global-attributes.extension.js'; export * from './extensions/tiptap-text-direction-extension.js'; export * from './extensions/tiptap-text-indent-extension.js'; export * from './extensions/tiptap-trailing-node.extension.js'; +export * from './extensions/tiptap-umb-bubble-menu.extension.js'; export * from './extensions/tiptap-umb-embedded-media.extension.js'; export * from './extensions/tiptap-umb-image.extension.js'; export * from './extensions/tiptap-umb-link.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts index 9a0f518bb378..3cae4c23acc6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts @@ -100,7 +100,7 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { :host { --uui-menu-item-flat-structure: 1; - background: var(--uui-color-surface); + background-color: var(--uui-color-surface); border-radius: var(--uui-border-radius); box-shadow: var(--uui-shadow-depth-3); padding: var(--uui-size-space-1); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/base.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/base.ts index 002422080156..7a2b218b3e0a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/base.ts @@ -59,7 +59,6 @@ export abstract class UmbTiptapToolbarElementApiBase extends UmbControllerBase i * @see {ManifestTiptapToolbarExtension} * @param {Editor} editor The editor instance. */ - public abstract execute(editor?: Editor): void; /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts index 28c2baf3e070..57b67924fea1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts @@ -645,12 +645,11 @@ const toolbarExtensions: Array = [ }, ]; -const extensions = [ +export const manifests = [ + ...kinds, ...coreExtensions, ...toolbarExtensions, ...blockExtensions, ...styleSelectExtensions, ...tableExtensions, ]; - -export const manifests = [...kinds, ...extensions]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-column-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-column-menu.element.ts new file mode 100644 index 000000000000..77075faf1389 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-column-menu.element.ts @@ -0,0 +1,50 @@ +import { css, customElement, html, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { Editor, UmbTiptapBubbleMenuElement } from '@umbraco-cms/backoffice/external/tiptap'; + +@customElement('umb-tiptap-table-column-menu') +export class UmbTiptapTableColumnMenuElement extends UmbLitElement implements UmbTiptapBubbleMenuElement { + @property({ attribute: false }) + editor?: Editor; + + #onAddColumnBefore = () => this.editor?.chain().focus().addColumnBefore().run(); + #onAddColumnAfter = () => this.editor?.chain().focus().addColumnAfter().run(); + #onDeleteColumn = () => this.editor?.chain().focus().deleteColumn().run(); + + override render() { + return html` + + + + + + + + + + `; + } + + static override readonly styles = [ + css` + :host { + --uui-menu-item-flat-structure: 1; + + display: flex; + flex-direction: column; + + background-color: var(--uui-color-surface); + border-radius: var(--uui-border-radius); + box-shadow: var(--uui-shadow-depth-3); + } + `, + ]; +} + +export default UmbTiptapTableColumnMenuElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-tiptap-table-column-menu': UmbTiptapTableColumnMenuElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-row-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-row-menu.element.ts new file mode 100644 index 000000000000..7678c77810fd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-row-menu.element.ts @@ -0,0 +1,50 @@ +import { css, customElement, html, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { Editor, UmbTiptapBubbleMenuElement } from '@umbraco-cms/backoffice/external/tiptap'; + +@customElement('umb-tiptap-table-row-menu') +export class UmbTiptapTableRowMenuElement extends UmbLitElement implements UmbTiptapBubbleMenuElement { + @property({ attribute: false }) + editor?: Editor; + + #onAddRowBefore = () => this.editor?.chain().focus().addRowBefore().run(); + #onAddRowAfter = () => this.editor?.chain().focus().addRowAfter().run(); + #onDeleteRow = () => this.editor?.chain().focus().deleteRow().run(); + + override render() { + return html` + + + + + + + + + + `; + } + + static override readonly styles = [ + css` + :host { + --uui-menu-item-flat-structure: 1; + + display: flex; + flex-direction: column; + + background-color: var(--uui-color-surface); + border-radius: var(--uui-border-radius); + box-shadow: var(--uui-shadow-depth-3); + } + `, + ]; +} + +export default UmbTiptapTableRowMenuElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-tiptap-table-row-menu': UmbTiptapTableRowMenuElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts index 68263c0e0727..fa9fbf51feb3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts @@ -47,8 +47,8 @@ const toolbarExtensions: Array = [ { label: 'Row', items: [ - { label: 'Add row before', data: 'addRowBefore' }, - { label: 'Add row after', data: 'addRowAfter' }, + { label: 'Add row before', icon: 'icon-page-up', data: 'addRowBefore' }, + { label: 'Add row after', icon: 'icon-page-down', data: 'addRowAfter' }, { label: 'Delete row', icon: 'icon-trash', data: 'deleteRow' }, { label: 'Toggle header row', data: 'toggleHeaderRow' }, ], @@ -56,8 +56,8 @@ const toolbarExtensions: Array = [ { label: 'Column', items: [ - { label: 'Add column before', data: 'addColumnBefore' }, - { label: 'Add column after', data: 'addColumnAfter' }, + { label: 'Add column before', icon: 'icon-navigation-first', data: 'addColumnBefore' }, + { label: 'Add column after', icon: 'icon-tab-key', data: 'addColumnAfter' }, { label: 'Delete column', icon: 'icon-trash', data: 'deleteColumn' }, { label: 'Toggle header column', data: 'toggleHeaderColumn' }, ], diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts index 74180c14fda4..652984408d4f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts @@ -2,6 +2,9 @@ import { UmbTiptapExtensionApiBase } from '../base.js'; import { css } from '@umbraco-cms/backoffice/external/lit'; import { UmbTable, UmbTableHeader, UmbTableRow, UmbTableCell } from '@umbraco-cms/backoffice/external/tiptap'; +import './components/table-column-menu.element.js'; +import './components/table-row-menu.element.js'; + export default class UmbTiptapTableExtensionApi extends UmbTiptapExtensionApiBase { getTiptapExtensions = () => [UmbTable, UmbTableHeader, UmbTableRow, UmbTableCell]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts index 79b5da40a517..4fb036f5ba06 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts @@ -6,18 +6,25 @@ import './components/table-insert.element.js'; export class UmbTiptapToolbarTableExtensionApi extends UmbTiptapToolbarElementApiBase { #commands: Record void> = { + // Cells mergeCells: (editor) => editor?.chain().focus().mergeCells().run(), splitCell: (editor) => editor?.chain().focus().splitCell().run(), mergeOrSplit: (editor) => editor?.chain().focus().mergeOrSplit().run(), toggleHeaderCell: (editor) => editor?.chain().focus().toggleHeaderCell().run(), + + // Rows addRowBefore: (editor) => editor?.chain().focus().addRowBefore().run(), addRowAfter: (editor) => editor?.chain().focus().addRowAfter().run(), deleteRow: (editor) => editor?.chain().focus().deleteRow().run(), toggleHeaderRow: (editor) => editor?.chain().focus().toggleHeaderRow().run(), + + // Columns addColumnBefore: (editor) => editor?.chain().focus().addColumnBefore().run(), addColumnAfter: (editor) => editor?.chain().focus().addColumnAfter().run(), deleteColumn: (editor) => editor?.chain().focus().deleteColumn().run(), toggleHeaderColumn: (editor) => editor?.chain().focus().toggleHeaderColumn().run(), + + // Table deleteTable: (editor) => editor?.chain().focus().deleteTable().run(), };