diff --git a/apps/web/components/tailwind/extensions.ts b/apps/web/components/tailwind/extensions.ts
index 0c581154f..bf104fc66 100644
--- a/apps/web/components/tailwind/extensions.ts
+++ b/apps/web/components/tailwind/extensions.ts
@@ -10,6 +10,7 @@ import {
TaskList,
TiptapImage,
TiptapLink,
+ Twitter,
UpdatedImage,
Youtube,
} from "novel/extensions";
@@ -122,6 +123,13 @@ const youtube = Youtube.configure({
inline: false,
});
+const twitter = Twitter.configure({
+ HTMLAttributes: {
+ class: cx("not-prose"),
+ },
+ inline: false,
+});
+
const characterCount = CharacterCount.configure();
export const defaultExtensions = [
@@ -136,6 +144,7 @@ export const defaultExtensions = [
aiHighlight,
codeBlockLowlight,
youtube,
+ twitter,
characterCount,
GlobalDragHandle,
];
diff --git a/apps/web/components/tailwind/slash-command.tsx b/apps/web/components/tailwind/slash-command.tsx
index 9f5c7c45b..e82ea4815 100644
--- a/apps/web/components/tailwind/slash-command.tsx
+++ b/apps/web/components/tailwind/slash-command.tsx
@@ -10,6 +10,7 @@ import {
MessageSquarePlus,
Text,
TextQuote,
+ Twitter,
Youtube,
} from "lucide-react";
import { createSuggestionItems } from "novel/extensions";
@@ -153,6 +154,31 @@ export const suggestionItems = createSuggestionItems([
}
},
},
+ {
+ title: "Twitter",
+ description: "Embed a Tweet.",
+ searchTerms: ["twitter", "embed"],
+ icon: ,
+ command: ({ editor, range }) => {
+ const tweetLink = prompt("Please enter Twitter Link");
+ const tweetRegex = new RegExp(/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/);
+
+ if (tweetRegex.test(tweetLink)) {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setTweet({
+ src: tweetLink,
+ })
+ .run();
+ } else {
+ if (tweetLink !== null) {
+ alert("Please enter a correct Twitter Link");
+ }
+ }
+ },
+ },
]);
export const slashCommand = Command.configure({
diff --git a/apps/web/lib/content.ts b/apps/web/lib/content.ts
index 6464cfa1d..859b62c0d 100644
--- a/apps/web/lib/content.ts
+++ b/apps/web/lib/content.ts
@@ -129,6 +129,26 @@ export const defaultEditorContent = {
},
],
},
+ {
+ type: "listItem",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "Add tweets from the command slash menu:",
+ },
+ ],
+ },
+ {
+ type: "twitter",
+ attrs: {
+ src: "https://x.com/elonmusk/status/1800759252224729577",
+ },
+ },
+ ],
+ },
],
},
{
diff --git a/packages/headless/package.json b/packages/headless/package.json
index 0a7adcd87..d6da4ea1e 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -69,6 +69,7 @@
"jotai": "^2.6.4",
"react-markdown": "^8.0.7",
"react-moveable": "^0.56.0",
+ "react-tweet": "^3.2.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.7",
"tiptap-markdown": "^0.8.9",
diff --git a/packages/headless/src/extensions/index.ts b/packages/headless/src/extensions/index.ts
index 469426970..febcc2dc2 100644
--- a/packages/headless/src/extensions/index.ts
+++ b/packages/headless/src/extensions/index.ts
@@ -13,6 +13,7 @@ import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import CustomKeymap from "./custom-keymap";
import { ImageResizer } from "./image-resizer";
+import { Twitter } from "./twitter";
import UpdatedImage from "./updated-image";
import CharacterCount from "@tiptap/extension-character-count";
@@ -80,8 +81,7 @@ export {
UpdatedImage,
simpleExtensions,
Youtube,
+ Twitter,
CharacterCount,
GlobalDragHandle,
};
-
-
\ No newline at end of file
diff --git a/packages/headless/src/extensions/twitter.tsx b/packages/headless/src/extensions/twitter.tsx
new file mode 100644
index 000000000..a28ba1800
--- /dev/null
+++ b/packages/headless/src/extensions/twitter.tsx
@@ -0,0 +1,153 @@
+import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
+import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewRendererOptions } from "@tiptap/react";
+import { Tweet } from "react-tweet";
+export const TWITTER_REGEX_GLOBAL = /(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?/g;
+export const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/;
+
+export const isValidTwitterUrl = (url: string) => {
+ return url.match(TWITTER_REGEX);
+};
+
+const TweetComponent = ({ node }: { node: Partial }) => {
+ const url = node?.attrs?.src;
+ const tweetId = url?.split("/").pop();
+
+ if (!tweetId) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+};
+
+export interface TwitterOptions {
+ /**
+ * Controls if the paste handler for tweets should be added.
+ * @default true
+ * @example false
+ */
+ addPasteHandler: boolean;
+
+ // biome-ignore lint/suspicious/noExplicitAny:
+ HTMLAttributes: Record;
+
+ /**
+ * Controls if the twitter node should be inline or not.
+ * @default false
+ * @example true
+ */
+ inline: boolean;
+
+ /**
+ * The origin of the tweet.
+ * @default ''
+ * @example 'https://tiptap.dev'
+ */
+ origin: string;
+}
+
+/**
+ * The options for setting a tweet.
+ */
+type SetTweetOptions = { src: string };
+
+declare module "@tiptap/core" {
+ interface Commands {
+ twitter: {
+ /**
+ * Insert a tweet
+ * @param options The tweet attributes
+ * @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' })
+ */
+ setTweet: (options: SetTweetOptions) => ReturnType;
+ };
+ }
+}
+
+/**
+ * This extension adds support for tweets.
+ */
+export const Twitter = Node.create({
+ name: "twitter",
+
+ addOptions() {
+ return {
+ addPasteHandler: true,
+ HTMLAttributes: {},
+ inline: false,
+ origin: "",
+ };
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(TweetComponent, { attrs: this.options.HTMLAttributes });
+ },
+
+ inline() {
+ return this.options.inline;
+ },
+
+ group() {
+ return this.options.inline ? "inline" : "block";
+ },
+
+ draggable: true,
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: "div[data-twitter]",
+ },
+ ];
+ },
+
+ addCommands() {
+ return {
+ setTweet:
+ (options: SetTweetOptions) =>
+ ({ commands }) => {
+ if (!isValidTwitterUrl(options.src)) {
+ return false;
+ }
+
+ return commands.insertContent({
+ type: this.name,
+ attrs: options,
+ });
+ },
+ };
+ },
+
+ addPasteRules() {
+ if (!this.options.addPasteHandler) {
+ return [];
+ }
+
+ return [
+ nodePasteRule({
+ find: TWITTER_REGEX_GLOBAL,
+ type: this.type,
+ getAttributes: (match) => {
+ return { src: match.input };
+ },
+ }),
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ["div", mergeAttributes({ "data-twitter": "" }, HTMLAttributes)];
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 975c6957e..4cea4370c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -231,6 +231,9 @@ importers:
react-moveable:
specifier: ^0.56.0
version: 0.56.0
+ react-tweet:
+ specifier: ^3.2.1
+ version: 3.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
tippy.js:
specifier: ^6.3.7
version: 6.3.7
@@ -1205,6 +1208,9 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
+ '@swc/helpers@0.5.11':
+ resolution: {integrity: sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==}
+
'@swc/helpers@0.5.2':
resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==}
@@ -3206,6 +3212,12 @@ packages:
'@types/react':
optional: true
+ react-tweet@3.2.1:
+ resolution: {integrity: sha512-dktP3RMuwRB4pnSDocKpSsW5Hq1IXRW6fONkHhxT5EBIXsKZzdQuI70qtub1XN2dtZdkJWWxfBm/Q+kN+vRYFA==}
+ peerDependencies:
+ react: '>= 18.0.0'
+ react-dom: '>= 18.0.0'
+
react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
@@ -3526,6 +3538,11 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0
+ swr@2.2.5:
+ resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0
+
swrev@4.0.0:
resolution: {integrity: sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==}
@@ -4979,6 +4996,10 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
+ '@swc/helpers@0.5.11':
+ dependencies:
+ tslib: 2.6.2
+
'@swc/helpers@0.5.2':
dependencies:
tslib: 2.6.2
@@ -7232,6 +7253,14 @@ snapshots:
optionalDependencies:
'@types/react': 18.2.55
+ react-tweet@3.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+ dependencies:
+ '@swc/helpers': 0.5.11
+ clsx: 2.0.0
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ swr: 2.2.5(react@18.2.0)
+
react@18.2.0:
dependencies:
loose-envify: 1.4.0
@@ -7605,6 +7634,12 @@ snapshots:
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
+ swr@2.2.5(react@18.2.0):
+ dependencies:
+ client-only: 0.0.1
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+
swrev@4.0.0: {}
swrv@1.0.4(vue@3.4.21(typescript@5.3.3)):