feat(lexical): add declarative rich text editor
Introduce @workspace/lexical with composable actions, toolbars, embeds, rich-text nodes, media uploads, selection utilities, HTML serialization, and theme styling.\n\nLocalize built-in controls through MessageDescriptor catalogs for English and Simplified Chinese, while providing an English I18nProvider fallback for standalone editor use. Include focused unit coverage for actions, controls, serialization, plugins, public API, and catalogs.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/* eslint-disable no-underscore-dangle -- Lexical nodes use protected __ fields by convention. */
|
||||
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
TextNode,
|
||||
} from "lexical"
|
||||
import type {
|
||||
DOMConversionMap,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedTextNode,
|
||||
Spread,
|
||||
} from "lexical"
|
||||
|
||||
import { isISODate } from "../date-value"
|
||||
|
||||
export type SerializedDateNode = Spread<
|
||||
{
|
||||
date: string
|
||||
},
|
||||
SerializedTextNode
|
||||
>
|
||||
|
||||
export class DateNode extends TextNode {
|
||||
__date: string
|
||||
|
||||
static getType(): string {
|
||||
return "date"
|
||||
}
|
||||
|
||||
static clone(node: DateNode): DateNode {
|
||||
return new DateNode(node.__date, node.__text, node.__key)
|
||||
}
|
||||
|
||||
static importJSON(node: SerializedDateNode): DateNode {
|
||||
return $createDateNode(node.date, node.text).updateFromJSON(node)
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
time: (element) => {
|
||||
const date =
|
||||
element.getAttribute("datetime") || element.dataset.lexicalDate
|
||||
if (!date || !isISODate(date)) return null
|
||||
|
||||
return {
|
||||
conversion: () => ({
|
||||
node: $createDateNode(date, element.textContent || date),
|
||||
forChild: () => null,
|
||||
}),
|
||||
priority: 4,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
constructor(date: string, text?: string, key?: NodeKey) {
|
||||
super(text ?? date, key)
|
||||
this.__date = date
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement {
|
||||
const element = super.createDOM(config, editor)
|
||||
element.dataset.lexicalDate = this.__date
|
||||
return element
|
||||
}
|
||||
|
||||
updateDOM(
|
||||
previousNode: this,
|
||||
element: HTMLElement,
|
||||
config: EditorConfig
|
||||
): boolean {
|
||||
const didUpdate = super.updateDOM(previousNode, element, config)
|
||||
if (previousNode.__date !== this.__date) {
|
||||
element.dataset.lexicalDate = this.__date
|
||||
}
|
||||
return didUpdate
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement("time")
|
||||
element.dateTime = this.__date
|
||||
element.dataset.lexicalDate = this.__date
|
||||
element.textContent = this.getTextContent()
|
||||
return { element }
|
||||
}
|
||||
|
||||
exportJSON(): SerializedDateNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
date: this.__date,
|
||||
type: "date",
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getDate(): string {
|
||||
return this.getLatest().__date
|
||||
}
|
||||
|
||||
setDate(date: string, text: string): this {
|
||||
const writable = this.getWritable()
|
||||
writable.__date = date
|
||||
writable.__text = text
|
||||
return writable
|
||||
}
|
||||
|
||||
isTextEntity(): true {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function $createDateNode(date: string, text?: string): DateNode {
|
||||
return $applyNodeReplacement(new DateNode(date, text))
|
||||
.setMode("token")
|
||||
.setDetail("unmergable")
|
||||
}
|
||||
|
||||
export function $isDateNode(
|
||||
node: LexicalNode | null | undefined
|
||||
): node is DateNode {
|
||||
return node instanceof DateNode
|
||||
}
|
||||
|
||||
export function $getSelectedDateNode(): DateNode | null {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) return null
|
||||
|
||||
const anchorNode = selection.anchor.getNode()
|
||||
const focusNode = selection.focus.getNode()
|
||||
if (anchorNode === focusNode && $isDateNode(anchorNode)) return anchorNode
|
||||
|
||||
const selectedNodes = selection.getNodes()
|
||||
return selectedNodes.length === 1 && $isDateNode(selectedNodes[0])
|
||||
? selectedNodes[0]
|
||||
: null
|
||||
}
|
||||
|
||||
export function $insertOrUpdateDate(
|
||||
date: string,
|
||||
text: string
|
||||
): DateNode | null {
|
||||
const selectedDateNode = $getSelectedDateNode()
|
||||
|
||||
if (selectedDateNode) {
|
||||
selectedDateNode.setDate(date, text).select(0, text.length)
|
||||
return selectedDateNode
|
||||
}
|
||||
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) return null
|
||||
|
||||
const dateNode = $createDateNode(date, text)
|
||||
selection.insertNodes([dateNode])
|
||||
dateNode.select(0, text.length)
|
||||
return dateNode
|
||||
}
|
||||
|
||||
export { isISODate } from "../date-value"
|
||||
Reference in New Issue
Block a user