Complete GUI Refactor
This patch brings in a massive number of changes to the frontend of the application. Please feel free to discuss the proposed changes with me at any time. Implemented Recoil as a state management system. Why Recoil? It is a robust library built by developers at Facebook for state management. It has an extremely simple API for implementation that is in sync with React syntax compared to any other state management system out there and works amazingly well. While the official release status is beta as it becomes fully featured, the library is already used in various systems at Facebook and is very stable for the use cases of this application. Why global state management? One of the major issues I saw with the current file structure is that there is minimal code splitting and it makes further development of the frontend a cumbersome task. I have broken down the frontend into various easy to access components isolating the GUI from the logic. To avoid prop drilling, we need global state management to handle the necessary tasks. This will also facilitate the addition of any new features greatly. Code Splitting. Majority of the components that can be isolated in the application have now been done so. All New Custom CSS & Removal of Tailwind While Tailwind is a great way to deploy beautiful interfaces quickly, anyone trying to stylize the application further needs to be familiar with Tailwind which makes it harder for more people to work on it. Not to mention, I am not a particular fan of flooding JSX elements with inline CSS classes. That makes reading the code extremely hard and bloats up component code drastically. As a replacement to Tailwind, I implemented a custom styling system using SCSS as a developer dependency. In the new system, all the general and shared styles are in the styles folder and all the component styles are in the same folder as the component for easy access.The _index.scss file now acts as a central import for every other stylesheet that needs to be loaded. What Changed? The entire application looks and feels like the current implementation with minimal changes. The green (#bdff01) highlight used in the application has now been changed to a bright yellow (rgb(255, 190, 0)) because I felt it better suited the new Dark Mode (see below). The swipe bar for comparing before and after images has now been removed and instead the comparison is a smooth fade effect. I felt this was better to analyze image changes rather than a swiper. // Can add the swipe back if needed. Dark Mode A brand new Dark Mode has been added for the application. Users can enable and disable by tapping the button in the header or by using the Shift + D hotkey. Other Misc New Features When the editor image is now zoomed out to its default size, the image now also gets centered back. TODO The currently used react-zoom-pinch-pan module is not mobile friendly. It does not allow brush strokes. Need to figure out a way to fix this. Further optimization of the frontend code with better code splitting and performance. When using the LaMa model, the first stroke has a delayed response from the backend but the ones that follow are almost immediate. I believe this is happening because of the initialization of the model on the first stroke. I wonder if either of us can look at it and see if this can somehow be preloaded so the user experience is smooth from the first stroke. Enable threading for the desktop application mode so flaskwebgui does not block the main applications Python console.
This commit is contained in:
133
lama_cleaner/app/src/components/Editor/Editor.scss
Normal file
133
lama_cleaner/app/src/components/Editor/Editor.scss
Normal file
@@ -0,0 +1,133 @@
|
||||
@use '../../styles/Mixins' as *;
|
||||
|
||||
.editor-container {
|
||||
grid-area: main-content;
|
||||
display: grid;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.react-transform-wrapper {
|
||||
display: grid !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.editor-canvas-container {
|
||||
display: grid;
|
||||
grid-template-areas: 'editor-content';
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-canvas {
|
||||
grid-area: editor-content;
|
||||
}
|
||||
|
||||
.original-image-container {
|
||||
grid-area: editor-content;
|
||||
pointer-events: none;
|
||||
animation: opacityReveal 350ms ease-in-out;
|
||||
}
|
||||
|
||||
.editor-canvas-loading {
|
||||
pointer-events: none;
|
||||
animation: pulsing 750ms infinite;
|
||||
}
|
||||
|
||||
.editor-toolkit-panel {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
padding: 1rem 4rem;
|
||||
display: grid;
|
||||
// grid-template-columns: repeat(4, max-content);
|
||||
grid-template-areas: 'toolkit-image-type toolkit-size-selector toolkit-brush-slider toolkit-btns';
|
||||
column-gap: 2rem;
|
||||
align-items: center;
|
||||
background-color: var(--editor-toolkit-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
|
||||
@include mobile {
|
||||
padding: 1rem 2rem;
|
||||
grid-template-areas:
|
||||
'toolkit-image-type toolkit-size-selector'
|
||||
'toolkit-brush-slider toolkit-brush-slider'
|
||||
'toolkit-btns toolkit-btns';
|
||||
row-gap: 2rem;
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-brush-slider {
|
||||
grid-area: toolkit-brush-slider;
|
||||
user-select: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
height: max-content;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
input[type='range'] {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolkit-btns {
|
||||
grid-area: toolkit-btns;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
.brush-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgb(255, 255, 255, 0.25);
|
||||
border: 1px dashed var(--border-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-size-selector {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.editor-size-selector-options {
|
||||
position: fixed;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.editor-size-selector {
|
||||
grid-area: toolkit-size-selector;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: var(--yellow-accent);
|
||||
outline: none;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
font-family: 'WorkSans-Bold';
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
color: rgb(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.image-type-tag {
|
||||
grid-area: toolkit-image-type;
|
||||
z-index: 2;
|
||||
background-color: var(--yellow-accent);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
font-family: 'WorkSans-Bold';
|
||||
color: rgb(0, 0, 0);
|
||||
}
|
||||
651
lama_cleaner/app/src/components/Editor/Editor.tsx
Normal file
651
lama_cleaner/app/src/components/Editor/Editor.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
import {
|
||||
ArrowsExpandIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import React, {
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
ReactZoomPanPinchRef,
|
||||
TransformComponent,
|
||||
TransformWrapper,
|
||||
} from 'react-zoom-pan-pinch'
|
||||
import {
|
||||
useWindowSize,
|
||||
useLocalStorage,
|
||||
useKey,
|
||||
useKeyPressEvent,
|
||||
} from 'react-use'
|
||||
import inpaint from '../../adapters/inpainting'
|
||||
import Button from '../shared/Button'
|
||||
import Slider from './Slider'
|
||||
import SizeSelector from './SizeSelector'
|
||||
import { downloadImage, loadImage, useImage } from '../../utils'
|
||||
|
||||
const TOOLBAR_SIZE = 200
|
||||
const BRUSH_COLOR = 'rgba(189, 255, 1, 0.75)'
|
||||
// const NO_COLOR = 'rgba(255,255,255,0)'
|
||||
|
||||
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: [] }])
|
||||
const [lines4Show, setLines4Show] = useState<Line[]>([{ pts: [] }])
|
||||
const [historyLineCount, setHistoryLineCount] = useState<number[]>([])
|
||||
const [{ x, y }, setCoords] = useState({ x: -1, y: -1 })
|
||||
const [showBrush, setShowBrush] = useState(false)
|
||||
const [isPanning, setIsPanning] = useState<boolean>(false)
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
const [isInpaintingLoading, setIsInpaintingLoading] = useState(false)
|
||||
const [scale, setScale] = useState<number>(1)
|
||||
const [minScale, setMinScale] = useState<number>()
|
||||
// ['1080', '2000', 'Original']
|
||||
const [sizeLimit, setSizeLimit] = useLocalStorage('sizeLimit', '1080')
|
||||
const windowSize = useWindowSize()
|
||||
const viewportRef = useRef<ReactZoomPanPinchRef | undefined | null>()
|
||||
|
||||
const [isDraging, setIsDraging] = useState(false)
|
||||
const [isMultiStrokeKeyPressed, setIsMultiStrokeKeyPressed] = useState(false)
|
||||
|
||||
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,
|
||||
original.naturalWidth,
|
||||
original.naturalHeight
|
||||
)
|
||||
} else {
|
||||
context.drawImage(original, 0, 0)
|
||||
}
|
||||
drawLines(context, lines4Show)
|
||||
}, [context, lines4Show, original, renders])
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
drawLines(ctx, lines, 'white')
|
||||
}, [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 hadDrawSomething = () => {
|
||||
return lines4Show.length !== 0 && lines4Show[0].pts.length !== 0
|
||||
}
|
||||
|
||||
const hadRunInpainting = () => {
|
||||
return renders.length !== 0
|
||||
}
|
||||
|
||||
const clearDrawing = () => {
|
||||
setIsDraging(false)
|
||||
lines4Show.length = 0
|
||||
setLines4Show([{ pts: [] } as Line])
|
||||
}
|
||||
|
||||
const handleMultiStrokeKeyDown = () => {
|
||||
if (isInpaintingLoading) {
|
||||
return
|
||||
}
|
||||
setIsMultiStrokeKeyPressed(true)
|
||||
}
|
||||
|
||||
const handleMultiStrokeKeyup = () => {
|
||||
if (!isMultiStrokeKeyPressed) {
|
||||
return
|
||||
}
|
||||
if (isInpaintingLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsMultiStrokeKeyPressed(false)
|
||||
if (hadDrawSomething()) {
|
||||
runInpainting()
|
||||
}
|
||||
}
|
||||
|
||||
const predicate = (event: KeyboardEvent) => {
|
||||
return event.key === 'Control' || event.key === 'Meta'
|
||||
}
|
||||
|
||||
useKey(predicate, handleMultiStrokeKeyup, { event: 'keyup' }, [
|
||||
isInpaintingLoading,
|
||||
isMultiStrokeKeyPressed,
|
||||
hadDrawSomething,
|
||||
])
|
||||
|
||||
useKey(
|
||||
predicate,
|
||||
handleMultiStrokeKeyDown,
|
||||
{
|
||||
event: 'keydown',
|
||||
},
|
||||
[isInpaintingLoading]
|
||||
)
|
||||
|
||||
// Draw once the original image is loaded
|
||||
useEffect(() => {
|
||||
if (!original) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOriginalLoaded) {
|
||||
const rW = windowSize.width / original.naturalWidth
|
||||
const rH = (windowSize.height - TOOLBAR_SIZE) / original.naturalHeight
|
||||
if (rW < 1 || rH < 1) {
|
||||
const s = Math.min(rW, rH)
|
||||
setMinScale(s)
|
||||
setScale(s)
|
||||
} else {
|
||||
setMinScale(1)
|
||||
}
|
||||
|
||||
if (context?.canvas) {
|
||||
context.canvas.width = original.naturalWidth
|
||||
context.canvas.height = original.naturalHeight
|
||||
}
|
||||
draw()
|
||||
}
|
||||
}, [context?.canvas, draw, original, isOriginalLoaded, windowSize])
|
||||
|
||||
// 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')
|
||||
viewport.state.scale = minScale
|
||||
setScale(minScale)
|
||||
}, [viewportRef, minScale, original, windowSize])
|
||||
|
||||
const handleEscPressed = () => {
|
||||
if (isInpaintingLoading) {
|
||||
return
|
||||
}
|
||||
if (isDraging || isMultiStrokeKeyPressed) {
|
||||
clearDrawing()
|
||||
} else {
|
||||
resetZoom()
|
||||
}
|
||||
}
|
||||
|
||||
useKey(
|
||||
'Escape',
|
||||
handleEscPressed,
|
||||
{
|
||||
event: 'keydown',
|
||||
},
|
||||
[
|
||||
isDraging,
|
||||
isInpaintingLoading,
|
||||
isMultiStrokeKeyPressed,
|
||||
resetZoom,
|
||||
clearDrawing,
|
||||
]
|
||||
)
|
||||
|
||||
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) => {
|
||||
if (isPanning) {
|
||||
return
|
||||
}
|
||||
if (!isDraging) {
|
||||
return
|
||||
}
|
||||
const mouseEvent = ev.nativeEvent as MouseEvent
|
||||
const px = mouseEvent.offsetX
|
||||
const py = mouseEvent.offsetY
|
||||
onPaint(px, py)
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (isPanning) {
|
||||
return
|
||||
}
|
||||
if (!original.src) {
|
||||
return
|
||||
}
|
||||
const canvas = context?.canvas
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
if (isInpaintingLoading) {
|
||||
return
|
||||
}
|
||||
if (!isDraging) {
|
||||
return
|
||||
}
|
||||
setIsDraging(false)
|
||||
if (isMultiStrokeKeyPressed) {
|
||||
lines.push({ pts: [] } as Line)
|
||||
setLines([...lines])
|
||||
|
||||
lines4Show.push({ pts: [] } as Line)
|
||||
setLines4Show([...lines4Show])
|
||||
return
|
||||
}
|
||||
|
||||
if (lines4Show.length !== 0 && lines4Show[0].pts.length !== 0) {
|
||||
runInpainting()
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseDown = (ev: SyntheticEvent) => {
|
||||
if (isPanning) {
|
||||
return
|
||||
}
|
||||
if (!original.src) {
|
||||
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]
|
||||
currLine.size = brushSize
|
||||
|
||||
const mouseEvent = ev.nativeEvent as MouseEvent
|
||||
onPaint(mouseEvent.offsetX, mouseEvent.offsetY)
|
||||
}
|
||||
|
||||
const undo = () => {
|
||||
if (!renders.length) {
|
||||
return
|
||||
}
|
||||
if (!historyLineCount.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const l = lines
|
||||
const count = historyLineCount[historyLineCount.length - 1]
|
||||
for (let i = 0; i <= count; i += 1) {
|
||||
l.pop()
|
||||
}
|
||||
|
||||
setLines([...l, { pts: [] }])
|
||||
historyLineCount.pop()
|
||||
setHistoryLineCount(historyLineCount)
|
||||
|
||||
const r = renders
|
||||
r.pop()
|
||||
setRenders([...r])
|
||||
}
|
||||
|
||||
// Handle Cmd+Z
|
||||
const undoPredicate = (event: KeyboardEvent) => {
|
||||
const isCmdZ = (event.metaKey || event.ctrlKey) && event.key === 'z'
|
||||
// Handle tab switch
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
}
|
||||
if (isCmdZ) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
useKey(undoPredicate, undo)
|
||||
|
||||
useKeyPressEvent(
|
||||
'Tab',
|
||||
ev => {
|
||||
ev?.preventDefault()
|
||||
ev?.stopPropagation()
|
||||
if (hadRunInpainting()) {
|
||||
setShowOriginal(true)
|
||||
}
|
||||
},
|
||||
ev => {
|
||||
ev?.preventDefault()
|
||||
ev?.stopPropagation()
|
||||
if (hadRunInpainting()) {
|
||||
setShowOriginal(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const toggleShowBrush = (newState: boolean) => {
|
||||
if (newState !== showBrush && !isPanning) {
|
||||
setShowBrush(newState)
|
||||
}
|
||||
}
|
||||
|
||||
const getCursor = useCallback(() => {
|
||||
if (isPanning) {
|
||||
return 'grab'
|
||||
}
|
||||
if (showBrush) {
|
||||
return 'none'
|
||||
}
|
||||
return undefined
|
||||
}, [showBrush, isPanning])
|
||||
|
||||
// Standard Hotkeys for Brush Size
|
||||
useKeyPressEvent('[', () => {
|
||||
setBrushSize(currentBrushSize => {
|
||||
if (currentBrushSize > 10) {
|
||||
return currentBrushSize - 10
|
||||
}
|
||||
if (currentBrushSize <= 10 && currentBrushSize > 0) {
|
||||
return currentBrushSize - 5
|
||||
}
|
||||
return currentBrushSize
|
||||
})
|
||||
})
|
||||
|
||||
useKeyPressEvent(']', () => {
|
||||
setBrushSize(currentBrushSize => {
|
||||
return currentBrushSize + 10
|
||||
})
|
||||
})
|
||||
|
||||
// Toggle clean/zoom tool on spacebar.
|
||||
useKeyPressEvent(
|
||||
' ',
|
||||
ev => {
|
||||
ev?.preventDefault()
|
||||
ev?.stopPropagation()
|
||||
setShowBrush(false)
|
||||
setIsPanning(true)
|
||||
},
|
||||
ev => {
|
||||
ev?.preventDefault()
|
||||
ev?.stopPropagation()
|
||||
setShowBrush(true)
|
||||
setIsPanning(false)
|
||||
}
|
||||
)
|
||||
|
||||
const getCurScale = (): number => {
|
||||
let s = minScale
|
||||
if (viewportRef.current?.state.scale !== undefined) {
|
||||
s = viewportRef.current?.state.scale
|
||||
}
|
||||
return s!
|
||||
}
|
||||
|
||||
const getBrushStyle = () => {
|
||||
const curScale = getCurScale()
|
||||
return {
|
||||
width: `${brushSize * curScale}px`,
|
||||
height: `${brushSize * curScale}px`,
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
}
|
||||
|
||||
if (!original || !scale || !minScale) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="editor-container"
|
||||
aria-hidden="true"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onPointerUp}
|
||||
>
|
||||
<TransformWrapper
|
||||
ref={r => {
|
||||
if (r) {
|
||||
viewportRef.current = r
|
||||
}
|
||||
}}
|
||||
panning={{ disabled: !isPanning, velocityDisabled: true }}
|
||||
wheel={{ step: 0.05 }}
|
||||
centerZoomedOut
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
centerOnInit
|
||||
limitToBounds={false}
|
||||
doubleClick={{ disabled: true }}
|
||||
initialScale={minScale}
|
||||
minScale={minScale}
|
||||
onZoom={ref => {
|
||||
setScale(ref.state.scale)
|
||||
}}
|
||||
>
|
||||
<TransformComponent
|
||||
contentClass={isInpaintingLoading ? 'editor-canvas-loading' : ''}
|
||||
>
|
||||
<div className="editor-canvas-container">
|
||||
<canvas
|
||||
className="editor-canvas"
|
||||
style={{ cursor: getCursor() }}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showOriginal ? (
|
||||
<div
|
||||
className="original-image-container"
|
||||
style={{
|
||||
width: `${original.naturalWidth}px`,
|
||||
height: `${original.naturalHeight}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className="original-image"
|
||||
src={original.src}
|
||||
alt="original"
|
||||
style={{
|
||||
width: `${original.naturalWidth}px`,
|
||||
height: `${original.naturalHeight}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
|
||||
{showBrush && !isInpaintingLoading && !isPanning && (
|
||||
<div className="brush-shape" style={getBrushStyle()} />
|
||||
)}
|
||||
|
||||
<div className="editor-toolkit-panel">
|
||||
<p className="image-type-tag">
|
||||
{showOriginal ? 'Original' : 'Inpainted'}
|
||||
</p>
|
||||
<SizeSelector
|
||||
value={sizeLimit || '1080'}
|
||||
onChange={onSizeLimitChange}
|
||||
originalWidth={original.naturalWidth}
|
||||
originalHeight={original.naturalHeight}
|
||||
/>
|
||||
<Slider
|
||||
label="Brush"
|
||||
min={10}
|
||||
max={150}
|
||||
value={brushSize}
|
||||
onChange={setBrushSize}
|
||||
/>
|
||||
<div className="editor-toolkit-btns">
|
||||
<Button
|
||||
icon={<ArrowsExpandIcon />}
|
||||
disabled={scale === minScale}
|
||||
onClick={resetZoom}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<svg
|
||||
width="19"
|
||||
height="9"
|
||||
viewBox="0 0 19 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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
|
||||
icon={<EyeIcon />}
|
||||
onDown={ev => {
|
||||
ev.preventDefault()
|
||||
setShowOriginal(true)
|
||||
}}
|
||||
onUp={() => {
|
||||
setShowOriginal(false)
|
||||
}}
|
||||
disabled={renders.length === 0}
|
||||
>
|
||||
{undefined}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DownloadIcon />}
|
||||
disabled={!renders.length}
|
||||
onClick={download}
|
||||
>
|
||||
{undefined}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
lama_cleaner/app/src/components/Editor/SizeSelector.tsx
Normal file
77
lama_cleaner/app/src/components/Editor/SizeSelector.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { FocusEvent, useCallback, useRef } from 'react'
|
||||
|
||||
const sizes = ['720', '1080', '2000', 'Original']
|
||||
|
||||
type SizeSelectorProps = {
|
||||
value: string
|
||||
originalWidth: number
|
||||
originalHeight: number
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export default function SizeSelector(props: SizeSelectorProps) {
|
||||
const { value, originalHeight, originalWidth, onChange } = props
|
||||
const selectRef = useRef()
|
||||
|
||||
const getSizeShowName = (size: string) => {
|
||||
if (size === 'Original') {
|
||||
return `${originalWidth}x${originalHeight}`
|
||||
}
|
||||
const length: number = parseInt(size, 10)
|
||||
const longSide: number =
|
||||
originalWidth > originalHeight ? originalWidth : originalHeight
|
||||
const scale = length / longSide
|
||||
|
||||
if (originalWidth > originalHeight) {
|
||||
const newHeight = Math.ceil(scale * originalHeight)
|
||||
return `${size}x${newHeight}`
|
||||
}
|
||||
const newWidth = Math.ceil(scale * originalWidth)
|
||||
return `${newWidth}x${size}`
|
||||
}
|
||||
|
||||
const onButtonFocus = (e: FocusEvent<any>) => {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
|
||||
const getValidSizes = useCallback((): string[] => {
|
||||
const longSide: number =
|
||||
originalWidth > originalHeight ? originalWidth : originalHeight
|
||||
|
||||
const validSizes = []
|
||||
for (let i = 0; i < sizes.length; i += 1) {
|
||||
const s = sizes[i]
|
||||
if (s === 'Original') {
|
||||
validSizes.push(s)
|
||||
} else if (parseInt(s, 10) <= longSide) {
|
||||
validSizes.push(s)
|
||||
}
|
||||
}
|
||||
return validSizes
|
||||
}, [originalHeight, originalWidth])
|
||||
|
||||
const getValidSize = useCallback(() => {
|
||||
if (getValidSizes().indexOf(value) === -1) {
|
||||
return getValidSizes()[0]
|
||||
}
|
||||
return value
|
||||
}, [value, getValidSizes])
|
||||
|
||||
const sizeChangeHandler = (e: any) => {
|
||||
onChange(e.target.value)
|
||||
e.target.blur()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-size-selector">
|
||||
<p>Size:</p>
|
||||
<select value={getValidSize()} onChange={sizeChangeHandler}>
|
||||
{getValidSizes().map(size => (
|
||||
<option key={size} value={size}>
|
||||
{getSizeShowName(size)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,14 +14,9 @@ export default function Slider(props: SliderProps) {
|
||||
const step = ((max || 100) - (min || 0)) / 100
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center space-x-4 text-black">
|
||||
<div className="editor-brush-slider">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className={[
|
||||
'appearance-none rounded-lg h-4',
|
||||
'bg-primary',
|
||||
'w-24 md:w-auto',
|
||||
].join(' ')}
|
||||
type="range"
|
||||
step={step}
|
||||
min={min}
|
||||
35
lama_cleaner/app/src/components/FileSelect/FileSelect.scss
Normal file
35
lama_cleaner/app/src/components/FileSelect/FileSelect.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@use '../../styles/Mixins/' as *;
|
||||
|
||||
.file-select-label {
|
||||
display: grid;
|
||||
cursor: pointer;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
min-width: 600px;
|
||||
|
||||
@include mobile {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
.file-select-label-hover {
|
||||
color: black;
|
||||
background-color: var(--yellow-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.file-select-container {
|
||||
display: grid;
|
||||
padding: 4rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.file-select-message {
|
||||
font-family: 'WorkSans-Bold';
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import useResolution from '../../hooks/useResolution'
|
||||
|
||||
type FileSelectProps = {
|
||||
onSelection: (file: File) => void
|
||||
@@ -10,6 +11,8 @@ export default function FileSelect(props: FileSelectProps) {
|
||||
const [dragHover, setDragHover] = useState(false)
|
||||
const [uploadElemId] = useState(`file-upload-${Math.random().toString()}`)
|
||||
|
||||
const resolution = useResolution()
|
||||
|
||||
function onFileSelected(file: File) {
|
||||
if (!file) {
|
||||
return
|
||||
@@ -94,17 +97,11 @@ export default function FileSelect(props: FileSelectProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={uploadElemId}
|
||||
className="block w-full h-full group relative cursor-pointer rounded-md font-medium focus-within:outline-none"
|
||||
>
|
||||
<label htmlFor={uploadElemId} className="file-select-label">
|
||||
<div
|
||||
className={[
|
||||
'w-full h-full flex items-center justify-center px-6 pt-5 pb-6 text-md',
|
||||
'border-2 border-dashed rounded-md',
|
||||
'hover:border-black hover:bg-primary',
|
||||
'text-center',
|
||||
dragHover ? 'border-black bg-primary' : 'bg-gray-100 border-gray-300',
|
||||
'file-select-container',
|
||||
dragHover ? 'file-select-label-hover' : '',
|
||||
].join(' ')}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={ev => {
|
||||
@@ -118,7 +115,6 @@ export default function FileSelect(props: FileSelectProps) {
|
||||
id={uploadElemId}
|
||||
name={uploadElemId}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={ev => {
|
||||
const file = ev.currentTarget.files?.[0]
|
||||
if (file) {
|
||||
@@ -127,8 +123,11 @@ export default function FileSelect(props: FileSelectProps) {
|
||||
}}
|
||||
accept="image/png, image/jpeg"
|
||||
/>
|
||||
<p className="hidden sm:block">Click here or drag an image file</p>
|
||||
<p className="sm:hidden">Tap here to load your picture</p>
|
||||
<p className="file-select-message">
|
||||
{resolution === 'desktop'
|
||||
? 'Click here or drag an image file'
|
||||
: 'Tap here to load your picture'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
16
lama_cleaner/app/src/components/Header/Header.scss
Normal file
16
lama_cleaner/app/src/components/Header/Header.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
header {
|
||||
grid-area: main-content;
|
||||
padding: 1rem 2rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, auto);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
justify-self: end;
|
||||
margin-right: 4rem;
|
||||
z-index: 1;
|
||||
}
|
||||
31
lama_cleaner/app/src/components/Header/Header.tsx
Normal file
31
lama_cleaner/app/src/components/Header/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ArrowLeftIcon } from '@heroicons/react/outline'
|
||||
import React from 'react'
|
||||
import { useSetRecoilState } from 'recoil'
|
||||
import { fileState } from '../../store/Atoms'
|
||||
import Button from '../shared/Button'
|
||||
import Shortcuts from '../Shortcuts/Shortcuts'
|
||||
import useResolution from '../../hooks/useResolution'
|
||||
|
||||
const Header = () => {
|
||||
const setFile = useSetRecoilState(fileState)
|
||||
const resolution = useResolution()
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<header>
|
||||
<Button
|
||||
icon={<ArrowLeftIcon className="w-6 h-6" />}
|
||||
onClick={() => {
|
||||
setFile(undefined)
|
||||
}}
|
||||
>
|
||||
{resolution === 'desktop' ? 'Start New' : undefined}
|
||||
</Button>
|
||||
<Shortcuts />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
return renderHeader()
|
||||
}
|
||||
|
||||
export default Header
|
||||
30
lama_cleaner/app/src/components/LandingPage/LandingPage.scss
Normal file
30
lama_cleaner/app/src/components/LandingPage/LandingPage.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@use '../../styles/Mixins/' as *;
|
||||
|
||||
.landing-page {
|
||||
display: grid;
|
||||
place-self: center;
|
||||
justify-items: center;
|
||||
row-gap: 2rem;
|
||||
grid-auto-rows: max-content;
|
||||
|
||||
@include mobile {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 1.4rem;
|
||||
|
||||
@include mobile {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
}
|
||||
}
|
||||
|
||||
.landing-file-selector {
|
||||
display: grid;
|
||||
}
|
||||
26
lama_cleaner/app/src/components/LandingPage/LandingPage.tsx
Normal file
26
lama_cleaner/app/src/components/LandingPage/LandingPage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { useSetRecoilState } from 'recoil'
|
||||
import { fileState } from '../../store/Atoms'
|
||||
import FileSelect from '../FileSelect/FileSelect'
|
||||
|
||||
const LandingPage = () => {
|
||||
const setFile = useSetRecoilState(fileState)
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<h1>
|
||||
Image inpainting powered by 🦙
|
||||
<a href="https://github.com/saic-mdal/lama">LaMa</a>
|
||||
</h1>
|
||||
<div className="landing-file-selector">
|
||||
<FileSelect
|
||||
onSelection={async f => {
|
||||
setFile(f)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LandingPage
|
||||
@@ -1,50 +0,0 @@
|
||||
import { XIcon } from '@heroicons/react/outline'
|
||||
import React, { ReactNode, useRef } from 'react'
|
||||
import { useClickAway, useKey } from 'react-use'
|
||||
import Button from './Button'
|
||||
|
||||
interface ModalProps {
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
const { children, onClose, className } = props
|
||||
const ref = useRef(null)
|
||||
|
||||
useClickAway(ref, () => {
|
||||
onClose?.()
|
||||
})
|
||||
|
||||
useKey('Escape', onClose, {
|
||||
event: 'keydown',
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute w-full h-full flex justify-center items-center',
|
||||
'z-20',
|
||||
'bg-gray-300 bg-opacity-40 backdrop-filter backdrop-blur-md',
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`bg-white max-w-4xl relative rounded-md shadow-md ${
|
||||
className || 'p-8 sm:p-12'
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
icon={<XIcon className="w-6 h-6" />}
|
||||
className={[
|
||||
'absolute right-4 top-4 rounded-full bg-gray-100 w-10 h-10',
|
||||
'flex justify-center items-center py-0 px-0 sm:px-0',
|
||||
].join(' ')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
lama_cleaner/app/src/components/Shortcuts/Shortcuts.scss
Normal file
32
lama_cleaner/app/src/components/Shortcuts/Shortcuts.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
.modal-shortcuts {
|
||||
grid-area: main-content;
|
||||
background-color: var(--modal-bg);
|
||||
color: var(--modal-text-color);
|
||||
box-shadow: 0px 0px 20px rgb(0, 0, 40, 0.2);
|
||||
}
|
||||
|
||||
.shortcut-options {
|
||||
display: grid;
|
||||
row-gap: 1rem;
|
||||
|
||||
.shortcut-option {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, auto);
|
||||
column-gap: 6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
font-family: 'WorkSans-Bold';
|
||||
background-color: var(--modal-hotkey-bg);
|
||||
padding: 0.4rem 1rem;
|
||||
width: max-content;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.shortcut-description {
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
54
lama_cleaner/app/src/components/Shortcuts/Shortcuts.tsx
Normal file
54
lama_cleaner/app/src/components/Shortcuts/Shortcuts.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import { useKeyPressEvent } from 'react-use'
|
||||
import { useRecoilState } from 'recoil'
|
||||
import { shortcutsState } from '../../store/Atoms'
|
||||
import Button from '../shared/Button'
|
||||
|
||||
const Shortcuts = () => {
|
||||
const [shortcutVisibility, setShortcutState] = useRecoilState(shortcutsState)
|
||||
|
||||
const shortcutStateHandler = () => {
|
||||
setShortcutState(prevShortcutState => {
|
||||
return !prevShortcutState
|
||||
})
|
||||
}
|
||||
|
||||
useKeyPressEvent('h', () => {
|
||||
shortcutStateHandler()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="shortcuts">
|
||||
<Button
|
||||
onClick={shortcutStateHandler}
|
||||
disabled={shortcutVisibility}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="28"
|
||||
height="28"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<g fill="currentColor">
|
||||
<path d="M14 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h12zM2 4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H2z" />
|
||||
<path d="M13 10.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm0-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5 0A.25.25 0 0 1 8.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 8 8.75v-.5zm2 0a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25v-.5zm1 2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5-2A.25.25 0 0 1 6.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 6 8.75v-.5zm-2 0A.25.25 0 0 1 4.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 4 8.75v-.5zm-2 0A.25.25 0 0 1 2.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 2 8.75v-.5zm11-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0A.25.25 0 0 1 9.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 9 6.75v-.5zm-2 0A.25.25 0 0 1 7.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 7 6.75v-.5zm-2 0A.25.25 0 0 1 5.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 5 6.75v-.5zm-3 0A.25.25 0 0 1 2.25 6h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5A.25.25 0 0 1 2 6.75v-.5zm0 4a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm2 0a.25.25 0 0 1 .25-.25h5.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-5.5a.25.25 0 0 1-.25-.25v-.5z" />
|
||||
</g>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Shortcuts
|
||||
66
lama_cleaner/app/src/components/Shortcuts/ShortcutsModal.tsx
Normal file
66
lama_cleaner/app/src/components/Shortcuts/ShortcutsModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import { useSetRecoilState } from 'recoil'
|
||||
import { shortcutsState } from '../../store/Atoms'
|
||||
import Modal from '../shared/Modal'
|
||||
|
||||
interface Shortcut {
|
||||
children: ReactNode
|
||||
content: string
|
||||
}
|
||||
|
||||
function ShortCut(props: Shortcut) {
|
||||
const { children, content } = props
|
||||
|
||||
return (
|
||||
<div className="shortcut-option">
|
||||
<div className="shortcut-key">{children}</div>
|
||||
<div className="shortcut-description">{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShortcutsModal() {
|
||||
const setShortcutState = useSetRecoilState(shortcutsState)
|
||||
|
||||
const shortcutStateHandler = () => {
|
||||
setShortcutState(prevShortcutState => !prevShortcutState)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={shortcutStateHandler}
|
||||
title="Hotkeys"
|
||||
className="modal-shortcuts"
|
||||
>
|
||||
<div className="shortcut-options">
|
||||
<ShortCut content="Enable multi-stroke mask drawing">
|
||||
<p>Hold Cmd/Ctrl</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Undo inpainting">
|
||||
<p>Cmd/Ctrl + Z</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Pan">
|
||||
<p>Space & Drag</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="View original image">
|
||||
<p>Hold Tab</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Reset zoom/pan & Cancel mask drawing">
|
||||
<p>Esc</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Decrease Brush Size">
|
||||
<p>[</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Increase Brush Size">
|
||||
<p>]</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Toggle Dark Mode">
|
||||
<p>Shift + D</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Toggle Hotkeys Panel">
|
||||
<p>H</p>
|
||||
</ShortCut>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import Modal from './Modal'
|
||||
|
||||
interface Shortcut {
|
||||
children: ReactNode
|
||||
content: string
|
||||
}
|
||||
|
||||
function ShortCut(props: Shortcut) {
|
||||
const { children, content } = props
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-row space-x-6 justify-between">
|
||||
<div className="mr-12 border-2 rounded-xl px-2 py-1">{children}</div>
|
||||
<div className="flex flex-col justify-center">{content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ShortcutsModalProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function ShortcutsModal(props: ShortcutsModalProps) {
|
||||
const { onClose } = props
|
||||
return (
|
||||
<Modal onClose={onClose} className="h-full sm:h-auto p-0 sm:p-0">
|
||||
<div className="h-full sm:h-auto flex flex-col sm:flex-row">
|
||||
<div className="flex sm:p-14 flex flex-col justify-center space-y-6">
|
||||
<ShortCut content="Enable multi-stroke mask drawing">
|
||||
<p>Hold Cmd/Ctrl</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Undo inpainting">
|
||||
<p>Cmd/Ctrl + z</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Pan">
|
||||
<p>Space & Drag</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="View original image">
|
||||
<p>Hold Tab</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Reset zoom/pan & Cancel mask drawing">
|
||||
<p>Esc</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Decrease Brush Size">
|
||||
<p>[</p>
|
||||
</ShortCut>
|
||||
<ShortCut content="Increase Brush Size">
|
||||
<p>]</p>
|
||||
</ShortCut>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import React, { FocusEvent, useCallback } from 'react'
|
||||
import { Listbox } from '@headlessui/react'
|
||||
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid'
|
||||
|
||||
const sizes = ['720', '1080', '2000', 'Original']
|
||||
|
||||
type SizeSelectorProps = {
|
||||
value: string
|
||||
originalWidth: number
|
||||
originalHeight: number
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export default function SizeSelector(props: SizeSelectorProps) {
|
||||
const { value, originalHeight, originalWidth, onChange } = props
|
||||
|
||||
const getSizeShowName = (size: string) => {
|
||||
if (size === 'Original') {
|
||||
return `${originalWidth}x${originalHeight}`
|
||||
}
|
||||
const length: number = parseInt(size, 10)
|
||||
const longSide: number =
|
||||
originalWidth > originalHeight ? originalWidth : originalHeight
|
||||
const scale = length / longSide
|
||||
|
||||
if (originalWidth > originalHeight) {
|
||||
const newHeight = Math.ceil(scale * originalHeight)
|
||||
return `${size}x${newHeight}`
|
||||
}
|
||||
const newWidth = Math.ceil(scale * originalWidth)
|
||||
return `${newWidth}x${size}`
|
||||
}
|
||||
|
||||
const onButtonFocus = (e: FocusEvent<any>) => {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
|
||||
const getValidSizes = useCallback((): string[] => {
|
||||
const longSide: number =
|
||||
originalWidth > originalHeight ? originalWidth : originalHeight
|
||||
|
||||
const validSizes = []
|
||||
for (let i = 0; i < sizes.length; i += 1) {
|
||||
const s = sizes[i]
|
||||
if (s === 'Original') {
|
||||
validSizes.push(s)
|
||||
} else if (parseInt(s, 10) <= longSide) {
|
||||
validSizes.push(s)
|
||||
}
|
||||
}
|
||||
return validSizes
|
||||
}, [originalHeight, originalWidth])
|
||||
|
||||
const getValidSize = useCallback(() => {
|
||||
if (getValidSizes().indexOf(value) === -1) {
|
||||
return getValidSizes()[0]
|
||||
}
|
||||
return value
|
||||
}, [value, getValidSizes])
|
||||
|
||||
return (
|
||||
<div className="w-32">
|
||||
<Listbox value={getValidSize()} onChange={onChange}>
|
||||
<div className="relative">
|
||||
<Listbox.Options
|
||||
style={{ top: `-${getValidSizes().length * 40 + 5}px` }}
|
||||
className="absolute mb-1 w-full overflow-auto text-base bg-opacity-10 bg-black backdrop-blur rounded-md max-h-60 outline-none sm:text-sm"
|
||||
>
|
||||
{getValidSizes().map(size => (
|
||||
<Listbox.Option
|
||||
key={size}
|
||||
className={({ active }) =>
|
||||
`${active ? 'bg-black bg-opacity-10' : 'text-gray-900'}
|
||||
cursor-default select-none relative py-2 pl-4 pr-4`
|
||||
}
|
||||
value={size}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-medium' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{getSizeShowName(size)}
|
||||
</span>
|
||||
{/* {selected ? (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-amber-600' : 'text-amber-600'
|
||||
}
|
||||
absolute inset-y-0 left-0 flex items-center pl-3`}
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null} */}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
<Listbox.Button
|
||||
onFocus={onButtonFocus}
|
||||
className="relative w-full inline-flex w-full px-4 py-2 text-sm font-medium bg-black rounded-md bg-opacity-10 focus:outline-none "
|
||||
>
|
||||
<span className="block truncate">
|
||||
{getSizeShowName(getValidSize())}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon
|
||||
className="w-5 h-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
lama_cleaner/app/src/components/Workspace.tsx
Normal file
23
lama_cleaner/app/src/components/Workspace.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import Editor from './Editor/Editor'
|
||||
import { shortcutsState } from '../store/Atoms'
|
||||
import Header from './Header/Header'
|
||||
import ShortcutsModal from './Shortcuts/ShortcutsModal'
|
||||
|
||||
interface WorkspaceProps {
|
||||
file: File
|
||||
}
|
||||
|
||||
const Workspace = ({ file }: WorkspaceProps) => {
|
||||
const shortcutVisbility = useRecoilValue(shortcutsState)
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Editor file={file} />
|
||||
{shortcutVisbility ? <ShortcutsModal /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Workspace
|
||||
28
lama_cleaner/app/src/components/shared/Button.scss
Normal file
28
lama_cleaner/app/src/components/shared/Button.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
.btn-primary {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
column-gap: 1rem;
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: black;
|
||||
font-family: 'WorkSans-Bold', sans-serif;
|
||||
width: max-content;
|
||||
padding: 0.5rem;
|
||||
place-items: center;
|
||||
border-radius: 0.5rem;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--btn-primary-hover-bg);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { ReactNode, useState } from 'react'
|
||||
import React, { ReactNode } from 'react'
|
||||
|
||||
interface ButtonProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
icon?: ReactNode
|
||||
primary?: boolean
|
||||
disabled?: boolean
|
||||
onKeyDown?: () => void
|
||||
onClick?: () => void
|
||||
@@ -18,23 +17,11 @@ export default function Button(props: ButtonProps) {
|
||||
className,
|
||||
disabled,
|
||||
icon,
|
||||
primary,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
onDown,
|
||||
onUp,
|
||||
} = props
|
||||
const [active, setActive] = useState(false)
|
||||
let background = ''
|
||||
if (primary && !disabled) {
|
||||
background = 'bg-primary hover:bg-black hover:text-white'
|
||||
}
|
||||
if (active) {
|
||||
background = 'bg-black text-white'
|
||||
}
|
||||
if (!primary && !active) {
|
||||
background = 'hover:bg-primary'
|
||||
}
|
||||
|
||||
const blurOnClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.blur()
|
||||
@@ -47,24 +34,21 @@ export default function Button(props: ButtonProps) {
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={blurOnClick}
|
||||
onPointerDown={(ev: React.PointerEvent<HTMLDivElement>) => {
|
||||
setActive(true)
|
||||
onDown?.(ev.nativeEvent)
|
||||
}}
|
||||
onPointerUp={(ev: React.PointerEvent<HTMLDivElement>) => {
|
||||
setActive(false)
|
||||
onUp?.(ev.nativeEvent)
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className={[
|
||||
'inline-flex py-3 px-3 rounded-md cursor-pointer',
|
||||
children ? 'space-x-3' : '',
|
||||
background,
|
||||
disabled ? 'pointer-events-none opacity-50' : '',
|
||||
'btn-primary',
|
||||
children ? 'btn-primary-content' : '',
|
||||
disabled ? 'btn-primary-disabled' : '',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
{icon}
|
||||
<span className="whitespace-nowrap select-none">{children}</span>
|
||||
{children ? <span>{children}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,5 @@ interface LinkProps {
|
||||
|
||||
export default function Link(props: LinkProps) {
|
||||
const { children, href } = props
|
||||
return (
|
||||
<a href={href} className="font-black underline">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
return <a href={href}>{children}</a>
|
||||
}
|
||||
19
lama_cleaner/app/src/components/shared/Modal.scss
Normal file
19
lama_cleaner/app/src/components/shared/Modal.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.modal {
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
row-gap: 2rem;
|
||||
place-self: center;
|
||||
padding: 2rem;
|
||||
border-radius: 0.95rem;
|
||||
z-index: 9999;
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, auto);
|
||||
align-items: center;
|
||||
|
||||
.btn-primary {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lama_cleaner/app/src/components/shared/Modal.tsx
Normal file
34
lama_cleaner/app/src/components/shared/Modal.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { XIcon } from '@heroicons/react/outline'
|
||||
import React, { ReactNode, useRef } from 'react'
|
||||
import { useClickAway, useKey } from 'react-use'
|
||||
import Button from './Button'
|
||||
|
||||
interface ModalProps {
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
title: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
const { children, onClose, className, title } = props
|
||||
const ref = useRef(null)
|
||||
|
||||
useClickAway(ref, () => {
|
||||
onClose?.()
|
||||
})
|
||||
|
||||
useKey('Escape', onClose, {
|
||||
event: 'keydown',
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`modal ${className}`}>
|
||||
<div className="modal-header">
|
||||
<h3>{title}</h3>
|
||||
<Button icon={<XIcon />} onClick={onClose} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
lama_cleaner/app/src/components/shared/ThemeChanger.scss
Normal file
29
lama_cleaner/app/src/components/shared/ThemeChanger.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.theme-changer {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
box-shadow: inset 4px 10px 0px rgb(80, 80, 80);
|
||||
transform: rotate(-75deg);
|
||||
transition: all 0.2s ease-in;
|
||||
margin: 1rem;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.25rem;
|
||||
z-index: 10;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
.theme-changer {
|
||||
background: rgb(255, 190, 0);
|
||||
box-shadow: none;
|
||||
transform: rotate(-75deg);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
25
lama_cleaner/app/src/components/shared/ThemeChanger.tsx
Normal file
25
lama_cleaner/app/src/components/shared/ThemeChanger.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { atom, useRecoilState } from 'recoil'
|
||||
|
||||
export const themeState = atom({
|
||||
key: 'themeState',
|
||||
default: 'dark',
|
||||
})
|
||||
|
||||
export const ThemeChanger = () => {
|
||||
const [theme, setTheme] = useRecoilState(themeState)
|
||||
|
||||
const themeSwitchHandler = () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="theme-changer"
|
||||
onClick={themeSwitchHandler}
|
||||
aria-label="Switch Theme"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user