Просмотр исходного кода

Merge branch 'minor' into major

Michael Bromley 3 лет назад
Родитель
Сommit
c3e59a5540
47 измененных файлов с 1749 добавлено и 396 удалено
  1. 11 14
      packages/admin-ui/package.json
  2. 11 1
      packages/admin-ui/src/lib/core/src/public_api.ts
  3. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.ts
  4. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html
  5. 33 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.html
  6. 87 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.scss
  7. 159 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.ts
  8. 82 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.service.ts
  9. 59 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/custom-nodes.ts
  10. 0 44
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/images.ts
  11. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/links.ts
  12. 31 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu-common.ts
  13. 23 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu-plugin.ts
  14. 80 22
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/menu.ts
  15. 29 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/sub-menu-with-icon.ts
  16. 126 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/image-plugin.ts
  17. 108 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/raw-editor-plugin.ts
  18. 215 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/tables-plugin.ts
  19. 99 7
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.scss
  20. 40 17
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts
  21. 0 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/types.ts
  22. 12 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.html
  23. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.scss
  24. 68 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.ts
  25. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.html
  26. 10 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.scss
  27. 12 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.ts
  28. 71 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/base-code-editor-form-input.component.ts
  29. 33 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/base-code-editor.scss
  30. 4 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.html
  31. 18 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.scss
  32. 78 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.ts
  33. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.html
  34. 3 31
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.scss
  35. 51 98
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.ts
  36. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  37. 6 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  38. 2 0
      packages/common/src/shared-types.ts
  39. 16 9
      packages/core/e2e/shop-order.e2e-spec.ts
  40. 1 20
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  41. 53 0
      packages/core/src/service/services/customer.service.ts
  42. 1 12
      packages/core/src/service/services/product-variant.service.ts
  43. 1 1
      packages/create/templates/vendure-config.hbs
  44. 30 2
      packages/payments-plugin/src/braintree/braintree.plugin.ts
  45. 7 2
      packages/payments-plugin/src/braintree/braintree.resolver.ts
  46. 4 1
      packages/payments-plugin/src/braintree/types.ts
  47. 65 108
      yarn.lock

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

@@ -50,16 +50,17 @@
     "messageformat": "2.3.0",
     "ngx-pagination": "^5.1.1",
     "ngx-translate-messageformat-compiler": "^5.0.1",
-    "prosemirror-commands": "^1.2.1",
-    "prosemirror-dropcursor": "^1.4.0",
-    "prosemirror-gapcursor": "^1.2.1",
-    "prosemirror-history": "^1.2.0",
-    "prosemirror-inputrules": "^1.1.3",
-    "prosemirror-keymap": "^1.1.5",
-    "prosemirror-menu": "^1.1.4",
-    "prosemirror-schema-basic": "^1.1.2",
-    "prosemirror-schema-list": "^1.1.6",
-    "prosemirror-state": "^1.3.4",
+    "prosemirror-commands": "^1.3.0",
+    "prosemirror-dropcursor": "^1.6.0",
+    "prosemirror-gapcursor": "^1.3.1",
+    "prosemirror-history": "^1.3.0",
+    "prosemirror-inputrules": "^1.2.0",
+    "prosemirror-keymap": "^1.2.0",
+    "prosemirror-menu": "^1.2.1",
+    "prosemirror-schema-basic": "^1.2.0",
+    "prosemirror-schema-list": "^1.2.1",
+    "prosemirror-state": "^1.4.1",
+    "prosemirror-tables": "^1.2.5",
     "rxjs": "^7.5.4",
     "tslib": "^2.1.0",
     "zone.js": "~0.11.4"
@@ -73,10 +74,6 @@
     "@types/jasmine": "~3.6.0",
     "@types/jasminewd2": "~2.0.6",
     "@types/node": "^14.14.31",
-    "@types/prosemirror-commands": "^1.0.4",
-    "@types/prosemirror-menu": "^1.0.6",
-    "@types/prosemirror-state": "^1.2.8",
-    "@types/prosemirror-view": "^1.23.1",
     "codelyzer": "^6.0.0",
     "cross-spawn": "^7.0.3",
     "fs-extra": "^10.0.0",

+ 11 - 1
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -153,16 +153,24 @@ export * from './shared/components/product-search-input/product-search-input.com
 export * from './shared/components/product-variant-selector/product-variant-selector.component';
 export * from './shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component';
 export * from './shared/components/rich-text-editor/link-dialog/link-dialog.component';
+export * from './shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component';
+export * from './shared/components/rich-text-editor/prosemirror/context-menu/context-menu.service';
+export * from './shared/components/rich-text-editor/prosemirror/custom-nodes';
 export * from './shared/components/rich-text-editor/prosemirror/inputrules';
 export * from './shared/components/rich-text-editor/prosemirror/keymap';
-export * from './shared/components/rich-text-editor/prosemirror/menu/images';
 export * from './shared/components/rich-text-editor/prosemirror/menu/links';
 export * from './shared/components/rich-text-editor/prosemirror/menu/menu-common';
+export * from './shared/components/rich-text-editor/prosemirror/menu/menu-plugin';
 export * from './shared/components/rich-text-editor/prosemirror/menu/menu';
+export * from './shared/components/rich-text-editor/prosemirror/menu/sub-menu-with-icon';
+export * from './shared/components/rich-text-editor/prosemirror/plugins/image-plugin';
 export * from './shared/components/rich-text-editor/prosemirror/plugins/link-select-plugin';
+export * from './shared/components/rich-text-editor/prosemirror/plugins/raw-editor-plugin';
+export * from './shared/components/rich-text-editor/prosemirror/plugins/tables-plugin';
 export * from './shared/components/rich-text-editor/prosemirror/prosemirror.service';
 export * from './shared/components/rich-text-editor/prosemirror/types';
 export * from './shared/components/rich-text-editor/prosemirror/utils';
+export * from './shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component';
 export * from './shared/components/rich-text-editor/rich-text-editor.component';
 export * from './shared/components/select-toggle/select-toggle.component';
 export * from './shared/components/simple-dialog/simple-dialog.component';
@@ -179,6 +187,8 @@ export * from './shared/directives/if-directive-base';
 export * from './shared/directives/if-multichannel.directive';
 export * from './shared/directives/if-permissions.directive';
 export * from './shared/dynamic-form-inputs/boolean-form-input/boolean-form-input.component';
+export * from './shared/dynamic-form-inputs/code-editor-form-input/base-code-editor-form-input.component';
+export * from './shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component';
 export * from './shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component';
 export * from './shared/dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component';
 export * from './shared/dynamic-form-inputs/currency-form-input/currency-form-input.component';

+ 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;

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html

@@ -28,6 +28,7 @@
 
 <ng-template vdrDialogButtons>
     <button type="submit" (click)="select()" class="btn btn-primary" [disabled]="form.invalid || !previewLoaded">
-        {{ 'editor.insert-image' | translate }}
+        <ng-container *ngIf="existing; else doesNotExist">{{ 'common.update' | translate }}</ng-container>
+        <ng-template #doesNotExist>{{ 'editor.insert-image' | translate }}</ng-template>
     </button>
 </ng-template>

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

@@ -0,0 +1,33 @@
+<ng-template #contextMenu>
+    <vdr-dropdown>
+        <button class="context-menu-trigger" vdrDropdownTrigger [class.hidden]="hideTrigger$ | async" (click)="triggerClick()">
+            <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>
+                    <clr-icon
+                        *ngIf="item.iconShape as shape"
+                        [attr.shape]="shape"
+                        size="16"
+                        class="mr2"
+                    ></clr-icon>
+                    {{ item.label }}
+                </button>
+                <div *ngIf="item.enabled && item.separator" class="dropdown-divider" role="separator"></div>
+            </ng-container>
+        </vdr-dropdown-menu>
+    </vdr-dropdown>
+</ng-template>

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

@@ -0,0 +1,87 @@
+: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 {
+        min-height: 16px;
+        &.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;
+        }
+    }
+}

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

@@ -0,0 +1,159 @@
+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 { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { distinctUntilChanged } from 'rxjs/operators';
+
+import { RichTextEditorComponent } from '../../rich-text-editor.component';
+
+import { ContextMenuConfig, ContextMenuItem, ContextMenuService } from './context-menu.service';
+
+type DropdownPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
+
+@Component({
+    selector: 'vdr-context-menu',
+    templateUrl: './context-menu.component.html',
+    styleUrls: ['./context-menu.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ContextMenuComponent implements AfterViewInit, OnDestroy {
+    @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) {
+                    const overlapMarginPx = 5;
+                    this.hideTriggerHandler = () => {
+                        if (
+                            triggerButton.getBoundingClientRect().top + overlapMarginPx <
+                            editorMenu.getBoundingClientRect().bottom
+                        ) {
+                            this.triggerIsHidden.next(true);
+                        } else {
+                            this.triggerIsHidden.next(false);
+                        }
+                    };
+                    this.contentArea.addEventListener('scroll', this.hideTriggerHandler, { passive: true });
+                    requestAnimationFrame(() => this.hideTriggerHandler?.());
+                }
+            } else {
+                if (this.hideTriggerHandler) {
+                    this.contentArea.removeEventListener('scroll', this.hideTriggerHandler);
+                }
+            }
+        });
+    }
+
+    triggerClick() {
+        this.contextMenuService.setVisibility(true);
+    }
+
+    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['top-left'];
+
+        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;
+    }
+}

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

