IOPaint/lama_cleaner/app/src/Editor.tsx

666 lines
18 KiB
TypeScript
Raw Normal View History

2022-02-06 14:40:49 +01:00
import {
ArrowsExpandIcon,
DownloadIcon,
EyeIcon,
} from '@heroicons/react/outline'
2022-02-06 03:37:22 +01:00
import React, {
SyntheticEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import {
ReactZoomPanPinchRef,
TransformComponent,
TransformWrapper,
} from 'react-zoom-pan-pinch'
2022-02-06 12:27:49 +01:00
import {
useWindowSize,
useLocalStorage,
useKey,
useKeyPressEvent,
} from 'react-use'
2021-11-15 08:22:34 +01:00
import inpaint from './adapters/inpainting'
import Button from './components/Button'
import Slider from './components/Slider'
import SizeSelector from './components/SizeSelector'
import { downloadImage, loadImage, useImage } from './utils'
2021-11-15 08:22:34 +01:00
const TOOLBAR_SIZE = 200
const BRUSH_COLOR = 'rgba(189, 255, 1, 0.75)'
2022-02-06 06:50:26 +01:00
// const NO_COLOR = 'rgba(255,255,255,0)'
2021-11-15 08:22:34 +01:00
interface EditorProps {
file: File
}
interface Line {
size?: number
pts: { x: number; y: number }[]
}
function drawLines(
ctx: CanvasRenderingContext2D,
lines: Line[],
color = BRUSH_COLOR
) {
ctx.strokeStyle = color
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
lines.forEach(line => {
if (!line?.pts.length || !line.size) {
return
}
ctx.lineWidth = line.size
ctx.beginPath()
ctx.moveTo(line.pts[0].x, line.pts[0].y)
line.pts.forEach(pt => ctx.lineTo(pt.x, pt.y))
ctx.stroke()
})
}
export default function Editor(props: EditorProps) {
const { file } = props
const [brushSize, setBrushSize] = useState(40)
const [original, isOriginalLoaded] = useImage(file)
const [renders, setRenders] = useState<HTMLImageElement[]>([])
const [context, setContext] = useState<CanvasRenderingContext2D>()
const [maskCanvas] = useState<HTMLCanvasElement>(() => {
return document.createElement('canvas')
})
const [lines, setLines] = useState<Line[]>([{ pts: [] }])
2021-12-09 05:24:03 +01:00
const [lines4Show, setLines4Show] = useState<Line[]>([{ pts: [] }])
const [historyLineCount, setHistoryLineCount] = useState<number[]>([])
2021-11-15 08:22:34 +01:00
const [{ x, y }, setCoords] = useState({ x: -1, y: -1 })
const [showBrush, setShowBrush] = useState(false)
2022-02-06 12:52:45 +01:00
const [isPanning, setIsPanning] = useState<boolean>(false)
2021-11-15 08:22:34 +01:00
const [showOriginal, setShowOriginal] = useState(false)
const [isInpaintingLoading, setIsInpaintingLoading] = useState(false)
const [showSeparator, setShowSeparator] = useState(false)
2022-02-06 03:37:22 +01:00
const [scale, setScale] = useState<number>(1)
const [minScale, setMinScale] = useState<number>()
// ['1080', '2000', 'Original']
const [sizeLimit, setSizeLimit] = useLocalStorage('sizeLimit', '1080')
2021-11-15 08:22:34 +01:00
const windowSize = useWindowSize()
2022-02-06 03:37:22 +01:00
const viewportRef = useRef<ReactZoomPanPinchRef | undefined | null>()
2021-11-15 08:22:34 +01:00
2021-12-09 05:24:03 +01:00
const [isDraging, setIsDraging] = useState(false)
const [isMultiStrokeKeyPressed, setIsMultiStrokeKeyPressed] = useState(false)
2021-11-15 08:22:34 +01:00
const draw = useCallback(() => {
if (!context) {
return
}
context.clearRect(0, 0, context.canvas.width, context.canvas.height)
const currRender = renders[renders.length - 1]
if (currRender?.src) {
context.drawImage(currRender, 0, 0)
} else {
context.drawImage(original, 0, 0)
}
2021-12-09 05:24:03 +01:00
drawLines(context, lines4Show)
}, [context, lines4Show, original, renders])
2021-11-15 08:22:34 +01:00
const refreshCanvasMask = useCallback(() => {
if (!context?.canvas.width || !context?.canvas.height) {
throw new Error('canvas has invalid size')
}
maskCanvas.width = context?.canvas.width
maskCanvas.height = context?.canvas.height
const ctx = maskCanvas.getContext('2d')
if (!ctx) {
throw new Error('could not retrieve mask canvas')
}
2021-12-09 05:24:03 +01:00
2021-11-15 08:22:34 +01:00
drawLines(ctx, lines, 'white')
}, [context?.canvas.height, context?.canvas.width, lines, maskCanvas])
2021-12-09 05:24:03 +01:00
const runInpainting = useCallback(async () => {
setIsInpaintingLoading(true)
refreshCanvasMask()
try {
const res = await inpaint(file, maskCanvas.toDataURL(), sizeLimit)
if (!res) {
throw new Error('empty response')
}
// TODO: fix the render if it failed loading
const newRender = new Image()
await loadImage(newRender, res)
renders.push(newRender)
lines.push({ pts: [] } as Line)
setRenders([...renders])
setLines([...lines])
historyLineCount.push(lines4Show.length)
setHistoryLineCount(historyLineCount)
lines4Show.length = 0
setLines4Show([{ pts: [] } as Line])
} catch (e: any) {
// eslint-disable-next-line
alert(e.message ? e.message : e.toString())
}
setIsInpaintingLoading(false)
draw()
}, [
draw,
file,
lines,
lines4Show,
maskCanvas,
refreshCanvasMask,
renders,
sizeLimit,
historyLineCount,
])
2022-02-06 06:50:26 +01:00
const hadDrawSomething = () => {
return lines4Show.length !== 0 && lines4Show[0].pts.length !== 0
}
2022-02-06 12:27:49 +01:00
const hadRunInpainting = () => {
return renders.length !== 0
}
2022-02-06 06:50:26 +01:00
const clearDrawing = () => {
setIsDraging(false)
lines4Show.length = 0
setLines4Show([{ pts: [] } as Line])
}
2021-12-09 05:24:03 +01:00
const handleMultiStrokeKeyDown = () => {
if (isInpaintingLoading) {
return
}
setIsMultiStrokeKeyPressed(true)
}
const handleMultiStrokeKeyup = () => {
if (!isMultiStrokeKeyPressed) {
return
}
if (isInpaintingLoading) {
return
}
setIsMultiStrokeKeyPressed(false)
2022-02-06 06:50:26 +01:00
if (hadDrawSomething()) {
2021-12-09 05:24:03 +01:00
runInpainting()
}
}
const predicate = (event: KeyboardEvent) => {
return event.key === 'Control' || event.key === 'Meta'
}
2022-02-06 06:50:26 +01:00
useKey(predicate, handleMultiStrokeKeyup, { event: 'keyup' }, [
isInpaintingLoading,
isMultiStrokeKeyPressed,
hadDrawSomething,
])
useKey(
predicate,
handleMultiStrokeKeyDown,
{
event: 'keydown',
},
[isInpaintingLoading]
)
2021-12-09 05:24:03 +01:00
2021-11-15 08:22:34 +01:00
// Draw once the original image is loaded
useEffect(() => {
2022-02-06 03:37:22 +01:00
if (!original) {
2021-11-15 08:22:34 +01:00
return
}
2022-02-06 03:37:22 +01:00
2021-11-15 08:22:34 +01:00
if (isOriginalLoaded) {
const rW = windowSize.width / original.naturalWidth
const rH = (windowSize.height - TOOLBAR_SIZE) / original.naturalHeight
if (rW < 1 || rH < 1) {
2022-02-06 03:37:22 +01:00
const s = Math.min(rW, rH)
setMinScale(s)
setScale(s)
2021-11-15 08:22:34 +01:00
} else {
2022-02-06 03:37:22 +01:00
setMinScale(1)
}
if (context?.canvas) {
context.canvas.width = original.naturalWidth
context.canvas.height = original.naturalHeight
2021-11-15 08:22:34 +01:00
}
draw()
}
}, [context?.canvas, draw, original, isOriginalLoaded, windowSize])
2022-02-06 03:37:22 +01:00
// Zoom reset
const resetZoom = useCallback(() => {
if (!minScale || !original || !windowSize) {
return
}
const viewport = viewportRef.current
if (!viewport) {
throw new Error('no viewport')
}
const offsetX = (windowSize.width - original.width * minScale) / 2
const offsetY = (windowSize.height - original.height * minScale) / 2
viewport.setTransform(offsetX, offsetY, minScale, 200, 'easeOutQuad')
2022-02-06 04:00:53 +01:00
setScale(minScale)
}, [minScale, original, windowSize])
2022-02-06 06:50:26 +01:00
const handleEscPressed = () => {
if (isInpaintingLoading) {
return
}
if (isDraging || isMultiStrokeKeyPressed) {
clearDrawing()
} else {
resetZoom()
}
}
useKey(
'Escape',
handleEscPressed,
{
event: 'keydown',
},
[isDraging, isMultiStrokeKeyPressed]
)
2022-02-06 03:37:22 +01:00
2021-12-09 05:24:03 +01:00
const onPaint = (px: number, py: number) => {
const currShowLine = lines4Show[lines4Show.length - 1]
currShowLine.pts.push({ x: px, y: py })
const currLine = lines[lines.length - 1]
currLine.pts.push({ x: px, y: py })
draw()
}
const onMouseMove = (ev: SyntheticEvent) => {
const mouseEvent = ev.nativeEvent as MouseEvent
setCoords({ x: mouseEvent.pageX, y: mouseEvent.pageY })
}
const onMouseDrag = (ev: SyntheticEvent) => {
2022-02-06 12:52:45 +01:00
if (isPanning) {
return
}
2021-12-09 05:24:03 +01:00
if (!isDraging) {
2021-11-15 08:22:34 +01:00
return
}
2021-12-09 05:24:03 +01:00
const mouseEvent = ev.nativeEvent as MouseEvent
const px = mouseEvent.offsetX
const py = mouseEvent.offsetY
onPaint(px, py)
}
2021-11-15 08:22:34 +01:00
2021-12-09 05:24:03 +01:00
const onPointerUp = () => {
2022-02-06 12:52:45 +01:00
if (isPanning) {
return
}
2021-12-09 05:24:03 +01:00
if (!original.src) {
return
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
const canvas = context?.canvas
if (!canvas) {
return
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
if (isInpaintingLoading) {
return
2021-11-15 08:22:34 +01:00
}
2022-02-06 06:50:26 +01:00
if (!isDraging) {
return
}
2021-12-09 05:24:03 +01:00
setIsDraging(false)
if (isMultiStrokeKeyPressed) {
lines.push({ pts: [] } as Line)
setLines([...lines])
lines4Show.push({ pts: [] } as Line)
setLines4Show([...lines4Show])
return
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
if (lines4Show.length !== 0 && lines4Show[0].pts.length !== 0) {
runInpainting()
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
}
const onMouseDown = (ev: SyntheticEvent) => {
2022-02-06 12:52:45 +01:00
if (isPanning) {
return
}
2021-12-09 05:24:03 +01:00
if (!original.src) {
return
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
const canvas = context?.canvas
if (!canvas) {
return
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
if (isInpaintingLoading) {
return
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
setIsDraging(true)
const currLine4Show = lines4Show[lines4Show.length - 1]
currLine4Show.size = brushSize
const currLine = lines[lines.length - 1]
currLine.size = brushSize
2021-11-15 08:22:34 +01:00
2021-12-09 05:24:03 +01:00
const mouseEvent = ev.nativeEvent as MouseEvent
onPaint(mouseEvent.offsetX, mouseEvent.offsetY)
}
const undo = () => {
2022-02-06 06:50:26 +01:00
if (!renders.length) {
return
}
if (!historyLineCount.length) {
return
}
2021-11-15 08:22:34 +01:00
const l = lines
2021-12-09 05:24:03 +01:00
const count = historyLineCount[historyLineCount.length - 1]
for (let i = 0; i <= count; i += 1) {
l.pop()
}
2021-11-15 08:22:34 +01:00
setLines([...l, { pts: [] }])
2021-12-09 05:24:03 +01:00
historyLineCount.pop()
setHistoryLineCount(historyLineCount)
2021-11-15 08:22:34 +01:00
const r = renders
r.pop()
setRenders([...r])
2021-12-09 05:24:03 +01:00
}
2021-11-15 08:22:34 +01:00
// Handle Cmd+Z
2021-12-09 05:24:03 +01:00
const undoPredicate = (event: KeyboardEvent) => {
const isCmdZ = (event.metaKey || event.ctrlKey) && event.key === 'z'
2022-02-06 12:27:49 +01:00
// Handle tab switch
if (event.key === 'Tab') {
event.preventDefault()
}
2021-12-09 05:24:03 +01:00
if (isCmdZ) {
event.preventDefault()
return true
}
return false
}
useKey(undoPredicate, undo)
2021-11-15 08:22:34 +01:00
2022-02-06 12:27:49 +01:00
useKeyPressEvent(
'Tab',
ev => {
ev?.preventDefault()
ev?.stopPropagation()
if (hadRunInpainting()) {
setShowSeparator(true)
setShowOriginal(true)
}
},
ev => {
ev?.preventDefault()
ev?.stopPropagation()
if (hadRunInpainting()) {
setShowOriginal(false)
setTimeout(() => setShowSeparator(false), 300)
}
}
)
2021-11-15 08:22:34 +01:00
function download() {
const name = file.name.replace(/(\.[\w\d_-]+)$/i, '_cleanup$1')
const currRender = renders[renders.length - 1]
downloadImage(currRender.currentSrc, name)
}
const onSizeLimitChange = (_sizeLimit: string) => {
setSizeLimit(_sizeLimit)
2021-11-15 08:22:34 +01:00
}
2021-12-09 05:24:03 +01:00
const toggleShowBrush = (newState: boolean) => {
2022-02-06 12:52:45 +01:00
if (newState !== showBrush && !isPanning) {
2021-12-09 05:24:03 +01:00
setShowBrush(newState)
}
}
2022-02-06 12:52:45 +01:00
const getCursor = useCallback(() => {
if (isPanning) {
return 'grab'
}
if (showBrush) {
return 'none'
}
return undefined
}, [showBrush, isPanning])
// Toggle clean/zoom tool on spacebar.
useKey(
' ',
ev => {
ev?.preventDefault()
2022-02-06 14:32:54 +01:00
ev?.stopPropagation()
2022-02-06 12:52:45 +01:00
setShowBrush(!showBrush)
setIsPanning(!isPanning)
},
{
event: 'keydown',
},
[isPanning, showBrush]
)
2022-02-06 03:37:22 +01:00
if (!original || !scale || !minScale) {
return <></>
}
2021-11-15 08:22:34 +01:00
return (
<div
2022-02-06 03:37:22 +01:00
className="flex flex-col items-center"
2021-11-15 08:22:34 +01:00
style={{
2022-02-06 03:37:22 +01:00
height: '100%',
width: '100%',
2021-11-15 08:22:34 +01:00
}}
2021-12-09 05:24:03 +01:00
aria-hidden="true"
onMouseMove={onMouseMove}
onMouseUp={onPointerUp}
2021-11-15 08:22:34 +01:00
>
2022-02-06 03:37:22 +01:00
<TransformWrapper
ref={r => {
if (r) {
viewportRef.current = r
}
}}
2022-02-06 12:52:45 +01:00
panning={{ disabled: !isPanning, velocityDisabled: true }}
2022-02-06 03:37:22 +01:00
wheel={{ step: 0.05 }}
centerZoomedOut
alignmentAnimation={{ disabled: true }}
centerOnInit
limitToBounds={false}
2022-02-06 06:50:26 +01:00
doubleClick={{ disabled: true }}
2022-02-06 03:37:22 +01:00
initialScale={minScale}
minScale={minScale}
onZoom={ref => {
setScale(ref.state.scale)
2021-12-09 05:24:03 +01:00
}}
2021-11-15 08:22:34 +01:00
>
2022-02-06 03:37:22 +01:00
<TransformComponent
wrapperStyle={{
width: '100%',
height: '100%',
2021-11-15 08:22:34 +01:00
}}
2022-02-06 03:37:22 +01:00
contentClass={
isInpaintingLoading
? 'animate-pulse-fast pointer-events-none transition-opacity'
: ''
}
2021-11-15 08:22:34 +01:00
>
2022-02-06 03:37:22 +01:00
<>
<canvas
className="rounded-sm"
2022-02-06 12:52:45 +01:00
style={{ cursor: getCursor() }}
2022-02-06 03:37:22 +01:00
onContextMenu={e => {
e.preventDefault()
}}
onMouseOver={() => toggleShowBrush(true)}
onFocus={() => toggleShowBrush(true)}
onMouseLeave={() => toggleShowBrush(false)}
onMouseDown={onMouseDown}
onMouseMove={onMouseDrag}
ref={r => {
if (r && !context) {
const ctx = r.getContext('2d')
if (ctx) {
setContext(ctx)
}
}
}}
/>
<div
className={[
'absolute top-0 right-0 pointer-events-none',
'overflow-hidden',
'border-primary',
showSeparator ? 'border-l-4' : '',
].join(' ')}
style={{
width: showOriginal
? `${Math.round(original.naturalWidth)}px`
: '0px',
height: original.naturalHeight,
transitionProperty: 'width, height',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '300ms',
}}
>
<img
className="absolute right-0"
src={original.src}
alt="original"
width={`${original.naturalWidth}px`}
height={`${original.naturalHeight}px`}
style={{
width: `${original.naturalWidth}px`,
height: `${original.naturalHeight}px`,
maxWidth: 'none',
}}
/>
</div>
</>
</TransformComponent>
</TransformWrapper>
2021-11-15 08:22:34 +01:00
2022-02-06 12:52:45 +01:00
{showBrush && !isInpaintingLoading && !isPanning && (
2021-11-15 08:22:34 +01:00
<div
className="hidden sm:block absolute rounded-full border border-primary bg-primary bg-opacity-80 pointer-events-none"
style={{
width: `${brushSize * scale}px`,
height: `${brushSize * scale}px`,
left: `${x}px`,
top: `${y}px`,
transform: 'translate(-50%, -50%)',
}}
/>
)}
<div
2022-02-06 13:56:40 +01:00
className="fixed w-full bottom-0 flex items-center justify-center"
style={{ height: '90px' }}
2021-11-15 08:22:34 +01:00
>
2022-02-06 13:56:40 +01:00
<div
className={[
'flex items-center justify-center space-x-6',
'',
// 'bg-black backdrop-blur backdrop-filter bg-opacity-10',
].join(' ')}
>
<SizeSelector
value={sizeLimit || '1080'}
onChange={onSizeLimitChange}
originalWidth={original.naturalWidth}
originalHeight={original.naturalHeight}
/>
<Slider
label={
<span>
<span className="hidden md:inline">Brush</span>
</span>
}
2022-02-06 13:56:40 +01:00
min={10}
max={150}
value={brushSize}
onChange={setBrushSize}
/>
2022-02-06 13:56:40 +01:00
<div>
2022-02-06 14:40:49 +01:00
<Button
className="mr-2"
icon={<ArrowsExpandIcon className="w-6 h-6" />}
disabled={scale === minScale}
onClick={resetZoom}
/>
2022-02-06 13:56:40 +01:00
<Button
className="mr-2"
icon={
<svg
width="19"
height="9"
viewBox="0 0 19 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
>
<path
d="M2 1C2 0.447715 1.55228 0 1 0C0.447715 0 0 0.447715 0 1H2ZM1 8H0V9H1V8ZM8 9C8.55228 9 9 8.55229 9 8C9 7.44771 8.55228 7 8 7V9ZM16.5963 7.42809C16.8327 7.92721 17.429 8.14016 17.9281 7.90374C18.4272 7.66731 18.6402 7.07103 18.4037 6.57191L16.5963 7.42809ZM16.9468 5.83205L17.8505 5.40396L16.9468 5.83205ZM0 1V8H2V1H0ZM1 9H8V7H1V9ZM1.66896 8.74329L6.66896 4.24329L5.33104 2.75671L0.331035 7.25671L1.66896 8.74329ZM16.043 6.26014L16.5963 7.42809L18.4037 6.57191L17.8505 5.40396L16.043 6.26014ZM6.65079 4.25926C9.67554 1.66661 14.3376 2.65979 16.043 6.26014L17.8505 5.40396C15.5805 0.61182 9.37523 -0.710131 5.34921 2.74074L6.65079 4.25926Z"
fill="currentColor"
/>
</svg>
}
onClick={undo}
disabled={renders.length === 0}
/>
<Button
className="mr-2"
icon={<EyeIcon className="w-6 h-6" />}
onDown={ev => {
ev.preventDefault()
setShowSeparator(true)
setShowOriginal(true)
}}
onUp={() => {
setShowOriginal(false)
setTimeout(() => setShowSeparator(false), 300)
}}
disabled={renders.length === 0}
>
{undefined}
</Button>
<Button
icon={<DownloadIcon className="w-6 h-6" />}
disabled={!renders.length}
onClick={download}
>
{undefined}
</Button>
</div>
<div
className="absolute bg-black backdrop-blur backdrop-filter bg-opacity-10 rounded-xl"
style={{
height: '58px',
2022-02-06 14:40:49 +01:00
width: '700px',
2022-02-06 13:56:40 +01:00
zIndex: -1,
marginLeft: '-1px',
}}
>
{undefined}
2022-02-06 13:56:40 +01:00
</div>
</div>
2021-11-15 08:22:34 +01:00
</div>
</div>
)
}