Add Image v2 Refine for on-device regional edits.
Mask painter and denoise strength sit beside Edit and Compose. The canned hand/chest prompt helper is gone so that text stays yours. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
ref="stage"
|
||||
class="relative overflow-hidden rounded-xl bg-black/40 touch-none"
|
||||
@pointerdown.prevent="onPointerDown"
|
||||
@pointermove.prevent="onPointerMove"
|
||||
@pointerup.prevent="onPointerUp"
|
||||
@pointercancel.prevent="onPointerUp"
|
||||
@pointerleave.prevent="onPointerUp"
|
||||
>
|
||||
<img
|
||||
ref="photo"
|
||||
:src="src"
|
||||
alt="Refine canvas"
|
||||
class="pointer-events-none block max-h-80 w-full object-contain"
|
||||
:class="hidePreview ? 'opacity-0' : ''"
|
||||
@load="fitCanvas"
|
||||
>
|
||||
<div
|
||||
v-if="hidePreview"
|
||||
class="absolute inset-0 flex min-h-44 items-center justify-center"
|
||||
>
|
||||
<span class="sensitive-glow absolute inset-3 rounded-xl" />
|
||||
<p class="relative text-sm text-zinc-300">Canvas loaded · paint the mask</p>
|
||||
</div>
|
||||
<canvas
|
||||
ref="paint"
|
||||
class="absolute cursor-crosshair"
|
||||
:style="overlayStyle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-2.5 py-1"
|
||||
:class="tool === 'brush' ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||||
@click="tool = 'brush'"
|
||||
>
|
||||
Brush
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-2.5 py-1"
|
||||
:class="tool === 'eraser' ? 'border-amber-300/70 text-amber-100' : 'border-white/10 text-zinc-400'"
|
||||
@click="tool = 'eraser'"
|
||||
>
|
||||
Eraser
|
||||
</button>
|
||||
<label class="flex items-center gap-2 text-zinc-400">
|
||||
Size
|
||||
<input v-model.number="size" type="range" min="8" max="96" step="2" class="w-24">
|
||||
<span class="tabular-nums text-zinc-300">{{ size }}</span>
|
||||
</label>
|
||||
<button type="button" class="rounded-full border border-white/10 px-2.5 py-1 text-zinc-400 hover:text-white" @click="clear">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">White paint = edit. Black = keep. Prompt only what should change in the painted area.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
hidePreview?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
painted: [has: boolean]
|
||||
}>()
|
||||
|
||||
const stage = ref<HTMLElement | null>(null)
|
||||
const photo = ref<HTMLImageElement | null>(null)
|
||||
const paint = ref<HTMLCanvasElement | null>(null)
|
||||
const tool = ref<'brush' | 'eraser'>('brush')
|
||||
const size = ref(28)
|
||||
const drawing = ref(false)
|
||||
const dirty = ref(false)
|
||||
const overlayStyle = ref<Record<string, string>>({
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
})
|
||||
|
||||
function ctx() {
|
||||
const canvas = paint.value
|
||||
if (!canvas) return null
|
||||
return canvas.getContext('2d')
|
||||
}
|
||||
|
||||
function contentBox() {
|
||||
const image = photo.value
|
||||
const wrap = stage.value
|
||||
if (!image || !wrap) return null
|
||||
const box = wrap.getBoundingClientRect()
|
||||
const img = image.getBoundingClientRect()
|
||||
return {
|
||||
left: img.left - box.left,
|
||||
top: img.top - box.top,
|
||||
width: Math.max(1, img.width),
|
||||
height: Math.max(1, img.height),
|
||||
naturalWidth: Math.max(1, image.naturalWidth),
|
||||
naturalHeight: Math.max(1, image.naturalHeight)
|
||||
}
|
||||
}
|
||||
|
||||
function fitCanvas() {
|
||||
const canvas = paint.value
|
||||
const box = contentBox()
|
||||
if (!canvas || !box) return
|
||||
const prev = canvas.width && canvas.height ? ctx()?.getImageData(0, 0, canvas.width, canvas.height) : null
|
||||
canvas.width = Math.round(box.width)
|
||||
canvas.height = Math.round(box.height)
|
||||
overlayStyle.value = {
|
||||
left: `${box.left}px`,
|
||||
top: `${box.top}px`,
|
||||
width: `${box.width}px`,
|
||||
height: `${box.height}px`
|
||||
}
|
||||
if (prev) ctx()?.putImageData(prev, 0, 0)
|
||||
}
|
||||
|
||||
function point(event: PointerEvent) {
|
||||
const canvas = paint.value
|
||||
if (!canvas) return null
|
||||
const box = canvas.getBoundingClientRect()
|
||||
return {
|
||||
x: (event.clientX - box.left) * (canvas.width / box.width),
|
||||
y: (event.clientY - box.top) * (canvas.height / box.height)
|
||||
}
|
||||
}
|
||||
|
||||
function stamp(event: PointerEvent) {
|
||||
const context = ctx()
|
||||
const at = point(event)
|
||||
if (!context || !at) return
|
||||
context.globalCompositeOperation = tool.value === 'eraser' ? 'destination-out' : 'source-over'
|
||||
context.fillStyle = 'rgba(255, 80, 80, 0.55)'
|
||||
context.beginPath()
|
||||
context.arc(at.x, at.y, size.value / 2, 0, Math.PI * 2)
|
||||
context.fill()
|
||||
dirty.value = true
|
||||
emit('painted', true)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
paint.value?.setPointerCapture(event.pointerId)
|
||||
drawing.value = true
|
||||
stamp(event)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!drawing.value) return
|
||||
stamp(event)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
drawing.value = false
|
||||
}
|
||||
|
||||
function clear() {
|
||||
const canvas = paint.value
|
||||
const context = ctx()
|
||||
if (!canvas || !context) return
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
dirty.value = false
|
||||
emit('painted', false)
|
||||
}
|
||||
|
||||
async function exportPng(): Promise<Blob | null> {
|
||||
const overlay = paint.value
|
||||
const box = contentBox()
|
||||
if (!overlay || !dirty.value || !box) return null
|
||||
const out = document.createElement('canvas')
|
||||
out.width = box.naturalWidth
|
||||
out.height = box.naturalHeight
|
||||
const context = out.getContext('2d')
|
||||
if (!context) return null
|
||||
context.fillStyle = '#000000'
|
||||
context.fillRect(0, 0, out.width, out.height)
|
||||
const scaled = document.createElement('canvas')
|
||||
scaled.width = box.naturalWidth
|
||||
scaled.height = box.naturalHeight
|
||||
const scaledCtx = scaled.getContext('2d')
|
||||
if (!scaledCtx) return null
|
||||
scaledCtx.drawImage(overlay, 0, 0, box.naturalWidth, box.naturalHeight)
|
||||
const pixels = scaledCtx.getImageData(0, 0, out.width, out.height)
|
||||
const white = context.createImageData(out.width, out.height)
|
||||
for (let i = 0; i < pixels.data.length; i += 4) {
|
||||
const painted = pixels.data[i + 3] > 8
|
||||
const value = painted ? 255 : 0
|
||||
white.data[i] = value
|
||||
white.data[i + 1] = value
|
||||
white.data[i + 2] = value
|
||||
white.data[i + 3] = 255
|
||||
}
|
||||
context.putImageData(white, 0, 0)
|
||||
return await new Promise((resolve) => {
|
||||
out.toBlob(blob => resolve(blob), 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fitCanvas()
|
||||
window.addEventListener('resize', fitCanvas)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', fitCanvas)
|
||||
})
|
||||
watch(() => props.src, () => {
|
||||
dirty.value = false
|
||||
emit('painted', false)
|
||||
nextTick(fitCanvas)
|
||||
})
|
||||
|
||||
defineExpose({ exportPng, clear, dirty })
|
||||
</script>
|
||||
Reference in New Issue
Block a user