Bläddra i källkod

feat(admin-ui): Use ProseMirror as rich text editor

The former text editor (Trix) was incompatible with Angular 9 due to an issue with how it handled custom elements. It turns out that ProseMirror is way, way more powerful and will open up lots of cool extensibility options.
Michael Bromley 6 år sedan
förälder
incheckning
e30911135f
27 ändrade filer med 1575 tillägg och 93 borttagningar
  1. 5 21
      packages/admin-ui/angular.json
  2. 14 1
      packages/admin-ui/package.json
  3. 33 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html
  4. 19 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.scss
  5. 44 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.ts
  6. 16 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/link-dialog/link-dialog.component.html
  7. 0 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/link-dialog/link-dialog.component.scss
  8. 40 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/link-dialog/link-dialog.component.ts
  9. 80 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/inputrules.ts
  10. 137 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/keymap.ts
  11. 45 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/images.ts
  12. 78 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/links.ts
  13. 22 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/menu-common.ts
  14. 225 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/menu.ts
  15. 25 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/plugins/link-select-plugin.ts
  16. 338 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/prosemirror.scss
  17. 126 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts
  18. 13 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/types.ts
  19. 36 0
      packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/utils.ts
  20. 2 2
      packages/admin-ui/src/app/shared/components/rich-text-editor/rich-text-editor.component.html
  21. 39 17
      packages/admin-ui/src/app/shared/components/rich-text-editor/rich-text-editor.component.scss
  22. 24 46
      packages/admin-ui/src/app/shared/components/rich-text-editor/rich-text-editor.component.ts
  23. 4 0
      packages/admin-ui/src/app/shared/shared-declarations.ts
  24. 4 0
      packages/admin-ui/src/app/shared/shared.module.ts
  25. 11 1
      packages/admin-ui/src/i18n-messages/en.json
  26. 11 0
      packages/admin-ui/src/styles/theme/_forms.scss
  27. 184 5
      yarn.lock

+ 5 - 21
packages/admin-ui/angular.json

@@ -37,11 +37,7 @@
             ],
             ],
             "styles": [
             "styles": [
               "../../node_modules/@clr/icons/clr-icons.min.css",
               "../../node_modules/@clr/icons/clr-icons.min.css",
-              "src/styles/styles.scss",
-              "../../node_modules/trix/dist/trix.css"
-            ],
-            "scripts": [
-              "../../node_modules/trix/dist/trix-core.js"
+              "src/styles/styles.scss"
             ],
             ],
             "stylePreprocessorOptions": {
             "stylePreprocessorOptions": {
               "includePaths": [
               "includePaths": [
@@ -98,11 +94,7 @@
               "buildOptimizer": true,
               "buildOptimizer": true,
               "styles": [
               "styles": [
                 "../../@clr/icons/clr-icons.min.css",
                 "../../@clr/icons/clr-icons.min.css",
-                "src/styles/styles.scss",
-                "../../trix/dist/trix.css"
-              ],
-              "scripts": [
-                "../../trix/dist/trix-core.js"
+                "src/styles/styles.scss"
               ]
               ]
             },
             },
             "plugin-watch": {
             "plugin-watch": {
@@ -115,11 +107,7 @@
               "aot": false,
               "aot": false,
               "styles": [
               "styles": [
                 "../../@clr/icons/clr-icons.min.css",
                 "../../@clr/icons/clr-icons.min.css",
-                "src/styles/styles.scss",
-                "../../trix/dist/trix.css"
-              ],
-              "scripts": [
-                "../../trix/dist/trix-core.js"
+                "src/styles/styles.scss"
               ]
               ]
             },
             },
             "plugin-dev": {
             "plugin-dev": {
@@ -132,11 +120,7 @@
               "aot": false,
               "aot": false,
               "styles": [
               "styles": [
                 "../../node_modules/@clr/icons/clr-icons.min.css",
                 "../../node_modules/@clr/icons/clr-icons.min.css",
-                "src/styles/styles.scss",
-                "../../node_modules/trix/dist/trix.css"
-              ],
-              "scripts": [
-                "../../node_modules/trix/dist/trix-core.js"
+                "src/styles/styles.scss"
               ]
               ]
             }
             }
           }
           }
@@ -239,4 +223,4 @@
     "packageManager": "yarn",
     "packageManager": "yarn",
     "analytics": "61fa89f7-706a-46c0-bcdb-b1d3664195ce"
     "analytics": "61fa89f7-706a-46c0-bcdb-b1d3664195ce"
   }
   }
-}
+}

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

@@ -55,8 +55,17 @@
     "messageformat": "2.2.0",
     "messageformat": "2.2.0",
     "ngx-pagination": "^5.0.0",
     "ngx-pagination": "^5.0.0",
     "ngx-translate-messageformat-compiler": "^4.5.0",
     "ngx-translate-messageformat-compiler": "^4.5.0",
+    "prosemirror-commands": "^1.0.0",
+    "prosemirror-dropcursor": "^1.0.0",
+    "prosemirror-gapcursor": "^1.0.0",
+    "prosemirror-history": "^1.0.0",
+    "prosemirror-inputrules": "^1.0.0",
+    "prosemirror-keymap": "^1.0.0",
+    "prosemirror-menu": "^1.0.0",
+    "prosemirror-schema-basic": "^1.1.2",
+    "prosemirror-schema-list": "^1.0.0",
+    "prosemirror-state": "^1.0.0",
     "rxjs": "^6.5.4",
     "rxjs": "^6.5.4",
-    "trix": "^1.2.2",
     "tslib": "^1.10.0",
     "tslib": "^1.10.0",
     "typescript": "~3.7.5",
     "typescript": "~3.7.5",
     "zone.js": "~0.10.2"
     "zone.js": "~0.10.2"
@@ -67,6 +76,10 @@
     "@types/jasmine": "~3.3.16",
     "@types/jasmine": "~3.3.16",
     "@types/jasminewd2": "~2.0.6",
     "@types/jasminewd2": "~2.0.6",
     "@types/node": "^12.11.1",
     "@types/node": "^12.11.1",
+    "@types/prosemirror-commands": "^1.0.1",
+    "@types/prosemirror-menu": "^1.0.1",
+    "@types/prosemirror-state": "^1.2.3",
+    "@types/prosemirror-view": "^1.11.2",
     "codelyzer": "^5.1.2",
     "codelyzer": "^5.1.2",
     "jasmine-core": "~3.4.0",
     "jasmine-core": "~3.4.0",
     "jasmine-spec-reporter": "~4.2.1",
     "jasmine-spec-reporter": "~4.2.1",

+ 33 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html

@@ -0,0 +1,33 @@
+<div class="flex">
+    <form [formGroup]="form" class="flex-spacer" clrForm clrLayout="vertical">
+        <clr-input-container class="expand">
+            <label>{{ 'editor.image-src' | translate }}</label>
+            <input clrInput type="text" formControlName="src" />
+        </clr-input-container>
+        <clr-input-container class="expand">
+            <label>{{ 'editor.image-title' | translate }}</label>
+            <input clrInput type="text" formControlName="title" />
+        </clr-input-container>
+        <clr-input-container class="expand">
+            <label>{{ 'editor.image-alt' | translate }}</label>
+            <input clrInput type="text" formControlName="alt" />
+        </clr-input-container>
+    </form>
+    <div class="preview">
+        <img
+            [src]="form.get('src')?.value"
+            [class.visible]="previewLoaded"
+            (load)="onImageLoad($event)"
+            (error)="onImageError($event)"
+        />
+        <div class="placeholder" *ngIf="!previewLoaded">
+            <clr-icon shape="image" size="128"></clr-icon>
+        </div>
+    </div>
+</div>
+
+<ng-template vdrDialogButtons>
+    <button type="submit" (click)="select()" class="btn btn-primary" [disabled]="form.invalid || !previewLoaded">
+        {{ 'editor.insert-image' | translate }}
+    </button>
+</ng-template>

+ 19 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.scss

@@ -0,0 +1,19 @@
+@import "variables";
+
+.preview {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    max-width: 150px;
+    margin-left: 12px;
+    img {
+        max-width: 100%;
+        display: none;
+        &.visible {
+            display: block;
+        }
+    }
+    .placeholder {
+        color: $color-grey-300;
+    }
+}

+ 44 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.ts

