Complete GUI Refactor

This patch brings in a massive number of changes to the frontend of the application. Please feel free to discuss the proposed changes with me at any time.

Implemented Recoil as a state management system.
Why Recoil? It is a robust library built by developers at Facebook for state management. It has an  extremely simple API for implementation that is in sync with React syntax compared to any other state management system out there and works amazingly well. While the official release status is beta as it becomes fully featured, the library is already used in various systems at Facebook and is very stable for the use cases of this application.

Why global state management? One of the major issues I saw with the current file structure is that there is minimal code splitting and it makes further development of the frontend a cumbersome task. I have broken down the frontend into various easy to access components isolating the GUI from the logic. To avoid prop drilling, we need global state management to handle the necessary tasks. This will also facilitate the addition of any new features greatly.

Code Splitting. Majority of the components that can be isolated in the application have now been done so.
All New Custom CSS & Removal of Tailwind
While Tailwind is a great way to deploy beautiful interfaces quickly, anyone trying to stylize the application further needs to be familiar with Tailwind which makes it harder for more people to work on it. Not to mention, I am not a particular fan of flooding JSX elements with inline CSS classes. That makes reading the code extremely hard and bloats up component code drastically.

As a replacement to Tailwind, I implemented a custom styling system using SCSS as a developer dependency.

In the new system, all the general and shared styles are in the styles folder and all the component styles are in the same folder as the component for easy access.The _index.scss file now acts as a central import for every other stylesheet that needs to be loaded.

