Explorar o código

feat(admin-ui): Add context menu for table operations

Relates to #1716
Michael Bromley %!s(int64=3) %!d(string=hai) anos
pai
achega
7b68300ba9
Modificáronse 13 ficheiros con 607 adicións e 158 borrados
  1. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.ts
  2. 86 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.scss
  3. 187 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.ts
  4. 48 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.service.ts
  5. 4 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu-plugin.ts
  6. 2 3
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu.ts
  7. 0 149
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/tables.ts
  8. 210 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/tables-plugin.ts
  9. 46 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.scss
  10. 7 2
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts
  11. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.html
  12. 12 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.ts
  13. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.ts

@@ -41,7 +41,7 @@ export type DropdownPosition = 'top-left' | 'top-right' | 'bottom-left' | 'botto
     template: `
         <ng-template #menu>
             <div class="dropdown open">
-                <div class="dropdown-menu">
+                <div class="dropdown-menu" [ngClass]="customClasses">
                     <div class="dropdown-content-wrapper">
                         <ng-content></ng-content>
                     </div>
@@ -54,6 +54,7 @@ export type DropdownPosition = 'top-left' | 'top-right' | 'bottom-left' | 'botto
 })
 export class DropdownMenuComponent implements AfterViewInit, OnInit, OnDestroy {
     @Input('vdrPosition') private position: DropdownPosition = 'bottom-left';
+    @Input() customClasses: string;
     @ViewChild('menu', { static: true }) private menuTemplate: TemplateRef<any>;
     private menuPortal: TemplatePortal<any>;
     private overlayRef: OverlayRef;

+ 86 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.scss

@@ -0,0 +1,86 @@
+:host {
+}
+
+.context-menu-trigger {
+    margin: 0;
+    display: flex;
+    align-items: center;
+    border: 1px solid var(--color-component-border-200);
+    font-size: 90%;
+    color: var(--color-text-200);
+    border-radius: var(--border-radius-input);
+    background-color: var(--color-component-bg-100);
+}
+
+.title-label {
+    padding-right: 15px;
+    position: relative;
+    &:after {
+        content: '';
+        border-left: 4px solid transparent;
+        border-right: 4px solid transparent;
+        border-top: 4px solid currentColor;
+        opacity: 0.6;
+        position: absolute;
+        right: 4px;
+        top: calc(50% - 2px);
+    }
+}
+
+.context-menu-item {
+    display: flex;
+    align-items: center;
+    width: 100%;
+    text-align: start;
+    font-size: 90%;
+    color: var(--color-text-200);
+    background-color: var(--color-component-bg-100);
+    cursor: pointer;
+    border: none;
+    &:hover {
+        background-color: var(--color-component-bg-200);
+    }
+}
+
+::ng-deep {
+    .dropdown-menu.context-menu {
+        padding: 0;
+        background-color: var(--color-component-bg-100);
+    }
+
+    .context-menu-trigger {
+        &.hidden {
+            visibility: hidden;
+        }
+    }
+
+    .cm-icon.add-column {
+        height: 14px;
+        width: 4px;
+        border: 1px solid;
+        margin: 0 6px 0 8px;
+        position: relative;
+        &:before {
+            content: '+';
+            position: absolute;
+            font-size: 16px;
+            line-height: 14px;
+            left: -10px;
+        }
+    }
+    .cm-icon.add-row {
+        height: 4px;
+        width: 14px;
+        border: 1px solid;
+        margin: 6px 4px 2px 0px;
+        position: relative;
+        &:before {
+            content: '+';
+            position: absolute;
+            font-size: 16px;
+            line-height: 14px;
+            left: -2px;
+            top: -10px;
+        }
+    }
+}

+ 187 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.ts

@@ -0,0 +1,187 @@
+import { ConnectedPosition, Overlay, OverlayRef, PositionStrategy } from '@angular/cdk/overlay';
+import { TemplatePortal } from '@angular/cdk/portal';
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    Component,
+    Input,
+    OnDestroy,
+    TemplateRef,
+    ViewChild,
+    ViewContainerRef,
+} from '@angular/core';
+import { RichTextEditorComponent } from '@vendure/admin-ui/core';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { distinctUntilChanged } from 'rxjs/operators';
+
+import { ContextMenuConfig, ContextMenuItem, ContextMenuService } from './context-menu.service';
+
+export type DropdownPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+
+@Component({
+    selector: 'vdr-context-menu',
+    template: `
+        <ng-template #contextMenu>
+            <vdr-dropdown>
+                <button class="context-menu-trigger" vdrDropdownTrigger [class.hidden]="hideTrigger$ | async">
+                    <clr-icon
+                        *ngIf="menuConfig?.iconShape as shape"
+                        [attr.shape]="shape"
+                        size="16"
+                        class="mr2"
+                    ></clr-icon>
+                    <span class="title-label">{{ menuConfig?.title }}</span>
+                </button>
+                <vdr-dropdown-menu vdrPosition="bottom-right" customClasses="context-menu">
+                    <ng-container *ngFor="let item of menuConfig?.items">
+                        <button
+                            class="context-menu-item"
+                            *ngIf="item.enabled && item.separator !== true"
+                            type="button"
+                            (click)="clickItem(item)"
+                        >
+                            <div *ngIf="item.iconClass" class="cm-icon" [ngClass]="item.iconClass"></div>
+                            {{ item.label }}
+                        </button>
+                        <div
+                            *ngIf="item.enabled && item.separator"
+                            class="dropdown-divider"
+                            role="separator"
+                        ></div>
+                    </ng-container>
+                </vdr-dropdown-menu>
+            </vdr-dropdown>
+        </ng-template>
+    `,
+    styleUrls: ['./context-menu.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ContextMenuComponent implements AfterViewInit, OnDestroy {
+    @Input('vdrPosition') private position: DropdownPosition = 'top-left';
+    @ViewChild('contextMenu', { static: true }) private menuTemplate: TemplateRef<any>;
+
+    menuConfig: ContextMenuConfig | undefined;
+    hideTrigger$: Observable<boolean>;
+    private triggerIsHidden = new BehaviorSubject<boolean>(false);
+    private menuPortal: TemplatePortal<any>;
+    private overlayRef: OverlayRef;
+    private contextMenuSub: Subscription;
+    private contentArea: HTMLDivElement;
+    private hideTriggerHandler: (() => void) | undefined;
+
+    constructor(
+        private richTextEditor: RichTextEditorComponent,
+        private overlay: Overlay,
+        private viewContainerRef: ViewContainerRef,
+        public contextMenuService: ContextMenuService,
+    ) {}
+
+    onScroll = () => {
+        if (this.overlayRef?.hasAttached()) {
+            this.overlayRef.updatePosition();
+        }
+    };
+
+    ngAfterViewInit() {
+        // tslint:disable-next-line:no-non-null-assertion
+        this.contentArea = document.querySelector('.content-area')!;
+        this.menuPortal = new TemplatePortal(this.menuTemplate, this.viewContainerRef);
+
+        this.hideTrigger$ = this.triggerIsHidden.asObservable().pipe(distinctUntilChanged());
+        this.contentArea.addEventListener('scroll', this.onScroll, { passive: true });
+
+        this.contextMenuSub = this.contextMenuService.contextMenu$.subscribe(contextMenuConfig => {
+            this.overlayRef?.dispose();
+            this.menuConfig = contextMenuConfig;
+            if (contextMenuConfig) {
+                this.overlayRef = this.overlay.create({
+                    hasBackdrop: false,
+                    positionStrategy: this.getPositionStrategy(contextMenuConfig.element),
+                    maxHeight: '70vh',
+                });
+                this.overlayRef.attach(this.menuPortal);
+                this.triggerIsHidden.next(false);
+
+                const triggerButton = this.overlayRef.hostElement.querySelector('.context-menu-trigger');
+                const editorMenu = this.richTextEditor.menuElement;
+                if (triggerButton) {
+                    this.hideTriggerHandler = () => {
+                        if (
+                            triggerButton.getBoundingClientRect().top <
+                            editorMenu.getBoundingClientRect().bottom
+                        ) {
+                            this.triggerIsHidden.next(true);
+                        } else {
+                            this.triggerIsHidden.next(false);
+                        }
+                    };
+                    this.contentArea.addEventListener('scroll', this.hideTriggerHandler, { passive: true });
+                }
+            } else {
+                if (this.hideTriggerHandler) {
+                    this.contentArea.removeEventListener('scroll', this.hideTriggerHandler);
+                }
+            }
+        });
+    }
+
+    private setUpIntersectionObserver() {}
+
+    ngOnDestroy(): void {
+        this.overlayRef?.dispose();
+        this.contextMenuSub?.unsubscribe();
+        this.contentArea.removeEventListener('scroll', this.onScroll);
+    }
+
+    clickItem(item: ContextMenuItem) {
+        item.onClick();
+    }
+
+    private getPositionStrategy(element: Element): PositionStrategy {
+        const position: { [K in DropdownPosition]: ConnectedPosition } = {
+            ['top-left']: {
+                originX: 'start',
+                originY: 'top',
+                overlayX: 'start',
+                overlayY: 'bottom',
+            },
+            ['top-right']: {
+                originX: 'end',
+                originY: 'top',
+                overlayX: 'end',
+                overlayY: 'bottom',
+            },
+            ['bottom-left']: {
+                originX: 'start',
+                originY: 'bottom',
+                overlayX: 'start',
+                overlayY: 'top',
+            },
+            ['bottom-right']: {
+                originX: 'end',
+                originY: 'bottom',
+                overlayX: 'end',
+                overlayY: 'top',
+            },
+        };
+
+        const pos = position[this.position];
+
+        return this.overlay
+            .position()
+            .flexibleConnectedTo(element)
+            .withPositions([pos, this.invertPosition(pos)])
+            .withViewportMargin(0)
+            .withLockedPosition(false)
+            .withPush(false);
+    }
+
+    /** Inverts an overlay position. */
+    private invertPosition(pos: ConnectedPosition): ConnectedPosition {
+        const inverted = { ...pos };
+        inverted.originY = pos.originY === 'top' ? 'bottom' : 'top';
+        inverted.overlayY = pos.overlayY === 'top' ? 'bottom' : 'top';
+
+        return inverted;
+    }
+}

+ 48 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.service.ts

@@ -0,0 +1,48 @@
+import { Injectable } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+import { distinctUntilChanged } from 'rxjs/operators';
+
+export interface ContextMenuConfig {
+    iconShape?: string;
+    title: string;
+    element: Element;
+    coords: { left: number; right: number; top: number; bottom: number };
+    items: ContextMenuItem[];
+}
+
+export interface ContextMenuItem {
+    separator?: boolean;
+    iconClass?: string;
+    label: string;
+    enabled: boolean;
+    onClick: () => void;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ContextMenuService {
+    contextMenu$: Observable<ContextMenuConfig | undefined>;
+    private setContextMenuConfig$ = new Subject<ContextMenuConfig | undefined>();
+    constructor() {
+        this.contextMenu$ = this.setContextMenuConfig$.asObservable().pipe(
+            distinctUntilChanged((a, b) => {
+                if (a == null && b == null) {
+                    return true;
+                }
+                if (a?.element === b?.element) {
+                    if (a?.items.map(i => i.enabled).join(',') === b?.items.map(i => i.enabled).join(',')) {
+                        return true;
+                    }
+                }
+                return false;
+            }),
+        );
+    }
+
+    setContextMenu(config: ContextMenuConfig) {
+        this.setContextMenuConfig$.next(config);
+    }
+
+    clearContextMenu() {
+        this.setContextMenuConfig$.next();
+    }
+}

+ 4 - 1
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu-plugin.ts

@@ -1,9 +1,12 @@
 import { Injector } from '@angular/core';
-import { buildMenuItems, ModalService } from '@vendure/admin-ui/core';
 import { menuBar } from 'prosemirror-menu';
 import { Schema } from 'prosemirror-model';
 import { EditorState, Plugin } from 'prosemirror-state';
 
+import { ModalService } from '../../../../../providers/modal/modal.service';
+
+import { buildMenuItems } from './menu';
+
 export interface CustomMenuPluginOptions {
     floatingMenu?: boolean;
     schema: Schema;

+ 2 - 3
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu.ts

@@ -15,12 +15,12 @@ import { wrapInList } from 'prosemirror-schema-list';
 import { EditorState } from 'prosemirror-state';
 
 import { ModalService } from '../../../../../providers/modal/modal.service';
+import { addTable } from '../plugins/tables-plugin';
 
 import { insertImageItem } from './images';
 import { linkItem } from './links';
 import { canInsert, IconSize, markActive, renderClarityIcon, wrapInMenuItemWithIcon } from './menu-common';
 import { SubMenuWithIcon } from './sub-menu-with-icon';
-import { addTable, getTableMenu } from './tables';
 
 // Helpers to create specific types of items
 
@@ -245,7 +245,6 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
         ]),
         { label: 'Type...' },
     );
-    r.tableMenu = new Dropdown(getTableMenu(schema), { label: 'Table' });
 
     const inlineMenu = cut([r.toggleStrong, r.toggleEm, r.toggleLink]);
     r.inlineMenu = [inlineMenu];
@@ -277,7 +276,7 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
             render: renderClarityIcon({ shape: 'redo', size: IconSize.Large }),
         }),
     ];
-    r.fullMenu = [inlineMenu].concat([[r.insertMenu, r.typeMenu, r.tableMenu]], [undoRedo], r.blockMenu);
+    r.fullMenu = [inlineMenu].concat([[r.insertMenu, r.typeMenu]], [undoRedo], r.blockMenu);
 
     return r;
 }

+ 0 - 149
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/tables.ts

@@ -1,149 +0,0 @@
-import { buildMenuItems, renderClarityIcon } from '@vendure/admin-ui/core';
-import OrderedMap from 'orderedmap';
-import { Dropdown, IconSpec, MenuElement, MenuItem } from 'prosemirror-menu';
-import { NodeSpec, Schema, Node } from 'prosemirror-model';
-import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
-import {
-    addColumnAfter,
-    addColumnBefore,
-    addRowAfter,
-    addRowBefore,
-    deleteColumn,
-    deleteRow,
-    deleteTable,
-    isInTable,
-    mergeCells,
-    setCellAttr,
-    splitCell,
-    tableNodes,
-    tableNodeTypes,
-    toggleHeaderCell,
-    toggleHeaderColumn,
-    toggleHeaderRow,
-} from 'prosemirror-tables';
-
-export function getTableNodes() {
-    return tableNodes({
-        tableGroup: 'block',
-        cellContent: 'block+',
-        cellAttributes: {
-            background: {
-                default: null,
-                getFromDOM(dom) {
-                    return (dom as HTMLElement).style.backgroundColor || null;
-                },
-                setDOMAttr(value, attrs) {
-                    if (value) attrs.style = (attrs.style || '') + `background-color: ${value};`;
-                },
-            },
-        },
-    });
-}
-
-export function getTableMenu(schema: Schema) {
-    function item(
-        label: string,
-        cmd: (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean,
-        iconShape?: string,
-    ) {
-        return new MenuItem({
-            label,
-            select: cmd,
-            run: cmd,
-            render: iconShape ? renderClarityIcon({ shape: iconShape, label }) : undefined,
-        });
-    }
-    function separator(): MenuElement {
-        return new MenuItem({
-            select: state => isInTable(state),
-            run: state => {},
-            render: view => {
-                const el = document.createElement('div');
-                el.classList.add('menu-separator');
-                return el;
-            },
-        });
-    }
-
-    return [
-        item('Insert column before', addColumnBefore),
-        item('Insert column after', addColumnAfter),
-        item('Insert row before', addRowBefore),
-        item('Insert row after', addRowAfter),
-        item('Merge cells', mergeCells),
-        item('Split cell', splitCell),
-        separator(),
-        item('Toggle header column', toggleHeaderColumn),
-        item('Toggle header row', toggleHeaderRow),
-        item('Toggle header cells', toggleHeaderCell),
-        separator(),
-        item('Delete column', deleteColumn),
-        item('Delete row', deleteRow),
-        item('Delete table', deleteTable),
-    ];
-}
-
-function createTable(state, rowsCount, colsCount, withHeaderRow, cellContent) {
-    const types = tableNodeTypes(state.schema);
-    const headerCells: Node[] = [];
-    const cells: Node[] = [];
-    const createCell = (cellType, _cellContent) =>
-        _cellContent ? cellType.createChecked(null, _cellContent) : cellType.createAndFill();
-
-    for (let index = 0; index < colsCount; index += 1) {
-        const cell = createCell(types.cell, cellContent);
-
-        if (cell) {
-            cells.push(cell);
-        }
-
-        if (withHeaderRow) {
-            const headerCell = createCell(types.header_cell, cellContent);
-
-            if (headerCell) {
-                headerCells.push(headerCell);
-            }
-        }
-    }
-
-    const rows: Node[] = [];
-
-    for (let index = 0; index < rowsCount; index += 1) {
-        rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells));
-    }
-
-    return types.table.createChecked(null, rows);
-}
-
-export function addTable(state, dispatch, { rowsCount, colsCount, withHeaderRow, cellContent }) {
-    const offset = state.tr.selection.anchor + 1;
-
-    const nodes = createTable(state, rowsCount, colsCount, withHeaderRow, cellContent);
-    const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView();
-    const resolvedPos = tr.doc.resolve(offset);
-
-    tr.setSelection(TextSelection.near(resolvedPos));
-
-    dispatch(tr);
-}
-
-// add table to a new paragraph
-export function addTableToEnd(state, dispatch, { rowsCount, colsCount, withHeaderRow, cellContent }) {
-    let tr = state.tr;
-
-    // get block end position
-    const end = tr.selection.$head.end(1); // param 1 is node deep
-    const resolvedEnd = tr.doc.resolve(end);
-
-    // move cursor to the end, then insert table
-    const nodes = createTable(state, rowsCount, colsCount, withHeaderRow, cellContent);
-    tr.setSelection(TextSelection.near(resolvedEnd));
-    tr = tr.replaceSelectionWith(nodes).scrollIntoView();
-
-    // move cursor into table
-    const offset = end + 1;
-    const resolvedPos = tr.doc.resolve(offset);
-    tr.setSelection(TextSelection.near(resolvedPos));
-
-    dispatch(tr);
-}

+ 210 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/tables-plugin.ts

@@ -0,0 +1,210 @@
+import { MenuElement, MenuItem } from 'prosemirror-menu';
+import { Node, Schema } from 'prosemirror-model';
+import { EditorState, Plugin, TextSelection, Transaction } from 'prosemirror-state';
+import {
+    addColumnAfter,
+    addColumnBefore,
+    addRowAfter,
+    addRowBefore,
+    deleteColumn,
+    deleteRow,
+    deleteTable,
+    isInTable,
+    mergeCells,
+    splitCell,
+    tableNodes,
+    tableNodeTypes,
+    toggleHeaderCell,
+    toggleHeaderColumn,
+    toggleHeaderRow,
+} from 'prosemirror-tables';
+import { Decoration, DecorationSet } from 'prosemirror-view';
+
+import { ContextMenuItem, ContextMenuService } from '../context-menu/context-menu.service';
+import { buildMenuItems } from '../menu/menu';
+import { renderClarityIcon } from '../menu/menu-common';
+
+export const tableContextMenuPlugin = (contextMenuService: ContextMenuService) =>
+    new Plugin({
+        view: () => {
+            return {
+                update: view => {
+                    const { doc, selection } = view.state;
+                    let tableNode: Node | undefined;
+                    let tableNodePos = 0;
+                    doc.nodesBetween(selection.from, selection.to, (n, pos, parent) => {
+                        if (n.type.name === 'table') {
+                            tableNode = n;
+                            tableNodePos = pos;
+                            return false;
+                        }
+                    });
+                    if (tableNode) {
+                        const node = view.nodeDOM(tableNodePos);
+                        if (node instanceof Element) {
+                            function createMenuItem(
+                                label: string,
+                                commandFn: (
+                                    state: EditorState,
+                                    dispatch?: (tr: Transaction) => void,
+                                ) => boolean,
+                                iconClass?: string,
+                            ): ContextMenuItem {
+                                const enabled = commandFn(view.state);
+                                return {
+                                    label,
+                                    enabled,
+                                    iconClass,
+                                    onClick: () => {
+                                        view.focus();
+                                        commandFn(view.state, view.dispatch);
+                                    },
+                                };
+                            }
+                            const separator: ContextMenuItem = {
+                                label: '',
+                                separator: true,
+                                enabled: true,
+                                onClick: () => {
+                                    /**/
+                                },
+                            };
+                            contextMenuService.setContextMenu({
+                                title: 'Table',
+                                iconShape: 'table',
+                                element: node,
+                                coords: view.coordsAtPos(tableNodePos),
+                                items: [
+                                    createMenuItem('Insert column before', addColumnBefore, 'add-column'),
+                                    createMenuItem('Insert column after', addColumnAfter, 'add-column'),
+                                    createMenuItem('Insert row before', addRowBefore, 'add-row'),
+                                    createMenuItem('Insert row after', addRowAfter, 'add-row'),
+                                    createMenuItem('Merge cells', mergeCells),
+                                    createMenuItem('Split cell', splitCell),
+                                    separator,
+                                    createMenuItem('Toggle header column', toggleHeaderColumn),
+                                    createMenuItem('Toggle header row', toggleHeaderRow),
+                                    separator,
+                                    createMenuItem('Delete column', deleteColumn),
+                                    createMenuItem('Delete row', deleteRow),
+                                    createMenuItem('Delete table', deleteTable),
+                                ],
+                            });
+                        }
+                    } else {
+                        contextMenuService.clearContextMenu();
+                    }
+                },
+            };
+        },
+    });
+
+export function getTableNodes() {
+    return tableNodes({
+        tableGroup: 'block',
+        cellContent: 'block+',
+        cellAttributes: {
+            background: {
+                default: null,
+                getFromDOM(dom) {
+                    return (dom as HTMLElement).style.backgroundColor || null;
+                },
+                setDOMAttr(value, attrs) {
+                    if (value) {
+                        attrs.style = (attrs.style || '') + `background-color: ${value};`;
+                    }
+                },
+            },
+        },
+    });
+}
+
+export function getTableMenu(schema: Schema) {
+    function item(
+        label: string,
+        cmd: (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean,
+        iconShape?: string,
+    ) {
+        return new MenuItem({
+            label,
+            select: cmd,
+            run: cmd,
+            render: iconShape ? renderClarityIcon({ shape: iconShape, label }) : undefined,
+        });
+    }
+
+    function separator(): MenuElement {
+        return new MenuItem({
+            select: state => isInTable(state),
+            run: state => {
+                /**/
+            },
+            render: view => {
+                const el = document.createElement('div');
+                el.classList.add('menu-separator');
+                return el;
+            },
+        });
+    }
+
+    return [
+        item('Insert column before', addColumnBefore),
+        item('Insert column after', addColumnAfter),
+        item('Insert row before', addRowBefore),
+        item('Insert row after', addRowAfter),
+        item('Merge cells', mergeCells),
+        item('Split cell', splitCell),
+        separator(),
+        item('Toggle header column', toggleHeaderColumn),
+        item('Toggle header row', toggleHeaderRow),
+        item('Toggle header cells', toggleHeaderCell),
+        separator(),
+        item('Delete column', deleteColumn),
+        item('Delete row', deleteRow),
+        item('Delete table', deleteTable),
+    ];
+}
+
+export function addTable(state, dispatch, { rowsCount, colsCount, withHeaderRow, cellContent }) {
+    const offset = state.tr.selection.anchor + 1;
+
+    const nodes = createTable(state, rowsCount, colsCount, withHeaderRow, cellContent);
+    const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView();
+    const resolvedPos = tr.doc.resolve(offset);
+
+    tr.setSelection(TextSelection.near(resolvedPos));
+
+    dispatch(tr);
+}
+
+function createTable(state, rowsCount, colsCount, withHeaderRow, cellContent) {
+    const types = tableNodeTypes(state.schema);
+    const headerCells: Node[] = [];
+    const cells: Node[] = [];
+    const createCell = (cellType, _cellContent) =>
+        _cellContent ? cellType.createChecked(null, _cellContent) : cellType.createAndFill();
+
+    for (let index = 0; index < colsCount; index += 1) {
+        const cell = createCell(types.cell, cellContent);
+
+        if (cell) {
+            cells.push(cell);
+        }
+
+        if (withHeaderRow) {
+            const headerCell = createCell(types.header_cell, cellContent);
+
+            if (headerCell) {
+                headerCells.push(headerCell);
+            }
+        }
+    }
+
+    const rows: Node[] = [];
+
+    for (let index = 0; index < rowsCount; index += 1) {
+        rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells));
+    }
+
+    return types.table.createChecked(null, rows);
+}

+ 46 - 1
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.scss

@@ -343,7 +343,7 @@
     .ProseMirror .tableWrapper {
         td,
         th {
-            border: 1px solid var(--color-grey-400);
+            border: 1px solid var(--color-grey-300);
             padding: 3px 6px;
         }
         td p,
@@ -356,6 +356,47 @@
         }
     }
 
+    .ProseMirror table {
+      border-collapse: collapse;
+      table-layout: fixed;
+      width: 100%;
+      overflow: hidden;
+    }
+    .ProseMirror td,
+    .ProseMirror th {
+      vertical-align: top;
+      box-sizing: border-box;
+      position: relative;
+    }
+    .ProseMirror .column-resize-handle {
+      position: absolute;
+      right: -2px;
+      top: 0;
+      bottom: 0;
+      width: 4px;
+      z-index: 20;
+      background-color: #adf;
+      pointer-events: none;
+    }
+    .ProseMirror.resize-cursor {
+      cursor: ew-resize;
+      cursor: col-resize;
+    }
+    /* Give selected cells a blue overlay */
+    .ProseMirror .selectedCell:after {
+      z-index: 2;
+      position: absolute;
+      content: '';
+      left: 0;
+      right: 0;
+      top: 0;
+      bottom: 0;
+      background: #afdaf355;
+      pointer-events: none;
+    }
+
+
+
     .menu-separator {
         border-bottom: 1px solid var(--color-grey-400);
         height: 0;
@@ -384,3 +425,7 @@
         }
     }
 }
+
+.context-menu {
+    position: fixed;
+}

+ 7 - 2
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts

@@ -10,12 +10,14 @@ import { addListNodes } from 'prosemirror-schema-list';
 import { EditorState, Plugin } from 'prosemirror-state';
 import { columnResizing, fixTables, tableEditing } from 'prosemirror-tables';
 import { EditorView } from 'prosemirror-view';
+import { Observable } from 'rxjs';
 
+import { ContextMenuService } from './context-menu/context-menu.service';
 import { buildInputRules } from './inputrules';
 import { buildKeymap } from './keymap';
 import { customMenuPlugin } from './menu/menu-plugin';
-import { getTableNodes } from './menu/tables';
 import { linkSelectPlugin } from './plugins/link-select-plugin';
+import { getTableNodes, tableContextMenuPlugin } from './plugins/tables-plugin';
 import { SetupOptions } from './types';
 
 export interface CreateEditorViewOptions {
@@ -36,7 +38,9 @@ export class ProsemirrorService {
     });
     private enabled = true;
 
-    constructor(private injector: Injector) {}
+    constructor(private injector: Injector, private contextMenuService: ContextMenuService) {}
+
+    contextMenuItems$: Observable<string>;
 
     createEditorView(options: CreateEditorViewOptions) {
         this.editorView = new EditorView(options.element, {
@@ -110,6 +114,7 @@ export class ProsemirrorService {
             linkSelectPlugin,
             columnResizing({}),
             tableEditing({ allowTableNodeSelection: true }),
+            tableContextMenuPlugin(this.contextMenuService),
             customMenuPlugin({
                 floatingMenu: options.floatingMenu,
                 injector: this.injector,

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.html

@@ -1,2 +1,3 @@
 <label class="clr-control-label">{{ label }}</label>
 <div #editor></div>
+<vdr-context-menu></vdr-context-menu>

+ 12 - 1
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.ts

@@ -8,9 +8,11 @@ import {
     Input,
     OnDestroy,
     ViewChild,
+    ViewContainerRef,
 } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 
+import { ContextMenuService } from './prosemirror/context-menu/context-menu.service';
 import { ProsemirrorService } from './prosemirror/prosemirror.service';
 
 /**
@@ -56,7 +58,16 @@ export class RichTextEditorComponent implements ControlValueAccessor, AfterViewI
 
     @ViewChild('editor', { static: true }) private editorEl: ElementRef<HTMLDivElement>;
 
-    constructor(private changeDetector: ChangeDetectorRef, private prosemirrorService: ProsemirrorService) {}
+    constructor(
+        private changeDetector: ChangeDetectorRef,
+        private prosemirrorService: ProsemirrorService,
+        private viewContainerRef: ViewContainerRef,
+        public contextMenuService: ContextMenuService,
+    ) {}
+
+    get menuElement(): HTMLDivElement {
+        return this.viewContainerRef.element.nativeElement.querySelector('.ProseMirror-menubar');
+    }
 
     ngAfterViewInit() {
         this.prosemirrorService.createEditorView({

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -74,6 +74,7 @@ import { ProductSearchInputComponent } from './components/product-search-input/p
 import { ProductSelectorComponent } from './components/product-selector/product-selector.component';
 import { ExternalImageDialogComponent } from './components/rich-text-editor/external-image-dialog/external-image-dialog.component';
 import { LinkDialogComponent } from './components/rich-text-editor/link-dialog/link-dialog.component';
+import { ContextMenuComponent } from './components/rich-text-editor/prosemirror/context-menu/context-menu.component';
 import { RichTextEditorComponent } from './components/rich-text-editor/rich-text-editor.component';
 import { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 import { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.component';
@@ -240,6 +241,7 @@ const DECLARATIONS = [
     AssetPreviewLinksComponent,
     ProductMultiSelectorDialogComponent,
     ProductSearchInputComponent,
+    ContextMenuComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [