update UI

This commit is contained in:
Joseph 2025-05-12 13:53:15 +07:00
parent f71e9cfb26
commit 4484a27366
77 changed files with 16239 additions and 2 deletions

4
.gitignore vendored
View File

@ -9,4 +9,6 @@ dist/
IOPaint.egg-info/
venv/
tmp/
iopaint/web_app/
# iopaint/web_app/
iopaint/.venv
iopaint/output_nohup.log

0
build_docker.sh Normal file → Executable file
View File

15
iopaint/nohup.out Normal file
View File

@ -0,0 +1,15 @@
2025-05-08 17:06:14.942 | INFO | iopaint.runtime:setup_model_dir:82 - Model directory: /root/.cache
- Platform: Linux-6.8.0-55-generic-x86_64-with-glibc2.39
- Python version: 3.12.3
- torch: 2.7.0
- torchvision: 0.22.0
- Pillow: 9.5.0
- diffusers: 0.27.2
- transformers: 4.48.3
- opencv-python: 4.11.0.86
- accelerate: 1.6.0
- iopaint: N/A
- rembg: 2.0.65
- realesrgan: N/A
- gfpgan: N/A

25
iopaint/requirements.txt Normal file
View File

@ -0,0 +1,25 @@
torch>=2.0.0
opencv-python
diffusers==0.27.2
huggingface_hub==0.25.2
accelerate
peft==0.7.1
transformers>=4.39.1
safetensors
controlnet-aux==0.0.3
fastapi==0.108.0
uvicorn
python-multipart
python-socketio==5.7.2
typer
pydantic>=2.5.2
rich
loguru
yacs
piexif==1.1.3
omegaconf
easydict
gradio==4.21.0
typer-config==1.4.0
Pillow==9.5.0 # for AnyText

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
iopaint/web_app/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
iopaint/web_app/README.md Normal file
View File

@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "gray",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IOPaint</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6465
iopaint/web_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
{
"name": "web_app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.8.7",
"@uidotdev/usehooks": "^2.4.1",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"fuse.js": "^7.0.0",
"immer": "^10.0.3",
"inter-ui": "^4.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.292.0",
"mitt": "^3.0.1",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-hotkeys-hook": "^4.4.1",
"react-photo-album": "^2.3.0",
"react-use": "^17.4.0",
"react-zoom-pan-pinch": "^3.3.0",
"recoil": "^0.7.7",
"socket.io-client": "^4.7.2",
"tailwind-merge": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
"zundo": "^2.0.0",
"zustand": "^4.4.6"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/axios": "^0.14.0",
"@types/flexsearch": "^0.7.6",
"@types/lodash": "^4.14.201",
"@types/node": "^20.9.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

167
iopaint/web_app/src/App.tsx Normal file
View File

@ -0,0 +1,167 @@
import { useCallback, useEffect, useRef } from "react"
import useInputImage from "@/hooks/useInputImage"
import { keepGUIAlive } from "@/lib/utils"
import { getServerConfig } from "@/lib/api"
import Header from "@/components/Header"
import Workspace from "@/components/Workspace"
import FileSelect from "@/components/FileSelect"
import { Toaster } from "./components/ui/toaster"
import { useStore } from "./lib/states"
import { useWindowSize } from "react-use"
const SUPPORTED_FILE_TYPE = [
"image/jpeg",
"image/png",
"image/webp",
"image/bmp",
"image/tiff",
]
function Home() {
const [file, updateAppState, setServerConfig, setFile] = useStore((state) => [
state.file,
state.updateAppState,
state.setServerConfig,
state.setFile,
])
const userInputImage = useInputImage()
const windowSize = useWindowSize()
useEffect(() => {
if (userInputImage) {
setFile(userInputImage)
}
}, [userInputImage, setFile])
useEffect(() => {
updateAppState({ windowSize })
}, [windowSize])
useEffect(() => {
const fetchServerConfig = async () => {
const serverConfig = await getServerConfig()
setServerConfig(serverConfig)
if (serverConfig.isDesktop) {
// Keeping GUI Window Open
keepGUIAlive()
}
}
fetchServerConfig()
}, [])
const dragCounter = useRef(0)
const handleDrag = useCallback((event: any) => {
event.preventDefault()
event.stopPropagation()
}, [])
const handleDragIn = useCallback((event: any) => {
event.preventDefault()
event.stopPropagation()
dragCounter.current += 1
}, [])
const handleDragOut = useCallback((event: any) => {
event.preventDefault()
event.stopPropagation()
dragCounter.current -= 1
if (dragCounter.current > 0) return
}, [])
const handleDrop = useCallback((event: any) => {
event.preventDefault()
event.stopPropagation()
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
if (event.dataTransfer.files.length > 1) {
// setToastState({
// open: true,
// desc: "Please drag and drop only one file",
// state: "error",
// duration: 3000,
// })
} else {
const dragFile = event.dataTransfer.files[0]
const fileType = dragFile.type
if (SUPPORTED_FILE_TYPE.includes(fileType)) {
setFile(dragFile)
} else {
// setToastState({
// open: true,
// desc: "Please drag and drop an image file",
// state: "error",
// duration: 3000,
// })
}
}
event.dataTransfer.clearData()
}
}, [])
const onPaste = useCallback((event: any) => {
// TODO: when sd side panel open, ctrl+v not work
// https://htmldom.dev/paste-an-image-from-the-clipboard/
if (!event.clipboardData) {
return
}
const clipboardItems = event.clipboardData.items
const items: DataTransferItem[] = [].slice
.call(clipboardItems)
.filter((item: DataTransferItem) => {
// Filter the image items only
return item.type.indexOf("image") !== -1
})
if (items.length === 0) {
return
}
event.preventDefault()
event.stopPropagation()
// TODO: add confirm dialog
const item = items[0]
// Get the blob of image
const blob = item.getAsFile()
if (blob) {
setFile(blob)
}
}, [])
useEffect(() => {
window.addEventListener("dragenter", handleDragIn)
window.addEventListener("dragleave", handleDragOut)
window.addEventListener("dragover", handleDrag)
window.addEventListener("drop", handleDrop)
window.addEventListener("paste", onPaste)
return function cleanUp() {
window.removeEventListener("dragenter", handleDragIn)
window.removeEventListener("dragleave", handleDragOut)
window.removeEventListener("dragover", handleDrag)
window.removeEventListener("drop", handleDrop)
window.removeEventListener("paste", onPaste)
}
})
return (
<main className="flex min-h-screen flex-col items-center justify-between w-full bg-[radial-gradient(circle_at_1px_1px,_#8e8e8e8e_1px,_transparent_0)] [background-size:20px_20px] bg-repeat">
<Toaster />
<Header />
<Workspace />
{!file ? (
<FileSelect
onSelection={async (f) => {
setFile(f)
}}
/>
) : (
<></>
)}
</main>
)
}
export default Home

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -0,0 +1,35 @@
import { Coffee as CoffeeIcon } from "lucide-react"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./ui/dialog"
import { IconButton } from "./ui/button"
import { DialogDescription } from "@radix-ui/react-dialog"
import Kofi from "@/assets/kofi_button_black.png"
export function Coffee() {
return (
<Dialog>
<DialogTrigger asChild>
<IconButton tooltip="Buy me a coffee">
<CoffeeIcon />
</IconButton>
</DialogTrigger>
<DialogContent>
<DialogTitle>Buy me a coffee</DialogTitle>
<DialogDescription className="mb-8">
Hi, if you found my project is useful, please conside buy me a coffee
to support my work. Thanks!
</DialogDescription>
<div className="w-full flex items-center justify-center">
<a
href="https://ko-fi.com/Z8Z1CZJGY"
target="_blank"
rel="noreferrer"
>
<img src={Kofi} className="h-[32px]" />
</a>
</div>
</DialogContent>
</Dialog>
)
}
export default Coffee

View File

@ -0,0 +1,400 @@
import { useStore } from "@/lib/states"
import { cn } from "@/lib/utils"
import React, { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge"
const DOC_MOVE_OPTS = { capture: true, passive: false }
const DRAG_HANDLE_BORDER = 2
interface EVData {
initX: number
initY: number
initHeight: number
initWidth: number
startResizeX: number
startResizeY: number
ord: string // top/right/bottom/left
}
interface Props {
maxHeight: number
maxWidth: number
scale: number
minHeight: number
minWidth: number
show: boolean
}
const clamp = (
newPos: number,
newLength: number,
oldPos: number,
oldLength: number,
minLength: number,
maxLength: number
) => {
if (newPos !== oldPos && newLength === oldLength) {
if (newPos < 0) {
return [0, oldLength]
}
if (newPos + newLength > maxLength) {
return [maxLength - oldLength, oldLength]
}
} else {
if (newLength < minLength) {
if (newPos === oldPos) {
return [newPos, minLength]
}
return [newPos + newLength - minLength, minLength]
}
if (newPos < 0) {
return [0, newPos + newLength]
}
if (newPos + newLength > maxLength) {
return [newPos, maxLength - newPos]
}
}
return [newPos, newLength]
}
const Cropper = (props: Props) => {
const { minHeight, minWidth, maxHeight, maxWidth, scale, show } = props
const [
imageWidth,
imageHeight,
isInpainting,
isSD,
{ x, y, width, height },
setX,
setY,
setWidth,
setHeight,
isResizing,
setIsResizing,
] = useStore((state) => [
state.imageWidth,
state.imageHeight,
state.isInpainting,
state.isSD(),
state.cropperState,
state.setCropperX,
state.setCropperY,
state.setCropperWidth,
state.setCropperHeight,
state.isCropperExtenderResizing,
state.setIsCropperExtenderResizing,
])
// const [isResizing, setIsResizing] = useState(false)
const [isMoving, setIsMoving] = useState(false)
useEffect(() => {
setX(Math.round((maxWidth - 512) / 2))
setY(Math.round((maxHeight - 512) / 2))
// TODO: 换了一张较小的图片cropper 的起始位置和边界要修改
// TODO: 一开始的 scale 不对
}, [maxHeight, maxWidth, imageWidth, imageHeight])
const [evData, setEVData] = useState<EVData>({
initX: 0,
initY: 0,
initHeight: 0,
initWidth: 0,
startResizeX: 0,
startResizeY: 0,
ord: "top",
})
const onDragFocus = () => {
// console.log("focus")
}
const clampLeftRight = (newX: number, newWidth: number) => {
return clamp(newX, newWidth, x, width, minWidth, maxWidth)
}
const clampTopBottom = (newY: number, newHeight: number) => {
return clamp(newY, newHeight, y, height, minHeight, maxHeight)
}
const onPointerMove = (e: PointerEvent) => {
if (isInpainting) {
return
}
const curX = e.clientX
const curY = e.clientY
const offsetY = Math.round((curY - evData.startResizeY) / scale)
const offsetX = Math.round((curX - evData.startResizeX) / scale)
const moveTop = () => {
const newHeight = evData.initHeight - offsetY
const newY = evData.initY + offsetY
const [clampedY, clampedHeight] = clampTopBottom(newY, newHeight)
setHeight(clampedHeight)
setY(clampedY)
}
const moveBottom = () => {
const newHeight = evData.initHeight + offsetY
const [clampedY, clampedHeight] = clampTopBottom(evData.initY, newHeight)
setHeight(clampedHeight)
setY(clampedY)
}
const moveLeft = () => {
const newWidth = evData.initWidth - offsetX
const newX = evData.initX + offsetX
const [clampedX, clampedWidth] = clampLeftRight(newX, newWidth)
setWidth(clampedWidth)
setX(clampedX)
}
const moveRight = () => {
const newWidth = evData.initWidth + offsetX
const [clampedX, clampedWidth] = clampLeftRight(evData.initX, newWidth)
setWidth(clampedWidth)
setX(clampedX)
}
if (isResizing) {
switch (evData.ord) {
case "topleft": {
moveTop()
moveLeft()
break
}
case "topright": {
moveTop()
moveRight()
break
}
case "bottomleft": {
moveBottom()
moveLeft()
break
}
case "bottomright": {
moveBottom()
moveRight()
break
}
case "top": {
moveTop()
break
}
case "right": {
moveRight()
break
}
case "bottom": {
moveBottom()
break
}
case "left": {
moveLeft()
break
}
default:
break
}
}
if (isMoving) {
const newX = evData.initX + offsetX
const newY = evData.initY + offsetY
const [clampedX, clampedWidth] = clampLeftRight(newX, evData.initWidth)
const [clampedY, clampedHeight] = clampTopBottom(newY, evData.initHeight)
setWidth(clampedWidth)
setHeight(clampedHeight)
setX(clampedX)
setY(clampedY)
}
}
const onPointerDone = () => {
if (isResizing) {
setIsResizing(false)
}
if (isMoving) {
setIsMoving(false)
}
}
useEffect(() => {
if (isResizing || isMoving) {
document.addEventListener("pointermove", onPointerMove, DOC_MOVE_OPTS)
document.addEventListener("pointerup", onPointerDone, DOC_MOVE_OPTS)
document.addEventListener("pointercancel", onPointerDone, DOC_MOVE_OPTS)
return () => {
document.removeEventListener(
"pointermove",
onPointerMove,
DOC_MOVE_OPTS
)
document.removeEventListener("pointerup", onPointerDone, DOC_MOVE_OPTS)
document.removeEventListener(
"pointercancel",
onPointerDone,
DOC_MOVE_OPTS
)
}
}
}, [isResizing, isMoving, width, height, evData])
const onCropPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const { ord } = (e.target as HTMLElement).dataset
if (ord) {
setIsResizing(true)
setEVData({
initX: x,
initY: y,
initHeight: height,
initWidth: width,
startResizeX: e.clientX,
startResizeY: e.clientY,
ord,
})
}
}
const createDragHandle = (cursor: string, side1: string, side2: string) => {
const sideLength = 12
const halfSideLength = sideLength / 2
const draghandleCls = `w-[${sideLength}px] h-[${sideLength}px] z-[4] absolute content-[''] block border-2 border-primary borde pointer-events-auto hover:bg-primary`
let xTrans = "0"
let yTrans = "0"
let side2Key = side2
let side2Val = `${-halfSideLength}px`
if (side2 === "") {
side2Val = "50%"
if (side1 === "left" || side1 === "right") {
side2Key = "top"
yTrans = "-50%"
} else {
side2Key = "left"
xTrans = "-50%"
}
}
return (
<div
className={cn(draghandleCls, cursor)}
style={{
[side1]: -halfSideLength,
[side2Key]: side2Val,
transform: `translate(${xTrans}, ${yTrans}) scale(${1 / scale})`,
}}
data-ord={side1 + side2}
aria-label={side1 + side2}
tabIndex={-1}
role="button"
/>
)
}
const createCropSelection = () => {
return (
<div
onFocus={onDragFocus}
onPointerDown={onCropPointerDown}
className="absolute top-0 h-full w-full"
>
<div
className="absolute pointer-events-auto top-0 left-0 w-full cursor-ns-resize h-[12px] mt-[-6px]"
data-ord="top"
/>
<div
className="absolute pointer-events-auto top-0 right-0 h-full cursor-ew-resize w-[12px] mr-[-6px]"
data-ord="right"
/>
<div
className="absolute pointer-events-auto bottom-0 left-0 w-full cursor-ns-resize h-[12px] mb-[-6px]"
data-ord="bottom"
/>
<div
className="absolute pointer-events-auto top-0 left-0 h-full cursor-ew-resize w-[12px] ml-[-6px]"
data-ord="left"
/>
{createDragHandle("cursor-nw-resize", "top", "left")}
{createDragHandle("cursor-ne-resize", "top", "right")}
{createDragHandle("cursor-sw-resize", "bottom", "left")}
{createDragHandle("cursor-se-resize", "bottom", "right")}
{createDragHandle("cursor-ns-resize", "top", "")}
{createDragHandle("cursor-ns-resize", "bottom", "")}
{createDragHandle("cursor-ew-resize", "left", "")}
{createDragHandle("cursor-ew-resize", "right", "")}
</div>
)
}
const onInfoBarPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
setIsMoving(true)
setEVData({
initX: x,
initY: y,
initHeight: height,
initWidth: width,
startResizeX: e.clientX,
startResizeY: e.clientY,
ord: "",
})
}
const createInfoBar = () => {
return (
<div
className={twMerge(
"border absolute pointer-events-auto px-2 py-1 rounded-full hover:cursor-move bg-background",
"origin-top-left top-0 left-0"
)}
style={{
transform: `scale(${(1 / scale) * 0.8})`,
}}
onPointerDown={onInfoBarPointerDown}
>
{/* TODO: 移动的时候会显示 brush */}
{width} x {height}
</div>
)
}
const createBorder = () => {
return (
<div
className="outline-dashed outline-primary"
style={{
height,
width,
outlineWidth: `${(DRAG_HANDLE_BORDER / scale) * 1.3}px`,
}}
/>
)
}
if (show === false || !isSD) {
return null
}
return (
<div className="absolute h-full w-full overflow-hidden pointer-events-none z-[2]">
<div
className="relative pointer-events-none z-[2] [box-shadow:0_0_0_9999px_rgba(0,_0,_0,_0.5)]"
style={{ height, width, left: x, top: y }}
>
{createBorder()}
{createInfoBar()}
{createCropSelection()}
</div>
</div>
)
}
export default Cropper

View File

@ -0,0 +1,63 @@
import * as React from "react"
import io from "socket.io-client"
import { Progress } from "./ui/progress"
import { useStore } from "@/lib/states"
export const API_ENDPOINT = import.meta.env.DEV
? import.meta.env.VITE_BACKEND
: ""
const socket = io(API_ENDPOINT)
const DiffusionProgress = () => {
const [settings, isInpainting, isSD] = useStore((state) => [
state.settings,
state.isInpainting,
state.isSD(),
])
const [isConnected, setIsConnected] = React.useState(false)
const [step, setStep] = React.useState(0)
const progress = Math.min(Math.round((step / settings.sdSteps) * 100), 100)
React.useEffect(() => {
socket.on("connect", () => {
setIsConnected(true)
})
socket.on("disconnect", () => {
setIsConnected(false)
})
socket.on("diffusion_progress", (data) => {
if (data) {
setStep(data.step + 1)
}
})
socket.on("diffusion_finish", () => {
setStep(0)
})
return () => {
socket.off("connect")
socket.off("disconnect")
socket.off("diffusion_progress")
socket.off("diffusion_finish")
}
}, [])
return (
<div
className="z-10 fixed bg-background w-[220px] left-1/2 -translate-x-1/2 top-[68px] h-[32px] flex justify-center items-center gap-[18px] border-[1px] border-[solid] rounded-[14px] pl-[8px] pr-[8px]"
style={{
visibility: isConnected && isInpainting && isSD ? "visible" : "hidden",
}}
>
<Progress value={progress} />
<div className="w-[45px] flex justify-center font-nums">{progress}%</div>
</div>
)
}
export default DiffusionProgress