@@ -0,0 +1,82 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, combineLatest, Observable, Subject, of, interval } from 'rxjs';
+import {
+    bufferWhen,
+    debounceTime,
+    delayWhen,
+    distinctUntilChanged,
+    filter,
+    map,
+    skip,
+    takeUntil,
+    tap,
+} from 'rxjs/operators';
+
+export interface ContextMenuConfig {
+    ref: any;
+    iconShape?: string;
+    title: string;
+    element: Element;
+    coords: { left: number; right: number; top: number; bottom: number };
+    items: ContextMenuItem[];
+}
+
+export interface ContextMenuItem {
+    separator?: boolean;
+    iconClass?: string;
+    iconShape?: string;
+    label: string;
+    enabled: boolean;
+    onClick: () => void;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ContextMenuService {
+    contextMenu$: Observable<ContextMenuConfig | undefined>;
+    private menuIsVisible$ = new BehaviorSubject<boolean>(false);
+    private setContextMenuConfig$ = new Subject<ContextMenuConfig | undefined>();
+    constructor() {
+        const source$ = this.setContextMenuConfig$.asObservable();
+        const groupedConfig$ = source$.pipe(
+            bufferWhen(() => source$.pipe(debounceTime(50))),
+            map(group => {
+                return group.reduce((acc, cur) => {
+                    if (!acc) {
+                        return cur;
+                    } else {
+                        if (cur?.ref === acc.ref) {
+                            acc.items.push(
+                                // de-duplicate items
+                                ...(cur?.items.filter(i => !acc.items.find(ai => ai.label === i.label)) ??
+                                    []),
+                            );
+                        }
+                    }
+                    return acc;
+                }, undefined as ContextMenuConfig | undefined);
+            }),
+        );
+
+        const visible$ = this.menuIsVisible$.pipe(filter(val => val === true));
+
+        const isVisible$ = this.menuIsVisible$.pipe(
+            delayWhen(value => (value ? of(value) : interval(100).pipe(takeUntil(visible$)))),
+            distinctUntilChanged(),
+        );
+        this.contextMenu$ = combineLatest(groupedConfig$, isVisible$).pipe(
+            map(([config, isVisible]) => (isVisible ? config : undefined)),
+        );
+    }
+
+    setVisibility(isVisible: boolean) {
+        this.menuIsVisible$.next(isVisible);
+    }
+
+    setContextMenu(config: ContextMenuConfig) {
+        this.setContextMenuConfig$.next(config);
+    }
+
+    clearContextMenu() {
+        this.setContextMenuConfig$.next();
+    }
+}

+ 59 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/custom-nodes.ts

@@ -0,0 +1,59 @@
+import { Attrs, DOMParser, DOMSerializer, Node, NodeSpec } from 'prosemirror-model';
+import { NodeViewConstructor } from 'prosemirror-view';
+
+export const iframeNode: NodeSpec = {
+    group: 'block',
+    attrs: {
+        allow: {},
+        allowfullscreeen: {},
+        frameborder: {},
+        height: { default: undefined },
+        name: { default: '' },
+        referrerpolicy: {},
+        sandbox: { default: undefined },
+        src: {},
+        srcdoc: { default: undefined },
+        title: { default: undefined },
+        width: { default: undefined },
+    },
+    parseDOM: [
+        {
+            tag: 'iframe',
+            getAttrs: node => {
+                if (node instanceof HTMLIFrameElement) {
+                    const attrs: Record<string, any> = {
+                        allow: node.allow,
+                        allowfullscreeen: node.allowFullscreen ?? true,
+                        frameborder: node.getAttribute('frameborder'),
+                        height: node.height,
+                        name: node.name,
+                        referrerpolicy: node.referrerPolicy,
+                        src: node.src,
+                        srcdoc: node.srcdoc || undefined,
+                        title: node.title ?? '',
+                        width: node.width,
+                    };
+                    if (node.sandbox.length) {
+                        attrs.sandbox = node.sandbox;
+                    }
+                    return attrs;
+                }
+                return null;
+            },
+        },
+    ],
+    toDOM(node) {
+        return ['iframe', { ...node.attrs }];
+    },
+};
+
+export const iframeNodeView: NodeViewConstructor = (node, view, getPos, decorations) => {
+    const domSerializer = DOMSerializer.fromSchema(view.state.schema);
+    const wrapper = document.createElement('div');
+    wrapper.classList.add('iframe-wrapper');
+    const iframe = domSerializer.serializeNode(node);
+    wrapper.appendChild(iframe);
+    return {
+        dom: wrapper,
+    };
+};

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

@@ -1,44 +0,0 @@
-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: '',
-        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();
-                });
-        },
-    });
-}

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

