Browse Source

feat(admin-ui): Implement editing of Asset focal point

Relates to #93
Michael Bromley 6 years ago
parent
commit
11b6b33a4f
22 changed files with 587 additions and 93 deletions
  1. 5 2
      packages/admin-ui/src/app/catalog/catalog.module.ts
  2. 29 3
      packages/admin-ui/src/app/catalog/catalog.routes.ts
  3. 22 0
      packages/admin-ui/src/app/catalog/components/asset-detail/asset-detail.component.html
  4. 5 0
      packages/admin-ui/src/app/catalog/components/asset-detail/asset-detail.component.scss
  5. 72 0
      packages/admin-ui/src/app/catalog/components/asset-detail/asset-detail.component.ts
  6. 30 0
      packages/admin-ui/src/app/catalog/providers/routing/asset-resolver.ts
  7. 13 0
      packages/admin-ui/src/app/common/generated-types.ts
  8. 13 0
      packages/admin-ui/src/app/data/definitions/product-definitions.ts
  9. 8 0
      packages/admin-ui/src/app/data/providers/product-data.service.ts
  10. 2 2
      packages/admin-ui/src/app/shared/components/asset-gallery/asset-gallery.component.html
  11. 5 5
      packages/admin-ui/src/app/shared/components/asset-preview-dialog/asset-preview-dialog.component.html
  12. 6 0
      packages/admin-ui/src/app/shared/components/asset-preview-dialog/asset-preview-dialog.component.scss
  13. 1 22
      packages/admin-ui/src/app/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts
  14. 76 16
      packages/admin-ui/src/app/shared/components/asset-preview/asset-preview.component.html
  15. 36 16
      packages/admin-ui/src/app/shared/components/asset-preview/asset-preview.component.scss
  16. 123 22
      packages/admin-ui/src/app/shared/components/asset-preview/asset-preview.component.ts
  17. 14 0
      packages/admin-ui/src/app/shared/components/focal-point-control/focal-point-control.component.html
  18. 46 0
      packages/admin-ui/src/app/shared/components/focal-point-control/focal-point-control.component.scss
  19. 66 0
      packages/admin-ui/src/app/shared/components/focal-point-control/focal-point-control.component.ts
  20. 1 0
      packages/admin-ui/src/app/shared/shared-declarations.ts
  21. 4 0
      packages/admin-ui/src/app/shared/shared.module.ts
  22. 10 5
      packages/admin-ui/src/i18n-messages/en.json

+ 5 - 2
packages/admin-ui/src/app/catalog/catalog.module.ts

@@ -1,4 +1,3 @@
-import { DragDropModule } from '@angular/cdk/drag-drop';
 import { NgModule } from '@angular/core';
 import { RouterModule } from '@angular/router';
 
@@ -6,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
 
 import { catalogRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
+import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { AssignProductsToChannelDialogComponent } from './components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { CollectionContentsComponent } from './components/collection-contents/collection-contents.component';
@@ -27,13 +27,14 @@ import { ProductVariantsTableComponent } from './components/product-variants-tab
 import { UpdateProductOptionDialogComponent } from './components/update-product-option-dialog/update-product-option-dialog.component';
 import { VariantPriceDetailComponent } from './components/variant-price-detail/variant-price-detail.component';
 import { ProductDetailService } from './providers/product-detail.service';
+import { AssetResolver } from './providers/routing/asset-resolver';
 import { CollectionResolver } from './providers/routing/collection-resolver';
 import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
 import { ProductVariantsResolver } from './providers/routing/product-variants-resolver';
 
 @NgModule({
-    imports: [SharedModule, RouterModule.forChild(catalogRoutes), DragDropModule],
+    imports: [SharedModule, RouterModule.forChild(catalogRoutes)],
     exports: [],
     declarations: [
         ProductListComponent,
@@ -57,6 +58,7 @@ import { ProductVariantsResolver } from './providers/routing/product-variants-re
         UpdateProductOptionDialogComponent,
         ProductVariantsEditorComponent,
         AssignProductsToChannelDialogComponent,
+        AssetDetailComponent,
     ],
     entryComponents: [
         ApplyFacetDialogComponent,
@@ -69,6 +71,7 @@ import { ProductVariantsResolver } from './providers/routing/product-variants-re
         CollectionResolver,
         ProductDetailService,
         ProductVariantsResolver,
+        AssetResolver,
     ],
 })
 export class CatalogModule {}

+ 29 - 3
packages/admin-ui/src/app/catalog/catalog.routes.ts

@@ -4,11 +4,18 @@ import { map } from 'rxjs/operators';
 
 import { createResolveData } from '../common/base-entity-resolver';
 import { detailBreadcrumb } from '../common/detail-breadcrumb';
-import { FacetWithValues, OrderDetail, ProductWithVariants } from '../common/generated-types';
+import {
+    Asset,
+    Collection,
+    FacetWithValues,
+    OrderDetail,
+    ProductWithVariants,
+} from '../common/generated-types';
 import { BreadcrumbValue } from '../core/components/breadcrumb/breadcrumb.component';
 import { _ } from '../core/providers/i18n/mark-for-extraction';
 import { CanDeactivateDetailGuard } from '../shared/providers/routing/can-deactivate-detail-guard';
 
+import { AssetDetailComponent } from './components/asset-detail/asset-detail.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { CollectionDetailComponent } from './components/collection-detail/collection-detail.component';
 import { CollectionListComponent } from './components/collection-list/collection-list.component';
@@ -17,6 +24,7 @@ import { FacetListComponent } from './components/facet-list/facet-list.component
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
+import { AssetResolver } from './providers/routing/asset-resolver';
 import { CollectionResolver } from './providers/routing/collection-resolver';
 import { FacetResolver } from './providers/routing/facet-resolver';
 import { ProductResolver } from './providers/routing/product-resolver';
@@ -87,6 +95,14 @@ export const catalogRoutes: Route[] = [
             breadcrumb: _('breadcrumb.assets'),
         },
     },
+    {
+        path: 'assets/:id',
+        component: AssetDetailComponent,
+        resolve: createResolveData(AssetResolver),
+        data: {
+            breadcrumb: assetBreadcrumb,
+        },
+    },
 ];
 
 export function productBreadcrumb(data: any, params: any) {
@@ -131,11 +147,21 @@ export function facetBreadcrumb(data: any, params: any) {
 }
 
 export function collectionBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<FacetWithValues.Fragment>({
+    return detailBreadcrumb<Collection.Fragment>({
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.collections',
-        getName: facet => facet.name,
+        getName: collection => collection.name,
         route: 'collections',
     });
 }
+
+export function assetBreadcrumb(data: any, params: any) {
+    return detailBreadcrumb<Asset.Fragment>({
+        entity: data.entity,
+        id: params.id,
+        breadcrumbKey: 'breadcrumb.assets',
+        getName: asset => asset.name,
+        route: 'assets',
+    });
+}

+ 22 - 0
packages/admin-ui/src/app/catalog/components/asset-detail/asset-detail.component.html

@@ -0,0 +1,22 @@
+<vdr-action-bar>
+    <vdr-ab-left>
+        <vdr-entity-info [entity]="entity$ | async"></vdr-entity-info>
+    </vdr-ab-left>
+
+    <vdr-ab-right>
+        <vdr-action-bar-items locationId="asset-detail"></vdr-action-bar-items>
+        <button
+            *vdrIfPermissions="'UpdateCatalog'"
+            class="btn btn-primary"
+            (click)="save()"
+            [disabled]="detailForm.invalid || detailForm.pristine"
+        >
+            {{ 'common.update' | translate }}
+        </button>
+    </vdr-ab-right>
+</vdr-action-bar>
+<vdr-asset-preview
+    [asset]="entity$ | async"
+    [editable]="true"
+    (assetChange)="onAssetChange($event)"
+></vdr-asset-preview>

+ 5 - 0
packages/admin-ui/src/app/catalog/components/asset-detail/asset-detail.component.scss

@@ -0,0 +1,5 @@
+:host {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+}

+ 72 - 0
packages/admin-ui/src/app/catalog/components/asset-detail/asset-detail.component.ts

@@ -0,0 +1,72 @@
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { BaseDetailComponent } from '../../../common/base-detail.component';
+import { Asset, LanguageCode } from '../../../common/generated-types';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { ServerConfigService } from '../../../data/server-config';
+
+@Component({
+    selector: 'vdr-asset-detail',
+    templateUrl: './asset-detail.component.html',
+    styleUrls: ['./asset-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> implements OnInit, OnDestroy {
+    detailForm = new FormGroup({});
+
+    constructor(
+        router: Router,
+        route: ActivatedRoute,
+        serverConfigService: ServerConfigService,
+        private notificationService: NotificationService,
+        private dataService: DataService,
+        private formBuilder: FormBuilder,
+    ) {
+        super(route, router, serverConfigService);
+    }
+
+    ngOnInit() {
+        this.detailForm = new FormGroup({
+            name: new FormControl(''),
+        });
+        this.init();
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    onAssetChange(event: { id: string; name: string }) {
+        // tslint:disable-next-line:no-non-null-assertion
+        this.detailForm.get('name')!.setValue(event.name);
+        // tslint:disable-next-line:no-non-null-assertion
+        this.detailForm.get('name')!.markAsDirty();
+    }
+
+    save() {
+        this.dataService.product
+            .updateAsset({
+                id: this.id,
+                name: this.detailForm.value.name,
+            })
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('common.notify-update-success'), { entity: 'Asset' });
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'Asset',
+                    });
+                },
+            );
+    }
+
+    protected setFormValues(entity: Asset.Fragment, languageCode: LanguageCode): void {
+        // tslint:disable-next-line:no-non-null-assertion
+        this.detailForm.get('name')!.setValue(entity.name);
+    }
+}

+ 30 - 0
packages/admin-ui/src/app/catalog/providers/routing/asset-resolver.ts

@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { BaseEntityResolver } from '../../../common/base-entity-resolver';
+import { Asset, AssetType } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+
+@Injectable()
+export class AssetResolver extends BaseEntityResolver<Asset.Fragment> {
+    constructor(router: Router, dataService: DataService) {
+        super(
+            router,
+            {
+                __typename: 'Asset' as const,
+                id: '',
+                createdAt: '',
+                updatedAt: '',
+                name: '',
+                type: AssetType.IMAGE,
+                fileSize: 0,
+                mimeType: '',
+                width: 0,
+                height: 0,
+                source: '',
+                preview: '',
+            },
+            id => dataService.product.getAsset(id).mapStream(data => data.asset),
+        );
+    }
+}

+ 13 - 0
packages/admin-ui/src/app/common/generated-types.ts

@@ -4138,6 +4138,13 @@ export type GetAssetListQueryVariables = {
 
 export type GetAssetListQuery = ({ __typename?: 'Query' } & { assets: ({ __typename?: 'AssetList' } & Pick<AssetList, 'totalItems'> & { items: Array<({ __typename?: 'Asset' } & AssetFragment)> }) });
 
+export type GetAssetQueryVariables = {
+  id: Scalars['ID']
+};
+
+
+export type GetAssetQuery = ({ __typename?: 'Query' } & { asset: Maybe<({ __typename?: 'Asset' } & AssetFragment)> });
+
 export type CreateAssetsMutationVariables = {
   input: Array<CreateAssetInput>
 };
@@ -5147,6 +5154,12 @@ export namespace GetAssetList {
   export type Items = AssetFragment;
 }
 
+export namespace GetAsset {
+  export type Variables = GetAssetQueryVariables;
+  export type Query = GetAssetQuery;
+  export type Asset = AssetFragment;
+}
+
 export namespace CreateAssets {
   export type Variables = CreateAssetsMutationVariables;
   export type Mutation = CreateAssetsMutation;

+ 13 - 0
packages/admin-ui/src/app/data/definitions/product-definitions.ts

@@ -13,6 +13,10 @@ export const ASSET_FRAGMENT = gql`
         source
         width
         height
+        focalPoint {
+            x
+            y
+        }
     }
 `;
 
@@ -335,6 +339,15 @@ export const GET_ASSET_LIST = gql`
     ${ASSET_FRAGMENT}
 `;
 
+export const GET_ASSET = gql`
+    query GetAsset($id: ID!) {
+        asset(id: $id) {
+            ...Asset
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;
+
 export const CREATE_ASSETS = gql`
     mutation CreateAssets($input: [CreateAssetInput!]!) {
         createAssets(input: $input) {

+ 8 - 0
packages/admin-ui/src/app/data/providers/product-data.service.ts

@@ -15,6 +15,7 @@ import {
     CreateProductVariants,
     DeleteProduct,
     DeleteProductVariant,
+    GetAsset,
     GetAssetList,
     GetProductList,
     GetProductOptionGroup,
@@ -46,6 +47,7 @@ import {
     CREATE_PRODUCT_VARIANTS,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
+    GET_ASSET,
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
     GET_PRODUCT_OPTION_GROUP,
@@ -262,6 +264,12 @@ export class ProductDataService {
         });
     }
 
+    getAsset(id: string) {
+        return this.baseDataService.query<GetAsset.Query, GetAsset.Variables>(GET_ASSET, {
+            id,
+        });
+    }
+
     createAssets(files: File[]) {
         return this.baseDataService.mutate<CreateAssets.Mutation, CreateAssets.Variables>(CREATE_ASSETS, {
             input: files.map(file => ({ file })),

+ 2 - 2
packages/admin-ui/src/app/shared/components/asset-gallery/asset-gallery.component.html

@@ -43,8 +43,8 @@
                 </button>
             </div>
             <div>
-                <a [href]="lastSelected().source" target="_blank" class="btn btn-link">
-                    {{ 'asset.open-asset-source' | translate }}
+                <a [routerLink]="['./', lastSelected().id]" class="btn btn-link">
+                    {{ 'common.edit' | translate }}
                 </a>
             </div>
         </div>

+ 5 - 5
packages/admin-ui/src/app/shared/components/asset-preview-dialog/asset-preview-dialog.component.html

@@ -4,8 +4,8 @@
     </div>
 </ng-template>
 
-<vdr-asset-preview [asset]="asset" (assetChange)="assetChanges = $event"></vdr-asset-preview>
-
-<ng-template vdrDialogButtons>
-    <button class="btn btn-primary" [disabled]="!assetChanges" (click)="updateAsset()">{{ 'common.update' | translate }}</button>
-</ng-template>
+<vdr-asset-preview
+    [asset]="asset"
+    (assetChange)="assetChanges = $event"
+    (editClick)="resolveWith()"
+></vdr-asset-preview>

+ 6 - 0
packages/admin-ui/src/app/shared/components/asset-preview-dialog/asset-preview-dialog.component.scss

@@ -1,3 +1,9 @@
 :host {
     height: 70vh;
 }
+
+.update-button {
+    &.hidden {
+        visibility: hidden;
+    }
+}

+ 1 - 22
packages/admin-ui/src/app/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts

@@ -1,9 +1,6 @@
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
-import { _ } from '@vendure/admin-ui/src/app/core/providers/i18n/mark-for-extraction';
+import { ChangeDetectionStrategy, Component } from '@angular/core';
 
 import { Asset, UpdateAssetInput } from '../../../common/generated-types';
-import { NotificationService } from '../../../core/providers/notification/notification.service';
-import { DataService } from '../../../data/providers/data.service';
 import { Dialog } from '../../providers/modal/modal.service';
 
 @Component({
@@ -16,22 +13,4 @@ export class AssetPreviewDialogComponent implements Dialog<void> {
     asset: Asset;
     assetChanges?: UpdateAssetInput;
     resolveWith: (result?: void) => void;
-
-    constructor(private dataService: DataService, private notificationService: NotificationService) {}
-
-    updateAsset() {
-        if (this.assetChanges) {
-            this.dataService.product.updateAsset(this.assetChanges).subscribe(
-                () => {
-                    this.assetChanges = undefined;
-                    this.notificationService.success(_('common.notify-update-success'), { entity: 'Asset' });
-                },
-                err => {
-                    this.notificationService.error(_('common.notify-update-error'), {
-                        entity: 'Asset',
-                    });
-                },
-            );
-        }
-    }
 }

+ 76 - 16
packages/admin-ui/src/app/shared/components/asset-preview/asset-preview.component.html

@@ -1,36 +1,89 @@
 <div class="preview-image" #previewDiv [class.centered]="centered">
-    <img class="" [src]="asset.preview + '?preset=' + size" #imageElement (load)="getDimensions()" />
+    <div class="image-wrapper">
+        <vdr-focal-point-control
+            [width]="width"
+            [height]="height"
+            [fpx]="fpx"
+            [fpy]="fpy"
+            [editable]="settingFocalPoint"
+            (focalPointChange)="onFocalPointChange($event)"
+        >
+            <img class="" [src]="asset.preview + '?preset=' + size" #imageElement (load)="getDimensions()" />
+        </vdr-focal-point-control>
+        <div class="focal-point-info" *ngIf="settingFocalPoint">
+            <button class="icon-button" (click)="setFocalPointCancel()">
+                <clr-icon shape="times"></clr-icon>
+            </button>
+            <button class="btn btn-primary btn-sm" (click)="setFocalPointEnd()" [disabled]="!lastFocalPoint">
+                <clr-icon shape="crosshairs"></clr-icon>
+                {{ 'asset.set-focal-point' | translate }}
+            </button>
+        </div>
+    </div>
 </div>
 
-<div class="controls">
+<div class="controls" [class.fade]="settingFocalPoint">
     <form [formGroup]="form">
-        <clr-input-container>
+        <clr-input-container class="name-input" *ngIf="editable">
             <label>{{ 'common.name' | translate }}</label>
             <input
                 clrInput
                 type="text"
                 formControlName="name"
-                [readonly]="!('UpdateCatalog' | hasPermission)"
+                [readonly]="!('UpdateCatalog' | hasPermission) || settingFocalPoint"
             />
         </clr-input-container>
-        <div class="asset-detail">
-            {{ 'asset.source-file' | translate }}:
-            <a [href]="asset.source" [title]="asset.source" target="_blank" class="source-link">{{
+
+        <vdr-labeled-data [label]="'common.name' | translate" *ngIf="!editable">
+            <span class="elide">
+            {{ asset.name }}
+            </span>
+        </vdr-labeled-data>
+
+        <vdr-labeled-data [label]="'asset.source-file' | translate">
+            <a [href]="asset.source" [title]="asset.source" target="_blank" class="elide source-link">{{
                 getSourceFileName()
             }}</a>
-        </div>
-        <div class="asset-detail">
-            {{ 'asset.original-asset-size' | translate }}: {{ asset.fileSize | filesize }}
-        </div>
-        <div class="asset-detail">
-            {{ 'asset.dimensions' | translate }}: {{ asset.width }} x {{ asset.height }}
-        </div>
+        </vdr-labeled-data>
+
+        <vdr-labeled-data [label]="'asset.original-asset-size' | translate">
+            {{ asset.fileSize | filesize }}
+        </vdr-labeled-data>
+
+        <vdr-labeled-data [label]="'asset.dimensions' | translate">
+            {{ asset.width }} x {{ asset.height }}
+        </vdr-labeled-data>
+
+        <vdr-labeled-data [label]="'asset.focal-point' | translate">
+            <span *ngIf="fpx"
+                ><clr-icon shape="crosshairs"></clr-icon> x: {{ fpx | number: '1.2-2' }}, y:
+                {{ fpy | number: '1.2-2' }}</span
+            >
+            <span *ngIf="!fpx">{{ 'common.not-set' | translate }}</span>
+            <br />
+            <button
+                class="btn btn-secondary-outline btn-sm"
+                [disabled]="settingFocalPoint"
+                (click)="setFocalPointStart()"
+            >
+                <ng-container *ngIf="!fpx">{{ 'asset.set-focal-point' | translate }}</ng-container>
+                <ng-container *ngIf="fpx">{{ 'asset.update-focal-point' | translate }}</ng-container>
+            </button>
+            <button
+                class="btn btn-warning-outline btn-sm"
+                [disabled]="settingFocalPoint"
+                *ngIf="!!fpx"
+                (click)="removeFocalPoint()"
+            >
+                {{ 'asset.unset-focal-point' | translate }}
+            </button>
+        </vdr-labeled-data>
     </form>
     <div class="flex-spacer"></div>
     <div class="preview-select">
         <clr-select-container>
             <label>{{ 'asset.preview' | translate }}</label>
-            <select clrSelect name="options" [(ngModel)]="size">
+            <select clrSelect name="options" [(ngModel)]="size" [disabled]="settingFocalPoint">
                 <option value="tiny">tiny</option>
                 <option value="thumb">thumb</option>
                 <option value="small">small</option>
@@ -41,5 +94,12 @@
         </clr-select-container>
         <div class="asset-detail">{{ width }} x {{ height }}</div>
     </div>
-
+    <a
+        *ngIf="!editable"
+        class="btn btn-link btn-sm"
+        [routerLink]="['/catalog', 'assets', asset.id]"
+        (click)="editClick.emit()"
+    >
+        <clr-icon shape="edit"></clr-icon> {{ 'common.edit' | translate }}
+    </a>
 </div>

+ 36 - 16
packages/admin-ui/src/app/shared/components/asset-preview/asset-preview.component.scss

@@ -1,4 +1,4 @@
-@import "src/styles/variables";
+@import 'src/styles/variables';
 
 :host {
     display: flex;
@@ -11,8 +11,8 @@
     min-height: 60vh;
     overflow: auto;
     text-align: center;
-    box-shadow: inset 0 0 5px 0 rgba(0,0,0,0.1);
-    background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACuoAAArqAVDM774AAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTZEaa/1AAAAK0lEQVQ4T2P4jwP8xgFGNSADqDwGIF0DlMYAUH0YYFQDMoDKYwASNfz/DwB/JvcficphowAAAABJRU5ErkJggg==");
+    box-shadow: inset 0 0 5px 0 rgba(0, 0, 0, 0.1);
+    background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACuoAAArqAVDM774AAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTZEaa/1AAAAK0lEQVQ4T2P4jwP8xgFGNSADqDwGIF0DlMYAUH0YYFQDMoDKYwASNfz/DwB/JvcficphowAAAABJRU5ErkJggg==');
     flex: 1;
 
     &.centered {
@@ -21,9 +21,23 @@
         justify-content: center;
     }
 
-    img {
-        margin: 12px;
-        box-shadow: 0px 0px 10px -3px rgba(0, 0, 0, 0.15)
+    vdr-focal-point-control {
+        position: relative;
+        box-shadow: 0px 0px 10px -3px rgba(0, 0, 0, 0.15);
+    }
+
+    .image-wrapper {
+        position: relative;
+    }
+
+    .asset-image {
+        width: 100%;
+    }
+
+    .focal-point-info {
+        position: absolute;
+        display: flex;
+        right: 0;
     }
 }
 
@@ -32,6 +46,16 @@
     flex-direction: column;
     margin-left: 12px;
     min-width: 15vw;
+    max-width: 25vw;
+    transition: opacity 0.3s;
+
+    &.fade {
+        opacity: 0.5;
+    }
+
+    .name-input {
+        margin-bottom: 24px;
+    }
 
     ::ng-deep .clr-control-container {
         width: 100%;
@@ -40,26 +64,22 @@
         }
     }
 
-    .asset-detail {
-        font-size: 12px;
-        display: flex;
-    }
-
-    .source-link {
-        max-width: 191px;
+    .elide {
         overflow: hidden;
         white-space: nowrap;
         text-overflow: ellipsis;
-        flex: none;
+        display: block;
+    }
+    .source-link {
         direction: rtl;
     }
 
     .preview-select {
         display: flex;
         align-items: flex-end;
-        margin-bottom: 30px;
+        margin-bottom: 12px;
         clr-select-container {
-            margin-right: 6px;
+            margin-right: 12px;
         }
     }
 }

+ 123 - 22
packages/admin-ui/src/app/shared/components/asset-preview/asset-preview.component.ts

@@ -1,5 +1,6 @@
 import {
     ChangeDetectionStrategy,
+    ChangeDetectorRef,
     Component,
     ElementRef,
     EventEmitter,
@@ -9,56 +10,78 @@ import {
     Output,
     ViewChild,
 } from '@angular/core';
-import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
-import { Subscription } from 'rxjs';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { fromEvent, Subscription } from 'rxjs';
+import { debounceTime } from 'rxjs/operators';
 
 import { Asset, UpdateAssetInput } from '../../../common/generated-types';
-import { Dialog } from '../../providers/modal/modal.service';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { Point } from '../focal-point-control/focal-point-control.component';
+
+export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
 
 @Component({
     selector: 'vdr-asset-preview',
     templateUrl: './asset-preview.component.html',
     styleUrls: ['./asset-preview.component.scss'],
-    changeDetection: ChangeDetectionStrategy.Default,
+    changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AssetPreviewComponent implements OnInit, OnDestroy {
     @Input() asset: Asset;
-    @Output() assetChange = new EventEmitter<UpdateAssetInput>();
+    @Input() editable = false;
+    @Output() assetChange = new EventEmitter<Omit<UpdateAssetInput, 'focalPoint'>>();
+    @Output() editClick = new EventEmitter();
 
     form: FormGroup;
 
-    size = 'medium';
-    resolveWith: (result?: void) => void;
+    size: PreviewPreset = 'medium';
     width = 0;
     height = 0;
     centered = true;
+    settingFocalPoint = false;
+    lastFocalPoint?: Point;
     @ViewChild('imageElement', { static: true }) private imageElementRef: ElementRef<HTMLImageElement>;
     @ViewChild('previewDiv', { static: true }) private previewDivRef: ElementRef<HTMLDivElement>;
     private subscription: Subscription;
+    private sizePriorToSettingFocalPoint: PreviewPreset;
+
+    constructor(
+        private formBuilder: FormBuilder,
+        private dataService: DataService,
+        private notificationService: NotificationService,
+        private changeDetector: ChangeDetectorRef,
+    ) {}
+
+    get fpx(): number | null {
+        return this.asset.focalPoint ? this.asset.focalPoint.x : null;
+    }
 
-    constructor(private formBuilder: FormBuilder) {}
+    get fpy(): number | null {
+        return this.asset.focalPoint ? this.asset.focalPoint.y : null;
+    }
 
     ngOnInit() {
         const { focalPoint } = this.asset;
         this.form = this.formBuilder.group({
             name: [this.asset.name],
-            focalPointX: [focalPoint ? focalPoint.x : null],
-            focalPointY: [focalPoint ? focalPoint.y : null],
         });
         this.subscription = this.form.valueChanges.subscribe(value => {
-            const focalPointValue =
-                value.focalPointX != null && value.focalPointY != null
-                    ? {
-                          x: value.focalPointX,
-                          y: value.focalPointY,
-                      }
-                    : null;
             this.assetChange.emit({
                 id: this.asset.id,
                 name: value.name,
-                focalPoint: focalPointValue,
             });
         });
+
+        this.subscription.add(
+            fromEvent(window, 'resize')
+                .pipe(debounceTime(50))
+                .subscribe(() => {
+                    this.updateDimensions();
+                    this.changeDetector.markForCheck();
+                }),
+        );
     }
 
     ngOnDestroy(): void {
@@ -72,11 +95,89 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
         return parts[parts.length - 1];
     }
 
-    getDimensions() {
+    updateDimensions() {
         const img = this.imageElementRef.nativeElement;
         const container = this.previewDivRef.nativeElement;
-        this.width = img.width;
-        this.height = img.height;
-        this.centered = img.width <= container.offsetWidth && img.height <= container.offsetHeight;
+        const imgWidth = img.naturalWidth;
+        const imgHeight = img.naturalHeight;
+        const containerWidth = container.offsetWidth;
+        const containerHeight = container.offsetHeight;
+
+        const constrainToContainer = this.settingFocalPoint;
+        if (constrainToContainer) {
+            const controlsMarginPx = 48 * 2;
+            const availableHeight = containerHeight - controlsMarginPx;
+            const availableWidth = containerWidth;
+            const hRatio = imgHeight / availableHeight;
+            const wRatio = imgWidth / availableWidth;
+
+            const imageExceedsAvailableDimensions = 1 < hRatio || 1 < wRatio;
+            if (imageExceedsAvailableDimensions) {
+                const factor = hRatio < wRatio ? wRatio : hRatio;
+                this.width = Math.round(imgWidth / factor);
+                this.height = Math.round(imgHeight / factor);
+                this.centered = true;
+                return;
+            }
+        }
+        this.width = imgWidth;
+        this.height = imgHeight;
+        this.centered = imgWidth <= containerWidth && imgHeight <= containerHeight;
+    }
+
+    setFocalPointStart() {
+        this.sizePriorToSettingFocalPoint = this.size;
+        this.size = 'medium';
+        this.settingFocalPoint = true;
+        this.lastFocalPoint = this.asset.focalPoint || { x: 0.5, y: 0.5 };
+        this.updateDimensions();
+    }
+
+    removeFocalPoint() {
+        this.dataService.product
+            .updateAsset({
+                id: this.asset.id,
+                focalPoint: null,
+            })
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('asset.update-focal-point-success'));
+                    this.asset.focalPoint = null;
+                    this.changeDetector.markForCheck();
+                },
+                () => this.notificationService.error(_('asset.update-focal-point-error')),
+            );
+    }
+
+    onFocalPointChange(point: Point) {
+        this.lastFocalPoint = point;
+    }
+
+    setFocalPointCancel() {
+        this.settingFocalPoint = false;
+        this.lastFocalPoint = undefined;
+        this.size = this.sizePriorToSettingFocalPoint;
+    }
+
+    setFocalPointEnd() {
+        this.settingFocalPoint = false;
+        this.size = this.sizePriorToSettingFocalPoint;
+        if (this.lastFocalPoint) {
+            const { x, y } = this.lastFocalPoint;
+            this.lastFocalPoint = undefined;
+            this.dataService.product
+                .updateAsset({
+                    id: this.asset.id,
+                    focalPoint: { x, y },
+                })
+                .subscribe(
+                    () => {
+                        this.notificationService.success(_('asset.update-focal-point-success'));
+                        this.asset.focalPoint = { x, y };
+                        this.changeDetector.markForCheck();
+                    },
+                    () => this.notificationService.error(_('asset.update-focal-point-error')),
+                );
+        }
     }
 }

+ 14 - 0
packages/admin-ui/src/app/shared/components/focal-point-control/focal-point-control.component.html

@@ -0,0 +1,14 @@
+<ng-content></ng-content>
+<div class="frame" #frame [style.width.px]="width" [style.height.px]="height">
+    <div
+        #dot
+        class="dot"
+        [class.visible]="visible"
+        [class.editable]="editable"
+        cdkDrag
+        [cdkDragDisabled]="!editable"
+        cdkDragBoundary=".frame"
+        (cdkDragEnded)="onDragEnded($event)"
+        [cdkDragFreeDragPosition]="initialPosition"
+    ></div>
+</div>

+ 46 - 0
packages/admin-ui/src/app/shared/components/focal-point-control/focal-point-control.component.scss

@@ -0,0 +1,46 @@
+@import "variables";
+
+:host {
+    position: relative;
+    display: block;
+}
+
+.frame {
+    position: absolute;
+    top: 0;
+}
+
+.dot {
+    width: 20px;
+    height: 20px;
+    border-radius: 50%;
+    border: 2px solid white;
+    position: absolute;
+    visibility: hidden;
+    transition: opacity 0.3s;
+    box-shadow: 0px 0px 4px 4px rgba(0, 0, 0, 0.42);
+    &.visible {
+        visibility: visible;
+        opacity: 0.7;
+    }
+    &.editable {
+        cursor: move;
+        visibility: visible;
+        opacity: 1;
+        animation: pulse;
+        animation-duration: 0.5s;
+        animation-iteration-count: 4;
+    }
+}
+
+@keyframes pulse {
+    0% {
+        border-color: white;
+    }
+    50% {
+        border-color: $color-warning-500;
+    }
+    100% {
+        border-color: white
+    }
+}

+ 66 - 0
packages/admin-ui/src/app/shared/components/focal-point-control/focal-point-control.component.ts

@@ -0,0 +1,66 @@
+import { CdkDragEnd } from '@angular/cdk/drag-drop';
+import {
+    ChangeDetectionStrategy,
+    Component,
+    ElementRef,
+    EventEmitter,
+    HostBinding,
+    Input,
+    Output,
+    ViewChild,
+} from '@angular/core';
+
+export type Point = { x: number; y: number };
+
+@Component({
+    selector: 'vdr-focal-point-control',
+    templateUrl: './focal-point-control.component.html',
+    styleUrls: ['./focal-point-control.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FocalPointControlComponent {
+    @Input() visible = false;
+    @Input() editable = false;
+    @HostBinding('style.width.px')
+    @Input()
+    width: number;
+    @HostBinding('style.height.px')
+    @Input()
+    height: number;
+    @Input() fpx = 0.5;
+    @Input() fpy = 0.5;
+    @Output() focalPointChange = new EventEmitter<Point>();
+
+    @ViewChild('frame', { static: true }) frame: ElementRef<HTMLDivElement>;
+    @ViewChild('dot', { static: true }) dot: ElementRef<HTMLDivElement>;
+
+    get initialPosition(): Point {
+        return this.focalPointToOffset(this.fpx == null ? 0.5 : this.fpx, this.fpy == null ? 0.5 : this.fpy);
+    }
+
+    onDragEnded(event: CdkDragEnd) {
+        const { x, y } = this.getCurrentFocalPoint();
+        this.fpx = x;
+        this.fpy = y;
+        this.focalPointChange.emit({ x, y });
+    }
+
+    private getCurrentFocalPoint(): Point {
+        const { left: dotLeft, top: dotTop, width, height } = this.dot.nativeElement.getBoundingClientRect();
+        const { left: frameLeft, top: frameTop } = this.frame.nativeElement.getBoundingClientRect();
+        const xInPx = dotLeft - frameLeft + width / 2;
+        const yInPx = dotTop - frameTop + height / 2;
+        return {
+            x: xInPx / this.width,
+            y: yInPx / this.height,
+        };
+    }
+
+    private focalPointToOffset(x: number, y: number): Point {
+        const { width, height } = this.dot.nativeElement.getBoundingClientRect();
+        return {
+            x: x * this.width - width / 2,
+            y: y * this.height - height / 2,
+        };
+    }
+}

+ 1 - 0
packages/admin-ui/src/app/shared/shared-declarations.ts

@@ -35,6 +35,7 @@ export { FacetValueChipComponent } from './components/facet-value-chip/facet-val
 export {
     FacetValueSelectorComponent,
 } from './components/facet-value-selector/facet-value-selector.component';
+export { FocalPointControlComponent } from './components/focal-point-control/focal-point-control.component';
 export { FormFieldControlDirective } from './components/form-field/form-field-control.directive';
 export { FormFieldComponent } from './components/form-field/form-field.component';
 export { FormItemComponent } from './components/form-item/form-item.component';

+ 4 - 0
packages/admin-ui/src/app/shared/shared.module.ts

@@ -1,3 +1,4 @@
+import { DragDropModule } from '@angular/cdk/drag-drop';
 import { OverlayModule } from '@angular/cdk/overlay';
 import { CommonModule } from '@angular/common';
 import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@@ -50,6 +51,7 @@ import {
     FacetValueChipComponent,
     FacetValueSelectorComponent,
     FileSizePipe,
+    FocalPointControlComponent,
     FormattedAddressComponent,
     FormFieldComponent,
     FormFieldControlDirective,
@@ -85,6 +87,7 @@ const IMPORTS = [
     NgxPaginationModule,
     TranslateModule,
     OverlayModule,
+    DragDropModule,
 ];
 
 const DECLARATIONS = [
@@ -148,6 +151,7 @@ const DECLARATIONS = [
     IfDefaultChannelActiveDirective,
     ExtensionHostComponent,
     CustomFieldLabelPipe,
+    FocalPointControlComponent,
 ];
 
 @NgModule({

+ 10 - 5
packages/admin-ui/src/i18n-messages/en.json

@@ -7,16 +7,22 @@
     "add-asset-with-count": "Add {count, plural, 0 {assets} one {1 asset} other {{count} assets}}",
     "assets-selected-count": "{ count } assets selected",
     "dimensions": "Dimensions",
+    "focal-point": "Focal point",
     "notify-create-assets-success": "Created {count, plural, one {new Asset} other {{count} new Assets}}",
     "open-asset-source": "Open asset source",
     "original-asset-size": "Source size",
+    "preview": "Preview",
     "remove-asset": "Remove asset",
     "search-asset-name": "Search assets by name",
     "select-assets": "Select assets",
     "set-as-featured-asset": "Set as featured asset",
+    "set-focal-point": "Set focal point",
     "source-file": "Source file",
-    "upload-assets": "Upload assets",
-    "preview": "Preview"
+    "unset-focal-point": "Unset",
+    "update-focal-point": "Update point",
+    "update-focal-point-error": "Could not update focal point",
+    "update-focal-point-success": "Updated focal point",
+    "upload-assets": "Upload assets"
   },
   "breadcrumb": {
     "administrators": "Administrators",
@@ -74,7 +80,6 @@
     "filter-by-name": "Filter by name",
     "filters": "Filters",
     "group-by-product": "Group by product",
-    "height": "Height",
     "manage-variants": "Manage variants",
     "move-down": "Move down",
     "move-to": "Move to",
@@ -117,8 +122,7 @@
     "values": "Values",
     "variant": "Variant",
     "view-contents": "View contents",
-    "visibility": "Visibility",
-    "width": "Width"
+    "visibility": "Visibility"
   },
   "common": {
     "ID": "ID",
@@ -156,6 +160,7 @@
     "more": "More...",
     "name": "Name",
     "no-results": "No results",
+    "not-set": "Not set",
     "notify-create-error": "An error occurred, could not create { entity }",
     "notify-create-success": "Created new { entity }",
     "notify-delete-error": "An error occurred, could not delete { entity }",