/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/core-data/src/utils/crdt-utils.ts
307 строк
9 KB
Marco Ciampini
ESLint: Replace strict config with bulk suppressions (#81248)
07 авг 2026, 14:34
Не верифицирован
07 авг 2026, 14:34
7f43eaf
Код
Авторство
О чём код?
import { Y } from '@wordpress/sync'; import { create, insert, toHTMLString } from '@wordpress/rich-text'; import type { YBlock, YBlocks } from './crdt-blocks'; import type { YPostRecord } from './crdt'; import { CRDT_RECORD_MAP_KEY } from '../sync'; /** * A YMapRecord represents the shape of the data stored in a Y.Map. */ export type YMapRecord = Record< string, unknown >; /** * A wrapper around Y.Map to provide type safety. The generic type accepted by * Y.Map represents the union of possible values of the map, which are varied in * many cases. This type is accurate, but its non-specificity requires aggressive * type narrowing or type casting / destruction with `as`. * * This type provides type enhancements so that the correct value type can be * inferred based on the provided key. It is just a type wrap / overlay, and * does not change the runtime behavior of Y.Map. * * This interface cannot extend Y.Map directly due to the limitations of * TypeScript's structural typing. One negative consequence of this is that * `instanceof` checks against Y.Map continue to work at runtime but will blur * the type at compile time. To navigate this, use the `isYMap` function below. */ export interface YMapWrap< T extends YMapRecord > extends Y.AbstractType< T > { delete: < K extends keyof T >( key: K ) => void; forEach: ( callback: ( value: T[ keyof T ], key: keyof T, map: YMapWrap< T > ) => void ) => void; has: < K extends keyof T >( key: K ) => boolean; get: < K extends keyof T >( key: K ) => T[ K ] | undefined; set: < K extends keyof T >( key: K, value: T[ K ] ) => void; toJSON: () => T; // add types for other Y.Map methods as needed } /** * Get or create a root-level Map for the given Y.Doc. Use this instead of * doc.getMap() for additional type safety. * * @param doc Y.Doc * @param key Map key */ export function getRootMap< T extends YMapRecord >( doc: Y.Doc, key: string ): YMapWrap< T > { return doc.getMap< T >( key ) as unknown as YMapWrap< T >; } /** * Create a new Y.Map (provided with YMapWrap type), optionally initialized with * data. Use this instead of `new Y.Map()` for additional type safety. * * @param partial Partial data to initialize the map with. */ export function createYMap< T extends YMapRecord >( partial: Partial< T > = {} ): YMapWrap< T > { return new Y.Map( Object.entries( partial ) ) as unknown as YMapWrap< T >; } /** * Type guard to check if a value is a Y.Map without losing type information. * * @param value Value to check. */ export function isYMap< T extends YMapRecord >( value: YMapWrap< T > | undefined ): value is YMapWrap< T > { return value instanceof Y.Map; } declare const richTextOffsetBrand: unique symbol; declare const htmlStringIndexBrand: unique symbol; /** * Branded type to prevent confusion between HTML string indices and RichText offsets. * * @see asRichTextOffset() */ export type RichTextOffset = number & { readonly [ richTextOffsetBrand ]: 'RichTextOffset'; }; /** * Branded type to prevent confusion between HTML string indices and RichText offsets. * * @see asHtmlStringIndex() */ export type HtmlStringIndex = number & { readonly [ htmlStringIndexBrand ]: 'HtmlStringIndex'; }; /** * Brand a number as an offset into a RichText’s text content. * * @param offset The rich-text offset to brand. * @return The branded rich-text offset. */ export function asRichTextOffset( offset: number ): RichTextOffset { return offset as RichTextOffset; } /** * Brand a number as a string index into serialized HTML. * * @param index The HTML string index to brand. * @return The branded HTML string index. */ export function asHtmlStringIndex( index: number ): HtmlStringIndex { return index as HtmlStringIndex; } /** * Resolve a selection attribute key to a Y.Text value. * * RichText identifiers are normally top-level block attribute keys, but nested * rich-text fields can provide a dot path such as `body.0.cells.0.content`. * * @param attributes The block attributes map. * @param attributeKey The top-level attribute key or nested attribute path. * @return The matching Y.Text, or null if the path is not a rich-text field. */ export function getYTextByAttributeKey( attributes: Y.Map< unknown >, attributeKey: string ): Y.Text | null { const directValue = attributes.get( attributeKey ); if ( directValue instanceof Y.Text ) { return directValue; } let value: unknown = attributes; for ( const pathPart of attributeKey.split( '.' ) ) { if ( value instanceof Y.Map ) { value = value.get( pathPart ); } else if ( value instanceof Y.Array ) { const index = Number.parseInt( pathPart, 10 ); if ( ! Number.isSafeInteger( index ) || index < 0 || index.toString() !== pathPart ) { return null; } value = value.get( index ); } else { return null; } } return value instanceof Y.Text ? value : null; } /** * Given a block ID and a Y.Doc, find the block in the document. * * @param blockId The block ID to find * @param ydoc The Y.Doc to find the block in * @return The block, or null if the block is not found */ export function findBlockByClientIdInDoc( blockId: string, ydoc: Y.Doc ): YBlock | null { const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY ); const blocks = ymap.get( 'blocks' ); if ( ! ( blocks instanceof Y.Array ) ) { return null; } return findBlockByClientIdInBlocks( blockId, blocks ); } // Marker for insertion. const MARKER_START = 0xe000; /** * Pick a marker character that does not appear in `text`. Returns the marker * or `null` if all candidates are present (extremely unlikely in practice). * * @param text The string to check for existing marker characters. */ function pickMarker( text: string ): string | null { const tryCount = 0x10; // Scan the unicode private use area for the first code point not present // in the text. for ( let code = MARKER_START; code < MARKER_START + tryCount; code++ ) { const candidate = String.fromCharCode( code ); if ( ! text.includes( candidate ) ) { return candidate; } } return null; } /** * Convert an HTML character index (counting tag characters) to a rich-text * offset (counting only text characters). Used on read paths where Y.Text * resolves to an HTML index but the block editor expects a text offset. * * @param html The full HTML string from Y.Text. * @param htmlIndex The HTML character index. * @return The corresponding rich-text offset. */ export function htmlIndexToRichTextOffset( html: string, htmlIndex: HtmlStringIndex ): RichTextOffset { if ( ! html.includes( '<' ) && ! html.includes( '&' ) ) { return asRichTextOffset( htmlIndex ); } const marker = pickMarker( html ); if ( ! marker ) { return asRichTextOffset( htmlIndex ); } // Insert marker and let create() do the parsing. const withMarker = html.slice( 0, htmlIndex ) + marker + html.slice( htmlIndex ); const value = create( { html: withMarker } ); const markerPos = value.text.indexOf( marker ); return asRichTextOffset( markerPos === -1 ? htmlIndex : markerPos ); } /** * Convert a rich-text offset (counting only text characters) to an HTML * character index (counting tag characters). Used on write paths where the * block editor provides a text offset but Y.Text expects an HTML index. * * @param html The full HTML string from Y.Text. * @param richTextOffset The rich-text text offset. * @return The corresponding HTML character index. */ export function richTextOffsetToHtmlIndex( html: string, richTextOffset: RichTextOffset ): HtmlStringIndex { if ( ! html.includes( '<' ) && ! html.includes( '&' ) ) { return asHtmlStringIndex( richTextOffset ); } const marker = pickMarker( html ); if ( ! marker ) { return asHtmlStringIndex( richTextOffset ); } const value = create( { html } ); const markerValue = create( { text: marker } ); // The marker must inherit the formatting at the insertion point so that // toHTMLString does not split surrounding tags (e.g. <strong>) around it. if ( value.formats[ richTextOffset ] ) { markerValue.formats[ 0 ] = value.formats[ richTextOffset ]; } const withMarker = insert( value, markerValue, richTextOffset, richTextOffset ); const htmlWithMarker = toHTMLString( { value: withMarker } ); const markerIndex = htmlWithMarker.indexOf( marker ); return asHtmlStringIndex( markerIndex === -1 ? richTextOffset : markerIndex ); } function findBlockByClientIdInBlocks( blockId: string, blocks: YBlocks ): YBlock | null { for ( const block of blocks ) { if ( block.get( 'clientId' ) === blockId ) { return block; } const innerBlocks = block.get( 'innerBlocks' ); if ( innerBlocks && innerBlocks.length > 0 ) { const innerBlock = findBlockByClientIdInBlocks( blockId, innerBlocks ); if ( innerBlock ) { return innerBlock; } } } return null; }