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">