UNPKG

@portabletext/toolkit

Version:

Toolkit of handy utility functions for dealing with Portable Text

130 lines (112 loc) 3.96 kB
import type { ArbitraryTypedObject, PortableTextBlock, PortableTextMarkDefinition, } from '@portabletext/types' import {isPortableTextSpan} from './asserters' import {sortMarksByOccurences} from './sortMarksByOccurences' import type {ToolkitNestedPortableTextSpan, ToolkitTextNode} from './types' /** * Takes a Portable Text block and returns a nested tree of nodes optimized for rendering * in HTML-like environments where you want marks/annotations to be nested inside of eachother. * For instance, a naive span-by-span rendering might yield: * * ```html * <strong>This block contains </strong> * <strong><a href="https://some.url/">a link</a></strong> * <strong> and some bolded and </strong> * <em><strong>italicized text</strong></em> * ``` * * ...whereas an optimal order would be: * * ```html * <strong> * This block contains <a href="https://some.url/">a link</a> * and some bolded and <em>italicized text</em> * </strong> * ``` * * Note that since "native" Portable Text spans cannot be nested, * this function returns an array of "toolkit specific" types: * {@link ToolkitTextNode | `@text`} and {@link ToolkitNestedPortableTextSpan | `@span` }. * * The toolkit-specific type can hold both types, as well as any arbitrary inline objects, * creating an actual tree. * * @param block - The Portable Text block to create a tree of nodes from * @returns Array of (potentially) nested spans, text nodes and/or arbitrary inline objects */ export function buildMarksTree<M extends PortableTextMarkDefinition = PortableTextMarkDefinition>( block: PortableTextBlock<M>, ): (ToolkitNestedPortableTextSpan<M> | ToolkitTextNode | ArbitraryTypedObject)[] { const {children} = block const markDefs = block.markDefs ?? [] if (!children || !children.length) { return [] } const sortedMarks = children.map(sortMarksByOccurences) const rootNode: ToolkitNestedPortableTextSpan<M> = { _type: '@span', children: [], markType: '<unknown>', } let nodeStack: ToolkitNestedPortableTextSpan<M>[] = [rootNode] for (let i = 0; i < children.length; i++) { const span = children[i] if (!span) { continue } const marksNeeded = sortedMarks[i] || [] let pos = 1 // Start at position one. Root is always plain and should never be removed if (nodeStack.length > 1) { for (pos; pos < nodeStack.length; pos++) { const mark = nodeStack[pos]?.markKey || '' const index = marksNeeded.indexOf(mark) if (index === -1) { break } marksNeeded.splice(index, 1) } } // Keep from beginning to first miss nodeStack = nodeStack.slice(0, pos) // Add needed nodes let currentNode = nodeStack[nodeStack.length - 1] if (!currentNode) { continue } for (const markKey of marksNeeded) { const markDef = markDefs?.find((def) => def._key === markKey) const markType = markDef ? markDef._type : markKey const node: ToolkitNestedPortableTextSpan<M> = { _type: '@span', _key: span._key, children: [], markDef, markType, markKey, } currentNode.children.push(node) nodeStack.push(node) currentNode = node } // Split at newlines to make individual line chunks, but keep newline // characters as individual elements in the array. We use these characters // in the span serializer to trigger hard-break rendering if (isPortableTextSpan(span)) { const lines = span.text.split('\n') for (let line = lines.length; line-- > 1; ) { lines.splice(line, 0, '\n') } currentNode.children = currentNode.children.concat( lines.map((text) => ({_type: '@text', text})), ) } else { // This is some other inline object, not a text span currentNode.children = currentNode.children.concat(span) } } return rootNode.children }