web/lightbox: add control buttons

This commit is contained in:
Tulir Asokan 2024-10-12 00:24:20 +03:00
parent 26346df920
commit 6bb1d4477c
13 changed files with 1082 additions and 21 deletions

View file

@ -20,7 +20,7 @@ export default tseslint.config(
"import": pluginImport, "import": pluginImport,
}, },
settings: { settings: {
"import/extensions": [".ts", ".tsx", ".css"], "import/extensions": [".ts", ".tsx", ".css", ".svg"],
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
@ -34,6 +34,11 @@ export default tseslint.config(
"named": true, "named": true,
"warnOnUnassignedImports": true, "warnOnUnassignedImports": true,
"pathGroups": [{ "pathGroups": [{
"pattern": "*.svg?react",
"patternOptions": {"matchBase": true},
"group": "sibling",
"position": "after",
}, {
"pattern": "*.css", "pattern": "*.css",
"patternOptions": {"matchBase": true}, "patternOptions": {"matchBase": true},
"group": "sibling", "group": "sibling",

975
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,8 @@
"globals": "^15.9.0", "globals": "^15.9.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.7.0", "typescript-eslint": "^8.7.0",
"vite": "^5.4.8" "vite": "^5.4.8",
"vite-plugin-svgr": "^4.2.0"
}, },
"overrides": { "overrides": {
"@types/react": "npm:types-react@rc", "@types/react": "npm:types-react@rc",

1
web/src/icons/close.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg>

After

Width:  |  Height:  |  Size: 279 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M520-80q-51 0-100-14t-92-42l58-58q31 17 65 25.5t69 8.5q117 0 198.5-81.5T800-440q0-117-81.5-198.5T520-720h-6l62 62-56 58-160-160 160-160 56 58-62 62h6q150 0 255 105t105 255q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T520-80ZM280-200 40-440l240-240 240 240-240 240Zm0-114 126-126-126-126-126 126 126 126Zm0-126Z"/></svg>

After

Width:  |  Height:  |  Size: 433 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-80q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T80-440q0-150 105-255t255-105h6l-62-62 56-58 160 160-160 160-56-58 62-62h-6q-117 0-198.5 81.5T160-440q0 117 81.5 198.5T440-160q35 0 69-8.5t65-25.5l58 58q-43 28-92 42T440-80Zm240-120L440-440l240-240 240 240-240 240Zm0-114 126-126-126-126-126 126 126 126Zm0-126Z"/></svg>

After

Width:  |  Height:  |  Size: 436 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Zm-40-60v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 427 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400ZM280-540v-80h200v80H280Z"/></svg>

After

Width:  |  Height:  |  Size: 401 B

View file

@ -6,8 +6,35 @@ div.overlay {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&.lightbox > div.controls {
position: fixed;
top: .5rem;
right: .5rem;
display: flex;
z-index: 1;
> button, > a {
display: flex;
justify-content: center;
align-items: center;
background: none;
border: none;
color: #ccc;
width: 48px;
height: 48px;
padding: 0;
> svg {
width: 32px;
height: 32px;
}
}
}
&.lightbox > img { &.lightbox > img {
max-width: 75%; max-width: 75%;
max-height: 75%; max-height: 75%;
transition: rotate 0.2s;
} }
} }

View file

@ -14,6 +14,12 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component, RefObject, createContext, createRef, useCallback, useState } from "react" import React, { Component, RefObject, createContext, createRef, useCallback, useState } from "react"
import DownloadIcon from "../icons/download.svg?react"
import CloseIcon from "../icons/close.svg?react"
import RotateLeftIcon from "../icons/rotate-left.svg?react"
import RotateRightIcon from "../icons/rotate-right.svg?react"
import ZoomInIcon from "../icons/zoom-in.svg?react"
import ZoomOutIcon from "../icons/zoom-out.svg?react"
import "./Lightbox.css" import "./Lightbox.css"
const isTouchDevice = window.ontouchstart !== undefined const isTouchDevice = window.ontouchstart !== undefined
@ -56,7 +62,9 @@ export interface LightboxProps extends LightboxParams {
} }
export class Lightbox extends Component<LightboxProps> { export class Lightbox extends Component<LightboxProps> {
transform = { zoom: 1, x: 0, y: 0 } translate = { x: 0, y: 0 }
zoom = 1
rotate = 0
maybePanning = false maybePanning = false
readonly ref: RefObject<HTMLImageElement | null> readonly ref: RefObject<HTMLImageElement | null>
@ -65,8 +73,12 @@ export class Lightbox extends Component<LightboxProps> {
this.ref = createRef<HTMLImageElement>() this.ref = createRef<HTMLImageElement>()
} }
transformString = () => { get style() {
return `translate(${this.transform.x}px, ${this.transform.y}px) scale(${this.transform.zoom})` return {
translate: `${this.translate.x}px ${this.translate.y}px`,
rotate: `${this.rotate}deg`,
scale: `${this.zoom}`,
}
} }
onClick = () => { onClick = () => {
@ -77,7 +89,9 @@ export class Lightbox extends Component<LightboxProps> {
this.ref.current.style.cursor = "auto" this.ref.current.style.cursor = "auto"
this.maybePanning = false this.maybePanning = false
} else { } else {
this.transform = { zoom: 1, x: 0, y: 0 } this.translate = { x: 0, y: 0 }
this.rotate = 0
this.zoom = 1
this.props.onClose() this.props.onClose()
} }
} }
@ -87,14 +101,16 @@ export class Lightbox extends Component<LightboxProps> {
return return
} }
evt.preventDefault() evt.preventDefault()
const oldZoom = this.transform.zoom const oldZoom = this.zoom
const delta = -evt.deltaY / 1000 const delta = -evt.deltaY / 1000
const newDelta = this.transform.zoom + delta * this.transform.zoom const newDelta = this.zoom + delta * this.zoom
this.transform.zoom = Math.min(Math.max(newDelta, 0.01), 10) this.zoom = Math.min(Math.max(newDelta, 0.01), 10)
const zoomDelta = this.transform.zoom - oldZoom const zoomDelta = this.zoom - oldZoom
this.transform.x += zoomDelta * (this.ref.current.clientWidth / 2 - evt.nativeEvent.offsetX) this.translate.x += zoomDelta * (this.ref.current.clientWidth / 2 - evt.nativeEvent.offsetX)
this.transform.y += zoomDelta * (this.ref.current.clientHeight / 2 - evt.nativeEvent.offsetY) this.translate.y += zoomDelta * (this.ref.current.clientHeight / 2 - evt.nativeEvent.offsetY)
this.ref.current.style.transform = this.transformString() const style = this.style
this.ref.current.style.translate = style.translate
this.ref.current.style.scale = style.scale
} }
onMouseDown = (evt: React.MouseEvent) => { onMouseDown = (evt: React.MouseEvent) => {
@ -114,24 +130,45 @@ export class Lightbox extends Component<LightboxProps> {
return return
} }
evt.preventDefault() evt.preventDefault()
this.transform.x += evt.movementX this.translate.x += evt.movementX
this.transform.y += evt.movementY this.translate.y += evt.movementY
this.ref.current.style.transform = this.transformString() this.ref.current.style.translate = this.style.translate
this.ref.current.style.cursor = "grabbing" this.ref.current.style.cursor = "grabbing"
} }
get style() { transformer = (callback: () => void)=> (evt: React.MouseEvent) => {
return { evt.stopPropagation()
transform: this.transformString(), if (!this.ref.current) {
return
} }
callback()
const style = this.style
this.ref.current.style.rotate = style.rotate
this.ref.current.style.scale = style.scale
} }
stopPropagation = (evt: React.MouseEvent) => evt.stopPropagation()
zoomIn = this.transformer(() => this.zoom = Math.min(this.zoom * 1.1, 10))
zoomOut = this.transformer(() => this.zoom = Math.max(this.zoom / 1.1, 0.01))
rotateLeft = this.transformer(() => this.rotate -= 90)
rotateRight = this.transformer(() => this.rotate += 90)
render() { render() {
return <div return <div
className="overlay lightbox" className="overlay lightbox"
onClick={this.onClick} onClick={this.onClick}
onMouseMove={isTouchDevice ? undefined : this.onMouseMove} onMouseMove={isTouchDevice ? undefined : this.onMouseMove}
> >
<div className="controls" onClick={this.stopPropagation}>
<button onClick={this.zoomOut}><ZoomOutIcon/></button>
<button onClick={this.zoomIn}><ZoomInIcon/></button>
<button onClick={this.rotateLeft}><RotateLeftIcon/></button>
<button onClick={this.rotateRight}><RotateRightIcon/></button>
<a href={this.props.src} target="_blank" rel="noopener noreferrer">
<DownloadIcon/>
</a>
<button onClick={this.props.onClose}><CloseIcon/></button>
</div>
<img <img
onMouseDown={isTouchDevice ? undefined : this.onMouseDown} onMouseDown={isTouchDevice ? undefined : this.onMouseDown}
onWheel={isTouchDevice ? undefined : this.onWheel} onWheel={isTouchDevice ? undefined : this.onWheel}

2
web/src/vite-env.d.ts vendored Normal file
View file

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

View file

@ -1,8 +1,18 @@
import react from "@vitejs/plugin-react-swc" import react from "@vitejs/plugin-react-swc"
import { defineConfig } from "vite" import { defineConfig } from "vite"
import svgr from "vite-plugin-svgr"
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
svgr({
svgrOptions: {
replaceAttrValues: {
"#5f6368": "currentColor",
},
},
}),
],
server: { server: {
proxy: { proxy: {
"/_gomuks/websocket": { "/_gomuks/websocket": {