From 18e5ab99e23f003879356ae1c9b4ed2c5b118d97 Mon Sep 17 00:00:00 2001 From: dfernandesbsolus <96054351+dfernandesbsolus@users.noreply.github.com> Date: Thu, 12 Sep 2024 09:04:35 +0200 Subject: [PATCH] feat(admin-ui): Integrate Vendure Assets Picker with ProseMirror and add single image selection (#3033) --- .../asset-picker-dialog.component.ts | 6 +- .../asset-preview-links.component.ts | 4 +- .../components/assets/assets.component.ts | 9 +- .../external-image-dialog.component.html | 101 +++++++++++++----- .../external-image-dialog.component.scss | 2 +- .../external-image-dialog.component.ts | 90 +++++++++++++++- .../prosemirror/plugins/image-plugin.ts | 54 +++++++--- .../prosemirror/prosemirror.service.ts | 8 +- .../src/lib/static/i18n-messages/en.json | 8 +- .../src/lib/static/i18n-messages/pt_PT.json | 1 + .../static/styles/component/prosemirror.scss | 2 + 11 files changed, 231 insertions(+), 54 deletions(-) diff --git a/packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts index f0244f5c5f..a58c5dbed7 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.ts +++ b/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; private destroy$ = new Subject(); - 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); diff --git a/packages/admin-ui/src/lib/core/src/shared/components/asset-preview-links/asset-preview-links.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/asset-preview-links/asset-preview-links.component.ts index 32a64ad64c..ccb5625953 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/asset-preview-links/asset-preview-links.component.ts +++ b/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; } diff --git a/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts index 2b2fab4064..452019cd8d 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/assets/assets.component.ts +++ b/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); diff --git a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html b/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html index fa7f5088a2..2ba637e8e7 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html +++ b/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.html @@ -1,33 +1,84 @@ -
-
- - - - - - - - - - - - -
-
- -
- +
+
+
+ + + + + + + + +
+ +
+
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
+
- diff --git a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.scss b/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.scss index 094b13a702..368962a2d6 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.scss +++ b/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; diff --git a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.ts index b837d6dac5..6140611896 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/external-image-dialog/external-image-dialog.component.ts +++ b/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 { form: UntypedFormGroup; + public assets: Asset[] = []; + // eslint-disable-next-line @angular-eslint/no-output-native + @Output() change = new EventEmitter(); 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 { + 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); + } } diff --git a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/image-plugin.ts b/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/image-plugin.ts index 4fa06a4764..c32bc1a47a 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/plugins/image-plugin.ts +++ b/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); }, diff --git a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts b/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts index 983c214457..c89198a7aa 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/rich-text-editor/prosemirror/prosemirror.service.ts +++ b/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; diff --git a/packages/admin-ui/src/lib/static/i18n-messages/en.json b/packages/admin-ui/src/lib/static/i18n-messages/en.json index 11d1f40540..acc24a5502 100644 --- a/packages/admin-ui/src/lib/static/i18n-messages/en.json +++ b/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.", diff --git a/packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json b/packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json index f1f57e013a..21b64d6fe2 100644 --- a/packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json +++ b/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", diff --git a/packages/admin-ui/src/lib/static/styles/component/prosemirror.scss b/packages/admin-ui/src/lib/static/styles/component/prosemirror.scss index 59ca1d6adf..a96248d221 100644 --- a/packages/admin-ui/src/lib/static/styles/component/prosemirror.scss +++ b/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,