feat: orientation toggle and mismatch indicator in crop editor
CI / test (push) Has been cancelled

The crop tool now exposes a landscape/portrait toggle next to the
device-name label, and the canvas crop frame snaps to the chosen
aspect when toggled. Choosing an orientation that does not match
the target frame's current orientation surfaces a yellow informational
chip — purely informational, no action required, clears as soon as
the user toggles back to the matching orientation (or changes the
frame in Settings).

The chosen orientation rides along on the upload/reprocess request
as a new cropOrientation form field and is persisted on the Image
entity, so the library view and rotation logic can later surface
the same mismatch state for already-uploaded photos. Existing photos
without a stored orientation get null and are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 14:45:59 -04:00
parent c387260ee7
commit 52e85703f7
11 changed files with 225 additions and 38 deletions
+140 -18
View File
@@ -8,7 +8,44 @@
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<div class="crop-editor__label" v-if="deviceName">{{ deviceName }}</div>
<div class="crop-editor__top">
<div v-if="deviceName" class="crop-editor__label">{{ deviceName }}</div>
<!-- Orientation toggle: switches the crop frame's aspect ratio -->
<div class="crop-editor__orient" role="radiogroup" aria-label="Crop orientation">
<button
v-for="opt in ORIENT_OPTS"
:key="opt.value"
type="button"
role="radio"
:aria-checked="cropOrientation === opt.value"
:aria-label="opt.label"
:class="['crop-editor__orient-btn', { 'crop-editor__orient-btn--active': cropOrientation === opt.value }]"
@click="setOrientation(opt.value)"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect v-if="opt.value === 'landscape'" x="2" y="6" width="20" height="12" rx="1.5"/>
<rect v-else x="6" y="2" width="12" height="20" rx="1.5"/>
</svg>
</button>
</div>
<!-- Mismatch chip: only shown when crop orientation differs from frame's -->
<div
v-if="mismatch"
class="crop-editor__mismatch"
role="status"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>Frame is set to {{ orientation }}. Switch the frame in Settings to display this crop.</span>
</div>
</div>
<div class="crop-editor__actions">
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
Use this crop
@@ -18,25 +55,45 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types'
type Orientation = 'landscape' | 'portrait'
const props = defineProps<{
src: string
orientation: 'landscape' | 'portrait'
/** Frame's current orientation used as the toggle's initial value and for the mismatch chip. */
orientation: Orientation
deviceName?: string
initialParams?: CropParams | null
/** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */
initialOrientation?: Orientation | null
}>()
const emit = defineEmits<{
(e: 'crop', result: { blob: Blob; params: CropParams }): void
(e: 'crop', result: { blob: Blob; params: CropParams; orientation: Orientation }): void
}>()
// Dimensions for each orientation
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600
const ASPECT = OUTPUT_W / OUTPUT_H
const ORIENT_OPTS: Array<{ value: Orientation; label: string }> = [
{ value: 'landscape', label: 'Landscape crop' },
{ value: 'portrait', label: 'Portrait crop' },
]
// User's chosen crop orientation. Initial value: the saved choice if editing,
// otherwise the frame's current orientation. Reactive — toggling re-shapes
// the crop frame and the eventual output blob.
const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation)
const outputDims = computed(() =>
cropOrientation.value === 'landscape'
? { w: 1600, h: 960 }
: { w: 960, h: 1600 }
)
const aspect = computed(() => outputDims.value.w / outputDims.value.h)
// Visible only when the user's choice doesn't match the frame's setting.
const mismatch = computed(() => cropOrientation.value !== props.orientation)
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
@@ -53,6 +110,17 @@ const zoom = ref(1)
let cropRect = { x: 0, y: 0, w: 0, h: 0 }
let minScale = 1 // natural px → canvas px at zoom=1 (cover)
function setOrientation(o: Orientation) {
if (cropOrientation.value === o) return
cropOrientation.value = o
// Re-shape the crop frame to the new aspect; reset pan/zoom because the
// saved coordinates no longer make sense against the new frame.
panX.value = 0
panY.value = 0
zoom.value = 1
sizeCanvas()
}
function sizeCanvas() {
const canvas = canvasRef.value
const container = containerRef.value
@@ -74,12 +142,12 @@ function sizeCanvas() {
const maxH = availH - pad * 2
let cropW: number, cropH: number
if (maxW / maxH > ASPECT) {
if (maxW / maxH > aspect.value) {
cropH = maxH
cropW = cropH * ASPECT
cropW = cropH * aspect.value
} else {
cropW = maxW
cropH = cropW / ASPECT
cropH = cropW / aspect.value
}
cropRect = {
@@ -247,13 +315,15 @@ async function useCrop() {
const natCropW = cropRect.w / actualScale
const natCropH = cropRect.h / actualScale
const out = new OffscreenCanvas(OUTPUT_W, OUTPUT_H)
const { w: outW, h: outH } = outputDims.value
const out = new OffscreenCanvas(outW, outH)
const outCtx = out.getContext('2d')!
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, OUTPUT_W, OUTPUT_H)
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, outW, outH)
const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
emit('crop', {
blob,
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
orientation: cropOrientation.value,
})
}
@@ -304,11 +374,19 @@ onBeforeUnmount(() => {
&:active { cursor: grabbing; }
}
&__label {
&__top {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
top: 12px;
left: 12px;
right: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
&__label {
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: var(--text-xs);
@@ -316,7 +394,51 @@ onBeforeUnmount(() => {
padding: 4px 12px;
border-radius: 999px;
letter-spacing: 0.04em;
pointer-events: none;
}
&__orient {
pointer-events: auto;
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.6);
border-radius: 999px;
padding: 4px;
}
&__orient-btn {
width: 36px;
height: 32px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.65);
border-radius: 999px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--duration-fast), color var(--duration-fast);
&--active {
background: #fff;
color: #000;
}
}
&__mismatch {
pointer-events: auto;
display: flex;
align-items: center;
gap: 6px;
background: var(--color-warning, #f59e0b);
color: #fff;
font-size: var(--text-xs);
font-weight: 600;
padding: 6px 12px;
border-radius: 999px;
max-width: 100%;
line-height: 1.3;
svg { flex-shrink: 0; }
}
&__actions {
+9 -6
View File
@@ -6,6 +6,7 @@ interface UploadExtras {
original?: File
cropParams?: CropParams
stickerState?: StickerLayer[]
cropOrientation?: 'landscape' | 'portrait'
}
export const useImagesStore = defineStore('images', () => {
@@ -31,9 +32,10 @@ export const useImagesStore = defineStore('images', () => {
async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> {
const form = new FormData()
form.append('file', file)
if (extras?.original) form.append('original', extras.original)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
if (extras?.original) form.append('original', extras.original)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
if (extras?.cropOrientation) form.append('cropOrientation', extras.cropOrientation)
const res = await fetch('/api/images', { method: 'POST', body: form })
if (!res.ok) {
@@ -45,11 +47,12 @@ export const useImagesStore = defineStore('images', () => {
return image
}
async function reprocessImage(imageId: number, composited: File, extras?: { cropParams?: CropParams; stickerState?: StickerLayer[] }): Promise<Image> {
async function reprocessImage(imageId: number, composited: File, extras?: { cropParams?: CropParams; stickerState?: StickerLayer[]; cropOrientation?: 'landscape' | 'portrait' }): Promise<Image> {
const form = new FormData()
form.append('file', composited)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState))
if (extras?.cropOrientation) form.append('cropOrientation', extras.cropOrientation)
const res = await fetch(`/api/images/${imageId}/reprocess`, { method: 'POST', body: form })
if (!res.ok) {
+9 -5
View File
@@ -8,6 +8,7 @@ export const useUploadStore = defineStore('upload', () => {
const croppedBlob = ref<Blob | null>(null)
const croppedUrl = ref<string | null>(null)
const cropParams = ref<CropParams | null>(null)
const cropOrientation = ref<'landscape' | 'portrait' | null>(null)
const stickers = ref<StickerLayer[]>([])
const contextDeviceId = ref<number | null>(null)
const selectedDeviceIds = ref<number[]>([])
@@ -29,16 +30,18 @@ export const useUploadStore = defineStore('upload', () => {
originalUrl.value = URL.createObjectURL(blob)
editingImageId.value = image.id
cropParams.value = image.cropParams ?? null
cropOrientation.value = image.cropOrientation ?? null
stickers.value = image.stickerState ? [...image.stickerState] : []
selectedDeviceIds.value = image.approvedDeviceIds
contextDeviceId.value = deviceId ?? null
}
function setCrop(blob: Blob, params: CropParams) {
function setCrop(blob: Blob, params: CropParams, orientation: 'landscape' | 'portrait') {
if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
croppedBlob.value = blob
croppedUrl.value = URL.createObjectURL(blob)
cropParams.value = params
croppedBlob.value = blob
croppedUrl.value = URL.createObjectURL(blob)
cropParams.value = params
cropOrientation.value = orientation
}
function addSticker(s: StickerLayer) {
@@ -61,6 +64,7 @@ export const useUploadStore = defineStore('upload', () => {
croppedBlob.value = null
croppedUrl.value = null
cropParams.value = null
cropOrientation.value = null
stickers.value = []
contextDeviceId.value = null
selectedDeviceIds.value = []
@@ -69,7 +73,7 @@ export const useUploadStore = defineStore('upload', () => {
return {
originalFile, originalUrl,
croppedBlob, croppedUrl, cropParams,
croppedBlob, croppedUrl, cropParams, cropOrientation,
stickers, contextDeviceId, selectedDeviceIds, editingImageId,
init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup,
}
+1
View File
@@ -12,6 +12,7 @@ const makeImage = (overrides: Partial<Image> = {}): Image => ({
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
cropOrientation: null,
...overrides,
})
+4 -2
View File
@@ -59,7 +59,7 @@ describe('upload store', () => {
expect(store.selectedDeviceIds).toEqual([])
})
it('setCrop stores croppedBlob and cropParams', () => {
it('setCrop stores croppedBlob, cropParams, and cropOrientation', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
@@ -67,12 +67,13 @@ describe('upload store', () => {
const blob = new Blob(['crop'], { type: 'image/jpeg' })
const params = { natX: 0, natY: 0, natW: 200, natH: 200 }
store.setCrop(blob, params)
store.setCrop(blob, params, 'portrait')
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.croppedBlob).toStrictEqual(blob)
expect(store.croppedUrl).toBe('blob:mock-url')
expect(store.cropParams).toEqual(params)
expect(store.cropOrientation).toBe('portrait')
})
it('addSticker appends to stickers', () => {
@@ -137,6 +138,7 @@ describe('upload store', () => {
expect(store.croppedBlob).toBeNull()
expect(store.croppedUrl).toBeNull()
expect(store.cropParams).toBeNull()
expect(store.cropOrientation).toBeNull()
expect(store.stickers).toHaveLength(0)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
@@ -65,6 +65,7 @@ const makeImage = (overrides: Partial<Image> = {}): Image => ({
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
cropOrientation: null,
...overrides,
})
+1
View File
@@ -46,6 +46,7 @@ export interface Image {
approvedDeviceIds: number[]
cropParams: CropParams | null
stickerState: StickerLayer[] | null
cropOrientation: 'landscape' | 'portrait' | null
}
export interface RenderedAsset {
+10 -7
View File
@@ -32,6 +32,7 @@
:orientation="contextOrientation"
:device-name="contextDeviceName"
:initial-params="uploadStore.cropParams"
:initial-orientation="uploadStore.cropOrientation"
class="upload-view__stage"
@crop="onCrop"
/>
@@ -133,8 +134,8 @@ const stepLabel = computed(() => {
return isEdit.value ? 'Updated' : 'Added'
})
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) {
uploadStore.setCrop(blob, params)
function onCrop({ blob, params, orientation }: { blob: Blob; params: CropParams; orientation: 'landscape' | 'portrait' }) {
uploadStore.setCrop(blob, params, orientation)
step.value = 'stickers'
}
@@ -178,8 +179,9 @@ async function doUpload() {
if (isEdit.value) {
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
cropOrientation: uploadStore.cropOrientation ?? undefined,
})
devicePickerOpen.value = false
step.value = 'done'
@@ -187,9 +189,10 @@ async function doUpload() {
}
const image = await imagesStore.uploadImage(composited, {
original: uploadStore.originalFile ?? undefined,
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
original: uploadStore.originalFile ?? undefined,
cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers,
cropOrientation: uploadStore.cropOrientation ?? undefined,
})
await Promise.all(