/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import type {ChildSchema} from './types'; import { $createParagraphNode, $isBlockElementNode, $isDecoratorNode, $isElementNode, $isLineBreakNode, type ElementNode, isHTMLElement, type LexicalNode, } from 'lexical'; import {isAlignmentValue} from './coreImportRules'; /** * True if the node fills a block slot at the root or inside another * block — covers both ElementNode-style blocks (paragraph, heading, * quote) and block-level DecoratorNodes (HorizontalRuleNode, * ImageNode-as-block, etc.). Used by {@link BlockSchema}, * {@link RootSchema}, and {@link NestedBlockSchema}. * * @experimental */ export function $isBlockLevel(node: LexicalNode): boolean { return ( $isBlockElementNode(node) || ($isDecoratorNode(node) && !node.isInline()) ); } /** * Distribute an inline wrapper (`LinkNode`, `MarkNode`, …) across a * heterogeneous run of children produced by `$importChildren`, lifting * any block children to the top level while keeping the wrapper around * the leaf inline content. * * Use from a rule whose DOM source is an inline element that the * browser permitted to enclose block elements — the canonical case is * `

title

body
`, which a link rule * wants to surface as two block siblings (heading + paragraph), each * with its own link wrapping the original inline content. Schemas * can't express this because they reason about a parent's children * only — they cannot lift the parent out of itself. * * For each top-level child: * - **Inline children** are collected into runs; each run is wrapped * in a single fresh wrapper (from `$makeWrapper()`). * - **Block children** are descended into: their own children are * recursively distributed with `$makeWrapper`, then re-attached so * the block keeps its position at the top level. * * The returned list will contain a mix of blocks and wrapped inline * runs. The enclosing schema (typically {@link BlockSchema}) will * then package those inline wrappers into paragraphs as usual. * * @experimental */ export function $distributeInlineWrapper( children: readonly LexicalNode[], $makeWrapper: () => ElementNode, ): LexicalNode[] { const out: LexicalNode[] = []; let inlineRun: LexicalNode[] = []; const flushInline = () => { if (inlineRun.length === 0) { return; } out.push($makeWrapper().splice(0, 0, inlineRun)); inlineRun = []; }; for (const child of children) { if ($isBlockLevel(child)) { flushInline(); // Recursively distribute the wrapper into the block's own // children. A block DecoratorNode (no children) is left alone. if ($isElementNode(child)) { const wrapped = $distributeInlineWrapper( child.getChildren(), $makeWrapper, ); child.splice(0, child.getChildrenSize(), wrapped); } out.push(child); } else { inlineRun.push(child); } } flushInline(); return out; } /** * Apply a {@link ChildSchema} to a flat list of children produced by * `$importChildren`. Walks the list once, partitions into accepted vs. * rejected runs, packages or drops rejected runs, then runs `$finalize`. * * @internal */ export function $applySchema( schema: ChildSchema, children: LexicalNode[], parent: LexicalNode | null, domParent: Node | null, ): LexicalNode[] { const out: LexicalNode[] = []; let run: LexicalNode[] | null = null; const flushRun = () => { if (run === null) { return; } const rejected = run; run = null; if (schema.$packageRun) { const packaged = schema.$packageRun(rejected, parent, domParent); if (packaged.length > 0) { for (const n of packaged) { out.push(n); } return; } } // No $packageRun (or it returned []) — apply onReject. 'drop' (default) // discards the run. 'hoist' lets it through unchanged at this level. if (schema.onReject === 'hoist') { for (const n of rejected) { out.push(n); } } }; for (const child of children) { if (schema.$accepts(child, parent)) { flushRun(); out.push(child); } else { if (run === null) { run = []; } run.push(child); } } flushRun(); return schema.$finalize ? schema.$finalize(out, parent) : out; } /** * Apply a parent DOM element's `text-align` (when set to one of the * supported {@link ElementFormatType} values) to each block-level child * Lexical node that does not yet have its own format. * * Mirrors the part of the legacy `wrapContinuousInlines` that wrote * `node.setFormat(textAlign)` onto pre-existing block children when the * DOM parent carried `style.textAlign`. Pair with * {@link $paragraphPackageRun} (which carries the same propagation onto * paragraphs synthesized around inline runs) to fully replicate the * legacy behavior on a run of mixed children. * * @experimental */ export function $propagateTextAlignToBlockChildren( children: LexicalNode[], domParent: Node | null, ): LexicalNode[] { if (!isHTMLElement(domParent)) { return children; } const textAlign = domParent.style.textAlign; if (!isAlignmentValue(textAlign)) { return children; } for (const child of children) { if ($isBlockElementNode(child) && child.getFormatType() === '') { child.setFormat(textAlign); } } return children; } /** * Wrap a run of inline lexical nodes in a fresh paragraph, propagating the * `text-align` of `domParent` as the paragraph's format type (matching the * legacy `wrapContinuousInlines` behavior). */ function $paragraphPackageRun( run: LexicalNode[], _parent: LexicalNode | null, domParent: Node | null, ): LexicalNode[] { // Mirror the legacy `$wrapInlineNodes` (driven by // `selection.insertNodes`) shortcut where a lone `
` at this // level (a `LineBreakNode` is the only thing in the rejected run) // becomes an *empty* paragraph rather than a paragraph wrapping a // visible line break — that's the form clipboard pastes ending in a // trailing `
` (Google Docs, Gmail, …) rely on for the editor's // "extra trailing empty line" expectation. if (run.length === 1 && $isLineBreakNode(run[0])) { run = []; } const paragraph = $createParagraphNode(); if (isHTMLElement(domParent)) { const textAlign = domParent.style.textAlign; if (isAlignmentValue(textAlign)) { paragraph.setFormat(textAlign); } } return [paragraph.splice(0, 0, run)]; } /** * Default schema for block-level positions (root of the document, the body * of a block element node). Accepts block lexical nodes; packages runs of * inline children into fresh paragraph nodes. * * @experimental */ export const BlockSchema: ChildSchema = { $accepts: $isBlockLevel, $packageRun: $paragraphPackageRun, name: 'BlockSchema', }; /** * Schema for inline-only positions (the body of an inline lexical node such * as a link). Accepts non-block lexical nodes; runs of block children are * dropped (`onReject: 'drop'` is the default). * * @experimental */ export const InlineSchema: ChildSchema = { $accepts: child => !$isBlockLevel(child), name: 'InlineSchema', }; /** * Schema for nested block positions — the equivalent of the legacy * `ArtificialNode__DO_NOT_USE` flow used when a block DOM element appears * inside another block lexical ancestor. Accepts block nodes; runs of inline * children are emitted with a line break between consecutive runs (instead * of being wrapped in a paragraph, which would introduce an extra level of * nesting). * * @experimental */ export const NestedBlockSchema: ChildSchema = { $accepts: $isBlockLevel, /** * Pass an inline run through unchanged. Because the schema iterator only * groups *maximal* rejected runs (each separated from the next by an * accepted block child), the legacy "linebreak between adjacent inline * groups" case never arises — adjacent inline siblings are already * coalesced into one run. */ $packageRun: run => run, name: 'NestedBlockSchema', }; /** * Schema for the topmost level of `$generateNodesFromDOM`. Identical to * {@link BlockSchema}; aliased for clarity at the entry point and so it can * be overridden separately in the future (e.g. to synthesize a `ListNode` * around runs of orphan `ListItemNode`s). * * @experimental */ export const RootSchema: ChildSchema = { $accepts: $isBlockLevel, $packageRun: $paragraphPackageRun, name: 'RootSchema', };