Add unsend campaign feature (#45)
* Add unsend email editor Add email editor Add more email editor Add renderer partial Add more marketing email features * Add more campaign feature * Add variables * Getting there * campaign is there mfs * Add migration
This commit is contained in:
92
packages/email-editor/src/extensions/ButtonExtension.ts
Normal file
92
packages/email-editor/src/extensions/ButtonExtension.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { AllowedAlignments } from "../types";
|
||||
|
||||
import { ButtonComponent } from "../nodes/button";
|
||||
// import { AllowedLogoAlignment } from '../nodes/logo';
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
button: {
|
||||
setButton: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ButtonExtension = Node.create({
|
||||
name: "button",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
component: {
|
||||
default: "button",
|
||||
},
|
||||
text: {
|
||||
default: "Button",
|
||||
},
|
||||
url: {
|
||||
default: "",
|
||||
},
|
||||
alignment: {
|
||||
default: "left",
|
||||
},
|
||||
borderRadius: {
|
||||
default: "4",
|
||||
},
|
||||
borderWidth: {
|
||||
default: "1",
|
||||
},
|
||||
buttonColor: {
|
||||
default: "rgb(0, 0, 0)",
|
||||
},
|
||||
borderColor: {
|
||||
default: "rgb(0, 0, 0)",
|
||||
},
|
||||
textColor: {
|
||||
default: "rgb(255, 255, 255)",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `a[data-unsend-component="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"a",
|
||||
mergeAttributes(
|
||||
{
|
||||
"data-unsend-component": this.name,
|
||||
},
|
||||
HTMLAttributes
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setButton:
|
||||
() =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
unsendComponent: this.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ButtonComponent);
|
||||
},
|
||||
});
|
487
packages/email-editor/src/extensions/SlashCommand.tsx
Normal file
487
packages/email-editor/src/extensions/SlashCommand.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { Editor, Extension, Range, ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { cn } from "@unsend/ui/lib/utils";
|
||||
import {
|
||||
CodeIcon,
|
||||
DivideIcon,
|
||||
EraserIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ListIcon,
|
||||
ListOrderedIcon,
|
||||
RectangleEllipsisIcon,
|
||||
SquareSplitVerticalIcon,
|
||||
TextIcon,
|
||||
TextQuoteIcon,
|
||||
UserXIcon,
|
||||
VariableIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import tippy, { GetReferenceClientRect } from "tippy.js";
|
||||
|
||||
export interface CommandProps {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
interface CommandItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export type SlashCommandItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
searchTerms: string[];
|
||||
icon: JSX.Element;
|
||||
command: (options: CommandProps) => void;
|
||||
};
|
||||
|
||||
export const SlashCommand = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
props: any;
|
||||
}) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const DEFAULT_SLASH_COMMANDS: SlashCommandItem[] = [
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <TextIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode("paragraph", "paragraph")
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1Icon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 1 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2Icon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 2 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3Icon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 3 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <ListIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrderedIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
// {
|
||||
// title: "Image",
|
||||
// description: "Full width image",
|
||||
// searchTerms: ["image"],
|
||||
// icon: <ImageIcon className="h-4 w-4" />,
|
||||
// command: ({ editor, range }: CommandProps) => {
|
||||
// const imageUrl = prompt("Image URL: ") || "";
|
||||
|
||||
// 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: <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",
|
||||
description: "Add a break between lines.",
|
||||
searchTerms: ["break", "line"],
|
||||
icon: <DivideIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHardBreak().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
description: "Add blockquote.",
|
||||
searchTerms: ["quote", "blockquote"],
|
||||
icon: <TextQuoteIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
// {
|
||||
// title: "Footer",
|
||||
// description: "Add a footer text to email.",
|
||||
// searchTerms: ["footer", "text"],
|
||||
// icon: <FootprintsIcon className="h-4 w-4" />,
|
||||
// command: ({ editor, range }: CommandProps) => {
|
||||
// editor.chain().focus().deleteRange(range).setFooter().run();
|
||||
// },
|
||||
// },
|
||||
|
||||
{
|
||||
title: "Button",
|
||||
description: "Add code.",
|
||||
searchTerms: ["button"],
|
||||
icon: <RectangleEllipsisIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setButton().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
description: "Add code.",
|
||||
searchTerms: ["code"],
|
||||
icon: <CodeIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Horizontal Rule",
|
||||
description: "Add a horizontal rule.",
|
||||
searchTerms: ["horizontal", "rule"],
|
||||
icon: <SquareSplitVerticalIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear Line",
|
||||
description: "Clear the current line.",
|
||||
searchTerms: ["clear", "line"],
|
||||
icon: <EraserIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().selectParentNode().deleteSelection().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Variable",
|
||||
description: "Add a variable.",
|
||||
searchTerms: ["variable"],
|
||||
icon: <VariableIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).insertContent("{{").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Unsubscribe Footer",
|
||||
description: "Add an unsubscribe link.",
|
||||
searchTerms: ["unsubscribe"],
|
||||
icon: <UserXIcon className="h-4 w-4" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setHorizontalRule()
|
||||
.insertContent(
|
||||
`<unsub data-unsend-component='unsubscribe-footer'><p>You are receiving this email because you opted in via our site.<br/><br/><a href="{{unsend_unsubscribe_url}}">Unsubscribe from the list</a></p><br><br><p>Company name,<br/>00 street name<br/>City, State 000000</p></unsub>`
|
||||
)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
editor,
|
||||
}: {
|
||||
items: CommandItemProps[];
|
||||
command: (item: CommandItemProps) => void;
|
||||
editor: Editor;
|
||||
range: any;
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[command, editor, items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 ? (
|
||||
<div className="z-50 w-52 rounded-md border border-gray-200 bg-white shadow-md transition-all">
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="no-scrollbar h-auto max-h-[330px] overflow-y-auto scroll-smooth px-1 py-2"
|
||||
>
|
||||
{items.map((item: CommandItemProps, index: number) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100 hover:text-gray-900",
|
||||
index === selectedIndex
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "bg-transparent"
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export function getSlashCommandSuggestions(
|
||||
commands: SlashCommandItem[] = []
|
||||
): Omit<SuggestionOptions, "editor"> {
|
||||
return {
|
||||
items: ({ query }) => {
|
||||
return [...DEFAULT_SLASH_COMMANDS, ...commands].filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms &&
|
||||
item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<any>;
|
||||
let popup: InstanceType<any> | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
});
|
||||
},
|
||||
onUpdate: (props) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
if (!popup || !popup?.[0] || !component) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { UnsubscribeFooterComponent } from "../nodes/unsubscribe-footer";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
unsubscribeFooter: {
|
||||
setUnsubscribeFooter: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const UnsubscribeFooterExtension = Node.create({
|
||||
name: "unsubscribeFooter",
|
||||
group: "block",
|
||||
content: "inline*",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
component: {
|
||||
default: "unsubscribeFooter",
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `unsub`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"unsub",
|
||||
mergeAttributes(
|
||||
{
|
||||
"data-unsend-component": this.name,
|
||||
class: "footer",
|
||||
contenteditable: "true",
|
||||
},
|
||||
HTMLAttributes
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(UnsubscribeFooterComponent);
|
||||
},
|
||||
});
|
141
packages/email-editor/src/extensions/VariableExtension.ts
Normal file
141
packages/email-editor/src/extensions/VariableExtension.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { VariableComponent, VariableOptions } from "../nodes/variable";
|
||||
|
||||
export interface VariableNodeAttrs extends VariableOptions {}
|
||||
|
||||
export type VariableExtensionOptions = {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
export const VariablePluginKey = new PluginKey("variable");
|
||||
|
||||
export const VariableExtension = Node.create<VariableExtensionOptions>({
|
||||
name: "variable",
|
||||
|
||||
group: "inline",
|
||||
|
||||
inline: true,
|
||||
|
||||
selectable: false,
|
||||
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
deleteTriggerWithBackspace: false,
|
||||
suggestion: {
|
||||
char: "{{",
|
||||
pluginKey: VariablePluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
console.log("props: ", props);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " ",
|
||||
},
|
||||
])
|
||||
.run();
|
||||
|
||||
window.getSelection()?.collapseToEnd();
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = type
|
||||
? !!$from.parent.type.contentMatch.matchType(type)
|
||||
: false;
|
||||
console.log("allow: ", allow);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-id": attributes.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
name: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-name"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.name) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-name": attributes.name,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
fallback: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-fallback"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.fallback) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-fallback": attributes.fallback,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(VariableComponent);
|
||||
},
|
||||
});
|
80
packages/email-editor/src/extensions/index.ts
Normal file
80
packages/email-editor/src/extensions/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { ButtonExtension } from "./ButtonExtension";
|
||||
import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
|
||||
import { VariableExtension } from "./VariableExtension";
|
||||
import { getVariableSuggestions } from "../nodes/variable";
|
||||
import { UnsubscribeFooterExtension } from "./UnsubsubscribeExtension";
|
||||
|
||||
export function extensions({ variables }: { variables?: Array<string> }) {
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
dropcursor: {
|
||||
color: "#555",
|
||||
width: 3,
|
||||
},
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"p-0.5 px-2 bg-slate-200 text-black text-sm rounded tracking-normal font-normal",
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose border-l-4 border-gray-300 pl-4 mt-4 mb-4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
HTMLAttributes: {
|
||||
class: "underline cursor-pointer",
|
||||
},
|
||||
openOnClick: false,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: [Paragraph.name, Heading.name],
|
||||
}),
|
||||
CodeBlock.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"p-4 bg-slate-800 text-gray-100 text-sm rounded-md tracking-normal font-normal",
|
||||
},
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1, 2, 3],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
TaskItem,
|
||||
TaskList,
|
||||
SlashCommand.configure({
|
||||
suggestion: getSlashCommandSuggestions([]),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: "write something on '/' for commands",
|
||||
}),
|
||||
ButtonExtension,
|
||||
GlobalDragHandle,
|
||||
VariableExtension.configure({
|
||||
suggestion: getVariableSuggestions(variables),
|
||||
}),
|
||||
UnsubscribeFooterExtension,
|
||||
];
|
||||
|
||||
return extensions;
|
||||
}
|
Reference in New Issue
Block a user