Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/components/videoEdit/VideoCropOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/* eslint-disable testing-library/prefer-user-event -- crop dragging needs low-level pointer events */
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'

import type { Bounds } from '@/renderer/core/layout/types'

import VideoCropOverlay from './VideoCropOverlay.vue'

const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
videoEdit: {
adjustCrop: 'Adjust crop region'
}
}
}
})

function renderOverlay({
bounds = { x: 100, y: 100, width: 200, height: 200 },
disabled = false,
lockedRatio = null as number | null
} = {}) {
const model = ref<Bounds>(bounds)
render(VideoCropOverlay, {
props: {
modelValue: model.value,
sourceWidth: 1000,
sourceHeight: 1000,
disabled,
lockedRatio,
'onUpdate:modelValue': (value: Bounds) => {
model.value = value
}
},
global: {
plugins: [i18n]
}
})

const root = screen.getByTestId('video-crop-overlay')
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: () => ({})
})

return { model }
}
Comment thread
jtydhr88 marked this conversation as resolved.

describe('VideoCropOverlay', () => {
it('positions the crop box by source-relative percentages', () => {
renderOverlay({ bounds: { x: 100, y: 200, width: 500, height: 250 } })

const box = screen.getByLabelText('Adjust crop region')
expect(box.style.left).toBe('10%')
expect(box.style.top).toBe('20%')
expect(box.style.width).toBe('50%')
expect(box.style.height).toBe('25%')
})

it('renders eight resize handles', () => {
renderOverlay()

for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
expect(screen.getByTestId(`crop-handle-${dir}`)).toBeTruthy()
}
})

it('moves the crop box by dragging it', async () => {
const { model } = renderOverlay()

const box = screen.getByLabelText('Adjust crop region')
box.setPointerCapture = vi.fn()

await fireEvent.pointerDown(box, {
clientX: 0,
clientY: 0,
button: 0,
pointerId: 1
})
await fireEvent.pointerMove(box, {
clientX: 5,
clientY: 7,
pointerId: 1
})
await fireEvent.pointerUp(box, { pointerId: 1 })

expect(model.value).toEqual({ x: 150, y: 170, width: 200, height: 200 })
})

it('resizes the crop box from a corner handle', async () => {
const { model } = renderOverlay()

const handle = screen.getByTestId('crop-handle-se')
handle.setPointerCapture = vi.fn()

await fireEvent.pointerDown(handle, {
clientX: 0,
clientY: 0,
button: 0,
pointerId: 1
})
await fireEvent.pointerMove(handle, {
clientX: 10,
clientY: 5,
pointerId: 1
})
await fireEvent.pointerUp(handle, { pointerId: 1 })

expect(model.value).toEqual({ x: 100, y: 100, width: 300, height: 250 })
})

it('does not react to drags while disabled', async () => {
const { model } = renderOverlay({ disabled: true })

const box = screen.getByLabelText('Adjust crop region')
box.setPointerCapture = vi.fn()

await fireEvent.pointerDown(box, {
clientX: 0,
clientY: 0,
button: 0,
pointerId: 1
})
await fireEvent.pointerMove(box, { clientX: 5, clientY: 5, pointerId: 1 })

expect(model.value).toEqual({ x: 100, y: 100, width: 200, height: 200 })
})
})
104 changes: 104 additions & 0 deletions src/components/videoEdit/VideoCropOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<div
ref="rootEl"
class="pointer-events-none absolute inset-0 overflow-hidden"
data-testid="video-crop-overlay"
>
<div
:class="
cn(
'pointer-events-auto absolute cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
disabled && 'pointer-events-none opacity-60'
)
"
:style="cropBoxStyle"
data-testid="crop-box"
:aria-label="$t('videoEdit.adjustCrop')"
@pointerdown.stop="startDrag('move', $event)"
/>
<div
v-for="handle in HANDLES"
:key="handle.dir"
:class="
cn(
'pointer-events-auto absolute size-2.5 -translate-1/2 border border-black/40 bg-white',
handle.cursor,
disabled && 'pointer-events-none opacity-60'
)
"
:style="handleStyle(handle.dir)"
:data-testid="`crop-handle-${handle.dir}`"
@pointerdown.stop="startDrag(handle.dir, $event)"
/>
Comment thread
jtydhr88 marked this conversation as resolved.
</div>
</template>

<script setup lang="ts">
import { computed, toRef, useTemplateRef } from 'vue'

import { useCropBoxEditor } from '@/composables/video/useCropBoxEditor'
import type { CropResizeDir } from '@/composables/video/useCropBoxEditor'
import type { Bounds } from '@/renderer/core/layout/types'
import { cn } from '@comfyorg/tailwind-utils'

const HANDLES: Array<{ dir: CropResizeDir; cursor: string }> = [
{ dir: 'nw', cursor: 'cursor-nwse-resize' },
{ dir: 'n', cursor: 'cursor-ns-resize' },
{ dir: 'ne', cursor: 'cursor-nesw-resize' },
{ dir: 'e', cursor: 'cursor-ew-resize' },
{ dir: 'se', cursor: 'cursor-nwse-resize' },
{ dir: 's', cursor: 'cursor-ns-resize' },
{ dir: 'sw', cursor: 'cursor-nesw-resize' },
{ dir: 'w', cursor: 'cursor-ew-resize' }
]

const {
sourceWidth,
sourceHeight,
lockedRatio = null,
disabled = false
} = defineProps<{
sourceWidth: number
sourceHeight: number
lockedRatio?: number | null
disabled?: boolean
}>()

const bounds = defineModel<Bounds>({ required: true })

const rootEl = useTemplateRef<HTMLDivElement>('rootEl')

const { startDrag } = useCropBoxEditor(bounds, {
rootEl,
sourceWidth: toRef(() => sourceWidth),
sourceHeight: toRef(() => sourceHeight),
isDisabled: () => disabled,
lockedRatio: toRef(() => lockedRatio)
})

function pct(value: number, total: number) {
return total > 0 ? `${(value / total) * 100}%` : '0%'
}

const cropBoxStyle = computed(() => ({
left: pct(bounds.value.x, sourceWidth),
top: pct(bounds.value.y, sourceHeight),
width: pct(bounds.value.width, sourceWidth),
height: pct(bounds.value.height, sourceHeight)
}))

function handleStyle(dir: CropResizeDir) {
const { x, y, width, height } = bounds.value
const cx = dir.includes('w')
? x
: dir.includes('e')
? x + width
: x + width / 2
const cy = dir.includes('n')
? y
: dir.includes('s')
? y + height
: y + height / 2
return { left: pct(cx, sourceWidth), top: pct(cy, sourceHeight) }
}
</script>
Loading
Loading