What Changed?
The entire application looks and feels like the current implementation with minimal changes.
The green (#bdff01) highlight used in the application has now been changed to a bright yellow (rgb(255, 190, 0)) because I felt it better suited the new Dark Mode (see below).
The swipe bar for comparing before and after images has now been removed and instead the comparison is a smooth fade effect. I felt this was better to analyze image changes rather than a swiper. // Can add the swipe back if needed.

Dark Mode
A brand new Dark Mode has been added for the application. Users can enable and disable by tapping the button in the header or by using the Shift + D hotkey.

Other Misc New Features
When the editor image is now zoomed out to its default size, the image now also gets centered back.

TODO
The currently used react-zoom-pinch-pan module is not mobile friendly. It does not allow brush strokes. Need to figure out a way to fix this.
Further optimization of the frontend code with better code splitting and performance.
When using the LaMa model, the first stroke has a delayed response from the backend but the ones that follow are almost immediate. I believe this is happening because of the initialization of the model on the first stroke. I wonder if either of us can look at it and see if this can somehow be preloaded so the user experience is smooth from the first stroke.
Enable threading for the desktop application mode so flaskwebgui does not block the main applications Python console.
This commit is contained in:
blessedcoolant 2022-03-28 17:52:05 +13:00
parent a40d92f23f
commit eea85b834e
74 changed files with 1021 additions and 632 deletions

View File

@ -1,16 +1,17 @@
{
"files": {
"main.css": "/static/css/main.1144a0ea.chunk.css",
"main.js": "/static/js/main.98890b3e.chunk.js",
"main.css": "/static/css/main.54fbc69f.chunk.css",
"main.js": "/static/js/main.d346743e.chunk.js",
"runtime-main.js": "/static/js/runtime-main.5e86ac81.js",
"static/js/2.d3149f41.chunk.js": "/static/js/2.d3149f41.chunk.js",
"static/js/2.2516aa7d.chunk.js": "/static/js/2.2516aa7d.chunk.js",
"index.html": "/index.html",
"static/js/2.d3149f41.chunk.js.LICENSE.txt": "/static/js/2.d3149f41.chunk.js.LICENSE.txt"
"static/js/2.2516aa7d.chunk.js.LICENSE.txt": "/static/js/2.2516aa7d.chunk.js.LICENSE.txt",
"static/media/_index.scss": "/static/media/WorkSans-SemiBold.1e98db4e.ttf"
},
"entrypoints": [
"static/js/runtime-main.5e86ac81.js",
"static/js/2.d3149f41.chunk.js",
"static/css/main.1144a0ea.chunk.css",
"static/js/main.98890b3e.chunk.js"
"static/js/2.2516aa7d.chunk.js",
"static/css/main.54fbc69f.chunk.css",
"static/js/main.d346743e.chunk.js"
]
}

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"/><meta name="theme-color" content="#ffffff"/><title>lama-cleaner - Image inpainting powered by LaMa</title><link href="/static/css/main.1144a0ea.chunk.css" rel="stylesheet"></head><body class="h-screen"><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="h-full"></div><script>"localhost"===location.hostname&&(self.FIREBASE_APPCHECK_DEBUG_TOKEN=!0)</script><script>!function(e){function r(r){for(var n,l,a=r[0],f=r[1],i=r[2],p=0,s=[];p<a.length;p++)l=a[p],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(r);s.length;)s.shift()();return u.push.apply(u,i||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var f=t[a];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={1:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="/";var a=this["webpackJsonplama-cleaner"]=this["webpackJsonplama-cleaner"]||[],f=a.push.bind(a);a.push=r,a=a.slice();for(var i=0;i<a.length;i++)r(a[i]);var c=f;t()}([])</script><script src="/static/js/2.d3149f41.chunk.js"></script><script src="/static/js/main.98890b3e.chunk.js"></script></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"/><meta name="theme-color" content="#ffffff"/><title>lama-cleaner - Image inpainting powered by LaMa</title><link href="/static/css/main.54fbc69f.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>"localhost"===location.hostname&&(self.FIREBASE_APPCHECK_DEBUG_TOKEN=!0)</script><script>!function(e){function r(r){for(var n,l,a=r[0],f=r[1],i=r[2],p=0,s=[];p<a.length;p++)l=a[p],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(r);s.length;)s.shift()();return u.push.apply(u,i||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var f=t[a];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={1:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="/";var a=this["webpackJsonplama-cleaner"]=this["webpackJsonplama-cleaner"]||[],f=a.push.bind(a);a.push=r,a=a.slice();for(var i=0;i<a.length;i++)r(a[i]);var c=f;t()}([])</script><script src="/static/js/2.2516aa7d.chunk.js"></script><script src="/static/js/main.d346743e.chunk.js"></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,6 @@
"private": true,
"proxy": "http://localhost:8080",
"dependencies": {
"@headlessui/react": "^1.4.2",
"@heroicons/react": "^1.0.4",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.2",
@ -13,30 +12,21 @@
"@types/node": "^16.11.1",
"@types/react": "^17.0.30",
"@types/react-dom": "^17.0.9",
"autoprefixer": "10.x",
"cross-env": "7.x",
"delay-cli": "^1.1.0",
"npm-run-all": "4.x",
"postcss": "8.x",
"postcss-cli": "8.x",
"postcss-preset-env": "6.x",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"react-use": "^17.3.1",
"react-zoom-pan-pinch": "^2.1.3",
"tailwindcss": "2.x",
"recoil": "^0.6.1",
"typescript": "4.x"
},
"scripts": {
"dev": "run-p watch:css react-scripts:start",
"build": "run-s build:css react-scripts:build",
"start": "react-scripts start",
"build": "cross-env GENERATE_SOURCEMAP=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"build:css": "cross-env TAILWIND_MODE=build NODE_ENV=production postcss src/styles/tailwind.css -o src/styles/index.css",
"watch:css": "cross-env TAILWIND_MODE=watch NODE_ENV=development postcss src/styles/tailwind.css -o src/styles/index.css --watch",
"react-scripts:start": "delay 5 && react-scripts start",
"react-scripts:build": "cross-env GENERATE_SOURCEMAP=false react-scripts build"
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
@ -62,6 +52,7 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"prettier": "^2.4.1"
"prettier": "^2.4.1",
"sass": "^1.49.9"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: [
require('tailwindcss'),
require('postcss-preset-env'),
],
};

View File

@ -19,9 +19,9 @@
-->
<title>lama-cleaner - Image inpainting powered by LaMa</title>
</head>
<body class="h-screen">
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="h-full"></div>
<div id="root"></div>
<script>
if (location.hostname === 'localhost') {
self.FIREBASE_APPCHECK_DEBUG_TOKEN = true

View File

@ -1,11 +1,11 @@
import { ArrowLeftIcon } from '@heroicons/react/outline'
import React, { useEffect, useState } from 'react'
import { useToggle, useWindowSize } from 'react-use'
import Button from './components/Button'
import FileSelect from './components/FileSelect'
import React, { useEffect } from 'react'
import { useKeyPressEvent } from 'react-use'
import { useRecoilState } from 'recoil'
import useInputImage from './hooks/useInputImage'
import ShortcutsModal from './components/ShortcutsModal'
import Editor from './Editor'
import LandingPage from './components/LandingPage/LandingPage'
import { ThemeChanger, themeState } from './components/shared/ThemeChanger'
import Workspace from './components/Workspace'
import { fileState } from './store/Atoms'
// Keeping GUI Window Open
async function getRequest(url = '') {
@ -29,105 +29,25 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === 'production') {
}
function App() {
const [file, setFile] = useState<File>()
const [showShortcuts, toggleShowShortcuts] = useToggle(false)
const windowSize = useWindowSize()
const [file, setFile] = useRecoilState(fileState)
const [theme, setTheme] = useRecoilState(themeState)
const userInputImage = useInputImage()
// Set Input Image
useEffect(() => {
setFile(userInputImage)
}, [userInputImage])
}, [userInputImage, setFile])
// Dark Mode Hotkey
useKeyPressEvent('D', () => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
})
return (
<div className="h-full full-visible-h-safari flex flex-col">
<header className="absolute z-10 flex w-full p-1 justify-center sm:justify-between items-center sm:items-start bg-white backdrop-blur backdrop-filter bg-opacity-30">
{file ? (
<Button
icon={<ArrowLeftIcon className="w-6 h-6" />}
onClick={() => {
setFile(undefined)
}}
>
{windowSize.width > 640 ? 'Start new' : undefined}
</Button>
) : (
<></>
)}
{file ? (
<Button
onClick={toggleShowShortcuts}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
role="img"
width="28"
height="28"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 16 16"
>
<rect
x="0"
y="0"
width="16"
height="16"
fill="none"
stroke="none"
/>
<g fill="currentColor">
<path d="M14 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h12zM2 4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H2z" />
<path d="M13 10.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm0-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5 0A.25.25 0 0 1 8.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 8 8.75v-.5zm2 0a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25v-.5zm1 2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5-2A.25.25 0 0 1 6.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 6 8.75v-.5zm-2 0A.25.25 0 0 1 4.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 4 8.75v-.5zm-2 0A.25.25 0 0 1 2.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 2 8.75v-.5zm11-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0A.25.25 0 0 1 9.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 9 6.75v-.5zm-2 0A.25.25 0 0 1 7.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 7 6.75v-.5zm-2 0A.25.25 0 0 1 5.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 5 6.75v-.5zm-3 0A.25.25 0 0 1 2.25 6h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5A.25.25 0 0 1 2 6.75v-.5zm0 4a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm2 0a.25.25 0 0 1 .25-.25h5.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-5.5a.25.25 0 0 1-.25-.25v-.5z" />
</g>
</svg>
}
/>
) : (
<></>
)}
</header>
{showShortcuts && <ShortcutsModal onClose={toggleShowShortcuts} />}
<main
className={[
'h-full flex flex-1 flex-col sm:items-center sm:justify-center overflow-hidden',
'items-center justify-center',
].join(' ')}
>
{file ? (
<Editor file={file} />
) : (
<>
<div
className={[
'flex flex-col sm:flex-row items-center',
'space-y-5 sm:space-y-0 sm:space-x-6 p-5 pt-0 pb-10',
].join(' ')}
>
<div className="max-w-xl flex flex-col items-center sm:items-start p-0 m-0 space-y-5">
<h1 className="text-center sm:text-left text-xl sm:text-3xl">
Image inpainting powered by 🦙
<u>
<a href="https://github.com/saic-mdal/lama">LaMa</a>
</u>
</h1>
</div>
</div>
<div
className="h-20 sm:h-52 px-4 w-full"
style={{ maxWidth: '800px' }}
>
<FileSelect
onSelection={async f => {
setFile(f)
}}
/>
</div>
</>
)}
</main>
<div className="lama-cleaner" data-theme={theme}>
<ThemeChanger />
{file ? <Workspace file={file} /> : <LandingPage />}
</div>
)
}

View File

