Skip to content

Commit f36d64b

Browse files
committed
feat(viewer): add visual image rotation (90° steps)
Add rotate left/right buttons to the image viewer toolbar. Rotation is visual-only (CSS transform), no file modification. Buttons appear inline in the header for images (jpeg/png/webp). Rotation resets when navigating to next/previous image. Assisted-by: ClaudeCode:claude-sonnet-4-6 Assisted-by: ClaudeCode:claude-opus-5
1 parent 9707694 commit f36d64b

2 files changed

Lines changed: 49 additions & 1 deletion

File tree

src/components/Images.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ export default {
104104
type: Boolean,
105105
default: false,
106106
},
107+
rotation: {
108+
type: Number,
109+
default: 0,
110+
},
107111
},
108112
data() {
109113
return {
@@ -134,17 +138,20 @@ export default {
134138
return this.basename
135139
},
136140
imgStyle() {
141+
const transform = this.rotation !== 0 ? `rotate(${this.rotation}deg)` : undefined
137142
if (this.zoomRatio === 1) {
138143
return {
139144
height: this.zoomHeight + 'px',
140145
width: this.zoomWidth + 'px',
146+
transform,
141147
}
142148
}
143149
return {
144150
marginTop: Math.round(this.shiftY * 2) + 'px',
145151
marginLeft: Math.round(this.shiftX * 2) + 'px',
146152
height: this.zoomHeight + 'px',
147153
width: this.zoomWidth + 'px',
154+
transform,
148155
}
149156
},
150157
livePhoto() {
@@ -200,6 +207,9 @@ export default {
200207
},
201208
},
202209
watch: {
210+
rotation() {
211+
this.resetZoom()
212+
},
203213
active(val, old) {
204214
// the item was hidden before and is now the current view
205215
if (val === true && old === false) {

src/views/Viewer.vue

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
:enable-swipe="canSwipe && !editing"
4242
:has-next="hasNext"
4343
:has-previous="hasPrevious"
44-
:inline-actions="canEdit ? 1 : 0"
44+
:inline-actions="(canEdit ? 1 : 0) + (canRotate ? 2 : 0)"
4545
:spread-navigation="true"
4646
:style="{ width: isSidebarShown ? `${sidebarPosition}px` : null }"
4747
:name="currentFile.basename"
@@ -61,6 +61,24 @@
6161
</template>
6262
{{ t('viewer', 'Edit') }}
6363
</NcActionButton>
64+
<NcActionButton v-if="canRotate"
65+
:close-after-click="false"
66+
:aria-label="t('viewer', 'Rotate counterclockwise')"
67+
@click="rotateLeft">
68+
<template #icon>
69+
<RotateLeft :size="20" />
70+
</template>
71+
{{ t('viewer', 'Rotate counterclockwise') }}
72+
</NcActionButton>
73+
<NcActionButton v-if="canRotate"
74+
:close-after-click="false"
75+
:aria-label="t('viewer', 'Rotate clockwise')"
76+
@click="rotateRight">
77+
<template #icon>
78+
<RotateRight :size="20" />
79+
</template>
80+
{{ t('viewer', 'Rotate clockwise') }}
81+
</NcActionButton>
6482
<!-- Menu items -->
6583
<NcActionButton :close-after-click="true"
6684
@click="toggleFullScreen">
@@ -148,6 +166,7 @@
148166
:is-full-screen="isFullscreen"
149167
:is-sidebar-shown="isSidebarShown"
150168
:loaded.sync="currentFile.loaded"
169+
:rotation="rotation"
151170
class="viewer__file viewer__file--active"
152171
@update:editing="toggleEditor"
153172
@error="currentFailed" />
@@ -207,6 +226,8 @@ import Fullscreen from 'vue-material-design-icons/Fullscreen.vue'
207226
import FullscreenExit from 'vue-material-design-icons/FullscreenExit.vue'
208227
import Pencil from 'vue-material-design-icons/PencilOutline.vue'
209228
import DockRight from 'vue-material-design-icons/DockRight.vue'
229+
import RotateLeft from 'vue-material-design-icons/RotateLeft.vue'
230+
import RotateRight from 'vue-material-design-icons/RotateRight.vue'
210231
211232
import '@nextcloud/dialogs/style.css'
212233
@@ -229,6 +250,8 @@ export default defineComponent({
229250
NcActionLink,
230251
NcModal,
231252
Pencil,
253+
RotateLeft,
254+
RotateRight,
232255
},
233256
234257
mixins: [isFullscreen, isMobile],
@@ -251,6 +274,7 @@ export default defineComponent({
251274
comparisonFile: null,
252275
nextFile: {},
253276
fileList: [],
277+
rotation: 0,
254278
sortingConfig: null,
255279
256280
// States
@@ -333,6 +357,10 @@ export default defineComponent({
333357
return ['image/jpeg', 'image/png', 'image/webp'].includes(this.currentFile?.mime)
334358
},
335359
360+
canRotate() {
361+
return this.isImage && !this.comparisonFile
362+
},
363+
336364
sidebarOpenFilePath() {
337365
try {
338366
const relativePath = this.currentFile?.davPath?.split(defaultRootPath)[1]
@@ -1062,7 +1090,16 @@ export default defineComponent({
10621090
/**
10631091
* Open previous available file
10641092
*/
1093+
rotateLeft() {
1094+
this.rotation = ((this.rotation - 90) + 360) % 360
1095+
},
1096+
1097+
rotateRight() {
1098+
this.rotation = (this.rotation + 90) % 360
1099+
},
1100+
10651101
previous() {
1102+
this.rotation = 0
10661103
this.currentIndex--
10671104
if (this.currentIndex < 0) {
10681105
this.currentIndex = this.fileList.length - 1
@@ -1078,6 +1115,7 @@ export default defineComponent({
10781115
* Open next available file
10791116
*/
10801117
next() {
1118+
this.rotation = 0
10811119
this.currentIndex++
10821120
if (this.currentIndex > this.fileList.length - 1) {
10831121
this.currentIndex = 0

0 commit comments

Comments
 (0)