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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user