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

feat(admin-ui): Add context menu for images in rich text editor

Relates to #1716
Michael Bromley 3 лет назад
Родитель
Сommit
5b09abd06a

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

@@ -153,7 +153,7 @@ export * from './shared/components/rich-text-editor/external-image-dialog/extern
 export * from './shared/components/rich-text-editor/link-dialog/link-dialog.component';
 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/plugins/image-plugin';
 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';

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

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

@@ -73,9 +73,10 @@ export class ContextMenuComponent implements AfterViewInit, OnDestroy {
                 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 <
+                            triggerButton.getBoundingClientRect().top + overlapMarginPx <
                             editorMenu.getBoundingClientRect().bottom
                         ) {
                             this.triggerIsHidden.next(true);
@@ -84,6 +85,7 @@ export class ContextMenuComponent implements AfterViewInit, OnDestroy {
                         }
                     };
                     this.contentArea.addEventListener('scroll', this.hideTriggerHandler, { passive: true });
+                    requestAnimationFrame(() => this.hideTriggerHandler?.());
                 }
             } else {
                 if (this.hideTriggerHandler) {

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

@@ -1,45 +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, renderClarityIcon } from './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();
-                });
-        },
-    });
-}

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

@@ -15,9 +15,9 @@ 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, IconSize, markActive, renderClarityIcon, wrapInMenuItemWithIcon } from './menu-common';
 import { SubMenuWithIcon } from './sub-menu-with-icon';

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

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

@@ -18,6 +18,7 @@ import { iframeNode, iframeNodeView } from './custom-nodes';
 import { buildInputRules } from './inputrules';
 import { buildKeymap } from './keymap';
 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';
@@ -131,6 +132,7 @@ export class ProsemirrorService {
             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,