@@ -0,0 +1,44 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+
+import { Dialog } from '../../../providers/modal/modal.service';
+
+export interface ExternalImageAttrs {
+    src: string;
+    title: string;
+    alt: string;
+}
+
+@Component({
+    selector: 'vdr-external-image-dialog',
+    templateUrl: './external-image-dialog.component.html',
+    styleUrls: ['./external-image-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ExternalImageDialogComponent implements OnInit, Dialog<ExternalImageAttrs> {
+    form: FormGroup;
+
+    resolveWith: (result?: ExternalImageAttrs) => void;
+    previewLoaded = false;
+    existing?: ExternalImageAttrs;
+
+    ngOnInit(): void {
+        this.form = new FormGroup({
+            src: new FormControl(this.existing ? this.existing.src : '', Validators.required),
+            title: new FormControl(this.existing ? this.existing.title : ''),
+            alt: new FormControl(this.existing ? this.existing.alt : ''),
+        });
+    }
+
+    select() {
+        this.resolveWith(this.form.value);
+    }
+
+    onImageLoad(event: Event) {
+        this.previewLoaded = true;
+    }
+
+    onImageError(event: Event) {
+        this.previewLoaded = false;
+    }
+}

+ 16 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/link-dialog/link-dialog.component.html

@@ -0,0 +1,16 @@
+<form [formGroup]="form">
+    <vdr-form-field [label]="'editor.link-href' | translate" for="href">
+        <input id="href" type="text" formControlName="href" />
+    </vdr-form-field>
+    <vdr-form-field [label]="'editor.link-title' | translate" for="title">
+        <input id="title" type="text" formControlName="title" />
+    </vdr-form-field>
+</form>
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn btn-secondary" (click)="remove()" *ngIf="existing">
+        <clr-icon shape="unlink"></clr-icon> {{ 'editor.remove-link' | translate }}
+    </button>
+    <button type="submit" (click)="select()" class="btn btn-primary" [disabled]="form.invalid">
+        {{ 'editor.set-link' | translate }}
+    </button>
+</ng-template>

+ 0 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/link-dialog/link-dialog.component.scss


+ 40 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/link-dialog/link-dialog.component.ts

@@ -0,0 +1,40 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+
+import { Dialog } from '../../../providers/modal/modal.service';
+
+export interface LinkAttrs {
+    href: string;
+    title: string;
+}
+
+@Component({
+    selector: 'vdr-link-dialog',
+    templateUrl: './link-dialog.component.html',
+    styleUrls: ['./link-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LinkDialogComponent implements OnInit, Dialog<LinkAttrs> {
+    form: FormGroup;
+
+    resolveWith: (result?: LinkAttrs) => void;
+    existing?: LinkAttrs;
+
+    ngOnInit(): void {
+        this.form = new FormGroup({
+            href: new FormControl(this.existing ? this.existing.href : '', Validators.required),
+            title: new FormControl(this.existing ? this.existing.title : ''),
+        });
+    }
+
+    remove() {
+        this.resolveWith({
+            title: '',
+            href: '',
+        });
+    }
+
+    select() {
+        this.resolveWith(this.form.value);
+    }
+}

+ 80 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/inputrules.ts

@@ -0,0 +1,80 @@
+import {
+    ellipsis,
+    emDash,
+    inputRules,
+    smartQuotes,
+    textblockTypeInputRule,
+    wrappingInputRule,
+} from 'prosemirror-inputrules';
+import { NodeType, Schema } from 'prosemirror-model';
+import { Plugin } from 'prosemirror-state';
+
+// : (NodeType) → InputRule
+// Given a blockquote node type, returns an input rule that turns `"> "`
+// at the start of a textblock into a blockquote.
+export function blockQuoteRule(nodeType) {
+    return wrappingInputRule(/^\s*>\s$/, nodeType);
+}
+
+// : (NodeType) → InputRule
+// Given a list node type, returns an input rule that turns a number
+// followed by a dot at the start of a textblock into an ordered list.
+export function orderedListRule(nodeType) {
+    return wrappingInputRule(
+        /^(\d+)\.\s$/,
+        nodeType,
+        match => ({ order: +match[1] }),
+        (match, node) => node.childCount + node.attrs.order === +match[1],
+    );
+}
+
+// : (NodeType) → InputRule
+// Given a list node type, returns an input rule that turns a bullet
+// (dash, plush, or asterisk) at the start of a textblock into a
+// bullet list.
+export function bulletListRule(nodeType) {
+    return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
+}
+
+// : (NodeType) → InputRule
+// Given a code block node type, returns an input rule that turns a
+// textblock starting with three backticks into a code block.
+export function codeBlockRule(nodeType) {
+    return textblockTypeInputRule(/^```$/, nodeType);
+}
+
+// : (NodeType, number) → InputRule
+// Given a node type and a maximum level, creates an input rule that
+// turns up to that number of `#` characters followed by a space at
+// the start of a textblock into a heading whose level corresponds to
+// the number of `#` signs.
+export function headingRule(nodeType, maxLevel) {
+    return textblockTypeInputRule(new RegExp('^(#{1,' + maxLevel + '})\\s$'), nodeType, match => ({
+        level: match[1].length,
+    }));
+}
+
+// : (Schema) → Plugin
+// A set of input rules for creating the basic block quotes, lists,
+// code blocks, and heading.
+export function buildInputRules(schema: Schema): Plugin {
+    const rules = smartQuotes.concat(ellipsis, emDash);
+    let type: NodeType;
+    // tslint:disable:no-conditional-assignment
+    if ((type = schema.nodes.blockquote)) {
+        rules.push(blockQuoteRule(type));
+    }
+    if ((type = schema.nodes.ordered_list)) {
+        rules.push(orderedListRule(type));
+    }
+    if ((type = schema.nodes.bullet_list)) {
+        rules.push(bulletListRule(type));
+    }
+    if ((type = schema.nodes.code_block)) {
+        rules.push(codeBlockRule(type));
+    }
+    if ((type = schema.nodes.heading)) {
+        rules.push(headingRule(type, 6));
+    }
+    return inputRules({ rules });
+}

+ 137 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/keymap.ts

@@ -0,0 +1,137 @@
+import {
+    chainCommands,
+    exitCode,
+    joinDown,
+    joinUp,
+    lift,
+    selectParentNode,
+    setBlockType,
+    toggleMark,
+    wrapIn,
+} from 'prosemirror-commands';
+import { redo, undo } from 'prosemirror-history';
+import { undoInputRule } from 'prosemirror-inputrules';
+import { MarkType, NodeType, Schema } from 'prosemirror-model';
+import { liftListItem, sinkListItem, splitListItem, wrapInList } from 'prosemirror-schema-list';
+
+import { Keymap } from './types';
+
+const mac = typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false;
+
+// :: (Schema, ?Object) → Object
+// Inspect the given schema looking for marks and nodes from the
+// basic schema, and if found, add key bindings related to them.
+// This will add:
+//
+// * **Mod-b** for toggling [strong](#schema-basic.StrongMark)
+// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark)
+// * **Mod-`** for toggling [code font](#schema-basic.CodeMark)
+// * **Ctrl-Shift-0** for making the current textblock a paragraph
+// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current
+//   textblock a heading of the corresponding level
+// * **Ctrl-Shift-Backslash** to make the current textblock a code block
+// * **Ctrl-Shift-8** to wrap the selection in an ordered list
+// * **Ctrl-Shift-9** to wrap the selection in a bullet list
+// * **Ctrl->** to wrap the selection in a block quote
+// * **Enter** to split a non-empty textblock in a list item while at
+//   the same time splitting the list item
+// * **Mod-Enter** to insert a hard break
+// * **Mod-_** to insert a horizontal rule
+// * **Backspace** to undo an input rule
+// * **Alt-ArrowUp** to `joinUp`
+// * **Alt-ArrowDown** to `joinDown`
+// * **Mod-BracketLeft** to `lift`
+// * **Escape** to `selectParentNode`
+//
+// You can suppress or map these bindings by passing a `mapKeys`
+// argument, which maps key names (say `"Mod-B"` to either `false`, to
+// remove the binding, or a new key name string.
+export function buildKeymap(schema: Schema, mapKeys?: Keymap) {
+    const keys = {};
+    let type: MarkType | NodeType;
+    function bind(key: string, cmd: (...args: any[]) => boolean) {
+        if (mapKeys) {
+            const mapped = mapKeys[key];
+            if (mapped === false) {
+                return;
+            }
+            if (mapped) {
+                key = mapped;
+            }
+        }
+        keys[key] = cmd;
+    }
+
+    bind('Mod-z', undo);
+    bind('Shift-Mod-z', redo);
+    bind('Backspace', undoInputRule);
+    if (!mac) {
+        bind('Mod-y', redo);
+    }
+
+    bind('Alt-ArrowUp', joinUp);
+    bind('Alt-ArrowDown', joinDown);
+    bind('Mod-BracketLeft', lift);
+    bind('Escape', selectParentNode);
+
+    // tslint:disable:no-conditional-assignment
+    if ((type = schema.marks.strong)) {
+        bind('Mod-b', toggleMark(type));
+        bind('Mod-B', toggleMark(type));
+    }
+    if ((type = schema.marks.em)) {
+        bind('Mod-i', toggleMark(type));
+        bind('Mod-I', toggleMark(type));
+    }
+    if ((type = schema.marks.code)) {
+        bind('Mod-`', toggleMark(type));
+    }
+
+    if ((type = schema.nodes.bullet_list)) {
+        bind('Shift-Ctrl-8', wrapInList(type));
+    }
+    if ((type = schema.nodes.ordered_list)) {
+        bind('Shift-Ctrl-9', wrapInList(type));
+    }
+    if ((type = schema.nodes.blockquote)) {
+        bind('Ctrl->', wrapIn(type));
+    }
+    if ((type = schema.nodes.hard_break)) {
+        const br = type;
+        const cmd = chainCommands(exitCode, (state, dispatch) => {
+            // tslint:disable-next-line:no-non-null-assertion
+            dispatch!(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
+            return true;
+        });
+        bind('Mod-Enter', cmd);
+        bind('Shift-Enter', cmd);
+        if (mac) {
+            bind('Ctrl-Enter', cmd);
+        }
+    }
+    if ((type = schema.nodes.list_item)) {
+        bind('Enter', splitListItem(type));
+        bind('Mod-[', liftListItem(type));
+        bind('Mod-]', sinkListItem(type));
+    }
+    if ((type = schema.nodes.paragraph)) {
+        bind('Shift-Ctrl-0', setBlockType(type));
+    }
+    if ((type = schema.nodes.code_block)) {
+        bind('Shift-Ctrl-\\', setBlockType(type));
+    }
+    if ((type = schema.nodes.heading)) {
+        for (let i = 1; i <= 6; i++) {
+            bind('Shift-Ctrl-' + i, setBlockType(type, { level: i }));
+        }
+    }
+    if ((type = schema.nodes.horizontal_rule)) {
+        const hr = type;
+        bind('Mod-_', (state, dispatch) => {
+            dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
+            return true;
+        });
+    }
+
+    return keys;
+}

+ 45 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/images.ts

@@ -0,0 +1,45 @@
+import { MenuItem } from 'prosemirror-menu';
+import { NodeType } from 'prosemirror-model';
+import { EditorState, NodeSelection } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
+
+import { ModalService } from '../../../../providers/modal/modal.service';
+import {
+    ExternalImageAttrs,
+    ExternalImageDialogComponent,
+} from '../../external-image-dialog/external-image-dialog.component';
+
+import { canInsert } from './menu-common';
+
+export function insertImageItem(nodeType: NodeType, modalService: ModalService) {
+    return new MenuItem({
+        title: 'Insert image',
+        label: 'Image',
+        class: '',
+        css: '',
+        execEvent: 'mousedown',
+        enable(state: EditorState) {
+            return canInsert(state, nodeType);
+        },
+        run(state: EditorState, _, view: EditorView) {
+            let attrs: ExternalImageAttrs | undefined;
+            if (state.selection instanceof NodeSelection && state.selection.node.type === nodeType) {
+                attrs = state.selection.node.attrs as ExternalImageAttrs;
+            }
+            modalService
+                .fromComponent(ExternalImageDialogComponent, {
+                    closable: true,
+                    locals: {
+                        existing: attrs,
+                    },
+                })
+                .subscribe(result => {
+                    if (result) {
+                        // tslint:disable-next-line:no-non-null-assertion
+                        view.dispatch(view.state.tr.replaceSelectionWith(nodeType.createAndFill(result)!));
+                    }
+                    view.focus();
+                });
+        },
+    });
+}

+ 78 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/links.ts

@@ -0,0 +1,78 @@
+import { toggleMark } from 'prosemirror-commands';
+import { icons, MenuItem } from 'prosemirror-menu';
+import { MarkType } from 'prosemirror-model';
+import { EditorState, TextSelection } from 'prosemirror-state';
+
+import { ModalService } from '../../../../providers/modal/modal.service';
+import { LinkAttrs, LinkDialogComponent } from '../../link-dialog/link-dialog.component';
+
+import { markActive } from './menu-common';
+
+function selectionIsWithinLink(state: EditorState, anchor: number, head: number): boolean {
+    const { doc } = state;
+    const headLink = doc
+        .resolve(head)
+        .marks()
+        .find(m => m.type.name === 'link');
+    const anchorLink = doc
+        .resolve(anchor)
+        .marks()
+        .find(m => m.type.name === 'link');
+    if (headLink && anchorLink && headLink.eq(anchorLink)) {
+        return true;
+    }
+    return false;
+}
+
+export function linkItem(linkMark: MarkType, modalService: ModalService) {
+    return new MenuItem({
+        title: 'Add or remove link',
+        icon: icons.link,
+        class: '',
+        css: '',
+        execEvent: 'mousedown',
+        active(state) {
+            return markActive(state, linkMark);
+        },
+        enable(state) {
+            const { selection } = state;
+            return !selection.empty || selectionIsWithinLink(state, selection.anchor, selection.head);
+        },
+        run(state: EditorState, dispatch, view) {
+            let attrs: LinkAttrs | undefined;
+            const { selection, doc } = state;
+            if (
+                selection instanceof TextSelection &&
+                selectionIsWithinLink(state, selection.anchor + 1, selection.head - 1)
+            ) {
+                const mark = doc
+                    .resolve(selection.anchor + 1)
+                    .marks()
+                    .find(m => m.type.name === 'link');
+                if (mark) {
+                    attrs = mark.attrs as LinkAttrs;
+                }
+            }
+            modalService
+                .fromComponent(LinkDialogComponent, {
+                    closable: true,
+                    locals: {
+                        existing: attrs,
+                    },
+                })
+                .subscribe(result => {
+                    let tr = state.tr;
+                    if (result) {
+                        const { $from, $to } = selection.ranges[0];
+                        tr = tr.removeMark($from.pos, $to.pos, linkMark);
+                        if (result.href !== '') {
+                            tr = tr.addMark($from.pos, $to.pos, linkMark.create(result));
+                        }
+                    }
+                    dispatch(tr.scrollIntoView());
+                    view.focus();
+                });
+            return true;
+        },
+    });
+}

+ 22 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/menu-common.ts

@@ -0,0 +1,22 @@
+import { NodeType } from 'prosemirror-model';
+import { EditorState } from 'prosemirror-state';
+
+export function markActive(state, type) {
+    const { from, $from, to, empty } = state.selection;
+    if (empty) {
+        return type.isInSet(state.storedMarks || $from.marks());
+    } else {
+        return state.doc.rangeHasMark(from, to, type);
+    }
+}
+
+export function canInsert(state: EditorState, nodeType: NodeType): boolean {
+    const $from = state.selection.$from;
+    for (let d = $from.depth; d >= 0; d--) {
+        const index = $from.index(d);
+        if ($from.node(d).canReplaceWith(index, index, nodeType)) {
+            return true;
+        }
+    }
+    return false;
+}

+ 225 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/menu/menu.ts

@@ -0,0 +1,225 @@
+import { toggleMark } from 'prosemirror-commands';
+import {
+    blockTypeItem,
+    Dropdown,
+    DropdownSubmenu,
+    icons,
+    joinUpItem,
+    liftItem,
+    MenuItem,
+    redoItem,
+    selectParentNodeItem,
+    undoItem,
+    wrapItem,
+} from 'prosemirror-menu';
+import { MarkType, NodeType, Schema } from 'prosemirror-model';
+import { wrapInList } from 'prosemirror-schema-list';
+import { EditorState } from 'prosemirror-state';
+
+import { ModalService } from '../../../../providers/modal/modal.service';
+
+import { insertImageItem } from './images';
+import { linkItem } from './links';
+import { canInsert, markActive } from './menu-common';
+
+// Helpers to create specific types of items
+
+function cmdItem(cmd: (...args: any[]) => void, options: Record<string, any>) {
+    const passedOptions = {
+        label: options.title,
+        run: cmd,
+    };
+    // tslint:disable-next-line:forin
+    for (const prop in options) {
+        passedOptions[prop] = options[prop];
+    }
+    if ((!options.enable || options.enable === true) && !options.select) {
+        passedOptions[options.enable ? 'enable' : 'select'] = state => cmd(state);
+    }
+
+    return new MenuItem(passedOptions as any);
+}
+
+function markItem(markType, options) {
+    const passedOptions = {
+        active(state) {
+            return markActive(state, markType);
+        },
+        enable: true,
+    };
+    // tslint:disable-next-line:forin
+    for (const prop in options) {
+        passedOptions[prop] = options[prop];
+    }
+    return cmdItem(toggleMark(markType), passedOptions);
+}
+
+function wrapListItem(nodeType, options) {
+    return cmdItem(wrapInList(nodeType, options.attrs), options);
+}
+
+// :: (Schema) → Object
+// Given a schema, look for default mark and node types in it and
+// return an object with relevant menu items relating to those marks:
+//
+// **`toggleStrong`**`: MenuItem`
+//   : A menu item to toggle the [strong mark](#schema-basic.StrongMark).
+//
+// **`toggleEm`**`: MenuItem`
+//   : A menu item to toggle the [emphasis mark](#schema-basic.EmMark).
+//
+// **`toggleCode`**`: MenuItem`
+//   : A menu item to toggle the [code font mark](#schema-basic.CodeMark).
+//
+// **`toggleLink`**`: MenuItem`
+//   : A menu item to toggle the [link mark](#schema-basic.LinkMark).
+//
+// **`insertImage`**`: MenuItem`
+//   : A menu item to insert an [image](#schema-basic.Image).
+//
+// **`wrapBulletList`**`: MenuItem`
+//   : A menu item to wrap the selection in a [bullet list](#schema-list.BulletList).
+//
+// **`wrapOrderedList`**`: MenuItem`
+//   : A menu item to wrap the selection in an [ordered list](#schema-list.OrderedList).
+//
+// **`wrapBlockQuote`**`: MenuItem`
+//   : A menu item to wrap the selection in a [block quote](#schema-basic.BlockQuote).
+//
+// **`makeParagraph`**`: MenuItem`
+//   : A menu item to set the current textblock to be a normal
+//     [paragraph](#schema-basic.Paragraph).
+//
+// **`makeCodeBlock`**`: MenuItem`
+//   : A menu item to set the current textblock to be a
+//     [code block](#schema-basic.CodeBlock).
+//
+// **`makeHead[N]`**`: MenuItem`
+//   : Where _N_ is 1 to 6. Menu items to set the current textblock to
+//     be a [heading](#schema-basic.Heading) of level _N_.
+//
+// **`insertHorizontalRule`**`: MenuItem`
+//   : A menu item to insert a horizontal rule.
+//
+// The return value also contains some prefabricated menu elements and
+// menus, that you can use instead of composing your own menu from
+// scratch:
+//
+// **`insertMenu`**`: Dropdown`
+//   : A dropdown containing the `insertImage` and
+//     `insertHorizontalRule` items.
+//
+// **`typeMenu`**`: Dropdown`
+//   : A dropdown containing the items for making the current
+//     textblock a paragraph, code block, or heading.
+//
+// **`fullMenu`**`: [[MenuElement]]`
+//   : An array of arrays of menu elements for use as the full menu
+//     for, for example the [menu bar](https://github.com/prosemirror/prosemirror-menu#user-content-menubar).
+export function buildMenuItems(schema: Schema, modalService: ModalService) {
+    const r: Record<string, any> = {};
+    let type: MarkType | NodeType;
+    // tslint:disable:no-conditional-assignment
+    if ((type = schema.marks.strong)) {
+        r.toggleStrong = markItem(type, { title: 'Toggle strong style', icon: icons.strong });
+    }
+    if ((type = schema.marks.em)) {
+        r.toggleEm = markItem(type, { title: 'Toggle emphasis', icon: icons.em });
+    }
+    if ((type = schema.marks.code)) {
+        r.toggleCode = markItem(type, { title: 'Toggle code font', icon: icons.code });
+    }
+    if ((type = schema.marks.link)) {
+        r.toggleLink = linkItem(type, modalService);
+    }
+
+    if ((type = schema.nodes.image)) {
+        r.insertImage = insertImageItem(type, modalService);
+    }
+    if ((type = schema.nodes.bullet_list)) {
+        r.wrapBulletList = wrapListItem(type, {
+            title: 'Wrap in bullet list',
+            icon: icons.bulletList,
+        });
+    }
+    if ((type = schema.nodes.ordered_list)) {
+        r.wrapOrderedList = wrapListItem(type, {
+            title: 'Wrap in ordered list',
+            icon: icons.orderedList,
+        });
+    }
+    if ((type = schema.nodes.blockquote)) {
+        r.wrapBlockQuote = wrapItem(type, {
+            title: 'Wrap in block quote',
+            icon: icons.blockquote,
+        });
+    }
+    if ((type = schema.nodes.paragraph)) {
+        r.makeParagraph = blockTypeItem(type, {
+            title: 'Change to paragraph',
+            label: 'Plain',
+        });
+    }
+    if ((type = schema.nodes.code_block)) {
+        r.makeCodeBlock = blockTypeItem(type, {
+            title: 'Change to code block',
+            label: 'Code',
+        });
+    }
+    if ((type = schema.nodes.heading)) {
+        for (let i = 1; i <= 10; i++) {
+            r['makeHead' + i] = blockTypeItem(type, {
+                title: 'Change to heading ' + i,
+                label: 'Level ' + i,
+                attrs: { level: i },
+            });
+        }
+    }
+    if ((type = schema.nodes.horizontal_rule)) {
+        const hr = type;
+        r.insertHorizontalRule = new MenuItem({
+            title: 'Insert horizontal rule',
+            label: 'Horizontal rule',
+            class: '',
+            css: '',
+            execEvent: 'mousedown',
+            enable(state) {
+                return canInsert(state, hr);
+            },
+            run(state: EditorState, dispatch) {
+                dispatch(state.tr.replaceSelectionWith(hr.create()));
+            },
+        });
+    }
+
+    const cut = <T>(arr: T[]): T[] => arr.filter(x => x);
+    r.insertMenu = new Dropdown(cut([r.insertImage, r.insertHorizontalRule]), { label: 'Insert' });
+    r.typeMenu = new Dropdown(
+        cut([
+            r.makeParagraph,
+            r.makeCodeBlock,
+            r.makeHead1 &&
+                new DropdownSubmenu(
+                    cut([r.makeHead1, r.makeHead2, r.makeHead3, r.makeHead4, r.makeHead5, r.makeHead6]),
+                    { label: 'Heading' },
+                ),
+        ]),
+        { label: 'Type...' },
+    );
+
+    const inlineMenu = cut([r.toggleStrong, r.toggleEm, r.toggleLink]);
+    r.inlineMenu = [inlineMenu];
+    r.blockMenu = [
+        cut([
+            r.wrapBulletList,
+            r.wrapOrderedList,
+            r.wrapBlockQuote,
+            joinUpItem,
+            liftItem,
+            selectParentNodeItem,
+        ]),
+    ];
+    r.fullMenu = [inlineMenu].concat([[r.insertMenu, r.typeMenu]], [[undoItem, redoItem]], r.blockMenu);
+
+    return r;
+}

+ 25 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/plugins/link-select-plugin.ts

@@ -0,0 +1,25 @@
+import { Plugin, TextSelection } from 'prosemirror-state';
+
+import { getMarkRange } from '../utils';
+
+/**
+ * Causes the entire link to be selected when clicked.
+ */
+export const linkSelectPlugin = new Plugin({
+    props: {
+        handleClick(view, pos) {
+            const { doc, tr, schema } = view.state;
+            const range = getMarkRange(doc.resolve(pos), schema.marks.link);
+            if (!range) {
+                return false;
+            }
+
+            const $start = doc.resolve(range.from);
+            const $end = doc.resolve(range.to);
+            const transaction = tr.setSelection(new TextSelection($start, $end));
+
+            view.dispatch(transaction);
+            return true;
+        },
+    },
+});

+ 338 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/prosemirror.scss

@@ -0,0 +1,338 @@
+::ng-deep {
+    .ProseMirror {
+        position: relative;
+    }
+
+    .ProseMirror {
+        word-wrap: break-word;
+        white-space: pre-wrap;
+        -webkit-font-variant-ligatures: none;
+        font-variant-ligatures: none;
+    }
+
+    .ProseMirror pre {
+        white-space: pre-wrap;
+    }
+
+    .ProseMirror li {
+        position: relative;
+    }
+
+    .ProseMirror-hideselection *::selection {
+        background: transparent;
+    }
+
+    .ProseMirror-hideselection *::-moz-selection {
+        background: transparent;
+    }
+
+    .ProseMirror-hideselection {
+        caret-color: transparent;
+    }
+
+    .ProseMirror-selectednode {
+        outline: 2px solid #8cf;
+    }
+
+    /* Make sure li selections wrap around markers */
+
+    li.ProseMirror-selectednode {
+        outline: none;
+    }
+
+    li.ProseMirror-selectednode:after {
+        content: '';
+        position: absolute;
+        left: -32px;
+        right: -2px;
+        top: -2px;
+        bottom: -2px;
+        border: 2px solid #8cf;
+        pointer-events: none;
+    }
+
+    .ProseMirror-textblock-dropdown {
+        min-width: 3em;
+    }
+
+    .ProseMirror-menu {
+        margin: 0 -4px;
+        line-height: 1;
+    }
+
+    .ProseMirror-tooltip .ProseMirror-menu {
+        width: -webkit-fit-content;
+        width: fit-content;
+        white-space: pre;
+    }
+
+    .ProseMirror-menuitem {
+        margin-right: 3px;
+        display: inline-block;
+    }
+
+    .ProseMirror-menuseparator {
+        border-right: 1px solid #ddd;
+        margin-right: 3px;
+    }
+
+    .ProseMirror-menu-dropdown,
+    .ProseMirror-menu-dropdown-menu {
+        font-size: 90%;
+        white-space: nowrap;
+    }
+
+    .ProseMirror-menu-dropdown {
+        vertical-align: 1px;
+        cursor: pointer;
+        position: relative;
+        padding-right: 15px;
+    }
+
+    .ProseMirror-menu-dropdown-wrap {
+        padding: 1px 0 1px 4px;
+        display: inline-block;
+        position: relative;
+    }
+
+    .ProseMirror-menu-dropdown: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);
+    }
+
+    .ProseMirror-menu-dropdown-menu,
+    .ProseMirror-menu-submenu {
+        position: absolute;
+        background: white;
+        color: #666;
+        border: 1px solid #aaa;
+        padding: 2px;
+    }
+
+    .ProseMirror-menu-dropdown-menu {
+        z-index: 15;
+        min-width: 6em;
+    }
+
+    .ProseMirror-menu-dropdown-item {
+        cursor: pointer;
+        padding: 2px 8px 2px 4px;
+    }
+
+    .ProseMirror-menu-dropdown-item:hover {
+        background: #f2f2f2;
+    }
+
+    .ProseMirror-menu-submenu-wrap {
+        position: relative;
+        margin-right: -4px;
+    }
+
+    .ProseMirror-menu-submenu-label:after {
+        content: '';
+        border-top: 4px solid transparent;
+        border-bottom: 4px solid transparent;
+        border-left: 4px solid currentColor;
+        opacity: 0.6;
+        position: absolute;
+        right: 4px;
+        top: calc(50% - 4px);
+    }
+
+    .ProseMirror-menu-submenu {
+        display: none;
+        min-width: 4em;
+        left: 100%;
+        top: -3px;
+    }
+
+    .ProseMirror-menu-active {
+        background: #eee;
+        border-radius: 4px;
+    }
+
+    .ProseMirror-menu-active {
+        background: #eee;
+        border-radius: 4px;
+    }
+
+    .ProseMirror-menu-disabled {
+        opacity: 0.3;
+    }
+
+    .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu,
+    .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
+        display: block;
+    }
+
+    .ProseMirror-menubar {
+        border-top-left-radius: inherit;
+        border-top-right-radius: inherit;
+        position: relative;
+        min-height: 1em;
+        color: #666;
+        padding: 1px 6px;
+        top: 0;
+        left: 0;
+        right: 0;
+        background: white;
+        z-index: 10;
+        -moz-box-sizing: border-box;
+        box-sizing: border-box;
+        overflow: visible;
+    }
+
+    .ProseMirror-icon {
+        display: inline-block;
+        line-height: 0.8;
+        vertical-align: -2px; /* Compensate for padding */
+        padding: 2px 8px;
+        cursor: pointer;
+    }
+
+    .ProseMirror-menu-disabled.ProseMirror-icon {
+        cursor: default;
+    }
+
+    .ProseMirror-icon svg {
+        fill: currentColor;
+        height: 1em;
+    }
+
+    .ProseMirror-icon span {
+        vertical-align: text-top;
+    }
+
+    .ProseMirror-gapcursor {
+        display: none;
+        pointer-events: none;
+        position: absolute;
+    }
+
+    .ProseMirror-gapcursor:after {
+        content: '';
+        display: block;
+        position: absolute;
+        top: -2px;
+        width: 20px;
+        border-top: 1px solid black;
+        animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
+    }
+
+    @keyframes ProseMirror-cursor-blink {
+        to {
+            visibility: hidden;
+        }
+    }
+
+    .ProseMirror-focused .ProseMirror-gapcursor {
+        display: block;
+    }
+
+    .ProseMirror ul,
+    .ProseMirror ol {
+        padding-left: 30px;
+        list-style-position: initial;
+    }
+
+    .ProseMirror blockquote {
+        padding-left: 1em;
+        border-left: 3px solid #eee;
+        margin-left: 0;
+        margin-right: 0;
+    }
+
+    .ProseMirror-prompt {
+        background: white;
+        padding: 5px 10px 5px 15px;
+        border: 1px solid silver;
+        position: fixed;
+        border-radius: 3px;
+        z-index: 11;
+        box-shadow: -0.5px 2px 5px rgba(0, 0, 0, 0.2);
+    }
+
+    .ProseMirror-prompt h5 {
+        margin: 0;
+        font-weight: normal;
+        font-size: 100%;
+        color: #444;
+    }
+
+    .ProseMirror-prompt input[type='text'],
+    .ProseMirror-prompt textarea {
+        background: #eee;
+        border: none;
+        outline: none;
+    }
+
+    .ProseMirror-prompt input[type='text'] {
+        padding: 0 4px;
+    }
+
+    .ProseMirror-prompt-close {
+        position: absolute;
+        left: 2px;
+        top: 1px;
+        color: #666;
+        border: none;
+        background: transparent;
+        padding: 0;
+    }
+
+    .ProseMirror-prompt-close:after {
+        content: '✕';
+        font-size: 12px;
+    }
+
+    .ProseMirror-invalid {
+        background: #ffc;
+        border: 1px solid #cc7;
+        border-radius: 4px;
+        padding: 5px 10px;
+        position: absolute;
+        min-width: 10em;
+    }
+
+    .ProseMirror-prompt-buttons {
+        margin-top: 5px;
+        display: none;
+    }
+
+    #editor,
+    .editor {
+        background: white;
+        color: black;
+        background-clip: padding-box;
+        border-radius: 4px;
+        border: 2px solid rgba(0, 0, 0, 0.2);
+        padding: 5px 0;
+        margin-bottom: 23px;
+    }
+
+    .ProseMirror p:first-child,
+    .ProseMirror h1:first-child,
+    .ProseMirror h2:first-child,
+    .ProseMirror h3:first-child,
+    .ProseMirror h4:first-child,
+    .ProseMirror h5:first-child,
+    .ProseMirror h6:first-child {
+        margin-top: 10px;
+    }
+
+    .ProseMirror {
+        padding: 4px 8px 4px 14px;
+        line-height: 1.2;
+        outline: none;
+    }
+
+    .ProseMirror p {
+        margin-bottom: 0.5rem;
+    }
+}

+ 126 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts

@@ -0,0 +1,126 @@
+import { Injectable } 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 { 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 { linkSelectPlugin } from './plugins/link-select-plugin';
+import { SetupOptions } from './types';
+
+export interface CreateEditorViewOptions {
+    onTextInput: (content: string) => void;
+    element: HTMLElement;
+    isEditable: () => boolean;
+}
+
+@Injectable()
+export class ProsemirrorService {
+    editorView: EditorView;
+
+    // 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'),
+        marks: schema.spec.marks,
+    });
+    private enabled = true;
+
+    constructor(private modalService: ModalService) {}
+
+    createEditorView(options: CreateEditorViewOptions) {
+        this.editorView = new EditorView<Schema>(options.element, {
+            state: this.getStateFromText(''),
+            dispatchTransaction: tr => {
+                if (!this.enabled) {
+                    return;
+                }
+                this.editorView.updateState(this.editorView.state.apply(tr));
+                if (tr.docChanged) {
+                    const content = this.getTextFromState(this.editorView.state);
+                    options.onTextInput(content);
+                }
+            },
+            editable: () => options.isEditable(),
+        });
+    }
+
+    update(text: string) {
+        if (this.editorView) {
+            const state = this.getStateFromText(text);
+            this.editorView.updateState(state);
+        }
+    }
+
+    destroy() {
+        if (this.editorView) {
+            this.editorView.destroy();
+        }
+    }
+
+    setEnabled(enabled: boolean) {
+        if (this.editorView) {
+            this.enabled = enabled;
+        }
+    }
+
+    private getStateFromText(text: string): EditorState {
+        const div = document.createElement('div');
+        div.innerHTML = text;
+        return EditorState.create({
+            doc: DOMParser.fromSchema(this.mySchema).parse(div),
+            plugins: this.configurePlugins({ schema: this.mySchema, floatingMenu: false }),
+        });
+    }
+
+    private getTextFromState(state: EditorState): string {
+        const div = document.createElement('div');
+        const fragment = DOMSerializer.fromSchema(this.mySchema).serializeFragment(state.doc.content);
+
+        div.appendChild(fragment);
+
+        return div.innerHTML;
+    }
+
+    private configurePlugins(options: SetupOptions) {
+        const plugins = [
+            buildInputRules(options.schema),
+            keymap(buildKeymap(options.schema, options.mapKeys)),
+            keymap(baseKeymap),
+            dropCursor(),
+            gapCursor(),
+            linkSelectPlugin,
+        ];
+        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());
+        }
+
+        return plugins.concat(
+            new Plugin({
+                props: {
+                    attributes: { class: 'vdr-prosemirror' },
+                },
+            }),
+        );
+    }
+}

+ 13 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/types.ts

@@ -0,0 +1,13 @@
+import { MenuItem } from 'prosemirror-menu';
+import { Schema } from 'prosemirror-model';
+
+export interface SetupOptions {
+    schema: Schema;
+    mapKeys?: Keymap;
+    menuBar?: boolean;
+    history?: boolean;
+    floatingMenu?: boolean;
+    menuContent?: MenuItem[][];
+}
+
+export type Keymap = Record<string, string | false>;

+ 36 - 0
packages/admin-ui/src/app/shared/components/rich-text-editor/prosemirror/utils.ts

@@ -0,0 +1,36 @@
+import { MarkType, ResolvedPos } from 'prosemirror-model';
+
+/**
+ * Retrieve the start and end position of a mark
+ * "Borrowed" from [tiptap](https://github.com/scrumpy/tiptap)
+ */
+export const getMarkRange = (
+    pmPosition: ResolvedPos | null = null,
+    type: MarkType | null | undefined = null,
+): { from: number; to: number } | false => {
+    if (!pmPosition || !type) {
+        return false;
+    }
+
+    const start = pmPosition.parent.childAfter(pmPosition.parentOffset);
+
+    if (!start.node) {
+        return false;
+    }
+
+    const mark = start.node.marks.find(({ type: markType }) => markType === type);
+    if (!mark) {
+        return false;
+    }
+
+    let startIndex = pmPosition.index();
+    let startPos = pmPosition.start() + start.offset;
+    while (startIndex > 0 && mark.isInSet(pmPosition.parent.child(startIndex - 1).marks)) {
+        startIndex -= 1;
+        startPos -= pmPosition.parent.child(startIndex).nodeSize;
+    }
+
+    const endPos = startPos + start.node.nodeSize;
+
+    return { from: startPos, to: endPos };
+};

+ 2 - 2
packages/admin-ui/src/app/shared/components/rich-text-editor/rich-text-editor.component.html

@@ -1,2 +1,2 @@
-<label (click)="trixEditor.focus()" class="clr-control-label">{{ label }}</label>
-<trix-editor #trixEditor [contentEditable]="!_readonly" [class.readonly]="_readonly"></trix-editor>
+<label class="clr-control-label">{{ label }}</label>
+<div #editor></div>

+ 39 - 17
packages/admin-ui/src/app/shared/components/rich-text-editor/rich-text-editor.component.scss

@@ -1,37 +1,59 @@
-@import "variables";
+@import 'variables';
+@import './prosemirror/prosemirror';
 
 
 :host {
 :host {
     display: block;
     display: block;
     max-width: 710px;
     max-width: 710px;
-    margin-bottom: .5rem;
+    margin-bottom: 0.5rem;
+
+    &.readonly {
+        ::ng-deep .ProseMirror-menubar {
+            display: none;
+        }
+    }
+}
+
+::ng-deep .ProseMirror-menubar {
+    position: sticky;
+    top: 24px;
+    margin-top: 6px;
+    border: 1px solid $color-grey-300;
+    border-bottom: none;
+    background-color: $color-grey-200;
+    padding: 6px 12px;
 }
 }
 
 
-trix-editor {
+::ng-deep .vdr-prosemirror {
     background-color: white;
     background-color: white;
     min-height: 128px;
     min-height: 128px;
     border: 1px solid $color-grey-300;
     border: 1px solid $color-grey-300;
+    border-radius: 0 0 3px 3px;
     transition: border-color 0.2s;
     transition: border-color 0.2s;
+    overflow: auto;
 
 
     &:focus {
     &:focus {
         border-color: $color-primary-500 !important;
         border-color: $color-primary-500 !important;
         box-shadow: 0 0 1px 1px $color-primary-100;
         box-shadow: 0 0 1px 1px $color-primary-100;
     }
     }
-}
 
 
-::ng-deep trix-toolbar {
-    background-color: $color-grey-200;
-    padding: 12px 12px 0;
-    position: sticky;
-    top: -24px;
-    z-index: 10;
+    /* Add space around the hr to make clicking it easier */
 
 
-    &.hidden {
-        display: none;
+    hr {
+        padding: 2px 10px;
+        border: none;
+        margin: 1em 0;
     }
     }
-}
 
 
-label {
-    color: #000;
-    font-size: .541667rem;
-    margin: 0 0 .5rem;
+    hr:after {
+        content: '';
+        display: block;
+        height: 1px;
+        background-color: silver;
+        line-height: 2px;
+    }
+
+    img {
+        cursor: default;
+        max-width: 100%;
+    }
 }
 }

+ 24 - 46
packages/admin-ui/src/app/shared/components/rich-text-editor/rich-text-editor.component.ts

@@ -4,16 +4,14 @@ import {
     ChangeDetectorRef,
     ChangeDetectorRef,
     Component,
     Component,
     ElementRef,
     ElementRef,
+    HostBinding,
     Input,
     Input,
     OnDestroy,
     OnDestroy,
     ViewChild,
     ViewChild,
 } from '@angular/core';
 } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 
 