View File

@ -0,0 +1,989 @@
import { SyntheticEvent, useCallback, useEffect, useRef, useState } from "react"
import { CursorArrowRaysIcon } from "@heroicons/react/24/outline"
import { useToast } from "@/components/ui/use-toast"
import {
ReactZoomPanPinchContentRef,
TransformComponent,
TransformWrapper,
} from "react-zoom-pan-pinch"
import { useKeyPressEvent } from "react-use"
import { downloadToOutput, runPlugin } from "@/lib/api"
import { IconButton } from "@/components/ui/button"
import {
askWritePermission,
cn,
copyCanvasImage,
downloadImage,
drawLines,
generateMask,
isMidClick,
isRightClick,
mouseXY,
srcToFile,
} from "@/lib/utils"
import { Eraser, Eye, Redo, Undo, Expand, Download } from "lucide-react"
import { useImage } from "@/hooks/useImage"
import { Slider } from "./ui/slider"
import { PluginName } from "@/lib/types"
import { useStore } from "@/lib/states"
import Cropper from "./Cropper"
import { InteractiveSegPoints } from "./InteractiveSeg"
import useHotKey from "@/hooks/useHotkey"
import Extender from "./Extender"
import { MAX_BRUSH_SIZE, MIN_BRUSH_SIZE } from "@/lib/const"
const TOOLBAR_HEIGHT = 200
const COMPARE_SLIDER_DURATION_MS = 300
interface EditorProps {
file: File
}
export default function Editor(props: EditorProps) {
const { file } = props
const { toast } = useToast()
const [
disableShortCuts,
windowSize,
isInpainting,
imageWidth,
imageHeight,
settings,
enableAutoSaving,
setImageSize,
setBaseBrushSize,
interactiveSegState,
updateInteractiveSegState,
handleCanvasMouseDown,
handleCanvasMouseMove,
undo,
redo,
undoDisabled,
redoDisabled,
isProcessing,
updateAppState,
runMannually,
runInpainting,
isCropperExtenderResizing,
decreaseBaseBrushSize,
increaseBaseBrushSize,
] = useStore((state) => [
state.disableShortCuts,
state.windowSize,
state.isInpainting,
state.imageWidth,
state.imageHeight,
state.settings,
state.serverConfig.enableAutoSaving,
state.setImageSize,
state.setBaseBrushSize,
state.interactiveSegState,
state.updateInteractiveSegState,
state.handleCanvasMouseDown,
state.handleCanvasMouseMove,
state.undo,
state.redo,
state.undoDisabled(),
state.redoDisabled(),
state.getIsProcessing(),
state.updateAppState,
state.runMannually(),
state.runInpainting,
state.isCropperExtenderResizing,
state.decreaseBaseBrushSize,
state.increaseBaseBrushSize,
])
const baseBrushSize = useStore((state) => state.editorState.baseBrushSize)
const brushSize = useStore((state) => state.getBrushSize())
const renders = useStore((state) => state.editorState.renders)
const extraMasks = useStore((state) => state.editorState.extraMasks)
const temporaryMasks = useStore((state) => state.editorState.temporaryMasks)
const lineGroups = useStore((state) => state.editorState.lineGroups)
const curLineGroup = useStore((state) => state.editorState.curLineGroup)
// Local State
const [showOriginal, setShowOriginal] = useState(false)
const [original, isOriginalLoaded] = useImage(file)
const [context, setContext] = useState<CanvasRenderingContext2D>()
const [imageContext, setImageContext] = useState<CanvasRenderingContext2D>()
const [{ x, y }, setCoords] = useState({ x: -1, y: -1 })
const [showBrush, setShowBrush] = useState(false)
const [showRefBrush, setShowRefBrush] = useState(false)
const [isPanning, setIsPanning] = useState<boolean>(false)
const [scale, setScale] = useState<number>(1)
const [panned, setPanned] = useState<boolean>(false)
const [minScale, setMinScale] = useState<number>(1.0)
const windowCenterX = windowSize.width / 2
const windowCenterY = windowSize.height / 2
const viewportRef = useRef<ReactZoomPanPinchContentRef | null>(null)
// Indicates that the image has been loaded and is centered on first load
const [initialCentered, setInitialCentered] = useState(false)
const [isDraging, setIsDraging] = useState(false)
const [sliderPos, setSliderPos] = useState<number>(0)
const [isChangingBrushSizeByWheel, setIsChangingBrushSizeByWheel] =
useState<boolean>(false)
const hadDrawSomething = useCallback(() => {
return curLineGroup.length !== 0
}, [curLineGroup])
useEffect(() => {
if (
!imageContext ||
!isOriginalLoaded ||
imageWidth === 0 ||
imageHeight === 0
) {
return
}
const render = renders.length === 0 ? original : renders[renders.length - 1]
imageContext.canvas.width = imageWidth
imageContext.canvas.height = imageHeight
imageContext.clearRect(
0,
0,
imageContext.canvas.width,
imageContext.canvas.height
)
imageContext.drawImage(render, 0, 0, imageWidth, imageHeight)
}, [
renders,
original,
isOriginalLoaded,
imageContext,
imageHeight,
imageWidth,
])
useEffect(() => {
if (
!context ||
!isOriginalLoaded ||
imageWidth === 0 ||
imageHeight === 0
) {
return
}
context.canvas.width = imageWidth
context.canvas.height = imageHeight
context.clearRect(0, 0, context.canvas.width, context.canvas.height)
temporaryMasks.forEach((maskImage) => {
context.drawImage(maskImage, 0, 0, imageWidth, imageHeight)
})
extraMasks.forEach((maskImage) => {
context.drawImage(maskImage, 0, 0, imageWidth, imageHeight)
})
if (
interactiveSegState.isInteractiveSeg &&
interactiveSegState.tmpInteractiveSegMask
) {
context.drawImage(
interactiveSegState.tmpInteractiveSegMask,
0,
0,
imageWidth,
imageHeight
)
}
drawLines(context, curLineGroup)
}, [
temporaryMasks,
extraMasks,
isOriginalLoaded,
interactiveSegState,
context,
curLineGroup,
imageHeight,
imageWidth,
])
const getCurrentRender = useCallback(async () => {
let targetFile = file
if (renders.length > 0) {
const lastRender = renders[renders.length - 1]
targetFile = await srcToFile(lastRender.currentSrc, file.name, file.type)
}
return targetFile
}, [file, renders])
const hadRunInpainting = () => {
return renders.length !== 0
}
const getCurrentWidthHeight = useCallback(() => {
let width = 512
let height = 512
if (!isOriginalLoaded) {
return [width, height]
}
if (renders.length === 0) {
width = original.naturalWidth
height = original.naturalHeight
} else if (renders.length !== 0) {
width = renders[renders.length - 1].width
height = renders[renders.length - 1].height
}
return [width, height]
}, [original, isOriginalLoaded, renders])
// Draw once the original image is loaded
useEffect(() => {
if (!isOriginalLoaded) {
return
}
const [width, height] = getCurrentWidthHeight()
if (width !== imageWidth || height !== imageHeight) {
setImageSize(width, height)
}
const rW = windowSize.width / width
const rH = (windowSize.height - TOOLBAR_HEIGHT) / height
let s = 1.0
if (rW < 1 || rH < 1) {
s = Math.min(rW, rH)
}
setMinScale(s)
setScale(s)
console.log(
`[on file load] image size: ${width}x${height}, scale: ${s}, initialCentered: ${initialCentered}`
)
if (context?.canvas) {
console.log("[on file load] set canvas size")
if (width != context.canvas.width) {
context.canvas.width = width
}
if (height != context.canvas.height) {
context.canvas.height = height
}
}
if (!initialCentered) {
// 防止每次擦除以后图片 zoom 还原
viewportRef.current?.centerView(s, 1)
console.log("[on file load] centerView")
setInitialCentered(true)
}
}, [
viewportRef,
imageHeight,
imageWidth,
original,
isOriginalLoaded,
windowSize,
initialCentered,
getCurrentWidthHeight,
])
useEffect(() => {
console.log("[useEffect] centerView")
// render 改变尺寸以后undo/redo 重新 center
viewportRef?.current?.centerView(minScale, 1)
}, [imageHeight, imageWidth, viewportRef, minScale])
// Zoom reset
const resetZoom = useCallback(() => {
if (!minScale || !windowSize) {
return
}
const viewport = viewportRef.current
if (!viewport) {
return
}
const offsetX = (windowSize.width - imageWidth * minScale) / 2
const offsetY = (windowSize.height - imageHeight * minScale) / 2
viewport.setTransform(offsetX, offsetY, minScale, 200, "easeOutQuad")
if (viewport.instance.transformState.scale) {
viewport.instance.transformState.scale = minScale
}
setScale(minScale)
setPanned(false)
}, [
viewportRef,
windowSize,
imageHeight,
imageWidth,
windowSize.height,
minScale,
])
useEffect(() => {
window.addEventListener("resize", () => {
resetZoom()
})
return () => {
window.removeEventListener("resize", () => {
resetZoom()
})
}
}, [windowSize, resetZoom])
const handleEscPressed = () => {
if (isProcessing) {
return
}
if (isDraging) {
setIsDraging(false)
} else {
resetZoom()
}
}
useHotKey("Escape", handleEscPressed, [
isDraging,
isInpainting,
resetZoom,
// drawOnCurrentRender,
])
const onMouseMove = (ev: SyntheticEvent) => {
const mouseEvent = ev.nativeEvent as MouseEvent
setCoords({ x: mouseEvent.pageX, y: mouseEvent.pageY })
}
const onMouseDrag = (ev: SyntheticEvent) => {
if (isProcessing) {
return
}
if (interactiveSegState.isInteractiveSeg) {
return
}
if (isPanning) {
return
}
if (!isDraging) {
return
}
if (curLineGroup.length === 0) {
return
}
handleCanvasMouseMove(mouseXY(ev))
}
const runInteractiveSeg = async (newClicks: number[][]) => {
updateAppState({ isPluginRunning: true })
const targetFile = await getCurrentRender()
try {
const res = await runPlugin(
true,
PluginName.InteractiveSeg,
targetFile,
undefined,
newClicks
)
const { blob } = res
const img = new Image()
img.onload = () => {
updateInteractiveSegState({ tmpInteractiveSegMask: img })
}
img.src = blob
} catch (e: any) {
toast({
variant: "destructive",
description: e.message ? e.message : e.toString(),
})
}
updateAppState({ isPluginRunning: false })
}
const onPointerUp = (ev: SyntheticEvent) => {
if (isMidClick(ev)) {
setIsPanning(false)
return
}
if (!hadDrawSomething()) {
return
}
if (interactiveSegState.isInteractiveSeg) {
return
}
if (isPanning) {
return
}
if (!original.src) {
return
}
const canvas = context?.canvas
if (!canvas) {
return
}
if (isInpainting) {
return
}
if (!isDraging) {
return
}
if (runMannually) {
setIsDraging(false)
} else {
runInpainting()
}
}
const onCanvasMouseUp = (ev: SyntheticEvent) => {
if (interactiveSegState.isInteractiveSeg) {
const xy = mouseXY(ev)
const newClicks: number[][] = [...interactiveSegState.clicks]
if (isRightClick(ev)) {
newClicks.push([xy.x, xy.y, 0, newClicks.length])
} else {
newClicks.push([xy.x, xy.y, 1, newClicks.length])
}
runInteractiveSeg(newClicks)
updateInteractiveSegState({ clicks: newClicks })
}
}
const onMouseDown = (ev: SyntheticEvent) => {
if (isProcessing) {
return
}
if (interactiveSegState.isInteractiveSeg) {
return
}
if (isPanning) {
return
}
if (!isOriginalLoaded) {
return
}
const canvas = context?.canvas
if (!canvas) {
return
}
if (isRightClick(ev)) {
return
}
if (isMidClick(ev)) {
setIsPanning(true)
return
}
setIsDraging(true)
handleCanvasMouseDown(mouseXY(ev))
}
const handleUndo = (keyboardEvent: KeyboardEvent | SyntheticEvent) => {
keyboardEvent.preventDefault()
undo()
}
useHotKey("meta+z,ctrl+z", handleUndo)
const handleRedo = (keyboardEvent: KeyboardEvent | SyntheticEvent) => {
keyboardEvent.preventDefault()
redo()
}
useHotKey("shift+ctrl+z,shift+meta+z", handleRedo)
useKeyPressEvent(
"Tab",
(ev) => {
ev?.preventDefault()
ev?.stopPropagation()
if (hadRunInpainting()) {
setShowOriginal(() => {
window.setTimeout(() => {
setSliderPos(100)
}, 10)
return true
})
}
},
(ev) => {
ev?.preventDefault()
ev?.stopPropagation()
if (hadRunInpainting()) {
window.setTimeout(() => {
setSliderPos(0)
}, 10)
window.setTimeout(() => {
setShowOriginal(false)
}, COMPARE_SLIDER_DURATION_MS)
}
}
)
const download = useCallback(async () => {
if (file === undefined) {
return
}
if (enableAutoSaving && renders.length > 0) {
try {
await downloadToOutput(
renders[renders.length - 1],
file.name,
file.type
)
toast({
description: "Save image success",
})
} catch (e: any) {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: e.message ? e.message : e.toString(),
})
}
return
}
// TODO: download to output directory
const name = file.name.replace(/(\.[\w\d_-]+)$/i, "_cleanup$1")
const curRender = renders[renders.length - 1]
downloadImage(curRender.currentSrc, name)
if (settings.enableDownloadMask) {
let maskFileName = file.name.replace(/(\.[\w\d_-]+)$/i, "_mask$1")
maskFileName = maskFileName.replace(/\.[^/.]+$/, ".jpg")
const maskCanvas = generateMask(imageWidth, imageHeight, lineGroups)
// Create a link
const aDownloadLink = document.createElement("a")
// Add the name of the file to the link
aDownloadLink.download = maskFileName
// Attach the data to the link
aDownloadLink.href = maskCanvas.toDataURL("image/jpeg")
// Get the code to click the download link
aDownloadLink.click()
}
}, [
file,
enableAutoSaving,
renders,
settings,
imageHeight,
imageWidth,
lineGroups,
])
useHotKey("meta+s,ctrl+s", download)
const toggleShowBrush = (newState: boolean) => {
if (newState !== showBrush && !isPanning && !isCropperExtenderResizing) {
setShowBrush(newState)
}
}
const getCursor = useCallback(() => {
if (isProcessing) {
return "default"
}
if (isPanning) {
return "grab"
}
if (showBrush) {
return "none"
}
return undefined
}, [showBrush, isPanning, isProcessing])
useHotKey(
"[",
() => {
decreaseBaseBrushSize()
},
[decreaseBaseBrushSize]
)
useHotKey(
"]",
() => {
increaseBaseBrushSize()
},
[increaseBaseBrushSize]
)
// Manual Inpainting Hotkey
useHotKey(
"shift+r",
() => {
if (runMannually && hadDrawSomething()) {
runInpainting()
}
},
[runMannually, runInpainting, hadDrawSomething]
)
useHotKey(
"ctrl+c,meta+c",
async () => {
const hasPermission = await askWritePermission()
if (hasPermission && renders.length > 0) {
if (context?.canvas) {
await copyCanvasImage(context?.canvas)
toast({
title: "Copy inpainting result to clipboard",
})
}
}
},
[renders, context]
)
// Toggle clean/zoom tool on spacebar.
useKeyPressEvent(
" ",
(ev) => {
if (!disableShortCuts) {
ev?.preventDefault()
ev?.stopPropagation()
setShowBrush(false)
setIsPanning(true)
}
},
(ev) => {
if (!disableShortCuts) {
ev?.preventDefault()
ev?.stopPropagation()
setShowBrush(true)
setIsPanning(false)
}
}
)
useKeyPressEvent(
"Alt",
(ev) => {
if (!disableShortCuts) {
ev?.preventDefault()
ev?.stopPropagation()
setIsChangingBrushSizeByWheel(true)
}
},
(ev) => {
if (!disableShortCuts) {
ev?.preventDefault()
ev?.stopPropagation()
setIsChangingBrushSizeByWheel(false)
}
}
)
const getCurScale = (): number => {
let s = minScale
if (viewportRef.current?.instance?.transformState.scale !== undefined) {
s = viewportRef.current?.instance?.transformState.scale
}
return s!
}
const getBrushStyle = (_x: number, _y: number) => {
const curScale = getCurScale()
return {
width: `${brushSize * curScale}px`,
height: `${brushSize * curScale}px`,
left: `${_x}px`,
top: `${_y}px`,
transform: "translate(-50%, -50%)",
}
}
const renderBrush = (style: any) => {
return (
<div
className="absolute rounded-[50%] border-[1px] border-[solid] border-[#ffcc00] pointer-events-none bg-[#ffcc00bb]"
style={style}
/>
)
}
const handleSliderChange = (value: number) => {
setBaseBrushSize(value)
if (!showRefBrush) {
setShowRefBrush(true)
window.setTimeout(() => {
setShowRefBrush(false)
}, 10000)
}
}
const renderInteractiveSegCursor = () => {
return (
<div
className="absolute h-[20px] w-[20px] pointer-events-none rounded-[50%] bg-[rgba(21,_215,_121,_0.936)] [box-shadow:0_0_0_0_rgba(21,_215,_121,_0.936)] animate-pulse"
style={{
left: `${x}px`,
top: `${y}px`,
transform: "translate(-50%, -50%)",
}}
>
<CursorArrowRaysIcon />
</div>
)
}
const renderCanvas = () => {
return (
<TransformWrapper
ref={(r) => {
if (r) {
viewportRef.current = r
}
}}
panning={{ disabled: !isPanning, velocityDisabled: true }}
wheel={{ step: 0.05, wheelDisabled: isChangingBrushSizeByWheel }}
centerZoomedOut
alignmentAnimation={{ disabled: true }}
centerOnInit
limitToBounds={false}
doubleClick={{ disabled: true }}
initialScale={minScale}
minScale={minScale * 0.3}
onPanning={() => {
if (!panned) {
setPanned(true)
}
}}
onZoom={(ref) => {
setScale(ref.state.scale)
}}
>
<TransformComponent
contentStyle={{
visibility: initialCentered ? "visible" : "hidden",
}}
>
<div className="grid [grid-template-areas:'editor-content'] gap-y-4">
<canvas
className="[grid-area:editor-content]"
style={{
clipPath: `inset(0 ${sliderPos}% 0 0)`,
transition: `clip-path ${COMPARE_SLIDER_DURATION_MS}ms`,
}}
ref={(r) => {
if (r && !imageContext) {
const ctx = r.getContext("2d")
if (ctx) {
setImageContext(ctx)
}
}
}}
/>
<canvas
className={cn(
"[grid-area:editor-content]",
isProcessing
? "pointer-events-none animate-pulse duration-600"
: ""
)}
style={{
cursor: getCursor(),
clipPath: `inset(0 ${sliderPos}% 0 0)`,
transition: `clip-path ${COMPARE_SLIDER_DURATION_MS}ms`,
}}
onContextMenu={(e) => {
e.preventDefault()
}}
onMouseOver={() => {
toggleShowBrush(true)
setShowRefBrush(false)
}}
onFocus={() => toggleShowBrush(true)}
onMouseLeave={() => toggleShowBrush(false)}
onMouseDown={onMouseDown}
onMouseUp={onCanvasMouseUp}
onMouseMove={onMouseDrag}
ref={(r) => {
if (r && !context) {
const ctx = r.getContext("2d")
if (ctx) {
setContext(ctx)
}
}
}}
/>
<div
className="[grid-area:editor-content] pointer-events-none grid [grid-template-areas:'original-image-content']"
style={{
width: `${imageWidth}px`,
height: `${imageHeight}px`,
}}
>
{showOriginal && (
<>
<div
className="[grid-area:original-image-content] z-10 bg-primary h-full w-[6px] justify-self-end"
style={{
marginRight: `${sliderPos}%`,
transition: `margin-right ${COMPARE_SLIDER_DURATION_MS}ms`,
}}
/>
<img
className="[grid-area:original-image-content]"
src={original.src}
alt="original"
style={{
width: `${imageWidth}px`,
height: `${imageHeight}px`,
}}
/>
</>
)}
</div>
</div>
<Cropper
maxHeight={imageHeight}
maxWidth={imageWidth}
minHeight={Math.min(512, imageHeight)}
minWidth={Math.min(512, imageWidth)}
scale={getCurScale()}
show={settings.showCropper}
/>
<Extender
minHeight={Math.min(512, imageHeight)}
minWidth={Math.min(512, imageWidth)}
scale={getCurScale()}
show={settings.showExtender}
/>
{interactiveSegState.isInteractiveSeg ? (
<InteractiveSegPoints />
) : (
<></>
)}
</TransformComponent>
</TransformWrapper>
)
}
const handleScroll = (event: React.WheelEvent<HTMLDivElement>) => {
// deltaY 是垂直滚动增量,正值表示向下滚动,负值表示向上滚动
// deltaX 是水平滚动增量,正值表示向右滚动,负值表示向左滚动
if (!isChangingBrushSizeByWheel) {
return
}
const { deltaY } = event
// console.log(`水平滚动增量: ${deltaX}, 垂直滚动增量: ${deltaY}`)
if (deltaY > 0) {
increaseBaseBrushSize()
} else if (deltaY < 0) {
decreaseBaseBrushSize()
}
}
return (
<div
className="flex w-screen h-screen justify-center items-center"
aria-hidden="true"
onMouseMove={onMouseMove}
onMouseUp={onPointerUp}
onWheel={handleScroll}
>
{renderCanvas()}
{showBrush &&
!isInpainting &&
!isPanning &&
(interactiveSegState.isInteractiveSeg
? renderInteractiveSegCursor()
: renderBrush(getBrushStyle(x, y)))}
{showRefBrush && renderBrush(getBrushStyle(windowCenterX, windowCenterY))}
<div className="fixed flex bottom-5 border px-4 py-2 rounded-[3rem] gap-8 items-center justify-center backdrop-filter backdrop-blur-md bg-background/70">
<Slider
className="w-48"
defaultValue={[50]}
min={MIN_BRUSH_SIZE}
max={MAX_BRUSH_SIZE}
step={1}
tabIndex={-1}
value={[baseBrushSize]}
onValueChange={(vals) => handleSliderChange(vals[0])}
onClick={() => setShowRefBrush(false)}
/>
<div className="flex gap-2">
<IconButton
tooltip="Reset zoom & pan"
disabled={scale === minScale && panned === false}
onClick={resetZoom}
>
<Expand />
</IconButton>
<IconButton
tooltip="Undo"
onClick={handleUndo}
disabled={undoDisabled}
>
<Undo />
</IconButton>
<IconButton
tooltip="Redo"
onClick={handleRedo}
disabled={redoDisabled}
>
<Redo />
</IconButton>
<IconButton
tooltip="Show original image"
onPointerDown={(ev) => {
ev.preventDefault()
setShowOriginal(() => {
window.setTimeout(() => {
setSliderPos(100)
}, 10)
return true
})
}}
onPointerUp={() => {
window.setTimeout(() => {
// 防止快速点击 show original image 按钮时图片消失
setSliderPos(0)
}, 10)
window.setTimeout(() => {
setShowOriginal(false)
}, COMPARE_SLIDER_DURATION_MS)
}}
disabled={renders.length === 0}
>
<Eye />
</IconButton>
<IconButton
tooltip="Save Image"
disabled={!renders.length}
onClick={download}
>
<Download />
</IconButton>
{settings.enableManualInpainting &&
settings.model.model_type === "inpaint" ? (
<IconButton
tooltip="Run Inpainting"
disabled={
isProcessing || (!hadDrawSomething() && extraMasks.length === 0)
}
onClick={() => {
runInpainting()
}}
>
<Eraser />
</IconButton>
) : (
<></>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,414 @@
import { useStore } from "@/lib/states"
import { ExtenderDirection } from "@/lib/types"
import { cn } from "@/lib/utils"
import React, { useEffect, useState } from "react"
import { twMerge } from "tailwind-merge"
const DOC_MOVE_OPTS = { capture: true, passive: false }
const DRAG_HANDLE_BORDER = 2
interface EVData {
initX: number
initY: number
initHeight: number
initWidth: number
startResizeX: number
startResizeY: number
ord: string // top/right/bottom/left
}
interface Props {
scale: number
minHeight: number
minWidth: number
show: boolean
}
const clamp = (
newPos: number,
newLength: number,
oldPos: number,
minLength: number
) => {
if (newLength < minLength) {
if (newPos === oldPos) {
return [newPos, minLength]
}
return [newPos + newLength - minLength, minLength]
}
return [newPos, newLength]
}
const Extender = (props: Props) => {
const { minHeight, minWidth, scale, show } = props
const [
isInpainting,
imageHeight,
imageWdith,
isSD,
{ x, y, width, height },
setX,
setY,
setWidth,
setHeight,
extenderDirection,
isResizing,
setIsResizing,
] = useStore((state) => [
state.isInpainting,
state.imageHeight,
state.imageWidth,
state.isSD(),
state.extenderState,
state.setExtenderX,
state.setExtenderY,
state.setExtenderWidth,
state.setExtenderHeight,
state.settings.extenderDirection,
state.isCropperExtenderResizing,
state.setIsCropperExtenderResizing,
])
const [evData, setEVData] = useState<EVData>({
initX: 0,
initY: 0,
initHeight: 0,
initWidth: 0,
startResizeX: 0,
startResizeY: 0,
ord: "top",
})
const onDragFocus = () => {
// console.log("focus")
}
const clampLeftRight = (newX: number, newWidth: number) => {
return clamp(newX, newWidth, x, minWidth)
}
const clampTopBottom = (newY: number, newHeight: number) => {
return clamp(newY, newHeight, y, minHeight)
}
const onPointerMove = (e: PointerEvent) => {
if (isInpainting) {
return
}
const curX = e.clientX
const curY = e.clientY
const offsetY = Math.round((curY - evData.startResizeY) / scale)
const offsetX = Math.round((curX - evData.startResizeX) / scale)
const moveTop = () => {
const newHeight = evData.initHeight - offsetY
const newY = evData.initY + offsetY
let clampedY = newY
let clampedHeight = newHeight
if (extenderDirection === ExtenderDirection.xy) {
if (clampedY > 0) {
clampedY = 0
clampedHeight = evData.initHeight - Math.abs(evData.initY)
}
} else {
const clamped = clampTopBottom(newY, newHeight)
clampedY = clamped[0]
clampedHeight = clamped[1]
}
setHeight(clampedHeight)
setY(clampedY)
}
const moveBottom = () => {
const newHeight = evData.initHeight + offsetY
let [clampedY, clampedHeight] = clampTopBottom(evData.initY, newHeight)
if (extenderDirection === ExtenderDirection.xy) {
if (clampedHeight < Math.abs(clampedY) + imageHeight) {
clampedHeight = Math.abs(clampedY) + imageHeight
}
}
setHeight(clampedHeight)
setY(clampedY)
}
const moveLeft = () => {
const newWidth = evData.initWidth - offsetX
const newX = evData.initX + offsetX
let clampedX = newX
let clampedWidth = newWidth
if (extenderDirection === ExtenderDirection.xy) {
if (clampedX > 0) {
clampedX = 0
clampedWidth = evData.initWidth - Math.abs(evData.initX)
}
} else {
const clamped = clampLeftRight(newX, newWidth)
clampedX = clamped[0]
clampedWidth = clamped[1]
}
setWidth(clampedWidth)
setX(clampedX)
}
const moveRight = () => {
const newWidth = evData.initWidth + offsetX
let [clampedX, clampedWidth] = clampLeftRight(evData.initX, newWidth)
if (extenderDirection === ExtenderDirection.xy) {
if (clampedWidth < Math.abs(clampedX) + imageWdith) {
clampedWidth = Math.abs(clampedX) + imageWdith
}
}
setWidth(clampedWidth)
setX(clampedX)
}
if (isResizing) {
switch (evData.ord) {
case "topleft": {
moveTop()
moveLeft()
break
}
case "topright": {
moveTop()
moveRight()
break
}
case "bottomleft": {
moveBottom()
moveLeft()
break
}
case "bottomright": {
moveBottom()
moveRight()
break
}
case "top": {
moveTop()
break
}
case "right": {
moveRight()
break
}
case "bottom": {
moveBottom()
break
}
case "left": {
moveLeft()
break
}
default:
break
}
}
}
const onPointerDone = () => {
if (isResizing) {
setIsResizing(false)
}
}
useEffect(() => {
if (isResizing) {
document.addEventListener("pointermove", onPointerMove, DOC_MOVE_OPTS)
document.addEventListener("pointerup", onPointerDone, DOC_MOVE_OPTS)
document.addEventListener("pointercancel", onPointerDone, DOC_MOVE_OPTS)
return () => {
document.removeEventListener(
"pointermove",
onPointerMove,
DOC_MOVE_OPTS
)
document.removeEventListener("pointerup", onPointerDone, DOC_MOVE_OPTS)
document.removeEventListener(
"pointercancel",
onPointerDone,
DOC_MOVE_OPTS
)
}
}
}, [isResizing, width, height, evData])
const onCropPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const { ord } = (e.target as HTMLElement).dataset
if (ord) {
setIsResizing(true)
setEVData({
initX: x,
initY: y,
initHeight: height,
initWidth: width,
startResizeX: e.clientX,
startResizeY: e.clientY,
ord,
})
}
}
const createDragHandle = (cursor: string, side1: string, side2: string) => {
const sideLength = 12
const halfSideLength = sideLength / 2
const draghandleCls = `w-[${sideLength}px] h-[${sideLength}px] z-[4] absolute content-[''] block border-2 border-primary borde pointer-events-auto hover:bg-primary`
let xTrans = "0"
let yTrans = "0"
let side2Key = side2
let side2Val = `${-halfSideLength}px`
if (side2 === "") {
side2Val = "50%"
if (side1 === "left" || side1 === "right") {
side2Key = "top"
yTrans = "-50%"
} else {
side2Key = "left"
xTrans = "-50%"
}
}
return (
<div
className={cn(draghandleCls, cursor)}
style={{
[side1]: -halfSideLength,
[side2Key]: side2Val,
transform: `translate(${xTrans}, ${yTrans}) scale(${1 / scale})`,
}}
data-ord={side1 + side2}
aria-label={side1 + side2}
tabIndex={-1}
role="button"
/>
)
}
const createCropSelection = () => {
return (
<div
onFocus={onDragFocus}
onPointerDown={onCropPointerDown}
className="absolute top-0 h-full w-full"
>
{[ExtenderDirection.y, ExtenderDirection.xy].includes(
extenderDirection
) ? (
<>
<div
className="absolute pointer-events-auto top-0 left-0 w-full cursor-ns-resize h-[12px] mt-[-6px]"
data-ord="top"
/>
<div
className="absolute pointer-events-auto bottom-0 left-0 w-full cursor-ns-resize h-[12px] mb-[-6px]"
data-ord="bottom"
/>
{createDragHandle("cursor-ns-resize", "top", "")}
{createDragHandle("cursor-ns-resize", "bottom", "")}
</>
) : (
<></>
)}
{[ExtenderDirection.x, ExtenderDirection.xy].includes(
extenderDirection
) ? (
<>
<div
className="absolute pointer-events-auto top-0 right-0 h-full cursor-ew-resize w-[12px] mr-[-6px]"
data-ord="right"
/>
<div
className="absolute pointer-events-auto top-0 left-0 h-full cursor-ew-resize w-[12px] ml-[-6px]"
data-ord="left"
/>
{createDragHandle("cursor-ew-resize", "left", "")}
{createDragHandle("cursor-ew-resize", "right", "")}
</>
) : (
<></>
)}
{extenderDirection === ExtenderDirection.xy ? (
<>
{createDragHandle("cursor-nw-resize", "top", "left")}
{createDragHandle("cursor-ne-resize", "top", "right")}
{createDragHandle("cursor-sw-resize", "bottom", "left")}
{createDragHandle("cursor-se-resize", "bottom", "right")}
</>
) : (
<></>
)}
</div>
)
}
const onInfoBarPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
setEVData({
initX: x,
initY: y,
initHeight: height,
initWidth: width,
startResizeX: e.clientX,
startResizeY: e.clientY,
ord: "",
})
}
const createInfoBar = () => {
return (
<div
className={twMerge(
"border absolute pointer-events-auto px-2 py-1 rounded-full bg-background",
"origin-top-left top-0 left-0"
)}
style={{
transform: `scale(${(1 / scale) * 0.8})`,
}}
onPointerDown={onInfoBarPointerDown}
>
{/* TODO: 移动的时候会显示 brush */}
{width} x {height}
</div>
)
}
const createBorder = () => {
return (
<div
className={cn("outline-dashed outline-primary")}
style={{
height,
width,
outlineWidth: `${(DRAG_HANDLE_BORDER / scale) * 1.3}px`,
}}
/>
)
}
if (show === false || !isSD) {
return null
}
return (
<div className="absolute h-full w-full pointer-events-none z-[2]">
<div
className="relative pointer-events-none z-[2] [box-shadow:0_0_0_9999px_rgba(0,_0,_0,_0.5)]"
style={{ height, width, left: x, top: y }}
>
{createBorder()}
{createInfoBar()}
{createCropSelection()}
</div>
</div>
)
}
export default Extender

View File

@ -0,0 +1,343 @@
import {
SyntheticEvent,
useEffect,
useState,
useCallback,
useRef,
FormEvent,
} from "react"
import _ from "lodash"
import PhotoAlbum from "react-photo-album"
import { BarsArrowDownIcon, BarsArrowUpIcon } from "@heroicons/react/24/outline"
import {
MagnifyingGlassIcon,
ViewHorizontalIcon,
ViewGridIcon,
} from "@radix-ui/react-icons"
import { useToggle } from "react-use"
import { useDebounce } from "@uidotdev/usehooks"
import Fuse from "fuse.js"
import { useToast } from "@/components/ui/use-toast"
import { API_ENDPOINT, getMedias } from "@/lib/api"
import { IconButton } from "./ui/button"
import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select"
import { ScrollArea } from "./ui/scroll-area"
import { DialogTrigger } from "@radix-ui/react-dialog"
import { useStore } from "@/lib/states"
import { Filename, SortBy, SortOrder } from "@/lib/types"
import { FolderClosed } from "lucide-react"
import useHotKey from "@/hooks/useHotkey"
interface Photo {
src: string
height: number
width: number
name: string
}
const SORT_BY_NAME = "Name"
const SORT_BY_CREATED_TIME = "Created time"
const SORT_BY_MODIFIED_TIME = "Modified time"
const IMAGE_TAB = "input"
const OUTPUT_TAB = "output"
const SortByMap = {
[SortBy.NAME]: SORT_BY_NAME,
[SortBy.CTIME]: SORT_BY_CREATED_TIME,
[SortBy.MTIME]: SORT_BY_MODIFIED_TIME,
}
interface Props {
onPhotoClick(tab: string, filename: string): void
photoWidth: number
}
export default function FileManager(props: Props) {
const { onPhotoClick, photoWidth } = props
const [open, toggleOpen] = useToggle(false)
const [fileManagerState, updateFileManagerState] = useStore((state) => [
state.fileManagerState,
state.updateFileManagerState,
])
const { toast } = useToast()
const [scrollTop, setScrollTop] = useState(0)
const [closeScrollTop, setCloseScrollTop] = useState(0)
const ref = useRef(null)
const debouncedSearchText = useDebounce(fileManagerState.searchText, 300)
const [tab, setTab] = useState(IMAGE_TAB)
const [filenames, setFilenames] = useState<Filename[]>([])
const [photos, setPhotos] = useState<Photo[]>([])
const [photoIndex, setPhotoIndex] = useState(0)
useHotKey("f", () => {
toggleOpen()
})
useHotKey(
"left",
() => {
let newIndex = photoIndex
if (photoIndex > 0) {
newIndex = photoIndex - 1
}
setPhotoIndex(newIndex)
onPhotoClick(tab, photos[newIndex].name)
},
[photoIndex, photos]
)
useHotKey(
"right",
() => {
let newIndex = photoIndex
if (photoIndex < photos.length - 1) {
newIndex = photoIndex + 1
}
setPhotoIndex(newIndex)
onPhotoClick(tab, photos[newIndex].name)
},
[photoIndex, photos]
)
useEffect(() => {
if (!open) {
setCloseScrollTop(scrollTop)
}
}, [open, scrollTop])
const onRefChange = useCallback(
(node: HTMLDivElement) => {
if (node !== null) {
if (open) {
setTimeout(() => {
// TODO: without timeout, scrollTo not work, why?
node.scrollTo({ top: closeScrollTop, left: 0 })
}, 100)
}
}
},
[open, closeScrollTop]
)
useEffect(() => {
const fetchData = async () => {
try {
const filenames = await getMedias(tab)
setFilenames(filenames)
} catch (e: any) {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: e.message ? e.message : e.toString(),
})
}
}
fetchData()
}, [tab])
useEffect(() => {
if (!open) {
return
}
const fetchData = async () => {
try {
let filteredFilenames = filenames
if (debouncedSearchText) {
const fuse = new Fuse(filteredFilenames, {
keys: ["name"],
})
const items = fuse.search(debouncedSearchText)
filteredFilenames = items.map(
(item) => filteredFilenames[item.refIndex]
)
}
filteredFilenames = _.orderBy(
filteredFilenames,
fileManagerState.sortBy,
fileManagerState.sortOrder
)
const newPhotos = filteredFilenames.map((filename: Filename) => {
const width = photoWidth
const height = filename.height * (width / filename.width)
const src = `${API_ENDPOINT}/media_thumbnail_file?tab=${tab}&filename=${encodeURIComponent(
filename.name
)}&width=${Math.ceil(width)}&height=${Math.ceil(height)}`
return { src, height, width, name: filename.name }
})
setPhotos(newPhotos)
} catch (e: any) {
toast({
variant: "destructive",
title: "Uh oh! Something went wrong.",
description: e.message ? e.message : e.toString(),
})
}
}
fetchData()
}, [filenames, debouncedSearchText, fileManagerState, photoWidth, open])
const onScroll = (event: SyntheticEvent) => {
setScrollTop(event.currentTarget.scrollTop)
}
const onClick = ({ index }: { index: number }) => {
toggleOpen()
setPhotoIndex(index)
onPhotoClick(tab, photos[index].name)
}
const renderTitle = () => {
return (
<div className="flex justify-start items-center gap-[12px]">
<div>{`Images (${photos.length})`}</div>
<div className="flex">
<IconButton
tooltip="Rows layout"
onClick={() => {
updateFileManagerState({ layout: "rows" })
}}
>
<ViewHorizontalIcon
className={fileManagerState.layout !== "rows" ? "opacity-50" : ""}
/>
</IconButton>
<IconButton
tooltip="Grid layout"
onClick={() => {
updateFileManagerState({ layout: "masonry" })
}}
>
<ViewGridIcon
className={
fileManagerState.layout !== "masonry" ? "opacity-50" : ""
}
/>
</IconButton>
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={toggleOpen}>
<DialogTrigger asChild>
<IconButton tooltip="File Manager">
<FolderClosed />
</IconButton>
</DialogTrigger>
<DialogContent className="h-4/5 max-w-6xl">
<DialogTitle>{renderTitle()}</DialogTitle>
<div className="flex justify-between gap-8 items-center">
<div className="flex relative justify-start items-center">
<MagnifyingGlassIcon className="absolute left-[8px]" />
<Input
ref={ref}
value={fileManagerState.searchText}
className="w-[250px] pl-[30px]"
tabIndex={-1}
onInput={(evt: FormEvent<HTMLInputElement>) => {
evt.preventDefault()
evt.stopPropagation()
const target = evt.target as HTMLInputElement
updateFileManagerState({ searchText: target.value })
}}
placeholder="Search by file name"
/>
</div>
<Tabs defaultValue={tab} onValueChange={(val) => setTab(val)}>
<TabsList aria-label="Manage your account">
<TabsTrigger value={IMAGE_TAB}>Image Directory</TabsTrigger>
<TabsTrigger value={OUTPUT_TAB}>Output Directory</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex gap-2">
<div className="flex gap-1">
<Select
value={SortByMap[fileManagerState.sortBy]}
onValueChange={(val) => {
switch (val) {
case SORT_BY_NAME:
updateFileManagerState({ sortBy: SortBy.NAME })
break
case SORT_BY_CREATED_TIME:
updateFileManagerState({ sortBy: SortBy.CTIME })
break
case SORT_BY_MODIFIED_TIME:
updateFileManagerState({ sortBy: SortBy.MTIME })
break
default:
break
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(SortByMap).map((val) => {
return (
<SelectItem value={val} key={val}>
{val}
</SelectItem>
)
})}
</SelectContent>
</Select>
{fileManagerState.sortOrder === SortOrder.DESCENDING ? (
<IconButton
tooltip="Descending Order"
onClick={() => {
updateFileManagerState({ sortOrder: SortOrder.ASCENDING })
}}
>
<BarsArrowDownIcon />
</IconButton>
) : (
<IconButton
tooltip="Ascending Order"
onClick={() => {
updateFileManagerState({ sortOrder: SortOrder.DESCENDING })
}}
>
<BarsArrowUpIcon />
</IconButton>
)}
</div>
</div>
</div>
<ScrollArea
className="w-full h-full rounded-md"
onScroll={onScroll}
ref={onRefChange}
>
<PhotoAlbum
layout={fileManagerState.layout}
photos={photos}
spacing={12}
padding={0}
onClick={onClick}
/>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,71 @@
import { useState } from "react"
import useResolution from "@/hooks/useResolution"
type FileSelectProps = {
onSelection: (file: File) => void
}
export default function FileSelect(props: FileSelectProps) {
const { onSelection } = props
const [uploadElemId] = useState(`file-upload-${Math.random().toString()}`)
const resolution = useResolution()
function onFileSelected(file: File) {
if (!file) {
return
}
// Skip non-image files
const isImage = file.type.match("image.*")
if (!isImage) {
return
}
try {
// Check if file is larger than 20mb
if (file.size > 20 * 1024 * 1024) {
throw new Error("file too large")
}
onSelection(file)
} catch (e) {
// eslint-disable-next-line
alert(`error: ${(e as any).message}`)
}
}
return (
<div className="absolute flex w-screen h-screen justify-center items-center pointer-events-none">
<label
htmlFor={uploadElemId}
className="grid bg-background border-[2px] border-[dashed] rounded-lg min-w-[600px] hover:bg-primary hover:text-primary-foreground pointer-events-auto"
>
<div
className="grid p-16 w-full h-full"
onDragOver={(ev) => {
ev.stopPropagation()
ev.preventDefault()
}}
>
<input
className="hidden"
id={uploadElemId}
name={uploadElemId}
type="file"
onChange={(ev) => {
const file = ev.currentTarget.files?.[0]
if (file) {
onFileSelected(file)
}
}}
accept="image/png, image/jpeg"
/>
<p className="text-center">
{resolution === "desktop"
? "Click here or drag an image file"
: "Tap here to load your picture"}
</p>
</div>
</label>
</div>
)
}

View File

@ -0,0 +1,198 @@
import { PlayIcon } from "@radix-ui/react-icons"
import { useState } from "react"
import { IconButton, ImageUploadButton } from "@/components/ui/button"
import Shortcuts from "@/components/Shortcuts"
import { useImage } from "@/hooks/useImage"
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"
import PromptInput from "./PromptInput"
import { RotateCw, Image, Upload } from "lucide-react"
import FileManager from "./FileManager"
import { getMediaFile } from "@/lib/api"
import { useStore } from "@/lib/states"
import SettingsDialog from "./Settings"
import { cn, fileToImage } from "@/lib/utils"
import Coffee from "./Coffee"
import { useToast } from "./ui/use-toast"
const Header = () => {
const [
file,
customMask,
isInpainting,
serverConfig,
runMannually,
enableUploadMask,
model,
setFile,
setCustomFile,
runInpainting,
showPrevMask,
hidePrevMask,
imageHeight,
imageWidth,
] = useStore((state) => [
state.file,
state.customMask,
state.isInpainting,
state.serverConfig,
state.runMannually(),
state.settings.enableUploadMask,
state.settings.model,
state.setFile,
state.setCustomFile,
state.runInpainting,
state.showPrevMask,
state.hidePrevMask,
state.imageHeight,
state.imageWidth,
])
const { toast } = useToast()
const [maskImage, maskImageLoaded] = useImage(customMask)
const [openMaskPopover, setOpenMaskPopover] = useState(false)
const handleRerunLastMask = () => {
runInpainting()
}
const onRerunMouseEnter = () => {
showPrevMask()
}
const onRerunMouseLeave = () => {
hidePrevMask()
}
return (
<header className="h-[60px] px-6 py-4 absolute top-[0] flex justify-between items-center w-full z-20 border-b backdrop-filter backdrop-blur-md bg-background/70">
<div className="flex items-center gap-1">
{serverConfig.enableFileManager ? (
<FileManager
photoWidth={512}
onPhotoClick={async (tab: string, filename: string) => {
try {
const newFile = await getMediaFile(tab, filename)
setFile(newFile)
} catch (e: any) {
toast({
variant: "destructive",
description: e.message ? e.message : e.toString(),
})
return
}
}}
/>
) : (
<></>
)}
<ImageUploadButton
disabled={isInpainting}
tooltip="Upload image"
onFileUpload={(file) => {
setFile(file)
}}
>
<Image />
</ImageUploadButton>
<div
className={cn([
"flex items-center gap-1",
file && enableUploadMask ? "visible" : "hidden",
])}
>
<ImageUploadButton
disabled={isInpainting}
tooltip="Upload custom mask"
onFileUpload={async (file) => {
let newCustomMask: HTMLImageElement | null = null
try {
newCustomMask = await fileToImage(file)
} catch (e: any) {
toast({
variant: "destructive",
description: e.message ? e.message : e.toString(),
})
return
}
if (
newCustomMask.naturalHeight !== imageHeight ||
newCustomMask.naturalWidth !== imageWidth
) {
toast({
variant: "destructive",
description: `The size of the mask must same as image: ${imageWidth}x${imageHeight}`,
})
return
}
setCustomFile(file)
if (!runMannually) {
runInpainting()
}
}}
>
<Upload />
</ImageUploadButton>
{customMask ? (
<Popover open={openMaskPopover}>
<PopoverTrigger
className="btn-primary side-panel-trigger"
onMouseEnter={() => setOpenMaskPopover(true)}
onMouseLeave={() => setOpenMaskPopover(false)}
style={{
visibility: customMask ? "visible" : "hidden",
outline: "none",
}}
onClick={() => {
if (customMask) {
}
}}
>
<IconButton tooltip="Run custom mask">
<PlayIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent>
{maskImageLoaded ? (
<img src={maskImage.src} alt="Custom mask" />
) : (
<></>
)}
</PopoverContent>
</Popover>
) : (
<></>
)}
</div>
{file && !model.need_prompt ? (
<IconButton
disabled={isInpainting}
tooltip="Rerun previous mask"
onClick={handleRerunLastMask}
onMouseEnter={onRerunMouseEnter}
onMouseLeave={onRerunMouseLeave}
>
<RotateCw />
</IconButton>
) : (
<></>
)}
</div>
{model.need_prompt ? <PromptInput /> : <></>}
<div className="flex gap-1">
<Coffee />
<Shortcuts />
{serverConfig.disableModelSwitch ? <></> : <SettingsDialog />}
</div>
</header>
)
}
export default Header

View File

@ -0,0 +1,20 @@
import { useStore } from "@/lib/states"
const ImageSize = () => {
const [imageWidth, imageHeight] = useStore((state) => [
state.imageWidth,
state.imageHeight,
])
if (!imageWidth || !imageHeight) {
return null
}
return (
<div className="border rounded-lg px-2 py-[6px] z-10 bg-background">
{imageWidth}x{imageHeight}
</div>
)
}
export default ImageSize

View File

@ -0,0 +1,130 @@
import { useStore } from "@/lib/states"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
interface InteractiveSegReplaceModal {
show: boolean
onClose: () => void
onCleanClick: () => void
onReplaceClick: () => void
}
const InteractiveSegReplaceModal = (props: InteractiveSegReplaceModal) => {
const { show, onClose, onCleanClick, onReplaceClick } = props
const onOpenChange = (open: boolean) => {
if (!open) {
onClose()
}
}
return (
<Dialog open={show} onOpenChange={onOpenChange}>
<DialogContent>
<DialogTitle>Do you want to remove it or create a new one?</DialogTitle>
<div className="flex gap-[12px] w-full justify-end items-center">
<Button
onClick={() => {
onClose()
onCleanClick()
}}
>
Remove
</Button>
<Button onClick={onReplaceClick}>Create new</Button>
</div>
</DialogContent>
</Dialog>
)
}
const InteractiveSegConfirmActions = () => {
const [
interactiveSegState,
resetInteractiveSegState,
handleInteractiveSegAccept,
] = useStore((state) => [
state.interactiveSegState,
state.resetInteractiveSegState,
state.handleInteractiveSegAccept,
])
if (!interactiveSegState.isInteractiveSeg) {
return null
}
return (
<div className="z-10 absolute top-[68px] rounded-xl border-solid border p-[8px] left-1/2 translate-x-[-50%] flex justify-center items-center gap-[8px] bg-background">
<Button
onClick={() => {
resetInteractiveSegState()
}}
size="sm"
variant="secondary"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
handleInteractiveSegAccept()
}}
>
Accept
</Button>
</div>
)
}
interface ItemProps {
x: number
y: number
positive: boolean
}
const Item = (props: ItemProps) => {
const { x, y, positive } = props
const name = positive
? "bg-[rgba(21,_215,_121,_0.936)] outline-[rgba(98,255,179,0.31)]"
: "bg-[rgba(237,_49,_55,_0.942)] outline-[rgba(255,89,95,0.31)]"
return (
<div
className={`absolute h-[10px] w-[10px] rounded-[50%] ${name} outline-8 outline`}
style={{
left: x,
top: y,
transform: "translate(-50%, -50%)",
}}
/>
)
}
const InteractiveSegPoints = () => {
const clicks = useStore((state) => state.interactiveSegState.clicks)
return (
<div className="absolute h-full w-full overflow-hidden pointer-events-none">
{clicks.map((click) => {
return (
<Item
key={click[3]}
x={click[0]}
y={click[1]}
positive={click[2] === 1}
/>
)
})}
</div>
)
}
const InteractiveSeg = () => {
return (
<div>
<InteractiveSegConfirmActions />
{/* <InteractiveSegReplaceModal /> */}
</div>
)
}
export { InteractiveSeg, InteractiveSegPoints }

View File

@ -0,0 +1,202 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { Button } from "./ui/button"
import {
Blocks,
Fullscreen,
MousePointerClick,
Slice,
Smile,
} from "lucide-react"
import { useStore } from "@/lib/states"
import { PluginInfo } from "@/lib/types"
export enum PluginName {
RemoveBG = "RemoveBG",
AnimeSeg = "AnimeSeg",
RealESRGAN = "RealESRGAN",
GFPGAN = "GFPGAN",
RestoreFormer = "RestoreFormer",
InteractiveSeg = "InteractiveSeg",
}
// TODO: get plugin config from server and using form-render??
const pluginMap = {
[PluginName.RemoveBG]: {
IconClass: Slice,
showName: "RemoveBG",
},
[PluginName.AnimeSeg]: {
IconClass: Slice,
showName: "Anime Segmentation",
},
[PluginName.RealESRGAN]: {
IconClass: Fullscreen,
showName: "RealESRGAN",
},
[PluginName.GFPGAN]: {
IconClass: Smile,
showName: "GFPGAN",
},
[PluginName.RestoreFormer]: {
IconClass: Smile,
showName: "RestoreFormer",
},
[PluginName.InteractiveSeg]: {
IconClass: MousePointerClick,
showName: "Interactive Segmentation",
},
}
const Plugins = () => {
const [
file,
plugins,
isPluginRunning,
updateInteractiveSegState,
runRenderablePlugin,
] = useStore((state) => [
state.file,
state.serverConfig.plugins,
state.isPluginRunning,
state.updateInteractiveSegState,
state.runRenderablePlugin,
])
const disabled = !file
if (plugins.length === 0) {
return null
}
const onPluginClick = (genMask: boolean, pluginName: string) => {
if (pluginName === PluginName.InteractiveSeg) {
updateInteractiveSegState({ isInteractiveSeg: true })
} else {
runRenderablePlugin(genMask, pluginName)
}
}
const renderRealESRGANPlugin = () => {
return (
<DropdownMenuSub key="RealESRGAN">
<DropdownMenuSubTrigger disabled={disabled}>
<div className="flex gap-2 items-center">
<Fullscreen />
RealESRGAN
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() =>
runRenderablePlugin(false, PluginName.RealESRGAN, { upscale: 2 })
}
>
upscale 2x
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
runRenderablePlugin(false, PluginName.RealESRGAN, { upscale: 4 })
}
>
upscale 4x
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
const renderGenImageAndMaskPlugin = (plugin: PluginInfo) => {
const { IconClass, showName } = pluginMap[plugin.name as PluginName]
return (
<DropdownMenuSub key={plugin.name}>
<DropdownMenuSubTrigger disabled={disabled}>
<div className="flex gap-2 items-center">
<IconClass className="p-1" />
{showName}
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onPluginClick(false, plugin.name)}>
Remove Background
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onPluginClick(true, plugin.name)}>
Generate Mask
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
const renderPlugins = () => {
return plugins.map((plugin: PluginInfo) => {
const { IconClass, showName } = pluginMap[plugin.name as PluginName]
if (plugin.name === PluginName.RealESRGAN) {
return renderRealESRGANPlugin()
}
if (
plugin.name === PluginName.RemoveBG ||
plugin.name === PluginName.AnimeSeg
) {
return renderGenImageAndMaskPlugin(plugin)
}
return (
<DropdownMenuItem
key={plugin.name}
onClick={() => onPluginClick(false, plugin.name)}
disabled={disabled}
>
<div className="flex gap-2 items-center">
<IconClass className="p-1" />
{showName}
</div>
</DropdownMenuItem>
)
})
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="border rounded-lg z-10 bg-background outline-none"
tabIndex={-1}
>
<Button variant="ghost" size="icon" asChild className="p-1.5">
{isPluginRunning ? (
<div role="status">
<svg
aria-hidden="true"
className="w-5 h-5 animate-spin fill-primary"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
) : (
<Blocks strokeWidth={1} />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{renderPlugins()}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default Plugins

View File

@ -0,0 +1,94 @@
import React, { FormEvent, useRef } from "react"
import { Button } from "./ui/button"
import { useStore } from "@/lib/states"
import { useClickAway, useToggle } from "react-use"
import { Textarea } from "./ui/textarea"
import { cn } from "@/lib/utils"
const PromptInput = () => {
const [
isProcessing,
prompt,
updateSettings,
runInpainting,
showPrevMask,
hidePrevMask,
] = useStore((state) => [
state.getIsProcessing(),
state.settings.prompt,
state.updateSettings,
state.runInpainting,
state.showPrevMask,
state.hidePrevMask,
])
const [showScroll, toggleShowScroll] = useToggle(false)
const ref = useRef(null)
useClickAway<MouseEvent>(ref, () => {
if (ref?.current) {
const input = ref.current as HTMLTextAreaElement
input.blur()
}
})
const handleOnInput = (evt: FormEvent<HTMLTextAreaElement>) => {
evt.preventDefault()
evt.stopPropagation()
const target = evt.target as HTMLTextAreaElement
updateSettings({ prompt: target.value })
}
const handleRepaintClick = () => {
if (!isProcessing) {
runInpainting()
}
}
const onKeyUp = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && e.ctrlKey && prompt.length !== 0) {
handleRepaintClick()
}
}
const onMouseEnter = () => {
showPrevMask()
}
const onMouseLeave = () => {
hidePrevMask()
}
return (
<div className="flex gap-4 relative w-full justify-center h-full">
<div className="absolute flex gap-4">
<Textarea
ref={ref}
placeholder="I want to repaint of..."
className={cn(
showScroll ? "focus:overflow-y-auto" : "overflow-y-hidden",
"min-h-[32px] h-[32px] overflow-x-hidden focus:h-[120px] overflow-y-hidden transition-[height] w-[500px] py-1 px-3 bg-background resize-none"
)}
style={{
scrollbarGutter: "stable",
}}
value={prompt}
onInput={handleOnInput}
onKeyUp={onKeyUp}
onTransitionEnd={toggleShowScroll}
/>
<Button
size="sm"
onClick={handleRepaintClick}
disabled={isProcessing}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
Paint
</Button>
</div>
</div>
)
}
export default PromptInput

View File

@ -0,0 +1,766 @@
import { IconButton } from "@/components/ui/button"
import { useToggle } from "@uidotdev/usehooks"
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./ui/dialog"
import { Settings } from "lucide-react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form"
import { Switch } from "./ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
import { useEffect, useState } from "react"
import { cn } from "@/lib/utils"
import { useQuery } from "@tanstack/react-query"
import { getServerConfig, switchModel, switchPluginModel } from "@/lib/api"
import { ModelInfo, PluginName } from "@/lib/types"
import { useStore } from "@/lib/states"
import { ScrollArea } from "./ui/scroll-area"
import { useToast } from "./ui/use-toast"
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
} from "./ui/alert-dialog"
import {
MODEL_TYPE_DIFFUSERS_SD,
MODEL_TYPE_DIFFUSERS_SDXL,
MODEL_TYPE_DIFFUSERS_SDXL_INPAINT,
MODEL_TYPE_DIFFUSERS_SD_INPAINT,
MODEL_TYPE_INPAINT,
MODEL_TYPE_OTHER,
} from "@/lib/const"
import useHotKey from "@/hooks/useHotkey"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select"
const formSchema = z.object({
enableFileManager: z.boolean(),
inputDirectory: z.string(),
outputDirectory: z.string(),
enableDownloadMask: z.boolean(),
enableManualInpainting: z.boolean(),
enableUploadMask: z.boolean(),
enableAutoExtractPrompt: z.boolean(),
removeBGModel: z.string(),
realesrganModel: z.string(),
interactiveSegModel: z.string(),
})
const TAB_GENERAL = "General"
const TAB_MODEL = "Model"
const TAB_PLUGINS = "Plugins"
// const TAB_FILE_MANAGER = "File Manager"
const TAB_NAMES = [TAB_MODEL, TAB_GENERAL, TAB_PLUGINS]
export function SettingsDialog() {
const [open, toggleOpen] = useToggle(false)
const [tab, setTab] = useState(TAB_MODEL)
const [
updateAppState,
settings,
updateSettings,
fileManagerState,
setAppModel,
setServerConfig,
] = useStore((state) => [
state.updateAppState,
state.settings,
state.updateSettings,
state.fileManagerState,
state.setModel,
state.setServerConfig,
])
const { toast } = useToast()
const [model, setModel] = useState<ModelInfo>(settings.model)
const [modelSwitchingTexts, setModelSwitchingTexts] = useState<string[]>([])
const openModelSwitching = modelSwitchingTexts.length > 0
useEffect(() => {
setModel(settings.model)
}, [settings.model])
const {
data: serverConfig,
status,
refetch,
} = useQuery({
queryKey: ["serverConfig"],
queryFn: getServerConfig,
})
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
enableDownloadMask: settings.enableDownloadMask,
enableManualInpainting: settings.enableManualInpainting,
enableUploadMask: settings.enableUploadMask,
enableAutoExtractPrompt: settings.enableAutoExtractPrompt,
inputDirectory: fileManagerState.inputDirectory,
outputDirectory: fileManagerState.outputDirectory,
removeBGModel: serverConfig?.removeBGModel,
realesrganModel: serverConfig?.realesrganModel,
interactiveSegModel: serverConfig?.interactiveSegModel,
},
})
useEffect(() => {
if (serverConfig) {
setServerConfig(serverConfig)
form.setValue("removeBGModel", serverConfig.removeBGModel)
form.setValue("realesrganModel", serverConfig.realesrganModel)
form.setValue("interactiveSegModel", serverConfig.interactiveSegModel)
}
}, [form, serverConfig])
async function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values. ✅ This will be type-safe and validated.
updateSettings({
enableDownloadMask: values.enableDownloadMask,
enableManualInpainting: values.enableManualInpainting,
enableUploadMask: values.enableUploadMask,
enableAutoExtractPrompt: values.enableAutoExtractPrompt,
})
// TODO: validate input/output Directory
// updateFileManagerState({
// inputDirectory: values.inputDirectory,
// outputDirectory: values.outputDirectory,
// })
const shouldSwitchModel = model.name !== settings.model.name
const shouldSwitchRemoveBGModel =
serverConfig?.removeBGModel !== values.removeBGModel && removeBGEnabled
const shouldSwitchRealesrganModel =
serverConfig?.realesrganModel !== values.realesrganModel &&
realesrganEnabled
const shouldSwitchInteractiveModel =
serverConfig?.interactiveSegModel !== values.interactiveSegModel &&
interactiveSegEnabled
const showModelSwitching =
shouldSwitchModel ||
shouldSwitchRemoveBGModel ||
shouldSwitchRealesrganModel ||
shouldSwitchInteractiveModel
if (showModelSwitching) {
const newModelSwitchingTexts: string[] = []
if (shouldSwitchModel) {
newModelSwitchingTexts.push(
`Switching model from ${settings.model.name} to ${model.name}`
)
}
if (shouldSwitchRemoveBGModel) {
newModelSwitchingTexts.push(
`Switching RemoveBG model from ${serverConfig?.removeBGModel} to ${values.removeBGModel}`
)
}
if (shouldSwitchRealesrganModel) {
newModelSwitchingTexts.push(
`Switching RealESRGAN model from ${serverConfig?.realesrganModel} to ${values.realesrganModel}`
)
}
if (shouldSwitchInteractiveModel) {
newModelSwitchingTexts.push(
`Switching ${PluginName.InteractiveSeg} model from ${serverConfig?.interactiveSegModel} to ${values.interactiveSegModel}`
)
}
setModelSwitchingTexts(newModelSwitchingTexts)
updateAppState({ disableShortCuts: true })
if (shouldSwitchModel) {
try {
const newModel = await switchModel(model.name)
toast({
title: `Switch to ${newModel.name} success`,
})
setAppModel(model)
} catch (error: any) {
toast({
variant: "destructive",
title: `Switch to ${model.name} failed: ${error}`,
})
setModel(settings.model)
}
}
if (shouldSwitchRemoveBGModel) {
try {
const res = await switchPluginModel(
PluginName.RemoveBG,
values.removeBGModel
)
if (res.status !== 200) {
throw new Error(res.statusText)
}
} catch (error: any) {
toast({
variant: "destructive",
title: `Switch RemoveBG model to ${values.removeBGModel} failed: ${error}`,
})
}
}
if (shouldSwitchRealesrganModel) {
try {
const res = await switchPluginModel(
PluginName.RealESRGAN,
values.realesrganModel
)
if (res.status !== 200) {
throw new Error(res.statusText)
}
} catch (error: any) {
toast({
variant: "destructive",
title: `Switch RealESRGAN model to ${values.realesrganModel} failed: ${error}`,
})
}
}
if (shouldSwitchInteractiveModel) {
try {
const res = await switchPluginModel(
PluginName.InteractiveSeg,
values.interactiveSegModel
)
if (res.status !== 200) {
throw new Error(res.statusText)
}
} catch (error: any) {
toast({
variant: "destructive",
title: `Switch ${PluginName.InteractiveSeg} model to ${values.interactiveSegModel} failed: ${error}`,
})
}
}
setModelSwitchingTexts([])
updateAppState({ disableShortCuts: false })
refetch()
}
}
useHotKey(
"s",
() => {
toggleOpen()
if (open) {
onSubmit(form.getValues())
}
},
[open, form, model, serverConfig]
)
if (status !== "success") {
return <></>
}
const modelInfos = serverConfig.modelInfos
const plugins = serverConfig.plugins
const removeBGEnabled = plugins.some(
(plugin) => plugin.name === PluginName.RemoveBG
)
const realesrganEnabled = plugins.some(
(plugin) => plugin.name === PluginName.RealESRGAN
)
const interactiveSegEnabled = plugins.some(
(plugin) => plugin.name === PluginName.InteractiveSeg
)
function onOpenChange(value: boolean) {
toggleOpen()
if (!value) {
onSubmit(form.getValues())
}
}
function onModelSelect(info: ModelInfo) {
setModel(info)
}
function renderModelList(model_types: string[]) {
if (!modelInfos) {
return <div>Please download model first</div>
}
return modelInfos
.filter((info) => model_types.includes(info.model_type))
.map((info: ModelInfo) => {
return (
<div
key={info.name}
onClick={() => onModelSelect(info)}
className="px-2"
>
<div
className={cn([
info.name === model.name ? "bg-muted" : "hover:bg-muted",
"rounded-md px-2 py-2",
"cursor-default",
])}
>
<div className="text-base">{info.name}</div>
</div>
<Separator className="my-1" />
</div>
)
})
}
function renderModelSettings() {
let defaultTab = MODEL_TYPE_INPAINT
for (let info of modelInfos) {
if (model.name === info.name) {
defaultTab = info.model_type
if (defaultTab === MODEL_TYPE_DIFFUSERS_SDXL) {
defaultTab = MODEL_TYPE_DIFFUSERS_SD
}
if (defaultTab === MODEL_TYPE_DIFFUSERS_SDXL_INPAINT) {
defaultTab = MODEL_TYPE_DIFFUSERS_SD_INPAINT
}
break
}
}
return (
<div className="flex flex-col gap-4 w-[510px]">
<div className="flex flex-col gap-4 rounded-md">
<div className="font-medium">Current Model</div>
<div>{model.name}</div>
</div>
<Separator />
<div className="space-y-4 rounded-md">
<div className="flex gap-1 items-center justify-start">
<div className="font-medium">Available models</div>
{/* <IconButton tooltip="How to download new model">
<Info size={20} strokeWidth={2} className="opacity-50" />
</IconButton> */}
</div>
<Tabs defaultValue={defaultTab}>
<TabsList>
<TabsTrigger value={MODEL_TYPE_INPAINT}>Inpaint</TabsTrigger>
<TabsTrigger value={MODEL_TYPE_DIFFUSERS_SD}>
Stable Diffusion
</TabsTrigger>
<TabsTrigger value={MODEL_TYPE_DIFFUSERS_SD_INPAINT}>
Stable Diffusion Inpaint
</TabsTrigger>
<TabsTrigger value={MODEL_TYPE_OTHER}>
Other Diffusion
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[240px] w-full mt-2 outline-none border rounded-lg">
<TabsContent value={MODEL_TYPE_INPAINT}>
{renderModelList([MODEL_TYPE_INPAINT])}
</TabsContent>
<TabsContent value={MODEL_TYPE_DIFFUSERS_SD}>
{renderModelList([
MODEL_TYPE_DIFFUSERS_SD,
MODEL_TYPE_DIFFUSERS_SDXL,
])}
</TabsContent>
<TabsContent value={MODEL_TYPE_DIFFUSERS_SD_INPAINT}>
{renderModelList([
MODEL_TYPE_DIFFUSERS_SD_INPAINT,
MODEL_TYPE_DIFFUSERS_SDXL_INPAINT,
])}
</TabsContent>
<TabsContent value={MODEL_TYPE_OTHER}>
{renderModelList([MODEL_TYPE_OTHER])}
</TabsContent>
</ScrollArea>
</Tabs>
</div>
</div>
)
}
function renderGeneralSettings() {
return (
<div className="space-y-4 w-[510px]">
<FormField
control={form.control}
name="enableManualInpainting"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Enable manual inpainting</FormLabel>
<FormDescription>
For erase model, click a button to trigger inpainting after
draw mask.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="enableDownloadMask"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Enable download mask</FormLabel>
<FormDescription>
Also download the mask after save the inpainting result.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="enableAutoExtractPrompt"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Enable auto extract prompt</FormLabel>
<FormDescription>
Automatically extract prompt/negativate prompt from the image
meta.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="enableUploadMask"
render={({ field }) => (
<FormItem className="flex tems-center justify-between">
<div className="space-y-0.5">
<FormLabel>Enable upload mask</FormLabel>
<FormDescription>
Enable upload custom mask to perform inpainting.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator /> */}
</div>
)
}
function renderPluginsSettings() {
return (
<div className="space-y-4 w-[510px]">
<FormField
control={form.control}
name="removeBGModel"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Remove Background</FormLabel>
<FormDescription>Remove background model</FormDescription>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={!removeBGEnabled}
>
<FormControl>
<SelectTrigger className="w-auto">
<SelectValue placeholder="Select removebg model" />
</SelectTrigger>
</FormControl>
<SelectContent align="end">
<SelectGroup>
{serverConfig?.removeBGModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="realesrganModel"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>RealESRGAN</FormLabel>
<FormDescription>RealESRGAN Model</FormDescription>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={!realesrganEnabled}
>
<FormControl>
<SelectTrigger className="w-auto">
<SelectValue placeholder="Select RealESRGAN model" />
</SelectTrigger>
</FormControl>
<SelectContent align="end">
<SelectGroup>
{serverConfig?.realesrganModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="interactiveSegModel"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<div className="space-y-0.5">
<FormLabel>Interactive Segmentation</FormLabel>
<FormDescription>
Interactive Segmentation Model
</FormDescription>
</div>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={!interactiveSegEnabled}
>
<FormControl>
<SelectTrigger className="w-auto">
<SelectValue placeholder="Select interactive segmentation model" />
</SelectTrigger>
</FormControl>
<SelectContent align="end">
<SelectGroup>
{serverConfig?.interactiveSegModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
</div>
)
}
// function renderFileManagerSettings() {
// return (
// <div className="flex flex-col justify-between rounded-lg gap-4 w-[400px]">
// <FormField
// control={form.control}
// name="enableFileManager"
// render={({ field }) => (
// <FormItem className="flex items-center justify-between gap-4">
// <div className="space-y-0.5">
// <FormLabel>Enable file manger</FormLabel>
// <FormDescription className="max-w-sm">
// Browser images
// </FormDescription>
// </div>
// <FormControl>
// <Switch
// checked={field.value}
// onCheckedChange={field.onChange}
// />
// </FormControl>
// </FormItem>
// )}
// />
// <Separator />
// <FormField
// control={form.control}
// name="inputDirectory"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Input directory</FormLabel>
// <FormControl>
// <Input placeholder="" {...field} />
// </FormControl>
// <FormDescription>
// Browser images from this directory.
// </FormDescription>
// <FormMessage />
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="outputDirectory"
// render={({ field }) => (
// <FormItem>
// <FormLabel>Save directory</FormLabel>
// <FormControl>
// <Input placeholder="" {...field} />
// </FormControl>
// <FormDescription>
// Result images will be saved to this directory.
// </FormDescription>
// <FormMessage />
// </FormItem>
// )}
// />
// </div>
// )
// }
return (
<>
<AlertDialog open={openModelSwitching}>
<AlertDialogContent>
<AlertDialogHeader>
{/* <AlertDialogDescription> */}
<div className="flex flex-col justify-center items-center gap-4">
<div role="status">
<svg
aria-hidden="true"
className="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-primary"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
{modelSwitchingTexts ? (
<div className="flex flex-col">
{modelSwitchingTexts.map((text, index) => (
<div key={index}>{text}</div>
))}
</div>
) : (
<></>
)}
</div>
{/* </AlertDialogDescription> */}
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<IconButton tooltip="Settings">
<Settings />
</IconButton>
</DialogTrigger>
<DialogContent
className="max-w-3xl h-[600px]"
// onEscapeKeyDown={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
// onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogTitle>Settings</DialogTitle>
<Separator />
<div className="flex flex-row space-x-8 h-full">
<div className="flex flex-col space-y-1">
{TAB_NAMES.map((item) => (
<Button
key={item}
variant="ghost"
onClick={() => setTab(item)}
className={cn(
tab === item ? "bg-muted " : "hover:bg-muted",
"justify-start"
)}
>
{item}
</Button>
))}
</div>
<Separator orientation="vertical" />
<Form {...form}>
<div className="flex w-full justify-center">
<form onSubmit={form.handleSubmit(onSubmit)}>
{tab === TAB_MODEL ? renderModelSettings() : <></>}
{tab === TAB_GENERAL ? renderGeneralSettings() : <></>}
{tab === TAB_PLUGINS ? renderPluginsSettings() : <></>}
{/* {tab === TAB_FILE_MANAGER ? (
renderFileManagerSettings()
) : (
<></>
)} */}
<div className="absolute right-10 bottom-6">
<Button onClick={() => onOpenChange(false)}>Ok</Button>
</div>
</form>
</div>
</Form>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default SettingsDialog

View File

@ -0,0 +1,86 @@
import { Keyboard } from "lucide-react"
import { IconButton } from "@/components/ui/button"
import { useToggle } from "@uidotdev/usehooks"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog"
import useHotKey from "@/hooks/useHotkey"
interface ShortcutProps {
content: string
keys: string[]
}
function ShortCut(props: ShortcutProps) {
const { content, keys } = props
return (
<div className="flex justify-between">
<div>{content}</div>
<div className="flex gap-[8px]">
{keys.map((k) => (
// TODO: 优化快捷键显示
<div className="border px-2 py-1 rounded-lg" key={k}>
{k}
</div>
))}
</div>
</div>
)
}
const isMac = function () {
return /macintosh|mac os x/i.test(navigator.userAgent)
}
const CmdOrCtrl = () => {
return isMac() ? "Cmd" : "Ctrl"
}
export function Shortcuts() {
const [open, toggleOpen] = useToggle(false)
useHotKey("h", () => {
toggleOpen()
})
return (
<Dialog open={open} onOpenChange={toggleOpen}>
<DialogTrigger asChild>
<IconButton tooltip="Hotkeys">
<Keyboard />
</IconButton>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Hotkeys</DialogTitle>
<div className="flex gap-2 flex-col pt-4">
<ShortCut content="Pan" keys={["Space + Drag"]} />
<ShortCut content="Reset Zoom/Pan" keys={["Esc"]} />
<ShortCut content="Decrease Brush Size" keys={["["]} />
<ShortCut content="Increase Brush Size" keys={["]"]} />
<ShortCut content="View Original Image" keys={["Hold Tab"]} />
<ShortCut content="Undo" keys={[CmdOrCtrl(), "Z"]} />
<ShortCut content="Redo" keys={[CmdOrCtrl(), "Shift", "Z"]} />
<ShortCut content="Copy Result" keys={[CmdOrCtrl(), "C"]} />
<ShortCut content="Paste Image" keys={[CmdOrCtrl(), "V"]} />
<ShortCut
content="Trigger Manually Inpainting"
keys={["Shift", "R"]}
/>
<ShortCut content="Toggle Hotkeys Dialog" keys={["H"]} />
<ShortCut content="Toggle Settings Dialog" keys={["S"]} />
<ShortCut content="Toggle File Manager" keys={["F"]} />
</div>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
export default Shortcuts

View File

@ -0,0 +1,77 @@
import { useStore } from "@/lib/states"
import { LabelTitle, RowContainer } from "./LabelTitle"
import { NumberInput } from "../ui/input"
import { Slider } from "../ui/slider"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select"
import { CV2Flag } from "@/lib/types"
const CV2Options = () => {
const [settings, updateSettings] = useStore((state) => [
state.settings,
state.updateSettings,
])
return (
<div className="flex flex-col gap-4 mt-4">
<RowContainer>
<LabelTitle
text="CV2 Flag"
url="https://docs.opencv.org/4.8.0/d7/d8b/group__photo__inpaint.html#gga8002a65f5a3328fbf15df81b842d3c3ca892824c38e258feb5e72f308a358d52e"
/>
<Select
value={settings.cv2Flag as string}
onValueChange={(value) => {
const flag = value as CV2Flag
updateSettings({ cv2Flag: flag })
}}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Select flag" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{Object.values(CV2Flag).map((flag) => (
<SelectItem key={flag as string} value={flag as string}>
{flag as string}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</RowContainer>
<LabelTitle
text="CV2 Radius"
url="https://docs.opencv.org/4.8.0/d7/d8b/group__photo__inpaint.html#gga8002a65f5a3328fbf15df81b842d3c3ca892824c38e258feb5e72f308a358d52e"
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[5]}
min={1}
max={100}
step={1}
value={[Math.floor(settings.cv2Radius)]}
onValueChange={(vals) => updateSettings({ cv2Radius: vals[0] })}
/>
<NumberInput
id="cv2-radius"
className="w-[60px] rounded-full"
numberValue={settings.cv2Radius}
allowFloat={false}
onNumberValueChange={(val) => {
updateSettings({ cv2Radius: val })
}}
/>
</RowContainer>
</div>
)
}
export default CV2Options

View File

@ -0,0 +1,894 @@
import { FormEvent, useRef } from "react"
import { useStore } from "@/lib/states"
import { Switch } from "../ui/switch"
import { NumberInput } from "../ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select"
import { Textarea } from "../ui/textarea"
import { ExtenderDirection, PowerPaintTask } from "@/lib/types"
import { Separator } from "../ui/separator"
import { Button, ImageUploadButton } from "../ui/button"
import { Slider } from "../ui/slider"
import { useImage } from "@/hooks/useImage"
import {
ANYTEXT,
INSTRUCT_PIX2PIX,
PAINT_BY_EXAMPLE,
POWERPAINT,
} from "@/lib/const"
import { RowContainer, LabelTitle } from "./LabelTitle"
import { Upload } from "lucide-react"
import { useClickAway } from "react-use"
const ExtenderButton = ({
text,
onClick,
}: {
text: string
onClick: () => void
}) => {
const [showExtender] = useStore((state) => [state.settings.showExtender])
return (
<Button
variant="outline"
className="p-1 h-8"
disabled={!showExtender}
onClick={onClick}
>
<div className="flex items-center gap-1">{text}</div>
</Button>
)
}
const DiffusionOptions = () => {
const [
samplers,
settings,
paintByExampleFile,
isProcessing,
updateSettings,
runInpainting,
updateAppState,
updateExtenderByBuiltIn,
updateExtenderDirection,
adjustMask,
clearMask,
] = useStore((state) => [
state.serverConfig.samplers,
state.settings,
state.paintByExampleFile,
state.getIsProcessing(),
state.updateSettings,
state.runInpainting,
state.updateAppState,
state.updateExtenderByBuiltIn,
state.updateExtenderDirection,
state.adjustMask,
state.clearMask,
])
const [exampleImage, isExampleImageLoaded] = useImage(paintByExampleFile)
const negativePromptRef = useRef(null)
useClickAway<MouseEvent>(negativePromptRef, () => {
if (negativePromptRef?.current) {
const input = negativePromptRef.current as HTMLInputElement
input.blur()
}
})
const onKeyUp = (e: React.KeyboardEvent) => {
// negativePrompt 回车触发 inpainting
if (e.key === "Enter" && e.ctrlKey && settings.prompt.length !== 0) {
runInpainting()
}
}
const renderCropper = () => {
return (
<RowContainer>
<LabelTitle
text="Cropper"
toolTip="Inpainting on part of image, improve inference speed and reduce memory usage."
/>
<Switch
id="cropper"
checked={settings.showCropper}
onCheckedChange={(value) => {
updateSettings({ showCropper: value })
if (value) {
updateSettings({ showExtender: false })
}
}}
/>
</RowContainer>
)
}
const renderConterNetSetting = () => {
if (!settings.model.support_controlnet) {
return null
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center pr-2">
<LabelTitle
text="ControlNet"
toolTip="Using an additional conditioning image to control how an image is generated"
url="https://huggingface.co/docs/diffusers/main/en/using-diffusers/inpaint#controlnet"
/>
<Switch
id="controlnet"
checked={settings.enableControlnet}
onCheckedChange={(value) => {
updateSettings({ enableControlnet: value })
}}
/>
</div>
<div className="flex flex-col gap-1">
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[100]}
min={1}
max={100}
step={1}
disabled={!settings.enableControlnet}
value={[Math.floor(settings.controlnetConditioningScale * 100)]}
onValueChange={(vals) =>
updateSettings({ controlnetConditioningScale: vals[0] / 100 })
}
/>
<NumberInput
id="controlnet-weight"
className="w-[60px] rounded-full"
disabled={!settings.enableControlnet}
numberValue={settings.controlnetConditioningScale}
allowFloat={false}
onNumberValueChange={(val) => {
updateSettings({ controlnetConditioningScale: val })
}}
/>
</RowContainer>
</div>
<div className="pr-2">
<Select
defaultValue={settings.controlnetMethod}
value={settings.controlnetMethod}
onValueChange={(value) => {
updateSettings({ controlnetMethod: value })
}}
disabled={!settings.enableControlnet}
>
<SelectTrigger>
<SelectValue placeholder="Select control method" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{Object.values(settings.model.controlnets).map((method) => (
<SelectItem key={method} value={method}>
{method.split("/")[1]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<Separator />
</div>
)
}
const renderLCMLora = () => {
if (!settings.model.support_lcm_lora) {
return null
}
return (
<>
<RowContainer>
<LabelTitle
text="LCM LoRA"
url="https://huggingface.co/docs/diffusers/main/en/using-diffusers/inference_with_lcm_lora"
toolTip="Enable quality image generation in typically 2-4 steps. Suggest disabling guidance_scale by setting it to 0. You can also try values between 1.0 and 2.0. When LCM Lora is enabled, LCMSampler will be used automatically."
/>
<Switch
id="lcm-lora"
checked={settings.enableLCMLora}
onCheckedChange={(value) => {
updateSettings({ enableLCMLora: value })
}}
/>
</RowContainer>
<Separator />
</>
)
}
const renderFreeu = () => {
if (!settings.model.support_freeu) {
return null
}
return (
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center pr-2">
<LabelTitle
text="FreeU"
toolTip="FreeU is a technique for improving image quality. Different models may require different FreeU-specific hyperparameters, which can be viewed in the more info section."
url="https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu"
/>
<Switch
id="freeu"
checked={settings.enableFreeu}
onCheckedChange={(value) => {
updateSettings({ enableFreeu: value })
}}
/>
</div>
<div className="flex flex-col gap-4">
<div className="flex justify-center gap-6">
<div className="flex gap-2 items-center justify-center">
<LabelTitle
htmlFor="freeu-s1"
text="s1"
disabled={!settings.enableFreeu}
/>
<NumberInput
id="freeu-s1"
className="w-14"
disabled={!settings.enableFreeu}
numberValue={settings.freeuConfig.s1}
allowFloat
onNumberValueChange={(value) => {
updateSettings({
freeuConfig: { ...settings.freeuConfig, s1: value },
})
}}
/>
</div>
<div className="flex gap-2 items-center justify-center">
<LabelTitle
htmlFor="freeu-s2"
text="s2"
disabled={!settings.enableFreeu}
/>
<NumberInput
id="freeu-s2"
className="w-14"
disabled={!settings.enableFreeu}
numberValue={settings.freeuConfig.s2}
allowFloat
onNumberValueChange={(value) => {
updateSettings({
freeuConfig: { ...settings.freeuConfig, s2: value },
})
}}
/>
</div>
</div>
<div className="flex justify-center gap-6">
<div className="flex gap-2 items-center justify-center">
<LabelTitle
htmlFor="freeu-b1"
text="b1"
disabled={!settings.enableFreeu}
/>
<NumberInput
id="freeu-b1"
className="w-14"
disabled={!settings.enableFreeu}
numberValue={settings.freeuConfig.b1}
allowFloat
onNumberValueChange={(value) => {
updateSettings({
freeuConfig: { ...settings.freeuConfig, b1: value },
})
}}
/>
</div>
<div className="flex gap-2 items-center justify-center">
<LabelTitle
htmlFor="freeu-b2"
text="b2"
disabled={!settings.enableFreeu}
/>
<NumberInput
id="freeu-b2"
className="w-14"
disabled={!settings.enableFreeu}
numberValue={settings.freeuConfig.b2}
allowFloat
onNumberValueChange={(value) => {
updateSettings({
freeuConfig: { ...settings.freeuConfig, b2: value },
})
}}
/>
</div>
</div>
</div>
<Separator />
</div>
)
}
const renderNegativePrompt = () => {
if (!settings.model.need_prompt) {
return null
}
return (
<div className="flex flex-col gap-4">
<LabelTitle
text="Negative prompt"
url="https://huggingface.co/docs/diffusers/main/en/using-diffusers/inpaint#negative-prompt"
toolTip="Negative prompt guides the model away from generating certain things in an image"
/>
<div className="pl-2 pr-4">
<Textarea
ref={negativePromptRef}
rows={4}
onKeyUp={onKeyUp}
className="max-h-[8rem] overflow-y-auto mb-2"
placeholder=""
id="negative-prompt"
value={settings.negativePrompt}
onInput={(evt: FormEvent<HTMLTextAreaElement>) => {
evt.preventDefault()
evt.stopPropagation()
const target = evt.target as HTMLTextAreaElement
updateSettings({ negativePrompt: target.value })
}}
/>
</div>
</div>
)
}
const renderPaintByExample = () => {
if (settings.model.name !== PAINT_BY_EXAMPLE) {
return null
}
return (
<div>
<RowContainer>
<LabelTitle
text="Example Image"
toolTip="An example image to guide image generation."
/>
<ImageUploadButton
tooltip="Upload example image"
onFileUpload={(file) => {
updateAppState({ paintByExampleFile: file })
}}
>
<Upload />
</ImageUploadButton>
</RowContainer>
{isExampleImageLoaded ? (
<div className="flex justify-center items-center">
<img
src={exampleImage.src}
alt="example"
className="max-w-[200px] max-h-[200px] m-3"
/>
</div>
) : (
<></>
)}
<Button
variant="default"
className="w-full"
disabled={isProcessing || !isExampleImageLoaded}
onClick={() => {
runInpainting()
}}
>
Paint
</Button>
</div>
)
}
const renderP2PImageGuidanceScale = () => {
if (settings.model.name !== INSTRUCT_PIX2PIX) {
return null
}
return (
<div className="flex flex-col gap-1">
<LabelTitle
text="Image guidance scale"
toolTip="Push the generated image towards the inital image. Higher image guidance scale encourages generated images that are closely linked to the source image, usually at the expense of lower image quality."
url="https://huggingface.co/docs/diffusers/main/en/api/pipelines/pix2pix"
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[150]}
min={100}
max={1000}
step={1}
value={[Math.floor(settings.p2pImageGuidanceScale * 100)]}
onValueChange={(vals) =>
updateSettings({ p2pImageGuidanceScale: vals[0] / 100 })
}
/>
<NumberInput
id="image-guidance-scale"
className="w-[60px] rounded-full"
numberValue={settings.p2pImageGuidanceScale}
allowFloat
onNumberValueChange={(val) => {
updateSettings({ p2pImageGuidanceScale: val })
}}
/>
</RowContainer>
</div>
)
}
const renderStrength = () => {
if (!settings.model.support_strength) {
return null
}
return (
<div className="flex flex-col gap-1">
<LabelTitle
text="Strength"
url="https://huggingface.co/docs/diffusers/main/en/using-diffusers/inpaint#strength"
toolTip="Strength is a measure of how much noise is added to the base image, which influences how similar the output is to the base image. Higher value means more noise and more different from the base image"
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[100]}
min={10}
max={100}
step={1}
value={[Math.floor(settings.sdStrength * 100)]}
onValueChange={(vals) =>
updateSettings({ sdStrength: vals[0] / 100 })
}
/>
<NumberInput
id="strength"
className="w-[60px] rounded-full"
numberValue={settings.sdStrength}
allowFloat
onNumberValueChange={(val) => {
updateSettings({ sdStrength: val })
}}
/>
</RowContainer>
</div>
)
}
const renderExtender = () => {
if (!settings.model.support_outpainting) {
return null
}
return (
<>
<div className="flex flex-col gap-4">
<RowContainer>
<LabelTitle
text="Extender"
toolTip="Perform outpainting on images to expand it's content."
/>
<Switch
id="extender"
checked={settings.showExtender}
onCheckedChange={(value) => {
updateSettings({ showExtender: value })
if (value) {
updateSettings({ showCropper: false })
}
}}
/>
</RowContainer>
<RowContainer>
<Select
defaultValue={settings.extenderDirection}
value={settings.extenderDirection}
onValueChange={(value) => {
updateExtenderDirection(value as ExtenderDirection)
}}
>
<SelectTrigger
className="w-[65px] h-7"
disabled={!settings.showExtender}
>
<SelectValue placeholder="Select axis" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{Object.values(ExtenderDirection).map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="flex gap-1 justify-center mt-0">
<ExtenderButton
text="1.25x"
onClick={() =>
updateExtenderByBuiltIn(settings.extenderDirection, 1.25)
}
/>
<ExtenderButton
text="1.5x"
onClick={() =>
updateExtenderByBuiltIn(settings.extenderDirection, 1.5)
}
/>
<ExtenderButton
text="1.75x"
onClick={() =>
updateExtenderByBuiltIn(settings.extenderDirection, 1.75)
}
/>
<ExtenderButton
text="2.0x"
onClick={() =>
updateExtenderByBuiltIn(settings.extenderDirection, 2.0)
}
/>
</div>
</RowContainer>
</div>
<Separator />
</>
)
}
const renderPowerPaintTaskType = () => {
if (settings.model.name !== POWERPAINT) {
return null
}
return (
<RowContainer>
<LabelTitle
text="Task"
toolTip="PowerPaint task. When using extender, image-outpainting task will be auto used. For object-removal and image-outpainting, it is recommended to set the guidance_scale at 10 or above."
/>
<Select
defaultValue={settings.powerpaintTask}
value={settings.powerpaintTask}
onValueChange={(value: PowerPaintTask) => {
updateSettings({ powerpaintTask: value })
}}
disabled={settings.showExtender}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Select task" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{[
PowerPaintTask.text_guided,
PowerPaintTask.object_remove,
PowerPaintTask.shape_guided,
].map((task) => (
<SelectItem key={task} value={task}>
{task}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</RowContainer>
)
}
const renderSteps = () => {
return (
<div className="flex flex-col gap-1">
<LabelTitle
htmlFor="steps"
text="Steps"
toolTip="The number of denoising steps. More denoising steps usually lead to a higher quality image at the expense of slower inference."
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[30]}
min={1}
max={100}
step={1}
value={[Math.floor(settings.sdSteps)]}
onValueChange={(vals) => updateSettings({ sdSteps: vals[0] })}
/>
<NumberInput
id="steps"
className="w-[60px] rounded-full"
numberValue={settings.sdSteps}
allowFloat={false}
onNumberValueChange={(val) => {
updateSettings({ sdSteps: val })
}}
/>
</RowContainer>
</div>
)
}
const renderGuidanceScale = () => {
return (
<div className="flex flex-col gap-1">
<LabelTitle
text="Guidance scale"
url="https://huggingface.co/docs/diffusers/main/en/using-diffusers/inpaint#guidance-scale"
toolTip="Guidance scale affects how aligned the text prompt and generated image are. Higher value means the prompt and generated image are closely aligned, so the output is a stricter interpretation of the prompt"
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[750]}
min={0}
max={1500}
step={1}
value={[Math.floor(settings.sdGuidanceScale * 100)]}
onValueChange={(vals) =>
updateSettings({ sdGuidanceScale: vals[0] / 100 })
}
/>
<NumberInput
id="guidance-scale"
className="w-[60px] rounded-full"
numberValue={settings.sdGuidanceScale}
allowFloat
onNumberValueChange={(val) => {
updateSettings({ sdGuidanceScale: val })
}}
/>
</RowContainer>
</div>
)
}
const renderSampler = () => {
if (settings.model.name === ANYTEXT) {
return null
}
return (
<RowContainer>
<LabelTitle text="Sampler" />
<Select
defaultValue={settings.sdSampler}
value={settings.sdSampler}
onValueChange={(value) => {
updateSettings({ sdSampler: value })
}}
>
<SelectTrigger className="w-[175px] text-xs">
<SelectValue placeholder="Select sampler" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{samplers.map((sampler) => (
<SelectItem key={sampler} value={sampler} className="text-xs">
{sampler}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</RowContainer>
)
}
const renderSeed = () => {
return (
<RowContainer>
{/* 每次会从服务器返回更新该值 */}
<LabelTitle
text="Seed"
toolTip="Using same parameters and a fixed seed can generate same result image."
/>
{/* <Pin /> */}
<div className="flex gap-2 justify-center items-center">
<Switch
id="seed"
checked={settings.seedFixed}
onCheckedChange={(value) => {
updateSettings({ seedFixed: value })
}}
/>
<NumberInput
id="seed"
className="w-[100px]"
disabled={!settings.seedFixed}
numberValue={settings.seed}
allowFloat={false}
onNumberValueChange={(val) => {
updateSettings({ seed: val })
}}
/>
</div>
</RowContainer>
)
}
const renderMaskBlur = () => {
return (
<div className="flex flex-col gap-1">
<LabelTitle
text="Mask blur"
toolTip="How much to blur the mask before processing, in pixels. Make the generated inpainting boundaries appear more natural."
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[settings.sdMaskBlur]}
min={0}
max={96}
step={1}
value={[Math.floor(settings.sdMaskBlur)]}
onValueChange={(vals) => updateSettings({ sdMaskBlur: vals[0] })}
/>
<NumberInput
id="mask-blur"
className="w-[60px] rounded-full"
numberValue={settings.sdMaskBlur}
allowFloat={false}
onNumberValueChange={(value) => {
updateSettings({ sdMaskBlur: value })
}}
/>
</RowContainer>
</div>
)
}
const renderMatchHistograms = () => {
return (
<>
<RowContainer>
<LabelTitle
text="Match histograms"
toolTip="Match the inpainting result histogram to the source image histogram"
url="https://github.com/Sanster/lama-cleaner/pull/143#issuecomment-1325859307"
/>
<Switch
id="match-histograms"
checked={settings.sdMatchHistograms}
onCheckedChange={(value) => {
updateSettings({ sdMatchHistograms: value })
}}
/>
</RowContainer>
<Separator />
</>
)
}
const renderMaskAdjuster = () => {
return (
<>
<div className="flex flex-col gap-1">
<LabelTitle
htmlFor="adjustMaskKernelSize"
text="Adjust Mask"
toolTip="Expand or shrink mask. Using the slider to adjust the kernel size for dilation or erosion."
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[12]}
min={1}
max={100}
step={1}
value={[Math.floor(settings.adjustMaskKernelSize)]}
onValueChange={(vals) =>
updateSettings({ adjustMaskKernelSize: vals[0] })
}
/>
<NumberInput
id="adjustMaskKernelSize"
className="w-[60px] rounded-full"
numberValue={settings.adjustMaskKernelSize}
allowFloat={false}
onNumberValueChange={(val) => {
updateSettings({ adjustMaskKernelSize: val })
}}
/>
</RowContainer>
<RowContainer>
<div className="flex gap-1 justify-start">
<Button
variant="outline"
className="p-1 h-8"
onClick={() => adjustMask("expand")}
disabled={isProcessing}
>
<div className="flex items-center gap-1 select-none">
{/* <Plus size={16} /> */}
Expand
</div>
</Button>
<Button
variant="outline"
className="p-1 h-8"
onClick={() => adjustMask("shrink")}
disabled={isProcessing}
>
<div className="flex items-center gap-1 select-none">
{/* <Minus size={16} /> */}
Shrink
</div>
</Button>
<Button
variant="outline"
className="p-1 h-8"
onClick={() => adjustMask("reverse")}
disabled={isProcessing}
>
<div className="flex items-center gap-1 select-none">
Reverse
</div>
</Button>
</div>
<Button
variant="outline"
className="p-1 h-8 justify-self-end"
onClick={clearMask}
disabled={isProcessing}
>
<div className="flex items-center gap-1 select-none">Clear</div>
</Button>
</RowContainer>
</div>
<Separator />
</>
)
}
return (
<div className="flex flex-col gap-4 mt-4">
{renderCropper()}
{renderExtender()}
{renderMaskAdjuster()}
{renderPowerPaintTaskType()}
{renderSteps()}
{renderGuidanceScale()}
{renderP2PImageGuidanceScale()}
{renderStrength()}
{renderSampler()}
{renderSeed()}
{renderNegativePrompt()}
<Separator />
{renderConterNetSetting()}
{renderLCMLora()}
{renderMaskBlur()}
{renderMatchHistograms()}
{renderFreeu()}
{renderPaintByExample()}
</div>
)
}
export default DiffusionOptions

View File

@ -0,0 +1,77 @@
import { useStore } from "@/lib/states"
import { LabelTitle, RowContainer } from "./LabelTitle"
import { NumberInput } from "../ui/input"
import { Slider } from "../ui/slider"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select"
import { LDMSampler } from "@/lib/types"
const LDMOptions = () => {
const [settings, updateSettings] = useStore((state) => [
state.settings,
state.updateSettings,
])
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex flex-col gap-1">
<LabelTitle
htmlFor="steps"
text="Steps"
toolTip="The number of denoising steps. More denoising steps usually lead to a higher quality image at the expense of slower inference."
/>
<RowContainer>
<Slider
className="w-[180px]"
defaultValue={[30]}
min={1}
max={100}
step={1}
value={[Math.floor(settings.ldmSteps)]}
onValueChange={(vals) => updateSettings({ ldmSteps: vals[0] })}
/>
<NumberInput
id="steps"
className="w-[60px] rounded-full"
numberValue={settings.ldmSteps}
allowFloat={false}
onNumberValueChange={(val) => {
updateSettings({ ldmSteps: val })
}}
/>
</RowContainer>
</div>
<RowContainer>
<LabelTitle text="Sampler" />
<Select
value={settings.ldmSampler as string}
onValueChange={(value) => {
const sampler = value as LDMSampler
updateSettings({ ldmSampler: sampler })
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Select sampler" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{Object.values(LDMSampler).map((sampler) => (
<SelectItem key={sampler as string} value={sampler as string}>
{sampler as string}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</RowContainer>
</div>
)
}
export default LDMOptions

View File

@ -0,0 +1,53 @@
import { Button } from "../ui/button"
import { Label } from "../ui/label"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
const RowContainer = ({ children }: { children: React.ReactNode }) => (
<div className="flex justify-between items-center pr-2">{children}</div>
)
const LabelTitle = ({
text,
toolTip = "",
url,
htmlFor,
disabled = false,
}: {
text: string
toolTip?: string
url?: string
htmlFor?: string
disabled?: boolean
}) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Label
htmlFor={htmlFor ? htmlFor : text.toLowerCase().replace(" ", "-")}
className="font-medium"
disabled={disabled}
>
{text}
</Label>
</TooltipTrigger>
{toolTip || url ? (
<TooltipContent className="flex flex-col max-w-xs text-sm" side="left">
<p>{toolTip}</p>
{url ? (
<Button variant="link" className="justify-end">
<a href={url} target="_blank">
More info
</a>
</Button>
) : (
<></>
)}
</TooltipContent>
) : (
<></>
)}
</Tooltip>
)
}
export { LabelTitle, RowContainer }

View File

@ -0,0 +1,99 @@
import { useToggle } from "react-use"
import { useStore } from "@/lib/states"
import { Separator } from "../ui/separator"
import { ScrollArea } from "../ui/scroll-area"
import { Sheet, SheetContent, SheetHeader, SheetTrigger } from "../ui/sheet"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "../ui/button"
import useHotKey from "@/hooks/useHotkey"
import { RowContainer } from "./LabelTitle"
import { CV2, LDM, MODEL_TYPE_INPAINT } from "@/lib/const"
import LDMOptions from "./LDMOptions"
import DiffusionOptions from "./DiffusionOptions"
import CV2Options from "./CV2Options"
const SidePanel = () => {
const [settings, windowSize] = useStore((state) => [
state.settings,
state.windowSize,
])
const [open, toggleOpen] = useToggle(true)
useHotKey("c", () => {
toggleOpen()
})
if (
settings.model.name !== LDM &&
settings.model.name !== CV2 &&
settings.model.model_type === MODEL_TYPE_INPAINT
) {
return null
}
const renderSidePanelOptions = () => {
if (settings.model.name === LDM) {
return <LDMOptions />
}
if (settings.model.name === CV2) {
return <CV2Options />
}
return <DiffusionOptions />
}
return (
<Sheet open={open} modal={false}>
<SheetTrigger
tabIndex={-1}
className="z-10 outline-none absolute top-[68px] right-6 rounded-lg border bg-background"
hidden={open}
>
<Button
variant="ghost"
size="icon"
asChild
className="p-1.5"
onClick={toggleOpen}
>
<ChevronLeft strokeWidth={1} />
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="w-[300px] mt-[60px] outline-none pl-4 pr-1"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<SheetHeader>
<RowContainer>
<div className="overflow-hidden mr-8">
{
settings.model.name.split("/")[
settings.model.name.split("/").length - 1
]
}
</div>
<Button
variant="ghost"
size="icon"
className="border h-6 w-6"
onClick={toggleOpen}
>
<ChevronRight strokeWidth={1} />
</Button>
</RowContainer>
<Separator />
</SheetHeader>
<ScrollArea
style={{ height: windowSize.height - 160 }}
className="pr-3"
>
{renderSidePanelOptions()}
</ScrollArea>
</SheetContent>
</Sheet>
)
}
export default SidePanel

View File

@ -0,0 +1,39 @@
import { useEffect } from "react"
import Editor from "./Editor"
import { currentModel } from "@/lib/api"
import { useStore } from "@/lib/states"
import ImageSize from "./ImageSize"
import Plugins from "./Plugins"
import { InteractiveSeg } from "./InteractiveSeg"
import SidePanel from "./SidePanel"
import DiffusionProgress from "./DiffusionProgress"
const Workspace = () => {
const [file, updateSettings] = useStore((state) => [
state.file,
state.updateSettings,
])
useEffect(() => {
const fetchCurrentModel = async () => {
const model = await currentModel()
updateSettings({ model })
}
fetchCurrentModel()
}, [])
return (
<>
<div className="flex gap-3 absolute top-[68px] left-[24px] items-center">
<Plugins />
<ImageSize />
</div>
<InteractiveSeg />
<DiffusionProgress />
<SidePanel />
{file ? <Editor file={file} /> : <></>}
</>
)
}
export default Workspace

View File

@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"outline-none flex flex-1 items-center justify-between py-3 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,128 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Input } from "./input"
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(
buttonVariants({ variant, size, className }),
"outline-none cursor-default select-none"
)}
ref={ref}
tabIndex={-1}
{...props}
/>
)
}
)
Button.displayName = "Button"
export interface IconButtonProps extends ButtonProps {
tooltip: string
}
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
({ tooltip, children, ...rest }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
ref={ref}
tabIndex={-1}
className="cursor-default bg-background"
>
<div className="icon-button-icon-wrapper">{children}</div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
)
}
)
export interface UploadButtonProps extends IconButtonProps {
onFileUpload: (file: File) => void
}
const ImageUploadButton = (props: UploadButtonProps) => {
const { onFileUpload, children, ...rest } = props
const [uploadElemId] = React.useState(
`file-upload-${Math.random().toString()}`
)
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const newFile = ev.currentTarget.files?.[0]
if (newFile) {
onFileUpload(newFile)
}
}
return (
<>
<label htmlFor={uploadElemId}>
<IconButton {...rest} asChild>
{children}
</IconButton>
</label>
<Input
style={{ display: "none" }}
id={uploadElemId}
name={uploadElemId}
type="file"
onChange={handleChange}
accept="image/png, image/jpeg"
/>
</>
)
}
export { Button, IconButton, ImageUploadButton, buttonVariants }

