Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";

Expand Down Expand Up @@ -154,6 +154,14 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
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 && (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./root";
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};