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" @pointerup="onPointerUp"
@pointercancel="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"> <div class="crop-editor__actions">
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop"> <BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
Use this crop Use this crop
@@ -18,25 +55,45 @@
</template> </template>
<script setup lang="ts"> <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 BaseButton from '@/components/BaseButton.vue'
import type { CropParams } from '@/types' import type { CropParams } from '@/types'
type Orientation = 'landscape' | 'portrait'
const props = defineProps<{ const props = defineProps<{
src: string 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 deviceName?: string
initialParams?: CropParams | null initialParams?: CropParams | null
/** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */
initialOrientation?: Orientation | null
}>() }>()
const emit = defineEmits<{ 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 ORIENT_OPTS: Array<{ value: Orientation; label: string }> = [
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960 { value: 'landscape', label: 'Landscape crop' },
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600 { value: 'portrait', label: 'Portrait crop' },
const ASPECT = OUTPUT_W / OUTPUT_H ]
// 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 containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>() const canvasRef = ref<HTMLCanvasElement>()
@@ -53,6 +110,17 @@ const zoom = ref(1)
let cropRect = { x: 0, y: 0, w: 0, h: 0 } let cropRect = { x: 0, y: 0, w: 0, h: 0 }
let minScale = 1 // natural px → canvas px at zoom=1 (cover) 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() { function sizeCanvas() {
const canvas = canvasRef.value const canvas = canvasRef.value
const container = containerRef.value const container = containerRef.value
@@ -74,12 +142,12 @@ function sizeCanvas() {
const maxH = availH - pad * 2 const maxH = availH - pad * 2
let cropW: number, cropH: number let cropW: number, cropH: number
if (maxW / maxH > ASPECT) { if (maxW / maxH > aspect.value) {
cropH = maxH cropH = maxH
cropW = cropH * ASPECT cropW = cropH * aspect.value
} else { } else {
cropW = maxW cropW = maxW
cropH = cropW / ASPECT cropH = cropW / aspect.value
} }
cropRect = { cropRect = {
@@ -247,13 +315,15 @@ async function useCrop() {
const natCropW = cropRect.w / actualScale const natCropW = cropRect.w / actualScale
const natCropH = cropRect.h / 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')! 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 }) const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
emit('crop', { emit('crop', {
blob, blob,
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH }, params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
orientation: cropOrientation.value,
}) })
} }
@@ -304,11 +374,19 @@ onBeforeUnmount(() => {
&:active { cursor: grabbing; } &:active { cursor: grabbing; }
} }
&__label { &__top {
position: absolute; position: absolute;
top: 16px; top: 12px;
left: 50%; left: 12px;
transform: translateX(-50%); right: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
&__label {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
color: #fff; color: #fff;
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -316,7 +394,51 @@ onBeforeUnmount(() => {
padding: 4px 12px; padding: 4px 12px;
border-radius: 999px; border-radius: 999px;
letter-spacing: 0.04em; 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 { &__actions {
+9 -6
View File
@@ -6,6 +6,7 @@ interface UploadExtras {
original?: File original?: File
cropParams?: CropParams cropParams?: CropParams
stickerState?: StickerLayer[] stickerState?: StickerLayer[]
cropOrientation?: 'landscape' | 'portrait'
} }
export const useImagesStore = defineStore('images', () => { export const useImagesStore = defineStore('images', () => {
@@ -31,9 +32,10 @@ export const useImagesStore = defineStore('images', () => {
async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> { async function uploadImage(file: File, extras?: UploadExtras): Promise<Image> {
const form = new FormData() const form = new FormData()
form.append('file', file) form.append('file', file)
if (extras?.original) form.append('original', extras.original) if (extras?.original) form.append('original', extras.original)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams)) if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState)) 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 }) const res = await fetch('/api/images', { method: 'POST', body: form })
if (!res.ok) { if (!res.ok) {
@@ -45,11 +47,12 @@ export const useImagesStore = defineStore('images', () => {
return image 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() const form = new FormData()
form.append('file', composited) form.append('file', composited)
if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams)) if (extras?.cropParams) form.append('cropParams', JSON.stringify(extras.cropParams))
if (extras?.stickerState) form.append('stickerState', JSON.stringify(extras.stickerState)) 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 }) const res = await fetch(`/api/images/${imageId}/reprocess`, { method: 'POST', body: form })
if (!res.ok) { if (!res.ok) {
+9 -5
View File
@@ -8,6 +8,7 @@ export const useUploadStore = defineStore('upload', () => {
const croppedBlob = ref<Blob | null>(null) const croppedBlob = ref<Blob | null>(null)
const croppedUrl = ref<string | null>(null) const croppedUrl = ref<string | null>(null)
const cropParams = ref<CropParams | null>(null) const cropParams = ref<CropParams | null>(null)
const cropOrientation = ref<'landscape' | 'portrait' | null>(null)
const stickers = ref<StickerLayer[]>([]) const stickers = ref<StickerLayer[]>([])
const contextDeviceId = ref<number | null>(null) const contextDeviceId = ref<number | null>(null)
const selectedDeviceIds = ref<number[]>([]) const selectedDeviceIds = ref<number[]>([])
@@ -29,16 +30,18 @@ export const useUploadStore = defineStore('upload', () => {
originalUrl.value = URL.createObjectURL(blob) originalUrl.value = URL.createObjectURL(blob)
editingImageId.value = image.id editingImageId.value = image.id
cropParams.value = image.cropParams ?? null cropParams.value = image.cropParams ?? null
cropOrientation.value = image.cropOrientation ?? null
stickers.value = image.stickerState ? [...image.stickerState] : [] stickers.value = image.stickerState ? [...image.stickerState] : []
selectedDeviceIds.value = image.approvedDeviceIds selectedDeviceIds.value = image.approvedDeviceIds
contextDeviceId.value = deviceId ?? null 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) if (croppedUrl.value) URL.revokeObjectURL(croppedUrl.value)
croppedBlob.value = blob croppedBlob.value = blob
croppedUrl.value = URL.createObjectURL(blob) croppedUrl.value = URL.createObjectURL(blob)
cropParams.value = params cropParams.value = params
cropOrientation.value = orientation
} }
function addSticker(s: StickerLayer) { function addSticker(s: StickerLayer) {
@@ -61,6 +64,7 @@ export const useUploadStore = defineStore('upload', () => {
croppedBlob.value = null croppedBlob.value = null
croppedUrl.value = null croppedUrl.value = null
cropParams.value = null cropParams.value = null
cropOrientation.value = null
stickers.value = [] stickers.value = []
contextDeviceId.value = null contextDeviceId.value = null
selectedDeviceIds.value = [] selectedDeviceIds.value = []
@@ -69,7 +73,7 @@ export const useUploadStore = defineStore('upload', () => {
return { return {
originalFile, originalUrl, originalFile, originalUrl,
croppedBlob, croppedUrl, cropParams, croppedBlob, croppedUrl, cropParams, cropOrientation,
stickers, contextDeviceId, selectedDeviceIds, editingImageId, stickers, contextDeviceId, selectedDeviceIds, editingImageId,
init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup, init, initEdit, setCrop, addSticker, updateSticker, removeSticker, cleanup,
} }
+1
View File
@@ -12,6 +12,7 @@ const makeImage = (overrides: Partial<Image> = {}): Image => ({
approvedDeviceIds: [], approvedDeviceIds: [],
cropParams: null, cropParams: null,
stickerState: null, stickerState: null,
cropOrientation: null,
...overrides, ...overrides,
}) })
+4 -2
View File
@@ -59,7 +59,7 @@ describe('upload store', () => {
expect(store.selectedDeviceIds).toEqual([]) expect(store.selectedDeviceIds).toEqual([])
}) })
it('setCrop stores croppedBlob and cropParams', () => { it('setCrop stores croppedBlob, cropParams, and cropOrientation', () => {
const store = useUploadStore() const store = useUploadStore()
const file = new File(['data'], 'photo.jpg') const file = new File(['data'], 'photo.jpg')
store.init(file) store.init(file)
@@ -67,12 +67,13 @@ describe('upload store', () => {
const blob = new Blob(['crop'], { type: 'image/jpeg' }) const blob = new Blob(['crop'], { type: 'image/jpeg' })
const params = { natX: 0, natY: 0, natW: 200, natH: 200 } 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 // Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.croppedBlob).toStrictEqual(blob) expect(store.croppedBlob).toStrictEqual(blob)
expect(store.croppedUrl).toBe('blob:mock-url') expect(store.croppedUrl).toBe('blob:mock-url')
expect(store.cropParams).toEqual(params) expect(store.cropParams).toEqual(params)
expect(store.cropOrientation).toBe('portrait')
}) })
it('addSticker appends to stickers', () => { it('addSticker appends to stickers', () => {
@@ -137,6 +138,7 @@ describe('upload store', () => {
expect(store.croppedBlob).toBeNull() expect(store.croppedBlob).toBeNull()
expect(store.croppedUrl).toBeNull() expect(store.croppedUrl).toBeNull()
expect(store.cropParams).toBeNull() expect(store.cropParams).toBeNull()
expect(store.cropOrientation).toBeNull()
expect(store.stickers).toHaveLength(0) expect(store.stickers).toHaveLength(0)
expect(store.contextDeviceId).toBeNull() expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([]) expect(store.selectedDeviceIds).toEqual([])
@@ -65,6 +65,7 @@ const makeImage = (overrides: Partial<Image> = {}): Image => ({
approvedDeviceIds: [], approvedDeviceIds: [],
cropParams: null, cropParams: null,
stickerState: null, stickerState: null,
cropOrientation: null,
...overrides, ...overrides,
}) })
+1
View File
@@ -46,6 +46,7 @@ export interface Image {
approvedDeviceIds: number[] approvedDeviceIds: number[]
cropParams: CropParams | null cropParams: CropParams | null
stickerState: StickerLayer[] | null stickerState: StickerLayer[] | null
cropOrientation: 'landscape' | 'portrait' | null
} }
export interface RenderedAsset { export interface RenderedAsset {
+10 -7
View File
@@ -32,6 +32,7 @@
:orientation="contextOrientation" :orientation="contextOrientation"
:device-name="contextDeviceName" :device-name="contextDeviceName"
:initial-params="uploadStore.cropParams" :initial-params="uploadStore.cropParams"
:initial-orientation="uploadStore.cropOrientation"
class="upload-view__stage" class="upload-view__stage"
@crop="onCrop" @crop="onCrop"
/> />
@@ -133,8 +134,8 @@ const stepLabel = computed(() => {
return isEdit.value ? 'Updated' : 'Added' return isEdit.value ? 'Updated' : 'Added'
}) })
function onCrop({ blob, params }: { blob: Blob; params: CropParams }) { function onCrop({ blob, params, orientation }: { blob: Blob; params: CropParams; orientation: 'landscape' | 'portrait' }) {
uploadStore.setCrop(blob, params) uploadStore.setCrop(blob, params, orientation)
step.value = 'stickers' step.value = 'stickers'
} }
@@ -178,8 +179,9 @@ async function doUpload() {
if (isEdit.value) { if (isEdit.value) {
await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, { await imagesStore.reprocessImage(uploadStore.editingImageId!, composited, {
cropParams: uploadStore.cropParams ?? undefined, cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers, stickerState: uploadStore.stickers,
cropOrientation: uploadStore.cropOrientation ?? undefined,
}) })
devicePickerOpen.value = false devicePickerOpen.value = false
step.value = 'done' step.value = 'done'
@@ -187,9 +189,10 @@ async function doUpload() {
} }
const image = await imagesStore.uploadImage(composited, { const image = await imagesStore.uploadImage(composited, {
original: uploadStore.originalFile ?? undefined, original: uploadStore.originalFile ?? undefined,
cropParams: uploadStore.cropParams ?? undefined, cropParams: uploadStore.cropParams ?? undefined,
stickerState: uploadStore.stickers, stickerState: uploadStore.stickers,
cropOrientation: uploadStore.cropOrientation ?? undefined,
}) })
await Promise.all( await Promise.all(
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260507220000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add image.crop_orientation so crop tool and library can flag orientation mismatches';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE image ADD crop_orientation VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE image DROP COLUMN crop_orientation');
}
}
+11
View File
@@ -102,6 +102,11 @@ class ImageApiController extends AbstractController
if ($request->request->has('stickerState')) { if ($request->request->has('stickerState')) {
$image->setStickerState($request->request->get('stickerState')); $image->setStickerState($request->request->get('stickerState'));
} }
if ($request->request->has('cropOrientation')) {
$image->setCropOrientation(
Orientation::tryFrom((string) $request->request->get('cropOrientation'))
);
}
// Generate thumbnail from composited if available, otherwise from original // Generate thumbnail from composited if available, otherwise from original
$thumbSrc = file_exists($storageDir . '/composited.jpg') $thumbSrc = file_exists($storageDir . '/composited.jpg')
@@ -208,6 +213,11 @@ class ImageApiController extends AbstractController
if ($request->request->has('stickerState')) { if ($request->request->has('stickerState')) {
$image->setStickerState($request->request->get('stickerState')); $image->setStickerState($request->request->get('stickerState'));
} }
if ($request->request->has('cropOrientation')) {
$image->setCropOrientation(
Orientation::tryFrom((string) $request->request->get('cropOrientation'))
);
}
// Reset all rendered assets so they re-render from the new composited // Reset all rendered assets so they re-render from the new composited
foreach ($image->getRenderedAssets() as $asset) { foreach ($image->getRenderedAssets() as $asset) {
@@ -321,6 +331,7 @@ class ImageApiController extends AbstractController
'approvedDeviceIds' => array_values($image->getApprovedDevices()->map(fn($d) => $d->getId())->toArray()), 'approvedDeviceIds' => array_values($image->getApprovedDevices()->map(fn($d) => $d->getId())->toArray()),
'cropParams' => $image->getCropParams() ? json_decode($image->getCropParams(), true) : null, 'cropParams' => $image->getCropParams() ? json_decode($image->getCropParams(), true) : null,
'stickerState' => $image->getStickerState() ? json_decode($image->getStickerState(), true) : null, 'stickerState' => $image->getStickerState() ? json_decode($image->getStickerState(), true) : null,
'cropOrientation' => $image->getCropOrientation()?->value,
]; ];
} }
+13
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use App\Enum\Orientation;
use App\Repository\ImageRepository; use App\Repository\ImageRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -36,6 +37,15 @@ class Image
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $stickerState = null; private ?string $stickerState = null;
/**
* Orientation the user picked in the crop editor. Drives the warning
* indicator on the library and the crop page when it doesn't match an
* approved device's current orientation. Null on legacy uploads predating
* the toggle.
*/
#[ORM\Column(nullable: true, enumType: Orientation::class)]
private ?Orientation $cropOrientation = null;
#[ORM\Column] #[ORM\Column]
private \DateTimeImmutable $uploadedAt; private \DateTimeImmutable $uploadedAt;
@@ -82,6 +92,9 @@ class Image
public function getStickerState(): ?string { return $this->stickerState; } public function getStickerState(): ?string { return $this->stickerState; }
public function setStickerState(?string $s): static { $this->stickerState = $s; return $this; } public function setStickerState(?string $s): static { $this->stickerState = $s; return $this; }
public function getCropOrientation(): ?Orientation { return $this->cropOrientation; }
public function setCropOrientation(?Orientation $o): static { $this->cropOrientation = $o; return $this; }
/** @return Collection<int, Device> */ /** @return Collection<int, Device> */
public function getApprovedDevices(): Collection { return $this->approvedDevices; } public function getApprovedDevices(): Collection { return $this->approvedDevices; }