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)):