Skip to content

Commit 18e5ab9

Browse files
feat(admin-ui): Integrate Vendure Assets Picker with ProseMirror and add single image selection (#3033)
1 parent 51671f0 commit 18e5ab9

File tree

11 files changed

+231
-54
lines changed

11 files changed

+231
-54
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { debounceTime, delay, finalize, map, take as rxjsTake, takeUntil, tap }
1313

1414
import {
1515
Asset,
16-
CreateAssetsMutation,
1716
GetAssetListQuery,
1817
GetAssetListQueryVariables,
1918
LogicalOperator,
@@ -79,7 +78,10 @@ export class AssetPickerDialogComponent implements OnInit, AfterViewInit, OnDest
7978
private listQuery: QueryResult<GetAssetListQuery, GetAssetListQueryVariables>;
8079
private destroy$ = new Subject<void>();
8180

82-
constructor(private dataService: DataService, private notificationService: NotificationService) {}
81+
constructor(
82+
private dataService: DataService,
83+
private notificationService: NotificationService,
84+
) {}
8385

8486
ngOnInit() {
8587
this.listQuery = this.dataService.product.getAssetList(this.paginationConfig.itemsPerPage, 0);

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
22

33
import { AssetLike } from '../asset-gallery/asset-gallery.types';
44

5+
export const ASSET_SIZES = ['tiny', 'thumb', 'small', 'medium', 'large', 'full'];
6+
57
@Component({
68
selector: 'vdr-asset-preview-links',
79
templateUrl: './asset-preview-links.component.html',
@@ -10,5 +12,5 @@ import { AssetLike } from '../asset-gallery/asset-gallery.types';
1012
})
1113
export class AssetPreviewLinksComponent {
1214
@Input() asset: AssetLike;
13-
sizes = ['tiny', 'thumb', 'small', 'medium', 'large', 'full'];
15+
sizes = ASSET_SIZES;
1416
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export class AssetsComponent {
5050
@Input()
5151
updatePermissions: string | string[] | Permission | Permission[];
5252

53+
@Input() multiSelect = true;
54+
5355
constructor(
5456
private modalService: ModalService,
5557
private changeDetector: ChangeDetectorRef,
@@ -59,11 +61,14 @@ export class AssetsComponent {
5961
this.modalService
6062
.fromComponent(AssetPickerDialogComponent, {
6163
size: 'xl',
64+
locals: {
65+
multiSelect: this.multiSelect,
66+
},
6267
})
6368
.subscribe(result => {
6469
if (result && result.length) {
65-
this.assets = unique(this.assets.concat(result), 'id');
66-
if (!this.featuredAsset) {
70+
this.assets = this.multiSelect ? unique(this.assets.concat(result), 'id') : result;
71+
if (!this.featuredAsset || !this.multiSelect) {
6772
this.featuredAsset = result[0];
6873
}
6974
this.emitChangeEvent(this.assets, this.featuredAsset);

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

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,84 @@
1-
<div class="flex">
2-
<form [formGroup]="form" class="flex-spacer" clrForm clrLayout="vertical">
3-
<clr-input-container class="expand">
4-
<label>{{ 'editor.image-src' | translate }}</label>
5-
<input clrInput type="text" formControlName="src" />
6-
</clr-input-container>
7-
<clr-input-container class="expand">
8-
<label>{{ 'editor.image-title' | translate }}</label>
9-
<input clrInput type="text" formControlName="title" />
10-
</clr-input-container>
11-
<clr-input-container class="expand">
12-
<label>{{ 'editor.image-alt' | translate }}</label>
13-
<input clrInput type="text" formControlName="alt" />
14-
</clr-input-container>
15-
</form>
16-
<div class="preview">
17-
<img
18-
[src]="form.get('src')?.value"
19-
[class.visible]="previewLoaded"
20-
(load)="onImageLoad($event)"
21-
(error)="onImageError($event)"
22-
/>
23-
<div class="placeholder" *ngIf="!previewLoaded">
24-
<clr-icon shape="image" size="128"></clr-icon>
1+
<div class="clr-row">
2+
<div class="clr-col-md-5 clr-row clr-justify-content-center">
3+
<div class="preview text-center clr-col-12 mt-10">
4+
<vdr-dropdown>
5+
<img
6+
[src]="form.get('src')?.value"
7+
[class.visible]="previewLoaded"
8+
vdrDropdownTrigger
9+
(load)="onImageLoad($event)"
10+
(error)="onImageError($event)"
11+
class="img-responsive"
12+
/>
13+
14+
<vdr-dropdown-menu vdrPosition="bottom-right">
15+
<button
16+
vdrDropdownItem
17+
[title]="'asset.remove-asset' | translate"
18+
(click)="removeImage()"
19+
>
20+
<clr-icon shape="times"></clr-icon>
21+
{{ 'asset.remove-asset' | translate }}
22+
</button>
23+
</vdr-dropdown-menu>
24+
</vdr-dropdown>
25+
26+
<div class="placeholder" *ngIf="!previewLoaded">
27+
<clr-icon shape="image" size="128"></clr-icon>
28+
</div>
29+
</div>
30+
<div class="text-center clr-col-12">
31+
<div *ngIf="previewLoaded && !form.get('dataExternal')?.value">
32+
<select name="options" (change)="onSizeSelect($event.target.value)" [(ngModel)]="preset">
33+
<option value="" selected>{{ 'asset.size' | translate }}</option>
34+
<option *ngFor="let size of sizes" [value]="size">{{ size }}</option>
35+
</select>
36+
</div>
37+
38+
<button
39+
class="btn btn-icon btn-sm btn-block mt-2"
40+
[title]="(!previewLoaded ? 'asset.add-asset' : 'asset.change-asset') | translate"
41+
(click)="selectAssets()"
42+
>
43+
<clr-icon shape="attachment"></clr-icon>
44+
{{ (!previewLoaded ? 'asset.add-asset' : 'asset.change-asset') | translate }}
45+
</button>
2546
</div>
2647
</div>
48+
49+
<div class="clr-col">
50+
<form [formGroup]="form" class="flex-spacer" clrForm clrLayout="vertical">
51+
<clr-input-container class="expand">
52+
<label>{{ 'editor.image-src' | translate }}</label>
53+
<input clrInput type="text" formControlName="src" />
54+
</clr-input-container>
55+
<clr-input-container class="expand mt-2">
56+
<label>{{ 'editor.image-title' | translate }}</label>
57+
<input clrInput type="text" formControlName="title" />
58+
</clr-input-container>
59+
<clr-input-container class="expand mt-2">
60+
<label>{{ 'editor.image-alt' | translate }}</label>
61+
<input clrInput type="text" formControlName="alt" />
62+
</clr-input-container>
63+
<clr-input-container class="expand mt-2">
64+
<label>{{ 'editor.width' | translate }}</label>
65+
<input clrInput type="text" formControlName="width" />
66+
</clr-input-container>
67+
<clr-input-container class="expand mt-2">
68+
<label>{{ 'editor.height' | translate }}</label>
69+
<input clrInput type="text" formControlName="height" />
70+
</clr-input-container>
71+
</form>
72+
</div>
2773
</div>
2874

2975
<ng-template vdrDialogButtons>
30-
<button type="submit" (click)="select()" class="btn btn-primary" [disabled]="form.invalid || !previewLoaded">
76+
<button
77+
type="submit"
78+
(click)="select()"
79+
class="btn btn-primary"
80+
[disabled]="form.invalid || !previewLoaded"
81+
>
3182
<ng-container *ngIf="existing; else doesNotExist">{{ 'common.update' | translate }}</ng-container>
3283
<ng-template #doesNotExist>{{ 'editor.insert-image' | translate }}</ng-template>
3384
</button>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
align-items: center;
55
justify-content: center;
66
max-width: 150px;
7-
margin-inline-start: 12px;
7+
height: 150px;
88
img {
99
max-width: 100%;
1010
display: none;

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

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1-
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
1+
import {
2+
ChangeDetectionStrategy,
3+
ChangeDetectorRef,
4+
Component,
5+
EventEmitter,
6+
OnInit,
7+
Output,
8+
} from '@angular/core';
29
import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
3-
10+
import { unique } from '@vendure/common/lib/unique';
11+
import { Asset } from '../../../../common/generated-types';
12+
import { ModalService } from '../../../../providers/modal/modal.service';
413
import { Dialog } from '../../../../providers/modal/modal.types';
14+
import { AssetPickerDialogComponent } from '../../asset-picker-dialog/asset-picker-dialog.component';
15+
import { ASSET_SIZES } from '../../asset-preview-links/asset-preview-links.component';
516

617
export interface ExternalImageAttrs {
718
src: string;
819
title: string;
920
alt: string;
21+
width: string;
22+
height: string;
23+
dataExternal: boolean;
24+
}
25+
26+
export interface ExternalAssetChange {
27+
assets: Asset[];
1028
}
1129

1230
@Component({
@@ -17,16 +35,36 @@ export interface ExternalImageAttrs {
1735
})
1836
export class ExternalImageDialogComponent implements OnInit, Dialog<ExternalImageAttrs> {
1937
form: UntypedFormGroup;
38+
public assets: Asset[] = [];
39+
// eslint-disable-next-line @angular-eslint/no-output-native
40+
@Output() change = new EventEmitter<ExternalAssetChange>();
2041

2142
resolveWith: (result?: ExternalImageAttrs) => void;
2243
previewLoaded = false;
2344
existing?: ExternalImageAttrs;
45+
sizes = ASSET_SIZES;
46+
preset = '';
47+
48+
constructor(
49+
private modalService: ModalService,
50+
private changeDetector: ChangeDetectorRef,
51+
) {}
2452

2553
ngOnInit(): void {
54+
const initialSrc = this.existing?.src ? this.existing.src : '';
55+
56+
if (initialSrc) {
57+
const url = new URL(initialSrc);
58+
this.preset = url.searchParams.get('preset') || '';
59+
}
60+
2661
this.form = new UntypedFormGroup({
2762
src: new UntypedFormControl(this.existing ? this.existing.src : '', Validators.required),
2863
title: new UntypedFormControl(this.existing ? this.existing.title : ''),
2964
alt: new UntypedFormControl(this.existing ? this.existing.alt : ''),
65+
width: new UntypedFormControl(this.existing ? this.existing.width : ''),
66+
height: new UntypedFormControl(this.existing ? this.existing.height : ''),
67+
dataExternal: new UntypedFormControl(this.existing ? this.existing.dataExternal : true),
3068
});
3169
}
3270

@@ -41,4 +79,52 @@ export class ExternalImageDialogComponent implements OnInit, Dialog<ExternalImag
4179
onImageError(event: Event) {
4280
this.previewLoaded = false;
4381
}
82+
83+
selectAssets() {
84+
this.modalService
85+
.fromComponent(AssetPickerDialogComponent, {
86+
size: 'xl',
87+
locals: {
88+
multiSelect: false,
89+
},
90+
})
91+
.subscribe(result => {
92+
if (result && result.length) {
93+
this.assets = unique(this.assets.concat(result), 'id');
94+
95+
this.form.patchValue({
96+
src: result[0].source,
97+
dataExternal: false,
98+
});
99+
100+
this.form.get('src')?.disable();
101+
102+
this.emitChangeEvent(this.assets);
103+
this.changeDetector.markForCheck();
104+
}
105+
});
106+
}
107+
108+
private emitChangeEvent(assets: Asset[]) {
109+
this.change.emit({
110+
assets,
111+
});
112+
}
113+
114+
onSizeSelect(size: string) {
115+
const url = this.form.get('src')?.value.split('?')[0];
116+
const src = `${url}?preset=${size}`;
117+
118+
this.form.patchValue({
119+
src,
120+
width: this.form.get('width')?.value,
121+
height: this.form.get('height')?.value,
122+
});
123+
}
124+
125+
removeImage() {
126+
this.form.get('src')?.setValue('');
127+
this.form.get('src')?.enable();
128+
this.form.get('dataExternal')?.setValue(true);
129+
}
44130
}

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

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,57 @@
11
import { MenuItem } from 'prosemirror-menu';
2-
import { Node, NodeType } from 'prosemirror-model';
3-
import { EditorState, NodeSelection, Plugin, Transaction } from 'prosemirror-state';
4-
import {
5-
addColumnAfter,
6-
addColumnBefore,
7-
addRowAfter,
8-
addRowBefore,
9-
deleteColumn,
10-
deleteRow,
11-
deleteTable,
12-
mergeCells,
13-
splitCell,
14-
toggleHeaderColumn,
15-
toggleHeaderRow,
16-
} from 'prosemirror-tables';
2+
import { Node, NodeSpec, NodeType } from 'prosemirror-model';
3+
import { EditorState, NodeSelection, Plugin } from 'prosemirror-state';
174
import { EditorView } from 'prosemirror-view';
185

196
import { ModalService } from '../../../../../providers/modal/modal.service';
207
import {
218
ExternalImageAttrs,
229
ExternalImageDialogComponent,
2310
} from '../../external-image-dialog/external-image-dialog.component';
24-
import { RawHtmlDialogComponent } from '../../raw-html-dialog/raw-html-dialog.component';
25-
import { ContextMenuItem, ContextMenuService } from '../context-menu/context-menu.service';
11+
import { ContextMenuService } from '../context-menu/context-menu.service';
2612
import { canInsert, renderClarityIcon } from '../menu/menu-common';
2713

14+
export const imageNode: NodeSpec = {
15+
inline: true,
16+
attrs: {
17+
src: {},
18+
alt: { default: null },
19+
title: { default: null },
20+
width: { default: null },
21+
height: { default: null },
22+
dataExternal: { default: true },
23+
},
24+
group: 'inline',
25+
draggable: true,
26+
parseDOM: [
27+
{
28+
tag: 'img[src]',
29+
getAttrs(dom) {
30+
return {
31+
src: (dom as HTMLImageElement).getAttribute('src'),
32+
title: (dom as HTMLImageElement).getAttribute('title'),
33+
alt: (dom as HTMLImageElement).getAttribute('alt'),
34+
width: (dom as HTMLImageElement).getAttribute('width'),
35+
height: (dom as HTMLImageElement).getAttribute('height'),
36+
dataExternal: (dom as HTMLImageElement).hasAttribute('data-external'),
37+
};
38+
},
39+
},
40+
],
41+
toDOM(node) {
42+
const { src, alt, title, width, height, dataExternal } = node.attrs;
43+
return ['img', { src, alt, title, width, height, 'data-external': dataExternal }];
44+
},
45+
};
46+
2847
export function insertImageItem(nodeType: NodeType, modalService: ModalService) {
2948
return new MenuItem({
3049
title: 'Insert image',
3150
label: 'Image',
3251
render: renderClarityIcon({ shape: 'image', label: 'Image' }),
3352
class: '',
3453
css: '',
54+
3555
enable(state: EditorState) {
3656
return canInsert(state, nodeType);
3757
},

0 commit comments

Comments
 (0)