View File

@ -0,0 +1,202 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,122 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 flex flex-col w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
"outline-none"
)}
onCloseAutoFocus={(event) => event.preventDefault()}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,204 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
onCloseAutoFocus={(e) => e.preventDefault()}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", "text-sm", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,82 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { useStore } from "@/lib/states"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
const updateAppState = useStore((state) => state.updateAppState)
const handleOnFocus = () => {
updateAppState({ disableShortCuts: true })
}
const handleOnBlur = () => {
updateAppState({ disableShortCuts: false })
}
return (
<input
type={type}
className={cn(
"flex h-8 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
autoComplete="off"
tabIndex={-1}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
{...props}
/>
)
}
)
Input.displayName = "Input"
export interface NumberInputProps extends InputProps {
numberValue: number
allowFloat: boolean
onNumberValueChange: (value: number) => void
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
({ numberValue, allowFloat, onNumberValueChange, ...rest }, ref) => {
const [value, setValue] = React.useState<string>(numberValue.toString())
React.useEffect(() => {
if (value !== numberValue.toString() + ".") {
setValue(numberValue.toString())
}
}, [numberValue])
const onInput = (evt: React.FormEvent<HTMLInputElement>) => {
const target = evt.target as HTMLInputElement
let val = target.value
if (allowFloat) {
val = val.replace(/[^0-9.]/g, "").replace(/(\..*?)\..*/g, "$1")
if (val.length === 0) {
onNumberValueChange(0)
return
}
// val = parseFloat(val).toFixed(2)
onNumberValueChange(parseFloat(val))
} else {
val = val.replace(/\D/g, "")
if (val.length === 0) {
onNumberValueChange(0)
return
}
onNumberValueChange(parseInt(val, 10))
}
setValue(val)
}
return <Input ref={ref} value={value} onInput={onInput} {...rest} />
}
)
export { Input, NumberInput }

