Explorar o código

feat(admin-ui): Add basic table support to rich text editor

Relates to #1716
Michael Bromley %!s(int64=3) %!d(string=hai) anos
pai
achega
09f848235f

+ 1 - 0
packages/admin-ui/package.json

@@ -60,6 +60,7 @@
     "prosemirror-schema-basic": "^1.2.0",
     "prosemirror-schema-list": "^1.2.1",
     "prosemirror-state": "^1.4.1",
+    "prosemirror-tables": "^1.2.5",
     "rxjs": "^6.6.6",
     "tslib": "^2.1.0",
     "zone.js": "~0.11.4"

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

@@ -0,0 +1,20 @@
+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';
+
+export interface CustomMenuPluginOptions {
+    floatingMenu?: boolean;
+    schema: Schema;
+    injector: Injector;
+}
+
+export function customMenuPlugin(options: CustomMenuPluginOptions) {
+    const modalService = options.injector.get(ModalService);
+    const pmMenuBarPlugin = menuBar({
+        floating: options.floatingMenu !== false,
+        content: buildMenuItems(options.schema, modalService).fullMenu,
+    });
+    return pmMenuBarPlugin;
+}

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

@@ -21,6 +21,7 @@ import { ModalService } from '../../../../../providers/modal/modal.service';
 import { insertImageItem } from './images';
 import { linkItem } from './links';
 import { canInsert, markActive } from './menu-common';
+import { addTable, getTableMenu } from './tables';
 
 // Helpers to create specific types of items
 
@@ -192,7 +193,24 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
     }
 
     const cut = <T>(arr: T[]): T[] => arr.filter(x => x);
-    r.insertMenu = new Dropdown(cut([r.insertImage, r.insertHorizontalRule]), { label: 'Insert' });
+    r.insertMenu = new Dropdown(
+        cut([
+            r.insertImage,
+            r.insertHorizontalRule,
+            new MenuItem({
+                label: 'Add table',
+                run: (state, dispatch) => {
+                    addTable(state, dispatch, {
+                        rowsCount: 2,
+                        colsCount: 2,
+                        withHeaderRow: true,
+                        cellContent: '',
+                    });
+                },
+            }),
+        ]),
+        { label: 'Insert' },
+    );
     r.typeMenu = new Dropdown(
         cut([
             r.makeParagraph,
@@ -205,6 +223,7 @@ 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];
@@ -218,7 +237,11 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
             selectParentNodeItem,
         ]),
     ];
-    r.fullMenu = [inlineMenu].concat([[r.insertMenu, r.typeMenu]], [[undoItem, redoItem]], r.blockMenu);
+    r.fullMenu = [inlineMenu].concat(
+        [[r.insertMenu, r.typeMenu, r.tableMenu]],
+        [[undoItem, redoItem]],
+        r.blockMenu,
+    );
 
     return r;
 }

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

@@ -0,0 +1,161 @@
+import { buildMenuItems } 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
+                ? view => {
+                      const icon = document.createElement('clr-icon');
+                      icon.setAttribute('shape', iconShape);
+                      icon.setAttribute('size', '16');
+                      const wrapperEl = document.createElement('span');
+                      wrapperEl.classList.add('menu-item-with-icon');
+                      const labelEl = document.createElement('span');
+                      labelEl.textContent = label;
+                      wrapperEl.append(icon, labelEl);
+                      return wrapperEl;
+                  }
+                : 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);
+}

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

@@ -336,4 +336,31 @@
         margin-bottom: 0.5rem;
         color: var(--color-grey-800) !important;
     }
+
+    .ProseMirror .tableWrapper {
+        td, th {
+            border: 1px solid var(--color-grey-400);
+            padding: 3px 6px;
+        }
+        td p, th p {
+            margin-top: 0;
+        }
+        th, th p {
+            font-weight: bold;
+        }
+    }
+
+    .menu-separator {
+        border-bottom: 1px solid var(--color-grey-400);
+        height: 0;
+        margin: 6px 0;
+        pointer-events: none;
+    }
+    .menu-item-with-icon {
+        display: flex;
+        align-items: center;
+        clr-icon {
+            margin-right: 2px;
+        }
+    }
 }

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

@@ -1,21 +1,20 @@
-import { Injectable } from '@angular/core';
+import { Injectable, Injector } from '@angular/core';
 import { baseKeymap } from 'prosemirror-commands';
 import { dropCursor } from 'prosemirror-dropcursor';
 import { gapCursor } from 'prosemirror-gapcursor';
 import { history } from 'prosemirror-history';
 import { keymap } from 'prosemirror-keymap';
-import { menuBar } from 'prosemirror-menu';
 import { DOMParser, DOMSerializer, Schema } from 'prosemirror-model';
 import { schema } from 'prosemirror-schema-basic';
 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 { ModalService } from '../../../../providers/modal/modal.service';
-
 import { buildInputRules } from './inputrules';
 import { buildKeymap } from './keymap';
-import { buildMenuItems } from './menu/menu';
+import { customMenuPlugin } from './menu/menu-plugin';
+import { getTableNodes } from './menu/tables';
 import { linkSelectPlugin } from './plugins/link-select-plugin';
 import { SetupOptions } from './types';
 
@@ -32,12 +31,12 @@ export class ProsemirrorService {
     // Mix the nodes from prosemirror-schema-list into the basic schema to
     // create a schema with list support.
     private mySchema = new Schema({
-        nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
+        nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block').append(getTableNodes() as any),
         marks: schema.spec.marks,
     });
     private enabled = true;
 
-    constructor(private modalService: ModalService) {}
+    constructor(private injector: Injector) {}
 
     createEditorView(options: CreateEditorViewOptions) {
         this.editorView = new EditorView(options.element, {
@@ -58,8 +57,10 @@ export class ProsemirrorService {
 
     update(text: string) {
         if (this.editorView) {
-            const state = this.getStateFromText(text);
+            let state = this.getStateFromText(text);
             if (document.body.contains(this.editorView.dom)) {
+                const fix = fixTables(state);
+                if (fix) state = state.apply(fix.setMeta('addToHistory', false));
                 this.editorView.updateState(state);
             }
         }
@@ -107,16 +108,14 @@ export class ProsemirrorService {
             dropCursor(),
             gapCursor(),
             linkSelectPlugin,
+            columnResizing({}),
+            tableEditing({ allowTableNodeSelection: true }),
+            customMenuPlugin({
+                floatingMenu: options.floatingMenu,
+                injector: this.injector,
+                schema: options.schema,
+            }),
         ];
-        if (options.menuBar !== false) {
-            plugins.push(
-                menuBar({
-                    floating: options.floatingMenu !== false,
-                    content:
-                        options.menuContent || buildMenuItems(options.schema, this.modalService).fullMenu,
-                }),
-            );
-        }
         if (options.history !== false) {
             plugins.push(history());
         }

+ 0 - 1
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/types.ts

@@ -7,7 +7,6 @@ export interface SetupOptions {
     menuBar?: boolean;
     history?: boolean;
     floatingMenu?: boolean;
-    menuContent?: MenuItem[][];
 }
 
 export type Keymap = Record<string, string | false>;

+ 16 - 5
yarn.lock

@@ -15841,7 +15841,7 @@ prosemirror-inputrules@^1.2.0:
     prosemirror-state "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.0:
+prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2, prosemirror-keymap@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.0.tgz#d5cc9da9b712020690a994b50b92a0e448a60bf5"
   integrity sha512-TdSfu+YyLDd54ufN/ZeD1VtBRYpgZnTPnnbY+4R08DDgs84KrIPEPbJL8t1Lm2dkljFx6xeBE26YWH3aIzkPKg==
@@ -15859,7 +15859,7 @@ prosemirror-menu@^1.2.1:
     prosemirror-history "^1.0.0"
     prosemirror-state "^1.0.0"
 
-prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.2.0:
+prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.2.0, prosemirror-model@^1.8.1:
   version "1.18.1"
   resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.18.1.tgz#1d5d6b6de7b983ee67a479dc607165fdef3935bd"
   integrity sha512-IxSVBKAEMjD7s3n8cgtwMlxAXZrC7Mlag7zYsAKDndAqnDScvSmp/UdnRTV/B33lTCVU3CCm7dyAn/rVVD0mcw==
@@ -15882,7 +15882,7 @@ prosemirror-schema-list@^1.2.1:
     prosemirror-state "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.1:
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.1, prosemirror-state@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.1.tgz#f6e26c7b6a7e11206176689eb6ebbf91870953e1"
   integrity sha512-U/LBDW2gNmVa07sz/D229XigSdDQ5CLFwVB1Vb32MJbAHHhWe/6pOc721faI17tqw4pZ49i1xfY/jEZ9tbIhPg==
@@ -15890,14 +15890,25 @@ prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.1:
     prosemirror-model "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0:
+prosemirror-tables@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.2.5.tgz#f140d4491acad4f8d9ebbdede65c933fe13f3c51"
+  integrity sha512-UB5XkWQC7YHJ2qubriOnKGxdVe+KujmoSatFyBlV8odVT/G++61XB1JXiU3ZAKJ60lTdq9WsowUhINSFeE7BoA==
+  dependencies:
+    prosemirror-keymap "^1.1.2"
+    prosemirror-model "^1.8.1"
+    prosemirror-state "^1.3.1"
+    prosemirror-transform "^1.2.1"
+    prosemirror-view "^1.13.3"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.2.1:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.7.0.tgz#a8a0768f3ee6418d26ebef435beda9d43c65e472"
   integrity sha512-O4T697Cqilw06Zvc3Wm+e237R6eZtJL/xGMliCi+Uo8VL6qHk6afz1qq0zNjT3eZMuYwnP8ZS0+YxX/tfcE9TQ==
   dependencies:
     prosemirror-model "^1.0.0"
 
-prosemirror-view@^1.0.0, prosemirror-view@^1.1.0:
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3:
   version "1.27.2"
   resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.27.2.tgz#7f1e9b73698ba2622c515e2a617fd49232d214c4"
   integrity sha512-RE2GLUaYXUyrpUl58vHoznZ3wKAj7z8f1ZZolivljwwOe1yiSzsEsuJPZmm3mpBXRgHGk7LWh5v+uhdDXAbjkA==