Skip to content

Commit 746cf43

Browse files
authored
Merge pull request #407 from cristianrdu/main
feature: add twitter extension
2 parents 1c4df17 + a5b7891 commit 746cf43

File tree

7 files changed

+246
-2
lines changed

7 files changed

+246
-2
lines changed

apps/web/components/tailwind/extensions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
TaskList,
1111
TiptapImage,
1212
TiptapLink,
13+
Twitter,
1314
UpdatedImage,
1415
Youtube,
1516
} from "novel/extensions";
@@ -122,6 +123,13 @@ const youtube = Youtube.configure({
122123
inline: false,
123124
});
124125

126+
const twitter = Twitter.configure({
127+
HTMLAttributes: {
128+
class: cx("not-prose"),
129+
},
130+
inline: false,
131+
});
132+
125133
const characterCount = CharacterCount.configure();
126134

127135
export const defaultExtensions = [
@@ -136,6 +144,7 @@ export const defaultExtensions = [
136144
aiHighlight,
137145
codeBlockLowlight,
138146
youtube,
147+
twitter,
139148
characterCount,
140149
GlobalDragHandle,
141150
];

apps/web/components/tailwind/slash-command.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
MessageSquarePlus,
1111
Text,
1212
TextQuote,
13+
Twitter,
1314
Youtube,
1415
} from "lucide-react";
1516
import { createSuggestionItems } from "novel/extensions";
@@ -153,6 +154,31 @@ export const suggestionItems = createSuggestionItems([
153154
}
154155
},
155156
},
157+
{
158+
title: "Twitter",
159+
description: "Embed a Tweet.",
160+
searchTerms: ["twitter", "embed"],
161+
icon: <Twitter size={18} />,
162+
command: ({ editor, range }) => {
163+
const tweetLink = prompt("Please enter Twitter Link");
164+
const tweetRegex = new RegExp(/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/);
165+
166+
if (tweetRegex.test(tweetLink)) {
167+
editor
168+
.chain()
169+
.focus()
170+
.deleteRange(range)
171+
.setTweet({
172+
src: tweetLink,
173+
})
174+
.run();
175+
} else {
176+
if (tweetLink !== null) {
177+
alert("Please enter a correct Twitter Link");
178+
}
179+
}
180+
},
181+
},
156182
]);
157183

