chore: remove options to add new embeds and render existing twitter embeds as links (#2917)
This commit is contained in:
@@ -12,9 +12,6 @@ import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin'
|
||||
import { EditorState, LexicalEditor } from 'lexical'
|
||||
import HorizontalRulePlugin from './Plugins/HorizontalRulePlugin'
|
||||
import TwitterPlugin from './Plugins/TwitterPlugin'
|
||||
import YouTubePlugin from './Plugins/YouTubePlugin'
|
||||
import AutoEmbedPlugin from './Plugins/AutoEmbedPlugin'
|
||||
import CollapsiblePlugin from './Plugins/CollapsiblePlugin'
|
||||
import DraggableBlockPlugin from './Plugins/DraggableBlockPlugin'
|
||||
import CodeHighlightPlugin from './Plugins/CodeHighlightPlugin'
|
||||
@@ -125,9 +122,6 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
<CodeHighlightPlugin />
|
||||
<LinkPlugin />
|
||||
<HashtagPlugin />
|
||||
<AutoEmbedPlugin />
|
||||
<TwitterPlugin />
|
||||
<YouTubePlugin />
|
||||
<CollapsiblePlugin />
|
||||
<TabIndentationPlugin />
|
||||
<RemoveBrokenTablesPlugin />
|
||||
|
||||
@@ -20,22 +20,6 @@ import type {
|
||||
|
||||
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
|
||||
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const WIDGET_SCRIPT_URL = '/dist/twitter-widgets.js'
|
||||
|
||||
type TweetComponentProps = Readonly<{
|
||||
className: Readonly<{
|
||||
base: string
|
||||
focus: string
|
||||
}>
|
||||
format: ElementFormatType | null
|
||||
loadingComponent?: JSX.Element | string
|
||||
nodeKey: NodeKey
|
||||
onError?: (error: string) => void
|
||||
onLoad?: () => void
|
||||
tweetID: string
|
||||
}>
|
||||
|
||||
function convertTweetElement(domNode: HTMLDivElement): DOMConversionOutput | null {
|
||||
const id = domNode.getAttribute('data-lexical-tweet-id')
|
||||
@@ -46,71 +30,6 @@ function convertTweetElement(domNode: HTMLDivElement): DOMConversionOutput | nul
|
||||
return null
|
||||
}
|
||||
|
||||
let isTwitterScriptLoading = true
|
||||
|
||||
function TweetComponent({
|
||||
className,
|
||||
format,
|
||||
loadingComponent,
|
||||
nodeKey,
|
||||
onError,
|
||||
onLoad,
|
||||
tweetID,
|
||||
}: TweetComponentProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const previousTweetIDRef = useRef<string>('')
|
||||
const [isTweetLoading, setIsTweetLoading] = useState(false)
|
||||
|
||||
const createTweet = useCallback(async () => {
|
||||
try {
|
||||
// @ts-expect-error Twitter is attached to the window.
|
||||
await window.twttr.widgets.createTweet(tweetID, containerRef.current)
|
||||
|
||||
setIsTweetLoading(false)
|
||||
isTwitterScriptLoading = false
|
||||
|
||||
if (onLoad) {
|
||||
onLoad()
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(String(error))
|
||||
}
|
||||
}
|
||||
}, [onError, onLoad, tweetID])
|
||||
|
||||
useEffect(() => {
|
||||
if (tweetID !== previousTweetIDRef.current) {
|
||||
setIsTweetLoading(true)
|
||||
|
||||
if (isTwitterScriptLoading) {
|
||||
const script = document.createElement('script')
|
||||
script.src = WIDGET_SCRIPT_URL
|
||||
script.async = true
|
||||
document.body?.appendChild(script)
|
||||
script.onload = createTweet
|
||||
if (onError) {
|
||||
script.onerror = onError as OnErrorEventHandler
|
||||
}
|
||||
} else {
|
||||
createTweet().catch(console.error)
|
||||
}
|
||||
|
||||
if (previousTweetIDRef) {
|
||||
previousTweetIDRef.current = tweetID
|
||||
}
|
||||
}
|
||||
}, [createTweet, onError, tweetID])
|
||||
|
||||
return (
|
||||
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
|
||||
{isTweetLoading ? loadingComponent : null}
|
||||
<div style={{ display: 'inline-block', width: '550px' }} ref={containerRef} />
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
|
||||
export type SerializedTweetNode = Spread<
|
||||
{
|
||||
id: string
|
||||
@@ -172,7 +91,7 @@ export class TweetNode extends DecoratorBlockNode {
|
||||
}
|
||||
|
||||
override getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
|
||||
return `https://twitter.com/i/web/status/${this.__id}`
|
||||
return `https://x.com/i/web/status/${this.__id}`
|
||||
}
|
||||
|
||||
override decorate(_: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
@@ -181,14 +100,13 @@ export class TweetNode extends DecoratorBlockNode {
|
||||
base: embedBlockTheme.base || '',
|
||||
focus: embedBlockTheme.focus || '',
|
||||
}
|
||||
const link = this.getTextContent()
|
||||
return (
|
||||
<TweetComponent
|
||||
className={className}
|
||||
format={this.__format}
|
||||
loadingComponent="Loading..."
|
||||
nodeKey={this.getKey()}
|
||||
tweetID={this.__id}
|
||||
/>
|
||||
<BlockWithAlignableContents className={className} format={this.__format} nodeKey={this.getKey()}>
|
||||
<a href={link} target="_blank" rel="noreferrer noopener">
|
||||
{link}
|
||||
</a>
|
||||
</BlockWithAlignableContents>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
|
||||
import {
|
||||
AutoEmbedOption,
|
||||
EmbedConfig,
|
||||
EmbedMatchResult,
|
||||
LexicalAutoEmbedPlugin,
|
||||
URL_MATCHER,
|
||||
} from '@lexical/react/LexicalAutoEmbedPlugin'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import * as ReactDOM from 'react-dom'
|
||||
|
||||
import useModal from '../../Lexical/Hooks/useModal'
|
||||
import { INSERT_TWEET_COMMAND } from '../TwitterPlugin'
|
||||
import { INSERT_YOUTUBE_COMMAND } from '../YouTubePlugin'
|
||||
import { classNames } from '@standardnotes/snjs'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import { isMobileScreen } from '../../../../Utils'
|
||||
|
||||
interface PlaygroundEmbedConfig extends EmbedConfig {
|
||||
// Human readable name of the embeded content e.g. Tweet or Google Map.
|
||||
contentName: string
|
||||
|
||||
// Icon for display.
|
||||
icon?: JSX.Element
|
||||
iconName: string
|
||||
|
||||
// An example of a matching url https://twitter.com/jack/status/20
|
||||
exampleUrl: string
|
||||
|
||||
// For extra searching.
|
||||
keywords: Array<string>
|
||||
|
||||
// Embed a Figma Project.
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
|
||||
contentName: 'Youtube Video',
|
||||
|
||||
exampleUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon youtube" />,
|
||||
iconName: 'youtube',
|
||||
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id)
|
||||
},
|
||||
|
||||
keywords: ['youtube', 'video'],
|
||||
|
||||
// Determine if a given URL is a match and return url data.
|
||||
parseUrl: (url: string) => {
|
||||
const match = /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url)
|
||||
|
||||
const id = match ? (match?.[2].length === 11 ? match[2] : null) : null
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
type: 'youtube-video',
|
||||
}
|
||||
|
||||
export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
|
||||
// e.g. Tweet or Google Map.
|
||||
contentName: 'Tweet',
|
||||
|
||||
exampleUrl: 'https://twitter.com/jack/status/20',
|
||||
|
||||
// Icon for display.
|
||||
icon: <i className="icon tweet" />,
|
||||
iconName: 'tweet',
|
||||
|
||||
// Create the Lexical embed node from the url data.
|
||||
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
|
||||
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id)
|
||||
},
|
||||
|
||||
// For extra searching.
|
||||
keywords: ['tweet', 'twitter'],
|
||||
|
||||
// Determine if a given URL is a match and return url data.
|
||||
parseUrl: (text: string) => {
|
||||
const match = /^https:\/\/twitter\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)$/.exec(text)
|
||||
|
||||
if (match != null) {
|
||||
return {
|
||||
id: match[4],
|
||||
url: match[0],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
type: 'tweet',
|
||||
}
|
||||
|
||||
export const EmbedConfigs = [TwitterEmbedConfig, YoutubeEmbedConfig]
|
||||
|
||||
function AutoEmbedMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: AutoEmbedOption
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={classNames('cursor-pointer rounded px-2 py-1', isSelected && 'bg-info-backdrop')}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text">{option.title}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoEmbedMenu({
|
||||
options,
|
||||
selectedItemIndex,
|
||||
onOptionClick,
|
||||
onOptionMouseEnter,
|
||||
}: {
|
||||
selectedItemIndex: number | null
|
||||
onOptionClick: (option: AutoEmbedOption, index: number) => void
|
||||
onOptionMouseEnter: (index: number) => void
|
||||
options: Array<AutoEmbedOption>
|
||||
}) {
|
||||
return (
|
||||
<div className="typeahead-popover min-w-max rounded border border-border bg-default p-1">
|
||||
<ul className="list-none">
|
||||
{options.map((option: AutoEmbedOption, i: number) => (
|
||||
<AutoEmbedMenuItem
|
||||
index={i}
|
||||
isSelected={selectedItemIndex === i}
|
||||
onClick={() => onOptionClick(option, i)}
|
||||
onMouseEnter={() => onOptionMouseEnter(i)}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const debounce = (callback: (text: string) => void, delay: number) => {
|
||||
let timeoutId: number
|
||||
|
||||
return (text: string) => {
|
||||
window.clearTimeout(timeoutId)
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
callback(text)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
export function AutoEmbedDialog({
|
||||
embedConfig,
|
||||
onClose,
|
||||
}: {
|
||||
embedConfig: PlaygroundEmbedConfig
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const [text, setText] = useState('')
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [embedResult, setEmbedResult] = useState<EmbedMatchResult | null>(null)
|
||||
|
||||
const validateText = useMemo(
|
||||
() =>
|
||||
debounce((inputText: string) => {
|
||||
const urlMatch = URL_MATCHER.exec(inputText)
|
||||
if (embedConfig != null && inputText != null && urlMatch != null) {
|
||||
void Promise.resolve(embedConfig.parseUrl(inputText)).then((parseResult) => {
|
||||
setEmbedResult(parseResult)
|
||||
})
|
||||
} else if (embedResult != null) {
|
||||
setEmbedResult(null)
|
||||
}
|
||||
}, 200),
|
||||
[embedConfig, embedResult],
|
||||
)
|
||||
|
||||
const onClick = () => {
|
||||
if (embedResult != null) {
|
||||
embedConfig.insertNode(editor, embedResult)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const focusOnMount = useCallback((element: HTMLInputElement | null) => {
|
||||
if (element) {
|
||||
setTimeout(() => element.focus())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
URL:
|
||||
<DecoratedInput
|
||||
value={text}
|
||||
onChange={(text) => {
|
||||
setText(text)
|
||||
validateText(text)
|
||||
}}
|
||||
ref={focusOnMount}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2.5 flex justify-end">
|
||||
<Button disabled={!embedResult} onClick={onClick} small={isMobileScreen()}>
|
||||
Embed
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AutoEmbedPlugin(): JSX.Element {
|
||||
const [modal, showModal] = useModal()
|
||||
|
||||
const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
|
||||
showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
|
||||
<AutoEmbedDialog embedConfig={embedConfig} onClose={onClose} />
|
||||
))
|
||||
}
|
||||
|
||||
const getMenuOptions = (activeEmbedConfig: PlaygroundEmbedConfig, embedFn: () => void, dismissFn: () => void) => {
|
||||
return [
|
||||
new AutoEmbedOption('Dismiss', {
|
||||
onSelect: dismissFn,
|
||||
}),
|
||||
new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
|
||||
onSelect: embedFn,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal}
|
||||
<LexicalAutoEmbedPlugin<PlaygroundEmbedConfig>
|
||||
embedConfigs={EmbedConfigs}
|
||||
onOpenEmbedModalForConfig={openEmbedModal}
|
||||
getMenuOptions={getMenuOptions}
|
||||
menuRenderFn={(anchorElementRef, { selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex }) => {
|
||||
return anchorElementRef.current
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
className="typeahead-popover auto-embed-menu"
|
||||
style={{
|
||||
marginLeft: anchorElementRef.current.style.width,
|
||||
}}
|
||||
>
|
||||
<AutoEmbedMenu
|
||||
options={options}
|
||||
selectedItemIndex={selectedIndex}
|
||||
onOptionClick={(option: AutoEmbedOption, index: number) => {
|
||||
setHighlightedIndex(index)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onOptionMouseEnter={(index: number) => {
|
||||
setHighlightedIndex(index)
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import { GetCodeBlockOption } from '../Blocks/Code'
|
||||
import { GetQuoteBlockOption } from '../Blocks/Quote'
|
||||
import { GetDividerBlockOption } from '../Blocks/Divider'
|
||||
import { GetCollapsibleBlockOption } from '../Blocks/Collapsible'
|
||||
import { GetEmbedsBlockOptions } from '../Blocks/Embeds'
|
||||
import { GetUploadFileOption } from '../Blocks/File'
|
||||
|
||||
export default function BlockPickerMenuPlugin({ popoverZIndex }: { popoverZIndex?: string }): JSX.Element {
|
||||
@@ -72,7 +71,6 @@ export default function BlockPickerMenuPlugin({ popoverZIndex }: { popoverZIndex
|
||||
GetJustifyAlignBlockOption(editor),
|
||||
GetPasswordBlockOption(editor),
|
||||
GetCollapsibleBlockOption(editor),
|
||||
...GetEmbedsBlockOptions(editor),
|
||||
]
|
||||
|
||||
const dynamicOptions = [
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { LexicalEditor } from 'lexical'
|
||||
import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPlugin'
|
||||
import { EmbedConfigs } from '../AutoEmbedPlugin'
|
||||
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
|
||||
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
|
||||
|
||||
export function GetEmbedsBlocks(editor: LexicalEditor) {
|
||||
return EmbedConfigs.map((embedConfig) => ({
|
||||
name: `Embed ${embedConfig.contentName}`,
|
||||
iconName: embedConfig.iconName as LexicalIconName,
|
||||
keywords: [...embedConfig.keywords, 'embed'],
|
||||
onSelect: () => editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
|
||||
}))
|
||||
}
|
||||
|
||||
export function GetEmbedsBlockOptions(editor: LexicalEditor) {
|
||||
return GetEmbedsBlocks(editor).map(
|
||||
(block) =>
|
||||
new BlockPickerOption(block.name, {
|
||||
iconName: block.iconName as LexicalIconName,
|
||||
keywords: block.keywords,
|
||||
onSelect: block.onSelect,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { $createTweetNode, TweetNode } from '../../Lexical/Nodes/TweetNode'
|
||||
|
||||
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand('INSERT_TWEET_COMMAND')
|
||||
|
||||
export default function TwitterPlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([TweetNode])) {
|
||||
throw new Error('TwitterPlugin: TweetNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_TWEET_COMMAND,
|
||||
(payload) => {
|
||||
const tweetNode = $createTweetNode(payload)
|
||||
$insertNodeToNearestRoot(tweetNode)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { $createYouTubeNode, YouTubeNode } from '../../Lexical/Nodes/YouTubeNode'
|
||||
|
||||
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand('INSERT_YOUTUBE_COMMAND')
|
||||
|
||||
export default function YouTubePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([YouTubeNode])) {
|
||||
throw new Error('YouTubePlugin: YouTubeNode not registered on editor')
|
||||
}
|
||||
|
||||
return editor.registerCommand<string>(
|
||||
INSERT_YOUTUBE_COMMAND,
|
||||
(payload) => {
|
||||
const youTubeNode = $createYouTubeNode(payload)
|
||||
$insertNodeToNearestRoot(youTubeNode)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
8
packages/web/src/vendor/twitter-widgets.js
vendored
8
packages/web/src/vendor/twitter-widgets.js
vendored
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user