add multi stroke
This commit is contained in:
parent
05e4c0993d
commit
06522a5f91
@ -1,6 +1,6 @@
|
|||||||
import { DownloadIcon, EyeIcon } from '@heroicons/react/outline'
|
import { DownloadIcon, EyeIcon } from '@heroicons/react/outline'
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { useWindowSize, useLocalStorage } from 'react-use'
|
import { useWindowSize, useLocalStorage, useKey } from 'react-use'
|
||||||
import inpaint from './adapters/inpainting'
|
import inpaint from './adapters/inpainting'
|
||||||
import Button from './components/Button'
|
import Button from './components/Button'
|
||||||
import Slider from './components/Slider'
|
import Slider from './components/Slider'
|
||||||
@ -9,6 +9,7 @@ import { downloadImage, loadImage, useImage } from './utils'
|
|||||||
|
|
||||||
const TOOLBAR_SIZE = 200
|
const TOOLBAR_SIZE = 200
|
||||||
const BRUSH_COLOR = 'rgba(189, 255, 1, 0.75)'
|
const BRUSH_COLOR = 'rgba(189, 255, 1, 0.75)'
|
||||||
|
const NO_COLOR = 'rgba(255,255,255,0)'
|
||||||
|
|
||||||
interface EditorProps {
|
interface EditorProps {
|
||||||
file: File
|
file: File
|
||||||
@ -50,6 +51,8 @@ export default function Editor(props: EditorProps) {
|
|||||||
return document.createElement('canvas')
|
return document.createElement('canvas')
|
||||||
})
|
})
|
||||||
const [lines, setLines] = useState<Line[]>([{ pts: [] }])
|
const [lines, setLines] = useState<Line[]>([{ pts: [] }])
|
||||||
|
const [lines4Show, setLines4Show] = useState<Line[]>([{ pts: [] }])
|
||||||
|
const [historyLineCount, setHistoryLineCount] = useState<number[]>([])
|
||||||
const [{ x, y }, setCoords] = useState({ x: -1, y: -1 })
|
const [{ x, y }, setCoords] = useState({ x: -1, y: -1 })
|
||||||
const [showBrush, setShowBrush] = useState(false)
|
const [showBrush, setShowBrush] = useState(false)
|
||||||
const [showOriginal, setShowOriginal] = useState(false)
|
const [showOriginal, setShowOriginal] = useState(false)
|
||||||
@ -60,6 +63,9 @@ export default function Editor(props: EditorProps) {
|
|||||||
const [sizeLimit, setSizeLimit] = useLocalStorage('sizeLimit', '1080')
|
const [sizeLimit, setSizeLimit] = useLocalStorage('sizeLimit', '1080')
|
||||||
const windowSize = useWindowSize()
|
const windowSize = useWindowSize()
|
||||||
|
|
||||||
|
const [isDraging, setIsDraging] = useState(false)
|
||||||
|
const [isMultiStrokeKeyPressed, setIsMultiStrokeKeyPressed] = useState(false)
|
||||||
|
|
||||||
const draw = useCallback(() => {
|
const draw = useCallback(() => {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return
|
return
|
||||||
@ -71,9 +77,8 @@ export default function Editor(props: EditorProps) {
|
|||||||
} else {
|
} else {
|
||||||
context.drawImage(original, 0, 0)
|
context.drawImage(original, 0, 0)
|
||||||
}
|
}
|
||||||
const currentLine = lines[lines.length - 1]
|
drawLines(context, lines4Show)
|
||||||
drawLines(context, [currentLine])
|
}, [context, lines4Show, original, renders])
|
||||||
}, [context, lines, original, renders])
|
|
||||||
|
|
||||||
const refreshCanvasMask = useCallback(() => {
|
const refreshCanvasMask = useCallback(() => {
|
||||||
if (!context?.canvas.width || !context?.canvas.height) {
|
if (!context?.canvas.width || !context?.canvas.height) {
|
||||||
@ -85,9 +90,77 @@ export default function Editor(props: EditorProps) {
|
|||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('could not retrieve mask canvas')
|
throw new Error('could not retrieve mask canvas')
|
||||||
}
|
}
|
||||||
|
|
||||||
drawLines(ctx, lines, 'white')
|
drawLines(ctx, lines, 'white')
|
||||||
}, [context?.canvas.height, context?.canvas.width, lines, maskCanvas])
|
}, [context?.canvas.height, context?.canvas.width, lines, maskCanvas])
|
||||||
|
|
||||||
|
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,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleMultiStrokeKeyDown = () => {
|
||||||
|
if (isInpaintingLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsMultiStrokeKeyPressed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMultiStrokeKeyup = () => {
|
||||||
|
if (!isMultiStrokeKeyPressed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isInpaintingLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsMultiStrokeKeyPressed(false)
|
||||||
|
if (lines4Show.length !== 0 && lines4Show[0].pts.length !== 0) {
|
||||||
|
runInpainting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const predicate = (event: KeyboardEvent) => {
|
||||||
|
return event.key === 'Control' || event.key === 'Meta'
|
||||||
|
}
|
||||||
|
useKey(predicate, handleMultiStrokeKeyup, { event: 'keyup' })
|
||||||
|
useKey(predicate, handleMultiStrokeKeyDown, {
|
||||||
|
event: 'keydown',
|
||||||
|
})
|
||||||
|
|
||||||
// Draw once the original image is loaded
|
// Draw once the original image is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!context?.canvas) {
|
if (!context?.canvas) {
|
||||||
@ -107,152 +180,111 @@ export default function Editor(props: EditorProps) {
|
|||||||
}
|
}
|
||||||
}, [context?.canvas, draw, original, isOriginalLoaded, windowSize])
|
}, [context?.canvas, draw, original, isOriginalLoaded, windowSize])
|
||||||
|
|
||||||
// Handle mouse interactions
|
const onPaint = (px: number, py: number) => {
|
||||||
useEffect(() => {
|
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) => {
|
||||||
|
if (!isDraging) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mouseEvent = ev.nativeEvent as MouseEvent
|
||||||
|
const px = mouseEvent.offsetX
|
||||||
|
const py = mouseEvent.offsetY
|
||||||
|
onPaint(px, py)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
if (!original.src) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const canvas = context?.canvas
|
const canvas = context?.canvas
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (isInpaintingLoading) {
|
||||||
const onMouseDown = (ev: MouseEvent) => {
|
|
||||||
if (!original.src) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currLine = lines[lines.length - 1]
|
setIsDraging(false)
|
||||||
currLine.size = brushSize
|
if (isMultiStrokeKeyPressed) {
|
||||||
canvas.addEventListener('mousemove', onMouseDrag)
|
|
||||||
window.addEventListener('mouseup', onPointerUp)
|
|
||||||
onPaint(ev.offsetX, ev.offsetY)
|
|
||||||
}
|
|
||||||
const onMouseMove = (ev: MouseEvent) => {
|
|
||||||
setCoords({ x: ev.pageX, y: ev.pageY })
|
|
||||||
}
|
|
||||||
const onPaint = (px: number, py: number) => {
|
|
||||||
const currLine = lines[lines.length - 1]
|
|
||||||
currLine.pts.push({ x: px, y: py })
|
|
||||||
draw()
|
|
||||||
}
|
|
||||||
const onMouseDrag = (ev: MouseEvent) => {
|
|
||||||
const px = ev.offsetX
|
|
||||||
const py = ev.offsetY
|
|
||||||
onPaint(px, py)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerUp = async () => {
|
|
||||||
if (!original.src) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsInpaintingLoading(true)
|
|
||||||
canvas.removeEventListener('mousemove', onMouseDrag)
|
|
||||||
window.removeEventListener('mouseup', onPointerUp)
|
|
||||||
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)
|
lines.push({ pts: [] } as Line)
|
||||||
|
|
||||||
setRenders([...renders])
|
|
||||||
setLines([...lines])
|
setLines([...lines])
|
||||||
} catch (e: any) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
alert(e.message ? e.message : e.toString())
|
|
||||||
}
|
|
||||||
setIsInpaintingLoading(false)
|
|
||||||
draw()
|
|
||||||
}
|
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
|
||||||
|
|
||||||
const onTouchMove = (ev: TouchEvent) => {
|
lines4Show.push({ pts: [] } as Line)
|
||||||
ev.preventDefault()
|
setLines4Show([...lines4Show])
|
||||||
ev.stopPropagation()
|
return
|
||||||
const currLine = lines[lines.length - 1]
|
|
||||||
const coords = canvas.getBoundingClientRect()
|
|
||||||
currLine.pts.push({
|
|
||||||
x: (ev.touches[0].clientX - coords.x) / scale,
|
|
||||||
y: (ev.touches[0].clientY - coords.y) / scale,
|
|
||||||
})
|
|
||||||
draw()
|
|
||||||
}
|
}
|
||||||
const onPointerStart = (ev: TouchEvent) => {
|
|
||||||
|
if (lines4Show.length !== 0 && lines4Show[0].pts.length !== 0) {
|
||||||
|
runInpainting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseDown = (ev: SyntheticEvent) => {
|
||||||
if (!original.src) {
|
if (!original.src) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const canvas = context?.canvas
|
||||||
|
if (!canvas) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isInpaintingLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsDraging(true)
|
||||||
|
const currLine4Show = lines4Show[lines4Show.length - 1]
|
||||||
|
currLine4Show.size = brushSize
|
||||||
const currLine = lines[lines.length - 1]
|
const currLine = lines[lines.length - 1]
|
||||||
currLine.size = brushSize
|
currLine.size = brushSize
|
||||||
canvas.addEventListener('mousemove', onMouseDrag)
|
|
||||||
window.addEventListener('mouseup', onPointerUp)
|
|
||||||
const coords = canvas.getBoundingClientRect()
|
|
||||||
const px = (ev.touches[0].clientX - coords.x) / scale
|
|
||||||
const py = (ev.touches[0].clientY - coords.y) / scale
|
|
||||||
onPaint(px, py)
|
|
||||||
}
|
|
||||||
canvas.addEventListener('touchstart', onPointerStart)
|
|
||||||
canvas.addEventListener('touchmove', onTouchMove)
|
|
||||||
canvas.addEventListener('touchend', onPointerUp)
|
|
||||||
canvas.onmouseenter = () => setShowBrush(true)
|
|
||||||
canvas.onmouseleave = () => setShowBrush(false)
|
|
||||||
canvas.onmousedown = onMouseDown
|
|
||||||
|
|
||||||
return () => {
|
const mouseEvent = ev.nativeEvent as MouseEvent
|
||||||
canvas.removeEventListener('mousemove', onMouseDrag)
|
onPaint(mouseEvent.offsetX, mouseEvent.offsetY)
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onPointerUp)
|
|
||||||
canvas.removeEventListener('touchstart', onPointerStart)
|
|
||||||
canvas.removeEventListener('touchmove', onTouchMove)
|
|
||||||
canvas.removeEventListener('touchend', onPointerUp)
|
|
||||||
canvas.onmouseenter = null
|
|
||||||
canvas.onmouseleave = null
|
|
||||||
canvas.onmousedown = null
|
|
||||||
}
|
}
|
||||||
}, [
|
|
||||||
brushSize,
|
|
||||||
context,
|
|
||||||
file,
|
|
||||||
draw,
|
|
||||||
lines,
|
|
||||||
refreshCanvasMask,
|
|
||||||
maskCanvas,
|
|
||||||
original.src,
|
|
||||||
renders,
|
|
||||||
original.naturalHeight,
|
|
||||||
original.naturalWidth,
|
|
||||||
scale,
|
|
||||||
sizeLimit,
|
|
||||||
])
|
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = () => {
|
||||||
const l = lines
|
const l = lines
|
||||||
|
const count = historyLineCount[historyLineCount.length - 1]
|
||||||
|
for (let i = 0; i <= count; i += 1) {
|
||||||
l.pop()
|
l.pop()
|
||||||
l.pop()
|
}
|
||||||
|
|
||||||
setLines([...l, { pts: [] }])
|
setLines([...l, { pts: [] }])
|
||||||
|
historyLineCount.pop()
|
||||||
|
setHistoryLineCount(historyLineCount)
|
||||||
|
|
||||||
const r = renders
|
const r = renders
|
||||||
r.pop()
|
r.pop()
|
||||||
setRenders([...r])
|
setRenders([...r])
|
||||||
}, [lines, renders])
|
}
|
||||||
|
|
||||||
// Handle Cmd+Z
|
// Handle Cmd+Z
|
||||||
useEffect(() => {
|
const undoPredicate = (event: KeyboardEvent) => {
|
||||||
const handler = (event: KeyboardEvent) => {
|
|
||||||
if (!renders.length) {
|
if (!renders.length) {
|
||||||
return
|
return false
|
||||||
|
}
|
||||||
|
if (!historyLineCount.length) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
const isCmdZ = (event.metaKey || event.ctrlKey) && event.key === 'z'
|
const isCmdZ = (event.metaKey || event.ctrlKey) && event.key === 'z'
|
||||||
if (isCmdZ) {
|
if (isCmdZ) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
undo()
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
return () => {
|
useKey(undoPredicate, undo)
|
||||||
window.removeEventListener('keydown', handler)
|
|
||||||
}
|
|
||||||
}, [renders, undo])
|
|
||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
const name = file.name.replace(/(\.[\w\d_-]+)$/i, '_cleanup$1')
|
const name = file.name.replace(/(\.[\w\d_-]+)$/i, '_cleanup$1')
|
||||||
@ -261,32 +293,49 @@ export default function Editor(props: EditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSizeLimitChange = (_sizeLimit: string) => {
|
const onSizeLimitChange = (_sizeLimit: string) => {
|
||||||
// TODO: clean renders
|
|
||||||
// if (renders.length !== 0) {
|
|
||||||
// }
|
|
||||||
setSizeLimit(_sizeLimit)
|
setSizeLimit(_sizeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleShowBrush = (newState: boolean) => {
|
||||||
|
if (newState !== showBrush) {
|
||||||
|
setShowBrush(newState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'flex flex-col items-center',
|
'flex flex-col items-center',
|
||||||
isInpaintingLoading
|
isInpaintingLoading ? 'animate-pulse-fast transition-opacity' : '',
|
||||||
? 'animate-pulse-fast pointer-events-none transition-opacity'
|
|
||||||
: '',
|
|
||||||
scale !== 1 ? 'pb-24' : '',
|
scale !== 1 ? 'pb-24' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{
|
style={{
|
||||||
height: scale !== 1 ? original.naturalHeight * scale : undefined,
|
height: scale !== 1 ? original.naturalHeight * scale : undefined,
|
||||||
}}
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseUp={onPointerUp}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[scale !== 1 ? '' : 'relative'].join(' ')}
|
className={[scale !== 1 ? '' : 'relative'].join(' ')}
|
||||||
style={{ transform: `scale(${scale})`, transformOrigin: 'top center' }}
|
style={{
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
borderColor: `${isMultiStrokeKeyPressed ? BRUSH_COLOR : NO_COLOR}`,
|
||||||
|
borderWidth: `${8 / scale}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
className="rounded-sm"
|
className="rounded-sm"
|
||||||
style={showBrush ? { cursor: 'none' } : {}}
|
style={showBrush ? { cursor: 'none' } : {}}
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
onMouseOver={() => toggleShowBrush(true)}
|
||||||
|
onFocus={() => toggleShowBrush(true)}
|
||||||
|
onMouseLeave={() => toggleShowBrush(false)}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseMove={onMouseDrag}
|
||||||
ref={r => {
|
ref={r => {
|
||||||
if (r && !context) {
|
if (r && !context) {
|
||||||
const ctx = r.getContext('2d')
|
const ctx = r.getContext('2d')
|
||||||
@ -329,7 +378,7 @@ export default function Editor(props: EditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBrush && (
|
{showBrush && !isInpaintingLoading && (
|
||||||
<div
|
<div
|
||||||
className="hidden sm:block absolute rounded-full border border-primary bg-primary bg-opacity-80 pointer-events-none"
|
className="hidden sm:block absolute rounded-full border border-primary bg-primary bg-opacity-80 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
|
@ -6,6 +6,7 @@ interface ButtonProps {
|
|||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
primary?: boolean
|
primary?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
onKeyDown?: () => void
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
onDown?: (ev: PointerEvent) => void
|
onDown?: (ev: PointerEvent) => void
|
||||||
onUp?: (ev: PointerEvent) => void
|
onUp?: (ev: PointerEvent) => void
|
||||||
@ -18,6 +19,7 @@ export default function Button(props: ButtonProps) {
|
|||||||
disabled,
|
disabled,
|
||||||
icon,
|
icon,
|
||||||
primary,
|
primary,
|
||||||
|
onKeyDown,
|
||||||
onClick,
|
onClick,
|
||||||
onDown,
|
onDown,
|
||||||
onUp,
|
onUp,
|
||||||
@ -36,7 +38,7 @@ export default function Button(props: ButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onKeyDown={onClick}
|
onKeyDown={onKeyDown}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onPointerDown={(ev: React.PointerEvent<HTMLDivElement>) => {
|
onPointerDown={(ev: React.PointerEvent<HTMLDivElement>) => {
|
||||||
setActive(true)
|
setActive(true)
|
||||||
|
Loading…
Reference in New Issue
Block a user