Procházet zdrojové kódy

feat(admin-ui): Implement raw HTML editing support in rich text editor

Relates to #1716
Michael Bromley před 3 roky
rodič
revize
e9f7fcdb29
21 změnil soubory, kde provedl 526 přidání a 79 odebrání
  1. 33 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.html
  2. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.scss
  3. 4 34
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.component.ts
  4. 46 12
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/context-menu/context-menu.service.ts
  5. 59 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/custom-nodes.ts
  6. 108 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/raw-editor-plugin.ts
  7. 5 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/tables-plugin.ts
  8. 18 1
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts
  9. 12 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.html
  10. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.scss
  11. 85 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/raw-html-dialog/raw-html-dialog.component.ts
  12. 10 0
      packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/rich-text-editor.component.scss
  13. 33 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/base-code-editor.scss
  14. 4 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.html
  15. 18 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.scss
  16. 78 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.ts
  17. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.html
  18. 3 31
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.scss
  19. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/register-dynamic-input-components.ts
  20. 4 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  21. 2 0
      packages/common/src/shared-types.ts

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

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

@@ -49,6 +49,7 @@
     }
 
     .context-menu-trigger {
+        min-height: 16px;
         &.hidden {
             visibility: hidden;
         }

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

@@ -20,39 +20,7 @@ export type DropdownPosition = 'top-left' | 'top-right' | 'bottom-left' | 'botto
 
 @Component({
     selector: 'vdr-context-menu',
-    template: `
-        <ng-template #contextMenu>
-            <vdr-dropdown>
-                <button class="context-menu-trigger" vdrDropdownTrigger [class.hidden]="hideTrigger$ | async">
-                    <clr-icon
-                        *ngIf="menuConfig?.iconShape as shape"
-                        [attr.shape]="shape"
-                        size="16"
-                        class="mr2"
-                    ></clr-icon>
-                    <span class="title-label">{{ menuConfig?.title }}</span>
-                </button>
-                <vdr-dropdown-menu vdrPosition="bottom-right" customClasses="context-menu">
-                    <ng-container *ngFor="let item of menuConfig?.items">
-                        <button
-                            class="context-menu-item"
-                            *ngIf="item.enabled && item.separator !== true"
-                            type="button"
-                            (click)="clickItem(item)"
-                        >
-                            <div *ngIf="item.iconClass" class="cm-icon" [ngClass]="item.iconClass"></div>
-                            {{ item.label }}
-                        </button>
-                        <div
-                            *ngIf="item.enabled && item.separator"
-                            class="dropdown-divider"
-                            role="separator"
-                        ></div>
-                    </ng-container>
-                </vdr-dropdown-menu>
-            </vdr-dropdown>
-        </ng-template>
-    `,
+    templateUrl: './context-menu.component.html',
     styleUrls: ['./context-menu.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
@@ -125,7 +93,9 @@ export class ContextMenuComponent implements AfterViewInit, OnDestroy {
         });
     }
 
-    private setUpIntersectionObserver() {}
+    triggerClick() {
+        this.contextMenuService.setVisibility(true);
+    }
 
     ngOnDestroy(): void {
         this.overlayRef?.dispose();

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

@@ -1,8 +1,19 @@
 import { Injectable } from '@angular/core';
-import { Observable, Subject } from 'rxjs';
-import { distinctUntilChanged } from 'rxjs/operators';
+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;
@@ -13,6 +24,7 @@ export interface ContextMenuConfig {
 export interface ContextMenuItem {
     separator?: boolean;
     iconClass?: string;
+    iconShape?: string;
     label: string;
     enabled: boolean;
     onClick: () => void;
@@ -21,21 +33,43 @@ export interface ContextMenuItem {
 @Injectable({ providedIn: 'root' })
 export class ContextMenuService {
     contextMenu$: Observable<ContextMenuConfig | undefined>;
+    private menuIsVisible$ = new BehaviorSubject<boolean>(false);
     private setContextMenuConfig$ = new Subject<ContextMenuConfig | undefined>();
     constructor() {
-        this.contextMenu$ = this.setContextMenuConfig$.asObservable().pipe(
-            distinctUntilChanged((a, b) => {
-                if (a == null && b == null) {
-                    return true;
-                }
-                if (a?.element === b?.element) {
-                    if (a?.items.map(i => i.enabled).join(',') === b?.items.map(i => i.enabled).join(',')) {
-                        return true;
+        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 false;
+                    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) {

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

+ 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 { ModalService } from '@vendure/admin-ui/core';
+import { DOMParser, DOMSerializer, Node } from 'prosemirror-model';
+import { Plugin } from 'prosemirror-state';
+import { Protocol } from 'puppeteer';
+
+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;
+}

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

@@ -29,6 +29,9 @@ export const tableContextMenuPlugin = (contextMenuService: ContextMenuService) =
         view: () => {
             return {
                 update: view => {
+                    if (!view.hasFocus()) {
+                        return;
+                    }
                     const { doc, selection } = view.state;
                     let tableNode: Node | undefined;
                     let tableNodePos = 0;
@@ -56,6 +59,7 @@ export const tableContextMenuPlugin = (contextMenuService: ContextMenuService) =
                                     enabled,
                                     iconClass,
                                     onClick: () => {
+                                        contextMenuService.clearContextMenu();
                                         view.focus();
                                         commandFn(view.state, view.dispatch);
                                     },
@@ -70,6 +74,7 @@ export const tableContextMenuPlugin = (contextMenuService: ContextMenuService) =
                                 },
                             };
                             contextMenuService.setContextMenu({
+                                ref: selection,
                                 title: 'Table',
                                 iconShape: 'table',
                                 element: node,

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

@@ -11,12 +11,15 @@ 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 { customMenuPlugin } from './menu/menu-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';
 
@@ -33,7 +36,9 @@ 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').append(getTableNodes() as any),
+        nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block')
+            .append(getTableNodes() as any)
+            .addToEnd('iframe', iframeNode),
         marks: schema.spec.marks,
     });
     private enabled = true;
@@ -56,6 +61,17 @@ export class ProsemirrorService {
                 }
             },
             editable: () => options.isReadOnly(),
+            handleDOMEvents: {
+                focus: view => {
+                    this.contextMenuService.setVisibility(true);
+                },
+                blur: view => {
+                    this.contextMenuService.setVisibility(false);
+                },
+            },
+            nodeViews: {
+                iframe: iframeNodeView,
+            },
         });
     }
 
@@ -115,6 +131,7 @@ export class ProsemirrorService {
             columnResizing({}),
             tableEditing({ allowTableNodeSelection: true }),
             tableContextMenuPlugin(this.contextMenuService),
+            rawEditorPlugin(this.contextMenuService, this.injector.get(ModalService)),
             customMenuPlugin({
                 floatingMenu: options.floatingMenu,
                 injector: this.injector,

+ 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


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

@@ -0,0 +1,85 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ComponentFactoryResolver,
+    ElementRef,
+    Injector,
+    OnInit,
+    ViewChild,
+} from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { ConfigArgDefinition, jsonValidator } from '@vendure/admin-ui/core';
+import { DefaultFormComponentConfig, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+import { CodeJar } from 'codejar';
+
+import { Dialog } from '../../../../providers/modal/modal.service';
+import { HtmlEditorFormInputComponent } from '../../../dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component';
+
+export interface LinkAttrs {
+    href: string;
+    title: string;
+}
+
+@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);
+    }
+}

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

+ 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';
+
+export function jsonValidator(): 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: jsonValidator,
+            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);
-}
-
-

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

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

@@ -75,6 +75,7 @@ import { ProductSelectorComponent } from './components/product-selector/product-
 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';
@@ -90,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';
@@ -242,6 +244,7 @@ const DECLARATIONS = [
     ProductMultiSelectorDialogComponent,
     ProductSearchInputComponent,
     ContextMenuComponent,
+    RawHtmlDialogComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [
@@ -266,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': {};