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

@@ -37,6 +37,7 @@
"@tiptap/extension-code-block": "^2.4.0", "@tiptap/extension-code-block": "^2.4.0",
"@tiptap/extension-color": "^2.4.0", "@tiptap/extension-color": "^2.4.0",
"@tiptap/extension-heading": "^2.4.0", "@tiptap/extension-heading": "^2.4.0",
"@tiptap/extension-image": "^2.6.4",
"@tiptap/extension-link": "^2.4.0", "@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-paragraph": "^2.4.0", "@tiptap/extension-paragraph": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0", "@tiptap/extension-placeholder": "^2.4.0",

View File

@@ -0,0 +1,65 @@
import { Button } from "@unsend/ui/src/button";
import { CheckIcon } from "lucide-react";
import { useState, useCallback, useMemo } from "react";
export type TextEditorPanelProps = {
initialText?: string;
onSetInitialText: (url: string) => void;
};
export const useTextEditorState = ({
initialText,
onSetInitialText,
}: TextEditorPanelProps) => {
const [url, setUrl] = useState(initialText || "");
const onChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setUrl(event.target.value);
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
onSetInitialText(url);
},
[url, onSetInitialText]
);
return {
url,
setUrl,
onChange,
handleSubmit,
};
};
export const TextEditorPanel = ({
onSetInitialText,
initialText,
}: TextEditorPanelProps) => {
const state = useTextEditorState({
onSetInitialText,
initialText,
});
return (
<div className="">
<form
onSubmit={state.handleSubmit}
className="flex items-center gap-2 justify-between"
>
<label className="flex items-center gap-2 p-2 rounded-lg cursor-text">
<input
className="flex-1 bg-transparent outline-none min-w-[12rem] text-black text-sm"
placeholder="Enter valid url"
value={state.url}
onChange={state.onChange}
/>
</label>
<Button variant="silent" size="sm" className="px-1">
<CheckIcon className="h-4 w-4 disabled:opacity-50" />
</Button>
</form>
</div>
);
};

View File

@@ -1,6 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { Button } from "@unsend/ui/src/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@unsend/ui/src/popover";
import { ReactNode, useState } from "react";
import { HexAlphaColorPicker, HexColorInput } from "react-colorful"; import { HexAlphaColorPicker, HexColorInput } from "react-colorful";
type ColorPickerProps = { type ColorPickerProps = {
@@ -19,7 +25,7 @@ export function ColorPicker(props: ColorPickerProps) {
}; };
return ( return (
<div className="min-w-[260px] rounded-xl border bg-white p-4"> <div className="min-w-[260px] rounded-xl shadow border border-gray-200 bg-white p-4">
<HexAlphaColorPicker <HexAlphaColorPicker
color={color} color={color}
onChange={handleColorChange} onChange={handleColorChange}
@@ -35,3 +41,36 @@ export function ColorPicker(props: ColorPickerProps) {
</div> </div>
); );
} }
export function ColorPickerPopup(
props: ColorPickerProps & { trigger: ReactNode }
) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="" size="sm" type="button">
{props.trigger}
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(newColor) => {
// HACK: This is a workaround for a bug in tiptap
// https://github.com/ueberdosis/tiptap/issues/3580
//
// ERROR: flushSync was called from inside a lifecycle
//
// To fix this, we need to make sure that the onChange
// callback is run after the current execution context.
queueMicrotask(() => {
onChange?.(newColor);
});
}}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,17 @@
import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react";
import { AllowedAlignments } from "../../../types";
export const AlignmentIcon = ({
alignment,
}: {
alignment: AllowedAlignments;
}) => {
if (alignment === "left") {
return <AlignLeftIcon className="h-4 w-4" />;
} else if (alignment === "center") {
return <AlignCenterIcon className="h-4 w-4" />;
} else if (alignment === "right") {
return <AlignRightIcon className="h-4 w-4" />;
}
return null;
};

View File

@@ -0,0 +1,51 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import TipTapImage from "@tiptap/extension-image";
import { ResizableImageTemplate } from "../nodes/image-resize";
const BORDER_COLOR = "#0096fd";
export const ResizableImageExtension = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }), default: "600" },
height: { renderHTML: ({ height }) => ({ height }) },
borderRadius: {
default: "0",
},
borderWidth: {
default: "0",
},
borderColor: {
default: "rgb(0, 0, 0)",
},
alignment: {
default: "center",
renderHTML: ({ alignment }) => ({ "data-alignment": alignment }),
parseHTML: (element) =>
element.getAttribute("data-alignment") || "center",
},
externalLink: {
default: null,
renderHTML: ({ externalLink }) => {
if (!externalLink) {
return {};
}
return {
"data-external-link": externalLink,
};
},
parseHTML: (element) => {
const externalLink = element.getAttribute("data-external-link");
return externalLink ? { externalLink } : null;
},
},
alt: {
default: "image",
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
});

