Add image component for editor (#57)

* Add image component

* More image editor

* add more image changes
This commit is contained in:
KM Koushik
2024-08-23 20:59:20 +10:00
committed by GitHub
parent 1a3364ed82
commit 1824a88a16
13 changed files with 874 additions and 265 deletions

View File

@@ -0,0 +1,412 @@
import { NodeViewProps } from "@tiptap/core";
import { CSSProperties, useRef, useState } from "react";
import { useEvent } from "../hooks/useEvent";
import { NodeViewWrapper } from "@tiptap/react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@unsend/ui/src/popover";
import {
ExpandIcon,
ScanIcon,
LinkIcon,
ImageIcon,
TypeIcon,
} from "lucide-react";
import { Input } from "@unsend/ui/src/input";
import { BorderWidth } from "../components/ui/icons/BorderWidth";
import { ColorPickerPopup } from "../components/ui/ColorPicker";
import { AllowedAlignments } from "../types";
import { Button } from "@unsend/ui/src/button";
import { AlignmentIcon } from "../components/ui/icons/AlignmentIcon";
import {
Tooltip,
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from "@unsend/ui/src/tooltip";
import { Separator } from "@unsend/ui/src/separator";
import { LinkEditorPanel } from "../components/panels/LinkEditorPanel";
import { TextEditorPanel } from "../components/panels/TextEditorPanel";
const alignments: Array<AllowedAlignments> = ["left", "center", "right"];
const MIN_WIDTH = 60;
export function ResizableImageTemplate(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const imgRef = useRef<HTMLImageElement>(null);
const [resizingStyle, setResizingStyle] = useState<
Pick<CSSProperties, "width" | "height"> | undefined
>();
let {
alignment = "center",
width,
height,
borderRadius,
borderWidth,
borderColor,
src,
} = node.attrs || {};
const [widthState, setWidthState] = useState<string>(width.toString());
const [openLink, setOpenLink] = useState(false);
const [openImgSrc, setOpenImgSrc] = useState(false);
const [openAltText, setOpenAltText] = useState(false);
const handleMouseDown = useEvent(
(event: React.MouseEvent<HTMLDivElement>) => {
const imageParent = document.querySelector(
".ProseMirror-selectednode"
) as HTMLDivElement;
if (!imgRef.current || !imageParent || !selected) {
return;
}
const imageParentWidth = imageParent.offsetWidth;
event.preventDefault();
const direction = event.currentTarget.dataset.direction || "--";
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
const currentHeight = imgRef.current.height;
let newWidth = currentWidth;
let newHeight = currentHeight;
const transform = direction === "left" ? -1 : 1;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth, height: newHeight });
setResizingStyle(undefined);
};
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = Math.max(
currentWidth + transform * (event.clientX - initialXPosition),
MIN_WIDTH
);
if (newWidth > imageParentWidth) {
newWidth = imageParentWidth;
}
newHeight = (newWidth / currentWidth) * currentHeight;
setResizingStyle({ width: newWidth, height: newHeight });
setWidthState(newWidth.toString());
// If mouse is up, remove event listeners
if (!event.buttons) {
return removeListeners();
}
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
}
);
const updateWidth = (_newWidth: string) => {
setWidthState(_newWidth.toString());
if (!imgRef.current) {
return;
}
const imageParent = document.querySelector(
".ProseMirror-selectednode"
) as HTMLDivElement;
const imageParentWidth = imageParent.offsetWidth;
const currentWidth = imgRef.current.width;
const currentHeight = imgRef.current.height;
let newWidth = Number(_newWidth);
newWidth = newWidth > 59 ? newWidth : 60;
if (newWidth > imageParentWidth) {
newWidth = imageParentWidth;
}
const newHeight = (newWidth / currentWidth) * currentHeight;
setResizingStyle({
width: newWidth > 59 ? newWidth : 60,
height: newHeight,
});
};
function dragButton(direction: "left" | "right") {
return (
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
className=" bg-white bg-opacity-40 border rounded-3xl"
style={{
position: "absolute",
height: "60px",
width: "7px",
[direction]: 5,
top: "50%",
transform: "translateY(-50%)",
cursor: "ew-resize",
}}
/>
);
}
const {
externalLink,
alt,
borderRadius: _br,
borderColor: _bc,
...attrs
} = node.attrs || {};
return (
<NodeViewWrapper
as="div"
draggable
data-drag-handle
style={{
width,
height,
borderRadius: Number(borderRadius),
borderWidth: Number(borderWidth),
borderColor,
...resizingStyle,
overflow: "hidden",
position: "relative",
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: "0px",
display: "block",
...({
center: { marginLeft: "auto", marginRight: "auto" },
left: { marginRight: "auto" },
right: { marginLeft: "auto" },
}[alignment as string] || {}),
}}
>
<Popover open={props.selected}>
<PopoverTrigger>
<img
{...attrs}
ref={imgRef}
style={{
...resizingStyle,
cursor: "default",
marginBottom: 0,
}}
/>
{selected && (
<>
{dragButton("left")}
{dragButton("right")}
</>
)}
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="light border-gray-200 px-2 py-2 w-[32rem]"
sideOffset={10}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger>
<div className="flex items-center border border-transparent focus-within:border-border gap-2 px-1 py-0.5 rounded-md">
<ExpandIcon className="text-slate-700 h-4 w-4" />
<Input
value={widthState}
onChange={(e) => updateWidth(e.target.value)}
className="border-0 focus-visible:ring-0 h-6 p-0 w-8"
/>
</div>
</TooltipTrigger>
<TooltipContent>Width</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-6 my-auto" />
{alignments.map((alignment) => (
<Tooltip key={alignment}>
<TooltipTrigger>
<Button
variant="ghost"
key={alignment}
className=""
size="sm"
type="button"
onClick={() => {
props.updateAttributes({
alignment,
});
}}
>
<AlignmentIcon alignment={alignment} />
</Button>
</TooltipTrigger>
<TooltipContent>Align {alignment}</TooltipContent>
</Tooltip>
))}
<Separator orientation="vertical" className="h-6 my-auto" />
<Tooltip>
<TooltipTrigger>
<div className="flex items-center border border-transparent focus-within:border-border gap-2 px-1 py-0.5 rounded-md">
<ScanIcon className="text-slate-700 h-4 w-4" />
<Input
value={borderRadius}
onChange={(e) =>
props.updateAttributes({
borderRadius: e.target.value,
})
}
className="border-0 focus-visible:ring-0 h-6 p-0 w-5"
/>
</div>
</TooltipTrigger>
<TooltipContent>Border radius</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div className="flex items-center border border-transparent focus-within:border-border gap-2 px-1 py-0.5 rounded-md">
<BorderWidth className="text-slate-700 h-4 w-4" />
<Input
value={borderWidth}
onChange={(e) =>
props.updateAttributes({
borderWidth: e.target.value,
})
}
className="border-0 focus-visible:ring-0 h-6 p-0 w-5"
/>
</div>
</TooltipTrigger>
<TooltipContent>Border width</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<ColorPickerPopup
trigger={
<div
className="h-4 w-4 rounded border"
style={{
backgroundColor: borderColor,
}}
/>
}
color={borderColor}
onChange={(color) => {
props.updateAttributes({
borderColor: color,
});
}}
/>
</TooltipTrigger>
<TooltipContent>Border color</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-6 my-auto" />
<Popover open={openImgSrc} onOpenChange={setOpenImgSrc}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="px-2"
size="sm"
type="button"
onClick={() => setOpenImgSrc(true)}
>
<Tooltip>
<TooltipTrigger>
<ImageIcon className="h-4 w-4 " />
</TooltipTrigger>
<TooltipContent>Image source</TooltipContent>
</Tooltip>
</Button>
</PopoverTrigger>
<PopoverContent className="light border-gray-200 px-4 py-2">
<LinkEditorPanel
initialUrl={src}
onSetLink={(u) => {
props.updateAttributes({
src: u,
});
setOpenImgSrc(false);
}}
/>
</PopoverContent>
</Popover>
<Popover open={openAltText} onOpenChange={setOpenAltText}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="px-2"
size="sm"
type="button"
onClick={() => setOpenAltText(true)}
>
<Tooltip>
<TooltipTrigger>
<TypeIcon className="h-4 w-4 " />
</TooltipTrigger>
<TooltipContent>Alt text</TooltipContent>
</Tooltip>
</Button>
</PopoverTrigger>
<PopoverContent className="light border-gray-200 px-4 py-2">
<TextEditorPanel
initialText={alt}
onSetInitialText={(t) => {
props.updateAttributes({
alt: t,
});
setOpenAltText(false);
}}
/>
</PopoverContent>
</Popover>
<Popover open={openLink} onOpenChange={setOpenLink}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="px-2"
size="sm"
type="button"
onClick={() => setOpenLink(true)}
>
<Tooltip>
<TooltipTrigger>
<LinkIcon className="h-4 w-4 " />
</TooltipTrigger>
<TooltipContent>Link</TooltipContent>
</Tooltip>
</Button>
</PopoverTrigger>
<PopoverContent className="light border-gray-200 px-4 py-2">
<LinkEditorPanel
initialUrl={externalLink}
onSetLink={(u) => {
props.updateAttributes({
externalLink: u,
});
setOpenLink(false);
}}
/>
</PopoverContent>
</Popover>
</div>
</TooltipProvider>
</PopoverContent>
</Popover>
</NodeViewWrapper>
);
}