[WEB-2509] feat: fullscreen option for editor images (#5665)
* feat: editor image full screen mode * fix: full screen modal visibility * refactor: memoize calculations * chore: update useEffect dependencies
This commit is contained in:
parent
3c1779b287
commit
83b83326c5
5 changed files with 195 additions and 1 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||||
import { NodeSelection } from "@tiptap/pm/state";
|
import { NodeSelection } from "@tiptap/pm/state";
|
||||||
// extensions
|
// extensions
|
||||||
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
|
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common";
|
import { cn } from "@/helpers/common";
|
||||||
|
|
||||||
|
|
@ -154,6 +154,14 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||||
height: size.height,
|
height: size.height,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ImageToolbarRoot
|
||||||
|
containerClassName="absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||||
|
image={{
|
||||||
|
src,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
|
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
|
||||||
{editor.isEditable && (
|
{editor.isEditable && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./toolbar";
|
||||||
export * from "./image-block";
|
export * from "./image-block";
|
||||||
export * from "./image-node";
|
export * from "./image-node";
|
||||||
export * from "./image-uploader";
|
export * from "./image-uploader";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
image: {
|
||||||
|
src: string;
|
||||||
|
height: string;
|
||||||
|
width: string;
|
||||||
|
};
|
||||||
|
isOpen: boolean;
|
||||||
|
toggleFullScreenMode: (val: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
|
||||||
|
|
||||||
|
export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||||
|
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
|
||||||
|
const { height, src, width } = image;
|
||||||
|
// states
|
||||||
|
const [magnification, setMagnification] = useState(1);
|
||||||
|
// derived values
|
||||||
|
const widthInNumber = useMemo(() => Number(width.replace("px", "")), [width]);
|
||||||
|
const heightInNumber = useMemo(() => Number(height.replace("px", "")), [height]);
|
||||||
|
const aspectRatio = useMemo(() => widthInNumber / heightInNumber, [heightInNumber, widthInNumber]);
|
||||||
|
// close handler
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
toggleFullScreenMode(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setMagnification(1);
|
||||||
|
}, 200);
|
||||||
|
}, [toggleFullScreenMode]);
|
||||||
|
// download handler
|
||||||
|
const handleOpenInNewTab = () => {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = src;
|
||||||
|
link.target = "_blank";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
// magnification decrease handler
|
||||||
|
const handleDecreaseMagnification = useCallback(() => {
|
||||||
|
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
|
||||||
|
if (currentIndex === 0) return;
|
||||||
|
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
|
||||||
|
}, [magnification]);
|
||||||
|
// magnification increase handler
|
||||||
|
const handleIncreaseMagnification = useCallback(() => {
|
||||||
|
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
|
||||||
|
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
|
||||||
|
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
|
||||||
|
}, [magnification]);
|
||||||
|
// keydown handler
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Escape") handleClose();
|
||||||
|
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
|
||||||
|
if (e.key === "-") handleDecreaseMagnification();
|
||||||
|
},
|
||||||
|
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
|
||||||
|
);
|
||||||
|
// register keydown listener
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
if (!isFullScreenEnabled) {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown, isFullScreenEnabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
|
||||||
|
{
|
||||||
|
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative size-full grid place-items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-10 right-10 size-8 grid place-items-center"
|
||||||
|
>
|
||||||
|
<X className="size-8 text-white/60 hover:text-white transition-colors" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
className="read-only-image rounded-lg transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
width: `${widthInNumber * magnification}px`,
|
||||||
|
aspectRatio,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDecreaseMagnification}
|
||||||
|
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
|
||||||
|
disabled={magnification === MAGNIFICATION_VALUES[0]}
|
||||||
|
>
|
||||||
|
<Minus className="size-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleIncreaseMagnification}
|
||||||
|
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
|
||||||
|
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenInNewTab}
|
||||||
|
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFullScreenMode(true);
|
||||||
|
}}
|
||||||
|
className="size-5 grid place-items-center hover:bg-black/40 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Maximize className="size-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common";
|
||||||
|
// components
|
||||||
|
import { ImageFullScreenAction } from "./full-screen";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
containerClassName?: string;
|
||||||
|
image: {
|
||||||
|
src: string;
|
||||||
|
height: string;
|
||||||
|
width: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ImageToolbarRoot: React.FC<Props> = (props) => {
|
||||||
|
const { containerClassName, image } = props;
|
||||||
|
// state
|
||||||
|
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(containerClassName, {
|
||||||
|
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ImageFullScreenAction
|
||||||
|
image={image}
|
||||||
|
isOpen={isFullScreenEnabled}
|
||||||
|
toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue