/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */ import React from "react" import type { ComponentType, ReactNode } from "react" import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection" import { $applyNodeReplacement, $getNodeByKey, $insertNodes, COMMAND_PRIORITY_LOW, DecoratorNode, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, mergeRegister, } from "lexical" import type { DOMConversionMap, DOMExportOutput, LexicalEditor, NodeKey, SerializedLexicalNode, Spread, } from "lexical" import { cn } from "@workspace/ui/lib/utils" import type { LexicalActionContext, LexicalActionDefinition } from "./types" export interface LexicalEmbedPayload { id: string title: string description?: string imageUrl?: string metadata?: Record } export interface LexicalEmbedRenderProps { payload: LexicalEmbedPayload } export interface LexicalEmbedDefinition { type: string render: ComponentType } export interface CreateLexicalEmbedActionOptions { embed?: LexicalEmbedDefinition name: string label: LexicalActionDefinition["label"] icon?: LexicalActionDefinition["icon"] onRequest: (context: LexicalActionContext) => void } type SerializedLexicalEmbedNode = Spread< { embedType: string payload: LexicalEmbedPayload }, SerializedLexicalNode > const LexicalEmbedContext = React.createContext< ReadonlyMap >(new Map()) export function LexicalEmbedProvider({ definitions, children, }: { definitions: readonly LexicalEmbedDefinition[] children: ReactNode }) { const definitionsByType = React.useMemo( () => new Map( definitions.map((definition) => [definition.type, definition] as const) ), [definitions] ) return ( {children} ) } export function createLexicalEmbedAction({ name, label, icon, embed, onRequest, }: CreateLexicalEmbedActionOptions): LexicalActionDefinition { return { name, label, icon, group: "insert", nodes: [LexicalEmbedNode], embeds: embed ? [embed] : undefined, execute: onRequest, } } export function insertLexicalEmbed( editor: LexicalEditor, embedType: string, payload: LexicalEmbedPayload ) { editor.update(() => { $insertNodes([$createLexicalEmbedNode(embedType, payload)]) }) } export class LexicalEmbedNode extends DecoratorNode { __embedType: string __payload: LexicalEmbedPayload static getType(): string { return "lexical-embed" } static clone(node: LexicalEmbedNode): LexicalEmbedNode { return new LexicalEmbedNode(node.__embedType, node.__payload, node.__key) } static importJSON(node: SerializedLexicalEmbedNode): LexicalEmbedNode { return $createLexicalEmbedNode(node.embedType, node.payload) } static importDOM(): DOMConversionMap | null { const conversion = (element: HTMLElement) => { const embedType = element.dataset.lexicalEmbedType const serializedPayload = element.dataset.lexicalEmbedPayload if (!embedType || !serializedPayload) return null try { const payload = JSON.parse(serializedPayload) as LexicalEmbedPayload if (!isLexicalEmbedPayload(payload)) return null return { conversion: () => ({ node: $createLexicalEmbedNode(embedType, payload), forChild: () => null, }), priority: 4 as const, } } catch { return null } } return { article: conversion, div: conversion, } } constructor(embedType: string, payload: LexicalEmbedPayload, key?: NodeKey) { super(key) this.__embedType = embedType this.__payload = payload } createDOM(): HTMLElement { return document.createElement("div") } updateDOM(): false { return false } exportDOM(): DOMExportOutput { const element = document.createElement("article") element.dataset.lexicalEmbedType = this.__embedType element.dataset.lexicalEmbedPayload = JSON.stringify(this.__payload) if (this.__payload.imageUrl) { const image = document.createElement("img") image.setAttribute("src", this.__payload.imageUrl) image.setAttribute("alt", "") element.append(image) } const title = document.createElement("strong") title.textContent = this.__payload.title element.append(title) if (this.__payload.description) { const description = document.createElement("p") description.textContent = this.__payload.description element.append(description) } return { element } } exportJSON(): SerializedLexicalEmbedNode { return { ...super.exportJSON(), embedType: this.__embedType, payload: this.__payload, type: "lexical-embed", version: 1, } } decorate(): ReactNode { return ( ) } } function LexicalEmbed({ embedType, payload, nodeKey, }: { embedType: string payload: LexicalEmbedPayload nodeKey: NodeKey }) { const definitions = React.useContext(LexicalEmbedContext) const [editor] = useLexicalComposerContext() const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey) const definition = definitions.get(embedType) const Render = definition?.render React.useEffect( () => mergeRegister( editor.registerCommand( KEY_DELETE_COMMAND, (event) => { if (!isSelected) return false event.preventDefault() editor.update(() => { const node = $getNodeByKey(nodeKey) if ($isLexicalEmbedNode(node)) node.remove() }) return true }, COMMAND_PRIORITY_LOW ), editor.registerCommand( KEY_BACKSPACE_COMMAND, (event) => { if (!isSelected) return false event.preventDefault() editor.update(() => { const node = $getNodeByKey(nodeKey) if ($isLexicalEmbedNode(node)) node.remove() }) return true }, COMMAND_PRIORITY_LOW ) ), [editor, isSelected, nodeKey] ) return (
{ if (!event.shiftKey) clearSelection() setSelected(event.shiftKey ? !isSelected : true) }} > {Render ? : }
) } function EmbedFallback({ title, description, imageUrl }: LexicalEmbedPayload) { return (
{imageUrl && ( )}

{title}

{description && (

{description}

)}
) } function isLexicalEmbedPayload(value: unknown): value is LexicalEmbedPayload { if (!value || typeof value !== "object") return false const payload = value as Partial return typeof payload.id === "string" && typeof payload.title === "string" } export function $createLexicalEmbedNode( embedType: string, payload: LexicalEmbedPayload ) { return $applyNodeReplacement(new LexicalEmbedNode(embedType, payload)) } export function $isLexicalEmbedNode(node: unknown): node is LexicalEmbedNode { return node instanceof LexicalEmbedNode }