View File

@ -0,0 +1,34 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
interface LabelProps {
disabled?: boolean
}
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> &
LabelProps
>(({ className, disabled, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
labelVariants(),
className,
disabled ? "opacity-50" : "",
"select-none"
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
tabIndex={-1}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import { CheckIcon } from "@radix-ui/react-icons"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CheckIcon className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,47 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
scrollHideDelay={400}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,164 @@
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
tabIndex={-1}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
onCloseAutoFocus={(event) => event.preventDefault()}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,133 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 bg-background/80 backdrop-blur-sm data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
tabIndex={-1}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50">
<SliderPrimitive.Range className="absolute h-full bg-primary data-[disabled]:cursor-not-allowed " />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
tabIndex={-1}
className="block h-4 w-4 rounded-full border border-primary/60 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[disabled]:cursor-not-allowed"
/>
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
tabIndex={-1}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
tabIndex={-1}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
tabIndex={-1}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
tabIndex={-1}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,39 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { useStore } from "@/lib/states"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
const updateAppState = useStore((state) => state.updateAppState)
const handleOnFocus = () => {
updateAppState({ disableShortCuts: true })
}
const handleOnBlur = () => {
updateAppState({ disableShortCuts: false })
}
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"overflow-auto",
className
)}
tabIndex={-1}
ref={ref}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,133 @@
import * as React from "react"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
tabIndex={-1}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
tabIndex={-1}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
tabIndex={-1}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
tabIndex={-1}
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
tabIndex={-1}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
tabIndex={-1}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-3",
sm: "h-8 px-2",
lg: "h-10 px-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 border overflow-hidden rounded-md bg-background text-foreground px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -0,0 +1,111 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html { font-family: "Inter", "system-ui"; overflow: hidden; }
@supports (font-variation-settings: normal) {
html { font-family: "Inter var", "system-ui"; }
}
.react-transform-wrapper {
display: grid !important;
width: 100% !important;
height: 100% !important;
}
.react-photo-album {
padding: 8px;
}
.react-photo-album--photo {
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
border-radius: 8px;
transition: transform 0.25s, visibility 0.25s ease-in;
}
.react-photo-album--photo:hover {
border: 1px solid var(--border);
transform: scale(1.03);
}
.icon-button-icon-wrapper svg {
stroke-width: 1px;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 48 100.0% 50.0%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--radius: 0.5rem;
}
[data-theme='dark'] {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;;
--primary: 48 100.0% 50.0%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,33 @@
import { DependencyList, useEffect, useState } from 'react'
export function useAsyncMemo<T>(
factory: () => Promise<T> | undefined | null,
deps: DependencyList
): T | undefined
export function useAsyncMemo<T>(
factory: () => Promise<T> | undefined | null,
deps: DependencyList,
initial: T
): T
export function useAsyncMemo<T>(
factory: () => Promise<T> | undefined | null,
deps: DependencyList,
initial?: T
) {
const [val, setVal] = useState<T | undefined>(initial)
useEffect(() => {
let cancel = false
const promise = factory()
if (promise === undefined || promise === null) return
promise.then(v => {
if (!cancel) {
setVal(v)
}
})
return () => {
cancel = true
}
}, deps)
return val
}

View File

@ -0,0 +1,11 @@
import { useStore } from "@/lib/states"
import { useHotkeys } from "react-hotkeys-hook"
const useHotKey = (keys: string, callback: any, deps?: any[]) => {
const disableShortCuts = useStore((state) => state.disableShortCuts)
const ref = useHotkeys(keys, callback, { enabled: !disableShortCuts }, deps)
return ref
}
export default useHotKey

View File

@ -0,0 +1,24 @@
import { useEffect, useState } from "react"
function useImage(file: File | null): [HTMLImageElement, boolean] {
const [image] = useState(new Image())
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
if (!file) {
return
}
image.onload = () => {
setIsLoaded(true)
}
setIsLoaded(false)
image.src = URL.createObjectURL(file)
return () => {
image.onload = null
}
}, [file, image])
return [image, isLoaded]
}
export { useImage }

View File

@ -0,0 +1,41 @@
import { API_ENDPOINT } from "@/lib/api"
import { useCallback, useEffect, useState } from "react"
export default function useInputImage() {
const [inputImage, setInputImage] = useState<File | null>(null)
const fetchInputImage = useCallback(() => {
const headers = new Headers()
headers.append("pragma", "no-cache")
headers.append("cache-control", "no-cache")
fetch(`${API_ENDPOINT}/inputimage`, { headers })
.then(async (res) => {
if (!res.ok) {
return
}
const filename = res.headers
.get("content-disposition")
?.split("filename=")[1]
.split(";")[0]
const data = await res.blob()
if (data && data.type.startsWith("image")) {
const userInput = new File(
[data],
filename !== undefined ? filename : "inputImage"
)
setInputImage(userInput)
}
})
.catch((err) => {
console.log(err)
})
}, [setInputImage])
useEffect(() => {
fetchInputImage()
}, [fetchInputImage])
return inputImage
}

View File

@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from 'react'
const useResolution = () => {
const [width, setWidth] = useState(window.innerWidth)
const windowSizeHandler = useCallback(() => {
setWidth(window.innerWidth)
}, [])
useEffect(() => {
window.addEventListener('resize', windowSizeHandler)
return () => {
window.removeEventListener('resize', windowSizeHandler)
}
})
if (width < 768) {
return 'mobile'
}
if (width >= 768 && width < 1224) {
return 'tablet'
}
if (width >= 1224) {
return 'desktop'
}
}
export default useResolution