View File

@@ -8,6 +8,7 @@ import {
Heading1Icon, Heading1Icon,
Heading2Icon, Heading2Icon,
Heading3Icon, Heading3Icon,
ImageIcon,
ListIcon, ListIcon,
ListOrderedIcon, ListOrderedIcon,
RectangleEllipsisIcon, RectangleEllipsisIcon,
@@ -151,70 +152,22 @@ const DEFAULT_SLASH_COMMANDS: SlashCommandItem[] = [
editor.chain().focus().deleteRange(range).toggleOrderedList().run(); editor.chain().focus().deleteRange(range).toggleOrderedList().run();
}, },
}, },
// { {
// title: "Image", title: "Image",
// description: "Full width image", description: "Full width image",
// searchTerms: ["image"], searchTerms: ["image"],
// icon: <ImageIcon className="h-4 w-4" />, icon: <ImageIcon className="h-4 w-4" />,
// command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
// const imageUrl = prompt("Image URL: ") || ""; const imageUrl = prompt("Image URL: ") || "";
// if (!imageUrl) { if (!imageUrl) {
// return; return;
// } }
// editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// editor.chain().focus().setImage({ src: imageUrl }).run(); editor.chain().focus().setImage({ src: imageUrl }).run();
// }, },
// }, },
// {
// title: "Logo",
// description: "Add your brand logo",
// searchTerms: ["image", "logo"],
// icon: <ImageIcon className="h-4 w-4" />,
// command: ({ editor, range }: CommandProps) => {
// const logoUrl = prompt("Logo URL: ") || "";
// if (!logoUrl) {
// return;
// }
// editor.chain().focus().deleteRange(range).run();
// editor.chain().focus().setLogoImage({ src: logoUrl }).run();
// },
// },
// {
// title: "Spacer",
// description:
// "Add a spacer to email. Useful for adding space between sections.",
// searchTerms: ["space", "gap", "divider"],
// icon: <MoveVertical className="h-4 w-4" />,
// command: ({ editor, range }: CommandProps) => {
// editor
// .chain()
// .focus()
// .deleteRange(range)
// .setSpacer({ height: "sm" })
// .run();
// },
// },
// {
// title: "Button",
// description: "Add a call to action button to email.",
// searchTerms: ["link", "button", "cta"],
// icon: <MousePointer className="h-4 w-4" />,
// command: ({ editor, range }: CommandProps) => {
// editor.chain().focus().deleteRange(range).setButton().run();
// },
// },
// {
// title: "Link Card",
// description: "Add a link card to email.",
// searchTerms: ["link", "button", "image"],
// icon: <ArrowUpRightSquare className="h-4 w-4" />,
// command: ({ editor, range }: CommandProps) => {
// editor.chain().focus().deleteRange(range).setLinkCard().run();
// },
// },
{ {
title: "Hard Break", title: "Hard Break",
description: "Add a break between lines.", description: "Add a break between lines.",

View File

@@ -16,6 +16,7 @@ import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
import { VariableExtension } from "./VariableExtension"; import { VariableExtension } from "./VariableExtension";
import { getVariableSuggestions } from "../nodes/variable"; import { getVariableSuggestions } from "../nodes/variable";
import { UnsubscribeFooterExtension } from "./UnsubsubscribeExtension"; import { UnsubscribeFooterExtension } from "./UnsubsubscribeExtension";
import { ResizableImageExtension } from "./ImageExtension";
export function extensions({ variables }: { variables?: Array<string> }) { export function extensions({ variables }: { variables?: Array<string> }) {
const extensions = [ const extensions = [
@@ -74,6 +75,7 @@ export function extensions({ variables }: { variables?: Array<string> }) {
suggestion: getVariableSuggestions(variables), suggestion: getVariableSuggestions(variables),
}), }),
UnsubscribeFooterExtension, UnsubscribeFooterExtension,
ResizableImageExtension,
]; ];
return extensions; return extensions;

View File

@@ -0,0 +1,16 @@
import { useCallback, useLayoutEffect, useRef } from "react";
export const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error("Handler is not assigned");
}
return handlerRef.current(...args);
}, []) as T;
};

