Skip to content

Commit

Permalink
feat(admin-ui): Integrate Vendure Assets Picker with ProseMirror and …
Browse files Browse the repository at this point in the history
…add single image selection (#3033)
  • Loading branch information
dfernandesbsolus authored Sep 12, 2024
1 parent 51671f0 commit 18e5ab9
Show file tree
Hide file tree
Showing 11 changed files with 231 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { debounceTime, delay, finalize, map, take as rxjsTake, takeUntil, tap }

import {
Asset,
CreateAssetsMutation,
GetAssetListQuery,
GetAssetListQueryVariables,
LogicalOperator,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export class AssetsComponent {
@Input()
updatePermissions: string | string[] | Permission | Permission[];

@Input() multiSelect = true;

constructor(
private modalService: ModalService,
private changeDetector: ChangeDetectorRef,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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),
});
}

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,57 @@
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';
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',
label: 'Image',
render: renderClarityIcon({ shape: 'image', label: 'Image' }),
class: '',
css: '',

enable(state: EditorState) {
return canInsert(state, nodeType);
},
Expand Down
Loading

0 comments on commit 18e5ab9

Please sign in to comment.