Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { ViewerProps } from '../../lib/viewer.ts'

import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { defineComponent, h, nextTick } from 'vue'
import { makeFile } from '../factories.ts'

// Resolve/keep the real network-free path: preloadMedia is the only fetch the
Expand All @@ -18,9 +18,8 @@ vi.mock('../../lib/services/mediaPreloader.ts', () => ({

// An svg is read and sanitized rather than handed to the element, so the
// only request Images makes by itself is that one.
vi.mock('@nextcloud/axios', () => ({
default: { get: vi.fn(async () => ({ data: '<svg/>' })) },
}))
const axiosGet = vi.hoisted(() => vi.fn(async () => ({ data: '<svg/>' })))
vi.mock('@nextcloud/axios', () => ({ default: { get: axiosGet } }))

// imagePath is evaluated at module load of usePlyrPlayer (blank.mp4). Keep the
// rest of the router real; only pin the two URL helpers so tests never depend on
Expand Down Expand Up @@ -317,6 +316,86 @@ describe('moving to another file of the same name', () => {
})
})

describe('an image that cannot be read at all', () => {
// An svg is fetched to be sanitized, and an E2EE file is fetched by
// hand: a request that rejects there left the element with nothing and
// the viewer waiting on a `loaded` event that could never come
it('reports a request that rejects on the first load', async () => {
axiosGet.mockRejectedValueOnce(new Error('503'))
const logged = vi.spyOn(logger, 'error').mockImplementation(() => {})
const file = makeFile({ basename: 'drawing.svg', mime: 'image/svg+xml' })

const wrapper = mountImages({ file, files: [file] })
await flushPromises()

expect(wrapper.emitted('errored')).toHaveLength(1)
expect(logged).toHaveBeenCalled()
})
})

describe('the pointers on an image', () => {
/**
* A pointer event jsdom will accept: it has no PointerEvent, and the
* coordinates of a MouseEvent cannot be set after the fact.
*
* @param type - The event name
* @param pointerId - Which pointer it is
* @param at - Where it is, in both axes
*/
function pointer(type: string, pointerId: number, at = 0): Event {
const event = new MouseEvent(type, { clientX: at, clientY: at, bubbles: true, cancelable: true })
Object.defineProperty(event, 'pointerId', { value: pointerId })
return event
}

/**
* Mount an image and return a way to put pointers on it.
*/
async function mountWithPointers() {
const wrapper = mountImages()
await flushPromises()
const image = wrapper.find('img').element
const send = async (type: string, pointerId: number, at?: number) => {
image.dispatchEvent(pointer(type, pointerId, at))
await nextTick()
}
const zoomed = () => wrapper.find('img').attributes('class')?.includes('zoomed') ?? false
return { wrapper, send, zoomed }
}

it('takes back only the pointer that was lifted', async () => {
const { send, zoomed } = await mountWithPointers()
await send('pointerdown', 1, 0)
await send('pointerdown', 2, 40)

// A pointer that went down somewhere else, so this element never
// cached it: splice reads its index of -1 as the last entry and
// drops a finger that is still on the screen
await send('pointerup', 99)
// Both fingers are still down, so this is still a pinch, and moving
// one of them apart zooms
await send('pointermove', 2, 200)

expect(zoomed()).toBe(true)
})

// The browser takes a pointer back when it turns the gesture into one of
// its own, and the `up` that would have ended it never arrives
it('lets go of a pointer the browser cancels', async () => {
const { wrapper, send, zoomed } = await mountWithPointers()
await send('pointerdown', 1, 0)
expect(wrapper.emitted('update:canSwipe')).toBeUndefined()

await send('pointercancel', 1)
// One finger again, so nothing here is a pinch
await send('pointerdown', 2, 0)
await send('pointermove', 2, 200)

expect(zoomed()).toBe(false)
expect(wrapper.emitted('update:canSwipe')).toEqual([[true]])
})
})

describe('Videos.vue (smoke)', () => {
// The speed menu is built from numbers plyr formats itself, which its own
// i18n never reaches, so it is relabelled once the controls exist
Expand Down
49 changes: 45 additions & 4 deletions lib/components/Images.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
@dblclick.prevent="onDblclick"
@pointerdown.prevent="pointerDown"
@pointerup.prevent="pointerUp"
@pointercancel.prevent="pointerCancel"
@pointermove.prevent="pointerMove">

<template v-if="livePhoto">
Expand All @@ -47,6 +48,7 @@
@dblclick.prevent="onDblclick"
@pointerdown.prevent="pointerDown"
@pointerup.prevent="pointerUp"
@pointercancel.prevent="pointerCancel"
@pointermove.prevent="pointerMove"
@ended="stopLivePhoto" />
<button
Expand Down Expand Up @@ -168,8 +170,8 @@ let inFlight: { controller: AbortController, source: string } | null = null
// Load data when component mounts or file changes. Keyed on the source, as
// two files can be shown under one name and it is the source that says
// which bytes to fetch.
watch(() => props.file.source, async () => {
await loadData()
watch(() => props.file.source, () => {
load()
})
watch(data, () => {
loaded.value = false
Expand All @@ -181,7 +183,22 @@ watch(() => props.localSource, (source) => {
data.value = source
}
})
loadData()
load()

/**
* Load the image, and report a load that cannot even be attempted.
*
* `loadData` reads the file over the network for an svg or an E2EE file,
* and a request that rejects there reached nobody: the element was left
* with nothing to show, and the viewer waited on its spinner for a
* `loaded` event that could never come.
*/
function load() {
loadData().catch((error) => {
logger.error(`Loading of file ${filename.value} failed`, { error })
emit('errored', new Error(t('Failed to load image.')))
})
}

/**
* Load the image data to be displayed
Expand Down Expand Up @@ -421,13 +438,37 @@ function pointerDown(event: PointerEvent) {
* @param event The pointer up event
*/
function pointerUp(event: PointerEvent) {
// Remove pointer from the pointer cache
// A pointer the cache never had, one that went down beside the image
// say, is not the end of anything: its index of -1 was read by splice
// as the last entry, dropping a finger that is still on the screen and
// ending the gesture it was part of.
const index = pointerCache.value.findIndex((cachedEv) => cachedEv.pointerId === event.pointerId)
if (index === -1) {
return
}

pointerCache.value.splice(index, 1)
dragging.value = false
zooming.value = false
}

/**
* A pointer the browser has taken back, because it turned the gesture into
* one of its own: a page scroll, or the swipe that pages to the next file.
*
* The `up` that would normally end it never comes, so without this the
* finger stays in the cache and the image is left mid-drag, mid-pinch, or
* refusing to swipe.
*
* @param event The pointer cancel event
*/
function pointerCancel(event: PointerEvent) {
pointerUp(event)
if (pointerCache.value.length === 0 && zoomRatio.value === 1) {
emit('update:canSwipe', true)
}
}

/**
* Used for pinch zoom and drag
*
Expand Down
Loading