chore: remove options to add new embeds and render existing twitter embeds as links (#2917)

This commit is contained in:
Aman Harwara
2025-07-21 19:17:53 +05:30
committed by GitHub
parent 77f8bf6368
commit 092dc7957f
8 changed files with 7 additions and 514 deletions

View File

@@ -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 />

View File

@@ -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>
)
}

View File

@@ -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
}}
/>
</>
)
}

View File

@@ -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 = [

View File

@@ -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,
}),
)
}

View File

@@ -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
}

View File

@@ -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
}

File diff suppressed because one or more lines are too long