From 062c8f23f602f682aebcb0c015e43155d3ff6715 Mon Sep 17 00:00:00 2001 From: Daniel Biegler Date: Mon, 22 Jan 2024 18:35:06 +0100 Subject: [PATCH] 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 1df186834273261ac1a6f0dea40be7e246e2601f. 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 * update file type Co-authored-by: Brainslug * update changeset Co-authored-by: Pascal Jufer * improve type for `ASSET_TRANSFORM_QUERY_KEYS` Co-authored-by: Brainslug * Apply suggestions from code review Trying out the batch change feature from github for the first time. Lets see. Co-authored-by: Pascal Jufer * 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 * from -> around Co-authored-by: Daniel Biegler * from -> around --------- Co-authored-by: Daniel Biegler * add changeset for docs * run prettier lets goooooooo * move & show focal point fields and add divider --------- Co-authored-by: Brainslug Co-authored-by: Pascal Jufer Co-authored-by: Kevin Lewis --- .changeset/cold-cobras-carry.md | 5 + .changeset/loud-crews-fix.md | 8 + api/src/constants.ts | 6 +- .../migrations/20231215A-add-focalpoints.ts | 15 ++ .../database/system-data/fields/files.yaml | 16 ++ api/src/services/assets.ts | 5 +- api/src/types/assets.ts | 2 + api/src/utils/transformations.test.ts | 156 +++++++++++- api/src/utils/transformations.ts | 143 ++++++++++- app/src/lang/translations/en-US.yaml | 5 + .../views/private/components/image-editor.vue | 238 +++++++++++++----- docs/reference/files.md | 14 ++ docs/user-guide/file-library/files.md | 16 +- packages/types/src/files.ts | 2 + sdk/src/schema/file.ts | 4 +- sdk/src/types/assets.ts | 2 + 16 files changed, 548 insertions(+), 89 deletions(-) create mode 100644 .changeset/cold-cobras-carry.md create mode 100644 .changeset/loud-crews-fix.md create mode 100644 api/src/database/migrations/20231215A-add-focalpoints.ts diff --git a/.changeset/cold-cobras-carry.md b/.changeset/cold-cobras-carry.md new file mode 100644 index 0000000000..03190cfb9a --- /dev/null +++ b/.changeset/cold-cobras-carry.md @@ -0,0 +1,5 @@ +--- +"docs": patch +--- + +Added docs for focal point support diff --git a/.changeset/loud-crews-fix.md b/.changeset/loud-crews-fix.md new file mode 100644 index 0000000000..5d695f3b11 --- /dev/null +++ b/.changeset/loud-crews-fix.md @@ -0,0 +1,8 @@ +--- +"@directus/app": minor +"@directus/api": minor +"@directus/types": patch +"@directus/sdk": patch +--- + +Added focal point support for images diff --git a/api/src/constants.ts b/api/src/constants.ts index ee152939c9..2732af8fff 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -38,7 +38,7 @@ export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [ }, ]; -export const ASSET_TRANSFORM_QUERY_KEYS: Array = [ +export const ASSET_TRANSFORM_QUERY_KEYS = [ 'key', 'transforms', 'width', @@ -47,7 +47,9 @@ export const ASSET_TRANSFORM_QUERY_KEYS: Array = [ 'fit', 'quality', 'withoutEnlargement', -]; + 'focal_point_x', + 'focal_point_y', +] as const satisfies Readonly<(keyof TransformationParams)[]>; export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE']; diff --git a/api/src/database/migrations/20231215A-add-focalpoints.ts b/api/src/database/migrations/20231215A-add-focalpoints.ts new file mode 100644 index 0000000000..3f2e421a4b --- /dev/null +++ b/api/src/database/migrations/20231215A-add-focalpoints.ts @@ -0,0 +1,15 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.alterTable('directus_files', (table) => { + table.dropColumn('focal_point_x'); + table.dropColumn('focal_point_y'); + }); +} diff --git a/api/src/database/system-data/fields/files.yaml b/api/src/database/system-data/fields/files.yaml index d3e9dfb5d4..2cb51e8999 100644 --- a/api/src/database/system-data/fields/files.yaml +++ b/api/src/database/system-data/fields/files.yaml @@ -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: diff --git a/api/src/services/assets.ts b/api/src/services/assets.ts index be8be64395..8e50a20fb4 100644 --- a/api/src/services/assets.ts +++ b/api/src/services/assets.ts @@ -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(); diff --git a/api/src/types/assets.ts b/api/src/types/assets.ts index adb2b3e305..3e7fde697f 100644 --- a/api/src/types/assets.ts +++ b/api/src/types/assets.ts @@ -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 = { diff --git a/api/src/utils/transformations.test.ts b/api/src/utils/transformations.test.ts index 9d3f08aac4..e80f7b0a60 100644 --- a/api/src/utils/transformations.test.ts +++ b/api/src/utils/transformations.test.ts @@ -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', diff --git a/api/src/utils/transformations.ts b/api/src/utils/transformations.ts index 094f0a9591..1c15f42f14 100644 --- a/api/src/utils/transformations.ts +++ b/api/src/utils/transformations.ts @@ -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, + }; +} diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 3af40dd049..480cf64caa 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -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: diff --git a/app/src/views/private/components/image-editor.vue b/app/src/views/private/components/image-editor.vue index eb254400ec..7dd0e44b57 100644 --- a/app/src/views/private/components/image-editor.vue +++ b/app/src/views/private/components/image-editor.vue @@ -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; 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(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((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); + } + + 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(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() { error -
+
-
- - +
+ + +
@@ -445,7 +548,7 @@ function setAspectRatio() { {{ t('free') }}
@@ -479,6 +582,22 @@ function setAspectRatio() {