View File

@@ -3,6 +3,8 @@ import {
AlignCenterIcon, AlignCenterIcon,
AlignLeftIcon, AlignLeftIcon,
AlignRightIcon, AlignRightIcon,
BoxSelectIcon,
LinkIcon,
ScanIcon, ScanIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -16,7 +18,15 @@ import { Button } from "@unsend/ui/src/button";
import { AllowedAlignments, ButtonOptions } from "../types"; import { AllowedAlignments, ButtonOptions } from "../types";
import { Separator } from "@unsend/ui/src/separator"; import { Separator } from "@unsend/ui/src/separator";
import { BorderWidth } from "../components/ui/icons/BorderWidth"; import { BorderWidth } from "../components/ui/icons/BorderWidth";
import { ColorPicker } from "../components/ui/ColorPicker"; import { ColorPicker, ColorPickerPopup } from "../components/ui/ColorPicker";
import { LinkEditorPanel } from "../components/panels/LinkEditorPanel";
import { useState } from "react";
import {
Tooltip,
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from "@unsend/ui/src/tooltip";
const alignments: Array<AllowedAlignments> = ["left", "center", "right"]; const alignments: Array<AllowedAlignments> = ["left", "center", "right"];
@@ -33,6 +43,10 @@ export function ButtonComponent(props: NodeViewProps) {
} = props.node.attrs as ButtonOptions; } = props.node.attrs as ButtonOptions;
const { getPos, editor } = props; const { getPos, editor } = props;
const [editUrlOpen, setEditUrlOpen] = useState(false);
console.log(props);
return ( return (
<NodeViewWrapper <NodeViewWrapper
className={`react-component ${ className={`react-component ${
@@ -47,8 +61,9 @@ export function ButtonComponent(props: NodeViewProps) {
<Popover open={props.selected}> <Popover open={props.selected}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<div> <div>
<button <div
className={cn( className={cn(
"cursor-pointer",
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors disabled:pointer-events-none disabled:opacity-50",
"h-10 px-4 py-2", "h-10 px-4 py-2",
"px-[32px] py-[20px] font-semibold no-underline" "px-[32px] py-[20px] font-semibold no-underline"
@@ -68,230 +83,241 @@ export function ButtonComponent(props: NodeViewProps) {
editor.commands.setNodeSelection(pos); editor.commands.setNodeSelection(pos);
}} }}
> >
{text} <div className="relative flex max-w-full items-center">
</button> {props.selected}
<div className="inset-0 flex items-center overflow-hidden ">
<span
className={cn(
" cursor-text",
props.selected ? "text-transparent" : ""
)}
>
{text === "" ? "Button text" : text}
</span>
</div>
{props.selected ? (
<form className="absolute inset-x-[-4px] inset-y-0 flex items-center justify-center">
<input
type="text"
value={text}
onChange={(e) => {
props.updateAttributes({
text: e.target.value,
});
}}
onBlur={() => {
editor.commands.setNodeSelection(getPos());
}}
autoFocus
className="w-full bg-transparent text-center outline-none"
/>
</form>
) : null}
</div>
</div>
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align="start" align="start"
side="top" side="top"
className="space-y-2 light border-gray-200" className="space-y-2 w-[28rem] light border-gray-200 py-1 px-1"
sideOffset={10} sideOffset={10}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
> >
<Input {/* <div className="flex gap-2">
placeholder="Add text here" <Input
value={text} placeholder="Button text"
onChange={(e) => { value={text}
props.updateAttributes({ onChange={(e) => {
text: e.target.value, props.updateAttributes({
}); text: e.target.value,
}} });
className="light" }}
/> className="light"
<Input />
placeholder="Add link here" <Input
value={url} placeholder="Add link here"
onChange={(e) => { value={url}
props.updateAttributes({ onChange={(e) => {
url: e.target.value, props.updateAttributes({
}); url: e.target.value,
}} });
/> }}
/>
</div> */}
<div className="flex flex-col gap-2"> <TooltipProvider>
<div className="text-xs text-gray-500 mt-4">Border</div> <div className="flex gap-1 items-center">
<div className="flex gap-2">
<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={_radius}
onChange={(e) =>
props.updateAttributes({
borderRadius: e.target.value,
})
}
className="border-0 focus-visible:ring-0 h-6 p-0"
/>
</div>
<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"
/>
</div>
</div>
<div className="flex gap-2">
<div> <div>
<div className="text-xs text-gray-500 mt-4 mb-2">Alignment</div>
<div className="flex"> <div className="flex">
{alignments.map((alignment) => ( {alignments.map((alignment) => (
<Button <Tooltip key={alignment}>
variant="ghost" <TooltipTrigger>
className="" <Button
size="sm" variant="ghost"
type="button" key={alignment}
onClick={() => { className=""
props.updateAttributes({ size="sm"
alignment, type="button"
}); onClick={() => {
}} props.updateAttributes({
> alignment,
<AlignmentIcon alignment={alignment} /> });
</Button> }}
>
<AlignmentIcon alignment={alignment} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Align {alignment}</p>
</TooltipContent>
</Tooltip>
))} ))}
</div> </div>
</div> </div>
<Separator orientation="vertical" className=" h-6 my-auto" />
<div> <div>
<div className="text-xs text-gray-500 mt-4 mb-2">Colors</div> <div className="flex">
<div className="flex gap-2"> <Tooltip>
<BorderColorPickerPopup <TooltipTrigger>
color={borderColor} <ColorPickerPopup
onChange={(color) => { trigger={
props.updateAttributes({ <div
borderColor: color, className="h-4 w-4 rounded border"
}); style={{
}} backgroundColor: buttonColor,
/> }}
<BackgroundColorPickerPopup />
color={buttonColor} }
onChange={(color) => { color={buttonColor}
props.updateAttributes({ onChange={(color) => {
buttonColor: color, props.updateAttributes({
}); buttonColor: color,
}} });
/> }}
<TextColorPickerPopup />
color={textColor} </TooltipTrigger>
onChange={(color) => { <TooltipContent>Background color</TooltipContent>
props.updateAttributes({ </Tooltip>
textColor: color, <Tooltip>
}); <TooltipTrigger>
}} <ColorPickerPopup
/> trigger={
<div className="flex flex-col items-center justify-center gap-[1px]">
<span className="font-bolder font-mono text-xs text-slate-700">
A
</span>
<div
className="h-[2px] w-3"
style={{ backgroundColor: textColor }}
/>
</div>
}
color={textColor}
onChange={(color) => {
props.updateAttributes({
textColor: color,
});
}}
/>
</TooltipTrigger>
<TooltipContent>Text color</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
<Separator orientation="vertical" className=" h-6 my-auto" />
<div>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger>
<div className="flex items-center border border-transparent hover:border-border focus-within:border-border gap-1 px-1 py-0.5 rounded-md">
<ScanIcon className="text-slate-700 h-4 w-4" />
<Input
value={_radius}
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 hover:border-border focus-within:border-border gap-1 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={
<BoxSelectIcon
className="h-4 w-4"
style={{ color: borderColor }}
/>
}
color={borderColor}
onChange={(color) => {
props.updateAttributes({
borderColor: color,
});
}}
/>
</TooltipTrigger>
<TooltipContent>Border color</TooltipContent>
</Tooltip>
</div>
</div>
<Separator orientation="vertical" className=" h-6 my-auto" />
<Popover open={editUrlOpen} onOpenChange={setEditUrlOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="px-2"
size="sm"
type="button"
>
Link
<LinkIcon className="h-4 w-4 ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="light border-gray-200 px-4 py-2">
<LinkEditorPanel
initialUrl={url}
onSetLink={(u) => {
props.updateAttributes({
url: u,
});
setEditUrlOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div> </div>
</div> </TooltipProvider>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
// type ColorPickerProps = {
// variant?: AllowedButtonVariant;
// color: string;
// onChange: (color: string) => void;
// };
function BackgroundColorPickerPopup(props: ColorPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="" size="sm" type="button">
<div
className="h-4 w-4 rounded border"
style={{
backgroundColor: color,
}}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(newColor) => {
// HACK: This is a workaround for a bug in tiptap
// https://github.com/ueberdosis/tiptap/issues/3580
//
// ERROR: flushSync was called from inside a lifecycle
//
// To fix this, we need to make sure that the onChange
// callback is run after the current execution context.
queueMicrotask(() => {
onChange(newColor);
});
}}
/>
</PopoverContent>
</Popover>
);
}
function TextColorPickerPopup(props: ColorPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" type="button">
<div className="flex flex-col items-center justify-center gap-[1px]">
<span className="font-bolder font-mono text-xs text-slate-700">
A
</span>
<div className="h-[2px] w-3" style={{ backgroundColor: color }} />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(color) => {
queueMicrotask(() => {
onChange(color);
});
}}
/>
</PopoverContent>
</Popover>
);
}
type ColorPickerProps = {
color: string;
onChange: (color: string) => void;
};
function BorderColorPickerPopup(props: ColorPickerProps) {
const { color, onChange } = props;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="" size="sm" type="button">
<BorderWidth className="h-4 w-4" style={{ color: color }} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full rounded-none border-0 !bg-transparent !p-0 shadow-none drop-shadow-md">
<ColorPicker
color={color}
onChange={(newColor) => {
// HACK: This is a workaround for a bug in tiptap
// https://github.com/ueberdosis/tiptap/issues/3580
//
// ERROR: flushSync was called from inside a lifecycle
//
// To fix this, we need to make sure that the onChange
// callback is run after the current execution context.
queueMicrotask(() => {
onChange(newColor);
});
}}
/>
</PopoverContent>
</Popover>
);
}
const AlignmentIcon = ({ alignment }: { alignment: AllowedAlignments }) => { const AlignmentIcon = ({ alignment }: { alignment: AllowedAlignments }) => {
if (alignment === "left") { if (alignment === "left") {
return <AlignLeftIcon className="h-4 w-4" />; return <AlignLeftIcon className="h-4 w-4" />;

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>
);
}

View File

@@ -655,6 +655,9 @@ export class EmailRenderer {
height = "auto", height = "auto",
alignment = "center", alignment = "center",
externalLink = "", externalLink = "",
borderRadius,
borderColor,
borderWidth,
} = attrs || {}; } = attrs || {};
const { next } = options || {}; const { next } = options || {};
@@ -669,8 +672,11 @@ export class EmailRenderer {
width, width,
maxWidth: "100%", maxWidth: "100%",
outline: "none", outline: "none",
border: "none",
textDecoration: "none", textDecoration: "none",
borderStyle: "solid",
borderRadius: `${borderRadius}px`,
borderColor,
borderWidth: `${borderWidth}px`,
}} }}
title={title || alt || "Image"} title={title || alt || "Image"}
/> />

View File

@@ -20,4 +20,14 @@ export interface ButtonOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
} }
export interface ImageOptions {
altText: string;
url: string;
alignment: AllowedAlignments;
borderRadius: string;
borderColor: string;
borderWidth: string;
HTMLAttributes: Record<string, any>;
}
export type SVGProps = React.SVGProps<SVGSVGElement>; export type SVGProps = React.SVGProps<SVGSVGElement>;

11
pnpm-lock.yaml generated
View File

@@ -339,6 +339,9 @@ importers:
'@tiptap/extension-heading': '@tiptap/extension-heading':
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0) version: 2.4.0(@tiptap/core@2.4.0)
'@tiptap/extension-image':
specifier: ^2.6.4
version: 2.6.4(@tiptap/core@2.4.0)
'@tiptap/extension-link': '@tiptap/extension-link':
specifier: ^2.4.0 specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0) version: 2.4.0(@tiptap/core@2.4.0)(@tiptap/pm@2.4.0)
@@ -6124,6 +6127,14 @@ packages:
'@tiptap/pm': 2.4.0 '@tiptap/pm': 2.4.0
dev: false dev: false
/@tiptap/extension-image@2.6.4(@tiptap/core@2.4.0):
resolution: {integrity: sha512-uc2JA1qnZ6X33di3RTIDfE9oaJeWKyE6aJdWDt5OXPOW60kPKO8PIxy9n11O8v0oVb/+bZ9cnPu9UpSnJVaUCg==}
peerDependencies:
'@tiptap/core': ^2.6.4
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
dev: false
/@tiptap/extension-italic@2.4.0(@tiptap/core@2.4.0): /@tiptap/extension-italic@2.4.0(@tiptap/core@2.4.0):
resolution: {integrity: sha512-aaW/L9q+KNHHK+X73MPloHeIsT191n3VLd3xm6uUcFDnUNvzYJ/q65/1ZicdtCaOLvTutxdrEvhbkrVREX6a8g==} resolution: {integrity: sha512-aaW/L9q+KNHHK+X73MPloHeIsT191n3VLd3xm6uUcFDnUNvzYJ/q65/1ZicdtCaOLvTutxdrEvhbkrVREX6a8g==}
peerDependencies: peerDependencies: