Files
simple-react-app-kit/packages/lexical/src/embed.tsx
T

324 lines
8.0 KiB
TypeScript
Raw Normal View History

/* 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<string, unknown>
}
export interface LexicalEmbedRenderProps {
payload: LexicalEmbedPayload
}
export interface LexicalEmbedDefinition {
type: string
render: ComponentType<LexicalEmbedRenderProps>
}
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<string, LexicalEmbedDefinition>
>(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 (
<LexicalEmbedContext.Provider value={definitionsByType}>
{children}
</LexicalEmbedContext.Provider>
)
}
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<ReactNode> {
__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 (
<LexicalEmbed
embedType={this.__embedType}
payload={this.__payload}
nodeKey={this.__key}
/>
)
}
}
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 (
<div
contentEditable={false}
data-lexical-embed-type={embedType}
className={cn(
"my-3 rounded-xl",
isSelected && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
onClick={(event) => {
if (!event.shiftKey) clearSelection()
setSelected(event.shiftKey ? !isSelected : true)
}}
>
{Render ? <Render payload={payload} /> : <EmbedFallback {...payload} />}
</div>
)
}
function EmbedFallback({ title, description, imageUrl }: LexicalEmbedPayload) {
return (
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="size-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{description && (
<p className="truncate text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
)
}
function isLexicalEmbedPayload(value: unknown): value is LexicalEmbedPayload {
if (!value || typeof value !== "object") return false
const payload = value as Partial<LexicalEmbedPayload>
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
}