324 lines
8.0 KiB
TypeScript
324 lines
8.0 KiB
TypeScript
|
|
/* 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
|
||
|
|
}
|