@ -0,0 +1,133 @@
@use '../../styles/Mixins' as *;
.editor-container {
grid-area: main-content;
display: grid;
width: 100vw;
height: 100vh;
place-items: center;
}
.react-transform-wrapper {
display: grid !important;
width: 100% !important;
height: 100% !important;
}
.editor-canvas-container {
display: grid;
grid-template-areas: 'editor-content';
row-gap: 1rem;
}
.editor-canvas {
grid-area: editor-content;
}
.original-image-container {
grid-area: editor-content;
pointer-events: none;
animation: opacityReveal 350ms ease-in-out;
}
.editor-canvas-loading {
pointer-events: none;
animation: pulsing 750ms infinite;
}
.editor-toolkit-panel {
position: fixed;
bottom: 0;
padding: 1rem 4rem;
display: grid;
// grid-template-columns: repeat(4, max-content);
grid-template-areas: 'toolkit-image-type toolkit-size-selector toolkit-brush-slider toolkit-btns';
column-gap: 2rem;
align-items: center;
background-color: var(--editor-toolkit-bg);
backdrop-filter: blur(10px);
border-radius: 0.5rem 0.5rem 0 0;
@include mobile {
padding: 1rem 2rem;
grid-template-areas:
'toolkit-image-type toolkit-size-selector'
'toolkit-brush-slider toolkit-brush-slider'
'toolkit-btns toolkit-btns';
row-gap: 2rem;
justify-items: center;
}
}
.editor-brush-slider {
grid-area: toolkit-brush-slider;
user-select: none;
display: grid;
grid-template-columns: repeat(2, max-content);
height: max-content;
column-gap: 1rem;
align-items: center;
input[type='range'] {
outline: none;
}
}
.editor-toolkit-btns {
grid-area: toolkit-btns;
display: grid;
grid-auto-flow: column;
column-gap: 1rem;
}
.brush-shape {
position: absolute;
border-radius: 50%;
background: rgb(255, 255, 255, 0.25);
border: 1px dashed var(--border-color);
pointer-events: none;
}
.editor-size-selector {
display: grid;
}
.editor-size-selector-options {
position: fixed;
display: grid;
}
.editor-size-selector {
grid-area: toolkit-size-selector;
display: grid;
grid-template-columns: repeat(2, max-content);
align-items: center;
column-gap: 0.5rem;
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: var(--yellow-accent);
outline: none;
border: 1px dashed var(--border-color);
border-radius: 0.5rem;
font-family: 'WorkSans-Bold';
font-size: 1rem;
padding: 0.5rem;
text-align: center;
color: rgb(0, 0, 0);
}
}
.image-type-tag {
grid-area: toolkit-image-type;
z-index: 2;
background-color: var(--yellow-accent);
padding: 0.5rem;
border-radius: 0.5rem;
width: 100px;
text-align: center;
font-family: 'WorkSans-Bold';
color: rgb(0, 0, 0);
}

View File

