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:
parent
daba28423a
commit
9311652bed
|
@ -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) {
|
||||||
|
|
37
common/util/tiptap-tweet-type.ts
Normal file
37
common/util/tiptap-tweet-type.ts
Normal 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)
|
|
@ -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,
|
||||||
|
|
116
web/components/editor/embed-modal.tsx
Normal file
116
web/components/editor/embed-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
13
web/components/editor/tiptap-tweet.tsx
Normal file
13
web/components/editor/tiptap-tweet.tsx
Normal 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)
|
||||||
|
},
|
||||||
|
})
|
19
web/components/editor/tweet-embed.tsx
Normal file
19
web/components/editor/tweet-embed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user