From f7e80c073d3a6fd35ade97e61376c00a0cf55b5c Mon Sep 17 00:00:00 2001 From: Ajay Patel Date: Sat, 1 Nov 2025 11:21:35 -0400 Subject: [PATCH 1/2] refactor: update TextMenu and Variable nodes to ensure copy action works correctly --- .env.example | 2 +- packages/email-editor/src/menus/TextMenu.tsx | 140 +++++++++++-------- packages/email-editor/src/nodes/variable.tsx | 45 +++--- 3 files changed, 107 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index a2ab1826..ae4a3763 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,4 @@ FROM_EMAIL="hello@usesend.com" API_RATE_LIMIT=2 AUTH_EMAIL_RATE_LIMIT=5 -NEXT_PUBLIC_IS_CLOUD=true +NEXT_PUBLIC_IS_CLOUD=true \ No newline at end of file diff --git a/packages/email-editor/src/menus/TextMenu.tsx b/packages/email-editor/src/menus/TextMenu.tsx index f7df6871..1ec6c906 100644 --- a/packages/email-editor/src/menus/TextMenu.tsx +++ b/packages/email-editor/src/menus/TextMenu.tsx @@ -1,4 +1,9 @@ -import { BubbleMenu, BubbleMenuProps, isTextSelection } from "@tiptap/react"; +import { + BubbleMenu, + BubbleMenuProps, + Editor, + isTextSelection, +} from "@tiptap/react"; import { AlignCenterIcon, AlignLeftIcon, @@ -20,16 +25,13 @@ import { TextQuoteIcon, UnderlineIcon, } from "lucide-react"; -import { TextMenuButton } from "./TextMenuButton"; -import { Button } from "@usesend/ui/src/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@usesend/ui/src/popover"; -import { Separator } from "@usesend/ui/src/separator"; -import { useMemo, useState } from "react"; -import { LinkEditorPanel } from "../components/panels/LinkEditorPanel"; +import {TextMenuButton} from "./TextMenuButton"; +import {Button} from "@usesend/ui/src/button"; +import {Popover, PopoverContent, PopoverTrigger} from "@usesend/ui/src/popover"; +import {Separator} from "@usesend/ui/src/separator"; +import {useMemo, useState} from "react"; +import {LinkEditorPanel} from "../components/panels/LinkEditorPanel"; +import {EditorState} from "@tiptap/pm/state"; // import { allowedLogoAlignment } from "../nodes/logo"; export interface TextMenuItem { @@ -92,15 +94,15 @@ const textColors = [ ]; export function TextMenu(props: TextMenuProps) { - const { editor } = props; + const {editor} = props; const icons = [AlignLeftIcon, AlignCenterIcon, AlignRightIcon]; const alignmentItems: TextMenuItem[] = ["left", "center", "right"].map( (alignment, index) => ({ name: alignment, - isActive: () => editor?.isActive({ textAlign: alignment })!, + isActive: () => editor?.isActive({textAlign: alignment})!, command: () => { - if (props?.editor?.isActive({ textAlign: alignment })) { + if (props?.editor?.isActive({textAlign: alignment})) { props?.editor?.chain()?.focus().unsetTextAlign().run(); } else { props?.editor?.chain().focus().setTextAlign(alignment).run()!; @@ -185,11 +187,11 @@ export function TextMenu(props: TextMenuProps) { .focus() .lift("taskItem") .liftListItem("listItem") - .setHeading({ level: 1 }) + .setHeading({level: 1}) .run(), id: "heading1", - disabled: () => !editor?.can().setHeading({ level: 1 }), - isActive: () => editor?.isActive("heading", { level: 1 }), + disabled: () => !editor?.can().setHeading({level: 1}), + isActive: () => editor?.isActive("heading", {level: 1}), label: "Heading 1", type: "option", }, @@ -201,11 +203,11 @@ export function TextMenu(props: TextMenuProps) { ?.focus() ?.lift("taskItem") .liftListItem("listItem") - .setHeading({ level: 2 }) + .setHeading({level: 2}) .run(), id: "heading2", - disabled: () => !editor?.can().setHeading({ level: 2 }), - isActive: () => editor?.isActive("heading", { level: 2 }), + disabled: () => !editor?.can().setHeading({level: 2}), + isActive: () => editor?.isActive("heading", {level: 2}), label: "Heading 2", type: "option", }, @@ -217,11 +219,11 @@ export function TextMenu(props: TextMenuProps) { ?.focus() ?.lift("taskItem") .liftListItem("listItem") - .setHeading({ level: 3 }) + .setHeading({level: 3}) .run(), id: "heading3", - disabled: () => !editor?.can().setHeading({ level: 3 }), - isActive: () => editor?.isActive("heading", { level: 3 }), + disabled: () => !editor?.can().setHeading({level: 3}), + isActive: () => editor?.isActive("heading", {level: 3}), label: "Heading 3", type: "option", }, @@ -247,36 +249,60 @@ export function TextMenu(props: TextMenuProps) { [editor, editor?.state] ); - const bubbleMenuProps: TextMenuProps = { - ...props, - shouldShow: ({ editor, state, from, to }) => { - const { doc, selection } = state; - const { empty } = selection; + let showTimer: number | null = null; + let lastShowResult = false; - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = - !doc.textBetween(from, to).length && isTextSelection(state.selection); + const delayedShouldShow = () => { + if (showTimer) clearTimeout(showTimer); - if ( - empty || - isEmptyTextBlock || - !editor.isEditable || - editor.isActive("image") || - editor.isActive("logo") || - editor.isActive("spacer") || - editor.isActive("variable") || - editor.isActive("link") || - editor.isActive({ - component: "button", - }) - ) { - return false; - } + showTimer = setTimeout( + ({ + editor, + state, + from, + to, + }: { + editor: Editor; + state: EditorState; + from: number; + to: number; + }) => { + const {doc, selection} = state; + const {empty} = selection; - return true; - }, + // Sometime check for `empty` is not enough. + // Doubleclick an empty paragraph returns a node size of 2. + // So we check also for an empty text size. + const isEmptyTextBlock = + !doc.textBetween(from, to).length && isTextSelection(state.selection); + + if ( + empty || + isEmptyTextBlock || + !editor.isEditable || + editor.isActive("image") || + editor.isActive("logo") || + editor.isActive("spacer") || + editor.isActive("variable") || + editor.isActive("link") || + editor.isActive({ + component: "button", + }) + ) { + return false; + } + + return true; + }, + 500 + ); + + return lastShowResult; + }; + + const bubbleMenuProps: TextMenuProps = { + ...props, + shouldShow: delayedShouldShow, tippyOptions: { maxWidth: "100%", moveTransition: "transform 0.15s ease-out", @@ -300,11 +326,7 @@ export function TextMenu(props: TextMenuProps) { { - editor - ?.chain() - .focus() - .setLink({ href: url, target: "_blank" }) - .run(); + editor?.chain().focus().setLink({href: url, target: "_blank"}).run(); // editor?.commands.blur(); }} @@ -320,7 +342,7 @@ export function TextMenu(props: TextMenuProps) { variant="ghost" className="hover:bg-slate-100 hover:text-slate-900" > - A + A @@ -341,7 +363,7 @@ export function TextMenu(props: TextMenuProps) { : "" }`} > - A + A {color.name} ))} @@ -355,7 +377,7 @@ type ContentTypePickerProps = { options: ContentTypePickerOption[]; }; -function ContentTypePicker({ options }: ContentTypePickerProps) { +function ContentTypePicker({options}: ContentTypePickerProps) { const activeOption = useMemo( () => options.find((option) => option.isActive()), [options] @@ -401,7 +423,7 @@ type EditLinkPopoverType = { onSetLink: (url: string) => void; }; -function EditLinkPopover({ onSetLink }: EditLinkPopoverType) { +function EditLinkPopover({onSetLink}: EditLinkPopoverType) { return ( diff --git a/packages/email-editor/src/nodes/variable.tsx b/packages/email-editor/src/nodes/variable.tsx index 0788da5b..6e283cb2 100644 --- a/packages/email-editor/src/nodes/variable.tsx +++ b/packages/email-editor/src/nodes/variable.tsx @@ -1,16 +1,12 @@ -import { NodeViewProps, NodeViewWrapper, ReactRenderer } from "@tiptap/react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@usesend/ui/src/popover"; -import { cn } from "@usesend/ui/lib/utils"; -import { Input } from "@usesend/ui/src/input"; -import { Button } from "@usesend/ui/src/button"; -import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; -import { SuggestionOptions } from "@tiptap/suggestion"; -import tippy, { GetReferenceClientRect } from "tippy.js"; -import { CheckIcon, TriangleAlert } from "lucide-react"; +import {NodeViewProps, NodeViewWrapper, ReactRenderer} from "@tiptap/react"; +import {Popover, PopoverContent, PopoverTrigger} from "@usesend/ui/src/popover"; +import {cn} from "@usesend/ui/lib/utils"; +import {Input} from "@usesend/ui/src/input"; +import {Button} from "@usesend/ui/src/button"; +import {forwardRef, useEffect, useImperativeHandle, useState} from "react"; +import {SuggestionOptions} from "@tiptap/suggestion"; +import tippy, {GetReferenceClientRect} from "tippy.js"; +import {CheckIcon, TriangleAlert} from "lucide-react"; export interface VariableOptions { name: string; @@ -26,14 +22,14 @@ export const VariableList = forwardRef((props: any, ref) => { console.log("item: ", item); if (item) { - props.command({ id: item, name: item, fallback: "" }); + props.command({id: item, name: item, fallback: ""}); } }; useEffect(() => setSelectedIndex(0), [props.items]); useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: { event: KeyboardEvent }) => { + onKeyDown: ({event}: {event: KeyboardEvent}) => { if (event.key === "ArrowUp") { setSelectedIndex( (selectedIndex + props.items.length - 1) % props.items.length @@ -85,7 +81,7 @@ export function getVariableSuggestions( variables: Array = [] ): Omit { return { - items: ({ query }) => { + items: ({query}) => { return variables .concat(query.length > 0 ? [query] : []) .filter((item) => item.toLowerCase().startsWith(query.toLowerCase())) @@ -154,9 +150,10 @@ export function getVariableSuggestions( } export function VariableComponent(props: NodeViewProps) { - const { name, fallback } = props.node.attrs as VariableOptions; + const [isEditing, setIsEditing] = useState(false); + const {name, fallback} = props.node.attrs as VariableOptions; const [fallbackValue, setFallbackValue] = useState(fallback); - const { getPos, editor } = props; + const {getPos, editor} = props; console.log(props.selected); @@ -165,6 +162,8 @@ export function VariableComponent(props: NodeViewProps) { props.updateAttributes({ fallback: fallbackValue, }); + + setIsEditing(false); }; return ( @@ -175,17 +174,23 @@ export function VariableComponent(props: NodeViewProps) { draggable="false" data-drag-handle="" > - +