View File

@ -0,0 +1,231 @@
import {
Filename,
GenInfo,
ModelInfo,
PowerPaintTask,
Rect,
ServerConfig,
} from "@/lib/types"
import { Settings } from "@/lib/states"
import { convertToBase64, srcToFile } from "@/lib/utils"
import axios from "axios"
export const API_ENDPOINT = import.meta.env.DEV
? import.meta.env.VITE_BACKEND + "/api/v1"
: "/api/v1"
const api = axios.create({
baseURL: API_ENDPOINT,
})
export default async function inpaint(
imageFile: File,
settings: Settings,
croperRect: Rect,
extenderState: Rect,
mask: File | Blob,
paintByExampleImage: File | null = null
) {
const imageBase64 = await convertToBase64(imageFile)
const maskBase64 = await convertToBase64(mask)
const exampleImageBase64 = paintByExampleImage
? await convertToBase64(paintByExampleImage)
: null
const res = await fetch(`${API_ENDPOINT}/inpaint`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image: imageBase64,
mask: maskBase64,
ldm_steps: settings.ldmSteps,
ldm_sampler: settings.ldmSampler,
zits_wireframe: settings.zitsWireframe,
cv2_flag: settings.cv2Flag,
cv2_radius: settings.cv2Radius,
hd_strategy: "Crop",
hd_strategy_crop_triger_size: 640,
hd_strategy_crop_margin: 128,
hd_trategy_resize_imit: 2048,
prompt: settings.prompt,
negative_prompt: settings.negativePrompt,
use_croper: settings.showCropper,
croper_x: croperRect.x,
croper_y: croperRect.y,
croper_height: croperRect.height,
croper_width: croperRect.width,
use_extender: settings.showExtender,
extender_x: extenderState.x,
extender_y: extenderState.y,
extender_height: extenderState.height,
extender_width: extenderState.width,
sd_mask_blur: settings.sdMaskBlur,
sd_strength: settings.sdStrength,
sd_steps: settings.sdSteps,
sd_guidance_scale: settings.sdGuidanceScale,
sd_sampler: settings.sdSampler,
sd_seed: settings.seedFixed ? settings.seed : -1,
sd_match_histograms: settings.sdMatchHistograms,
sd_freeu: settings.enableFreeu,
sd_freeu_config: settings.freeuConfig,
sd_lcm_lora: settings.enableLCMLora,
paint_by_example_example_image: exampleImageBase64,
p2p_image_guidance_scale: settings.p2pImageGuidanceScale,
enable_controlnet: settings.enableControlnet,
controlnet_conditioning_scale: settings.controlnetConditioningScale,
controlnet_method: settings.controlnetMethod
? settings.controlnetMethod
: "",
powerpaint_task: settings.showExtender
? PowerPaintTask.outpainting
: settings.powerpaintTask,
}),
})
if (res.ok) {
const blob = await res.blob()
return {
blob: URL.createObjectURL(blob),
seed: res.headers.get("X-Seed"),
}
}
const errors = await res.json()
throw new Error(`Something went wrong: ${errors.errors}`)
}
export async function getServerConfig(): Promise<ServerConfig> {
const res = await api.get(`/server-config`)
return res.data
}
export async function switchModel(name: string): Promise<ModelInfo> {
const res = await api.post(`/model`, { name })
return res.data
}
export async function switchPluginModel(
plugin_name: string,
model_name: string
) {
return api.post(`/switch_plugin_model`, { plugin_name, model_name })
}
export async function currentModel(): Promise<ModelInfo> {
const res = await api.get("/model")
return res.data
}
export async function runPlugin(
genMask: boolean,
name: string,
imageFile: File,
upscale?: number,
clicks?: number[][]
) {
const imageBase64 = await convertToBase64(imageFile)
const p = genMask ? "run_plugin_gen_mask" : "run_plugin_gen_image"
const res = await fetch(`${API_ENDPOINT}/${p}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
image: imageBase64,
upscale,
clicks,
}),
})
if (res.ok) {
const blob = await res.blob()
return { blob: URL.createObjectURL(blob) }
}
const errMsg = await res.json()
throw new Error(errMsg)
}
export async function getMediaFile(tab: string, filename: string) {
const res = await fetch(
`${API_ENDPOINT}/media_file?tab=${tab}&filename=${encodeURIComponent(
filename
)}`,
{
method: "GET",
}
)
if (res.ok) {
const blob = await res.blob()
const file = new File([blob], filename, {
type: res.headers.get("Content-Type") ?? "image/png",
})
return file
}
const errMsg = await res.json()
throw new Error(errMsg.errors)
}
export async function getMedias(tab: string): Promise<Filename[]> {
const res = await api.get(`medias`, { params: { tab } })
return res.data
}
export async function downloadToOutput(
image: HTMLImageElement,
filename: string,
mimeType: string
) {
const file = await srcToFile(image.src, filename, mimeType)
const fd = new FormData()
fd.append("file", file)
try {
const res = await fetch(`${API_ENDPOINT}/save_image`, {
method: "POST",
body: fd,
})
if (!res.ok) {
const errMsg = await res.text()
throw new Error(errMsg)
}
} catch (error) {
throw new Error(`Something went wrong: ${error}`)
}
}
export async function getGenInfo(file: File): Promise<GenInfo> {
const fd = new FormData()
fd.append("file", file)
const res = await api.post(`/gen-info`, fd)
return res.data
}
export async function getSamplers(): Promise<string[]> {
const res = await api.post("/samplers")
return res.data
}
export async function postAdjustMask(
mask: File | Blob,
operate: "expand" | "shrink" | "reverse",
kernel_size: number
) {
const maskBase64 = await convertToBase64(mask)
const res = await fetch(`${API_ENDPOINT}/adjust_mask`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mask: maskBase64,
operate: operate,
kernel_size: kernel_size,
}),
})
if (res.ok) {
const blob = await res.blob()
return blob
}
const errMsg = await res.json()
throw new Error(errMsg)
}

View File

@ -0,0 +1,23 @@
export const ACCENT_COLOR = "#ffcc00bb"
export const DEFAULT_BRUSH_SIZE = 40
export const MIN_BRUSH_SIZE = 3
export const MAX_BRUSH_SIZE = 200
export const MODEL_TYPE_INPAINT = "inpaint"
export const MODEL_TYPE_DIFFUSERS_SD = "diffusers_sd"
export const MODEL_TYPE_DIFFUSERS_SDXL = "diffusers_sdxl"
export const MODEL_TYPE_DIFFUSERS_SD_INPAINT = "diffusers_sd_inpaint"
export const MODEL_TYPE_DIFFUSERS_SDXL_INPAINT = "diffusers_sdxl_inpaint"
export const MODEL_TYPE_OTHER = "diffusers_other"
export const BRUSH_COLOR = "#ffcc00bb"
export const LDM = "ldm"
export const CV2 = "cv2"
export const PAINT_BY_EXAMPLE = "Fantasy-Studio/Paint-by-Example"
export const INSTRUCT_PIX2PIX = "timbrooks/instruct-pix2pix"
export const KANDINSKY_2_2 = "kandinsky-community/kandinsky-2-2-decoder-inpaint"
export const POWERPAINT = "Sanster/PowerPaint-V1-stable-diffusion-inpainting"
export const ANYTEXT = "Sanster/AnyText"
export const DEFAULT_NEGATIVE_PROMPT =
"out of frame, lowres, error, cropped, worst quality, low quality, jpeg artifacts, ugly, duplicate, morbid, mutilated, out of frame, mutation, deformed, blurry, dehydrated, bad anatomy, bad proportions, extra limbs, disfigured, gross proportions, malformed limbs, watermark, signature"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,136 @@
export interface Filename {
name: string
height: number
width: number
ctime: number
mtime: number
}
export interface PluginInfo {
name: string
support_gen_image: boolean
support_gen_mask: boolean
}
export interface ServerConfig {
plugins: PluginInfo[]
modelInfos: ModelInfo[]
removeBGModel: string
removeBGModels: string[]
realesrganModel: string
realesrganModels: string[]
interactiveSegModel: string
interactiveSegModels: string[]
enableFileManager: boolean
enableAutoSaving: boolean
enableControlnet: boolean
controlnetMethod: string
disableModelSwitch: boolean
isDesktop: boolean
samplers: string[]
}
export interface GenInfo {
prompt: string
negative_prompt: string
}
export interface ModelInfo {
name: string
path: string
model_type:
| "inpaint"
| "diffusers_sd"
| "diffusers_sdxl"
| "diffusers_sd_inpaint"
| "diffusers_sdxl_inpaint"
| "diffusers_other"
support_strength: boolean
support_outpainting: boolean
support_controlnet: boolean
controlnets: string[]
support_freeu: boolean
support_lcm_lora: boolean
need_prompt: boolean
is_single_file_diffusers: boolean
}
export enum PluginName {
RemoveBG = "RemoveBG",
AnimeSeg = "AnimeSeg",
RealESRGAN = "RealESRGAN",
GFPGAN = "GFPGAN",
RestoreFormer = "RestoreFormer",
InteractiveSeg = "InteractiveSeg",
}
export interface PluginParams {
upscale: number
}
export enum SortBy {
NAME = "name",
CTIME = "ctime",
MTIME = "mtime",
}
export enum SortOrder {
DESCENDING = "desc",
ASCENDING = "asc",
}
export enum LDMSampler {
ddim = "ddim",
plms = "plms",
}
export enum CV2Flag {
INPAINT_NS = "INPAINT_NS",
INPAINT_TELEA = "INPAINT_TELEA",
}
export interface Rect {
x: number
y: number
width: number
height: number
}
export interface FreeuConfig {
s1: number
s2: number
b1: number
b2: number
}
export interface Point {
x: number
y: number
}
export interface Line {
size?: number
pts: Point[]
}
export type LineGroup = Array<Line>
export interface Size {
width: number
height: number
}
export enum ExtenderDirection {
x = "x",
y = "y",
xy = "xy",
}
export enum PowerPaintTask {
text_guided = "text-guided",
shape_guided = "shape-guided",
object_remove = "object-remove",
outpainting = "outpainting",
}
export type AdjustMaskOperate = "expand" | "shrink" | "reverse"

View File

@ -0,0 +1,246 @@
import { type ClassValue, clsx } from "clsx"
import { SyntheticEvent } from "react"
import { twMerge } from "tailwind-merge"
import { LineGroup } from "./types"
import { BRUSH_COLOR } from "./const"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function keepGUIAlive() {
async function getRequest(url = "") {
const response = await fetch(url, {
method: "GET",
cache: "no-cache",
})
return response.json()
}
const keepAliveServer = () => {
const url = document.location
const route = "/flaskwebgui-keep-server-alive"
getRequest(url + route).then((data) => {
return data
})
}
const intervalRequest = 3 * 1000
keepAliveServer()
setInterval(keepAliveServer, intervalRequest)
}
export function dataURItoBlob(dataURI: string) {
const mime = dataURI.split(",")[0].split(":")[1].split(";")[0]
const binary = atob(dataURI.split(",")[1])
const array = []
for (let i = 0; i < binary.length; i += 1) {
array.push(binary.charCodeAt(i))
}
return new Blob([new Uint8Array(array)], { type: mime })
}
export function loadImage(image: HTMLImageElement, src: string) {
return new Promise((resolve, reject) => {
const initSRC = image.src
const img = image
img.onload = resolve
img.onerror = (err) => {
img.src = initSRC
reject(err)
}
img.src = src
})
}
export async function blobToImage(blob: Blob) {
const dataURL = URL.createObjectURL(blob)
const newImage = new Image()
await loadImage(newImage, dataURL)
return newImage
}
export function canvasToImage(
canvas: HTMLCanvasElement
): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener("load", () => {
resolve(image)
})
image.addEventListener("error", (error) => {
reject(error)
})
image.src = canvas.toDataURL()
})
}
export function fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const image = new Image()
image.onload = () => {
resolve(image)
}
image.onerror = () => {
reject("无法加载图像。")
}
image.src = reader.result as string
}
reader.onerror = () => {
reject("无法读取文件。")
}
reader.readAsDataURL(file)
})
}
export function srcToFile(src: string, fileName: string, mimeType: string) {
return fetch(src)
.then(function (res) {
return res.arrayBuffer()
})
.then(function (buf) {
return new File([buf], fileName, { type: mimeType })
})
}
export async function askWritePermission() {
try {
// The clipboard-write permission is granted automatically to pages
// when they are the active tab. So it's not required, but it's more safe.
const { state } = await navigator.permissions.query({
name: "clipboard-write" as PermissionName,
})
return state === "granted"
} catch (error) {
// Browser compatibility / Security error (ONLY HTTPS) ...
return false
}
}
function canvasToBlob(canvas: HTMLCanvasElement, mime: string): Promise<any> {
return new Promise((resolve, reject) =>
canvas.toBlob(async (d) => {
if (d) {
resolve(d)
} else {
reject(new Error("Expected toBlob() to be defined"))
}
}, mime)
)
}
const setToClipboard = async (blob: any) => {
const data = [new ClipboardItem({ [blob.type]: blob })]
await navigator.clipboard.write(data)
}
export function isRightClick(ev: SyntheticEvent) {
const mouseEvent = ev.nativeEvent as MouseEvent
return mouseEvent.button === 2
}
export function isMidClick(ev: SyntheticEvent) {
const mouseEvent = ev.nativeEvent as MouseEvent
return mouseEvent.button === 1
}
export async function copyCanvasImage(canvas: HTMLCanvasElement) {
const blob = await canvasToBlob(canvas, "image/png")
try {
await setToClipboard(blob)
} catch {
console.log("Copy image failed!")
}
}
export function downloadImage(uri: string, name: string) {
const link = document.createElement("a")
link.href = uri
link.download = name
// this is necessary as link.click() does not work on the latest firefox
link.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
})
)
setTimeout(() => {
// For Firefox it is necessary to delay revoking the ObjectURL
// window.URL.revokeObjectURL(base64)
link.remove()
}, 100)
}
export function mouseXY(ev: SyntheticEvent) {
const mouseEvent = ev.nativeEvent as MouseEvent
return { x: mouseEvent.offsetX, y: mouseEvent.offsetY }
}
export function drawLines(
ctx: CanvasRenderingContext2D,
lines: LineGroup,
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 const generateMask = (
imageWidth: number,
imageHeight: number,
lineGroups: LineGroup[],
maskImages: HTMLImageElement[] = [],
lineGroupsColor: string = "white"
): HTMLCanvasElement => {
const maskCanvas = document.createElement("canvas")
maskCanvas.width = imageWidth
maskCanvas.height = imageHeight
const ctx = maskCanvas.getContext("2d")
if (!ctx) {
throw new Error("could not retrieve mask canvas")
}
maskImages.forEach((maskImage) => {
ctx.drawImage(maskImage, 0, 0, imageWidth, imageHeight)
})
lineGroups.forEach((lineGroup) => {
drawLines(ctx, lineGroup, lineGroupsColor)
})
return maskCanvas
}
export const convertToBase64 = (fileOrBlob: File | Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
const base64String = event.target?.result as string
resolve(base64String)
}
reader.onerror = (error) => {
reject(error)
}
reader.readAsDataURL(fileOrBlob)
})
}

View File

@ -0,0 +1,22 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import "inter-ui/inter.css"
import App from "./App.tsx"
import "./globals.css"
import { ThemeProvider } from "next-themes"
import { TooltipProvider } from "./components/ui/tooltip.tsx"
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="dark" disableTransitionOnChange>
<TooltipProvider>
<App />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>
)

1
iopaint/web_app/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,12 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

View File

@ -1,4 +1,4 @@
from iopaint import entry_point
#nohup python3 ../main.py start --enable-remove-bg > output_nohup.log 2>&1 &
if __name__ == "__main__":
entry_point()