@@ -6,7 +6,7 @@ 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';
+import { markActive, renderClarityIcon } from './menu-common';
 
 function selectionIsWithinLink(state: EditorState, anchor: number, head: number): boolean {
     const { doc } = state;
@@ -27,7 +27,7 @@ function selectionIsWithinLink(state: EditorState, anchor: number, head: number)
 export function linkItem(linkMark: MarkType, modalService: ModalService) {
     return new MenuItem({
         title: 'Add or remove link',
-        icon: icons.link,
+        render: renderClarityIcon({ shape: 'link', size: 22 }),
         class: '',
         css: '',
         active(state) {

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

@@ -1,5 +1,7 @@
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { NodeType } from 'prosemirror-model';
 import { EditorState } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
 
 export function markActive(state, type) {
     const { from, $from, to, empty } = state.selection;
@@ -20,3 +22,32 @@ export function canInsert(state: EditorState, nodeType: NodeType): boolean {
     }
     return false;
 }
+
+export interface ClarityIconOptions {
+    shape: string;
+    size?: number;
+    label?: string;
+}
+
+export function renderClarityIcon(options: ClarityIconOptions): (view: EditorView) => HTMLElement {
+    return (view: EditorView) => {
+        const icon = document.createElement('clr-icon');
+        icon.setAttribute('shape', options.shape);
+        icon.setAttribute('size', (options.size ?? IconSize.Small).toString());
+        const labelEl = document.createElement('span');
+        labelEl.textContent = options.label ?? '';
+        return wrapInMenuItemWithIcon(icon, options.label ? labelEl : undefined);
+    };
+}
+
+export function wrapInMenuItemWithIcon(...elements: Array<HTMLElement | undefined | null>) {
+    const wrapperEl = document.createElement('span');
+    wrapperEl.classList.add('menu-item-with-icon');
+    wrapperEl.append(...elements.filter(notNullOrUndefined));
+    return wrapperEl;
+}
+
+export const IconSize = {
+    Large: 22,
+    Small: 16,
+};

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

@@ -0,0 +1,23 @@
+import { Injector } from '@angular/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;
+    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;
+}

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

@@ -1,15 +1,13 @@
 import { toggleMark } from 'prosemirror-commands';
+import { redo, undo } from 'prosemirror-history';
 import {
     blockTypeItem,
     Dropdown,
-    DropdownSubmenu,
     icons,
     joinUpItem,
     liftItem,
     MenuItem,
-    redoItem,
     selectParentNodeItem,
-    undoItem,
     wrapItem,
 } from 'prosemirror-menu';
 import { MarkType, NodeType, Schema } from 'prosemirror-model';
@@ -17,17 +15,24 @@ import { wrapInList } from 'prosemirror-schema-list';
 import { EditorState } from 'prosemirror-state';
 
 import { ModalService } from '../../../../../providers/modal/modal.service';
+import { insertImageItem } from '../plugins/image-plugin';
+import { addTable } from '../plugins/tables-plugin';
 
-import { insertImageItem } from './images';
 import { linkItem } from './links';
-import { canInsert, markActive } from './menu-common';
+import { canInsert, IconSize, markActive, renderClarityIcon, wrapInMenuItemWithIcon } from './menu-common';
+import { SubMenuWithIcon } from './sub-menu-with-icon';
 
 // Helpers to create specific types of items
 
-function cmdItem(cmd: (...args: any[]) => void, options: Record<string, any>) {
+type CmdItemOptions = Record<string, any> & { iconShape?: string };
+
+function cmdItem(cmd: (...args: any[]) => void, options: CmdItemOptions) {
     const passedOptions = {
         label: options.title,
         run: cmd,
+        render: options.iconShape
+            ? renderClarityIcon({ shape: options.iconShape, size: IconSize.Large })
+            : undefined,
     };
     // tslint:disable-next-line:forin
     for (const prop in options) {
@@ -40,7 +45,7 @@ function cmdItem(cmd: (...args: any[]) => void, options: Record<string, any>) {
     return new MenuItem(passedOptions as any);
 }
 
-function markItem(markType, options) {
+function markItem(markType, options: CmdItemOptions) {
     const passedOptions = {
         active(state) {
             return markActive(state, markType);
@@ -54,7 +59,7 @@ function markItem(markType, options) {
     return cmdItem(toggleMark(markType), passedOptions);
 }
 
-function wrapListItem(nodeType, options) {
+function wrapListItem(nodeType, options: CmdItemOptions) {
     return cmdItem(wrapInList(nodeType, options.attrs), options);
 }
 
@@ -121,10 +126,16 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
     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 });
+        r.toggleStrong = markItem(type, {
+            title: 'Toggle strong style',
+            iconShape: 'bold',
+        });
     }
     if ((type = schema.marks.em)) {
-        r.toggleEm = markItem(type, { title: 'Toggle emphasis', icon: icons.em });
+        r.toggleEm = markItem(type, {
+            title: 'Toggle emphasis',
+            iconShape: 'italic',
+        });
     }
     if ((type = schema.marks.code)) {
         r.toggleCode = markItem(type, { title: 'Toggle code font', icon: icons.code });
@@ -139,31 +150,31 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
     if ((type = schema.nodes.bullet_list)) {
         r.wrapBulletList = wrapListItem(type, {
             title: 'Wrap in bullet list',
-            icon: icons.bulletList,
+            iconShape: 'bullet-list',
         });
     }
     if ((type = schema.nodes.ordered_list)) {
         r.wrapOrderedList = wrapListItem(type, {
             title: 'Wrap in ordered list',
-            icon: icons.orderedList,
+            iconShape: 'number-list',
         });
     }
     if ((type = schema.nodes.blockquote)) {
         r.wrapBlockQuote = wrapItem(type, {
             title: 'Wrap in block quote',
-            icon: icons.blockquote,
+            render: renderClarityIcon({ shape: 'block-quote', size: IconSize.Large }),
         });
     }
     if ((type = schema.nodes.paragraph)) {
         r.makeParagraph = blockTypeItem(type, {
             title: 'Change to paragraph',
-            label: 'Plain',
+            render: renderClarityIcon({ shape: 'text', label: 'Plain' }),
         });
     }
     if ((type = schema.nodes.code_block)) {
         r.makeCodeBlock = blockTypeItem(type, {
             title: 'Change to code block',
-            label: 'Code',
+            render: renderClarityIcon({ shape: 'code', label: 'Code' }),
         });
     }
     if ((type = schema.nodes.heading)) {
@@ -179,9 +190,13 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
         const hr = type;
         r.insertHorizontalRule = new MenuItem({
             title: 'Insert horizontal rule',
-            label: 'Horizontal rule',
-            class: '',
-            css: '',
+            render: view => {
+                const icon = document.createElement('div');
+                icon.classList.add('custom-icon', 'hr-icon');
+                const labelEl = document.createElement('span');
+                labelEl.textContent = 'Horizontal rule';
+                return wrapInMenuItemWithIcon(icon, labelEl);
+            },
             enable(state) {
                 return canInsert(state, hr);
             },
@@ -192,15 +207,40 @@ 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({
+                run: (state, dispatch) => {
+                    addTable(state, dispatch, {
+                        rowsCount: 2,
+                        colsCount: 2,
+                        withHeaderRow: true,
+                        cellContent: '',
+                    });
+                },
+                render: renderClarityIcon({ shape: 'table', label: 'Table' }),
+            }),
+        ]),
+        { label: 'Insert' },
+    );
     r.typeMenu = new Dropdown(
         cut([
             r.makeParagraph,
             r.makeCodeBlock,
             r.makeHead1 &&
-                new DropdownSubmenu(
+                new SubMenuWithIcon(
                     cut([r.makeHead1, r.makeHead2, r.makeHead3, r.makeHead4, r.makeHead5, r.makeHead6]),
-                    { label: 'Heading' },
+                    {
+                        label: 'Heading',
+                        icon: () => {
+                            const icon = document.createElement('div');
+                            icon.textContent = 'H';
+                            icon.classList.add('custom-icon', 'h-icon');
+                            return icon;
+                        },
+                    },
                 ),
         ]),
         { label: 'Type...' },
@@ -218,7 +258,25 @@ export function buildMenuItems(schema: Schema, modalService: ModalService) {
             selectParentNodeItem,
         ]),
     ];
-    r.fullMenu = [inlineMenu].concat([[r.insertMenu, r.typeMenu]], [[undoItem, redoItem]], r.blockMenu);
+    const undoRedo = [
+        new MenuItem({
+            title: 'Undo last change',
+            run: undo,
+            enable(state) {
+                return undo(state);
+            },
+            render: renderClarityIcon({ shape: 'undo', size: IconSize.Large }),
+        }),
+        new MenuItem({
+            title: 'Redo last undone change',
+            run: redo,
+            enable(state) {
+                return redo(state);
+            },
+            render: renderClarityIcon({ shape: 'redo', size: IconSize.Large }),
+        }),
+    ];
+    r.fullMenu = [inlineMenu].concat([[r.insertMenu, r.typeMenu]], [undoRedo], r.blockMenu);
 
     return r;
 }

+ 29 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/menu/sub-menu-with-icon.ts

@@ -0,0 +1,29 @@
+import { DropdownSubmenu, MenuElement } from 'prosemirror-menu';
+import { EditorState } from 'prosemirror-state';
+import { EditorView } from 'prosemirror-view';
+
+import { wrapInMenuItemWithIcon } from './menu-common';
+
+export class SubMenuWithIcon extends DropdownSubmenu {
+    private icon: HTMLElement;
+    constructor(
+        content: readonly MenuElement[] | MenuElement,
+        options: {
+            label?: string;
+            icon: () => HTMLElement;
+        },
+    ) {
+        super(content, options);
+        this.icon = options.icon();
+    }
+    render(view: EditorView): {
+        dom: HTMLElement;
+        update: (state: EditorState) => boolean;
+    } {
+        const { dom, update } = super.render(view);
+        return {
+            dom: wrapInMenuItemWithIcon(this.icon, dom),
+            update,
+        };
+    }
+}

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

@@ -0,0 +1,126 @@
+import { MenuItem } from 'prosemirror-menu';
+import { Node, NodeType } from 'prosemirror-model';
+import { EditorState, NodeSelection, Plugin, Transaction } from 'prosemirror-state';
+import {
+    addColumnAfter,
+    addColumnBefore,
+    addRowAfter,
+    addRowBefore,
+    deleteColumn,
+    deleteRow,
+    deleteTable,
+    mergeCells,
+    splitCell,
+    toggleHeaderColumn,
+    toggleHeaderRow,
+} from 'prosemirror-tables';
+import { EditorView } from 'prosemirror-view';
+
+import { ModalService } from '../../../../../providers/modal/modal.service';
+import {
+    ExternalImageAttrs,
+    ExternalImageDialogComponent,
+} from '../../external-image-dialog/external-image-dialog.component';
+import { RawHtmlDialogComponent } from '../../raw-html-dialog/raw-html-dialog.component';
+import { ContextMenuItem, ContextMenuService } from '../context-menu/context-menu.service';
+import { canInsert, renderClarityIcon } from '../menu/menu-common';
+
+export function insertImageItem(nodeType: NodeType, modalService: ModalService) {
+    return new MenuItem({
+        title: 'Insert image',
+        label: 'Image',
+        render: renderClarityIcon({ shape: 'image', label: 'Image' }),
+        class: '',
+        css: '',
+        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();
+                });
+        },
+    });
+}
+
+export const imageContextMenuPlugin = (contextMenuService: ContextMenuService, modalService: ModalService) =>
+    new Plugin({
+        view: () => {
+            return {
+                update: view => {
+                    if (!view.hasFocus()) {
+                        return;
+                    }
+                    const { doc, selection } = view.state;
+                    let imageNode: Node | undefined;
+                    let imageNodePos = 0;
+                    doc.nodesBetween(selection.from, selection.to, (n, pos, parent) => {
+                        if (n.type.name === 'image') {
+                            imageNode = n;
+                            imageNodePos = pos;
+                            return false;
+                        }
+                    });
+                    if (imageNode) {
+                        const node = view.nodeDOM(imageNodePos);
+                        if (node instanceof HTMLImageElement) {
+                            contextMenuService.setContextMenu({
+                                ref: selection,
+                                title: 'Image',
+                                iconShape: 'image',
+                                element: node,
+                                coords: view.coordsAtPos(imageNodePos),
+                                items: [
+                                    {
+                                        enabled: true,
+                                        iconShape: 'image',
+                                        label: 'Image properties',
+                                        onClick: () => {
+                                            contextMenuService.clearContextMenu();
+                                            modalService
+                                                .fromComponent(ExternalImageDialogComponent, {
+                                                    closable: true,
+                                                    locals: {
+                                                        // tslint:disable-next-line:no-non-null-assertion
+                                                        existing: imageNode!.attrs as ExternalImageAttrs,
+                                                    },
+                                                })
+                                                .subscribe(result => {
+                                                    if (result) {
+                                                        // tslint:disable-next-line:no-non-null-assertion
+                                                        view.dispatch(
+                                                            view.state.tr.replaceSelectionWith(
+                                                                // tslint:disable-next-line:no-non-null-assertion
+                                                                imageNode!.type.createAndFill(result)!,
+                                                            ),
+                                                        );
+                                                    }
+                                                    view.focus();
+                                                });
+                                        },
+                                    },
+                                ],
+                            });
+                        }
+                    } else {
+                        contextMenuService.clearContextMenu();
+                    }
+                },
+            };
+        },
+    });

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

@@ -0,0 +1,108 @@
+import { DOMParser, DOMSerializer, Node } from 'prosemirror-model';
+import { Plugin } from 'prosemirror-state';
+import { Protocol } from 'puppeteer';
+
+import { ModalService } from '../../../../../providers/modal/modal.service';
+import { RawHtmlDialogComponent } from '../../raw-html-dialog/raw-html-dialog.component';
+import { ContextMenuService } from '../context-menu/context-menu.service';
+
+/**
+ * Implements editing of raw HTML for the selected node in the editor.
+ */
+export const rawEditorPlugin = (contextMenuService: ContextMenuService, modalService: ModalService) =>
+    new Plugin({
+        view: _view => {
+            const domParser = DOMParser.fromSchema(_view.state.schema);
+            const domSerializer = DOMSerializer.fromSchema(_view.state.schema);
+            return {
+                update: view => {
+                    if (!view.hasFocus()) {
+                        return;
+                    }
+                    let topLevelNode: Node | undefined;
+                    const { doc, selection } = view.state;
+                    let topLevelNodePos = 0;
+                    doc.nodesBetween(selection.from, selection.to, (n, pos, parent) => {
+                        if (parent === doc) {
+                            topLevelNode = n;
+                            topLevelNodePos = pos;
+                            return false;
+                        }
+                    });
+                    if (topLevelNode) {
+                        const node = view.nodeDOM(topLevelNodePos);
+                        if (node instanceof HTMLElement) {
+                            contextMenuService.setContextMenu({
+                                ref: selection,
+                                title: '',
+                                // iconShape: 'ellipsis-vertical',
+                                element: node,
+                                coords: view.coordsAtPos(topLevelNodePos),
+                                items: [
+                                    {
+                                        enabled: true,
+                                        iconShape: 'code',
+                                        label: 'Edit HTML',
+                                        onClick: () => {
+                                            contextMenuService.clearContextMenu();
+                                            const element = domSerializer.serializeNode(
+                                                // tslint:disable-next-line:no-non-null-assertion
+                                                topLevelNode!,
+                                            ) as HTMLElement;
+                                            modalService
+                                                .fromComponent(RawHtmlDialogComponent, {
+                                                    size: 'xl',
+                                                    locals: {
+                                                        html: element.outerHTML,
+                                                    },
+                                                })
+                                                .subscribe(result => {
+                                                    if (result) {
+                                                        const domNode = htmlToDomNode(
+                                                            result,
+                                                            topLevelNode?.isLeaf ? undefined : node,
+                                                        );
+                                                        if (domNode) {
+                                                            let tr = view.state.tr;
+                                                            const parsedNodeSlice = domParser.parse(domNode);
+                                                            try {
+                                                                tr = tr.replaceRangeWith(
+                                                                    topLevelNodePos,
+                                                                    topLevelNodePos +
+                                                                        (topLevelNode?.nodeSize ?? 0),
+                                                                    parsedNodeSlice,
+                                                                );
+                                                            } catch (err: any) {
+                                                                // tslint:disable-next-line:no-console
+                                                                console.error(err);
+                                                            }
+                                                            view.dispatch(tr);
+                                                            view.focus();
+                                                        }
+                                                    }
+                                                });
+                                        },
+                                    },
+                                ],
+                            });
+                        }
+                    }
+                },
+            };
+        },
+    });
+
+function htmlToDomNode(html: string, wrapInParent?: HTMLElement) {
+    html = `${html.trim()}`;
+    const template = document.createElement('template');
+    if (wrapInParent) {
+        const parentClone = wrapInParent.cloneNode(false) as HTMLElement;
+        parentClone.innerHTML = html;
+        template.content.appendChild(parentClone);
+    } else {
+        const parent = document.createElement('p');
+        parent.innerHTML = html;
+        template.content.appendChild(parent);
+    }
+    return template.content.firstChild;
+}

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

@@ -0,0 +1,215 @@
+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 => {
+                    if (!view.hasFocus()) {
+                        return;
+                    }
+                    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: () => {
+                                        contextMenuService.clearContextMenu();
+                                        view.focus();
+                                        commandFn(view.state, view.dispatch);
+                                    },
+                                };
+                            }
+                            const separator: ContextMenuItem = {
+                                label: '',
+                                separator: true,
+                                enabled: true,
+                                onClick: () => {
+                                    /**/
+                                },
+                            };
+                            contextMenuService.setContextMenu({
+                                ref: selection,
+                                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);
+}

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

@@ -73,13 +73,15 @@
 
     .ProseMirror-menuseparator {
         border-right: 1px solid var(--color-component-border-200);
-        margin-right: 3px;
+        margin: 0 12px 0 8px;
+        height: 18px;
     }
 
     .ProseMirror-menu-dropdown,
     .ProseMirror-menu-dropdown-menu {
         font-size: 90%;
         white-space: nowrap;
+        border-radius: var(--border-radius-input);
     }
 
     .ProseMirror-menu-dropdown {
@@ -90,7 +92,7 @@
     }
 
     .ProseMirror-menu-dropdown-wrap {
-        padding: 1px 0 1px 4px;
+        padding: 1px 3px 1px 6px;
         display: inline-block;
         position: relative;
     }
@@ -109,8 +111,7 @@
     .ProseMirror-menu-dropdown-menu,
     .ProseMirror-menu-submenu {
         position: absolute;
-        background: white;
-        color: var(--color-grey-600);
+        background: var(--color-component-bg-100);
         border: 1px solid var(--color-component-border-200);
         padding: 2px;
     }
@@ -118,6 +119,7 @@
     .ProseMirror-menu-dropdown-menu {
         z-index: 15;
         min-width: 6em;
+        color: var(--color-text-200);
     }
 
     .ProseMirror-menu-dropdown-item {
@@ -126,12 +128,12 @@
     }
 
     .ProseMirror-menu-dropdown-item:hover {
-        background: var(--color-component-bg-100);
+        background: var(--color-component-bg-200);
     }
 
     .ProseMirror-menu-submenu-wrap {
         position: relative;
-        margin-right: -4px;
+        margin-right: 4px;
     }
 
     .ProseMirror-menu-submenu-label:after {
@@ -141,7 +143,7 @@
         border-left: 4px solid currentColor;
         opacity: 0.6;
         position: absolute;
-        right: 4px;
+        right: -8px;
         top: calc(50% - 4px);
     }
 
@@ -186,6 +188,7 @@
         -moz-box-sizing: border-box;
         box-sizing: border-box;
         overflow: visible;
+        align-items: center;
     }
 
     .ProseMirror-icon {
@@ -336,4 +339,93 @@
         margin-bottom: 0.5rem;
         color: var(--color-grey-800) !important;
     }
+
+    .ProseMirror .tableWrapper {
+        td,
+        th {
+            border: 1px solid var(--color-grey-300);
+            padding: 3px 6px;
+        }
+        td p,
+        th p {
+            margin-top: 0;
+        }
+        th,
+        th p {
+            font-weight: bold;
+        }
+    }
+
+    .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;
+        margin: 6px 0;
+        pointer-events: none;
+    }
+    .menu-item-with-icon {
+        display: flex;
+        align-items: center;
+        clr-icon,
+        .custom-icon {
+            margin-right: 4px;
+            color: var(--color-text-200);
+        }
+        .hr-icon {
+            width: 13px;
+            height: 8px;
+            border-bottom: 2px solid var(--color-text-100);
+            margin: -8px 5px 0 2px;
+        }
+        .h-icon {
+            width: 16px;
+            text-align: center;
+            font-weight: bold;
+            font-size: 12px;
+        }
+    }
+}
+
+.context-menu {
+    position: fixed;
 }

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

@@ -1,22 +1,27 @@
-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 { Observable } from 'rxjs';
 import { ModalService } from '../../../../providers/modal/modal.service';
 
+import { ContextMenuService } from './context-menu/context-menu.service';
+import { iframeNode, iframeNodeView } from './custom-nodes';
 import { buildInputRules } from './inputrules';
 import { buildKeymap } from './keymap';
-import { buildMenuItems } from './menu/menu';
+import { customMenuPlugin } from './menu/menu-plugin';
+import { imageContextMenuPlugin } from './plugins/image-plugin';
 import { linkSelectPlugin } from './plugins/link-select-plugin';
+import { rawEditorPlugin } from './plugins/raw-editor-plugin';
+import { getTableNodes, tableContextMenuPlugin } from './plugins/tables-plugin';
 import { SetupOptions } from './types';
 
 export interface CreateEditorViewOptions {
@@ -32,15 +37,19 @@ 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)
+            .addToEnd('iframe', iframeNode),
         marks: schema.spec.marks,
     });
     private enabled = true;
 
