Bladeren bron

feat(admin-ui): Integrate Vendure Assets Picker with ProseMirror and add single image selection (#3033)

dfernandesbsolus 1 jaar geleden
bovenliggende
commit
18e5ab99e2

+ 4 - 2
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts

@@ -13,7 +13,6 @@ import { debounceTime, delay, finalize, map, take as rxjsTake, takeUntil, tap }
 
 import {
     Asset,
-    CreateAssetsMutation,
     GetAssetListQuery,
     GetAssetListQueryVariables,
     LogicalOperator,
@@ -79,7 +78,10 @@ export class AssetPickerDialogComponent implements OnInit, AfterViewInit, OnDest
     private listQuery: QueryResult<GetAssetListQuery, GetAssetListQueryVariables>;
     private destroy$ = new Subject<void>();
 
-    constructor(private dataService: DataService, private notificationService: NotificationService) {}
+    constructor(
+        private dataService: DataService,
+        private notificationService: NotificationService,
+    ) {}
 
     ngOnInit() {
         this.listQuery = this.dataService.product.getAssetList(this.paginationConfig.itemsPerPage, 0);

+ 3 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-preview-links/asset-preview-links.component.ts

@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
 
 import { AssetLike } from '../asset-gallery/asset-gallery.types';
 
+export const ASSET_SIZES = ['tiny', 'thumb', 'small', 'medium', 'large', 'full'];
+
 @Component({
     selector: 'vdr-asset-preview-links',
     templateUrl: './asset-preview-links.component.html',
@@ -10,5 +12,5 @@ import { AssetLike } from '../asset-gallery/asset-gallery.types';
 })
 export class AssetPreviewLinksComponent {
     @Input() asset: AssetLike;
-    sizes = ['tiny', 'thumb', 'small', 'medium', 'large', 'full'];
+    sizes = ASSET_SIZES;
 }

+ 7 - 2
packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts

@@ -50,6 +50,8 @@ export class AssetsComponent {
     @Input()
     updatePermissions: string | string[] | Permission | Permission[];
 
+    @Input() multiSelect = true;
+
     constructor(
         private modalService: ModalService,
         private changeDetector: ChangeDetectorRef,
@@ -59,11 +61,14 @@ export class AssetsComponent {
         this.modalService
             .fromComponent(AssetPickerDialogComponent, {
                 size: 'xl',
+                locals: {
+                    multiSelect: this.multiSelect,
+                },
             })
             .subscribe(result => {
                 if (result && result.length) {
-                    this.assets = unique(this.assets.concat(result), 'id');
-                    if (!this.featuredAsset) {
+                    this.assets = this.multiSelect ? unique(this.assets.concat(result), 'id') : result;
+                    if (!this.featuredAsset || !this.multiSelect) {
                         this.featuredAsset = result[0];
                     }
                     this.emitChangeEvent(this.assets, this.featuredAsset);

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

@@ -1,33 +1,84 @@
-<div class="flex">
-    <form [formGroup]="form" class="flex-spacer" clrForm clrLayout="vertical">
-        <clr-input-container class="expand">
-            <label>{{ 'editor.image-src' | translate }}</label>
-            <input clrInput type="text" formControlName="src" />
-        </clr-input-container>
-        <clr-input-container class="expand">
-            <label>{{ 'editor.image-title' | translate }}</label>
-            <input clrInput type="text" formControlName="title" />
-        </clr-input-container>
-        <clr-input-container class="expand">
-            <label>{{ 'editor.image-alt' | translate }}</label>
-            <input clrInput type="text" formControlName="alt" />
-        </clr-input-container>
-    </form>
-    <div class="preview">
-        <img
-            [src]="form.get('src')?.value"
-            [class.visible]="previewLoaded"
-            (load)="onImageLoad($event)"
-            (error)="onImageError($event)"
-        />
-        <div class="placeholder" *ngIf="!previewLoaded">
-            <clr-icon shape="image" size="128"></clr-icon>
+<div class="clr-row">
+    <div class="clr-col-md-5 clr-row clr-justify-content-center">
+        <div class="preview text-center clr-col-12 mt-10">
+            <vdr-dropdown>
+                <img
+                    [src]="form.get('src')?.value"
+                    [class.visible]="previewLoaded"
+                    vdrDropdownTrigger
+                    (load)="onImageLoad($event)"
+                    (error)="onImageError($event)"
+                    class="img-responsive"
+                />
+
+                <vdr-dropdown-menu vdrPosition="bottom-right">
+                    <button
+                        vdrDropdownItem
+                        [title]="'asset.remove-asset' | translate"
+                        (click)="removeImage()"
+                    >
+                        <clr-icon shape="times"></clr-icon>
+                        {{ 'asset.remove-asset' | translate }}
+                    </button>
+                </vdr-dropdown-menu>
+            </vdr-dropdown>
+
+            <div class="placeholder" *ngIf="!previewLoaded">
+                <clr-icon shape="image" size="128"></clr-icon>
+            </div>
+        </div>
+        <div class="text-center clr-col-12">
+            <div *ngIf="previewLoaded && !form.get('dataExternal')?.value">
+                <select name="options" (change)="onSizeSelect($event.target.value)" [(ngModel)]="preset">
+                    <option value="" selected>{{ 'asset.size' | translate }}</option>
+                    <option *ngFor="let size of sizes" [value]="size">{{ size }}</option>
+                </select>
+            </div>
+
+            <button
+                class="btn btn-icon btn-sm btn-block mt-2"
+                [title]="(!previewLoaded ? 'asset.add-asset' : 'asset.change-asset') | translate"
+                (click)="selectAssets()"
+            >
+                <clr-icon shape="attachment"></clr-icon>
+                {{ (!previewLoaded ? 'asset.add-asset' : 'asset.change-asset') | translate }}
+            </button>
         </div>
     </div>
+
+    <div class="clr-col">
+        <form [formGroup]="form" class="flex-spacer" clrForm clrLayout="vertical">
+            <clr-input-container class="expand">
+                <label>{{ 'editor.image-src' | translate }}</label>
+                <input clrInput type="text" formControlName="src" />
+            </clr-input-container>
+            <clr-input-container class="expand mt-2">
+                <label>{{ 'editor.image-title' | translate }}</label>
+                <input clrInput type="text" formControlName="title" />
+            </clr-input-container>
+            <clr-input-container class="expand mt-2">
+                <label>{{ 'editor.image-alt' | translate }}</label>
+                <input clrInput type="text" formControlName="alt" />
+            </clr-input-container>
+            <clr-input-container class="expand mt-2">
+                <label>{{ 'editor.width' | translate }}</label>
+                <input clrInput type="text" formControlName="width" />
+            </clr-input-container>
+            <clr-input-container class="expand mt-2">
+                <label>{{ 'editor.height' | translate }}</label>
+                <input clrInput type="text" formControlName="height" />
+            </clr-input-container>
+        </form>
+    </div>
 </div>
 
 <ng-template vdrDialogButtons>
-    <button type="submit" (click)="select()" class="btn btn-primary" [disabled]="form.invalid || !previewLoaded">
+    <button
+        type="submit"
+        (click)="select()"
+        class="btn btn-primary"
+        [disabled]="form.invalid || !previewLoaded"
+    >
         <ng-container *ngIf="existing; else doesNotExist">{{ 'common.update' | translate }}</ng-container>
         <ng-template #doesNotExist>{{ 'editor.insert-image' | translate }}</ng-template>
     </button>

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

@@ -4,7 +4,7 @@
     align-items: center;
     justify-content: center;
     max-width: 150px;
-    margin-inline-start: 12px;
+    height: 150px;
     img {
         max-width: 100%;
         display: none;

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

@@ -1,12 +1,30 @@
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import {
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    EventEmitter,
+    OnInit,
+    Output,
+} from '@angular/core';
 import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
-
+import { unique } from '@vendure/common/lib/unique';
+import { Asset } from '../../../../common/generated-types';
+import { ModalService } from '../../../../providers/modal/modal.service';
 import { Dialog } from '../../../../providers/modal/modal.types';
+import { AssetPickerDialogComponent } from '../../asset-picker-dialog/asset-picker-dialog.component';
+import { ASSET_SIZES } from '../../asset-preview-links/asset-preview-links.component';
 
 export interface ExternalImageAttrs {
     src: string;
     title: string;
     alt: string;
+    width: string;
+    height: string;
+    dataExternal: boolean;
+}
+
+export interface ExternalAssetChange {
+    assets: Asset[];
 }
 
 @Component({
@@ -17,16 +35,36 @@ export interface ExternalImageAttrs {
 })
 export class ExternalImageDialogComponent implements OnInit, Dialog<ExternalImageAttrs> {
     form: UntypedFormGroup;
+    public assets: Asset[] = [];
+    // eslint-disable-next-line @angular-eslint/no-output-native
+    @Output() change = new EventEmitter<ExternalAssetChange>();
 
     resolveWith: (result?: ExternalImageAttrs) => void;
     previewLoaded = false;
     existing?: ExternalImageAttrs;
+    sizes = ASSET_SIZES;
+    preset = '';
+
+    constructor(
+        private modalService: ModalService,
+        private changeDetector: ChangeDetectorRef,
+    ) {}
 
     ngOnInit(): void {
+        const initialSrc = this.existing?.src ? this.existing.src : '';
+
+        if (initialSrc) {
+            const url = new URL(initialSrc);
+            this.preset = url.searchParams.get('preset') || '';
+        }
+
         this.form = new UntypedFormGroup({
             src: new UntypedFormControl(this.existing ? this.existing.src : '', Validators.required),
             title: new UntypedFormControl(this.existing ? this.existing.title : ''),
             alt: new UntypedFormControl(this.existing ? this.existing.alt : ''),
+            width: new UntypedFormControl(this.existing ? this.existing.width : ''),
+            height: new UntypedFormControl(this.existing ? this.existing.height : ''),
+            dataExternal: new UntypedFormControl(this.existing ? this.existing.dataExternal : true),
         });
     }
 
@@ -41,4 +79,52 @@ export class ExternalImageDialogComponent implements OnInit, Dialog<ExternalImag
     onImageError(event: Event) {
         this.previewLoaded = false;
     }
+
+    selectAssets() {
+        this.modalService
+            .fromComponent(AssetPickerDialogComponent, {
+                size: 'xl',
+                locals: {
+                    multiSelect: false,
+                },
+            })
+            .subscribe(result => {
+                if (result && result.length) {
+                    this.assets = unique(this.assets.concat(result), 'id');
+
+                    this.form.patchValue({
+                        src: result[0].source,
+                        dataExternal: false,
+                    });
+
+                    this.form.get('src')?.disable();
+
+                    this.emitChangeEvent(this.assets);
+                    this.changeDetector.markForCheck();
+                }
+            });
+    }
+
+    private emitChangeEvent(assets: Asset[]) {
+        this.change.emit({
+            assets,
+        });
+    }
+
+    onSizeSelect(size: string) {
+        const url = this.form.get('src')?.value.split('?')[0];
+        const src = `${url}?preset=${size}`;
+
+        this.form.patchValue({
+            src,
+            width: this.form.get('width')?.value,
+            height: this.form.get('height')?.value,
+        });
+    }
+
+    removeImage() {
+        this.form.get('src')?.setValue('');
+        this.form.get('src')?.enable();
+        this.form.get('dataExternal')?.setValue(true);
+    }
 }

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

@@ -1,19 +1,6 @@
 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 { Node, NodeSpec, NodeType } from 'prosemirror-model';
+import { EditorState, NodeSelection, Plugin } from 'prosemirror-state';
 import { EditorView } from 'prosemirror-view';
 
 import { ModalService } from '../../../../../providers/modal/modal.service';
@@ -21,10 +8,42 @@ 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 { ContextMenuService } from '../context-menu/context-menu.service';
 import { canInsert, renderClarityIcon } from '../menu/menu-common';
 
+export const imageNode: NodeSpec = {
+    inline: true,
+    attrs: {
+        src: {},
+        alt: { default: null },
+        title: { default: null },
+        width: { default: null },
+        height: { default: null },
+        dataExternal: { default: true },
+    },
+    group: 'inline',
+    draggable: true,
+    parseDOM: [
+        {
+            tag: 'img[src]',
+            getAttrs(dom) {
+                return {
+                    src: (dom as HTMLImageElement).getAttribute('src'),
+                    title: (dom as HTMLImageElement).getAttribute('title'),
+                    alt: (dom as HTMLImageElement).getAttribute('alt'),
+                    width: (dom as HTMLImageElement).getAttribute('width'),
+                    height: (dom as HTMLImageElement).getAttribute('height'),
+                    dataExternal: (dom as HTMLImageElement).hasAttribute('data-external'),
+                };
+            },
+        },
+    ],
+    toDOM(node) {
+        const { src, alt, title, width, height, dataExternal } = node.attrs;
+        return ['img', { src, alt, title, width, height, 'data-external': dataExternal }];
+    },
+};
+
 export function insertImageItem(nodeType: NodeType, modalService: ModalService) {
     return new MenuItem({
         title: 'Insert image',
@@ -32,6 +51,7 @@ export function insertImageItem(nodeType: NodeType, modalService: ModalService)
         render: renderClarityIcon({ shape: 'image', label: 'Image' }),
         class: '',
         css: '',
+
         enable(state: EditorState) {
             return canInsert(state, nodeType);
         },

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

@@ -19,7 +19,7 @@ import { iframeNode, iframeNodeView, linkMark } from './custom-nodes';
 import { buildInputRules } from './inputrules';
 import { buildKeymap } from './keymap';
 import { customMenuPlugin } from './menu/menu-plugin';
-import { imageContextMenuPlugin } from './plugins/image-plugin';
+import { imageContextMenuPlugin, imageNode } 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';
@@ -40,6 +40,7 @@ export class ProsemirrorService {
     private mySchema = new Schema({
         nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block')
             .append(getTableNodes() as any)
+            .update('image', imageNode)
             .addToEnd('iframe', iframeNode),
         marks: schema.spec.marks.update('link', linkMark),
     });
@@ -50,7 +51,10 @@ export class ProsemirrorService {
      */
     private detachedDoc: Document | null = null;
 
-    constructor(private injector: Injector, private contextMenuService: ContextMenuService) {}
+    constructor(
+        private injector: Injector,
+        private contextMenuService: ContextMenuService,
+    ) {}
 
     contextMenuItems$: Observable<string>;
 

+ 6 - 2
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -5,6 +5,7 @@
   "asset": {
     "add-asset": "Add asset",
     "add-asset-with-count": "Add {count, plural, =0 {assets} one {1 asset} other {{count} assets}}",
+    "change-asset": "Change asset",
     "assets-selected-count": "{ count } assets selected",
     "dimensions": "Dimensions",
     "focal-point": "Focal point",
@@ -22,7 +23,8 @@
     "update-focal-point-error": "Could not update focal point",
     "update-focal-point-success": "Updated focal point",
     "upload-assets": "Upload assets",
-    "uploading": "Uploading..."
+    "uploading": "Uploading...",
+    "size": "Size"
   },
   "breadcrumb": {
     "administrators": "Administrators",
@@ -483,7 +485,9 @@
     "link-target": "Link target",
     "link-title": "Link title",
     "remove-link": "Remove",
-    "set-link": "Set link"
+    "set-link": "Set link",
+    "width": "Width",
+    "height": "Height"
   },
   "error": {
     "403-forbidden": "You are not currently authorized to access \"{ path }\". Either you lack permissions, or your session has expired.",

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -5,6 +5,7 @@
   "asset": {
     "add-asset": "Adicionar imagens",
     "add-asset-with-count": "Adiciona {count, plural, =0 {assets} one {1 asset} other {{count} assets}}",
+    "change-asset": "Mudar imagem",
     "assets-selected-count": "{ count } imagens selecionadas",
     "dimensions": "Dimensões",
     "focal-point": "Ponto central",

+ 2 - 0
packages/admin-ui/src/lib/static/styles/component/prosemirror.scss

@@ -51,6 +51,8 @@ label.rich-text-label {
     img {
         cursor: default;
         max-width: 100%;
+        width: revert-layer;
+        height: revert-layer;
     }
 
     a:link,