From e019f34575b0a9d8a1e1fbc04c72c100780ed035 Mon Sep 17 00:00:00 2001 From: Cristi Date: Tue, 11 Jun 2024 17:41:55 +0300 Subject: [PATCH 1/4] feat: add twitter extension --- apps/web/components/tailwind/extensions.ts | 9 + .../web/components/tailwind/slash-command.tsx | 26 +++ packages/headless/package.json | 1 + packages/headless/src/extensions/index.ts | 4 +- packages/headless/src/extensions/twitter.tsx | 154 ++++++++++++++++++ pnpm-lock.yaml | 35 ++++ 6 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 packages/headless/src/extensions/twitter.tsx diff --git a/apps/web/components/tailwind/extensions.ts b/apps/web/components/tailwind/extensions.ts index 0c581154f..9b90b5141 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("rounded-lg border border-muted"), + }, + 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..7bb6428dd 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) + .setTwitter({ + src: tweetLink, + }) + .run(); + } else { + if (tweetLink !== null) { + alert("Please enter a correct Twitter Link"); + } + } + }, + }, ]); export const slashCommand = Command.configure({ 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..9fe7fc4d2 --- /dev/null +++ b/packages/headless/src/extensions/twitter.tsx @@ -0,0 +1,154 @@ +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 youtube video. + */ +type SetTwitterOptions = { src: string; width?: number; height?: number; start?: number }; + +declare module "@tiptap/core" { + interface Commands { + twitter: { + /** + * Insert a youtube video + * @param options The youtube video attributes + * @example editor.commands.setYoutubeVideo({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + */ + setTwitter: (options: SetTwitterOptions) => ReturnType; + }; + } +} + +/** + * This extension adds support for youtube videos. + * @see https://www.tiptap.dev/api/nodes/youtube + */ +export const Twitter = Node.create({ + name: "twitter", + + addOptions() { + return { + addPasteHandler: true, + HTMLAttributes: {}, + inline: false, + origin: "", + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(TweetComponent); + }, + + 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 { + setTwitter: + (options: SetTwitterOptions) => + ({ 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)): From 1da07ca510d35a8593c0d9602703f866d1b7c7f7 Mon Sep 17 00:00:00 2001 From: Cristi Date: Tue, 11 Jun 2024 17:48:28 +0300 Subject: [PATCH 2/4] renaming --- .../web/components/tailwind/slash-command.tsx | 2 +- packages/headless/src/extensions/twitter.tsx | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/web/components/tailwind/slash-command.tsx b/apps/web/components/tailwind/slash-command.tsx index 7bb6428dd..e82ea4815 100644 --- a/apps/web/components/tailwind/slash-command.tsx +++ b/apps/web/components/tailwind/slash-command.tsx @@ -168,7 +168,7 @@ export const suggestionItems = createSuggestionItems([ .chain() .focus() .deleteRange(range) - .setTwitter({ + .setTweet({ src: tweetLink, }) .run(); diff --git a/packages/headless/src/extensions/twitter.tsx b/packages/headless/src/extensions/twitter.tsx index 9fe7fc4d2..c01d2bb44 100644 --- a/packages/headless/src/extensions/twitter.tsx +++ b/packages/headless/src/extensions/twitter.tsx @@ -52,26 +52,25 @@ export interface TwitterOptions { } /** - * The options for setting a youtube video. + * The options for setting a tweet. */ -type SetTwitterOptions = { src: string; width?: number; height?: number; start?: number }; +type SetTweetOptions = { src: string }; declare module "@tiptap/core" { interface Commands { twitter: { /** - * Insert a youtube video - * @param options The youtube video attributes - * @example editor.commands.setYoutubeVideo({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + * Insert a tweet + * @param options The tweet attributes + * @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' }) */ - setTwitter: (options: SetTwitterOptions) => ReturnType; + setTweet: (options: SetTweetOptions) => ReturnType; }; } } /** - * This extension adds support for youtube videos. - * @see https://www.tiptap.dev/api/nodes/youtube + * This extension adds support for tweets. */ export const Twitter = Node.create({ name: "twitter", @@ -117,8 +116,8 @@ export const Twitter = Node.create({ addCommands() { return { - setTwitter: - (options: SetTwitterOptions) => + setTweet: + (options: SetTweetOptions) => ({ commands }) => { if (!isValidTwitterUrl(options.src)) { return false; From 4e743e67229be4e6f4e9182ea14996106905faca Mon Sep 17 00:00:00 2001 From: Cristi Date: Wed, 12 Jun 2024 22:59:51 +0300 Subject: [PATCH 3/4] add content example --- apps/web/lib/content.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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", + }, + }, + ], + }, ], }, { From a5b7891269b9d43af4d69085cf985d5607219848 Mon Sep 17 00:00:00 2001 From: Cristi Date: Thu, 13 Jun 2024 00:13:47 +0300 Subject: [PATCH 4/4] fix stylings --- apps/web/components/tailwind/extensions.ts | 2 +- packages/headless/src/extensions/twitter.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/components/tailwind/extensions.ts b/apps/web/components/tailwind/extensions.ts index 9b90b5141..bf104fc66 100644 --- a/apps/web/components/tailwind/extensions.ts +++ b/apps/web/components/tailwind/extensions.ts @@ -125,7 +125,7 @@ const youtube = Youtube.configure({ const twitter = Twitter.configure({ HTMLAttributes: { - class: cx("rounded-lg border border-muted"), + class: cx("not-prose"), }, inline: false, }); diff --git a/packages/headless/src/extensions/twitter.tsx b/packages/headless/src/extensions/twitter.tsx index c01d2bb44..a28ba1800 100644 --- a/packages/headless/src/extensions/twitter.tsx +++ b/packages/headless/src/extensions/twitter.tsx @@ -85,7 +85,7 @@ export const Twitter = Node.create({ }, addNodeView() { - return ReactNodeViewRenderer(TweetComponent); + return ReactNodeViewRenderer(TweetComponent, { attrs: this.options.HTMLAttributes }); }, inline() {