diff --git a/.changeset/path-picker-image-zoom.md b/.changeset/path-picker-image-zoom.md new file mode 100644 index 00000000..58c82c8e --- /dev/null +++ b/.changeset/path-picker-image-zoom.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +The path picker's image preview can now be zoomed: pinch and drag on a phone, the mouse wheel or a trackpad on a desktop, double-click to toggle 2x, and zoom out / fit / zoom in buttons. A small image is shown at its own size instead of being stretched to fill the preview. Thanks to @shenlvkang-collab, whose #608 this comes from. diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 6a526e9d..45abca96 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -74,6 +74,8 @@ const PathPicker = { _previewOverlay: null, _previewRequestSequence: 0, _previewPreviousFocus: null, + _previewImageCleanup: null, + _previewImageZoom: null, _showHidden: false, _sortMode: PATH_PICKER_DEFAULT_SORT, @@ -563,6 +565,11 @@ const PathPicker = { + Open @@ -580,13 +587,30 @@ const PathPicker = { const body = overlay.querySelector('.path-preview-body'); if (entry.previewKind === 'image') { + const zoomControls = overlay.querySelector('.path-preview-zoom'); + body.classList.add('path-preview-image-viewport'); const image = document.createElement('img'); image.className = 'path-preview-image'; image.alt = entry.name; - image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove()); - image.addEventListener('error', () => this.showPreviewError('Image preview failed to load')); + image.draggable = false; + // Both handlers check the sequence like the text branch: an image whose preview was already + // replaced can still finish loading, and `this._previewOverlay` and the zoom state are then + // the newer preview's. The controls show only once there is an image to zoom. + image.addEventListener('load', () => { + if (requestSequence !== this._previewRequestSequence) return; + body.querySelector('.path-preview-loading')?.remove(); + zoomControls.hidden = false; + this._setPreviewImageZoom(1); + }); + image.addEventListener('error', () => { + if (requestSequence !== this._previewRequestSequence) return; + zoomControls.hidden = true; + this._previewImageCleanup?.(); + this.showPreviewError('Image preview failed to load'); + }); image.src = previewUrl; body.appendChild(image); + this._bindPreviewImageGestures(image, body, zoomControls); } else if (entry.previewKind === 'text') { fetch(previewUrl) .then(async (response) => { @@ -630,8 +654,142 @@ const PathPicker = { body.replaceChildren(error); }, + _setPreviewImageZoom(scale, focalPoint) { + const state = this._previewImageZoom; + if (!state) return; + const nextScale = Math.max(1, Math.min(6, scale)); + if (focalPoint) { + const rect = state.body.getBoundingClientRect(); + const qx = focalPoint.x - (rect.left + rect.width / 2); + const qy = focalPoint.y - (rect.top + rect.height / 2); + const ratio = nextScale / state.scale; + state.x = qx + (state.x - qx) * ratio; + state.y = qy + (state.y - qy) * ratio; + } + state.scale = nextScale; + this._clampPreviewImagePan(); + state.image.style.transform = `translate(${state.x}px, ${state.y}px) scale(${state.scale})`; + const fitButton = state.controls.querySelector('[data-zoom="fit"]'); + if (fitButton) fitButton.textContent = `${Math.round(state.scale * 100)}%`; + state.image.classList.toggle('is-zoomed', state.scale > 1); + }, + + /** Size the image is drawn at before zoom: `object-fit: scale-down` in the viewport box, so never + * larger than its natural size. The box until the image has loaded (no natural size yet). */ + _previewImageFitSize(state, rect) { + const naturalWidth = state.image.naturalWidth; + const naturalHeight = state.image.naturalHeight; + if (!naturalWidth || !naturalHeight) return { width: rect.width, height: rect.height }; + const fit = Math.min(1, rect.width / naturalWidth, rect.height / naturalHeight); + return { width: naturalWidth * fit, height: naturalHeight * fit }; + }, + + /** Keep the zoomed image covering the viewport: it may move until its edge reaches the viewport's + * edge, never further, and an axis on which it is still smaller than the viewport stays centred. + * Clamping to the image box instead let a small or letterboxed image be panned out of view. */ + _clampPreviewImagePan() { + const state = this._previewImageZoom; + if (!state) return; + const rect = state.body.getBoundingClientRect(); + const fitted = this._previewImageFitSize(state, rect); + const maxX = Math.max(0, (fitted.width * state.scale - rect.width) / 2); + const maxY = Math.max(0, (fitted.height * state.scale - rect.height) / 2); + state.x = Math.max(-maxX, Math.min(maxX, state.x)); + state.y = Math.max(-maxY, Math.min(maxY, state.y)); + }, + + /** Zoom factor for one wheel event, proportional to `deltaY` (d3-zoom's scale: about 15% per + * 100px mouse notch, ten times that for ctrl+wheel, which is how Chrome and Firefox deliver a + * trackpad pinch), so a trackpad's stream of small deltas zooms smoothly instead of 20% per + * event. Clamped per event so one large delta cannot jump to the maximum; no vertical delta + * (a sideways scroll) zooms nothing. */ + _previewWheelZoomFactor(event) { + const deltaY = Number(event?.deltaY) || 0; + if (!deltaY) return 1; + const perUnit = event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002; + const exponent = -deltaY * perUnit * (event.ctrlKey ? 10 : 1); + return 2 ** Math.max(-0.5, Math.min(0.5, exponent)); + }, + + _bindPreviewImageGestures(image, body, controls) { + this._previewImageZoom = { image, body, controls, scale: 1, x: 0, y: 0, pointers: new Map(), gesture: null }; + const state = this._previewImageZoom; + const cleanups = []; + const listen = (target, name, handler, options) => { + target.addEventListener(name, handler, options); + cleanups.push(() => target.removeEventListener(name, handler, options)); + }; + const pointer = (event) => ({ x: event.clientX, y: event.clientY }); + const midpoint = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); + const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + const startGesture = () => { + const points = Array.from(state.pointers.values()); + if (points.length > 1) { + state.gesture = { + kind: 'pinch', + scale: state.scale, + distance: Math.max(1, distance(points[0], points[1])), + midpoint: midpoint(points[0], points[1]), + }; + } else if (points.length === 1) { + state.gesture = { kind: 'pan', point: points[0], x: state.x, y: state.y }; + } + }; + listen(image, 'pointerdown', (event) => { + event.preventDefault(); + image.setPointerCapture?.(event.pointerId); + state.pointers.set(event.pointerId, pointer(event)); + startGesture(); + }); + listen(image, 'pointermove', (event) => { + if (!state.pointers.has(event.pointerId) || !state.gesture) return; + event.preventDefault(); + state.pointers.set(event.pointerId, pointer(event)); + if (state.pointers.size > 1) { + const points = Array.from(state.pointers.values()); + this._setPreviewImageZoom( + state.gesture.scale * distance(points[0], points[1]) / state.gesture.distance, + state.gesture.midpoint + ); + } else if (state.gesture.kind === 'pan' && state.scale > 1) { + const point = state.pointers.values().next().value; + state.x = state.gesture.x + point.x - state.gesture.point.x; + state.y = state.gesture.y + point.y - state.gesture.point.y; + this._setPreviewImageZoom(state.scale); + } + }); + const endPointer = (event) => { + state.pointers.delete(event.pointerId); + startGesture(); + }; + listen(image, 'pointerup', endPointer); + listen(image, 'pointercancel', endPointer); + listen(image, 'lostpointercapture', endPointer); + listen(image, 'dblclick', (event) => { + event.preventDefault(); + this._setPreviewImageZoom(state.scale > 1 ? 1 : 2, pointer(event)); + }); + listen(body, 'wheel', (event) => { + event.preventDefault(); + const factor = this._previewWheelZoomFactor(event); + if (factor !== 1) this._setPreviewImageZoom(state.scale * factor, pointer(event)); + }, { passive: false }); + listen(controls, 'click', (event) => { + const button = event.target.closest('[data-zoom]'); + if (!button) return; + if (button.dataset.zoom === 'fit') this._setPreviewImageZoom(1); + else this._setPreviewImageZoom(state.scale * (button.dataset.zoom === 'in' ? 1.5 : 1 / 1.5)); + }); + this._previewImageCleanup = () => { + cleanups.forEach((cleanup) => cleanup()); + this._previewImageZoom = null; + this._previewImageCleanup = null; + }; + }, + closePreview(restoreFocus = true) { this._previewRequestSequence += 1; + this._previewImageCleanup?.(); this._previewOverlay?.remove(); this._previewOverlay = null; const previousFocus = this._previewPreviousFocus; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index af663547..7e426707 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16218,6 +16218,29 @@ body.touch-device.cjk-input-visible .main { text-decoration: none; } +.path-preview-zoom { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 3px; +} + +.path-preview-zoom[hidden] { + display: none; +} + +.path-preview-zoom button { + min-width: 32px; + height: 34px; + padding: 0 6px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 0.75rem; + cursor: pointer; +} + .path-preview-close { width: 38px; height: 38px; @@ -16236,6 +16259,12 @@ body.touch-device.cjk-input-visible .main { background: #111; } +.path-preview-body.path-preview-image-viewport { + overflow: hidden; + touch-action: none; + overscroll-behavior: contain; +} + .path-preview-loading, .path-preview-error { padding: 24px; @@ -16250,10 +16279,23 @@ body.touch-device.cjk-input-visible .main { .path-preview-image { display: block; - max-width: 100%; - max-height: 100%; - margin: auto; - object-fit: contain; + width: 100%; + height: 100%; + /* scale-down, not contain: a small image keeps its own size instead of being blown up to the + viewport (the zoom controls are there to enlarge it). */ + object-fit: scale-down; + touch-action: none; + user-select: none; + -webkit-user-drag: none; + cursor: zoom-in; +} + +.path-preview-image.is-zoomed { + cursor: grab; +} + +.path-preview-image.is-zoomed:active { + cursor: grabbing; } .path-preview-frame { diff --git a/test/path-picker-image-zoom.test.ts b/test/path-picker-image-zoom.test.ts new file mode 100644 index 00000000..4a6473be --- /dev/null +++ b/test/path-picker-image-zoom.test.ts @@ -0,0 +1,309 @@ +/** + * @fileoverview Zoom in the path picker's image preview (salvaged from #608): pinch, one-finger pan, + * double-click, wheel and the out / fit / in buttons. Same jsdom harness as + * path-picker-hidden.test.ts (see its header for why the window is built by hand). + * + * These drive the real listeners and read the zoom state back, because what can go wrong is + * geometric, not textual: + * + * 1. the point under the cursor or between the fingers drifting while the scale changes, + * 2. a pan that carries the image out of view (a small image is drawn at its own size with + * `object-fit: scale-down`, so the image box is not the image), + * 3. a trackpad's stream of small wheel deltas jumping to the maximum, as 20% per event did, + * 4. controls left on screen for an image that failed to load, + * 5. listeners or zoom state outliving the preview, or a late `load`/`error` from a replaced + * preview acting on the current one. + * + * jsdom lays nothing out and loads no images, so the viewport's rect and the image's natural size + * are stubbed per test and `load`/`error` are dispatched by hand. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8'); +const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); + +const dom = new JSDOM('', { url: 'https://localhost/' }); +const jsdomWindow = dom.window as unknown as Window & typeof globalThis; +const doc = jsdomWindow.document; + +/** The preview body's box: 400x300 at the page origin, so its centre is (200, 150). */ +const BOX = { left: 0, top: 0, width: 400, height: 300 }; +const CENTRE = { x: BOX.left + BOX.width / 2, y: BOX.top + BOX.height / 2 }; + +type Point = { x: number; y: number }; +type ZoomState = { scale: number; x: number; y: number }; + +function loadPathPicker(): any { + const factory = new Function( + 'window', + 'document', + 'localStorage', + 'fetch', + 'MobileDetection', + `${accessoryJs}\nreturn PathPicker;` + ); + return factory(jsdomWindow, doc, jsdomWindow.localStorage, vi.fn(), { isTouchDevice: () => false }); +} + +describe('PathPicker image preview zoom', () => { + let PathPicker: any; + + beforeEach(() => { + doc.body.replaceChildren(); + PathPicker = loadPathPicker(); + }); + + afterEach(() => { + PathPicker?.closePreview?.(false); + doc.body.replaceChildren(); + }); + + /** Open an image preview whose image has the given natural size (800x600 fills the box exactly). */ + const openImage = (natural = { width: 800, height: 600 }, name = 'shot.png') => { + PathPicker.openPreview({ name, path: `/home/dev/${name}`, previewKind: 'image' }); + const overlay = PathPicker._previewOverlay as HTMLElement; + const body = overlay.querySelector('.path-preview-body') as HTMLElement; + body.getBoundingClientRect = () => + ({ + ...BOX, + x: BOX.left, + y: BOX.top, + right: BOX.left + BOX.width, + bottom: BOX.top + BOX.height, + toJSON: () => ({}), + }) as DOMRect; + const image = body.querySelector('img.path-preview-image') as HTMLImageElement; + Object.defineProperty(image, 'naturalWidth', { value: natural.width, configurable: true }); + Object.defineProperty(image, 'naturalHeight', { value: natural.height, configurable: true }); + const controls = overlay.querySelector('.path-preview-zoom') as HTMLElement; + const button = (zoom: string) => controls.querySelector(`[data-zoom="${zoom}"]`) as HTMLButtonElement; + return { overlay, body, image, controls, button }; + }; + + const fire = (target: EventTarget, type: string) => target.dispatchEvent(new jsdomWindow.Event(type)); + const state = (): ZoomState => PathPicker._previewImageZoom; + + /** Where, in the unzoomed image, the given screen point lands under the current transform. */ + const imagePointUnder = (p: Point, s: ZoomState = state()): Point => ({ + x: (p.x - CENTRE.x - s.x) / s.scale, + y: (p.y - CENTRE.y - s.y) / s.scale, + }); + + const wheel = (target: EventTarget, init: Record) => { + const event = new jsdomWindow.WheelEvent('wheel', { bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; + }; + + /** jsdom has no PointerEvent: a MouseEvent of the pointer type with a pointerId behaves the same. */ + const pointer = (target: EventTarget, type: string, id: number, p: Point) => { + const event = new jsdomWindow.MouseEvent(type, { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y }); + Object.defineProperty(event, 'pointerId', { value: id }); + target.dispatchEvent(event); + }; + + const dblclick = (target: EventTarget, p: Point) => + target.dispatchEvent( + new jsdomWindow.MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: p.x, clientY: p.y }) + ); + + it('shows the controls only once the image has loaded, and the buttons step the zoom', () => { + const { image, controls, button } = openImage(); + expect(controls.hidden).toBe(true); + + fire(image, 'load'); + expect(controls.hidden).toBe(false); + expect(button('fit').textContent).toBe('100%'); + + button('in').click(); + expect(state().scale).toBeCloseTo(1.5); + expect(button('fit').textContent).toBe('150%'); + expect(image.classList.contains('is-zoomed')).toBe(true); + + button('fit').click(); + expect(state()).toMatchObject({ scale: 1 }); + expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0); + expect(button('fit').textContent).toBe('100%'); + expect(image.classList.contains('is-zoomed')).toBe(false); + + button('out').click(); + expect(state().scale).toBe(1); + }); + + it('wheel zoom keeps the image point under the cursor where it was', () => { + const { body, image } = openImage(); + fire(image, 'load'); + + for (const cursor of [ + { x: 300, y: 100 }, + { x: 120, y: 220 }, + { x: 390, y: 290 }, + ]) { + const before = imagePointUnder(cursor); + const scaleBefore = state().scale; + wheel(body, { deltaY: -100, clientX: cursor.x, clientY: cursor.y }); + expect(state().scale).toBeCloseTo(scaleBefore * 2 ** 0.2); + const after = imagePointUnder(cursor); + expect(after.x).toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + } + expect(image.style.transform).toContain(`scale(${state().scale})`); + }); + + it('pinch zooms by the finger distance around the pinch centre', () => { + const { image } = openImage(); + fire(image, 'load'); + const centre = { x: 150, y: 150 }; + const before = imagePointUnder(centre); + + pointer(image, 'pointerdown', 1, { x: 100, y: 150 }); + pointer(image, 'pointerdown', 2, { x: 200, y: 150 }); + pointer(image, 'pointermove', 2, { x: 300, y: 150 }); + + expect(state().scale).toBeCloseTo(2); + const after = imagePointUnder(centre); + expect(after.x).toBeCloseTo(before.x); + expect(after.y).toBeCloseTo(before.y); + + pointer(image, 'pointerup', 2, { x: 300, y: 150 }); + pointer(image, 'pointerup', 1, { x: 100, y: 150 }); + expect(state().pointers.size).toBe(0); + }); + + it('pans a zoomed image only until its edge meets the viewport edge', () => { + const { image } = openImage(); + fire(image, 'load'); + dblclick(image, CENTRE); + expect(state().scale).toBe(2); + + pointer(image, 'pointerdown', 1, CENTRE); + pointer(image, 'pointermove', 1, { x: 5000, y: 5000 }); + // 800x600 drawn at 400x300, zoomed 2x to 800x600 in a 400x300 box: 200 and 150 of slack. + expect(state()).toMatchObject({ x: 200, y: 150 }); + pointer(image, 'pointermove', 1, { x: -5000, y: -5000 }); + expect(state()).toMatchObject({ x: -200, y: -150 }); + pointer(image, 'pointerup', 1, { x: -5000, y: -5000 }); + + // Without zoom there is nothing to pan. + dblclick(image, CENTRE); + pointer(image, 'pointerdown', 1, CENTRE); + pointer(image, 'pointermove', 1, { x: 300, y: 250 }); + expect(state()).toMatchObject({ scale: 1 }); + expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0); + }); + + it('keeps a small image in view: centred while it is smaller than the viewport, clamped to its own edges after', () => { + // 100x50 is drawn at its own size (scale-down), not stretched to the 400x300 box. + const { image } = openImage({ width: 100, height: 50 }); + fire(image, 'load'); + + dblclick(image, { x: 380, y: 280 }); + expect(state().scale).toBe(2); + pointer(image, 'pointerdown', 1, CENTRE); + pointer(image, 'pointermove', 1, { x: 5000, y: 5000 }); + // 200x100 still fits inside 400x300 on both axes: it stays centred. + expect(Math.abs(state().x) + Math.abs(state().y)).toBe(0); + pointer(image, 'pointerup', 1, { x: 5000, y: 5000 }); + + PathPicker._setPreviewImageZoom(6); + pointer(image, 'pointerdown', 1, CENTRE); + pointer(image, 'pointermove', 1, { x: 5000, y: 5000 }); + // 600x300 at 6x: 100 of horizontal slack, none vertically. The image box (400x300 at 6x) would + // have allowed 1000 and 750, carrying the image entirely out of the viewport. + expect(state().x).toBe(100); + expect(Math.abs(state().y)).toBe(0); + }); + + it('scales wheel zoom by deltaY, ignores a delta of 0 and caps one event', () => { + const factor = (init: Record) => PathPicker._previewWheelZoomFactor(init); + expect(factor({ deltaY: 0, deltaX: 40 })).toBe(1); + expect(factor({ deltaY: -4 })).toBeCloseTo(2 ** 0.008); + expect(factor({ deltaY: -100 })).toBeCloseTo(2 ** 0.2); + expect(factor({ deltaY: 100 })).toBeCloseTo(2 ** -0.2); + // Lines (Firefox's mouse wheel) and ctrl+wheel (a trackpad pinch) are scaled up, not ignored. + expect(factor({ deltaY: -3, deltaMode: 1 })).toBeCloseTo(2 ** 0.15); + expect(factor({ deltaY: -10, ctrlKey: true })).toBeCloseTo(2 ** 0.2); + expect(factor({ deltaY: -100000 })).toBeCloseTo(Math.SQRT2); + expect(factor({ deltaY: 100000 })).toBeCloseTo(Math.SQRT1_2); + + const { body, image } = openImage(); + fire(image, 'load'); + const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom'); + const sideways = wheel(body, { deltaY: 0, deltaX: 40, clientX: 300, clientY: 100 }); + expect(sideways.defaultPrevented).toBe(true); + expect(zoom).not.toHaveBeenCalled(); + expect(state().scale).toBe(1); + + // A trackpad scroll arrives as many small deltas; at 20% per event these 30 hit the 600% cap. + for (let i = 0; i < 30; i++) wheel(body, { deltaY: -4, clientX: 300, clientY: 100 }); + expect(state().scale).toBeCloseTo(2 ** 0.24); + expect(state().scale).toBeLessThan(1.25); + }); + + it('hides the controls and stops zooming when the image fails to load', () => { + const { body, image, controls } = openImage(); + fire(image, 'error'); + + expect(controls.hidden).toBe(true); + expect(body.querySelector('.path-preview-error')?.textContent).toBe('Image preview failed to load'); + expect(PathPicker._previewImageZoom).toBeNull(); + + const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom'); + wheel(body, { deltaY: -100, clientX: 200, clientY: 150 }); + expect(zoom).not.toHaveBeenCalled(); + }); + + it('closing drops the zoom state and every listener, and the next image starts at 100%', () => { + const first = openImage(); + fire(first.image, 'load'); + dblclick(first.image, { x: 300, y: 100 }); + expect(state().scale).toBe(2); + const transformAtClose = first.image.style.transform; + + PathPicker.closePreview(false); + expect(PathPicker._previewImageZoom).toBeNull(); + expect(PathPicker._previewImageCleanup).toBeNull(); + expect(first.overlay.isConnected).toBe(false); + + const zoom = vi.spyOn(PathPicker, '_setPreviewImageZoom'); + wheel(first.body, { deltaY: -100, clientX: 200, clientY: 150 }); + dblclick(first.image, CENTRE); + first.button('in').click(); + expect(zoom).not.toHaveBeenCalled(); + expect(first.image.style.transform).toBe(transformAtClose); + zoom.mockRestore(); + + const second = openImage({ width: 800, height: 600 }, 'next.png'); + fire(second.image, 'load'); + expect(state()).toMatchObject({ scale: 1 }); + expect(second.button('fit').textContent).toBe('100%'); + expect(second.image.classList.contains('is-zoomed')).toBe(false); + }); + + it('a late load or error from a replaced preview leaves the current one alone', () => { + const stale = openImage({ width: 800, height: 600 }, 'old.png'); + const current = openImage({ width: 800, height: 600 }, 'new.png'); + fire(current.image, 'load'); + dblclick(current.image, { x: 300, y: 100 }); + const zoomed = { ...state() }; + + fire(stale.image, 'load'); + expect(state()).toMatchObject({ scale: zoomed.scale, x: zoomed.x, y: zoomed.y }); + + fire(stale.image, 'error'); + expect(current.body.querySelector('.path-preview-error')).toBeNull(); + expect(current.image.isConnected).toBe(true); + expect(current.controls.hidden).toBe(false); + expect(PathPicker._previewImageZoom).not.toBeNull(); + }); + + it('draws a small image at its own size rather than stretching it (scale-down)', () => { + const rule = stylesCss.match(/\n\.path-preview-image \{([^}]*)\}/); + expect(rule?.[1]).toMatch(/object-fit:\s*scale-down;/); + expect(rule?.[1]).not.toMatch(/object-fit:\s*contain;/); + }); +});