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>
220 lines
6.3 KiB
Vue
220 lines
6.3 KiB
Vue
<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>
|