diff --git a/web_app/package-lock.json b/web_app/package-lock.json index 90627ca..dfc6f7f 100644 --- a/web_app/package-lock.json +++ b/web_app/package-lock.json @@ -11,6 +11,7 @@ "@heroicons/react": "^2.0.18", "@radix-ui/react-accordion": "^1.1.2", "@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", @@ -27,6 +28,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "flexsearch": "^0.7.21", + "immer": "^10.0.3", "lodash": "^4.17.21", "lucide-react": "^0.292.0", "mitt": "^3.0.1", @@ -39,7 +41,8 @@ "react-zoom-pan-pinch": "^3.3.0", "recoil": "^0.7.7", "tailwind-merge": "^2.0.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^4.4.6" }, "devDependencies": { "@types/flexsearch": "^0.7.3", @@ -1564,6 +1567,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz", + "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", @@ -1655,6 +1687,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz", + "integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", @@ -4023,6 +4095,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5652,6 +5733,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5770,6 +5859,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.6.tgz", + "integrity": "sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/web_app/package.json b/web_app/package.json index 9ef38e3..78752a2 100644 --- a/web_app/package.json +++ b/web_app/package.json @@ -13,6 +13,7 @@ "@heroicons/react": "^2.0.18", "@radix-ui/react-accordion": "^1.1.2", "@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", @@ -29,6 +30,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "flexsearch": "^0.7.21", + "immer": "^10.0.3", "lodash": "^4.17.21", "lucide-react": "^0.292.0", "mitt": "^3.0.1", @@ -41,7 +43,8 @@ "react-zoom-pan-pinch": "^3.3.0", "recoil": "^0.7.7", "tailwind-merge": "^2.0.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^4.4.6" }, "devDependencies": { "@types/flexsearch": "^0.7.3", diff --git a/web_app/src/components/Cropper.tsx b/web_app/src/components/Cropper.tsx new file mode 100644 index 0000000..f0be764 --- /dev/null +++ b/web_app/src/components/Cropper.tsx @@ -0,0 +1,418 @@ +import { useStore } from "@/lib/states" +import React, { useEffect, useState } from "react" + +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 [ + isInpainting, + { x, y, width, height }, + setX, + setY, + setWidth, + setHeight, + ] = useStore((state) => [ + state.isInpainting, + state.cropperState, + state.setCropperX, + state.setCropperY, + state.setCropperWidth, + state.setCropperHeight, + ]) + // const [x, setX] = useRecoilState(croperX) + // const [y, setY] = useRecoilState(croperY) + // const [height, setHeight] = useRecoilState(croperHeight) + // const [width, setWidth] = useRecoilState(croperWidth) + // const isInpainting = useRecoilValue(isInpaintingState) + + const [isResizing, setIsResizing] = useState(false) + const [isMoving, setIsMoving] = useState(false) + + useEffect(() => { + setX(Math.round((maxWidth - 512) / 2)) + setY(Math.round((maxHeight - 512) / 2)) + }, [maxHeight, maxWidth]) + + const [evData, setEVData] = useState({ + 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 = (e: PointerEvent) => { + 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) => { + 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 createCropSelection = () => { + return ( +
+
+
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+ ) + } + + const onInfoBarPointerDown = (e: React.PointerEvent) => { + setIsMoving(true) + setEVData({ + initX: x, + initY: y, + initHeight: height, + initWidth: width, + startResizeX: e.clientX, + startResizeY: e.clientY, + ord: "", + }) + } + + const createInfoBar = () => { + return ( +
+
+ {width} x {height} +
+
+ ) + } + + const createBorder = () => { + return ( +
+ ) + } + + return ( +
+
+ {createBorder()} + {createInfoBar()} + {createCropSelection()} +
+
+ ) +} + +export default Cropper diff --git a/web_app/src/components/Editor.tsx b/web_app/src/components/Editor.tsx index e6b31d8..3bf8c1e 100644 --- a/web_app/src/components/Editor.tsx +++ b/web_app/src/components/Editor.tsx @@ -2,6 +2,7 @@ import { SyntheticEvent, useCallback, useEffect, useRef, useState } from "react" import { CursorArrowRaysIcon } from "@heroicons/react/24/outline" import { useToast } from "@/components/ui/use-toast" import { + ReactZoomPanPinchContentRef, ReactZoomPanPinchRef, TransformComponent, TransformWrapper, @@ -55,6 +56,8 @@ import { Slider } from "./ui/slider" import { PluginName } from "@/lib/types" import { useHotkeys } from "react-hotkeys-hook" import { useStore } from "@/lib/states" +import Cropper from "./Cropper" +import { HotkeysEvent } from "react-hotkeys-hook/dist/types" const TOOLBAR_HEIGHT = 200 const MIN_BRUSH_SIZE = 10 @@ -193,7 +196,7 @@ export default function Editor(props: EditorProps) { const windowSize = useWindowSize() const windowCenterX = windowSize.width / 2 const windowCenterY = windowSize.height / 2 - const viewportRef = useRef(null) + const viewportRef = useRef(null) // Indicates that the image has been loaded and is centered on first load const [initialCentered, setInitialCentered] = useState(false) @@ -1119,9 +1122,8 @@ export default function Editor(props: EditorProps) { context, ]) - const undo = () => { - // TODO: prevent default event - console.log("undo") + const undo = (keyboardEvent: KeyboardEvent, hotkeysEvent: HotkeysEvent) => { + keyboardEvent.preventDefault() if (runMannually && curLineGroup.length !== 0) { undoStroke() } else { @@ -1186,7 +1188,8 @@ export default function Editor(props: EditorProps) { // draw(newRenders[newRenders.length - 1], []) }, [draw, renders, redoRenders, redoLineGroups, lineGroups, original]) - const redo = () => { + const redo = (keyboardEvent: KeyboardEvent, hotkeysEvent: HotkeysEvent) => { + keyboardEvent.preventDefault() if (runMannually && redoCurLines.length !== 0) { redoStroke() } else { @@ -1194,29 +1197,12 @@ export default function Editor(props: EditorProps) { } } - // Handle Cmd+shift+Z - const redoPredicate = (event: KeyboardEvent) => { - const isCmdZ = - (event.metaKey || event.ctrlKey) && - event.shiftKey && - event.key.toLowerCase() === "z" - // Handle tab switch - if (event.key === "Tab") { - event.preventDefault() - } - if (isCmdZ) { - event.preventDefault() - return true - } - return false - } - - // useKey(redoPredicate, redo, undefined, [ - // redoStroke, - // redoRender, - // runMannually, - // redoCurLines, - // ]) + useHotkeys("shift+ctrl+z,shift+meta+z", redo, undefined, [ + redoStroke, + redoRender, + runMannually, + redoCurLines, + ]) const disableRedo = () => { if (isProcessing) { @@ -1410,12 +1396,13 @@ export default function Editor(props: EditorProps) { let s = minScale if (viewportRef.current?.state?.scale !== undefined) { s = viewportRef.current?.state.scale + console.log("!!!!!!") } return s! } const getBrushStyle = (_x: number, _y: number) => { - const curScale = getCurScale() + const curScale = scale return { width: `${brushSize * curScale}px`, height: `${brushSize * curScale}px`, @@ -1463,7 +1450,12 @@ export default function Editor(props: EditorProps) { const renderCanvas = () => { return ( { + if (r) { + viewportRef.current = r + } + }} panning={{ disabled: !isPanning, velocityDisabled: true }} wheel={{ step: 0.05 }} centerZoomedOut @@ -1546,14 +1538,15 @@ export default function Editor(props: EditorProps) {
- {/* */} + show={true} + // show={isDiffusionModels && settings.showCroper} + /> {/* {isInteractiveSeg ? : <>} */} diff --git a/web_app/src/components/FileManager.tsx b/web_app/src/components/FileManager.tsx index dc6a67a..145b51b 100644 --- a/web_app/src/components/FileManager.tsx +++ b/web_app/src/components/FileManager.tsx @@ -197,11 +197,12 @@ export default function FileManager(props: Props) { onClick={() => { setFileManagerLayout("masonry") }} - className={ - fileManagerState.layout !== "masonry" ? "opacity-50" : "" - } > - +
diff --git a/web_app/src/components/Shortcuts.tsx b/web_app/src/components/Shortcuts.tsx index 2353470..9e3860c 100644 --- a/web_app/src/components/Shortcuts.tsx +++ b/web_app/src/components/Shortcuts.tsx @@ -4,7 +4,6 @@ import { useToggle } from "@uidotdev/usehooks" import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, diff --git a/web_app/src/lib/states.ts b/web_app/src/lib/states.ts index 4fb0ca0..ba58a85 100644 --- a/web_app/src/lib/states.ts +++ b/web_app/src/lib/states.ts @@ -11,6 +11,13 @@ type FileManagerState = { searchText: string } +type CropperState = { + x: number + y: number + width: number + height: number +} + type AppState = { file: File | null imageHeight: number @@ -26,6 +33,7 @@ type AppState = { prompt: string fileManagerState: FileManagerState + cropperState: CropperState } type AppAction = { @@ -33,13 +41,19 @@ type AppAction = { setIsInpainting: (newValue: boolean) => void setBrushSize: (newValue: number) => void setImageSize: (width: number, height: number) => void + setPrompt: (newValue: string) => void + setFileManagerSortBy: (newValue: SortBy) => void setFileManagerSortOrder: (newValue: SortOrder) => void setFileManagerLayout: ( newValue: AppState["fileManagerState"]["layout"] ) => void setFileManagerSearchText: (newValue: string) => void - setPrompt: (newValue: string) => void + + setCropperX: (newValue: number) => void + setCropperY: (newValue: number) => void + setCropperWidth: (newValue: number) => void + setCropperHeight: (newValue: number) => void } export const useStore = create()( @@ -56,6 +70,12 @@ export const useStore = create()( isInteractiveSegRunning: false, interactiveSegClicks: [], prompt: "", + cropperState: { + x: 0, + y: 0, + width: 0, + height: 0, + }, fileManagerState: { sortBy: SortBy.CTIME, sortOrder: SortOrder.DESCENDING, @@ -66,15 +86,18 @@ export const useStore = create()( set((state: AppState) => { state.isInpainting = newValue }), + setFile: (file: File) => set((state: AppState) => { // TODO: 清空各种状态 state.file = file }), + setBrushSize: (newValue: number) => set((state: AppState) => { state.brushSize = newValue }), + setImageSize: (width: number, height: number) => { // 根据图片尺寸调整 brushSize 的 scale set((state: AppState) => { @@ -83,22 +106,47 @@ export const useStore = create()( state.brushSizeScale = Math.max(Math.min(width, height), 512) / 512 }) }, + setPrompt: (newValue: string) => set((state: AppState) => { state.prompt = newValue }), + + setCropperX: (newValue: number) => + set((state: AppState) => { + state.cropperState.x = newValue + }), + + setCropperY: (newValue: number) => + set((state: AppState) => { + state.cropperState.y = newValue + }), + + setCropperWidth: (newValue: number) => + set((state: AppState) => { + state.cropperState.width = newValue + }), + + setCropperHeight: (newValue: number) => + set((state: AppState) => { + state.cropperState.height = newValue + }), + setFileManagerSortBy: (newValue: SortBy) => set((state: AppState) => { state.fileManagerState.sortBy = newValue }), + setFileManagerSortOrder: (newValue: SortOrder) => set((state: AppState) => { state.fileManagerState.sortOrder = newValue }), + setFileManagerLayout: (newValue: "rows" | "masonry") => set((state: AppState) => { state.fileManagerState.layout = newValue }), + setFileManagerSearchText: (newValue: string) => set((state: AppState) => { state.fileManagerState.searchText = newValue