拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Lexical NodeState Style 示例深度解析:用 NodeState 与 DOM 扩展统一管理任意节点的内联样式

Lexical NodeState Style 示例深度解析用 NodeState 与 DOM 扩展统一管理任意节点的内联样式【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical导读本示例examples/node-state-style演示了 Lexical 新一代扩展体系中三个核心能力的协同用法用NodeStatecreateState在任意节点上挂载自定义状态、用DOMRenderExtension统一覆盖节点的 DOM 创建$decorateDOM与 HTML 导出$exportDOM行为、用DOMImportExtension在导入 HTML 时捕获任意内联style属性。读完本文你将掌握如何为 TextNode 等任意节点实现一套可读写、可序列化、可渲染、可导出、可导入的完整样式子系统并能复刻本示例中的 Style Tree 可视化调试面板。1. 示例概览与运行方式该示例位于仓库的 examples/node-state-style 目录其 README 给出的运行方式为pnpm i pnpm run dev此外 package.json 还提供了 monorepo 内联运行、构建与预览脚本scripts: { dev: vite, monorepo:dev: vite -c vite.config.monorepo.ts, build: tsc vite build, preview: vite preview }dev直接启动 Vite 开发服务器monorepo:dev使用 vite.config.monorepo.ts 在仓库根工作区中解析各lexical/*本地包适合在 Lexical monorepo 内联调试build先执行tsc类型检查再执行vite build产出产物preview本地预览构建结果。示例依赖以0.50.0版本为核心的lexical、lexical/html、lexical/extension、lexical/react、lexical/rich-text、lexical/history、lexical/selection、lexical/utilsUI 层使用ark-ui/reactTabs、Combobox、Splitter、TreeView、shikiHTML/JSON 高亮、lucide-react图标与prettier格式化类型侧使用csstype提供 CSS 属性类型。启动后编辑器下方有三个 TabStyle Tree节点树 样式编辑器、HTML$generateHtmlFromNodes导出结果、JSONeditorState.toJSON()序列化结果它们由 App.tsx 中的 Ark UI Tabs 组装。2. 核心 API 全景NodeState、DOMRenderExtension、DOMImportExtension本示例之所以小而全是因为它恰好覆盖了三条新一代扩展链路2.1 NodeState给任意节点挂载自定义字段NodeState 由 packages/lexical/src/LexicalNodeState.ts 提供核心 API 为createState(key, valueConfig)创建StateConfigkey在同一节点上必须局部唯一开发模式下重复 key 会报错valueConfig支持parse、unparse、isEqual、defaultValue等钩子源码 L340-L360。$getState(node, stateConfig, version)读取状态。version默认为NODE_STATE_LATEST内部先node.getLatest()也可传NODE_STATE_DIRECT直接读取当前对象上存储的值不要求处于 editor state 上下文源码 L362-L394。$setState(node, stateConfig, valueOrUpdater)写入状态支持直接值或(prev) next更新函数使用更新函数时若stateConfig.isEqual(prev, value)为真则不会将节点标记为 dirty源码 L420-L459。$getStateChange(node, prevNode, stateConfig)比较两个版本节点的状态差异返回[value, prevValue]或null专门用于实现updateDOM类场景源码 L396-L418。NodeState 的价值在于它把节点上的自定义数据从LexicalNode的子类字段中解放出来无需新建节点类型即可给 TextNode、ElementNode 甚至 DecoratorNode 附加任意状态且自动参与 dirty 标记、序列化toJSON、撤销重做等既有机制。2.2 DOMRenderExtension统一覆盖创建与导出packages/lexical-html/src/DOMRenderExtension.ts 是实验性扩展允许通过configExtension(DOMRenderExtension, { overrides: [...] })注册一组domOverride每个 override 可针对特定节点类型或*通配覆盖两个钩子$decorateDOM(nextNode, prevNode, dom)节点更新到 DOM 时被调用用于将状态增量地写入 DOM 元素$exportDOM(node, $next)节点导出为 HTML 时被调用$next()调用后续导出逻辑返回{element, after?}。$next()机制保证 override 是包装而非替换多个 override 与节点的默认exportDOM形成责任链本示例正是依赖这一点在保留核心导出能力的前提下追加样式。2.3 DOMImportExtension在 HTML 导入时捕获内联样式packages/lexical-html/src/import/DOMImportExtension.ts 提供新的 HTML 导入管线通过defineImportRule定义匹配规则match$import规则按列表顺序求值谁先不调用$next()谁决定结果类似中间件。本示例用一条通配规则捕获所有带style属性的元素属于该管线的典型用法。3. 用 createState 定义可序列化的 StyleObject 状态示例的核心状态定义集中在 styleState.tsexport const styleState createState(style, { isEqual, parse, unparse, });3.1 状态值的类型设计StyleObject由 csstype 的PropertiesHyphenFallback派生而来只保留值为string的属性刻意简化不处理数组/数字型值export type StyleObject Prettify{ [K in keyof PropertiesHyphenFallback]?: | undefined | ExtractPropertiesHyphenFallback[K], string; };这保证了该状态天然具备类型安全$setStyleProperty(node, text-shadow, value)之类的调用在编译期即可校验属性名与值类型。NO_STYLE Object.freeze({})作为不可变空对象用来表达无样式。3.2 parse / unparse / isEqual让状态可序列化、可比较三个钩子决定了状态如何与字符串互相转换、如何判断相等function parse(v: unknown): StyleObject { return typeof v string ? getStyleObjectFromRawCSS(v) : NO_STYLE; } function unparse(style: StyleObject): string { const styles: string[] []; for (const [k, v] of Object.entries(style)) { if (k v) { styles.push(${k}: ${v};); } } return styles.sort().join( ); }parse借助 Lexical 自带的getStyleObjectFromCSS把 CSS 字符串解析为对象getStyleObjectFromRawCSSunparse将对象反向拼成排序后的k: v;字符串用于导出到 DOM 的style属性isEqual进行深度比较源码 L88-L115这是$setState判断值是否真的变了、是否要标记 dirty的依据。3.3 便捷访问器示例在createState之上封装了一套$前缀安全函数styleState.ts L123-L170$getStyleObject(node)/getStyleObjectDirect(node)分别对应NODE_STATE_LATEST与NODE_STATE_DIRECT读取后者不经过getLatest()用于 StyleViewPlugin 的只读面板$setStyleObject(node, valueOrUpdater)批量设置$setStyleProperty(node, prop, value)设置单个属性支持函数式更新值相等时返回原对象避免无谓 dirty$removeStyleProperty(node, prop)删除单个属性。4. DOMRenderExtension用 overrides 接管创建与导出4.1 注册方式在 StyleStateExtension 中通过configExtension注册configExtension(DOMRenderExtension, { overrides: [ domOverride([TextNode], { /* TextNode 专属导出清理 */ }), domOverride(*, { /* 通配样式应用与导出 */ }), ], }),4.2 通配 override 的$decorateDOM增量应用样式核心是把 NodeState 里的 StyleObject 增量同步到 DOM 元素styleState.ts L422-L434domOverride(*, { $decorateDOM(nextNode, prevNode, dom) { const managedDOM: HTMLElementWithManagedStyle dom; const nextStyleObject $getStyleObject(nextNode); const diffStyleObject diffStyleObjects( getPreviousStyleObject(nextNode, prevNode, dom), nextStyleObject, ); managedDOM[PREV_STYLE_STATE] nextStyleObject; if (diffStyleObject ! NO_STYLE) { setDOMStyleObject(dom.style, diffStyleObject); } }, // ... }),几个值得注意的实现细节diff 而非全量覆盖diffStyleObjectsstyleState.ts L172-L198计算从上一个样式对象到下一个样式对象的变化只把变更项含被删除的属性值为undefined写入 DOM避免每次 reconcile 都重写全部样式在 DOM 元素上缓存上一状态PREV_STYLE_STATE Symbol.for(styleState)styleState.ts L275-L281把上次 reconcile 的 StyleObject 直接挂在 DOM 元素上而不是依赖 prevNode——因为nodeMutation: updated时 prevNode 并不可靠识别样式是否被核心逻辑覆盖styleStringChanged检查节点自带的__styleTextNode/ElementNode 上默认存在的字符串样式属性是否变化。若变化说明上游el.style.foo ...已经改过该属性此时放弃增量 diff、回退到全量写入避免把旧值又写回去styleState.ts L283-L308。4.3 通配 override 的$exportDOM导出样式到 HTML导出时把 StyleObject 写进元素或after钩子返回的元素styleState.ts L435-L457$exportDOM(node, $next) { const output $next(); const style $getStyleObject(node); if (output.element style ! NO_STYLE) { if (output.after) { return { ...output, after: generatedElement { const el output.after ? output.after(generatedElement) : generatedElement; if (isHTMLElement(el)) { setDOMStyleObject(el.style, style); } return el; }, }; } else if (isHTMLElement(output.element)) { setDOMStyleObject(output.element.style, style); } } return output; }它保留$next()的导出结果仅在存在after钩子元素在之后才真正生成时包装该钩子否则直接写入output.element。4.4 TextNode 专属 override导出后的样式清理针对 TextNode 的 overridestyleState.ts L390-L421处理两个历史遗留问题移除不必要的white-space: pre-wrap核心导出为保留文本内相邻空格会设置pre-wrap。示例检测文本是否真的存在首部/尾部/连续空格/^\s|\s$|\s\s/不存在时调用el.style.removeProperty(white-space)剥离空style属性某些浏览器或 JSDOM 中removeProperty会残留空style或上游 override 清空唯一属性后也会残留。代码遍历result.element及其所有后代凡style属性 trim 后为空一律removeAttribute(style)保证导出 HTML 干净。这个 override 展示了domOverride的包装哲学$next()产出基础结果override 只做精修互不破坏。5. DOMImportExtension用一条通配规则捕获内联 style5.1 规则定义createStyleImportRulestyleState.ts L347-L372是旧版constructStyleImportMap方案逐个包装 TextNode importer的替代品export function createStyleImportRule(styleMapping: StyleMapping input input) { return defineImportRule({ $import: (_ctx, el, $next) { const extra el.hasAttribute(style) ? extractExtraStyles(el) : null; const out $next(); if (extra) { const mapped styleMapping(extra); for (const child of out) { if ($isTextNode(child)) { $setStyleObject(child, prev mergeStyleObjects(prev, mapped)); } } } return out; }, match: sel.any().attr(style, /\S/), name: lexical/examples/node-state-style/style, }); }要点match: sel.any().attr(style, /\S/)匹配所有带非空白style属性的元素是一条通配规则$import先调用$next()走完后续所有匹配规则包括 CoreImportExtension 按标签驱动的节点创建再对产出的子节点做后处理——只对TextNode注入样式非文本节点自然跳过styleMapping参数默认恒等映射允许调用方对导入的样式做变换规则名lexical/examples/node-state-style/style在开发模式下用于诊断与冲突提示。5.2 与核心内联格式规则的分工extractExtraStylesstyleState.ts L319-L334遍历el.style中每个 CSS 属性跳过IGNORE_STYLES集合内的四项const IGNORE_STYLES: Setkeyof StyleObject new Set([ font-weight, text-decoration, font-style, vertical-align, ]);这四项正是核心内联格式规则bold/italic/underline/strikethrough已经处理的属性。示例只捕获额外样式把粗体、斜体等格式交给核心机制避免状态重复与互相覆盖。createStyleImportRule的注释明确说明这是DOMImportExtension 原生方案替代旧版构造constructStyleImportMap时逐个包装 TextNode importer的 workaround。5.3 注册与优先级在StyleStateExtension的dependencies中注册styleState.ts L374-L383dependencies: [ CoreImportExtension, configExtension(DOMImportExtension, { rules: [createStyleImportRule()], }), configExtension(DOMRenderExtension, { overrides: [...] }), ],规则注释指出该通配规则以最低优先级注册在规则数组末尾match是通配具体处理靠$import体内的$isTextNode判断因此CoreImportExtension的按标签规则仍然主导节点创建样式捕获只是装饰层。6. 命令层PATCH_TEXT_STYLE_COMMAND 与选中文本样式补丁6.1 命令定义示例定义了一个编辑器级命令styleState.ts L219-L221export const PATCH_TEXT_STYLE_COMMAND createCommand StyleObject | ((prevStyles: StyleObject) StyleObject) (PATCH_TEXT_STYLE_COMMAND);并在StyleStateExtension.register中将其与$patchSelectedTextStyle绑定styleState.ts L462-L468register: editor editor.registerCommand( PATCH_TEXT_STYLE_COMMAND, $patchSelectedTextStyle, COMMAND_PRIORITY_EDITOR, ),6.2 补丁逻辑$patchSelectedTextStylestyleState.ts L245-L273无当前 selection 时回退到$getPreviousSelection()的克隆并$setSelection恢复值为函数时直接作为 updater值为对象时包装为mergeStyleObjects(prev, obj)合并折叠collapsed选区且焦点在 TextNode 上时只更新该节点否则用$forEachSelectedTextNode遍历所有选中文本节点逐一更新。6.3 工具栏触发工具栏中的Toggle Text Style按钮ToolbarPlugin.tsx L144-L160在isStyled为假时派发一个text-shadow样式editor.dispatchCommand( PATCH_TEXT_STYLE_COMMAND, isStyled ? () NO_STYLE : { text-shadow: 1px 1px 2px red, 0 0 1em blue, 0 0 0.2em blue, }, );isStyled由$selectionHasStyle()styleState.ts L227-L243驱动——它利用 caret range 的getTextSlices()与iterNodeCarets(root)检测选区中是否有任意文本节点携带非空样式。工具栏其余按钮加粗/斜体/下划线/删除线直接使用核心FORMAT_TEXT_COMMAND撤销/重做按钮使用UNDO_COMMAND/REDO_COMMAND活跃态则由ToolbarExtension内的 signalisBold、isItalic等通过useExtensionSignalValue提供给 ReactToolbarPlugin.tsx L41-L79。7. StyleViewPlugin可视化调试任意节点的样式StyleViewPluginStyleViewPlugin.tsx把上文所有能力可视化出来节点树用 Ark UI TreeView 渲染整个EditorState的节点树Root/Element/Text/Decorator 分别使用不同图标点击节点可回填 editor selection$setSelectionFromCaretRange样式面板右侧面板展示当前选中节点的 StyleObject通过getStyleObjectDirect直接读取节点对象上的状态每一行是一个属性名 内联可编辑值可删除、可新增新增属性CSSPropertyComboBox从document.body.style枚举所有浏览器支持的 CSS 属性名转为 kebab-case并提供自动补全StyleViewPlugin.tsx L604-L666编辑值每个属性值是一个嵌套的纯文本 Lexical 编辑器StyleValueEditor在 300ms 防抖或失焦/回车时通过editor.update调用$setStyleProperty并以skip-dom-selection、skip-scroll-into-view两个 update tag 避免干扰主编辑器选区StyleViewPlugin.tsx L360-L505。同时ShikiViewPlugin 用$generateHtmlFromNodes实时生成 HTML、用editorState.toJSON()生成 JSON再经 prettier 格式化与 shikinord主题高亮让你能直观对照NodeState 样式 → DOM 导出 → 序列化 JSON三者的一致性——这正是验证unparse/parse与 DOMRender/DOMImport 扩展是否闭合的调试利器。8. 从源码视角理解的设计要点结合 LexicalNodeState.ts 与示例实现可以提炼出该模式的几个关键设计决策状态与 DOM 解耦样式状态存于节点参与历史与序列化DOM 只是它的投影。$decorateDOM每次 reconcile 都基于上一状态 → 下一状态的 diff 更新配合 DOM 元素上的Symbol.for(styleState)缓存规避了 prevNode 不可靠的问题。解析闭环unparse导出为 CSS 字符串与parse导入时解析互为逆运算配合IGNORE_STYLES让核心格式规则已处理的属性不进入 NodeState避免状态重复所有权。责任链而非替换$next()贯穿$import与$exportDOMoverride 只是包装层。从源码结构看这正是 DOMRenderExtension / DOMImportExtension 被设计为可组合中间件的原因多个库的 override 可以叠加而不互相破坏。命令驱动的 UI 层样式操作统一收敛到PATCH_TEXT_STYLE_COMMAND工具栏与理论上的快捷键、菜单可以复用同一套逻辑UI 状态用 signal 而非 React state 同步减少重复渲染。9. 实战提示该示例同时涉及实验性 APIDOMRenderExtension与DOMImportExtension在 DOMRenderExtension.ts 与 DOMImportExtension.ts 中均标注experimental其形态可能随版本演进示例锁定的lexical/lexical/*版本为0.50.0跨版本使用时请以当前仓库源码为准。若要在自己的编辑器复刻任意节点附加任意 CSS 属性能力最小路径是createState定义状态 →$setStyleProperty/$removeStyleProperty读写 →domOverride(*, ...)的$decorateDOM/$exportDOM负责 DOM 投影与导出 →defineImportRule通配规则负责导入捕获。完整参考实现可对照 styleState.ts 阅读核心扩展 API 文档见 lexical-html 包 与 LexicalNodeState.ts。【免费下载链接】lexicalLexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.项目地址: https://gitcode.com/GitHub_Trending/le/lexical创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门