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:
Daniel Biegler
2024-01-22 18:35:06 +01:00
committed by GitHub
parent 1f4253432c
commit 062c8f23f6
16 changed files with 548 additions and 89 deletions

View File

@@ -0,0 +1,5 @@
---
"docs": patch
---
Added docs for focal point support

View File

@@ -0,0 +1,8 @@
---
"@directus/app": minor
"@directus/api": minor
"@directus/types": patch
"@directus/sdk": patch
---
Added focal point support for images

View File

@@ -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'];

View 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');
});
}

View File

@@ -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:

View File

@@ -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();

View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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,
};
}

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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;
}
>;

View File

@@ -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[]][];
};