-export interface Trix {
-    editor: any;
-    toolbarElement: any;
-}
+import { ProsemirrorService } from './prosemirror/prosemirror.service';
 
 
 /**
 /**
  * A rich text (HTML) editor based on Trix (https://github.com/basecamp/trix)
  * A rich text (HTML) editor based on Trix (https://github.com/basecamp/trix)
@@ -29,58 +27,42 @@ export interface Trix {
             useExisting: RichTextEditorComponent,
             useExisting: RichTextEditorComponent,
             multi: true,
             multi: true,
         },
         },
+        ProsemirrorService,
     ],
     ],
 })
 })
 export class RichTextEditorComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {
 export class RichTextEditorComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {
     @Input() label: string;
     @Input() label: string;
     @Input() set readonly(value: any) {
     @Input() set readonly(value: any) {
         this._readonly = !!value;
         this._readonly = !!value;
-        if (this._readonly) {
-            this.trix.toolbarElement.classList.add('hidden');
-        } else {
-            this.trix.toolbarElement.classList.remove('hidden');
-        }
+        this.prosemirrorService.setEnabled(!this._readonly);
     }
     }
+    @HostBinding('class.readonly')
     _readonly = false;
     _readonly = false;
 
 
-    id = Math.random()
-        .toString(36)
-        .substr(3);
     onChange: (val: any) => void;
     onChange: (val: any) => void;
     onTouch: () => void;
     onTouch: () => void;
-    disabled = false;
-    private initialized = false;
+    private value: string;
 
 
-    @ViewChild('trixEditor', { static: true }) private trixEditor: ElementRef;
+    @ViewChild('editor', { static: true }) private editorEl: ElementRef<HTMLDivElement>;
 
 
-    constructor(private changeDetector: ChangeDetectorRef) {}
-
-    get trix(): HTMLElement & Trix {
-        return this.trixEditor ? this.trixEditor.nativeElement : {};
-    }
-
-    onTrixChangeHandler = () => {
-        if (this.initialized) {
-            this.onChange(this.trix.innerHTML);
-            this.changeDetector.markForCheck();
-        }
-    };
-
-    onTrixFocusHandler = () => {
-        if (this.initialized) {
-            this.onTouch();
-            this.changeDetector.markForCheck();
-        }
-    };
+    constructor(private changeDetector: ChangeDetectorRef, private prosemirrorService: ProsemirrorService) {}
 
 
     ngAfterViewInit() {
     ngAfterViewInit() {
-        this.trix.addEventListener('trix-change', this.onTrixChangeHandler);
-        this.trix.addEventListener('trix-focus', this.onTrixFocusHandler);
+        this.prosemirrorService.createEditorView({
+            element: this.editorEl.nativeElement,
+            onTextInput: content => {
+                this.onChange(content);
+                this.changeDetector.markForCheck();
+            },
+            isEditable: () => !this._readonly,
+        });
+        if (this.value) {
+            this.prosemirrorService.update(this.value);
+        }
     }
     }
 
 
     ngOnDestroy() {
     ngOnDestroy() {
-        this.trix.removeEventListener('trix-change', this.onTrixChangeHandler);
-        this.trix.removeEventListener('trix-focus', this.onTrixFocusHandler);
+        this.prosemirrorService.destroy();
     }
     }
 
 
     registerOnChange(fn: any) {
     registerOnChange(fn: any) {
@@ -92,17 +74,13 @@ export class RichTextEditorComponent implements ControlValueAccessor, AfterViewI
     }
     }
 
 
     setDisabledState(isDisabled: boolean) {
     setDisabledState(isDisabled: boolean) {
-        this.disabled = isDisabled;
+        this.prosemirrorService.setEnabled(!isDisabled);
     }
     }
 
 
     writeValue(value: any) {
     writeValue(value: any) {
-        if (this.trix.innerHTML !== value || value === '') {
-            if (!this.initialized) {
-                setTimeout(() => {
-                    this.trix.editor.loadHTML(value);
-                    this.initialized = true;
-                });
-            }
+        this.value = value;
+        if (this.prosemirrorService) {
+            this.prosemirrorService.update(value);
         }
         }
     }
     }
 }
 }

+ 4 - 0
packages/admin-ui/src/app/shared/shared-declarations.ts

@@ -59,6 +59,10 @@ export { ObjectTreeComponent } from './components/object-tree/object-tree.compon
 export { OrderStateLabelComponent } from './components/order-state-label/order-state-label.component';
 export { OrderStateLabelComponent } from './components/order-state-label/order-state-label.component';
 export { PaginationControlsComponent } from './components/pagination-controls/pagination-controls.component';
 export { PaginationControlsComponent } from './components/pagination-controls/pagination-controls.component';
 export { RichTextEditorComponent } from './components/rich-text-editor/rich-text-editor.component';
 export { RichTextEditorComponent } from './components/rich-text-editor/rich-text-editor.component';
+export {
+    ExternalImageDialogComponent,
+} from './components/rich-text-editor/external-image-dialog/external-image-dialog.component';
+export { LinkDialogComponent } from './components/rich-text-editor/link-dialog/link-dialog.component';
 export { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 export { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 export { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.component';
 export { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.component';
 export { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
 export { TableRowActionComponent } from './components/table-row-action/table-row-action.component';

+ 4 - 0
packages/admin-ui/src/app/shared/shared.module.ts

@@ -49,6 +49,7 @@ import {
     DropdownTriggerDirective,
     DropdownTriggerDirective,
     EntityInfoComponent,
     EntityInfoComponent,
     ExtensionHostComponent,
     ExtensionHostComponent,
+    ExternalImageDialogComponent,
     FacetValueChipComponent,
     FacetValueChipComponent,
     FacetValueSelectorComponent,
     FacetValueSelectorComponent,
     FileSizePipe,
     FileSizePipe,
@@ -63,6 +64,7 @@ import {
     ItemsPerPageControlsComponent,
     ItemsPerPageControlsComponent,
     LabeledDataComponent,
     LabeledDataComponent,
     LanguageSelectorComponent,
     LanguageSelectorComponent,
+    LinkDialogComponent,
     ModalDialogComponent,
     ModalDialogComponent,
     ObjectTreeComponent,
     ObjectTreeComponent,
     OrderStateLabelComponent,
     OrderStateLabelComponent,
@@ -154,6 +156,8 @@ const DECLARATIONS = [
     CustomFieldLabelPipe,
     CustomFieldLabelPipe,
     FocalPointControlComponent,
     FocalPointControlComponent,
     AssetPreviewPipe,
     AssetPreviewPipe,
+    LinkDialogComponent,
+    ExternalImageDialogComponent,
 ];
 ];
 
 
 @NgModule({
 @NgModule({

+ 11 - 1
packages/admin-ui/src/i18n-messages/en.json

@@ -239,6 +239,16 @@
     "weekday-tu": "Tu",
     "weekday-tu": "Tu",
     "weekday-we": "We"
     "weekday-we": "We"
   },
   },
+  "editor": {
+    "image-alt": "Description (alt)",
+    "image-src": "Source",
+    "image-title": "Title",
+    "insert-image": "Insert image",
+    "link-href": "Link href",
+    "link-title": "Link title",
+    "remove-link": "Remove",
+    "set-link": "Set link"
+  },
   "error": {
   "error": {
     "403-forbidden": "You are not currently authorized to access \"{ path }\". Either you lack permissions, or your session has expired.",
     "403-forbidden": "You are not currently authorized to access \"{ path }\". Either you lack permissions, or your session has expired.",
     "could-not-connect-to-server": "Could not connect to the Vendure server at { url }",
     "could-not-connect-to-server": "Could not connect to the Vendure server at { url }",
@@ -618,4 +628,4 @@
     "update": "Update",
     "update": "Update",
     "zone": "Zone"
     "zone": "Zone"
   }
   }
-}
+}

+ 11 - 0
packages/admin-ui/src/styles/theme/_forms.scss

@@ -46,6 +46,17 @@ input, select {
     }
     }
 }
 }
 
 
+// Add the "expand" class to make the input container full width
+clr-input-container.expand {
+    .clr-control-container {
+        width: 100%;
+
+        input {
+            width: 100%;
+        }
+    }
+}
+
 .clr-input {
 .clr-input {
     max-height: none !important;
     max-height: none !important;
 }
 }

+ 184 - 5
yarn.lock

@@ -3059,6 +3059,11 @@
   resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
   resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
   integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
   integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
 
 
+"@types/orderedmap@*":
+  version "1.0.0"
+  resolved "https://registry.npmjs.org/@types/orderedmap/-/orderedmap-1.0.0.tgz#807455a192bba52cbbb4517044bc82bdbfa8c596"
+  integrity sha512-dxKo80TqYx3YtBipHwA/SdFmMMyLCnP+5mkEqN0eMjcTBzHkiiX0ES118DsjDBjvD+zeSsSU9jULTZ+frog+Gw==
+
 "@types/pg-types@*":
 "@types/pg-types@*":
   version "1.11.5"
   version "1.11.5"
   resolved "https://registry.npmjs.org/@types/pg-types/-/pg-types-1.11.5.tgz#1eebbe62b6772fcc75c18957a90f933d155e005b"
   resolved "https://registry.npmjs.org/@types/pg-types/-/pg-types-1.11.5.tgz#1eebbe62b6772fcc75c18957a90f933d155e005b"
@@ -3089,6 +3094,56 @@
   resolved "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.2.tgz#ff214681f9b20bd9678a3e4ef0785315881f0de1"
   resolved "https://registry.npmjs.org/@types/prompts/-/prompts-2.0.2.tgz#ff214681f9b20bd9678a3e4ef0785315881f0de1"
   integrity sha512-Jx+tWZ2UORJT0cZZ0hT0MuQR8qDVc34sRLz225XpdZzC5EH8+uLNqXn/i920GSvONHlbzWcmLZteLYBbpWAQ+w==
   integrity sha512-Jx+tWZ2UORJT0cZZ0hT0MuQR8qDVc34sRLz225XpdZzC5EH8+uLNqXn/i920GSvONHlbzWcmLZteLYBbpWAQ+w==
 
 
+"@types/prosemirror-commands@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.npmjs.org/@types/prosemirror-commands/-/prosemirror-commands-1.0.1.tgz#e23f75629d90eef8c25132257b1ea1c5f978db9e"
+  integrity sha512-GeE12m8VT9N1JrzoY//946IX8ZyQOLNmvryJ+BNQs/HvhmXW9EWOcWUE6OBRtxK7Y8SrzSOwx4XmqSgVmK3tGQ==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+    "@types/prosemirror-view" "*"
+
+"@types/prosemirror-menu@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.npmjs.org/@types/prosemirror-menu/-/prosemirror-menu-1.0.1.tgz#6079b5249dad74da69365a0b277d53b040dd5707"
+  integrity sha512-wVGc6G7uYRvjIuVwV0zKSLwntFH1wanFwM1fDkq2YcUrLhuj4zZ1i7IPe+yqSoPm7JfmjiDEgHXTpafmwLKJrA==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+    "@types/prosemirror-view" "*"
+
+"@types/prosemirror-model@*":
+  version "1.7.2"
+  resolved "https://registry.npmjs.org/@types/prosemirror-model/-/prosemirror-model-1.7.2.tgz#9c7aff2fd62f0f56eb76e2e0eb27bf6996e6c28a"
+  integrity sha512-2l+yXvidg3AUHN07mO4Jd8Q84fo6ksFsy7LHUurLYrZ74uTahBp2fzcO49AKZMzww2EulXJ40Kl/OFaQ/7A1fw==
+  dependencies:
+    "@types/orderedmap" "*"
+
+"@types/prosemirror-state@*", "@types/prosemirror-state@^1.2.3":
+  version "1.2.3"
+  resolved "https://registry.npmjs.org/@types/prosemirror-state/-/prosemirror-state-1.2.3.tgz#7f5f871acf7b8c22e1862ff0068f9bf7e9682c0e"
+  integrity sha512-6m433Hubix9bx+JgcLW7zzyiZuzwjq5mBdSMYY4Yi5c5ZpV2RiVmg7Cy6f9Thtts8vuztilw+PczJAgDm1Frfw==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-transform" "*"
+    "@types/prosemirror-view" "*"
+
+"@types/prosemirror-transform@*":
+  version "1.1.1"
+  resolved "https://registry.npmjs.org/@types/prosemirror-transform/-/prosemirror-transform-1.1.1.tgz#5a0de16e8e0123b4c3d9559235e19f39cee85e5c"
+  integrity sha512-yYCYSoiRH+Wcbl8GJc0PFCzeyMzNQ1vL2xrHHSXZuNcIlH75VoiKrZFeZ6BS9cl8mYXjZrlmdBe8YOxYvyKM6A==
+  dependencies:
+    "@types/prosemirror-model" "*"
+
+"@types/prosemirror-view@*", "@types/prosemirror-view@^1.11.2":
+  version "1.11.2"
+  resolved "https://registry.npmjs.org/@types/prosemirror-view/-/prosemirror-view-1.11.2.tgz#58af5dcb7de20b7de874de99147552d5627209a1"
+  integrity sha512-EKcQmR4KdkFZU13wS5pWrkSojRCPGqz/l/uzpZFfW5cgdr7fQsftf2/ttvIjpk1a94ISifEY4UZwflVJ+uL4Rg==
+  dependencies:
+    "@types/prosemirror-model" "*"
+    "@types/prosemirror-state" "*"
+    "@types/prosemirror-transform" "*"
+
 "@types/q@^0.0.32":
 "@types/q@^0.0.32":
   version "0.0.32"
   version "0.0.32"
   resolved "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5"
   resolved "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5"
@@ -5997,6 +6052,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
     safe-buffer "^5.0.1"
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
     sha.js "^2.4.8"
 
 
+crel@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.npmjs.org/crel/-/crel-3.1.0.tgz#c0d5ed3df6b8017ff5ecc9f0743c60572e268c64"
+  integrity sha512-VIGY44ERxx8lXVkOEfcB0A49OkjxkQNK+j+fHvoLy7GsGX1KKgAaQ+p9N0YgvQXu+X+ryUWGDeLx/fSI+w7+eg==
+
 cross-fetch@2.2.2:
 cross-fetch@2.2.2:
   version "2.2.2"
   version "2.2.2"
   resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.2.2.tgz#a47ff4f7fc712daba8f6a695a11c948440d45723"
   resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.2.2.tgz#a47ff4f7fc712daba8f6a695a11c948440d45723"
@@ -12742,6 +12802,11 @@ ordered-read-streams@^1.0.0:
   dependencies:
   dependencies:
     readable-stream "^2.0.1"
     readable-stream "^2.0.1"
 
 
+orderedmap@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.npmjs.org/orderedmap/-/orderedmap-1.1.1.tgz#c618e77611b3b21d0fe3edc92586265e0059c789"
+  integrity sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ==
+
 original@^1.0.0:
 original@^1.0.0:
   version "1.0.2"
   version "1.0.2"
   resolved "https://registry.npmjs.org/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f"
   resolved "https://registry.npmjs.org/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f"
@@ -13880,6 +13945,115 @@ promzard@^0.3.0:
   dependencies:
   dependencies:
     read "1"
     read "1"
 
 
+prosemirror-commands@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.1.3.tgz#4ee481ff062a02498ff3d50cfcca2f6327fd1fde"
+  integrity sha512-YVbKwTR4likoyhuwIUC9egbzHvnFrFUNbiesB0DB/HZ8hBcopQ42Tb4KGlYrS3n+pNDTFObN73CLFY6mYLN2IQ==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-dropcursor@^1.0.0:
+  version "1.3.2"
+  resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.3.2.tgz#28738c4ed7102e814d7a8a26d70018523fc7cd6d"
+  integrity sha512-4c94OUGyobGnwcQI70OXyMhE/9T4aTgjU+CHxkd5c7D+jH/J0mKM/lk+jneFVKt7+E4/M0D9HzRPifu8U28Thw==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+    prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.1.3.tgz#87eb7520c0f17459774a02afb52ad322ee755efe"
+  integrity sha512-/lgWvt2AdHjsM6oEsF65z0lhdQJGl6sQSfXSOX8/xjpd8ycfOolhgKZd4TPYpikwnh85JF4l5eIyiFZsl/RQQA==
+  dependencies:
+    prosemirror-keymap "^1.0.0"
+    prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.1.3.tgz#4f76a1e71db4ef7cdf0e13dec6d8da2aeaecd489"
+  integrity sha512-zGDotijea+vnfnyyUGyiy1wfOQhf0B/b6zYcCouBV8yo6JmrE9X23M5q7Nf/nATywEZbgRLG70R4DmfSTC+gfg==
+  dependencies:
+    prosemirror-state "^1.2.2"
+    prosemirror-transform "^1.0.0"
+    rope-sequence "^1.3.0"
+
+prosemirror-inputrules@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.1.2.tgz#487e46c763e1212a4577397aba7706139084f012"
+  integrity sha512-Ja5Z3BWestlHYGvtSGqyvxMeB8QEuBjlHM8YnKtLGUXMDp965qdDV4goV8lJb17kIWHk7e7JNj6Catuoa3302g==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-keymap@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.1.3.tgz#be22d6108df2521608e9216a87b1a810f0ed361e"
+  integrity sha512-PRA4NzkUMzV/NFf5pyQ6tmlIHiW/qjQ1kGWUlV2rF/dvlOxtpGpTEjIMhWgLuMf+HiDEFnUEP7uhYXu+t+491g==
+  dependencies:
+    prosemirror-state "^1.0.0"
+    w3c-keyname "^2.2.0"
+
+prosemirror-menu@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.1.2.tgz#a58c81e01fdf3e4e25347cea3db24d51d17579ec"
+  integrity sha512-iAPBMnxaj0AXzqgzxrJPrjo5njIqUaDQjyS17R/vb6zIBnEtH1ZDPanrmZnYkBEFvvM4fBtzDZSQct5iJNTcDQ==
+  dependencies:
+    crel "^3.1.0"
+    prosemirror-commands "^1.0.0"
+    prosemirror-history "^1.0.0"
+    prosemirror-state "^1.0.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.1.0, prosemirror-model@^1.2.0:
+  version "1.9.1"
+  resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.9.1.tgz#8c08cf556f593c5f015548d2c1a6825661df087f"
+  integrity sha512-Qblh8pm1c7Ll64sYLauwwzjimo/tFg1zW3Q3IWhKRhvfOEgRKqa6dC5pRrAa+XHOIjBFEYrqbi52J5bqA2dV8Q==
+  dependencies:
+    orderedmap "^1.1.0"
+
+prosemirror-schema-basic@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.1.2.tgz#4bde5c339c845e0d08ec8fe473064e372ca51ae3"
+  integrity sha512-G4q8WflNsR1Q33QAV4MQO0xWrHLOJ+BQcKswGXMy626wlQj6c/1n1v4eC9ns+h2y1r/fJHZEgSZnsNhm9lbrDw==
+  dependencies:
+    prosemirror-model "^1.2.0"
+
+prosemirror-schema-list@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.1.2.tgz#310809209094b03425da7f5c337105074913da6c"
+  integrity sha512-dgM9PwtM4twa5WsgSYMB+J8bwjnR43DAD3L9MsR9rKm/nZR5Y85xcjB7gusVMSsbQ2NomMZF03RE6No6mTnclQ==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2:
+  version "1.3.2"
+  resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.3.2.tgz#1b910b0dc01c1f00926bb9ba1589f7b7ac0d658b"
+  integrity sha512-t/JqE3aR0SV9QrzFVkAXsQwsgrQBNs/BDbcFH20RssW0xauqNNdjTXxy/J/kM7F+0zYi6+BRmz7cMMQQFU3mwQ==
+  dependencies:
+    prosemirror-model "^1.0.0"
+    prosemirror-transform "^1.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0:
+  version "1.2.3"
+  resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.2.3.tgz#239d17591af24d39ef3f1999daa09e1f1c76b06a"
+  integrity sha512-PUfayeskQfuUBXktvL6207ZWRwHBFNPNPiek4fR+LgCPnBofuEb2+L0FfbNtrAwffHVs6M3DaFvJB1W2VQdV0A==
+  dependencies:
+    prosemirror-model "^1.0.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0:
+  version "1.14.2"
+  resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.14.2.tgz#23eb89f6101e9671b5e0c19d82ee0ad9de5608de"
+  integrity sha512-9yPVH6OLyaEraHjWHbSk2DB0R/1TsEE6AA1LI+vmCypXXA+zTzNrktUFzBhSJHehXDoEJcQfnl1Wdp5GPSh2+g==
+  dependencies:
+    prosemirror-model "^1.1.0"
+    prosemirror-state "^1.0.0"
+    prosemirror-transform "^1.1.0"
+
 proto-list@~1.2.1:
 proto-list@~1.2.1:
   version "1.2.4"
   version "1.2.4"
   resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
   resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@@ -14797,6 +14971,11 @@ rollup@^1.27.9:
     "@types/node" "*"
     "@types/node" "*"
     acorn "^7.1.0"
     acorn "^7.1.0"
 
 
+rope-sequence@^1.3.0:
+  version "1.3.2"
+  resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.2.tgz#a19e02d72991ca71feb6b5f8a91154e48e3c098b"
+  integrity sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg==
+
 rsvp@^4.8.4:
 rsvp@^4.8.4:
   version "4.8.5"
   version "4.8.5"
   resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
   resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
@@ -16416,11 +16595,6 @@ trim-right@^1.0.1:
   resolved "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
   resolved "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
   integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
   integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
 
 
-trix@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.npmjs.org/trix/-/trix-1.2.2.tgz#bb2afacb981df9a6edb49bc66f57427b9728909d"
-  integrity sha512-xNWwKDa1PG5do/qV3FRESXjM17U5ACB9Ih+x4mylLYfAgmYYTA17ExVdrrA7vCJ5J9nS1tVZFyhVTPgPTtIFVg==
-
 ts-invariant@^0.4.0:
 ts-invariant@^0.4.0:
   version "0.4.4"
   version "0.4.4"
   resolved "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"
   resolved "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"
@@ -17027,6 +17201,11 @@ w3c-hr-time@^1.0.1:
   dependencies:
   dependencies:
     browser-process-hrtime "^0.1.2"
     browser-process-hrtime "^0.1.2"
 
 
+w3c-keyname@^2.2.0:
+  version "2.2.2"
+  resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.2.tgz#7ea63170454bb19f1a3c6b628fc3dc8889276e91"
+  integrity sha512-8Vs/aVwcy0IJACaPm4tyzh1fzehZE70bGSjEl3dDms5UXtWnaBElrSHC8lDDeak0Gk5jxKOFstL64/65o7Ge2A==
+
 walker@^1.0.7, walker@~1.0.5:
 walker@^1.0.7, walker@~1.0.5:
   version "1.0.7"
   version "1.0.7"
   resolved "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"
   resolved "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"