feat(variant): image preview on variant creation with a real sample image
This commit is contained in:
1 parent
a1cdfce529
commit
04dc151ed7
9 files changed
+314
-40
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -17,6 +17,8 @@ const CHIP =
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string
|
name: string
|
||||||
comparable: boolean
|
comparable: boolean
|
||||||
|
originalSrc?: string | null
|
||||||
|
variantSrc?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const position = defineModel<number>({ default: 50 })
|
const position = defineModel<number>({ default: 50 })
|
||||||
@@ -64,7 +66,13 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
|
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
|
||||||
:class="PATTERN"
|
:class="PATTERN"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-image" class="size-8 opacity-55" />
|
<img
|
||||||
|
v-if="originalSrc"
|
||||||
|
:src="originalSrc"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 size-full object-cover"
|
||||||
|
/>
|
||||||
|
<UIcon v-else name="i-lucide-image" class="size-8 opacity-55" />
|
||||||
</div>
|
</div>
|
||||||
<template v-if="comparable">
|
<template v-if="comparable">
|
||||||
<div
|
<div
|
||||||
@@ -72,7 +80,14 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
class="absolute inset-0 bg-accented"
|
class="absolute inset-0 bg-accented"
|
||||||
:class="VARIANT_PATTERN"
|
:class="VARIANT_PATTERN"
|
||||||
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
|
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
|
||||||
/>
|
>
|
||||||
|
<img
|
||||||
|
v-if="variantSrc"
|
||||||
|
:src="variantSrc"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 size-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
|
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
|
||||||
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
|
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { t } from '@/i18n'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import ImageCompare from '@/components/variants/ImageCompare.vue'
|
import ImageCompare from '@/components/variants/ImageCompare.vue'
|
||||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||||
import { SAMPLE_SOURCE, estimateLabel, estimateVariant } from '@/lib/variants/variantEstimate'
|
import { useVariantRender } from '@/composables/useVariantRender'
|
||||||
|
import {
|
||||||
|
SAMPLE_SOURCE,
|
||||||
|
estimateLabel,
|
||||||
|
estimateVariant,
|
||||||
|
type EstimateRequest,
|
||||||
|
} from '@/lib/variants/variantEstimate'
|
||||||
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
|
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
|
||||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||||
|
|
||||||
@@ -25,22 +31,31 @@ const dimensionsAreValid = computed(
|
|||||||
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
|
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
|
||||||
)
|
)
|
||||||
|
|
||||||
const estimate = computed(() =>
|
const request = computed<EstimateRequest | null>(() =>
|
||||||
dimensionsAreValid.value
|
dimensionsAreValid.value
|
||||||
? estimateVariant({
|
? {
|
||||||
width: parseDimension(props.values.width),
|
width: parseDimension(props.values.width),
|
||||||
height: parseDimension(props.values.height),
|
height: parseDimension(props.values.height),
|
||||||
fit: props.values.fit,
|
fit: props.values.fit,
|
||||||
format: props.values.format,
|
format: props.values.format,
|
||||||
quality: sliderValue(props.values.quality),
|
quality: sliderValue(props.values.quality),
|
||||||
})
|
}
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const estimate = computed(() => (request.value ? estimateVariant(request.value) : null))
|
||||||
|
const { original, variant } = useVariantRender(request)
|
||||||
|
|
||||||
const shownName = computed(() => props.values.name || t('name'))
|
const shownName = computed(() => props.values.name || t('name'))
|
||||||
const outputLine = computed(() =>
|
const outputLine = computed(() => {
|
||||||
estimate.value ? estimateLabel(estimate.value) : t('No output yet'),
|
if (!estimate.value) {
|
||||||
)
|
return t('No output yet')
|
||||||
|
}
|
||||||
|
const bytes = variant.value
|
||||||
|
? Math.min(SAMPLE_SOURCE.bytes, variant.value.bytes)
|
||||||
|
: estimate.value.bytes
|
||||||
|
return estimateLabel({ size: estimate.value.size, bytes })
|
||||||
|
})
|
||||||
const originalLine = computed(
|
const originalLine = computed(
|
||||||
() =>
|
() =>
|
||||||
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
|
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
|
||||||
@@ -67,7 +82,13 @@ async function onCopy() {
|
|||||||
t('Sample: {0}', [SAMPLE_SOURCE.filename])
|
t('Sample: {0}', [SAMPLE_SOURCE.filename])
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<ImageCompare v-model="position" :name="values.name" :comparable="estimate !== null" />
|
<ImageCompare
|
||||||
|
v-model="position"
|
||||||
|
:name="values.name"
|
||||||
|
:comparable="estimate !== null"
|
||||||
|
:original-src="original"
|
||||||
|
:variant-src="variant?.url ?? null"
|
||||||
|
/>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
|
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||||
|
import sampleUrl from '@/assets/variant-sample.jpg'
|
||||||
|
import { SAMPLE_SOURCE, type EstimateRequest } from '@/lib/variants/variantEstimate'
|
||||||
|
import { outputSize } from '@/lib/variants/variantOutput'
|
||||||
|
import { canRender, drawPlan, flattensTransparency, mimeFor } from '@/lib/variants/variantRender'
|
||||||
|
|
||||||
|
const RENDER_DELAY_MS = 150
|
||||||
|
const PERCENT = 100
|
||||||
|
|
||||||
|
export interface RenderedVariant {
|
||||||
|
url: string
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let sampleImage: Promise<HTMLImageElement | null> | null = null
|
||||||
|
|
||||||
|
function loadSample() {
|
||||||
|
sampleImage ??= new Promise((resolve) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.onload = () => resolve(image)
|
||||||
|
image.onerror = () => resolve(null)
|
||||||
|
image.src = sampleUrl
|
||||||
|
})
|
||||||
|
return sampleImage
|
||||||
|
}
|
||||||
|
|
||||||
|
function encode(canvas: HTMLCanvasElement, request: EstimateRequest) {
|
||||||
|
const mime = mimeFor(request.format)
|
||||||
|
const quality = mime === 'image/png' ? undefined : request.quality / PERCENT
|
||||||
|
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mime, quality))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderVariant(image: HTMLImageElement, request: EstimateRequest) {
|
||||||
|
const size = outputSize(SAMPLE_SOURCE, request)
|
||||||
|
if (size === null || !canRender(size)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = size.width
|
||||||
|
canvas.height = size.height
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (context === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (flattensTransparency(request.format)) {
|
||||||
|
context.fillStyle = 'white'
|
||||||
|
context.fillRect(0, 0, size.width, size.height)
|
||||||
|
}
|
||||||
|
const plan = drawPlan(SAMPLE_SOURCE, size, request.fit)
|
||||||
|
context.drawImage(image, plan.sx, plan.sy, plan.sw, plan.sh, plan.dx, plan.dy, plan.dw, plan.dh)
|
||||||
|
return encode(canvas, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVariantRender(request: Ref<EstimateRequest | null>) {
|
||||||
|
const original = ref<string | null>(null)
|
||||||
|
const variant = ref<RenderedVariant | null>(null)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let token = 0
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
if (variant.value) {
|
||||||
|
URL.revokeObjectURL(variant.value.url)
|
||||||
|
variant.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(current: EstimateRequest | null, ticket: number) {
|
||||||
|
const image = await loadSample()
|
||||||
|
if (image === null || ticket !== token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
original.value = sampleUrl
|
||||||
|
const blob = current === null ? null : await renderVariant(image, current)
|
||||||
|
if (ticket !== token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
if (blob !== null) {
|
||||||
|
variant.value = { url: URL.createObjectURL(blob), bytes: blob.size }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule(current: EstimateRequest | null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
const ticket = ++token
|
||||||
|
timer = setTimeout(() => void run(current, ticket), RENDER_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(request, schedule, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
token++
|
||||||
|
release()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { original, variant }
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../variantEstimate'
|
} from '../variantEstimate'
|
||||||
import { outputSize } from '../variantOutput'
|
import { outputSize } from '../variantOutput'
|
||||||
|
|
||||||
const SAMPLE = { width: 4032, height: 3024 }
|
const SAMPLE = { width: 1500, height: 1000 }
|
||||||
|
|
||||||
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
|
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
|
||||||
return {
|
return {
|
||||||
@@ -28,11 +28,11 @@ function labelOf(overrides: Partial<EstimateRequest>) {
|
|||||||
describe('Output size, never enlarged', () => {
|
describe('Output size, never enlarged', () => {
|
||||||
it.each([
|
it.each([
|
||||||
[1200, 630, 'cover', '1200×630'],
|
[1200, 630, 'cover', '1200×630'],
|
||||||
[800, null, 'cover', '800×600'],
|
[800, null, 'cover', '800×533'],
|
||||||
[null, 2400, 'inside', '3200×2400'],
|
[null, 2400, 'inside', '1500×1000'],
|
||||||
[600, 600, 'contain', '600×600'],
|
[600, 600, 'contain', '600×600'],
|
||||||
[1000, 1000, 'inside', '1000×750'],
|
[1000, 1000, 'inside', '1000×667'],
|
||||||
[8000, 8000, 'cover', '4032×3024'],
|
[8000, 8000, 'cover', '1500×1000'],
|
||||||
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
|
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
|
||||||
const size = outputSize(SAMPLE, { width, height, fit })
|
const size = outputSize(SAMPLE, { width, height, fit })
|
||||||
|
|
||||||
@@ -61,10 +61,10 @@ describe('Output size, never enlarged', () => {
|
|||||||
|
|
||||||
describe('Size estimate', () => {
|
describe('Size estimate', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 96 KB'],
|
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 30 KB'],
|
||||||
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 71 KB'],
|
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 22 KB'],
|
||||||
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 310 KB'],
|
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 84 KB'],
|
||||||
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 18 KB'],
|
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 6 KB'],
|
||||||
] as const)(
|
] as const)(
|
||||||
'%s %s×%s %s %s q%s reads %s',
|
'%s %s×%s %s %s q%s reads %s',
|
||||||
(_name, width, height, fit, format, quality, expected) => {
|
(_name, width, height, fit, format, quality, expected) => {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canRender, drawPlan, flattensTransparency, mimeFor } from '../variantRender'
|
||||||
|
|
||||||
|
const SOURCE = { width: 1500, height: 1000 }
|
||||||
|
|
||||||
|
describe('Variant render', () => {
|
||||||
|
it.each([
|
||||||
|
['png', 'image/png'],
|
||||||
|
['webp', 'image/webp'],
|
||||||
|
['jpeg', 'image/jpeg'],
|
||||||
|
['original', 'image/jpeg'],
|
||||||
|
] as const)('encodes %s as %s', (format, mime) => {
|
||||||
|
expect(mimeFor(format)).toBe(mime)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('crops the centre of the source to fill a wider box', () => {
|
||||||
|
const plan = drawPlan(SOURCE, { width: 1200, height: 400 }, 'cover')
|
||||||
|
|
||||||
|
expect(plan).toEqual({
|
||||||
|
sx: 0,
|
||||||
|
sy: 250,
|
||||||
|
sw: 1500,
|
||||||
|
sh: 500,
|
||||||
|
dx: 0,
|
||||||
|
dy: 0,
|
||||||
|
dw: 1200,
|
||||||
|
dh: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the whole source when the ratio already matches', () => {
|
||||||
|
const plan = drawPlan(SOURCE, { width: 750, height: 500 }, 'inside')
|
||||||
|
|
||||||
|
expect([plan.sx, plan.sy, plan.sw, plan.sh]).toEqual([0, 0, 1500, 1000])
|
||||||
|
expect([plan.dx, plan.dy, plan.dw, plan.dh]).toEqual([0, 0, 750, 500])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('letterboxes the whole source inside a contain box', () => {
|
||||||
|
const plan = drawPlan(SOURCE, { width: 600, height: 600 }, 'contain')
|
||||||
|
|
||||||
|
expect(plan).toEqual({
|
||||||
|
sx: 0,
|
||||||
|
sy: 0,
|
||||||
|
sw: 1500,
|
||||||
|
sh: 1000,
|
||||||
|
dx: 0,
|
||||||
|
dy: 100,
|
||||||
|
dw: 600,
|
||||||
|
dh: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never enlarges the source inside a contain box', () => {
|
||||||
|
const plan = drawPlan(SOURCE, { width: 3000, height: 3000 }, 'contain')
|
||||||
|
|
||||||
|
expect([plan.dx, plan.dy, plan.dw, plan.dh]).toEqual([750, 1000, 1500, 1000])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['jpeg', true],
|
||||||
|
['original', true],
|
||||||
|
['png', false],
|
||||||
|
['webp', false],
|
||||||
|
] as const)('flattens transparency for %s: %s', (format, flattens) => {
|
||||||
|
expect(flattensTransparency(format)).toBe(flattens)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to render huge outputs', () => {
|
||||||
|
expect(canRender({ width: 4000, height: 4000 })).toBe(true)
|
||||||
|
expect(canRender({ width: 8000, height: 8000 })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,23 +5,23 @@ import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/v
|
|||||||
|
|
||||||
export type { PixelSize } from '@/lib/variants/variantOutput'
|
export type { PixelSize } from '@/lib/variants/variantOutput'
|
||||||
|
|
||||||
const BYTES_PER_MB = 1024 * 1024
|
|
||||||
const FULL_QUALITY = 100
|
const FULL_QUALITY = 100
|
||||||
|
const REFERENCE_QUALITY = 82
|
||||||
const PNG_BYTES_PER_PIXEL = 0.42
|
const PNG_BYTES_PER_PIXEL = 0.42
|
||||||
const SIZE_EXPONENT = 0.885
|
const SIZE_EXPONENT = 0.885
|
||||||
const QUALITY_EXPONENT = 1.9
|
const QUALITY_EXPONENT = 1.9
|
||||||
|
|
||||||
const LOSSY_BASE: Record<Exclude<VariantFormat, 'png'>, number> = {
|
const FORMAT_WEIGHT: Record<Exclude<VariantFormat, 'png'>, number> = {
|
||||||
webp: 0.9,
|
webp: 0.65,
|
||||||
jpeg: 1.4,
|
jpeg: 1,
|
||||||
original: 1.4,
|
original: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SAMPLE_SOURCE = {
|
export const SAMPLE_SOURCE = {
|
||||||
filename: 'north-wall.jpg',
|
filename: 'variant-sample.jpg',
|
||||||
width: 4032,
|
width: 1500,
|
||||||
height: 3024,
|
height: 1000,
|
||||||
bytes: Math.round(3.9 * BYTES_PER_MB),
|
bytes: 86_382,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EstimateRequest extends OutputRequest {
|
export interface EstimateRequest extends OutputRequest {
|
||||||
@@ -38,10 +38,12 @@ function rawBytes(pixels: number, request: EstimateRequest) {
|
|||||||
if (request.format === 'png') {
|
if (request.format === 'png') {
|
||||||
return pixels * PNG_BYTES_PER_PIXEL
|
return pixels * PNG_BYTES_PER_PIXEL
|
||||||
}
|
}
|
||||||
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / FULL_QUALITY
|
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / REFERENCE_QUALITY
|
||||||
|
const area = pixels / (SAMPLE_SOURCE.width * SAMPLE_SOURCE.height)
|
||||||
return (
|
return (
|
||||||
LOSSY_BASE[request.format] *
|
SAMPLE_SOURCE.bytes *
|
||||||
Math.pow(pixels, SIZE_EXPONENT) *
|
FORMAT_WEIGHT[request.format] *
|
||||||
|
Math.pow(area, SIZE_EXPONENT) *
|
||||||
Math.pow(quality, QUALITY_EXPONENT)
|
Math.pow(quality, QUALITY_EXPONENT)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { VariantFit, VariantFormat } from '@/composables/useImageVariants'
|
||||||
|
import type { PixelSize } from '@/lib/variants/variantOutput'
|
||||||
|
|
||||||
|
export const MAX_RENDER_PIXELS = 16_000_000
|
||||||
|
|
||||||
|
export interface DrawPlan {
|
||||||
|
sx: number
|
||||||
|
sy: number
|
||||||
|
sw: number
|
||||||
|
sh: number
|
||||||
|
dx: number
|
||||||
|
dy: number
|
||||||
|
dw: number
|
||||||
|
dh: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mimeFor(format: VariantFormat) {
|
||||||
|
if (format === 'png') {
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
if (format === 'webp') {
|
||||||
|
return 'image/webp'
|
||||||
|
}
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattensTransparency(format: VariantFormat) {
|
||||||
|
return mimeFor(format) === 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRender(size: PixelSize) {
|
||||||
|
return size.width * size.height <= MAX_RENDER_PIXELS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawPlan(source: PixelSize, target: PixelSize, fit: VariantFit): DrawPlan {
|
||||||
|
const widthScale = target.width / source.width
|
||||||
|
const heightScale = target.height / source.height
|
||||||
|
if (fit === 'contain') {
|
||||||
|
const scale = Math.min(1, widthScale, heightScale)
|
||||||
|
const dw = source.width * scale
|
||||||
|
const dh = source.height * scale
|
||||||
|
return {
|
||||||
|
sx: 0,
|
||||||
|
sy: 0,
|
||||||
|
sw: source.width,
|
||||||
|
sh: source.height,
|
||||||
|
dx: (target.width - dw) / 2,
|
||||||
|
dy: (target.height - dh) / 2,
|
||||||
|
dw,
|
||||||
|
dh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scale = Math.max(widthScale, heightScale)
|
||||||
|
const sw = target.width / scale
|
||||||
|
const sh = target.height / scale
|
||||||
|
return {
|
||||||
|
sx: (source.width - sw) / 2,
|
||||||
|
sy: (source.height - sh) / 2,
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
dx: 0,
|
||||||
|
dy: 0,
|
||||||
|
dw: target.width,
|
||||||
|
dh: target.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ const QUALITY_ERROR = 'Enter a value from 1 to 100.'
|
|||||||
const DIMENSION_ERROR = 'Set a width, a height, or both.'
|
const DIMENSION_ERROR = 'Set a width, a height, or both.'
|
||||||
const DIMENSION_HELP = 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.'
|
const DIMENSION_HELP = 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.'
|
||||||
const SAVE_ERROR_TITLE = "Couldn't save the variant"
|
const SAVE_ERROR_TITLE = "Couldn't save the variant"
|
||||||
const URL_PREFIX = '/static/uploadedImages/north-wall.jpg?v='
|
const URL_PREFIX = '/static/uploadedImages/variant-sample.jpg?v='
|
||||||
const WARNING_ALERT =
|
const WARNING_ALERT =
|
||||||
'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'
|
'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'
|
||||||
|
|
||||||
@@ -438,7 +438,7 @@ describe('Rule: Form states', () => {
|
|||||||
'i-lucide-circle-dot',
|
'i-lucide-circle-dot',
|
||||||
)
|
)
|
||||||
expect(previewText()).toContain('hero')
|
expect(previewText()).toContain('hero')
|
||||||
expect(previewText()).toContain('1200×630 · about 96 KB')
|
expect(previewText()).toContain('1200×630 · about 30 KB')
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -853,14 +853,14 @@ describe('Rule: Saving an edit', () => {
|
|||||||
it('Edit: dirty', async () => {
|
it('Edit: dirty', async () => {
|
||||||
await mountPage()
|
await mountPage()
|
||||||
await openEditForm('hero')
|
await openEditForm('hero')
|
||||||
expect(previewText()).toContain('1200×630 · about 96 KB')
|
expect(previewText()).toContain('1200×630 · about 30 KB')
|
||||||
|
|
||||||
await typeInto(qualityInput(), '70')
|
await typeInto(qualityInput(), '70')
|
||||||
|
|
||||||
expect(saveButton('Save changes').disabled).toBe(false)
|
expect(saveButton('Save changes').disabled).toBe(false)
|
||||||
expect(footerStatus()).toBe('Unsaved changes')
|
expect(footerStatus()).toBe('Unsaved changes')
|
||||||
expect(qualityInput()!.value).toBe('70')
|
expect(qualityInput()!.value).toBe('70')
|
||||||
expect(previewText()).toContain('1200×630 · about 71 KB')
|
expect(previewText()).toContain('1200×630 · about 22 KB')
|
||||||
expect(form().querySelector('[data-testid="edit-warning"]')).not.toBeNull()
|
expect(form().querySelector('[data-testid="edit-warning"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -954,13 +954,13 @@ describe('Rule: Preview and ImageCompare', () => {
|
|||||||
|
|
||||||
const panel = form()
|
const panel = form()
|
||||||
expect(panel.querySelector('h3')?.textContent).toBe('Preview')
|
expect(panel.querySelector('h3')?.textContent).toBe('Preview')
|
||||||
expect(panel.textContent).toContain('Sample: north-wall.jpg')
|
expect(panel.textContent).toContain('Sample: variant-sample.jpg')
|
||||||
const box = panel.querySelector('[data-testid="image-compare"]')!
|
const box = panel.querySelector('[data-testid="image-compare"]')!
|
||||||
expect(box.className).toContain('aspect-3/2')
|
expect(box.className).toContain('aspect-3/2')
|
||||||
expect(box.className).toContain('border')
|
expect(box.className).toContain('border')
|
||||||
expect(box.className).toContain('overflow-hidden')
|
expect(box.className).toContain('overflow-hidden')
|
||||||
const labels = box.nextElementSibling!
|
const labels = box.nextElementSibling!
|
||||||
expect(textOf(labels)).toBe('Original 4032 × 3024 · 3.9 MB hero 1200×630 · about 96 KB')
|
expect(textOf(labels)).toBe('Original 1500 × 1000 · 84 KB hero 1200×630 · about 30 KB')
|
||||||
expect(panel.textContent).toContain(
|
expect(panel.textContent).toContain(
|
||||||
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
|
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
|
||||||
)
|
)
|
||||||
@@ -1078,7 +1078,7 @@ describe('Rule: Preview and ImageCompare', () => {
|
|||||||
expect(overlay.className).toContain('bottom-3')
|
expect(overlay.className).toContain('bottom-3')
|
||||||
expect(overlay.className).toContain('left-3')
|
expect(overlay.className).toContain('left-3')
|
||||||
expect(textOf(box.nextElementSibling!)).toBe(
|
expect(textOf(box.nextElementSibling!)).toBe(
|
||||||
'Original 4032 × 3024 · 3.9 MB name No output yet',
|
'Original 1500 × 1000 · 84 KB name No output yet',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1089,7 +1089,7 @@ describe('Rule: Preview and ImageCompare', () => {
|
|||||||
await typeInto(field('width'), '800')
|
await typeInto(field('width'), '800')
|
||||||
|
|
||||||
expect(compareInput().getAttribute('aria-label')).toBe('Compare original and name')
|
expect(compareInput().getAttribute('aria-label')).toBe('Compare original and name')
|
||||||
expect(previewText()).toContain('name 800×600 · about')
|
expect(previewText()).toContain('name 800×533 · about')
|
||||||
expect(
|
expect(
|
||||||
Array.from(form().querySelectorAll('span.absolute.top-2')).map(
|
Array.from(form().querySelectorAll('span.absolute.top-2')).map(
|
||||||
(chip) => chip.textContent,
|
(chip) => chip.textContent,
|
||||||
@@ -1107,7 +1107,7 @@ describe('Rule: Preview and ImageCompare', () => {
|
|||||||
|
|
||||||
expect(labels.map(Number)).toEqual([...labels.map(Number)].sort((a, b) => a - b))
|
expect(labels.map(Number)).toEqual([...labels.map(Number)].sort((a, b) => a - b))
|
||||||
await setFormat('png')
|
await setFormat('png')
|
||||||
expect(previewText()).toContain('1200×630 · about 310 KB')
|
expect(previewText()).toContain('1200×630 · about 84 KB')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user