Support Youtube, Tweet, and Metaculus embeds in editor (#744)

* Embed a tweet by URL

* Clean up imports

* Prevent tweetId from getting interpreted as a number

* Use a single place to embed iframe, Youtube, and Tweets

* Support Manifold and Metaculus embeds

* Remove unused import

* Simplify Manifold paste logic

* Clean up embed rewrite code

* Add back comment

* Rejigger config so tsx is only in web/

* Clean up comment

* Revert unnecessary tsconfig change

* Fix placeholder

* Lighten placeholder
This commit is contained in:
Austin Chen 2022-08-11 20:18:01 -07:00 committed by GitHub
parent daba28423a
commit 9311652bed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 206 additions and 74 deletions

View File

@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link' import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention' import { Mention } from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe' import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type'
import { uniq } from 'lodash' import { uniq } from 'lodash'
export function parseTags(text: string) { export function parseTags(text: string) {
@ -94,6 +95,7 @@ export const exhibitExts = [
Link, Link,
Mention, Mention,
Iframe, Iframe,
TiptapTweet,
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {

View File

@ -0,0 +1,37 @@
import { Node, mergeAttributes } from '@tiptap/core'
export interface TweetOptions {
tweetId: string
}
// This is a version of the Tiptap Node config without addNodeView,
// since that would require bundling in tsx
export const TiptapTweetNode = {
name: 'tiptapTweet',
group: 'block',
atom: true,
addAttributes() {
return {
tweetId: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: 'tiptap-tweet',
},
]
},
renderHTML(props: { HTMLAttributes: Record<string, any> }) {
return ['tiptap-tweet', mergeAttributes(props.HTMLAttributes)]
},
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export default Node.create<TweetOptions>(TiptapTweetNode)

View File

@ -21,16 +21,13 @@ import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion' import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
import TiptapTweet from './editor/tiptap-tweet'
import { EmbedModal } from './editor/embed-modal'
import { import {
CodeIcon, CodeIcon,
PhotographIcon, PhotographIcon,
PresentationChartLineIcon, PresentationChartLineIcon,
} from '@heroicons/react/solid' } from '@heroicons/react/solid'
import { Modal } from './layout/modal'
import { Col } from './layout/col'
import { Button } from './button'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import { MarketModal } from './editor/market-modal' import { MarketModal } from './editor/market-modal'
import { insertContent } from './editor/utils' import { insertContent } from './editor/utils'
@ -88,6 +85,7 @@ export function useTextEditor(props: {
suggestion: mentionSuggestion(users), suggestion: mentionSuggestion(users),
}), }),
Iframe, Iframe,
TiptapTweet,
], ],
content: defaultValue, content: defaultValue,
}, },
@ -131,13 +129,6 @@ function isValidIframe(text: string) {
return /^<iframe.*<\/iframe>$/.test(text) return /^<iframe.*<\/iframe>$/.test(text)
} }
function isValidUrl(text: string) {
// Conjured by Codex, not sure if it's actually good
return /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/.test(
text
)
}
export function TextEditor(props: { export function TextEditor(props: {
editor: Editor | null editor: Editor | null
upload: ReturnType<typeof useUploadMutation> upload: ReturnType<typeof useUploadMutation>
@ -169,7 +160,7 @@ export function TextEditor(props: {
onClick={() => setIframeOpen(true)} onClick={() => setIframeOpen(true)}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
> >
<IframeModal <EmbedModal
editor={editor} editor={editor}
open={iframeOpen} open={iframeOpen}
setOpen={setIframeOpen} setOpen={setIframeOpen}
@ -214,66 +205,6 @@ export function TextEditor(props: {
) )
} }
function IframeModal(props: {
editor: Editor | null
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const [input, setInput] = useState('')
const valid = isValidIframe(input) || isValidUrl(input)
const embedCode = isValidIframe(input) ? input : `<iframe src="${input}" />`
return (
<Modal open={open} setOpen={setOpen}>
<Col className="gap-2 rounded bg-white p-6">
<label
htmlFor="embed"
className="block text-sm font-medium text-gray-700"
>
Embed a market, Youtube video, etc.
</label>
<input
type="text"
name="embed"
id="embed"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder='e.g. <iframe src="..."></iframe>'
value={input}
onChange={(e) => setInput(e.target.value)}
/>
{/* Preview the embed if it's valid */}
{valid ? <RichContent content={embedCode} /> : <Spacer h={2} />}
<Row className="gap-2">
<Button
disabled={!valid}
onClick={() => {
if (editor && valid) {
insertContent(editor, embedCode)
setInput('')
setOpen(false)
}
}}
>
Embed
</Button>
<Button
color="gray"
onClick={() => {
setInput('')
setOpen(false)
}}
>
Cancel
</Button>
</Row>
</Col>
</Modal>
)
}
const useUploadMutation = (editor: Editor | null) => const useUploadMutation = (editor: Editor | null) =>
useMutation( useMutation(
(files: File[]) => (files: File[]) =>
@ -292,7 +223,7 @@ const useUploadMutation = (editor: Editor | null) =>
} }
) )
function RichContent(props: { export function RichContent(props: {
content: JSONContent | string content: JSONContent | string
smallImage?: boolean smallImage?: boolean
}) { }) {
@ -305,6 +236,7 @@ function RichContent(props: {
DisplayLink, DisplayLink,
DisplayMention, DisplayMention,
Iframe, Iframe,
TiptapTweet,
], ],
content, content,
editable: false, editable: false,

View File

@ -0,0 +1,116 @@
import { Editor } from '@tiptap/react'
import { useState } from 'react'
import { Button } from '../button'
import { RichContent } from '../editor'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
type EmbedPattern = {
// Regex should have a single capture group.
regex: RegExp
rewrite: (text: string) => string
}
const embedPatterns: EmbedPattern[] = [
{
regex: /^(<iframe.*<\/iframe>)$/,
rewrite: (text: string) => text,
},
{
regex: /^https?:\/\/manifold\.markets\/([^\/]+\/[^\/]+)/,
rewrite: (slug) =>
`<iframe src="https://manifold.markets/embed/${slug}"></iframe>`,
},
{
regex: /^https?:\/\/twitter\.com\/.*\/status\/(\d+)/,
// Hack: append a leading 't', to prevent tweetId from being interpreted as a number.
// If it's a number, there may be numeric precision issues.
rewrite: (id) => `<tiptap-tweet tweetid="t${id}"></tiptap-tweet>`,
},
{
regex: /^https?:\/\/www\.youtube\.com\/watch\?v=([^&]+)/,
rewrite: (id) =>
`<iframe src="https://www.youtube.com/embed/${id}"></iframe>`,
},
{
regex: /^https?:\/\/www\.metaculus\.com\/questions\/(\d+)/,
rewrite: (id) =>
`<iframe src="https://www.metaculus.com/questions/embed/${id}"></iframe>`,
},
{
regex: /^(https?:\/\/.*)/,
rewrite: (url) => `<iframe src="${url}"></iframe>`,
},
]
function embedCode(text: string) {
for (const pattern of embedPatterns) {
const match = text.match(pattern.regex)
if (match) {
return pattern.rewrite(match[1])
}
}
return null
}
export function EmbedModal(props: {
editor: Editor | null
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const [input, setInput] = useState('')
const embed = embedCode(input)
return (
<Modal open={open} setOpen={setOpen}>
<Col className="gap-2 rounded bg-white p-6">
<label
htmlFor="embed"
className="block text-sm font-medium text-gray-700"
>
Embed a Youtube video, Tweet, or other link
</label>
<input
type="text"
name="embed"
id="embed"
className="block w-full rounded-md border-gray-300 shadow-sm placeholder:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="e.g. https://www.youtube.com/watch?v=dQw4w9WgXcQ"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
{/* Preview the embed if it's valid */}
{embed ? <RichContent content={embed} /> : <Spacer h={2} />}
<Row className="gap-2">
<Button
disabled={!embed}
onClick={() => {
if (editor && embed) {
editor.chain().insertContent(embed).run()
console.log('editorjson', editor.getJSON())
setInput('')
setOpen(false)
}
}}
>
Embed
</Button>
<Button
color="gray"
onClick={() => {
setInput('')
setOpen(false)
}}
>
Cancel
</Button>
</Row>
</Col>
</Modal>
)
}

View File

@ -0,0 +1,13 @@
import { Node } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { TiptapTweetNode } from 'common/util/tiptap-tweet-type'
import WrappedTwitterTweetEmbed from './tweet-embed'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
export default Node.create<TweetOptions>({
...TiptapTweetNode,
addNodeView() {
return ReactNodeViewRenderer(WrappedTwitterTweetEmbed)
},
})

View File

@ -0,0 +1,19 @@
import { NodeViewWrapper } from '@tiptap/react'
import { TwitterTweetEmbed } from 'react-twitter-embed'
export default function WrappedTwitterTweetEmbed(props: {
node: {
attrs: {
tweetId: string
}
}
}): JSX.Element {
// Remove the leading 't' from the tweet id
const tweetId = props.node.attrs.tweetId.slice(1)
return (
<NodeViewWrapper className="tiptap-tweet">
<TwitterTweetEmbed tweetId={tweetId} />
</NodeViewWrapper>
)
}

View File

@ -53,6 +53,7 @@
"react-hot-toast": "2.2.0", "react-hot-toast": "2.2.0",
"react-instantsearch-hooks-web": "6.24.1", "react-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0", "react-query": "3.39.0",
"react-twitter-embed": "4.0.4",
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"tippy.js": "6.3.7" "tippy.js": "6.3.7"
}, },

View File

@ -9964,6 +9964,13 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0" use-composed-ref "^1.3.0"
use-latest "^1.2.1" use-latest "^1.2.1"
react-twitter-embed@4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/react-twitter-embed/-/react-twitter-embed-4.0.4.tgz#4a6b8354acc266876ff1110b9f648518ea20db6d"
integrity sha512-2JIL7qF+U62zRzpsh6SZDXNI3hRNVYf5vOZ1WRcMvwKouw+xC00PuFaD0aEp2wlyGaZ+f4x2VvX+uDadFQ3HVA==
dependencies:
scriptjs "^2.5.9"
react-with-forwarded-ref@^0.3.3: react-with-forwarded-ref@^0.3.3:
version "0.3.4" version "0.3.4"
resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5" resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5"
@ -10464,6 +10471,11 @@ schema-utils@^4.0.0:
ajv-formats "^2.1.1" ajv-formats "^2.1.1"
ajv-keywords "^5.0.0" ajv-keywords "^5.0.0"
scriptjs@^2.5.9:
version "2.5.9"
resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.9.tgz#343915cd2ec2ed9bfdde2b9875cd28f59394b35f"
integrity sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==
search-insights@^2.1.0: search-insights@^2.1.0:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/search-insights/-/search-insights-2.2.1.tgz#9c93344fbae5fbf2f88c1a81b46b4b5d888c11f7" resolved "https://registry.yarnpkg.com/search-insights/-/search-insights-2.2.1.tgz#9c93344fbae5fbf2f88c1a81b46b4b5d888c11f7"