-    constructor(private modalService: ModalService) {}
+    constructor(private injector: Injector, private contextMenuService: ContextMenuService) {}
+
+    contextMenuItems$: Observable<string>;
 
     createEditorView(options: CreateEditorViewOptions) {
-        this.editorView = new EditorView<Schema>(options.element, {
+        this.editorView = new EditorView(options.element, {
             state: this.getStateFromText(''),
             dispatchTransaction: tr => {
                 if (!this.enabled) {
@@ -53,13 +62,26 @@ export class ProsemirrorService {
                 }
             },
             editable: () => options.isReadOnly(),
+            handleDOMEvents: {
+                focus: view => {
+                    this.contextMenuService.setVisibility(true);
+                },
+                blur: view => {
+                    this.contextMenuService.setVisibility(false);
+                },
+            },
+            nodeViews: {
+                iframe: iframeNodeView,
+            },
         });
     }
 
     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 +129,17 @@ export class ProsemirrorService {
             dropCursor(),
             gapCursor(),
             linkSelectPlugin,
+            columnResizing({}),
+            tableEditing({ allowTableNodeSelection: true }),
+            tableContextMenuPlugin(this.contextMenuService),
+            imageContextMenuPlugin(this.contextMenuService, this.injector.get(ModalService)),
+            rawEditorPlugin(this.contextMenuService, this.injector.get(ModalService)),
+            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>;

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

@@ -0,0 +1,12 @@
+<vdr-dynamic-form-input
+                      [def]="config"
+                      [control]="formControl"
+                  ></vdr-dynamic-form-input>
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn btn-secondary" (click)="cancel()">
+       {{ 'common.cancel' | translate }}
+    </button>
+    <button type="submit" (click)="select()" class="btn btn-primary" [disabled]="formControl.invalid">
+        {{ 'common.update' | translate }}
+    </button>
+</ng-template>

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


+ 68 - 0
packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.ts

@@ -0,0 +1,68 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+
+import { ConfigArgDefinition } from '../../../../common/generated-types';
+import { Dialog } from '../../../../providers/modal/modal.service';
+import { HtmlEditorFormInputComponent } from '../../../dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component';
+
+@Component({
+    selector: 'vdr-raw-html-dialog',
+    templateUrl: './raw-html-dialog.component.html',
+    styleUrls: ['./raw-html-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RawHtmlDialogComponent implements OnInit, Dialog<string> {
+    html: string;
+    formControl = new FormControl();
+    config: ConfigArgDefinition = {
+        name: '',
+        type: '',
+        list: false,
+        required: true,
+        ui: { component: HtmlEditorFormInputComponent.id },
+    };
+
+    resolveWith: (html: string | undefined) => void;
+
+    ngOnInit(): void {
+        this.formControl.setValue(this.process(this.html));
+    }
+
+    process(str: string) {
+        const div = document.createElement('div');
+        div.innerHTML = str.trim();
+        return this.format(div, 0).innerHTML.trim();
+    }
+
+    /**
+     * Taken from https://stackoverflow.com/a/26361620/772859
+     */
+    format(node: Element, level = 0) {
+        const indentBefore = new Array(level++ + 1).join('\t');
+        const indentAfter = new Array(level - 1).join('\t');
+        let textNode: Text;
+
+        // tslint:disable-next-line:prefer-for-of
+        for (let i = 0; i < node.children.length; i++) {
+            textNode = document.createTextNode('\n' + indentBefore);
+            node.insertBefore(textNode, node.children[i]);
+
+            this.format(node.children[i], level);
+
+            if (node.lastElementChild === node.children[i]) {
+                textNode = document.createTextNode('\n' + indentAfter);
+                node.appendChild(textNode);
+            }
+        }
+
+        return node;
+    }
+
+    cancel() {
+        this.resolveWith(undefined);
+    }
+
+    select() {
+        this.resolveWith(this.formControl.value);
+    }
+}

+ 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>

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

@@ -62,4 +62,14 @@
         cursor: default;
         max-width: 100%;
     }
+
+    .iframe-wrapper {
+        width: 100%;
+        text-align: center;
+        padding: 6px;
+        transition: background-color 0.3s;
+        &:hover {
+            background-color: var(--color-primary-100);
+        }
+    }
 }

+ 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({

+ 71 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/base-code-editor-form-input.component.ts

@@ -0,0 +1,71 @@
+import { AfterViewInit, ChangeDetectorRef, Directive, ElementRef, ViewChild } from '@angular/core';
+import { FormControl, ValidatorFn } from '@angular/forms';
+import { DefaultFormComponentConfig } from '@vendure/common/lib/shared-types';
+import { CodeJar } from 'codejar';
+
+import { FormInputComponent } from '../../../common/component-registry-types';
+
+export interface CodeEditorConfig {
+    validator: ValidatorFn;
+    getErrorMessage: (content: string) => string | undefined;
+    highlight: (content: string, errorPos: number | undefined) => string;
+}
+
+@Directive()
+export abstract class BaseCodeEditorFormInputComponent implements FormInputComponent, AfterViewInit {
+    readonly: boolean;
+    formControl: FormControl;
+    config: DefaultFormComponentConfig<'json-editor-form-input'>;
+    isValid = true;
+    errorMessage: string | undefined;
+    @ViewChild('editor') private editorElementRef: ElementRef<HTMLDivElement>;
+    jar: CodeJar;
+    private highlight: CodeEditorConfig['highlight'];
+    private getErrorMessage: CodeEditorConfig['getErrorMessage'];
+
+    protected constructor(protected changeDetector: ChangeDetectorRef) {}
+
+    get height() {
+        return this.config.ui?.height || this.config.height;
+    }
+
+    configure(config: CodeEditorConfig) {
+        this.formControl.addValidators(config.validator);
+        this.highlight = config.highlight;
+        this.getErrorMessage = config.getErrorMessage;
+    }
+
+    ngAfterViewInit() {
+        let lastVal = '';
+        const highlight = (editor: HTMLElement) => {
+            const code = editor.textContent ?? '';
+            if (code === lastVal) {
+                return;
+            }
+            lastVal = code;
+            this.errorMessage = this.getErrorMessage(code);
+            this.changeDetector.markForCheck();
+            editor.innerHTML = this.highlight(code, this.getErrorPos(this.errorMessage));
+        };
+        this.jar = CodeJar(this.editorElementRef.nativeElement, highlight);
+        this.jar.onUpdate(value => {
+            this.formControl.setValue(value);
+            this.formControl.markAsDirty();
+            this.isValid = this.formControl.valid;
+        });
+        this.jar.updateCode(this.formControl.value);
+
+        if (this.readonly) {
+            this.editorElementRef.nativeElement.contentEditable = 'false';
+        }
+    }
+
+    protected getErrorPos(errorMessage: string | undefined): number | undefined {
+        if (!errorMessage) {
+            return;
+        }
+        const matches = errorMessage.match(/at position (\d+)/);
+        const pos = matches?.[1];
+        return pos != null ? +pos : undefined;
+    }
+}

+ 33 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/base-code-editor.scss

@@ -0,0 +1,33 @@
+@mixin code-editor {
+    .code-editor {
+        min-height: 6rem;
+        background-color: var(--color-json-editor-background-color);
+        color: var(--color-json-editor-text);
+        border: 1px solid var(--color-component-border-200);
+        border-radius: 3px;
+        padding: 6px;
+        tab-size: 4;
+        font-family: 'Source Code Pro', 'Lucida Console', Monaco, monospace;
+        font-size: 14px;
+        font-weight: 400;
+        height: 340px;
+        letter-spacing: normal;
+        line-height: 20px;
+        resize: both;
+        text-align: initial;
+        min-width: 200px;
+
+        &:focus {
+            border-color: var(--color-primary-500);
+        }
+
+        &.invalid {
+            border-color: var(--clr-forms-invalid-color);
+        }
+    }
+
+    .error-message {
+        min-height: 1rem;
+        color: var(--color-json-editor-error);
+    }
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.html

@@ -0,0 +1,4 @@
+<div #editor class="code-editor html-editor" [class.invalid]="!isValid" [style.height]="height || '300px'"></div>
+<div class="error-message">
+    <span *ngIf="errorMessage">{{ errorMessage }}</span>
+</div>

+ 18 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.scss

@@ -0,0 +1,18 @@
+@import './base-code-editor';
+
+@include code-editor;
+
+.code-editor {
+    // prettier-ignore
+    ::ng-deep {
+       .he-tag { color: var(--color-json-editor-key); }
+       .he-attr { color: var(--color-json-editor-number); }
+       .he-error {
+           text-decoration-line: underline;
+           text-decoration-style: wavy;
+           text-decoration-color: var(--color-json-editor-error);
+       }
+    }
+}
+
+

+ 78 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.ts

@@ -0,0 +1,78 @@
+import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+
+import { FormInputComponent } from '../../../common/component-registry-types';
+
+import { BaseCodeEditorFormInputComponent } from './base-code-editor-form-input.component';
+
+function htmlValidator(): ValidatorFn {
+    return (control: AbstractControl): ValidationErrors | null => {
+        return null;
+    };
+}
+
+const HTML_TAG_RE = /<\/?[^>]+>?/g;
+
+/**
+ * @description
+ * A JSON editor input with syntax highlighting and error detection. Works well
+ * with `text` type fields.
+ *
+ * @docsCategory custom-input-components
+ * @docsPage default-inputs
+ */
+@Component({
+    selector: 'vdr-html-editor-form-input',
+    templateUrl: './html-editor-form-input.component.html',
+    styleUrls: ['./html-editor-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class HtmlEditorFormInputComponent
+    extends BaseCodeEditorFormInputComponent
+    implements FormInputComponent, AfterViewInit, OnInit
+{
+    static readonly id: DefaultFormComponentId = 'html-editor-form-input';
+
+    constructor(protected changeDetector: ChangeDetectorRef) {
+        super(changeDetector);
+    }
+
+    ngOnInit() {
+        this.configure({
+            validator: htmlValidator,
+            highlight: (html: string, errorPos: number | undefined) => {
+                let hasMarkedError = false;
+                return html.replace(HTML_TAG_RE, (match, ...args) => {
+                    let errorClass = '';
+                    if (errorPos && !hasMarkedError) {
+                        const length = args[0].length;
+                        const offset = args[4];
+                        if (errorPos <= length + offset) {
+                            errorClass = 'je-error';
+                            hasMarkedError = true;
+                        }
+                    }
+                    return (
+                        '<span class="he-tag' +
+                        ' ' +
+                        errorClass +
+                        '">' +
+                        this.encodeHtmlChars(match).replace(
+                            /([a-zA-Z0-9-]+=)(["'][^'"]*["'])/g,
+                            (_match, ..._args) => `${_args[0]}<span class="he-attr">${_args[1]}</span>`,
+                        ) +
+                        '</span>'
+                    );
+                });
+            },
+            getErrorMessage: (json: string): string | undefined => {
+                return;
+            },
+        });
+    }
+
+    private encodeHtmlChars(html: string): string {
+        return html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+    }
+}

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.html

@@ -1,4 +1,4 @@
-<div #editor class="json-editor" [class.invalid]="!isValid" [style.height]="height || '300px'"></div>
+<div #editor class="code-editor json-editor" [class.invalid]="!isValid" [style.height]="height || '300px'"></div>
 <div class="error-message">
     <span *ngIf="errorMessage">{{ errorMessage }}</span>
 </div>

+ 3 - 31
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.scss

@@ -1,29 +1,8 @@
-.json-editor {
-    min-height: 6rem;
-    background-color: var(--color-json-editor-background-color);
-    color: var(--color-json-editor-text);
-    border: 1px solid var(--color-component-border-200);
-    border-radius: 3px;
-    padding: 6px;
-    tab-size: 4;
-    font-family: 'Source Code Pro', 'Lucida Console', Monaco, monospace;
-    font-size: 14px;
-    font-weight: 400;
-    height: 340px;
-    letter-spacing: normal;
-    line-height: 20px;
-    resize: both;
-    text-align: initial;
-    min-width: 200px;
+@import './base-code-editor';
 
-    &:focus {
-        border-color: var(--color-primary-500);
-    }
-
-    &.invalid {
-        border-color: var(--clr-forms-invalid-color);
-    }
+@include code-editor;
 
+.code-editor {
     // prettier-ignore
     ::ng-deep {
        .je-string { color: var(--color-json-editor-string); }
@@ -38,10 +17,3 @@
        }
     }
 }
-
-.error-message {
-    min-height: 1rem;
-    color: var(--color-json-editor-error);
-}
-
-

+ 51 - 98
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.ts

@@ -1,18 +1,11 @@
-import {
-    AfterViewInit,
-    ChangeDetectionStrategy,
-    ChangeDetectorRef,
-    Component,
-    ElementRef,
-    OnInit,
-    ViewChild,
-} from '@angular/core';
-import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from '@angular/forms';
-import { DefaultFormComponentConfig, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
-import { CodeJar } from 'codejar';
+import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+import { DefaultFormComponentId } from '@vendure/common/lib/shared-types';
 
 import { FormInputComponent } from '../../../common/component-registry-types';
 
+import { BaseCodeEditorFormInputComponent } from './base-code-editor-form-input.component';
+
 export function jsonValidator(): ValidatorFn {
     return (control: AbstractControl): ValidationErrors | null => {
         const error: ValidationErrors = { jsonInvalid: true };
@@ -43,98 +36,58 @@ export function jsonValidator(): ValidatorFn {
     styleUrls: ['./json-editor-form-input.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class JsonEditorFormInputComponent implements FormInputComponent, AfterViewInit, OnInit {
+export class JsonEditorFormInputComponent
+    extends BaseCodeEditorFormInputComponent
+    implements FormInputComponent, AfterViewInit, OnInit
+{
     static readonly id: DefaultFormComponentId = 'json-editor-form-input';
-    readonly: boolean;
-    formControl: FormControl;
-    config: DefaultFormComponentConfig<'json-editor-form-input'>;
-    isValid = true;
-    errorMessage: string | undefined;
-    @ViewChild('editor') private editorElementRef: ElementRef<HTMLDivElement>;
-    jar: CodeJar;
-
-    constructor(private changeDetector: ChangeDetectorRef) {}
 
-    get height() {
-        return this.config.ui?.height || this.config.height;
+    constructor(protected changeDetector: ChangeDetectorRef) {
+        super(changeDetector);
     }
 
     ngOnInit() {
-        this.formControl.addValidators(jsonValidator());
-    }
-
-    ngAfterViewInit() {
-        let lastVal = '';
-        const highlight = (editor: HTMLElement) => {
-            const code = editor.textContent ?? '';
-            if (code === lastVal) {
-                return;
-            }
-            lastVal = code;
-            this.errorMessage = this.getJsonError(code);
-            this.changeDetector.markForCheck();
-            editor.innerHTML = this.syntaxHighlight(code, this.getErrorPos(this.errorMessage));
-        };
-        this.jar = CodeJar(this.editorElementRef.nativeElement, highlight);
-        this.jar.onUpdate(value => {
-            this.formControl.setValue(value);
-            this.formControl.markAsDirty();
-            this.isValid = this.formControl.valid;
-        });
-        this.jar.updateCode(this.formControl.value);
-
-        if (this.readonly) {
-            this.editorElementRef.nativeElement.contentEditable = 'false';
-        }
-    }
-
-    private getJsonError(json: string): string | undefined {
-        try {
-            JSON.parse(json);
-        } catch (e: any) {
-            return e.message;
-        }
-        return;
-    }
-
-    private getErrorPos(errorMessage: string | undefined): number | undefined {
-        if (!errorMessage) {
-            return;
-        }
-        const matches = errorMessage.match(/at position (\d+)/);
-        const pos = matches?.[1];
-        return pos != null ? +pos : undefined;
-    }
-
-    private syntaxHighlight(json: string, errorPos: number | undefined) {
-        json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-        let hasMarkedError = false;
-        return json.replace(
-            /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
-            (match, ...args) => {
-                let cls = 'number';
-                if (/^"/.test(match)) {
-                    if (/:$/.test(match)) {
-                        cls = 'key';
-                    } else {
-                        cls = 'string';
-                    }
-                } else if (/true|false/.test(match)) {
-                    cls = 'boolean';
-                } else if (/null/.test(match)) {
-                    cls = 'null';
-                }
-                let errorClass = '';
-                if (errorPos && !hasMarkedError) {
-                    const length = args[0].length;
-                    const offset = args[4];
-                    if (errorPos <= length + offset) {
-                        errorClass = 'je-error';
-                        hasMarkedError = true;
-                    }
+        this.configure({
+            validator: jsonValidator,
+            highlight: (json: string, errorPos: number | undefined) => {
+                json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+                let hasMarkedError = false;
+                return json.replace(
+                    /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
+                    (match, ...args) => {
+                        let cls = 'number';
+                        if (/^"/.test(match)) {
+                            if (/:$/.test(match)) {
+                                cls = 'key';
+                            } else {
+                                cls = 'string';
+                            }
+                        } else if (/true|false/.test(match)) {
+                            cls = 'boolean';
+                        } else if (/null/.test(match)) {
+                            cls = 'null';
+                        }
+                        let errorClass = '';
+                        if (errorPos && !hasMarkedError) {
+                            const length = args[0].length;
+                            const offset = args[4];
+                            if (errorPos <= length + offset) {
+                                errorClass = 'je-error';
+                                hasMarkedError = true;
+                            }
+                        }
+                        return '<span class="je-' + cls + ' ' + errorClass + '">' + match + '</span>';
+                    },
+                );
+            },
+            getErrorMessage: (json: string): string | undefined => {
+                try {
+                    JSON.parse(json);
+                } catch (e: any) {
+                    return e.message;
                 }
-                return '<span class="je-' + cls + ' ' + errorClass + '">' + match + '</span>';
+                return;
             },
-        );
+        });
     }
 }

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts

@@ -9,6 +9,7 @@ import {
 } from '../../providers/custom-field-component/custom-field-component.service';
 
 import { BooleanFormInputComponent } from './boolean-form-input/boolean-form-input.component';
+import { HtmlEditorFormInputComponent } from './code-editor-form-input/html-editor-form-input.component';
 import { JsonEditorFormInputComponent } from './code-editor-form-input/json-editor-form-input.component';
 import { CombinationModeFormInputComponent } from './combination-mode-form-input/combination-mode-form-input.component';
 import { CurrencyFormInputComponent } from './currency-form-input/currency-form-input.component';
@@ -40,6 +41,7 @@ export const defaultFormInputs = [
     TextareaFormInputComponent,
     RichTextFormInputComponent,
     JsonEditorFormInputComponent,
+    HtmlEditorFormInputComponent,
     ProductMultiSelectorFormInputComponent,
     CombinationModeFormInputComponent,
 ];

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

@@ -74,6 +74,8 @@ import { ProductSearchInputComponent } from './components/product-search-input/p
 import { ProductVariantSelectorComponent } from './components/product-variant-selector/product-variant-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 { RawHtmlDialogComponent } from './components/rich-text-editor/raw-html-dialog/raw-html-dialog.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';
@@ -89,6 +91,7 @@ import { IfDefaultChannelActiveDirective } from './directives/if-default-channel
 import { IfMultichannelDirective } from './directives/if-multichannel.directive';
 import { IfPermissionsDirective } from './directives/if-permissions.directive';
 import { BooleanFormInputComponent } from './dynamic-form-inputs/boolean-form-input/boolean-form-input.component';
+import { HtmlEditorFormInputComponent } from './dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component';
 import { JsonEditorFormInputComponent } from './dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component';
 import { CombinationModeFormInputComponent } from './dynamic-form-inputs/combination-mode-form-input/combination-mode-form-input.component';
 import { CurrencyFormInputComponent } from './dynamic-form-inputs/currency-form-input/currency-form-input.component';
@@ -240,6 +243,8 @@ const DECLARATIONS = [
     AssetPreviewLinksComponent,
     ProductMultiSelectorDialogComponent,
     ProductSearchInputComponent,
+    ContextMenuComponent,
+    RawHtmlDialogComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [
@@ -264,6 +269,7 @@ const DYNAMIC_FORM_INPUTS = [
     TextareaFormInputComponent,
     RichTextFormInputComponent,
     JsonEditorFormInputComponent,
+    HtmlEditorFormInputComponent,
     ProductMultiSelectorFormInputComponent,
     CombinationModeFormInputComponent,
 ];

+ 2 - 0
packages/common/src/shared-types.ts

@@ -136,6 +136,7 @@ export type DefaultFormComponentId =
     | 'date-form-input'
     | 'facet-value-form-input'
     | 'json-editor-form-input'
+    | 'html-editor-form-input'
     | 'number-form-input'
     | 'password-form-input'
     | 'product-selector-form-input'
@@ -161,6 +162,7 @@ type DefaultFormConfigHash = {
     'date-form-input': { min?: string; max?: string; yearRange?: number };
     'facet-value-form-input': {};
     'json-editor-form-input': { height?: string };
+    'html-editor-form-input': { height?: string };
     'number-form-input': { min?: number; max?: number; step?: number; prefix?: string; suffix?: string };
     'password-form-input': {};
     'product-selector-form-input': {};

+ 16 - 9
packages/core/e2e/shop-order.e2e-spec.ts

@@ -873,7 +873,7 @@ describe('Shop orders', () => {
             const address: CreateAddressInput = {
                 fullName: 'name',
                 company: 'company',
-                streetLine1: '12 the street',
+                streetLine1: '12 Shipping Street',
                 streetLine2: null,
                 city: 'foo',
                 province: 'bar',
@@ -891,7 +891,7 @@ describe('Shop orders', () => {
             expect(setOrderShippingAddress!.shippingAddress).toEqual({
                 fullName: 'name',
                 company: 'company',
-                streetLine1: '12 the street',
+                streetLine1: '12 Shipping Street',
                 streetLine2: null,
                 city: 'foo',
                 province: 'bar',
@@ -905,7 +905,7 @@ describe('Shop orders', () => {
             const address: CreateAddressInput = {
                 fullName: 'name',
                 company: 'company',
-                streetLine1: '12 the street',
+                streetLine1: '22 Billing Avenue',
                 streetLine2: null,
                 city: 'foo',
                 province: 'bar',
@@ -923,7 +923,7 @@ describe('Shop orders', () => {
             expect(setOrderBillingAddress!.billingAddress).toEqual({
                 fullName: 'name',
                 company: 'company',
-                streetLine1: '12 the street',
+                streetLine1: '22 Billing Avenue',
                 streetLine2: null,
                 city: 'foo',
                 province: 'bar',
@@ -1016,11 +1016,18 @@ describe('Shop orders', () => {
             });
 
             // tslint:disable-next-line:no-non-null-assertion
-            const address = result.customer!.addresses![0];
-            expect(address.streetLine1).toBe('12 the street');
-            expect(address.postalCode).toBe('123456');
-            expect(address.defaultBillingAddress).toBe(true);
-            expect(address.defaultShippingAddress).toBe(true);
+            const shippingAddress = result.customer!.addresses!.find(a => a.defaultShippingAddress)!;
+            expect(shippingAddress.streetLine1).toBe('12 Shipping Street');
+            expect(shippingAddress.postalCode).toBe('123456');
+            expect(shippingAddress.defaultBillingAddress).toBe(false);
+            expect(shippingAddress.defaultShippingAddress).toBe(true);
+
+            // tslint:disable-next-line:no-non-null-assertion
+            const billingAddress = result.customer!.addresses!.find(a => a.defaultBillingAddress)!;
+            expect(billingAddress.streetLine1).toBe('22 Billing Avenue');
+            expect(billingAddress.postalCode).toBe('123456');
+            expect(billingAddress.defaultBillingAddress).toBe(true);
+            expect(billingAddress.defaultShippingAddress).toBe(false);
         });
     });
 

+ 1 - 20
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -341,26 +341,7 @@ export class ShopOrderResolver {
                     return order;
                 }
                 if (order.active === false) {
-                    if (order.customer) {
-                        const addresses = await this.customerService.findAddressesByCustomerId(
-                            ctx,
-                            order.customer.id,
-                        );
-                        // If the Customer has no addresses yet, use the shipping address data
-                        // to populate the initial default Address.
-                        if (addresses.length === 0 && order.shippingAddress?.country) {
-                            const address = order.shippingAddress;
-                            await this.customerService.createAddress(ctx, order.customer.id, {
-                                ...address,
-                                company: address.company || '',
-                                streetLine1: address.streetLine1 || '',
-                                streetLine2: address.streetLine2 || '',
-                                countryCode: address.countryCode || '',
-                                defaultBillingAddress: true,
-                                defaultShippingAddress: true,
-                            });
-                        }
-                    }
+                    await this.customerService.createAddressesForNewCustomer(ctx, order);
                 }
                 if (order.active === false && ctx.session?.activeOrderId === sessionOrder.id) {
                     await this.sessionService.unsetActiveOrder(ctx, ctx.session);

+ 53 - 0
packages/core/src/service/services/customer.service.ts

@@ -14,6 +14,7 @@ import {
     DeletionResponse,
     DeletionResult,
     HistoryEntryType,
+    OrderAddress,
     UpdateAddressInput,
     UpdateCustomerInput,
     UpdateCustomerNoteInput,
@@ -46,6 +47,7 @@ import { Channel } from '../../entity/channel/channel.entity';
 import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
 import { Customer } from '../../entity/customer/customer.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
+import { Order } from '../../entity/index';
 import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { AccountRegistrationEvent } from '../../event-bus/events/account-registration-event';
@@ -790,6 +792,57 @@ export class CustomerService {
         };
     }
 
+    /**
+     * @description
+     * If the Customer associated with the given Order does not yet have any Addresses,
+     * this method will create new Address(es) based on the Order's shipping & billing
+     * addresses.
+     */
+    async createAddressesForNewCustomer(ctx: RequestContext, order: Order) {
+        if (!order.customer) {
+            return;
+        }
+        const addresses = await this.findAddressesByCustomerId(ctx, order.customer.id);
+        // If the Customer has no addresses yet, use the shipping/billing address data
+        // to populate the initial default Address.
+        if (addresses.length === 0 && order.shippingAddress?.country) {
+            const shippingAddress = order.shippingAddress;
+            const billingAddress = order.billingAddress;
+            const hasSeparateBillingAddress =
+                billingAddress?.streetLine1 && !this.addressesAreEqual(shippingAddress, billingAddress);
+            if (shippingAddress.streetLine1) {
+                await this.createAddress(ctx, order.customer.id, {
+                    ...shippingAddress,
+                    company: shippingAddress.company || '',
+                    streetLine1: shippingAddress.streetLine1 || '',
+                    streetLine2: shippingAddress.streetLine2 || '',
+                    countryCode: shippingAddress.countryCode || '',
+                    defaultBillingAddress: !hasSeparateBillingAddress,
+                    defaultShippingAddress: true,
+                });
+            }
+            if (hasSeparateBillingAddress) {
+                await this.createAddress(ctx, order.customer.id, {
+                    ...billingAddress,
+                    company: billingAddress.company || '',
+                    streetLine1: billingAddress.streetLine1 || '',
+                    streetLine2: billingAddress.streetLine2 || '',
+                    countryCode: billingAddress.countryCode || '',
+                    defaultBillingAddress: true,
+                    defaultShippingAddress: false,
+                });
+            }
+        }
+    }
+
+    private addressesAreEqual(address1: OrderAddress, address2: OrderAddress): boolean {
+        return (
+            address1.streetLine1 === address2.streetLine1 &&
+            address1.streetLine2 === address2.streetLine2 &&
+            address1.postalCode === address2.postalCode
+        );
+    }
+
     async addNoteToCustomer(ctx: RequestContext, input: AddNoteToCustomerInput): Promise<Customer> {
         const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id, {
             channelId: ctx.channelId,

+ 1 - 12
packages/core/src/service/services/product-variant.service.ts

@@ -493,18 +493,7 @@ export class ProductVariantService {
         });
         await this.customFieldRelationService.updateRelations(ctx, ProductVariant, input, updatedVariant);
         if (input.price != null) {
-            const variantPriceRepository = this.connection.getRepository(ctx, ProductVariantPrice);
-            const variantPrice = await variantPriceRepository.findOne({
-                where: {
-                    variant: input.id,
-                    channelId: ctx.channelId,
-                },
-            });
-            if (!variantPrice) {
-                throw new InternalServerError(`error.could-not-find-product-variant-price`);
-            }
-            variantPrice.price = input.price;
-            await variantPriceRepository.save(variantPrice);
+            await this.createOrUpdateProductVariantPrice(ctx, input.id, input.price, ctx.channelId);
         }
         return updatedVariant.id;
     }

+ 1 - 1
packages/create/templates/vendure-config.hbs

@@ -46,7 +46,7 @@ export const config: VendureConfig = {
         // See the README.md "Migrations" section for an explanation of
         // the `synchronize` and `migrations` options.
         synchronize: false,
-        migrations: [path.join(__dirname, './migrations/*.ts')],
+        migrations: [path.join(__dirname, './migrations/*.+(js|ts)')],
         logging: false,
         database: {{#if isSQLjs}}new Uint8Array([]){{else if isSQLite}}path.join(__dirname, '../vendure.sqlite'){{else}}process.env.DB_NAME{{/if}},
         {{#if dbSchema}}

+ 30 - 2
packages/payments-plugin/src/braintree/braintree.plugin.ts

@@ -37,7 +37,8 @@ import { BraintreePluginOptions } from './types';
  *       BraintreePlugin.init({
  *         environment: Environment.Sandbox,
  *         // This allows saving customer payment
- *         // methods with Braintree
+ *         // methods with Braintree (see "vaulting"
+ *         // section below for details)
  *         storeCustomersInBraintree: true,
  *       }),
  *     ]
@@ -187,6 +188,33 @@ import { BraintreePluginOptions } from './types';
  *   }
  * }
  * ```
+ *
+ * ## Storing payment details (vaulting)
+ *
+ * Braintree has a [vault feature](https://developer.paypal.com/braintree/articles/control-panel/vault/overview) which allows the secure storage
+ * of customer's payment information. Using the vault allows you to offer a faster checkout for repeat customers without needing to worry about
+ * how to securely store payment details.
+ *
+ * To enable this feature, set the `storeCustomersInBraintree` option to `true`.
+ *
+ * ```TypeScript
+ * BraintreePlugin.init({
+ *   environment: Environment.Sandbox,
+ *   storeCustomersInBraintree: true,
+ * }),
+ * ```
+ *
+ * Since v1.8, it is possible to override vaulting on a per-payment basis by passing `includeCustomerId: false` to the `generateBraintreeClientToken`
+ * mutation:
+ *
+ * ```GraphQL
+ * const { generateBraintreeClientToken } = await graphQlClient.query(gql`
+ *   query GenerateBraintreeClientToken($includeCustomerId: Boolean) {
+ *     generateBraintreeClientToken(includeCustomerId: $includeCustomerId)
+ *   }
+ * `, { includeCustomerId: false });
+ * ```
+ *
  * @docsCategory payments-plugin
  * @docsPage BraintreePlugin
  */
@@ -215,7 +243,7 @@ import { BraintreePluginOptions } from './types';
     shopApiExtensions: {
         schema: gql`
             extend type Query {
-                generateBraintreeClientToken(orderId: ID): String!
+                generateBraintreeClientToken(orderId: ID, includeCustomerId: Boolean): String!
             }
         `,
         resolvers: [BraintreeResolver],

+ 7 - 2
packages/payments-plugin/src/braintree/braintree.resolver.ts

@@ -27,7 +27,10 @@ export class BraintreeResolver {
     ) {}
 
     @Query()
-    async generateBraintreeClientToken(@Ctx() ctx: RequestContext, @Args() { orderId }: { orderId?: ID }) {
+    async generateBraintreeClientToken(
+        @Ctx() ctx: RequestContext,
+        @Args() { orderId, includeCustomerId }: { orderId?: ID; includeCustomerId?: boolean },
+    ) {
         if (orderId) {
             Logger.warn(
                 `The orderId argument to the generateBraintreeClientToken mutation has been deprecated and may be omitted.`,
@@ -45,7 +48,9 @@ export class BraintreeResolver {
             const args = await this.getPaymentMethodArgs(ctx);
             const gateway = getGateway(args, this.options);
             try {
-                const result = await gateway.clientToken.generate({ customerId });
+                const result = await gateway.clientToken.generate({
+                    customerId: includeCustomerId === false ? undefined : customerId,
+                });
                 return result.clientToken;
             } catch (e: any) {
                 Logger.error(

+ 4 - 1
packages/payments-plugin/src/braintree/types.ts

@@ -33,10 +33,13 @@ export interface BraintreePluginOptions {
     /**
      * @description
      * If set to `true`, a [Customer](https://developer.paypal.com/braintree/docs/guides/customers) object
-     * will be created in Braintree, which allows the secure storage of previously-used payment methods.
+     * will be created in Braintree, which allows the secure storage ("vaulting") of previously-used payment methods.
      * This is done by adding a custom field to the Customer entity to store the Braintree customer ID,
      * so switching this on will require a database migration / synchronization.
      *
+     * Since v1.8, it is possible to override vaulting on a per-payment basis by passing `includeCustomerId: false` to the
+     * `generateBraintreeClientToken` mutation.
+     *
      * @default false
      */
     storeCustomersInBraintree?: boolean;

+ 65 - 108
yarn.lock

@@ -4121,11 +4121,6 @@
   resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
   integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
 
-"@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/parse-json@^4.0.0":
   version "4.0.0"
   resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@@ -4159,56 +4154,6 @@
   dependencies:
     "@types/node" "*"
 
-"@types/prosemirror-commands@^1.0.4":
-  version "1.0.4"
-  resolved "https://registry.npmjs.org/@types/prosemirror-commands/-/prosemirror-commands-1.0.4.tgz#d08551415127d93ae62e7239d30db0b5e7208e22"
-  integrity sha512-utDNYB3EXLjAfYIcRWJe6pn3kcQ5kG4RijbT/0Y/TFOm6yhvYS/D9eJVnijdg9LDjykapcezchxGRqFD5LcyaQ==
-  dependencies:
-    "@types/prosemirror-model" "*"
-    "@types/prosemirror-state" "*"
-    "@types/prosemirror-view" "*"
-
-"@types/prosemirror-menu@^1.0.6":
-  version "1.0.6"
-  resolved "https://registry.npmjs.org/@types/prosemirror-menu/-/prosemirror-menu-1.0.6.tgz#5de3b6fcceb118fed50f1f0419056291ad7a21f8"
-  integrity sha512-teKfCPnnbYeB8DJiUIof4N0f8kyWbQP3BagEBRP6r+7/o0fiqHsnrex8GXuCSNNUqlpSpDQkJy52aq4jvzmDpg==
-  dependencies:
-    "@types/prosemirror-model" "*"
-    "@types/prosemirror-state" "*"
-    "@types/prosemirror-view" "*"
-
-"@types/prosemirror-model@*":
-  version "1.16.1"
-  resolved "https://registry.npmjs.org/@types/prosemirror-model/-/prosemirror-model-1.16.1.tgz#0ce6c80cd81b398b8a11b1bf7cf695bff3160c9a"
-  integrity sha512-SrrCe2cHlYrQ9o55e2i/c3wt1yRajTTpRLvzfmB+2DWjWEbBLTByVWyjrdpKtQTxAaTeU2aeDGo1iuwl/jF27w==
-  dependencies:
-    "@types/orderedmap" "*"
-
-"@types/prosemirror-state@*", "@types/prosemirror-state@^1.2.8":
-  version "1.2.8"
-  resolved "https://registry.npmjs.org/@types/prosemirror-state/-/prosemirror-state-1.2.8.tgz#65080eeec52f63c50bf7034377f07773b4f6b2ac"
-  integrity sha512-mq9uyQWcpu8jeamO6Callrdvf/e1H/aRLR2kZWSpZrPHctEsxWHBbluD/wqVjXBRIOoMHLf6ZvOkrkmGLoCHVA==
-  dependencies:
-    "@types/prosemirror-model" "*"
-    "@types/prosemirror-transform" "*"
-    "@types/prosemirror-view" "*"
-
-"@types/prosemirror-transform@*":
-  version "1.1.5"
-  resolved "https://registry.npmjs.org/@types/prosemirror-transform/-/prosemirror-transform-1.1.5.tgz#e6949398c64a5d3ca53e6081352751aa9e9ce76e"
-  integrity sha512-Wr2HXaEF4JPklWpC17RTxE6PxyU54Taqk5FMhK1ojgcN93J+GpkYW8s0mD3rl7KfTmlhVwZPCHE9o0cYf2Go5A==
-  dependencies:
-    "@types/prosemirror-model" "*"
-
-"@types/prosemirror-view@*", "@types/prosemirror-view@^1.23.1":
-  version "1.23.1"
-  resolved "https://registry.npmjs.org/@types/prosemirror-view/-/prosemirror-view-1.23.1.tgz#a9a926bb6b6e6873e3a9d8caa61c32f3402629eb"
-  integrity sha512-6e1B2oKUnhmZPUrsVvYjDqeVjE6jGezygjtoHsAK4ZENAxHzHqy5NT4jUvdPTWjCYeH0t2Y7pSfRPNrPIyQX4A==
-  dependencies:
-    "@types/prosemirror-model" "*"
-    "@types/prosemirror-state" "*"
-    "@types/prosemirror-transform" "*"
-
 "@types/q@^0.0.32":
   version "0.0.32"
   resolved "https://registry.npmjs.org/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5"
@@ -14983,110 +14928,122 @@ propagate@^2.0.0:
   resolved "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
   integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
 
-prosemirror-commands@^1.0.0, prosemirror-commands@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.2.1.tgz#eae0cb714df695260659b78ff5d201d3a037e50d"
-  integrity sha512-S/IkpXfpuLFsRynC2HQ5iYROUPiZskKS1+ClcWycGJvj4HMb/mVfeEkQrixYxgTl96EAh+RZQNWPC06GZXk5tQ==
+prosemirror-commands@^1.0.0, prosemirror-commands@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.3.0.tgz#361b2e2b2a347ce7453386459f97c3f549a1113b"
+  integrity sha512-BwBbZ5OAScPcm0x7H8SPbqjuEJnCU2RJT9LDyOiiIl/3NbL1nJZI4SFNHwU2e/tRr2Xe7JsptpzseqvZvToLBQ==
   dependencies:
     prosemirror-model "^1.0.0"
     prosemirror-state "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-dropcursor@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz#91a859d4ee79c99b1c0ba6ee61c093b195c0d9f0"
-  integrity sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ==
+prosemirror-dropcursor@^1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.6.0.tgz#35b891224f79319755cadbec0e075bba8e95a0a3"
+  integrity sha512-2vj5tYDXADpd6Acg5iuZV2/3dEBy9s3tRUju6lQPOlKYSvJd7Tsz9c4uLS+L9ZCJndyW0EBrT+PadarHa1G30Q==
   dependencies:
     prosemirror-state "^1.0.0"
     prosemirror-transform "^1.1.0"
     prosemirror-view "^1.1.0"
 
-prosemirror-gapcursor@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.1.tgz#02365e1bcc1ad25d390b0fb7f0e94a7fc173ad75"
-  integrity sha512-PHa9lj27iM/g4C46gxVzsefuXVfy/LrGQH4QjMRht7VDBgw77iWYWn8ZHMWSFkwtr9jQEuxI5gccHHHwWG80nw==
+prosemirror-gapcursor@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.1.tgz#8cfd874592e4504d63720e14ed680c7866e64554"
+  integrity sha512-GKTeE7ZoMsx5uVfc51/ouwMFPq0o8YrZ7Hx4jTF4EeGbXxBveUV8CGv46mSHuBBeXGmvu50guoV2kSnOeZZnUA==
   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, prosemirror-history@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.2.0.tgz#04cc4df8d2f7b2a46651a2780de191ada6d465ea"
-  integrity sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==
+prosemirror-history@^1.0.0, prosemirror-history@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.3.0.tgz#bf5a1ff7759aca759ddf0c722c2fa5b14fb0ddc1"
+  integrity sha512-qo/9Wn4B/Bq89/YD+eNWFbAytu6dmIM85EhID+fz9Jcl9+DfGEo8TTSrRhP15+fFEoaPqpHSxlvSzSEbmlxlUA==
   dependencies:
     prosemirror-state "^1.2.2"
     prosemirror-transform "^1.0.0"
     rope-sequence "^1.3.0"
 
-prosemirror-inputrules@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz#93f9199ca02473259c30d7e352e4c14022d54638"
-  integrity sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw==
+prosemirror-inputrules@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.2.0.tgz#476dde2dc244050b3aca00cf58a82adfad6749e7"
+  integrity sha512-eAW/M/NTSSzpCOxfR8Abw6OagdG0MiDAiWHQMQveIsZtoKVYzm0AflSPq/ymqJd56/Su1YPbwy9lM13wgHOFmQ==
   dependencies:
     prosemirror-state "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz#b5984c7d30f5c75956c853126c54e9e624c0327b"
-  integrity sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==
+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==
   dependencies:
     prosemirror-state "^1.0.0"
     w3c-keyname "^2.2.0"
 
-prosemirror-menu@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.1.4.tgz#a845ae0e14ce1f92dd39d7f23caa6063265cd98c"
-  integrity sha512-2ROsji/X9ciDnVSRvSTqFygI34GEdHfQSsK4zBKjPxSEroeiHHcdRMS1ofNIf2zM0Vpp5/YqfpxynElymQkqzg==
+prosemirror-menu@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.1.tgz#94d99a8547b7ba5680c20e9c497ce19846ce3b2c"
+  integrity sha512-sBirXxVfHalZO4f1ZS63WzewINK4182+7dOmoMeBkqYO8wqMBvBS7wQuwVOHnkMWPEh0+N0LJ856KYUN+vFkmQ==
   dependencies:
     crelt "^1.0.0"
     prosemirror-commands "^1.0.0"
     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:
-  version "1.16.1"
-  resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.16.1.tgz#fb388270bc9609b66298d6a7e15d0cc1d6c61253"
-  integrity sha512-r1/w0HDU40TtkXp0DyKBnFPYwd8FSlUSJmGCGFv4DeynfeSlyQF2FD0RQbVEMOe6P3PpUSXM6LZBV7W/YNZ4mA==
+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==
   dependencies:
-    orderedmap "^1.1.0"
+    orderedmap "^2.0.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==
+prosemirror-schema-basic@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.0.tgz#c33ad74426efae1d41e2260371866f623e8eb10e"
+  integrity sha512-JMN/ammP94ObOUS6cpIy121r0MEDN9V95mAxFVALwC4bbmhpWXGjBGHTA5LHPPdbqZKyR6Jar1Akv4Z5k9CNLw==
   dependencies:
     prosemirror-model "^1.2.0"
 
-prosemirror-schema-list@^1.1.6:
-  version "1.1.6"
-  resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz#c3e13fe2f74750e4a53ff88d798dc0c4ccca6707"
-  integrity sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw==
+prosemirror-schema-list@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.2.1.tgz#3de9fb2482ace2a83f90b2c128873b343b0d778a"
+  integrity sha512-rYT4azRBZboxl54a4dRSiW0wXBEIZcMCCM9z9x0TD1jqJMm89GR16UgPNYb5+pKZ8qyti5enYN1Hhztq3KvqrQ==
   dependencies:
     prosemirror-model "^1.0.0"
+    prosemirror-state "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.4:
-  version "1.3.4"
-  resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.3.4.tgz#4c6b52628216e753fc901c6d2bfd84ce109e8952"
-  integrity sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==
+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==
   dependencies:
     prosemirror-model "^1.0.0"
     prosemirror-transform "^1.0.0"
 
-prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0:
-  version "1.3.4"
-  resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.3.4.tgz#1d1997009b7b145c2aa2773f7f670c8a3d4cb46f"
-  integrity sha512-gTsg3UIeaFuEY6+YmNPMgTpEkCKPedkFIUnsPpOMbclU701fEVI/e4VOXACXh3BO5rZJaBbEBwrnzB0mLp6eBA==
+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:
-  version "1.23.6"
-  resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.23.6.tgz#f514b3166942cb70aac4ac24d0a28c21c3897608"
-  integrity sha512-B4DAzriNpI/AVoW0Lu6SVfX00jZZQxOVwdBQEjWlRbCdT9V0pvk4GQJ3JTFaib+b6BcPdRZ3MjWXz2xvV1rblA==
+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==
   dependencies:
     prosemirror-model "^1.16.0"
     prosemirror-state "^1.0.0"