Compare commits

..

1 Commits

Author SHA1 Message Date
Min Idzelis 9cd5d9e218 refactor(web): decouple FaceEditor from DOM element
Change-Id: I71f07ba8d0bc2d829c0b2af4da5ee5bc6a6a6964
2026-06-03 14:33:41 +00:00
8 changed files with 203 additions and 176 deletions
@@ -10,7 +10,6 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
@@ -103,10 +102,9 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
@@ -119,29 +117,17 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
ocrManager.clear();
selectedStackAsset = await assetCacheManager.getAsset({ id });
if (!sharedLink) {
await ocrManager.getAssetOcr(id);
}
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (!cursor.current.stack) {
stack = undefined;
selectedStackAsset = undefined;
return;
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
}
stack = await getStack({ id: cursor.current.stack.id });
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
};
@@ -199,21 +185,11 @@
onClose?.(asset.id);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const refreshedAsset = await assetCacheManager.getAsset({ id });
assetViewerManager.setAsset(refreshedAsset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
await refreshPreservingSelection();
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
}
assetViewerManager.closeEditor();
};
@@ -328,6 +304,10 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -340,7 +320,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack ?? undefined;
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -348,7 +328,7 @@
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack ?? undefined;
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
@@ -411,7 +391,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
cursor.current;
asset;
untrack(() => handlePromiseError(refresh()));
});
@@ -580,12 +560,7 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
@@ -642,7 +617,7 @@
translate="yes"
>
{#if showDetailPanel}
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
<DetailPanel {asset} currentAlbum={album} />
{:else if assetViewerManager.isShowEditor}
<EditorPanel {asset} onClose={closeEditor} />
{/if}
@@ -654,24 +629,27 @@
<div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full">
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
<div
class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']}
style:bottom={isSelected ? '0' : '-10px'}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': isSelected }}
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={!isSelected}
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => selectStackedAsset(stackedAsset.id)}
onClick={() => {
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if isSelected}
{#if stackedAsset.id === asset.id}
<div class="flex w-full place-content-center place-items-center">
<div class="mt-0.5 flex size-2 rounded-full bg-white"></div>
</div>
@@ -16,7 +16,13 @@
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
@@ -31,10 +37,9 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let latlng = $derived(
@@ -89,6 +94,11 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
assetViewerManager.closeEditFacesPanel();
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -375,6 +385,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={() => void onRefreshPeople?.()}
onRefresh={handleRefreshPeople}
/>
{/if}
@@ -31,10 +31,9 @@
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -287,13 +286,11 @@
{/snippet}
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
<FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
imageSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
{onTagFace}
/>
{/if}
</div>
@@ -308,9 +308,31 @@
let containerHeight = $state(0);
$effect(() => {
if (assetViewerManager.isFaceEditMode) {
videoPlayer?.pause();
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
return;
}
videoPlayer.pause();
const { videoWidth, videoHeight } = videoPlayer;
if (videoWidth === 0 || videoHeight === 0) {
return;
}
const canvas = document.createElement('canvas');
canvas.width = videoWidth;
canvas.height = videoHeight;
canvas.getContext('2d')?.drawImage(videoPlayer, 0, 0);
const img = new Image();
const onImageLoad = () => (assetViewerManager.imgRef = img);
img.addEventListener('load', onImageLoad);
img.src = canvas.toDataURL('image/png');
return () => {
img.removeEventListener('load', onImageLoad);
img.src = '';
assetViewerManager.imgRef = undefined;
};
});
// The time is only refreshed on HLS fragment decode by default,
@@ -454,8 +476,12 @@
</div>
{/if}
{#if assetViewerManager.isFaceEditMode && videoPlayer}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{#if assetViewerManager.isFaceEditMode}
<FaceEditor
imageSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
containerSize={{ width: containerWidth, height: containerHeight }}
{assetId}
/>
{/if}
{/if}
</div>
@@ -4,7 +4,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -14,14 +14,12 @@
import { t } from 'svelte-i18n';
type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
imageSize: Size;
containerSize: Size;
assetId: string;
onTagFace?: () => Promise<void>;
};
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let { imageSize, containerSize, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -55,7 +53,7 @@
};
const setupCanvas = () => {
if (!canvasEl || !htmlElement) {
if (!canvasEl) {
return;
}
@@ -87,17 +85,7 @@
searchInputEl?.focus();
});
const imageContentMetrics = $derived.by(() => {
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
return {
contentWidth,
contentHeight,
offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
};
});
const imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics;
@@ -117,8 +105,8 @@
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
width: containerSize.width,
height: containerSize.height,
});
if (!faceRect) {
@@ -176,11 +164,11 @@
};
const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight;
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
@@ -239,61 +227,42 @@
});
const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) {
if (!faceRect || imageContentMetrics.contentWidth === 0) {
return;
}
const { left, top, width, height } = faceRect.getBoundingRect();
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / contentWidth;
const scaleY = natural.height / contentHeight;
const imageX = (left - offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY;
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, imageSize);
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
imageWidth: imageSize.width,
imageHeight: imageSize.height,
x: Math.floor(imageRect.left),
y: Math.floor(imageRect.top),
width: Math.floor(imageRect.width),
height: Math.floor(imageRect.height),
};
};
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) {
const imgRef = assetViewerManager.imgRef;
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return;
}
const natural = getNaturalSize(htmlElement);
if (natural.width <= 0 || natural.height <= 0) {
return;
}
const x = clamp(data.x, 0, natural.width - 1);
const y = clamp(data.y, 0, natural.height - 1);
const width = clamp(data.width, 1, natural.width - x);
const height = clamp(data.height, 1, natural.height - y);
if (width <= 0 || height <= 0) {
return;
}
const scaleX = imgRef.naturalWidth / imageSize.width;
const scaleY = imgRef.naturalHeight / imageSize.height;
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
try {
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
canvas.getContext('2d')?.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
return canvas.toDataURL('image/png');
} catch {
return;
@@ -326,7 +295,7 @@
},
});
await onTagFace?.();
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
@@ -178,10 +178,7 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}
+67 -33
View File
@@ -1,18 +1,15 @@
import {
getContentMetrics,
computeContentMetrics,
getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent,
mapNormalizedToContent,
scaleToCover,
scaleToFit,
} from '$lib/utils/container-utils';
const mockImage = (props: {
naturalWidth: number;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
props as unknown as HTMLImageElement;
const mockVideo = (props: {
videoWidth: number;
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
});
});
describe('getContentMetrics', () => {
it('should compute zero offsets when aspect ratios match', () => {
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
expect(getContentMetrics(img)).toEqual({
describe('computeContentMetrics', () => {
it('should return zero metrics for zero-width content', () => {
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({
contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should return zero metrics for zero-height content', () => {
expect(computeContentMetrics({ width: 1920, height: 0 }, { width: 800, height: 600 })).toEqual({
contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should center wide content vertically', () => {
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 800,
contentHeight: 400,
offsetX: 0,
offsetY: 100,
});
});
it('should center tall content horizontally', () => {
expect(computeContentMetrics({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 300,
contentHeight: 600,
offsetX: 250,
offsetY: 0,
});
});
it('should produce zero offsets when aspect ratios match', () => {
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
contentWidth: 800,
contentHeight: 450,
offsetX: 0,
offsetY: 0,
});
});
});
it('should compute horizontal letterbox offsets for tall image', () => {
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(300);
expect(metrics.contentHeight).toBe(600);
expect(metrics.offsetX).toBe(250);
expect(metrics.offsetY).toBe(0);
describe('mapContentRectToNatural', () => {
it('should map a full-content rect back to natural size', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
width: 2000,
height: 1000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
});
it('should compute vertical letterbox offsets for wide image', () => {
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(800);
expect(metrics.contentHeight).toBe(400);
expect(metrics.offsetX).toBe(0);
expect(metrics.offsetY).toBe(100);
it('should map a centered sub-rect to natural coordinates', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
width: 2000,
height: 1000,
});
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
});
it('should use clientWidth/clientHeight for video elements', () => {
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
const metrics = getContentMetrics(video);
expect(metrics.contentWidth).toBe(800);
expect(metrics.contentHeight).toBe(450);
expect(metrics.offsetX).toBe(0);
expect(metrics.offsetY).toBe(75);
it('should handle letterboxed content with horizontal offset', () => {
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
width: 1000,
height: 2000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
});
});
describe('getNaturalSize', () => {
it('should return naturalWidth/naturalHeight for images', () => {
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
});
+30 -14
View File
@@ -49,13 +49,6 @@ export const scaleToFit = (dimensions: Size, container: Size): Size => {
};
};
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight };
@@ -63,17 +56,18 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
return { width: element.naturalWidth, height: element.naturalHeight };
};
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
const natural = getNaturalSize(element);
const client = getElementSize(element);
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
export function computeContentMetrics(imageSize: Size, containerSize: Size): ContentMetrics {
if (imageSize.width === 0 || imageSize.height === 0) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const { width: contentWidth, height: contentHeight } = scaleToFit(imageSize, containerSize);
return {
contentWidth,
contentHeight,
offsetX: (client.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2,
offsetX: (containerSize.width - contentWidth) / 2,
offsetY: (containerSize.height - contentHeight) / 2,
};
};
}
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
@@ -109,3 +103,25 @@ export function mapNormalizedRectToContent(
height: br.y - tl.y,
};
}
export function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
return {
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
};
}
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
const bottomRight = mapContentToNatural(
{ x: rect.left + rect.width, y: rect.top + rect.height },
metrics,
naturalSize,
);
return {
top: topLeft.y,
left: topLeft.x,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
};
}