diff --git a/packages/email-editor/package.json b/packages/email-editor/package.json index 051968c..61620d3 100644 --- a/packages/email-editor/package.json +++ b/packages/email-editor/package.json @@ -37,6 +37,7 @@ "@tiptap/extension-code-block": "^2.4.0", "@tiptap/extension-color": "^2.4.0", "@tiptap/extension-heading": "^2.4.0", + "@tiptap/extension-image": "^2.6.4", "@tiptap/extension-link": "^2.4.0", "@tiptap/extension-paragraph": "^2.4.0", "@tiptap/extension-placeholder": "^2.4.0", diff --git a/packages/email-editor/src/components/panels/TextEditorPanel.tsx b/packages/email-editor/src/components/panels/TextEditorPanel.tsx new file mode 100644 index 0000000..ba47eb5 --- /dev/null +++ b/packages/email-editor/src/components/panels/TextEditorPanel.tsx @@ -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) => { + 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 ( +
+
+ + +
+
+ ); +}; diff --git a/packages/email-editor/src/components/ui/ColorPicker.tsx b/packages/email-editor/src/components/ui/ColorPicker.tsx index 899db90..af628f0 100644 --- a/packages/email-editor/src/components/ui/ColorPicker.tsx +++ b/packages/email-editor/src/components/ui/ColorPicker.tsx @@ -1,6 +1,12 @@ "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"; type ColorPickerProps = { @@ -19,7 +25,7 @@ export function ColorPicker(props: ColorPickerProps) { }; return ( -
+
); } + +export function ColorPickerPopup( + props: ColorPickerProps & { trigger: ReactNode } +) { + const { color, onChange } = props; + + return ( + + + + + + { + // 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); + }); + }} + /> + + + ); +} diff --git a/packages/email-editor/src/components/ui/icons/AlignmentIcon.tsx b/packages/email-editor/src/components/ui/icons/AlignmentIcon.tsx new file mode 100644 index 0000000..e27e17d --- /dev/null +++ b/packages/email-editor/src/components/ui/icons/AlignmentIcon.tsx @@ -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 ; + } else if (alignment === "center") { + return ; + } else if (alignment === "right") { + return ; + } + return null; +}; diff --git a/packages/email-editor/src/extensions/ImageExtension.tsx b/packages/email-editor/src/extensions/ImageExtension.tsx new file mode 100644 index 0000000..65d21a5 --- /dev/null +++ b/packages/email-editor/src/extensions/ImageExtension.tsx @@ -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); + }, +}); diff --git a/packages/email-editor/src/extensions/SlashCommand.tsx b/packages/email-editor/src/extensions/SlashCommand.tsx index 86389f6..a6dd757 100644 --- a/packages/email-editor/src/extensions/SlashCommand.tsx +++ b/packages/email-editor/src/extensions/SlashCommand.tsx @@ -8,6 +8,7 @@ import { Heading1Icon, Heading2Icon, Heading3Icon, + ImageIcon, ListIcon, ListOrderedIcon, RectangleEllipsisIcon, @@ -151,70 +152,22 @@ const DEFAULT_SLASH_COMMANDS: SlashCommandItem[] = [ editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, - // { - // title: "Image", - // description: "Full width image", - // searchTerms: ["image"], - // icon: , - // command: ({ editor, range }: CommandProps) => { - // const imageUrl = prompt("Image URL: ") || ""; + { + title: "Image", + description: "Full width image", + searchTerms: ["image"], + icon: , + command: ({ editor, range }: CommandProps) => { + const imageUrl = prompt("Image URL: ") || ""; - // if (!imageUrl) { - // return; - // } + if (!imageUrl) { + return; + } - // editor.chain().focus().deleteRange(range).run(); - // editor.chain().focus().setImage({ src: imageUrl }).run(); - // }, - // }, - // { - // title: "Logo", - // description: "Add your brand logo", - // searchTerms: ["image", "logo"], - // icon: , - // 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: , - // 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: , - // 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: , - // command: ({ editor, range }: CommandProps) => { - // editor.chain().focus().deleteRange(range).setLinkCard().run(); - // }, - // }, + editor.chain().focus().deleteRange(range).run(); + editor.chain().focus().setImage({ src: imageUrl }).run(); + }, + }, { title: "Hard Break", description: "Add a break between lines.", diff --git a/packages/email-editor/src/extensions/index.ts b/packages/email-editor/src/extensions/index.ts index 66fb5ff..a1e40c3 100644 --- a/packages/email-editor/src/extensions/index.ts +++ b/packages/email-editor/src/extensions/index.ts @@ -16,6 +16,7 @@ import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand"; import { VariableExtension } from "./VariableExtension"; import { getVariableSuggestions } from "../nodes/variable"; import { UnsubscribeFooterExtension } from "./UnsubsubscribeExtension"; +import { ResizableImageExtension } from "./ImageExtension"; export function extensions({ variables }: { variables?: Array }) { const extensions = [ @@ -74,6 +75,7 @@ export function extensions({ variables }: { variables?: Array }) { suggestion: getVariableSuggestions(variables), }), UnsubscribeFooterExtension, + ResizableImageExtension, ]; return extensions; diff --git a/packages/email-editor/src/hooks/useEvent.ts b/packages/email-editor/src/hooks/useEvent.ts new file mode 100644 index 0000000..0005008 --- /dev/null +++ b/packages/email-editor/src/hooks/useEvent.ts @@ -0,0 +1,16 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +export const useEvent = any>(handler: T): T => { + const handlerRef = useRef(null); + + useLayoutEffect(() => { + handlerRef.current = handler; + }, [handler]); + + return useCallback((...args: Parameters): ReturnType => { + if (handlerRef.current === null) { + throw new Error("Handler is not assigned"); + } + return handlerRef.current(...args); + }, []) as T; +}; diff --git a/packages/email-editor/src/nodes/button.tsx b/packages/email-editor/src/nodes/button.tsx index 840a087..3391046 100644 --- a/packages/email-editor/src/nodes/button.tsx +++ b/packages/email-editor/src/nodes/button.tsx @@ -3,6 +3,8 @@ import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, + BoxSelectIcon, + LinkIcon, ScanIcon, } from "lucide-react"; import { @@ -16,7 +18,15 @@ import { Button } from "@unsend/ui/src/button"; import { AllowedAlignments, ButtonOptions } from "../types"; import { Separator } from "@unsend/ui/src/separator"; 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 = ["left", "center", "right"]; @@ -33,6 +43,10 @@ export function ButtonComponent(props: NodeViewProps) { } = props.node.attrs as ButtonOptions; const { getPos, editor } = props; + const [editUrlOpen, setEditUrlOpen] = useState(false); + + console.log(props); + return (
- +
+ {props.selected} +
+ + {text === "" ? "Button text" : text} + +
+ {props.selected ? ( +
+ { + props.updateAttributes({ + text: e.target.value, + }); + }} + onBlur={() => { + editor.commands.setNodeSelection(getPos()); + }} + autoFocus + className="w-full bg-transparent text-center outline-none" + /> +
+ ) : null} +
+
e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > - { - props.updateAttributes({ - text: e.target.value, - }); - }} - className="light" - /> - { - props.updateAttributes({ - url: e.target.value, - }); - }} - /> + {/*
+ { + props.updateAttributes({ + text: e.target.value, + }); + }} + className="light" + /> + { + props.updateAttributes({ + url: e.target.value, + }); + }} + /> +
*/} -
-
Border
-
-
- - - props.updateAttributes({ - borderRadius: e.target.value, - }) - } - className="border-0 focus-visible:ring-0 h-6 p-0" - /> -
-
- - - props.updateAttributes({ - borderWidth: e.target.value, - }) - } - className="border-0 focus-visible:ring-0 h-6 p-0" - /> -
-
-
+ +
-
Alignment
{alignments.map((alignment) => ( - + + + + + +

Align {alignment}

+
+
))}
+
-
Colors
-
- { - props.updateAttributes({ - borderColor: color, - }); - }} - /> - { - props.updateAttributes({ - buttonColor: color, - }); - }} - /> - { - props.updateAttributes({ - textColor: color, - }); - }} - /> +
+ + + + } + color={buttonColor} + onChange={(color) => { + props.updateAttributes({ + buttonColor: color, + }); + }} + /> + + Background color + + + + + + A + +
+
+ } + color={textColor} + onChange={(color) => { + props.updateAttributes({ + textColor: color, + }); + }} + /> +
+ Text color +
+ +
+
+ + +
+ + + props.updateAttributes({ + borderRadius: e.target.value, + }) + } + className="border-0 focus-visible:ring-0 h-6 p-0 w-5" + /> +
+
+ Border radius +
+ + +
+ + + props.updateAttributes({ + borderWidth: e.target.value, + }) + } + className="border-0 focus-visible:ring-0 h-6 p-0 w-5" + /> +
+
+ Border width +
+ + + + + } + color={borderColor} + onChange={(color) => { + props.updateAttributes({ + borderColor: color, + }); + }} + /> + + Border color + +
+
+ + + + + + + { + props.updateAttributes({ + url: u, + }); + setEditUrlOpen(false); + }} + /> + +
-
+
); } -// type ColorPickerProps = { -// variant?: AllowedButtonVariant; -// color: string; -// onChange: (color: string) => void; -// }; - -function BackgroundColorPickerPopup(props: ColorPickerProps) { - const { color, onChange } = props; - - return ( - - - - - - { - // 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); - }); - }} - /> - - - ); -} - -function TextColorPickerPopup(props: ColorPickerProps) { - const { color, onChange } = props; - return ( - - - - - - { - queueMicrotask(() => { - onChange(color); - }); - }} - /> - - - ); -} - -type ColorPickerProps = { - color: string; - onChange: (color: string) => void; -}; - -function BorderColorPickerPopup(props: ColorPickerProps) { - const { color, onChange } = props; - - return ( - - - - - - { - // 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); - }); - }} - /> - - - ); -} - const AlignmentIcon = ({ alignment }: { alignment: AllowedAlignments }) => { if (alignment === "left") { return ; diff --git a/packages/email-editor/src/nodes/image-resize.tsx b/packages/email-editor/src/nodes/image-resize.tsx new file mode 100644 index 0000000..287fc0b --- /dev/null +++ b/packages/email-editor/src/nodes/image-resize.tsx @@ -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 = ["left", "center", "right"]; + +const MIN_WIDTH = 60; + +export function ResizableImageTemplate(props: NodeViewProps) { + const { node, updateAttributes, selected } = props; + + const imgRef = useRef(null); + + const [resizingStyle, setResizingStyle] = useState< + Pick | undefined + >(); + + let { + alignment = "center", + width, + height, + borderRadius, + borderWidth, + borderColor, + src, + } = node.attrs || {}; + + const [widthState, setWidthState] = useState(width.toString()); + const [openLink, setOpenLink] = useState(false); + const [openImgSrc, setOpenImgSrc] = useState(false); + const [openAltText, setOpenAltText] = useState(false); + + const handleMouseDown = useEvent( + (event: React.MouseEvent) => { + 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 ( +
+ ); + } + + const { + externalLink, + alt, + borderRadius: _br, + borderColor: _bc, + ...attrs + } = node.attrs || {}; + + return ( + + + + + {selected && ( + <> + {dragButton("left")} + {dragButton("right")} + + )} + + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + +
+ + +
+ + updateWidth(e.target.value)} + className="border-0 focus-visible:ring-0 h-6 p-0 w-8" + /> +
+
+ Width +
+ + {alignments.map((alignment) => ( + + + + + Align {alignment} + + ))} + + + + +
+ + + props.updateAttributes({ + borderRadius: e.target.value, + }) + } + className="border-0 focus-visible:ring-0 h-6 p-0 w-5" + /> +
+
+ Border radius +
+ + +
+ + + props.updateAttributes({ + borderWidth: e.target.value, + }) + } + className="border-0 focus-visible:ring-0 h-6 p-0 w-5" + /> +
+
+ Border width +
+ + + + } + color={borderColor} + onChange={(color) => { + props.updateAttributes({ + borderColor: color, + }); + }} + /> + + Border color + + + + + + + + { + props.updateAttributes({ + src: u, + }); + setOpenImgSrc(false); + }} + /> + + + + + + + + { + props.updateAttributes({ + alt: t, + }); + setOpenAltText(false); + }} + /> + + + + + + + + { + props.updateAttributes({ + externalLink: u, + }); + setOpenLink(false); + }} + /> + + +
+
+
+
+
+ ); +} diff --git a/packages/email-editor/src/renderer.tsx b/packages/email-editor/src/renderer.tsx index d5d5b3d..e895579 100644 --- a/packages/email-editor/src/renderer.tsx +++ b/packages/email-editor/src/renderer.tsx @@ -655,6 +655,9 @@ export class EmailRenderer { height = "auto", alignment = "center", externalLink = "", + borderRadius, + borderColor, + borderWidth, } = attrs || {}; const { next } = options || {}; @@ -669,8 +672,11 @@ export class EmailRenderer { width, maxWidth: "100%", outline: "none", - border: "none", textDecoration: "none", + borderStyle: "solid", + borderRadius: `${borderRadius}px`, + borderColor, + borderWidth: `${borderWidth}px`, }} title={title || alt || "Image"} /> diff --git a/packages/email-editor/src/types.ts b/packages/email-editor/src/types.ts index 0847c3b..fc6d094 100644 --- a/packages/email-editor/src/types.ts +++ b/packages/email-editor/src/types.ts @@ -20,4 +20,14 @@ export interface ButtonOptions { HTMLAttributes: Record; } +export interface ImageOptions { + altText: string; + url: string; + alignment: AllowedAlignments; + borderRadius: string; + borderColor: string; + borderWidth: string; + HTMLAttributes: Record; +} + export type SVGProps = React.SVGProps; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ab7c34..ac08364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: '@tiptap/extension-heading': specifier: ^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': specifier: ^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 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): resolution: {integrity: sha512-aaW/L9q+KNHHK+X73MPloHeIsT191n3VLd3xm6uUcFDnUNvzYJ/q65/1ZicdtCaOLvTutxdrEvhbkrVREX6a8g==} peerDependencies: