mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Add focal point support for images (#20768)
* add visual feedback for invalid value
* add focal point MVP
* Revert "add visual feedback for invalid value"
This reverts commit 1df1868342.
Accidently committed some local testing stuff. Pls disregard! :)
* fix wrong cropping
* fix text for new cropping, import correct type
* fix saving
* place initial focal point to saved value or center, display different cancel text
* split up tooltips
* honor rotations & flips when saving focal point
* apply custom cropper styles for focal mode
* Create loud-crews-fix.md
* add test and only crop when covering with fixed dimensions to preserve default behaviour
* linter gods pls forgive me
* replace json field with two int fields
* add focal point to sdk
* fix transformation for the two new db columns
* update test for new columns, add new tests
* wip: saving now differentiates between only img data and focal point and only enable saving if there are changes
but this is not optimal. would be better to check beforehand if we can collapse
to requests to one. Now its bad because
one request might succeed and the other fails.
* refactor image editor change persistence
now we save it in one request!
* Update loud-crews-fix.md
* add `focal_point_x` and `focal_point_y` to possible asset transformations
* fix assigning localdragmode upon cropper init
* reuse fetched fields in type
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
* update file type
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
* update changeset
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
* improve type for `ASSET_TRANSFORM_QUERY_KEYS`
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
* Apply suggestions from code review
Trying out the batch change feature from github for the first time. Lets see.
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
* rename `persistChanges` to `saveImage`
* Add docs for focal points (#20959)
* Add user guide
* Added to API Reference
* Prettier
* Spellchecker
* default null
Co-authored-by: Daniel Biegler <DanielBiegler@users.noreply.github.com>
* from -> around
Co-authored-by: Daniel Biegler <DanielBiegler@users.noreply.github.com>
* from -> around
---------
Co-authored-by: Daniel Biegler <DanielBiegler@users.noreply.github.com>
* add changeset for docs
* run prettier lets goooooooo
* move & show focal point fields and add divider
---------
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Co-authored-by: Kevin Lewis <kvn@lws.io>
This commit is contained in:
5
.changeset/cold-cobras-carry.md
Normal file
5
.changeset/cold-cobras-carry.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"docs": patch
|
||||
---
|
||||
|
||||
Added docs for focal point support
|
||||
8
.changeset/loud-crews-fix.md
Normal file
8
.changeset/loud-crews-fix.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@directus/app": minor
|
||||
"@directus/api": minor
|
||||
"@directus/types": patch
|
||||
"@directus/sdk": patch
|
||||
---
|
||||
|
||||
Added focal point support for images
|
||||
@@ -38,7 +38,7 @@ export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const ASSET_TRANSFORM_QUERY_KEYS: Array<keyof TransformationParams> = [
|
||||
export const ASSET_TRANSFORM_QUERY_KEYS = [
|
||||
'key',
|
||||
'transforms',
|
||||
'width',
|
||||
@@ -47,7 +47,9 @@ export const ASSET_TRANSFORM_QUERY_KEYS: Array<keyof TransformationParams> = [
|
||||
'fit',
|
||||
'quality',
|
||||
'withoutEnlargement',
|
||||
];
|
||||
'focal_point_x',
|
||||
'focal_point_y',
|
||||
] as const satisfies Readonly<(keyof TransformationParams)[]>;
|
||||
|
||||
export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE'];
|
||||
|
||||
|
||||
15
api/src/database/migrations/20231215A-add-focalpoints.ts
Normal file
15
api/src/database/migrations/20231215A-add-focalpoints.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('directus_files', (table) => {
|
||||
table.integer('focal_point_x').nullable();
|
||||
table.integer('focal_point_y').nullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable('directus_files', (table) => {
|
||||
table.dropColumn('focal_point_x');
|
||||
table.dropColumn('focal_point_y');
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,22 @@ fields:
|
||||
width: half
|
||||
readonly: true
|
||||
|
||||
- field: focal_point_divider
|
||||
interface: presentation-divider
|
||||
options:
|
||||
icon: image_search
|
||||
title: $t:field_options.directus_files.focal_point_divider
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
width: full
|
||||
|
||||
- field: focal_point_x
|
||||
width: half
|
||||
|
||||
- field: focal_point_y
|
||||
width: half
|
||||
|
||||
- field: storage_divider
|
||||
interface: presentation-divider
|
||||
options:
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { AbstractServiceOptions, Transformation, TransformationSet } from '
|
||||
import { getMilliseconds } from '../utils/get-milliseconds.js';
|
||||
import * as TransformationUtils from '../utils/transformations.js';
|
||||
import { AuthorizationService } from './authorization.js';
|
||||
import { FilesService } from './files.js';
|
||||
|
||||
const env = useEnv();
|
||||
const logger = useLogger();
|
||||
@@ -32,10 +33,12 @@ export class AssetsService {
|
||||
knex: Knex;
|
||||
accountability: Accountability | null;
|
||||
authorizationService: AuthorizationService;
|
||||
filesService: FilesService;
|
||||
|
||||
constructor(options: AbstractServiceOptions) {
|
||||
this.knex = options.knex || getDatabase();
|
||||
this.accountability = options.accountability || null;
|
||||
this.filesService = new FilesService(options);
|
||||
this.authorizationService = new AuthorizationService(options);
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ export class AssetsService {
|
||||
await this.authorizationService.checkAccess('read', 'directus_files', id);
|
||||
}
|
||||
|
||||
const file = (await this.knex.select('*').from('directus_files').where({ id }).first()) as File;
|
||||
const file = (await this.filesService.readOne(id, { limit: 1 })) as File | undefined;
|
||||
|
||||
if (!file) throw new ForbiddenError();
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ export type TransformationParams = {
|
||||
transforms?: Transformation[];
|
||||
format?: TransformationFormat | 'auto';
|
||||
quality?: number;
|
||||
focal_point_x?: number;
|
||||
focal_point_y?: number;
|
||||
} & TransformationResize;
|
||||
|
||||
export type TransformationSet = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expect, test, describe } from 'vitest';
|
||||
import { maybeExtractFormat, resolvePreset } from './transformations.js';
|
||||
import type { File } from '../types/files.js';
|
||||
import type { File } from '@directus/types';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type { Transformation, TransformationParams } from '../types/assets.js';
|
||||
import { maybeExtractFormat, resolvePreset } from './transformations.js';
|
||||
|
||||
const inputFile: File = {
|
||||
const inputFile = {
|
||||
id: '43a15f67-84a7-4e07-880d-e46a9f33c542',
|
||||
storage: 'local',
|
||||
filename_disk: 'test',
|
||||
@@ -12,18 +12,22 @@ const inputFile: File = {
|
||||
type: null,
|
||||
folder: null,
|
||||
uploaded_by: null,
|
||||
uploaded_on: new Date(),
|
||||
uploaded_on: '2023-12-19T16:12:53.149Z',
|
||||
charset: null,
|
||||
filesize: 123,
|
||||
width: null,
|
||||
height: null,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
duration: null,
|
||||
embed: null,
|
||||
description: null,
|
||||
location: null,
|
||||
tags: null,
|
||||
metadata: null,
|
||||
};
|
||||
modified_by: null,
|
||||
modified_on: '',
|
||||
focal_point_x: null,
|
||||
focal_point_y: null,
|
||||
} satisfies File;
|
||||
|
||||
describe('resolvePreset', () => {
|
||||
test('Prevent input mutation #18301', () => {
|
||||
@@ -88,7 +92,7 @@ describe('resolvePreset', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('Add resize transformation', () => {
|
||||
test('Add resize transformation: cover without focal point', () => {
|
||||
const transformationParams: TransformationParams = {
|
||||
key: 'system-small-cover',
|
||||
width: 64,
|
||||
@@ -111,6 +115,140 @@ describe('resolvePreset', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('Add resize transformation: cover with centered focal point', () => {
|
||||
const transformationParams: TransformationParams = {
|
||||
key: 'system-small-cover',
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: 'cover',
|
||||
};
|
||||
|
||||
const output = resolvePreset(
|
||||
{ transformationParams },
|
||||
{ ...inputFile, focal_point_x: inputFile.width / 2, focal_point_y: inputFile.height / 2 },
|
||||
);
|
||||
|
||||
/*
|
||||
* The following is relevant for a centered focal point
|
||||
* The initial aspect ratio is 16:9 so we have to resize the image to an intermediate size
|
||||
* that fully covers our desired 1:1 aspect ratio and then crop out the final dimensions
|
||||
* This results in: 1080/64 == 16.875 --> 1920/16.875 == 113.77 (round up) == 114 width
|
||||
* Next we need the inner padding to get the centered crop: (114 - 64) / 2 == 25
|
||||
* That results in the following
|
||||
* <──────────114───────────>
|
||||
* <──25──><───64───><──25──>
|
||||
* ┌──────┬──────────┬──────┐
|
||||
* │ │ │ │
|
||||
* │ │ extract │ │
|
||||
* │ │ centered │ │
|
||||
* │ │ │ │
|
||||
* └──────┴──────────┴──────┘
|
||||
*/
|
||||
expect(output).toStrictEqual([
|
||||
[
|
||||
'resize',
|
||||
{
|
||||
width: 114,
|
||||
height: 64,
|
||||
fit: 'cover',
|
||||
withoutEnlargement: undefined,
|
||||
},
|
||||
],
|
||||
['extract', { left: 25, top: 0, width: 64, height: 64 }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Add resize transformation: cover with negative focal point', () => {
|
||||
const transformationParams: TransformationParams = {
|
||||
key: 'system-small-cover',
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: 'cover',
|
||||
};
|
||||
|
||||
const output = resolvePreset({ transformationParams }, { ...inputFile, focal_point_x: -999, focal_point_y: -999 });
|
||||
|
||||
/*
|
||||
* That should result in the following
|
||||
* <──────────114────────────>
|
||||
* <─────64──────><────50────>
|
||||
* ┌─────────────┬───────────┐
|
||||
* │ │ │
|
||||
* │ extract │ │
|
||||
* │ │ │
|
||||
* └─────────────┴───────────┘
|
||||
*/
|
||||
expect(output).toStrictEqual([
|
||||
[
|
||||
'resize',
|
||||
{
|
||||
width: 114,
|
||||
height: 64,
|
||||
fit: 'cover',
|
||||
withoutEnlargement: undefined,
|
||||
},
|
||||
],
|
||||
['extract', { left: 0, top: 0, width: 64, height: 64 }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Add resize transformation: cover with out of bounds focal point', () => {
|
||||
const transformationParams: TransformationParams = {
|
||||
key: 'system-small-cover',
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: 'cover',
|
||||
};
|
||||
|
||||
const output = resolvePreset({ transformationParams }, { ...inputFile, focal_point_x: 9999, focal_point_y: -999 });
|
||||
|
||||
/*
|
||||
* That should result in the following
|
||||
* <──────────114────────────>
|
||||
* <────50────><─────64──────>
|
||||
* ┌───────────┬─────────────┐
|
||||
* │ │ │
|
||||
* │ │ extract │
|
||||
* │ │ │
|
||||
* └───────────┴─────────────┘
|
||||
*/
|
||||
expect(output).toStrictEqual([
|
||||
[
|
||||
'resize',
|
||||
{
|
||||
width: 114,
|
||||
height: 64,
|
||||
fit: 'cover',
|
||||
withoutEnlargement: undefined,
|
||||
},
|
||||
],
|
||||
['extract', { left: 50, top: 0, width: 64, height: 64 }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Add resize transformation: contain', () => {
|
||||
const transformationParams: TransformationParams = {
|
||||
key: 'system-small-cover',
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: 'contain',
|
||||
};
|
||||
|
||||
const output = resolvePreset({ transformationParams }, inputFile);
|
||||
|
||||
expect(output).toStrictEqual([
|
||||
[
|
||||
'resize',
|
||||
{
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: 'contain',
|
||||
withoutEnlargement: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Resolve auto format (fallback)', () => {
|
||||
const transformationParams: TransformationParams = {
|
||||
format: 'auto',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { File } from '@directus/types';
|
||||
import { clamp } from 'lodash-es';
|
||||
import type { Region } from 'sharp';
|
||||
import type { Transformation, TransformationFormat, TransformationSet } from '../types/index.js';
|
||||
|
||||
export function resolvePreset({ transformationParams, acceptFormat }: TransformationSet, file: File): Transformation[] {
|
||||
@@ -14,18 +16,66 @@ export function resolvePreset({ transformationParams, acceptFormat }: Transforma
|
||||
]);
|
||||
}
|
||||
|
||||
if (transformationParams.width || transformationParams.height) {
|
||||
transforms.push([
|
||||
'resize',
|
||||
{
|
||||
width: transformationParams.width ? Number(transformationParams.width) : undefined,
|
||||
height: transformationParams.height ? Number(transformationParams.height) : undefined,
|
||||
fit: transformationParams.fit,
|
||||
withoutEnlargement: transformationParams.withoutEnlargement
|
||||
? Boolean(transformationParams.withoutEnlargement)
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
if ((transformationParams.width || transformationParams.height) && file.width && file.height) {
|
||||
const toWidth = transformationParams.width ? Number(transformationParams.width) : undefined;
|
||||
const toHeight = transformationParams.height ? Number(transformationParams.height) : undefined;
|
||||
|
||||
const toFocalPointX = transformationParams.focal_point_x
|
||||
? Number(transformationParams.focal_point_x)
|
||||
: file.focal_point_x;
|
||||
|
||||
const toFocalPointY = transformationParams.focal_point_y
|
||||
? Number(transformationParams.focal_point_y)
|
||||
: file.focal_point_y;
|
||||
|
||||
/*
|
||||
* Focal point cropping only works with a fixed size (width x height) when `cover`ing,
|
||||
* since the other modes show the whole image. Sharp by default also simply scales up/down
|
||||
* when only supplied with one dimension, so we **must** check, else we break existing behaviour.
|
||||
* See: https://sharp.pixelplumbing.com/api-resize#resize
|
||||
* Also only crop to focal point when explicitly defined so that users can still `cover` with
|
||||
* other parameters like `position` and `gravity` - Else fall back to regular behaviour
|
||||
*/
|
||||
if (
|
||||
(transformationParams.fit === undefined || transformationParams.fit === 'cover') &&
|
||||
toWidth &&
|
||||
toHeight &&
|
||||
toFocalPointX !== null &&
|
||||
toFocalPointY !== null
|
||||
) {
|
||||
const transformArgs = getResizeArguments(
|
||||
{ w: file.width, h: file.height },
|
||||
{ w: toWidth, h: toHeight },
|
||||
{ x: toFocalPointX, y: toFocalPointY },
|
||||
);
|
||||
|
||||
transforms.push(
|
||||
[
|
||||
'resize',
|
||||
{
|
||||
width: transformArgs.width,
|
||||
height: transformArgs.height,
|
||||
fit: transformationParams.fit,
|
||||
withoutEnlargement: transformationParams.withoutEnlargement
|
||||
? Boolean(transformationParams.withoutEnlargement)
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
['extract', transformArgs.region],
|
||||
);
|
||||
} else {
|
||||
transforms.push([
|
||||
'resize',
|
||||
{
|
||||
width: toWidth,
|
||||
height: toHeight,
|
||||
fit: transformationParams.fit,
|
||||
withoutEnlargement: transformationParams.withoutEnlargement
|
||||
? Boolean(transformationParams.withoutEnlargement)
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return transforms;
|
||||
@@ -63,3 +113,72 @@ export function maybeExtractFormat(transforms: Transformation[]): string | undef
|
||||
const lastToFormat = toFormats[toFormats.length - 1];
|
||||
return lastToFormat ? lastToFormat[1]?.toString() : undefined;
|
||||
}
|
||||
|
||||
type Dimensions = { w: number; h: number };
|
||||
type FocalPoint = { x: number; y: number };
|
||||
|
||||
/**
|
||||
* Resize an image but keep it centered on the focal point.
|
||||
* Based on the method outlined in https://github.com/lovell/sharp/issues/1198#issuecomment-384591756
|
||||
*/
|
||||
function getResizeArguments(
|
||||
original: Dimensions,
|
||||
target: Dimensions,
|
||||
focalPoint?: FocalPoint | null,
|
||||
): { width: number; height: number; region: Region } {
|
||||
const { width, height, factor } = getIntermediateDimensions(original, target);
|
||||
|
||||
const region = getExtractionRegion(factor, focalPoint ?? { x: original.w / 2, y: original.h / 2 }, target, {
|
||||
w: width,
|
||||
h: height,
|
||||
});
|
||||
|
||||
return { width, height, region };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dimensions of the intermediate (resized) image.
|
||||
*/
|
||||
function getIntermediateDimensions(
|
||||
original: Dimensions,
|
||||
target: Dimensions,
|
||||
): { width: number; height: number; factor: number } {
|
||||
const hRatio = original.h / target.h;
|
||||
const wRatio = original.w / target.w;
|
||||
|
||||
let factor: number;
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if (hRatio < wRatio) {
|
||||
factor = hRatio;
|
||||
height = Math.round(target.h);
|
||||
width = Math.round(original.w / factor);
|
||||
} else {
|
||||
factor = wRatio;
|
||||
width = Math.round(target.w);
|
||||
height = Math.round(original.h / factor);
|
||||
}
|
||||
|
||||
return { width, height, factor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the Region to extract from the intermediate image.
|
||||
*/
|
||||
function getExtractionRegion(
|
||||
factor: number,
|
||||
focalPoint: FocalPoint,
|
||||
target: Dimensions,
|
||||
intermediate: Dimensions,
|
||||
): Region {
|
||||
const newXCenter = focalPoint.x / factor;
|
||||
const newYCenter = focalPoint.y / factor;
|
||||
|
||||
return {
|
||||
left: clamp(Math.round(newXCenter - target.w / 2), 0, intermediate.w - target.w),
|
||||
top: clamp(Math.round(newYCenter - target.h / 2), 0, intermediate.h - target.h),
|
||||
width: target.w,
|
||||
height: target.h,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,7 +506,11 @@ months:
|
||||
november: November
|
||||
december: December
|
||||
drag_mode: Drag Mode
|
||||
move_tool: Move Tool
|
||||
crop_tool: Crop Tool
|
||||
focal_point_tool: Focal Point Tool
|
||||
cancel_crop: Cancel Crop
|
||||
cancel_selection: Cancel Selection
|
||||
original: Original
|
||||
url: URL
|
||||
import_label: Import
|
||||
@@ -1412,6 +1416,7 @@ field_options:
|
||||
description: An optional description...
|
||||
location: An optional location...
|
||||
storage_divider: File Naming
|
||||
focal_point_divider: Focal Point
|
||||
filename_disk: Name on disk storage...
|
||||
filename_download: Name when downloading...
|
||||
directus_roles:
|
||||
|
||||
@@ -3,19 +3,25 @@ import api, { addTokenToURL } from '@/api';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import type { File } from '@directus/types';
|
||||
import Cropper from 'cropperjs';
|
||||
import { isEqual } from 'lodash';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
type Image = {
|
||||
type: string;
|
||||
filesize: number;
|
||||
filename_download: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
const imageFields = [
|
||||
'type',
|
||||
'filesize',
|
||||
'filename_download',
|
||||
'width',
|
||||
'height',
|
||||
'focal_point_x',
|
||||
'focal_point_y',
|
||||
] as const;
|
||||
|
||||
type Image = Pick<File, (typeof imageFields)[number]>;
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
@@ -43,7 +49,7 @@ const internalActive = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const { loading, error, imageData, imageElement, save, saving, fetchImage, onImageLoad } = useImage();
|
||||
const { loading, error, imageData, imageElement, hasEdits, save, saving, fetchImage, onImageLoad } = useImage();
|
||||
|
||||
const {
|
||||
cropperInstance,
|
||||
@@ -115,14 +121,24 @@ function useImage() {
|
||||
|
||||
const imageElement = ref<HTMLImageElement | null>(null);
|
||||
|
||||
const hasEdits = computed(() => {
|
||||
return (
|
||||
!isEqual(
|
||||
{ x: 0, y: 0, width: 0, height: 0, rotate: 0, scaleX: 1, scaleY: 1 },
|
||||
cropperInstance.value?.getData(),
|
||||
) || cropping.value
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
imageData,
|
||||
saving,
|
||||
fetchImage,
|
||||
imageElement,
|
||||
hasEdits,
|
||||
save,
|
||||
saving,
|
||||
onImageLoad,
|
||||
};
|
||||
|
||||
@@ -132,7 +148,7 @@ function useImage() {
|
||||
|
||||
const response = await api.get(`/files/${props.id}`, {
|
||||
params: {
|
||||
fields: ['type', 'filesize', 'filename_download', 'width', 'height'],
|
||||
fields: imageFields,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -144,36 +160,88 @@ function useImage() {
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
async function saveImage(focalPoint?: { x: number | null; y: number | null }) {
|
||||
// Wrap the callback so that we can actually listen to it
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
cropperInstance.value
|
||||
?.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high',
|
||||
})
|
||||
.toBlob(
|
||||
async (blob) => {
|
||||
if (blob === null) {
|
||||
return reject(`Couldn't process and save edited image`);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (focalPoint) {
|
||||
formData.append('focal_point_x' as keyof File, focalPoint.x?.toString() ?? '');
|
||||
formData.append('focal_point_y' as keyof File, focalPoint.y?.toString() ?? '');
|
||||
}
|
||||
|
||||
formData.append('file', blob, imageData.value?.filename_download);
|
||||
|
||||
api.patch(`/files/${props.id}`, formData).then(
|
||||
() => resolve(),
|
||||
(err) => reject(err),
|
||||
);
|
||||
},
|
||||
imageData.value?.type ?? undefined,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
|
||||
cropperInstance.value
|
||||
?.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high',
|
||||
})
|
||||
.toBlob(
|
||||
async (blob) => {
|
||||
if (blob === null) {
|
||||
saving.value = false;
|
||||
return;
|
||||
}
|
||||
// Only save focal point if we're also actively selecting it
|
||||
const gotChangeForFocalPoint = localDragMode.value === 'focal_point' && cropping.value;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, imageData.value?.filename_download);
|
||||
let focalPointX = null;
|
||||
let focalPointY = null;
|
||||
|
||||
try {
|
||||
await api.patch(`/files/${props.id}`, formData);
|
||||
emit('refresh');
|
||||
internalActive.value = false;
|
||||
randomId.value = nanoid();
|
||||
} catch (error) {
|
||||
unexpectedError(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
},
|
||||
imageData.value?.type,
|
||||
);
|
||||
// Important: Check focal point first because we must(!) reset the cropping area
|
||||
// before saving so that we're not wrongfully cropping
|
||||
if (gotChangeForFocalPoint) {
|
||||
const data = cropperInstance.value?.getData();
|
||||
focalPointX = Math.round((data?.x ?? 0) + (data?.width ?? 0) / 2);
|
||||
focalPointY = Math.round((data?.y ?? 0) + (data?.height ?? 0) / 2);
|
||||
cropperInstance.value?.clear();
|
||||
}
|
||||
|
||||
// Important: This check has to go after(!) we reset the cropping area because
|
||||
// `getData` returns info about our focal point, which leads to a false positive
|
||||
const gotChangeForImg = !isEqual(
|
||||
{ x: 0, y: 0, width: 0, height: 0, rotate: 0, scaleX: 1, scaleY: 1 },
|
||||
cropperInstance.value?.getData(),
|
||||
);
|
||||
|
||||
// Collapse focal point patch request and image data into one request if we can
|
||||
let patchRequest = null;
|
||||
|
||||
if (gotChangeForImg && gotChangeForFocalPoint) {
|
||||
patchRequest = saveImage({ x: focalPointX, y: focalPointY });
|
||||
} else if (gotChangeForImg) {
|
||||
patchRequest = saveImage();
|
||||
} else if (gotChangeForFocalPoint) {
|
||||
patchRequest = api.patch(`/files/${props.id}`, {
|
||||
focal_point_x: focalPointX,
|
||||
focal_point_y: focalPointY,
|
||||
} satisfies Partial<File>);
|
||||
}
|
||||
|
||||
try {
|
||||
await patchRequest;
|
||||
emit('refresh');
|
||||
localDragMode.value = 'move';
|
||||
randomId.value = nanoid();
|
||||
internalActive.value = false;
|
||||
} catch (error) {
|
||||
unexpectedError(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onImageLoad() {
|
||||
@@ -182,6 +250,8 @@ function useImage() {
|
||||
}
|
||||
}
|
||||
|
||||
const localDragMode = ref<'move' | 'crop' | 'focal_point'>('move');
|
||||
|
||||
function useCropper() {
|
||||
const cropperInstance = ref<Cropper | null>(null);
|
||||
|
||||
@@ -193,7 +263,7 @@ function useCropper() {
|
||||
});
|
||||
|
||||
watch(imageData, () => {
|
||||
if (!imageData.value) return;
|
||||
if (!imageData.value || !imageData.value.width || !imageData.value.height) return;
|
||||
localAspectRatio.value = imageData.value.width / imageData.value.height;
|
||||
newDimensions.width = imageData.value.width;
|
||||
newDimensions.height = imageData.value.height;
|
||||
@@ -207,12 +277,11 @@ function useCropper() {
|
||||
localAspectRatio.value = newAspectRatio;
|
||||
cropperInstance.value?.setAspectRatio(newAspectRatio);
|
||||
cropperInstance.value?.crop();
|
||||
dragMode.value = 'crop';
|
||||
},
|
||||
});
|
||||
|
||||
const aspectRatioIcon = computed(() => {
|
||||
if (!imageData.value) return 'crop_original';
|
||||
if (!imageData.value || !imageData.value.width || !imageData.value.height) return 'crop_original';
|
||||
|
||||
if (customAspectRatios) {
|
||||
const customAspectRatio = customAspectRatios.find((customAR) => customAR.value == aspectRatio.value);
|
||||
@@ -237,19 +306,36 @@ function useCropper() {
|
||||
}
|
||||
});
|
||||
|
||||
const localDragMode = ref<'move' | 'crop'>('move');
|
||||
|
||||
const dragMode = computed({
|
||||
get() {
|
||||
return localDragMode.value;
|
||||
},
|
||||
set(newMode: 'move' | 'crop') {
|
||||
cropperInstance.value?.setDragMode(newMode);
|
||||
set(newMode) {
|
||||
cropperInstance.value?.setDragMode(newMode === 'move' ? 'move' : 'crop');
|
||||
localDragMode.value = newMode;
|
||||
|
||||
if (newMode === 'move') {
|
||||
cropperInstance.value?.clear();
|
||||
localCropping.value = false;
|
||||
} else if (newMode === 'crop') {
|
||||
cropperInstance.value?.crop();
|
||||
} else if (newMode === 'focal_point') {
|
||||
const canvasData = cropperInstance.value?.getCanvasData();
|
||||
// Size the box as percentage so that it stays visible for both small and large images
|
||||
const boxSize = Math.max(16, (canvasData?.naturalWidth ?? 0) * 0.1);
|
||||
let centeredX = 0;
|
||||
let centeredY = 0;
|
||||
|
||||
if (imageData.value && imageData.value.focal_point_x !== null && imageData.value.focal_point_y !== null) {
|
||||
centeredX = imageData.value.focal_point_x - boxSize / 2;
|
||||
centeredY = imageData.value.focal_point_y - boxSize / 2;
|
||||
} else {
|
||||
centeredX = (canvasData?.naturalWidth ?? 0) / 2 - boxSize / 2;
|
||||
centeredY = (canvasData?.naturalHeight ?? 0) / 2 - boxSize / 2;
|
||||
}
|
||||
|
||||
aspectRatio.value = 1;
|
||||
cropperInstance.value?.setData({ x: centeredX, y: centeredY, width: boxSize, height: boxSize });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -290,6 +376,7 @@ function useCropper() {
|
||||
}
|
||||
|
||||
localCropping.value = false;
|
||||
localDragMode.value = 'move';
|
||||
|
||||
cropperInstance.value = new Cropper(imageElement.value as HTMLImageElement, {
|
||||
autoCrop: false,
|
||||
@@ -347,7 +434,7 @@ function useCropper() {
|
||||
}
|
||||
|
||||
function setAspectRatio() {
|
||||
if (imageData.value) {
|
||||
if (imageData.value && imageData.value.width && imageData.value.height) {
|
||||
aspectRatio.value = imageData.value.width / imageData.value.height;
|
||||
}
|
||||
}
|
||||
@@ -375,19 +462,35 @@ function setAspectRatio() {
|
||||
|
||||
<v-notice v-else-if="error" type="error">error</v-notice>
|
||||
|
||||
<div v-if="imageData && !loading && !error" class="editor-container">
|
||||
<div
|
||||
v-if="imageData && !loading && !error"
|
||||
class="editor-container"
|
||||
:class="{ 'focal-point': localDragMode === 'focal_point' }"
|
||||
>
|
||||
<div class="editor">
|
||||
<img ref="imageElement" :src="imageURL" role="presentation" alt="" @load="onImageLoad" />
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div
|
||||
v-tooltip.top.inverted="t('drag_mode')"
|
||||
class="drag-mode toolbar-button"
|
||||
@click="dragMode = dragMode === 'crop' ? 'move' : 'crop'"
|
||||
>
|
||||
<v-icon name="pan_tool" :class="{ active: dragMode === 'move' }" />
|
||||
<v-icon name="crop" :class="{ active: dragMode === 'crop' }" />
|
||||
<div class="drag-mode toolbar-button">
|
||||
<v-icon
|
||||
v-tooltip.top.inverted="t('move_tool')"
|
||||
name="pan_tool"
|
||||
:class="{ active: localDragMode === 'move' }"
|
||||
@click="dragMode = 'move'"
|
||||
/>
|
||||
<v-icon
|
||||
v-tooltip.top.inverted="t('crop_tool')"
|
||||
name="crop"
|
||||
:class="{ active: localDragMode === 'crop' }"
|
||||
@click="dragMode = 'crop'"
|
||||
/>
|
||||
<v-icon
|
||||
v-tooltip.top.inverted="t('focal_point_tool')"
|
||||
name="location_searching"
|
||||
:class="{ active: localDragMode === 'focal_point' }"
|
||||
@click="dragMode = 'focal_point'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-icon v-tooltip.top.inverted="t('rotate')" name="rotate_90_degrees_ccw" clickable @click="rotate" />
|
||||
@@ -445,7 +548,7 @@ function setAspectRatio() {
|
||||
<v-list-item-content>{{ t('free') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="imageData"
|
||||
v-if="imageData && imageData.width && imageData.height"
|
||||
clickable
|
||||
:active="aspectRatio === imageData.width / imageData.height"
|
||||
@click="setAspectRatio"
|
||||
@@ -465,13 +568,13 @@ function setAspectRatio() {
|
||||
</div>
|
||||
|
||||
<button v-show="cropping" class="toolbar-button cancel" @click="cropping = false">
|
||||
{{ t('cancel_crop') }}
|
||||
{{ localDragMode === 'focal_point' ? t('cancel_selection') : t('cancel_crop') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<v-button v-tooltip.bottom="t('save')" :loading="saving" icon rounded @click="save">
|
||||
<v-button v-tooltip.bottom="t('save')" :loading="saving" icon rounded :disabled="!hasEdits" @click="save">
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
@@ -479,6 +582,22 @@ function setAspectRatio() {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.focal-point:deep(.editor) {
|
||||
.cropper-point.point-nw,
|
||||
.cropper-point.point-ne,
|
||||
.cropper-point.point-se,
|
||||
.cropper-point.point-sw,
|
||||
.cropper-dashed,
|
||||
.cropper-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cropper-face,
|
||||
.cropper-view-box {
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
--v-drawer-content-padding-small: 0px;
|
||||
--v-drawer-content-padding: 0px;
|
||||
@@ -554,6 +673,11 @@ function setAspectRatio() {
|
||||
.drag-mode {
|
||||
margin-right: 16px;
|
||||
margin-left: -8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.v-icon {
|
||||
margin-right: 0;
|
||||
@@ -563,10 +687,6 @@ function setAspectRatio() {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.v-icon:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel {
|
||||
|
||||
@@ -238,6 +238,12 @@ const result = await client.request(readAssetRaw('1ac73658-8b62-4dea-b6da-529fbc
|
||||
</template>
|
||||
</SnippetToggler>
|
||||
|
||||
### Focal Points
|
||||
|
||||
Directus will crop assets when requested with a `width` or `height` query parameter. By default, images are cropped
|
||||
around the center of the image. If `focal_point_x` and `focal_point_y` values are stored in the file object, cropping
|
||||
will center around these coordinates.
|
||||
|
||||
## The File Object
|
||||
|
||||
`id` **uuid**\
|
||||
@@ -281,6 +287,12 @@ This property is only auto-extracted for images.
|
||||
If the file is a(n) image/video, it's the height in px.\
|
||||
This property is only auto-extracted for images.
|
||||
|
||||
`focal_point_x` **number**\
|
||||
If the file is an image, cropping will center around this point.
|
||||
|
||||
`focal_point_y` **number**\
|
||||
If the file is an image, cropping will center around this point.
|
||||
|
||||
`duration` **number**\
|
||||
If the file contains audio/video, it's the duration in milliseconds.\
|
||||
This property is not auto-extracted.
|
||||
@@ -313,6 +325,8 @@ Any additional metadata Directus was able to scrape from the file. For images, t
|
||||
"filesize": 3442252,
|
||||
"width": 3456,
|
||||
"height": 5184,
|
||||
"focal_point_x": null,
|
||||
"focal_point_y": null,
|
||||
"duration": null,
|
||||
"description": null,
|
||||
"location": null,
|
||||
|
||||
@@ -114,11 +114,7 @@ The file sidebar also includes the following details, which are not editable and
|
||||
|
||||
## Edit an Image
|
||||
|
||||
Rotate, crop, flip, or adjust aspect ratios of an image.
|
||||
|
||||
<video alt="Edit an Image" loop muted controls autoplay playsinline>
|
||||
<source src="https://cdn.directus.io/docs/v9/app-guide/file-library/file-library-20220516A/edit-an-image-20220516A.mp4" type="video/mp4">
|
||||
</video>
|
||||
Rotate, crop, flip, adjust aspect ratios, or set focal points of an image.
|
||||
|
||||
1. From the **File Library**, click a file to open its detail page.
|
||||
2. Click the <span mi btn sec>tune</span> button in the top right to open the image editor.
|
||||
@@ -130,6 +126,16 @@ Edits overwrite the original file on disk. This can't be reversed.
|
||||
|
||||
:::
|
||||
|
||||
## Set a Focal Point
|
||||
|
||||
By default, Directus will crop images around the center when specific sizes are requested. Focal points change the
|
||||
center point to specific coordinates.
|
||||
|
||||
1. From the **File Library**, click a file to open its detail page.
|
||||
2. Click the <span mi btn sec>tune</span> button in the top right to open the image editor.
|
||||
3. Click the <span mi btn sec>location_searching</span> button to select a focal point.
|
||||
4. Make your changes and click <span mi btn>check</span> in the top right to save the updates.
|
||||
|
||||
## Upload a File
|
||||
|
||||
We covered the File Library's three upload methods in [How it Works](#how-it-works). Keep in mind that files can also be
|
||||
|
||||
@@ -22,6 +22,8 @@ export type File = {
|
||||
location: string | null;
|
||||
tags: string | null;
|
||||
metadata: Record<string, any> | null;
|
||||
focal_point_x: number | null;
|
||||
focal_point_y: number | null;
|
||||
};
|
||||
|
||||
export type BusboyFileStream = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MergeCoreCollection } from '../index.js';
|
||||
import type { DirectusUser } from './user.js';
|
||||
import type { DirectusFolder } from './folder.js';
|
||||
import type { DirectusUser } from './user.js';
|
||||
|
||||
// Base type for directus_files
|
||||
export type DirectusFile<Schema extends object> = MergeCoreCollection<
|
||||
@@ -28,5 +28,7 @@ export type DirectusFile<Schema extends object> = MergeCoreCollection<
|
||||
location: string | null;
|
||||
tags: string[] | null;
|
||||
metadata: Record<string, any> | null;
|
||||
focal_point_x: number | null;
|
||||
focal_point_y: number | null;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -11,5 +11,7 @@ export type AssetsQuery =
|
||||
quality?: number;
|
||||
withoutEnlargement?: boolean;
|
||||
format?: 'auto' | 'jpg' | 'png' | 'webp' | 'tiff';
|
||||
focal_point_x?: number;
|
||||
focal_point_y?: number;
|
||||
transforms?: [string, ...any[]][];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user