@ -21,11 +21,11 @@ import {
useKey,
useKeyPressEvent,
} from 'react-use'
import inpaint from './adapters/inpainting'
import Button from './components/Button'
import Slider from './components/Slider'
import SizeSelector from './components/SizeSelector'
import { downloadImage, loadImage, useImage } from './utils'
import inpaint from '../../adapters/inpainting'
import Button from '../shared/Button'
import Slider from './Slider'
import SizeSelector from './SizeSelector'
import { downloadImage, loadImage, useImage } from '../../utils'
const TOOLBAR_SIZE = 200
const BRUSH_COLOR = 'rgba(189, 255, 1, 0.75)'
@ -78,7 +78,6 @@ export default function Editor(props: EditorProps) {
const [isPanning, setIsPanning] = useState<boolean>(false)
const [showOriginal, setShowOriginal] = useState(false)
const [isInpaintingLoading, setIsInpaintingLoading] = useState(false)
const [showSeparator, setShowSeparator] = useState(false)
const [scale, setScale] = useState<number>(1)
const [minScale, setMinScale] = useState<number>()
// ['1080', '2000', 'Original']
@ -411,7 +410,6 @@ export default function Editor(props: EditorProps) {
ev?.preventDefault()
ev?.stopPropagation()
if (hadRunInpainting()) {
setShowSeparator(true)
setShowOriginal(true)
}
},
@ -420,7 +418,6 @@ export default function Editor(props: EditorProps) {
ev?.stopPropagation()
if (hadRunInpainting()) {
setShowOriginal(false)
setTimeout(() => setShowSeparator(false), 300)
}
}
)
@ -430,7 +427,6 @@ export default function Editor(props: EditorProps) {
const currRender = renders[renders.length - 1]
downloadImage(currRender.currentSrc, name)
}
const onSizeLimitChange = (_sizeLimit: string) => {
setSizeLimit(_sizeLimit)
}
@ -512,11 +508,7 @@ export default function Editor(props: EditorProps) {
return (
<div
className="flex flex-col items-center"
style={{
height: '100%',
width: '100%',
}}
className="editor-container"
aria-hidden="true"
onMouseMove={onMouseMove}
onMouseUp={onPointerUp}
@ -541,19 +533,11 @@ export default function Editor(props: EditorProps) {
}}
>
<TransformComponent
wrapperStyle={{
width: '100%',
height: '100%',
}}
contentClass={
isInpaintingLoading
? 'animate-pulse-fast pointer-events-none transition-opacity'
: ''
}
contentClass={isInpaintingLoading ? 'editor-canvas-loading' : ''}
>
<>
<div className="editor-canvas-container">
<canvas
className="rounded-sm"
className="editor-canvas"
style={{ cursor: getCursor() }}
onContextMenu={e => {
e.preventDefault()
@ -572,139 +556,94 @@ export default function Editor(props: EditorProps) {
}
}}
/>
<div
className={[
'absolute top-0 right-0 pointer-events-none',
'overflow-hidden',
'border-primary',
showSeparator ? 'border-l-4' : '',
].join(' ')}
style={{
width: showOriginal
? `${Math.round(original.naturalWidth)}px`
: '0px',
height: original.naturalHeight,
transitionProperty: 'width, height',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '300ms',
}}
>
<img
className="absolute right-0"
src={original.src}
alt="original"
width={`${original.naturalWidth}px`}
height={`${original.naturalHeight}px`}
{showOriginal ? (
<div
className="original-image-container"
style={{
width: `${original.naturalWidth}px`,
height: `${original.naturalHeight}px`,
maxWidth: 'none',
}}
/>
</div>
</>
>
<img
className="original-image"
src={original.src}
alt="original"
style={{
width: `${original.naturalWidth}px`,
height: `${original.naturalHeight}px`,
}}
/>
</div>
) : null}
</div>
</TransformComponent>
</TransformWrapper>
{showBrush && !isInpaintingLoading && !isPanning && (
<div
className="hidden sm:block absolute rounded-full border border-primary bg-primary bg-opacity-80 pointer-events-none"
style={getBrushStyle()}
/>
<div className="brush-shape" style={getBrushStyle()} />
)}
<div
className="fixed w-full bottom-0 flex items-center justify-center"
style={{ height: '90px' }}
>
<div
className={[
'flex items-center justify-center space-x-6',
'',
// 'bg-black backdrop-blur backdrop-filter bg-opacity-10',
].join(' ')}
>
<SizeSelector
value={sizeLimit || '1080'}
onChange={onSizeLimitChange}
originalWidth={original.naturalWidth}
originalHeight={original.naturalHeight}
<div className="editor-toolkit-panel">
<p className="image-type-tag">
{showOriginal ? 'Original' : 'Inpainted'}
</p>
<SizeSelector
value={sizeLimit || '1080'}
onChange={onSizeLimitChange}
originalWidth={original.naturalWidth}
originalHeight={original.naturalHeight}
/>
<Slider
label="Brush"
min={10}
max={150}
value={brushSize}
onChange={setBrushSize}
/>
<div className="editor-toolkit-btns">
<Button
icon={<ArrowsExpandIcon />}
disabled={scale === minScale}
onClick={resetZoom}
/>
<Slider
label={
<span>
<span className="hidden md:inline">Brush</span>
</span>
<Button
icon={
<svg
width="19"
height="9"
viewBox="0 0 19 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 1C2 0.447715 1.55228 0 1 0C0.447715 0 0 0.447715 0 1H2ZM1 8H0V9H1V8ZM8 9C8.55228 9 9 8.55229 9 8C9 7.44771 8.55228 7 8 7V9ZM16.5963 7.42809C16.8327 7.92721 17.429 8.14016 17.9281 7.90374C18.4272 7.66731 18.6402 7.07103 18.4037 6.57191L16.5963 7.42809ZM16.9468 5.83205L17.8505 5.40396L16.9468 5.83205ZM0 1V8H2V1H0ZM1 9H8V7H1V9ZM1.66896 8.74329L6.66896 4.24329L5.33104 2.75671L0.331035 7.25671L1.66896 8.74329ZM16.043 6.26014L16.5963 7.42809L18.4037 6.57191L17.8505 5.40396L16.043 6.26014ZM6.65079 4.25926C9.67554 1.66661 14.3376 2.65979 16.043 6.26014L17.8505 5.40396C15.5805 0.61182 9.37523 -0.710131 5.34921 2.74074L6.65079 4.25926Z"
fill="currentColor"
/>
</svg>
}
min={10}
max={150}
value={brushSize}
onChange={setBrushSize}
onClick={undo}
disabled={renders.length === 0}
/>
<div>
<Button
className="mr-2"
icon={<ArrowsExpandIcon className="w-6 h-6" />}
disabled={scale === minScale}
onClick={resetZoom}
/>
<Button
className="mr-2"
icon={
<svg
width="19"
height="9"
viewBox="0 0 19 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
>
<path
d="M2 1C2 0.447715 1.55228 0 1 0C0.447715 0 0 0.447715 0 1H2ZM1 8H0V9H1V8ZM8 9C8.55228 9 9 8.55229 9 8C9 7.44771 8.55228 7 8 7V9ZM16.5963 7.42809C16.8327 7.92721 17.429 8.14016 17.9281 7.90374C18.4272 7.66731 18.6402 7.07103 18.4037 6.57191L16.5963 7.42809ZM16.9468 5.83205L17.8505 5.40396L16.9468 5.83205ZM0 1V8H2V1H0ZM1 9H8V7H1V9ZM1.66896 8.74329L6.66896 4.24329L5.33104 2.75671L0.331035 7.25671L1.66896 8.74329ZM16.043 6.26014L16.5963 7.42809L18.4037 6.57191L17.8505 5.40396L16.043 6.26014ZM6.65079 4.25926C9.67554 1.66661 14.3376 2.65979 16.043 6.26014L17.8505 5.40396C15.5805 0.61182 9.37523 -0.710131 5.34921 2.74074L6.65079 4.25926Z"
fill="currentColor"
/>
</svg>
}
onClick={undo}
disabled={renders.length === 0}
/>
<Button
className="mr-2"
icon={<EyeIcon className="w-6 h-6" />}
onDown={ev => {
ev.preventDefault()
setShowSeparator(true)
setShowOriginal(true)
}}
onUp={() => {
setShowOriginal(false)
setTimeout(() => setShowSeparator(false), 300)
}}
disabled={renders.length === 0}
>
{undefined}
</Button>
<Button
icon={<DownloadIcon className="w-6 h-6" />}
disabled={!renders.length}
onClick={download}
>
{undefined}
</Button>
</div>
<div
className="absolute bg-black backdrop-blur backdrop-filter bg-opacity-10 rounded-xl"
style={{
height: '58px',
width: '600px',
zIndex: -1,
marginLeft: '-1px',
<Button
icon={<EyeIcon />}
onDown={ev => {
ev.preventDefault()
setShowOriginal(true)
}}
onUp={() => {
setShowOriginal(false)
}}
disabled={renders.length === 0}
>
{undefined}
</div>
</Button>
<Button
icon={<DownloadIcon />}
disabled={!renders.length}
onClick={download}
>
{undefined}
</Button>
</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
import React, { FocusEvent, useCallback, useRef } from 'react'
const sizes = ['720', '1080', '2000', 'Original']
type SizeSelectorProps = {
value: string
originalWidth: number
originalHeight: number
onChange: (value: string) => void
}
export default function SizeSelector(props: SizeSelectorProps) {
const { value, originalHeight, originalWidth, onChange } = props
const selectRef = useRef()
const getSizeShowName = (size: string) => {
if (size === 'Original') {
return `${originalWidth}x${originalHeight}`
}
const length: number = parseInt(size, 10)
const longSide: number =
originalWidth > originalHeight ? originalWidth : originalHeight
const scale = length / longSide
if (originalWidth > originalHeight) {
const newHeight = Math.ceil(scale * originalHeight)
return `${size}x${newHeight}`
}
const newWidth = Math.ceil(scale * originalWidth)
return `${newWidth}x${size}`
}
const onButtonFocus = (e: FocusEvent<any>) => {
e.currentTarget.blur()
}
const getValidSizes = useCallback((): string[] => {
const longSide: number =
originalWidth > originalHeight ? originalWidth : originalHeight
const validSizes = []
for (let i = 0; i < sizes.length; i += 1) {
const s = sizes[i]
if (s === 'Original') {
validSizes.push(s)
} else if (parseInt(s, 10) <= longSide) {
validSizes.push(s)
}
}
return validSizes
}, [originalHeight, originalWidth])
const getValidSize = useCallback(() => {
if (getValidSizes().indexOf(value) === -1) {
return getValidSizes()[0]
}
return value
}, [value, getValidSizes])
const sizeChangeHandler = (e: any) => {
onChange(e.target.value)
e.target.blur()
}
return (
<div className="editor-size-selector">
<p>Size:</p>
<select value={getValidSize()} onChange={sizeChangeHandler}>
{getValidSizes().map(size => (
<option key={size} value={size}>
{getSizeShowName(size)}
</option>
))}
</select>
</div>
)
}

View File

@ -14,14 +14,9 @@ export default function Slider(props: SliderProps) {
const step = ((max || 100) - (min || 0)) / 100
return (
<div className="inline-flex items-center space-x-4 text-black">
<div className="editor-brush-slider">
<span>{label}</span>
<input
className={[
'appearance-none rounded-lg h-4',
'bg-primary',
'w-24 md:w-auto',
].join(' ')}
type="range"
step={step}
min={min}

View File

@ -0,0 +1,35 @@
@use '../../styles/Mixins/' as *;
.file-select-label {
display: grid;
cursor: pointer;
border: 2px dashed var(--border-color);
border-radius: 0.5rem;
min-width: 600px;
@include mobile {
min-width: 300px;
}
&:hover,
.file-select-label-hover {
color: black;
background-color: var(--yellow-accent);
}
}
.file-select-container {
display: grid;
padding: 4rem;
width: 100%;
height: 100%;
input {
display: none;
}
}
.file-select-message {
font-family: 'WorkSans-Bold';
text-align: center;
}

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react'
import useResolution from '../../hooks/useResolution'
type FileSelectProps = {
onSelection: (file: File) => void
@ -10,6 +11,8 @@ export default function FileSelect(props: FileSelectProps) {
const [dragHover, setDragHover] = useState(false)
const [uploadElemId] = useState(`file-upload-${Math.random().toString()}`)
const resolution = useResolution()
function onFileSelected(file: File) {
if (!file) {
return
@ -94,17 +97,11 @@ export default function FileSelect(props: FileSelectProps) {
}
return (
<label
htmlFor={uploadElemId}
className="block w-full h-full group relative cursor-pointer rounded-md font-medium focus-within:outline-none"
>
<label htmlFor={uploadElemId} className="file-select-label">
<div
className={[
'w-full h-full flex items-center justify-center px-6 pt-5 pb-6 text-md',
'border-2 border-dashed rounded-md',
'hover:border-black hover:bg-primary',
'text-center',
dragHover ? 'border-black bg-primary' : 'bg-gray-100 border-gray-300',
'file-select-container',
dragHover ? 'file-select-label-hover' : '',
].join(' ')}
onDrop={handleDrop}
onDragOver={ev => {
@ -118,7 +115,6 @@ export default function FileSelect(props: FileSelectProps) {
id={uploadElemId}
name={uploadElemId}
type="file"
className="sr-only"
onChange={ev => {
const file = ev.currentTarget.files?.[0]
if (file) {
@ -127,8 +123,11 @@ export default function FileSelect(props: FileSelectProps) {
}}
accept="image/png, image/jpeg"
/>
<p className="hidden sm:block">Click here or drag an image file</p>
<p className="sm:hidden">Tap here to load your picture</p>
<p className="file-select-message">
{resolution === 'desktop'
? 'Click here or drag an image file'
: 'Tap here to load your picture'}
</p>
</div>
</label>
)

View File

@ -0,0 +1,16 @@
header {
grid-area: main-content;
padding: 1rem 2rem;
position: absolute;
top: 0;
display: grid;
grid-template-columns: repeat(2, max-content);
width: 100%;
grid-template-columns: repeat(2, auto);
}
.shortcuts {
justify-self: end;
margin-right: 4rem;
z-index: 1;
}

View File

@ -0,0 +1,31 @@
import { ArrowLeftIcon } from '@heroicons/react/outline'
import React from 'react'
import { useSetRecoilState } from 'recoil'
import { fileState } from '../../store/Atoms'
import Button from '../shared/Button'
import Shortcuts from '../Shortcuts/Shortcuts'
import useResolution from '../../hooks/useResolution'
const Header = () => {
const setFile = useSetRecoilState(fileState)
const resolution = useResolution()
const renderHeader = () => {
return (
<header>
<Button
icon={<ArrowLeftIcon className="w-6 h-6" />}
onClick={() => {
setFile(undefined)
}}
>
{resolution === 'desktop' ? 'Start New' : undefined}
</Button>
<Shortcuts />
</header>
)
}
return renderHeader()
}
export default Header

View File

@ -0,0 +1,30 @@
@use '../../styles/Mixins/' as *;
.landing-page {
display: grid;
place-self: center;
justify-items: center;
row-gap: 2rem;
grid-auto-rows: max-content;
@include mobile {
padding: 1rem;
}
h1 {
text-align: center;
font-size: 1.4rem;
@include mobile {
font-size: 1.2rem;
}
}
a {
color: var(--link-color);
}
}
.landing-file-selector {
display: grid;
}

View File

@ -0,0 +1,26 @@
import React from 'react'
import { useSetRecoilState } from 'recoil'
import { fileState } from '../../store/Atoms'
import FileSelect from '../FileSelect/FileSelect'
const LandingPage = () => {
const setFile = useSetRecoilState(fileState)
return (
<div className="landing-page">
<h1>
Image inpainting powered by 🦙
<a href="https://github.com/saic-mdal/lama">LaMa</a>
</h1>
<div className="landing-file-selector">
<FileSelect
onSelection={async f => {
setFile(f)
}}
/>
</div>
</div>
)
}
export default LandingPage

View File

@ -1,50 +0,0 @@
import { XIcon } from '@heroicons/react/outline'
import React, { ReactNode, useRef } from 'react'
import { useClickAway, useKey } from 'react-use'
import Button from './Button'
interface ModalProps {
children?: ReactNode
onClose?: () => void
className?: string
}
export default function Modal(props: ModalProps) {
const { children, onClose, className } = props
const ref = useRef(null)
useClickAway(ref, () => {
onClose?.()
})
useKey('Escape', onClose, {
event: 'keydown',
})
return (
<div
className={[
'absolute w-full h-full flex justify-center items-center',
'z-20',
'bg-gray-300 bg-opacity-40 backdrop-filter backdrop-blur-md',
].join(' ')}
>
<div
ref={ref}
className={`bg-white max-w-4xl relative rounded-md shadow-md ${
className || 'p-8 sm:p-12'
}`}
>
<Button
icon={<XIcon className="w-6 h-6" />}
className={[
'absolute right-4 top-4 rounded-full bg-gray-100 w-10 h-10',
'flex justify-center items-center py-0 px-0 sm:px-0',
].join(' ')}
onClick={onClose}
/>
{children}
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
.modal-shortcuts {
grid-area: main-content;
background-color: var(--modal-bg);
color: var(--modal-text-color);
box-shadow: 0px 0px 20px rgb(0, 0, 40, 0.2);
}
.shortcut-options {
display: grid;
row-gap: 1rem;
.shortcut-option {
display: grid;
grid-template-columns: repeat(2, auto);
column-gap: 6rem;
align-items: center;
}
.shortcut-key {
font-family: 'WorkSans-Bold';
background-color: var(--modal-hotkey-bg);
padding: 0.4rem 1rem;
width: max-content;
border-radius: 0.4rem;
}
.shortcut-description {
justify-self: end;
text-align: right;
width: 15rem;
}
}

View File

@ -0,0 +1,54 @@
import React from 'react'
import { useKeyPressEvent } from 'react-use'
import { useRecoilState } from 'recoil'
import { shortcutsState } from '../../store/Atoms'
import Button from '../shared/Button'
const Shortcuts = () => {
const [shortcutVisibility, setShortcutState] = useRecoilState(shortcutsState)
const shortcutStateHandler = () => {
setShortcutState(prevShortcutState => {
return !prevShortcutState
})
}
useKeyPressEvent('h', () => {
shortcutStateHandler()
})
return (
<div className="shortcuts">
<Button
onClick={shortcutStateHandler}
disabled={shortcutVisibility}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
role="img"
width="28"
height="28"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 16 16"
>
<rect
x="0"
y="0"
width="16"
height="16"
fill="none"
stroke="none"
/>
<g fill="currentColor">
<path d="M14 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h12zM2 4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H2z" />
<path d="M13 10.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm0-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5 0A.25.25 0 0 1 8.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 8 8.75v-.5zm2 0a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25v-.5zm1 2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5-2A.25.25 0 0 1 6.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 6 8.75v-.5zm-2 0A.25.25 0 0 1 4.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 4 8.75v-.5zm-2 0A.25.25 0 0 1 2.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 2 8.75v-.5zm11-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0A.25.25 0 0 1 9.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 9 6.75v-.5zm-2 0A.25.25 0 0 1 7.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 7 6.75v-.5zm-2 0A.25.25 0 0 1 5.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 5 6.75v-.5zm-3 0A.25.25 0 0 1 2.25 6h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5A.25.25 0 0 1 2 6.75v-.5zm0 4a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm2 0a.25.25 0 0 1 .25-.25h5.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-5.5a.25.25 0 0 1-.25-.25v-.5z" />
</g>
</svg>
}
/>
</div>
)
}
export default Shortcuts

View File

@ -0,0 +1,66 @@
import React, { ReactNode } from 'react'
import { useSetRecoilState } from 'recoil'
import { shortcutsState } from '../../store/Atoms'
import Modal from '../shared/Modal'
interface Shortcut {
children: ReactNode
content: string
}
function ShortCut(props: Shortcut) {
const { children, content } = props
return (
<div className="shortcut-option">
<div className="shortcut-key">{children}</div>
<div className="shortcut-description">{content}</div>
</div>
)
}
export default function ShortcutsModal() {
const setShortcutState = useSetRecoilState(shortcutsState)
const shortcutStateHandler = () => {
setShortcutState(prevShortcutState => !prevShortcutState)
}
return (
<Modal
onClose={shortcutStateHandler}
title="Hotkeys"
className="modal-shortcuts"
>
<div className="shortcut-options">
<ShortCut content="Enable multi-stroke mask drawing">
<p>Hold Cmd/Ctrl</p>
</ShortCut>
<ShortCut content="Undo inpainting">
<p>Cmd/Ctrl + Z</p>
</ShortCut>
<ShortCut content="Pan">
<p>Space & Drag</p>
</ShortCut>
<ShortCut content="View original image">
<p>Hold Tab</p>
</ShortCut>
<ShortCut content="Reset zoom/pan & Cancel mask drawing">
<p>Esc</p>
</ShortCut>
<ShortCut content="Decrease Brush Size">
<p>[</p>
</ShortCut>
<ShortCut content="Increase Brush Size">
<p>]</p>
</ShortCut>
<ShortCut content="Toggle Dark Mode">
<p>Shift + D</p>
</ShortCut>
<ShortCut content="Toggle Hotkeys Panel">
<p>H</p>
</ShortCut>
</div>
</Modal>
)
}

View File

@ -1,55 +0,0 @@
import React, { ReactNode } from 'react'
import Modal from './Modal'
interface Shortcut {
children: ReactNode
content: string
}
function ShortCut(props: Shortcut) {
const { children, content } = props
return (
<div className="h-full flex flex-row space-x-6 justify-between">
<div className="mr-12 border-2 rounded-xl px-2 py-1">{children}</div>
<div className="flex flex-col justify-center">{content}</div>
</div>
)
}
interface ShortcutsModalProps {
onClose?: () => void
}
export default function ShortcutsModal(props: ShortcutsModalProps) {
const { onClose } = props
return (
<Modal onClose={onClose} className="h-full sm:h-auto p-0 sm:p-0">
<div className="h-full sm:h-auto flex flex-col sm:flex-row">
<div className="flex sm:p-14 flex flex-col justify-center space-y-6">
<ShortCut content="Enable multi-stroke mask drawing">
<p>Hold Cmd/Ctrl</p>
</ShortCut>
<ShortCut content="Undo inpainting">
<p>Cmd/Ctrl + z</p>
</ShortCut>
<ShortCut content="Pan">
<p>Space & Drag</p>
</ShortCut>
<ShortCut content="View original image">
<p>Hold Tab</p>
</ShortCut>
<ShortCut content="Reset zoom/pan & Cancel mask drawing">
<p>Esc</p>
</ShortCut>
<ShortCut content="Decrease Brush Size">
<p>[</p>
</ShortCut>
<ShortCut content="Increase Brush Size">
<p>]</p>
</ShortCut>
</div>
</div>
</Modal>
)
}

View File

@ -1,120 +0,0 @@
import React, { FocusEvent, useCallback } from 'react'
import { Listbox } from '@headlessui/react'
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid'
const sizes = ['720', '1080', '2000', 'Original']
type SizeSelectorProps = {
value: string
originalWidth: number
originalHeight: number
onChange: (value: string) => void
}
export default function SizeSelector(props: SizeSelectorProps) {
const { value, originalHeight, originalWidth, onChange } = props
const getSizeShowName = (size: string) => {
if (size === 'Original') {
return `${originalWidth}x${originalHeight}`
}
const length: number = parseInt(size, 10)
const longSide: number =
originalWidth > originalHeight ? originalWidth : originalHeight
const scale = length / longSide
if (originalWidth > originalHeight) {
const newHeight = Math.ceil(scale * originalHeight)
return `${size}x${newHeight}`
}
const newWidth = Math.ceil(scale * originalWidth)
return `${newWidth}x${size}`
}
const onButtonFocus = (e: FocusEvent<any>) => {
e.currentTarget.blur()
}
const getValidSizes = useCallback((): string[] => {
const longSide: number =
originalWidth > originalHeight ? originalWidth : originalHeight
const validSizes = []
for (let i = 0; i < sizes.length; i += 1) {
const s = sizes[i]
if (s === 'Original') {
validSizes.push(s)
} else if (parseInt(s, 10) <= longSide) {
validSizes.push(s)
}
}
return validSizes
}, [originalHeight, originalWidth])
const getValidSize = useCallback(() => {
if (getValidSizes().indexOf(value) === -1) {
return getValidSizes()[0]
}
return value
}, [value, getValidSizes])
return (
<div className="w-32">
<Listbox value={getValidSize()} onChange={onChange}>
<div className="relative">
<Listbox.Options
style={{ top: `-${getValidSizes().length * 40 + 5}px` }}
className="absolute mb-1 w-full overflow-auto text-base bg-opacity-10 bg-black backdrop-blur rounded-md max-h-60 outline-none sm:text-sm"
>
{getValidSizes().map(size => (
<Listbox.Option
key={size}
className={({ active }) =>
`${active ? 'bg-black bg-opacity-10' : 'text-gray-900'}
cursor-default select-none relative py-2 pl-4 pr-4`
}
value={size}
>
{({ selected, active }) => (
<>
<span
className={`${
selected ? 'font-medium' : 'font-normal'
} block truncate`}
>
{getSizeShowName(size)}
</span>
{/* {selected ? (
<span
className={`${
active ? 'text-amber-600' : 'text-amber-600'
}
absolute inset-y-0 left-0 flex items-center pl-3`}
>
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null} */}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
<Listbox.Button
onFocus={onButtonFocus}
className="relative w-full inline-flex w-full px-4 py-2 text-sm font-medium bg-black rounded-md bg-opacity-10 focus:outline-none "
>
<span className="block truncate">
{getSizeShowName(getValidSize())}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon
className="w-5 h-5 text-gray-400"
aria-hidden="true"
/>
</span>
</Listbox.Button>
</div>
</Listbox>
</div>
)
}

View File

@ -0,0 +1,23 @@
import React from 'react'
import { useRecoilValue } from 'recoil'
import Editor from './Editor/Editor'
import { shortcutsState } from '../store/Atoms'
import Header from './Header/Header'
import ShortcutsModal from './Shortcuts/ShortcutsModal'
interface WorkspaceProps {
file: File
}
const Workspace = ({ file }: WorkspaceProps) => {
const shortcutVisbility = useRecoilValue(shortcutsState)
return (
<>
<Header />
<Editor file={file} />
{shortcutVisbility ? <ShortcutsModal /> : null}
</>
)
}
export default Workspace

View File

@ -0,0 +1,28 @@
.btn-primary {
display: grid;
grid-auto-flow: column;
column-gap: 1rem;
background-color: var(--btn-primary-bg);
color: black;
font-family: 'WorkSans-Bold', sans-serif;
width: max-content;
padding: 0.5rem;
place-items: center;
border-radius: 0.5rem;
z-index: 1;
cursor: pointer;
&:hover {
background-color: var(--btn-primary-hover-bg);
}
svg {
width: 20px;
height: auto;
}
}
.btn-primary-disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@ -1,10 +1,9 @@
import React, { ReactNode, useState } from 'react'
import React, { ReactNode } from 'react'
interface ButtonProps {
children?: ReactNode
className?: string
icon?: ReactNode
primary?: boolean
disabled?: boolean
onKeyDown?: () => void
onClick?: () => void
@ -18,23 +17,11 @@ export default function Button(props: ButtonProps) {
className,
disabled,
icon,
primary,
onKeyDown,
onClick,
onDown,
onUp,
} = props
const [active, setActive] = useState(false)
let background = ''
if (primary && !disabled) {
background = 'bg-primary hover:bg-black hover:text-white'
}
if (active) {
background = 'bg-black text-white'
}
if (!primary && !active) {
background = 'hover:bg-primary'
}
const blurOnClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.blur()
@ -47,24 +34,21 @@ export default function Button(props: ButtonProps) {
onKeyDown={onKeyDown}
onClick={blurOnClick}
onPointerDown={(ev: React.PointerEvent<HTMLDivElement>) => {
setActive(true)
onDown?.(ev.nativeEvent)
}}
onPointerUp={(ev: React.PointerEvent<HTMLDivElement>) => {
setActive(false)
onUp?.(ev.nativeEvent)
}}
tabIndex={-1}
className={[
'inline-flex py-3 px-3 rounded-md cursor-pointer',
children ? 'space-x-3' : '',
background,
disabled ? 'pointer-events-none opacity-50' : '',
'btn-primary',
children ? 'btn-primary-content' : '',
disabled ? 'btn-primary-disabled' : '',
className,
].join(' ')}
>
{icon}
<span className="whitespace-nowrap select-none">{children}</span>
{children ? <span>{children}</span> : null}
</div>
)
}

View File

@ -7,9 +7,5 @@ interface LinkProps {
export default function Link(props: LinkProps) {
const { children, href } = props
return (
<a href={href} className="font-black underline">
{children}
</a>
)
return <a href={href}>{children}</a>
}

View File

@ -0,0 +1,19 @@
.modal {
display: grid;
grid-auto-rows: max-content;
row-gap: 2rem;
place-self: center;
padding: 2rem;
border-radius: 0.95rem;
z-index: 9999;
.modal-header {
display: grid;
grid-template-columns: repeat(2, auto);
align-items: center;
.btn-primary {
justify-self: end;
}
}
}

View File

@ -0,0 +1,34 @@
import { XIcon } from '@heroicons/react/outline'
import React, { ReactNode, useRef } from 'react'
import { useClickAway, useKey } from 'react-use'
import Button from './Button'
interface ModalProps {
children?: ReactNode
onClose?: () => void
title: string
className?: string
}
export default function Modal(props: ModalProps) {
const { children, onClose, className, title } = props
const ref = useRef(null)
useClickAway(ref, () => {
onClose?.()
})
useKey('Escape', onClose, {
event: 'keydown',
})
return (
<div ref={ref} className={`modal ${className}`}>
<div className="modal-header">
<h3>{title}</h3>
<Button icon={<XIcon />} onClick={onClose} />
</div>
{children}
</div>
)
}

View File

@ -0,0 +1,29 @@
.theme-changer {
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
background: transparent;
box-shadow: inset 4px 10px 0px rgb(80, 80, 80);
transform: rotate(-75deg);
transition: all 0.2s ease-in;
margin: 1rem;
position: absolute;
right: 1rem;
top: 0.25rem;
z-index: 10;
outline: none;
&:hover {
cursor: pointer;
}
}
[data-theme='dark'] {
.theme-changer {
background: rgb(255, 190, 0);
box-shadow: none;
transform: rotate(-75deg);
outline: none;
}
}

View File

@ -0,0 +1,25 @@
import React from 'react'
import { atom, useRecoilState } from 'recoil'
export const themeState = atom({
key: 'themeState',
default: 'dark',
})
export const ThemeChanger = () => {
const [theme, setTheme] = useRecoilState(themeState)
const themeSwitchHandler = () => {
const newTheme = theme === 'light' ? 'dark' : 'light'
setTheme(newTheme)
}
return (
<button
type="button"
className="theme-changer"
onClick={themeSwitchHandler}
aria-label="Switch Theme"
/>
)
}

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

@ -1,6 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom'
import './styles/index.css'
import './styles/_index.scss'
import { RecoilRoot } from 'recoil'
import App from './App'
ReactDOM.render(<App />, document.getElementById('root'))
ReactDOM.render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>,
document.getElementById('root')
)

View File

@ -0,0 +1,11 @@
import { atom } from 'recoil'
export const fileState = atom<File | undefined>({
key: 'fileState',
default: undefined,
})
export const shortcutsState = atom<boolean>({
key: 'shortcutsState',
default: false,
})

View File

@ -0,0 +1,11 @@
.lama-cleaner {
display: grid;
grid-template-areas: 'main-content';
width: 100vw;
height: 100vh;
background-color: var(--page-bg);
color: var(--page-text-color);
transition-property: background-color, color;
transition-duration: 0.2s;
transition-timing-function: repeat(2, ease-out);
}

View File

@ -0,0 +1,40 @@
// Define Breakpoints
$breakpoints: (
mobile-res: 768px,
desktop-res: 1224px,
);
// Calcualte Min and Max Widths Based on Breakpoints
$sizes: (
mobile-max-width: calc(map-get($breakpoints, mobile-res) - 1px),
tablet-min-width: map-get($breakpoints, mobile-res),
tablet-max-width: calc(map-get($breakpoints, desktop-res) - 1px),
desktop-min-width: map-get($breakpoints, desktop-res),
);
// Mobile Mixin
@mixin mobile {
@media screen and (max-width: map-get($sizes, mobile-max-width)) {
@content;
}
}
// Tablet Mixin
@mixin tablet {
@media screen and (min-width: map-get($sizes, tablet-min-width)) and (max-width: map-get($sizes, tablet-max-width)) {
@content;
}
}
// Desktop Mixin
@mixin desktop {
@media screen and (min-width: map-get($sizes, desktop-min-width)) {
@content;
}
}
@mixin large {
@media screen and (min-width: map-get($sizes, tablet-min-width)) {
@content;
}
}

View File

@ -0,0 +1,2 @@
@forward './Mixins';
@forward './MediaQueries';

View File

@ -0,0 +1,21 @@
@keyframes pulsing {
0% {
opacity: 1;
}
50% {
opacity: 0.75;
background-color: var(--animation-pulsing-bg);
}
100% {
opacity: 1;
}
}
@keyframes opacityReveal {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@ -0,0 +1,23 @@
:root {
// General
// Theme
--page-bg: rgb(240, 240, 250);
--page-text-color: rgb(0, 0, 0);
--link-color: rgb(0, 0, 0);
--yellow-accent: rgb(255, 190, 0);
--border-color: rgb(100, 100, 120);
// Editor
--editor-toolkit-bg: rgb(240, 240, 250, 0.15);
// Modal
--modal-bg: var(--page-bg);
--modal-text-color: rgb(0, 0, 0);
--modal-hotkey-bg: rgb(240, 240, 240);
// Shared
--btn-primary-bg: rgb(210, 210, 220);
--btn-primary-hover-bg: var(--yellow-accent);
--animation-pulsing-bg: rgb(255, 255, 255, 0.5);
}

View File

@ -0,0 +1,23 @@
[data-theme='dark'] {
// General
// Theme
--page-bg: rgb(20, 20, 30);
--page-text-color: rgb(200, 200, 210);
--link-color: rgb(255, 190, 0);
--yellow-accent: rgb(255, 190, 0);
--border-color: rgb(100, 100, 120);
// Editor
--editor-toolkit-bg: rgb(20, 20, 30, 0.15);
// Modal
--modal-bg: var(--page-bg);
--modal-text-color: var(--page-text-color);
--modal-hotkey-bg: rgb(60, 60, 90);
// Shared
--btn-primary-bg: rgb(140, 140, 180);
--btn-primary-hover-bg: var(--yellow-accent);
--animation-pulsing-bg: rgb(240, 240, 255);
}

View File

@ -0,0 +1,19 @@
@font-face {
font-family: 'WorkSans';
src: url('../media/fonts/Work_Sans/WorkSans-Regular.ttf');
}
@font-face {
font-family: 'WorkSans-Semibold';
src: url('../media/fonts/Work_Sans/WorkSans-SemiBold.ttf');
}
@font-face {
font-family: 'WorkSans-Bold';
src: url('../media/fonts/Work_Sans/WorkSans-Bold.ttf');
}
@font-face {
font-family: 'WorkSans-Black';
src: url('../media/fonts/Work_Sans/WorkSans-Black.ttf');
}

View File

@ -0,0 +1,32 @@
// General
@use './Fonts';
@use './Colors';
@use './ColorsDark';
@use './Animations';
// App
@use './App';
@use '../components/Editor/Editor';
@use '../components/LandingPage/LandingPage';
@use '../components/Header/Header';
@use '../components/Shortcuts/Shortcuts';
// Shared
@use '../components/shared/ThemeChanger';
@use '../components/FileSelect/FileSelect';
@use '../components/shared/Button';
@use '../components/shared/Modal';
// Main CSS
*,
*:before,
*:after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
font-family: 'WorkSans', sans-serif;
}

View File

@ -1,46 +0,0 @@
@tailwind base;
/* Your own custom base styles */
/* Start purging... */
@tailwind components;
/* Stop purging. */
/* Your own custom component styles */
/* Start purging... */
@tailwind utilities;
/* Stop purging. */
/* Your own custom utilities */
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@600;900&display=swap');
html,
body {
width: 100%;
height: 100%;
font-family: 'Work Sans';
font-weight: 600;
overflow: hidden;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 17px;
height: 17px;
background: black;
border-radius: 50%;
}
*:not(input):not(textarea) {
user-select: none; /* disable selection/Copy of UIWebView */
-webkit-user-select: none; /* disable selection/Copy of UIWebView */
-webkit-touch-callout: none; /* disable the IOS popup when long-press on a link */
}
@supports (-webkit-touch-callout: none) {
.full-visible-h-safari {
height: calc(100% - 80px); /* -webkit-fill-available;*/
}
}

View File

@ -1,37 +0,0 @@
module.exports = {
mode: 'jit',
theme: {
extend: {
animation: {
'pulse-fast': 'pulse 0.7s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
colors: {
primary: '#BDFF01',
},
keyframes: {
pulse: {
'0%, 100%': { opacity: 0.8 },
'50%': { opacity: 0.7 },
},
},
},
},
variants: {},
plugins: [],
purge: {
// Filenames to scan for classes
content: [
'./src/**/*.html',
'./src/**/*.js',
'./src/**/*.jsx',
'./src/**/*.ts',
'./src/**/*.tsx',
'./public/index.html',
],
// Options passed to PurgeCSS
options: {
// Whitelist specific selectors by name
// safelist: [],
},
},
}