158184
export const slashCommand = Command.configure({

apps/web/lib/content.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ export const defaultEditorContent = {
129129
},
130130
],
131131
},
132+
{
133+
type: "listItem",
134+
content: [
135+
{
136+
type: "paragraph",
137+
content: [
138+
{
139+
type: "text",
140+
text: "Add tweets from the command slash menu:",
141+
},
142+
],
143+
},
144+
{
145+
type: "twitter",
146+
attrs: {
147+
src: "https://x.com/elonmusk/status/1800759252224729577",
148+
},
149+
},
150+
],
151+
},
132152
],
133153
},
134154
{

packages/headless/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"jotai": "^2.6.4",
7070
"react-markdown": "^8.0.7",
7171
"react-moveable": "^0.56.0",
72+
"react-tweet": "^3.2.1",
7273
"tippy.js": "^6.3.7",
7374
"tiptap-extension-global-drag-handle": "^0.1.7",
7475
"tiptap-markdown": "^0.8.9",

packages/headless/src/extensions/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import StarterKit from "@tiptap/starter-kit";
1313
import { Markdown } from "tiptap-markdown";
1414
import CustomKeymap from "./custom-keymap";
1515
import { ImageResizer } from "./image-resizer";
16+
import { Twitter } from "./twitter";
1617
import UpdatedImage from "./updated-image";
1718

1819
import CharacterCount from "@tiptap/extension-character-count";
@@ -80,8 +81,7 @@ export {
8081
UpdatedImage,
8182
simpleExtensions,
8283
Youtube,
84+
Twitter,
8385
CharacterCount,
8486
GlobalDragHandle,
8587
};
86-
87-
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
2+
import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewRendererOptions } from "@tiptap/react";
3+
import { Tweet } from "react-tweet";
4+
export const TWITTER_REGEX_GLOBAL = /(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?/g;
5+
export const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/;
6+
7+
export const isValidTwitterUrl = (url: string) => {
8+
return url.match(TWITTER_REGEX);
9+
};
10+
11+
const TweetComponent = ({ node }: { node: Partial<ReactNodeViewRendererOptions> }) => {
12+
const url = node?.attrs?.src;
13+
const tweetId = url?.split("/").pop();
14+
15+
if (!tweetId) {
16+
return null;
17+
}
18+
19+
return (
20+
<NodeViewWrapper>
21+
<div data-twitter="">
22+
<Tweet id={tweetId} />
23+
</div>
24+
</NodeViewWrapper>
25+
);
26+
};
27+
28+
export interface TwitterOptions {
29+
/**
30+
* Controls if the paste handler for tweets should be added.
31+
* @default true
32+
* @example false
33+
*/
34+
addPasteHandler: boolean;
35+
36+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
37+
HTMLAttributes: Record<string, any>;
38+
39+
/**
40+
* Controls if the twitter node should be inline or not.
41+
* @default false
42+
* @example true
43+
*/
44+
inline: boolean;
45+
46+
/**
47+
* The origin of the tweet.
48+
* @default ''
49+
* @example 'https://tiptap.dev'
50+
*/
51+
origin: string;
52+
}
53+
54+
/**
55+
* The options for setting a tweet.
56+
*/
57+
type SetTweetOptions = { src: string };
58+
59+
declare module "@tiptap/core" {
60+
interface Commands<ReturnType> {
61+
twitter: {
62+
/**
63+
* Insert a tweet
64+
* @param options The tweet attributes
65+
* @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' })
66+
*/
67+
setTweet: (options: SetTweetOptions) => ReturnType;
68+
};
69+
}
70+
}
71+
72+
/**
73+
* This extension adds support for tweets.
74+
*/
75+
export const Twitter = Node.create<TwitterOptions>({
76+
name: "twitter",
77+
78+
addOptions() {
79+
return {
80+
addPasteHandler: true,
81+
HTMLAttributes: {},
82+
inline: false,
83+
origin: "",
84+
};
85+
},
86+
87+
addNodeView() {
88+
return ReactNodeViewRenderer(TweetComponent, { attrs: this.options.HTMLAttributes });
89+
},
90+
91+
inline() {
92+
return this.options.inline;
93+
},
94+
95+
group() {
96+
return this.options.inline ? "inline" : "block";
97+
},
98+
99+
draggable: true,
100+
101+
addAttributes() {
102+
return {
103+
src: {
104+
default: null,
105+
},
106+
};
107+
},
108+
109+
parseHTML() {
110+
return [
111+
{
112+
tag: "div[data-twitter]",
113+
},
114+
];
115+
},
116+
117+
addCommands() {
118+
return {
119+
setTweet:
120+
(options: SetTweetOptions) =>
121+
({ commands }) => {
122+
if (!isValidTwitterUrl(options.src)) {
123+
return false;
124+
}
125+
126+
return commands.insertContent({
127+
type: this.name,
128+
attrs: options,
129+
});
130+
},
131+
};
132+
},
133+
134+
addPasteRules() {
135+
if (!this.options.addPasteHandler) {
136+
return [];
137+
}
138+
139+
return [
140+
nodePasteRule({
141+
find: TWITTER_REGEX_GLOBAL,
142+
type: this.type,
143+
getAttributes: (match) => {
144+
return { src: match.input };
145+
},
146+
}),
147+
];
148+
},
149+
150+
renderHTML({ HTMLAttributes }) {
151+
return ["div", mergeAttributes({ "data-twitter": "" }, HTMLAttributes)];
152+
},
153+
});

pnpm-lock.yaml

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)