From 91ab0fd8872e22f59d990023f18baf48f5352ca1 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Fri, 18 Sep 2026 10:00:06 +0200 Subject: [PATCH] feat(rotate): turn a JPEG from the viewer and keep the turn A rotate button ahead of the edit button turns the picture on screen at once, and the turn is written to the file shortly after by rewriting its Exif orientation tag. Nothing is decoded and nothing is re-encoded, so the picture is the same picture however many times it is turned, and the tag is what the preview generator reads, so the new framing follows the file into the Files grid, Photos and the mobile clients. Offered only for a JPEG the user may write. No other format carries an orientation that both the browser and the preview generator honour, and a turn shown on a file that cannot keep it is worse than no button. Written once the user stops rather than once per click: every write makes a version of the file, so a turn per click would leave four copies of a photo in the history on the way round a full circle. A picture turned the whole way round is not written at all. The fit is worked out against the box the picture occupies on screen rather than its own, or a landscape photo turned on its side would be taller than the frame and spill over the chrome above and below. Zoom and pan are measured against the same box. Co-authored-by: vladopol Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- __tests__/useRotation.spec.ts | 267 +++++++++++++++++++++++++++++++++ e2e/rotation.spec.ts | 195 ++++++++++++++++++++++++ lib/components/Images.vue | 58 +++++-- lib/composables/useRotation.ts | 169 +++++++++++++++++++++ lib/viewer.ts | 7 + lib/views/Viewer.vue | 19 ++- package-lock.json | 8 +- package.json | 2 +- 8 files changed, 710 insertions(+), 15 deletions(-) create mode 100644 __tests__/useRotation.spec.ts create mode 100644 e2e/rotation.spec.ts create mode 100644 lib/composables/useRotation.ts diff --git a/__tests__/useRotation.spec.ts b/__tests__/useRotation.spec.ts new file mode 100644 index 0000000..8e3c3b9 --- /dev/null +++ b/__tests__/useRotation.spec.ts @@ -0,0 +1,267 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFile } from '@nextcloud/files' +import type { EffectScope, Ref } from 'vue' + +import { Permission } from '@nextcloud/files' +import { flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import { makeFile } from './factories.ts' + +const axiosGet = vi.hoisted(() => vi.fn()) +const axiosPut = vi.hoisted(() => vi.fn()) +vi.mock('@nextcloud/axios', () => ({ default: { get: axiosGet, put: axiosPut } })) +vi.mock('@nextcloud/event-bus') +vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn(), showSuccess: vi.fn() })) + +// The byte work is the editor package's and is tested there. What matters +// here is the orchestration around it: when a turn is written, which file +// it is written to, and what is sent with it. The stub composes turns the +// way the real one does so the count can be asserted. +const setOrientation = vi.hoisted(() => vi.fn<() => Uint8Array | null>(() => new Uint8Array([0xFF, 0xD8, 0x99]))) +vi.mock('@nextcloud/image-editor/jpeg', () => ({ + readJpegOrientation: () => 1, + rotateOrientation: (orientation: number) => orientation + 1, + setJpegOrientation: setOrientation, +})) + +const { showError } = await import('@nextcloud/dialogs') +const { emit: emitBus } = await import('@nextcloud/event-bus') +const { useRotation } = await import('../lib/composables/useRotation.ts') + +/** How long the composable waits before writing, plus a margin */ +const AFTER_QUIET = 1300 + +describe('useRotation', () => { + let scope: EffectScope + let file: Ref + let rotation: ReturnType + + /** + * Run the composable over a file, in a scope the test can dispose. + * + * @param node the file on screen + */ + function start(node?: IFile) { + file = ref(node) + scope = effectScope() + rotation = scope.run(() => useRotation(file))! + } + + beforeEach(() => { + vi.useFakeTimers() + axiosGet.mockReset() + axiosPut.mockReset() + setOrientation.mockClear() + vi.mocked(showError).mockClear() + vi.mocked(emitBus).mockClear() + axiosGet.mockResolvedValue({ data: new Uint8Array([0xFF, 0xD8, 0x00]).buffer }) + axiosPut.mockResolvedValue({ headers: { 'oc-etag': '"written"' } }) + }) + + afterEach(() => { + scope?.stop() + vi.useRealTimers() + }) + + /** Let the debounce fire and the write run to completion */ + async function settle() { + await vi.advanceTimersByTimeAsync(AFTER_QUIET) + await flushPromises() + } + + describe('what it offers', () => { + it('offers a turn on a JPEG the user may write', () => { + start(makeFile({ mime: 'image/jpeg' })) + expect(rotation.canRotate.value).toBe(true) + }) + + it('refuses a JPEG the user may only read', () => { + // The turn would be shown and then fail on save, which is worse + // than not offering it + start(makeFile({ mime: 'image/jpeg', permissions: Permission.READ })) + expect(rotation.canRotate.value).toBe(false) + }) + + it('refuses a format that cannot keep the turn', () => { + for (const mime of ['image/png', 'image/webp', 'image/gif']) { + start(makeFile({ mime })) + expect(rotation.canRotate.value).toBe(false) + } + }) + + it('refuses when there is no file at all', () => { + start(undefined) + expect(rotation.canRotate.value).toBe(false) + }) + }) + + describe('what it shows', () => { + it('turns the picture before anything is written', () => { + start(makeFile()) + rotation.rotateLeft() + expect(rotation.turns.value).toBe(1) + expect(axiosPut).not.toHaveBeenCalled() + }) + + it('comes back round to square after four turns', () => { + start(makeFile()) + for (let i = 0; i < 4; i++) { + rotation.rotateLeft() + } + expect(rotation.turns.value).toBe(0) + }) + + it('does not turn a picture it would not offer to turn', () => { + start(makeFile({ mime: 'image/png' })) + rotation.rotateLeft() + expect(rotation.turns.value).toBe(0) + }) + }) + + describe('what it writes', () => { + it('writes once for a run of turns rather than once each', async () => { + start(makeFile()) + rotation.rotateLeft() + rotation.rotateLeft() + await settle() + + // Every write makes a version of the file, so a turn per click + // would leave a trail of them + expect(axiosPut).toHaveBeenCalledTimes(1) + // Two turns composed, not one + expect(setOrientation).toHaveBeenCalledWith(expect.anything(), 3) + }) + + it('writes nothing for a picture turned the whole way round', async () => { + start(makeFile()) + for (let i = 0; i < 4; i++) { + rotation.rotateLeft() + } + await settle() + + // The file is as it was, and a version of a file that did not + // change is worse than no version + expect(axiosGet).not.toHaveBeenCalled() + expect(axiosPut).not.toHaveBeenCalled() + }) + + it('sends the bytes back as a JPEG', async () => { + start(makeFile()) + rotation.rotateLeft() + await settle() + + const [, body] = axiosPut.mock.calls[0]! + expect(body).toBeInstanceOf(Blob) + expect((body as Blob).type).toBe('image/jpeg') + }) + + it('guards the write against a change made elsewhere', async () => { + start(makeFile({ attributes: { etag: 'opened-as' } })) + rotation.rotateLeft() + await settle() + + expect(axiosPut.mock.calls[0]![2].headers).toEqual({ 'If-Match': '"opened-as"' }) + }) + + it('guards a second turn against the version it just wrote', async () => { + // The file's own etag is stale the moment the first write lands, + // and reusing it would fail the second write + start(makeFile({ attributes: { etag: 'opened-as' } })) + rotation.rotateLeft() + await settle() + rotation.rotateLeft() + await settle() + + expect(axiosPut.mock.calls[1]![2].headers).toEqual({ 'If-Match': '"written"' }) + }) + + it('tells the rest of the app the file changed', async () => { + start(makeFile()) + rotation.rotateLeft() + await settle() + + expect(emitBus).toHaveBeenCalledWith('files:node:updated', expect.anything()) + }) + + it('leaves the preview where it is, so the turn on screen holds', async () => { + // The preview URL carries the etag: moving it reloads the element + // to a freshly turned preview while the turn is still shown on top + const node = makeFile({ attributes: { etag: 'opened-as' } }) + start(node) + rotation.rotateLeft() + await settle() + + expect(node.attributes.etag).toBe('opened-as') + expect(rotation.turns.value).toBe(1) + }) + }) + + describe('moving on', () => { + it('writes what is owed on the file being left', async () => { + const first = makeFile({ basename: 'first.jpg' }) + start(first) + rotation.rotateLeft() + + file.value = makeFile({ basename: 'second.jpg' }) + await nextTick() + await flushPromises() + + expect(axiosPut).toHaveBeenCalledTimes(1) + expect(axiosGet.mock.calls[0]![0]).toContain('first.jpg') + }) + + it('starts the next file square', async () => { + start(makeFile({ basename: 'first.jpg' })) + rotation.rotateLeft() + + file.value = makeFile({ basename: 'second.jpg' }) + await nextTick() + + expect(rotation.turns.value).toBe(0) + }) + + it('writes what is owed when the viewer closes', async () => { + start(makeFile()) + rotation.rotateLeft() + + scope.stop() + await flushPromises() + + expect(axiosPut).toHaveBeenCalledTimes(1) + }) + }) + + describe('when it cannot', () => { + it('says so rather than leaving the turn looking saved', async () => { + axiosPut.mockRejectedValue(new Error('nope')) + start(makeFile()) + rotation.rotateLeft() + await settle() + + expect(showError).toHaveBeenCalled() + }) + + it('names the case where someone else got there first', async () => { + axiosPut.mockRejectedValue({ response: { status: 412 } }) + start(makeFile()) + rotation.rotateLeft() + await settle() + + expect(vi.mocked(showError).mock.calls[0]![0]).toContain('changed elsewhere') + }) + + it('writes nothing when the tag cannot be put into the file', async () => { + setOrientation.mockReturnValueOnce(null) + start(makeFile()) + rotation.rotateLeft() + await settle() + + expect(axiosPut).not.toHaveBeenCalled() + expect(showError).toHaveBeenCalled() + }) + }) +}) diff --git a/e2e/rotation.spec.ts b/e2e/rotation.spec.ts new file mode 100644 index 0000000..d6b5f87 --- /dev/null +++ b/e2e/rotation.spec.ts @@ -0,0 +1,195 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { ViewerPage } from './support/viewer.ts' + +/** Longer than the composable waits before writing */ +const AFTER_QUIET = 1600 + +/** + * Read the Exif orientation of a JPEG, without the library that wrote it. + * + * Walking the block here rather than importing the reader keeps the + * assertion independent of the code that produced the bytes: a writer and + * a reader that agree with each other but with nothing else would pass. + * + * @param bytes the file to read + */ +function orientationOf(bytes: Buffer): number | null { + if (bytes[0] !== 0xFF || bytes[1] !== 0xD8) { + return null + } + let at = 2 + while (at + 3 < bytes.length && bytes[at] === 0xFF) { + const marker = bytes[at + 1]! + // The scan carries the pixels and runs to the end of the file + if (marker === 0xDA || marker === 0xD9) { + return null + } + const length = bytes.readUInt16BE(at + 2) + if (marker === 0xE1 && bytes.toString('latin1', at + 4, at + 10) === 'Exif\0\0') { + const tiff = at + 10 + const little = bytes.toString('latin1', tiff, tiff + 2) === 'II' + const u16 = (offset: number) => little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset) + const u32 = (offset: number) => little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset) + const start = tiff + u32(tiff + 4) + const count = u16(start) + for (let i = 0; i < count; i++) { + const entry = start + 2 + i * 12 + if (u16(entry) === 0x0112) { + return u16(entry + 8) + } + } + return null + } + at += 2 + length + } + return null +} + +/** + * Hold every write to the fixtures and hand back the bodies. + * + * Nothing answers WebDAV behind the playground, so a write has to be + * caught here or it is a 405 from a static file server. + * + * @param page the page to intercept on + */ +async function captureWrites(page: Page): Promise { + const written: Buffer[] = [] + await page.route('**/remote.php/dav/**', async (route) => { + if (route.request().method() !== 'PUT') { + await route.fallback() + return + } + const body = route.request().postDataBuffer() + if (body) { + written.push(body) + } + await route.fulfill({ status: 204, headers: { 'oc-etag': '"turned"' } }) + }) + return written +} + +test.describe('Rotation', () => { + test('offers a turn on a JPEG the user may write', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + await expect(viewer.container.getByRole('button', { name: 'Rotate left' })).toBeVisible() + }) + + test('does not offer one on a JPEG the user may only read', async ({ page }) => { + const viewer = new ViewerPage(page) + // The same format and the same handler: it is the permission that + // differs, and a turn offered here could only fail on save + await viewer.open('protected.jpg') + await viewer.waitForOpen() + + await expect(viewer.container.getByRole('button', { name: 'Rotate left' })).toHaveCount(0) + }) + + test('does not offer one on a format that cannot keep it', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('animation.gif') + await viewer.waitForOpen() + + await expect(viewer.container.getByRole('button', { name: 'Rotate left' })).toHaveCount(0) + }) + + test('turns the picture before anything has been written', async ({ page }) => { + await captureWrites(page) + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + await viewer.container.getByRole('button', { name: 'Rotate left' }).click() + + const image = viewer.container.locator('img').first() + await expect(image).toHaveCSS('transform', 'matrix(0, -1, 1, 0, 0, 0)') + }) + + test('keeps a turned picture inside the frame', async ({ page }) => { + // The regression this guards: a landscape photo fitted to a + // landscape frame and then turned on its side is taller than the + // frame unless the fit is worked out against the box it will + // actually occupy. Rotating the element alone spills it over the + // chrome above and below. + await captureWrites(page) + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + const image = viewer.container.locator('img').first() + const before = await image.boundingBox() + expect(before!.width).toBeGreaterThan(before!.height) + + await viewer.container.getByRole('button', { name: 'Rotate left' }).click() + + const frame = await viewer.container.locator('.modal-container').boundingBox() + await expect(async () => { + const after = await image.boundingBox() + // On its side now, and still within what holds it + expect(after!.height).toBeGreaterThan(after!.width) + expect(after!.height).toBeLessThanOrEqual(frame!.height + 1) + expect(after!.width).toBeLessThanOrEqual(frame!.width + 1) + }).toPass({ timeout: 5000 }) + }) + + test('writes the new orientation to the file', async ({ page }) => { + const written = await captureWrites(page) + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + await viewer.container.getByRole('button', { name: 'Rotate left' }).click() + + await expect(async () => { + expect(written).toHaveLength(1) + }).toPass({ timeout: AFTER_QUIET + 4000 }) + + // The fixture carries no orientation, so one turn anticlockwise from + // square is 8, "rotate 270 CW", which is what a reader is asked for + expect(orientationOf(written[0]!)).toBe(8) + }) + + test('writes one turn for a run of them, not one each', async ({ page }) => { + const written = await captureWrites(page) + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + const button = viewer.container.getByRole('button', { name: 'Rotate left' }) + await button.click() + await button.click() + + await expect(async () => { + expect(written).toHaveLength(1) + }).toPass({ timeout: AFTER_QUIET + 4000 }) + + // Two quarters anticlockwise is a half turn, which is 3 + expect(orientationOf(written[0]!)).toBe(3) + }) + + test('writes nothing for a picture turned the whole way round', async ({ page }) => { + const written = await captureWrites(page) + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + const button = viewer.container.getByRole('button', { name: 'Rotate left' }) + for (let turn = 0; turn < 4; turn++) { + await button.click() + } + await page.waitForTimeout(AFTER_QUIET) + + // The file is as it was. Writing it would make a version of a file + // that did not change + expect(written).toHaveLength(0) + }) +}) diff --git a/lib/components/Images.vue b/lib/components/Images.vue index b7d7d7e..5e106fc 100644 --- a/lib/components/Images.vue +++ b/lib/components/Images.vue @@ -132,15 +132,38 @@ const metadataFilesLivePhoto = computed(() => props.file.attributes?.['metadata- // Asked for the space it will be shown in, rather than for the whole display const previewPath = computed(() => getPreviewIfAny(props.file, { width: props.maxWidth, height: props.maxHeight })) +/** + * Quarter turns anticlockwise the viewer is showing. + * + * Read through Number because a handler is mounted as a custom element, + * where a prop can arrive as the string it was written as: "0" is truthy, + * and a picture that was never turned would be given a transform. + */ +const turns = computed(() => Number(props.turns ?? 0) % 4) + +/** Whether that turn puts the picture on its side */ +const quarterTurned = computed(() => turns.value % 2 === 1) + +/** + * The element's extent on screen, which is its own the right way up and + * its other one when it has been turned a quarter. Zoom and pan are + * measured against what the user sees, not against the element's box. + */ +const screenWidth = computed(() => quarterTurned.value ? height.value : width.value) +const screenHeight = computed(() => quarterTurned.value ? width.value : height.value) + const zoomHeight = computed(() => Math.round(height.value * zoomRatio.value)) const zoomWidth = computed(() => Math.round(width.value * zoomRatio.value)) const alt = computed(() => props.file.displayname) const imgStyle = computed(() => { + // Anticlockwise, about the element's centre, so the picture stays put + const transform = turns.value === 0 ? undefined : `rotate(${-90 * turns.value}deg)` if (zoomRatio.value === 1) { return { height: zoomHeight.value + 'px', width: zoomWidth.value + 'px', + transform, } } return { @@ -148,6 +171,7 @@ const imgStyle = computed(() => { marginLeft: Math.round(shiftX.value * 2) + 'px', height: zoomHeight.value + 'px', width: zoomWidth.value + 'px', + transform, } }) @@ -298,7 +322,13 @@ function updateImageSize() { return } - const ratio = Math.min(props.maxHeight / mediaHeight, props.maxWidth / mediaWidth) + // A turned picture is fitted by the box it occupies on screen, which + // is its own with the sides swapped. Fitting the element's own box + // instead would leave a landscape photo taller than the frame the + // moment it went on its side, and it would spill over the chrome. + const boxWidth = quarterTurned.value ? mediaHeight : mediaWidth + const boxHeight = quarterTurned.value ? mediaWidth : mediaHeight + const ratio = Math.min(props.maxHeight / boxHeight, props.maxWidth / boxWidth) width.value = Math.floor(mediaWidth * ratio) height.value = Math.floor(mediaHeight * ratio) } @@ -307,6 +337,13 @@ function updateImageSize() { // already-known intrinsic size. A new file refits through its load event. watch([() => props.maxWidth, () => props.maxHeight], updateImageSize) +// A turn changes the box the picture has to fit, and starts it square: +// a zoom carried across a turn leaves the view somewhere nobody chose +watch(turns, () => { + resetZoom() + updateImageSize() +}) + onUnmounted(() => { inFlight?.controller.abort() }) @@ -330,8 +367,8 @@ async function getBase64FromImage(signal?: AbortSignal): Promise { * @param newZoomRatio - The zoom ratio used to compute the maximum allowed shift */ function updateShift(newShiftX: number, newShiftY: number, newZoomRatio: number) { - const maxShiftX = width.value * newZoomRatio - width.value - const maxShiftY = height.value * newZoomRatio - height.value + const maxShiftX = screenWidth.value * newZoomRatio - screenWidth.value + const maxShiftY = screenHeight.value * newZoomRatio - screenHeight.value shiftX.value = Math.min(Math.max(newShiftX, -maxShiftX / 2), maxShiftX / 2) shiftY.value = Math.min(Math.max(newShiftY, -maxShiftY / 2), maxShiftY / 2) } @@ -348,14 +385,17 @@ function updateZoomAndShift(stableX: number, stableY: number, newZoomRatio: numb return } - const scrollX = stableX - element.getBoundingClientRect().x - (width.value * zoomRatio.value / 2) - const scrollY = stableY - element.getBoundingClientRect().y - (height.value * zoomRatio.value / 2) - const scrollPercX = scrollX / (width.value * zoomRatio.value) - const scrollPercY = scrollY / (height.value * zoomRatio.value) + // Against the extent on screen: the rect a turned element reports is + // its turned one, so measuring the anchor off the element's own box + // would put the point the zoom pivots on in the wrong place + const scrollX = stableX - element.getBoundingClientRect().x - (screenWidth.value * zoomRatio.value / 2) + const scrollY = stableY - element.getBoundingClientRect().y - (screenHeight.value * zoomRatio.value / 2) + const scrollPercX = scrollX / (screenWidth.value * zoomRatio.value) + const scrollPercY = scrollY / (screenHeight.value * zoomRatio.value) // calc how much the img grow from its current size and adjust the margin accordingly - const growX = width.value * newZoomRatio - width.value * zoomRatio.value - const growY = height.value * newZoomRatio - height.value * zoomRatio.value + const growX = screenWidth.value * newZoomRatio - screenWidth.value * zoomRatio.value + const growY = screenHeight.value * newZoomRatio - screenHeight.value * zoomRatio.value // compensate for existing margins const newShiftX = shiftX.value - scrollPercX * growX diff --git a/lib/composables/useRotation.ts b/lib/composables/useRotation.ts new file mode 100644 index 0000000..78a3625 --- /dev/null +++ b/lib/composables/useRotation.ts @@ -0,0 +1,169 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFile } from '@nextcloud/files' +import type { Ref } from 'vue' + +import axios from '@nextcloud/axios' +import { showError } from '@nextcloud/dialogs' +import { emit as emitBus } from '@nextcloud/event-bus' +import { Permission } from '@nextcloud/files' +import { readJpegOrientation, rotateOrientation, setJpegOrientation } from '@nextcloud/image-editor/jpeg' +import { computed, onScopeDispose, ref, watch } from 'vue' +import { logger } from '../services/logger.ts' +import { t } from '../utils/l10n.ts' + +/** + * How long the viewer waits after the last turn before writing. + * + * Every write makes a version of the file, so writing a turn per click + * would leave four copies of a photo in the version history on the way + * round a full circle. Waiting for the user to settle writes the net turn + * once, and costs nothing when they only meant to turn it one quarter. + */ +const QUIET_MS = 1200 + +/** The only format carrying an orientation this stack honours */ +const ROTATABLE_MIME = 'image/jpeg' + +/** + * A rotation the viewer shows at once and writes to the file shortly after. + * + * The turn is shown by rotating the element, which is instant and costs + * nothing, while the file is amended in the background by rewriting its + * Exif orientation tag. Nothing is decoded and nothing is re-encoded, so + * the picture is the same picture however many times it is turned. That + * tag is also what the server's preview generator reads, so once the write + * lands the new framing follows the file everywhere: the Files grid, + * Photos, the mobile clients. + * + * @param file the file on screen + */ +export function useRotation(file: Ref) { + /** Quarter turns anticlockwise the viewer is showing, beyond the file's own */ + const turns = ref(0) + + /** Whether a write is in flight, so a caller can show it */ + const saving = ref(false) + + /** + * The turns not yet written, and the file they belong to. Held apart + * from `file` because a write outlives the view: turning a picture and + * moving straight on has to amend the one that was turned. + */ + let pending = 0 + let target: IFile | undefined + let timer: ReturnType | undefined + + /** + * The version last written for a file, against which the next turn + * guards. Kept here rather than on the file because the preview URL + * carries the etag: moving it would reload the element to a freshly + * turned preview while the turn is still being shown on top. + */ + const versions = new Map() + + /** How many writes are in flight, so the last one out clears the flag */ + let writing = 0 + + /** + * Whether the file on screen can be turned. + * + * Both halves matter. Only a JPEG carries an orientation that the + * browser and the preview generator both honour, and only a file the + * user may write can keep one. Offering the turn anywhere else means a + * rotation that quietly fails to stick. + */ + const canRotate = computed(() => file.value?.mime === ROTATABLE_MIME + && ((file.value?.permissions ?? Permission.NONE) & Permission.UPDATE) !== 0) + + /** Turn the picture on screen a quarter anticlockwise */ + function rotateLeft(): void { + if (file.value === undefined || !canRotate.value) { + return + } + // A turn owed on another file is written before this one takes + // over, rather than being dropped + if (target !== undefined && target.source !== file.value.source) { + void save() + } + target = file.value + pending++ + turns.value = (turns.value + 1) % 4 + clearTimeout(timer) + timer = setTimeout(() => void save(), QUIET_MS) + } + + /** + * Write whatever turns are owed, if any. + * + * Nothing is written for a picture turned the whole way round: four + * quarters leave the file as it was, and a version of a file that did + * not change is worse than no version at all. + */ + async function save(): Promise { + clearTimeout(timer) + const owed = pending % 4 + const node = target + pending = 0 + target = undefined + if (node === undefined || owed === 0) { + return + } + + writing++ + saving.value = true + try { + const response = await axios.get(node.encodedSource, { responseType: 'arraybuffer' }) + const bytes = new Uint8Array(response.data as ArrayBuffer) + + let orientation = readJpegOrientation(bytes) + for (let turn = 0; turn < owed; turn++) { + orientation = rotateOrientation(orientation, 'left') + } + const written = setJpegOrientation(bytes, orientation) + if (written === null) { + logger.error('Could not write the orientation of this JPEG', { source: node.source }) + showError(t('This image could not be rotated')) + return + } + + const known = versions.get(node.source) ?? node.attributes?.etag as string | undefined + const result = await axios.put(node.encodedSource, new Blob([written], { type: ROTATABLE_MIME }), { + headers: known ? { 'If-Match': `"${String(known).replace(/"|"/g, '')}"` } : undefined, + }) + + const saved = result.headers?.['oc-etag'] ?? result.headers?.etag + if (saved) { + versions.set(node.source, String(saved).replace(/"/g, '')) + } + + emitBus('files:node:updated', node) + } catch (error) { + logger.error('Failed to rotate the image', { error }) + if ((error as { response?: { status?: number } }).response?.status === 412) { + showError(t('The file was changed elsewhere. Reload the page to rotate it.')) + } else { + showError(t('Could not rotate the image')) + } + } finally { + writing-- + saving.value = writing > 0 + } + } + + // Moving to another file writes what is owed on the one being left, + // and starts the new one square + watch(() => file.value?.source, () => { + void save() + turns.value = 0 + }) + + onScopeDispose(() => { + void save() + }) + + return { canRotate, rotateLeft, saving, turns } +} diff --git a/lib/viewer.ts b/lib/viewer.ts index 2f4e9dc..3a4dc64 100644 --- a/lib/viewer.ts +++ b/lib/viewer.ts @@ -49,6 +49,13 @@ export interface ViewerProps { */ isSidebarShown: boolean + /** + * Quarter turns anticlockwise the viewer is showing on top of the + * file's own orientation, while a rotation is being written. Handlers + * that can turn their content should honour it; the rest may ignore it. + */ + turns?: number + /** * Optional client-side source to display instead of fetching from the server * (e.g. an object URL for a freshly edited image not yet reflected in the diff --git a/lib/views/Viewer.vue b/lib/views/Viewer.vue index dff124a..bc33f0e 100644 --- a/lib/views/Viewer.vue +++ b/lib/views/Viewer.vue @@ -18,7 +18,7 @@ :enableSlideshow="!isComparing && (hasPrevious || hasNext)" :hasNext="!isComparing && hasNext" :hasPrevious="!isComparing && hasPrevious" - :inlineActions="canEdit ? 1 : 0" + :inlineActions="(canRotate ? 1 : 0) + (canEdit ? 1 : 0)" :lightBackdrop="lightBackdrop" :name="modalName" :show="!!currentFile || !!errorString" @@ -32,6 +32,17 @@ @next="next">