From 513ef568ec75732aac909214db54cb40fc0daa05 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 24 Jul 2026 17:08:53 -0700 Subject: [PATCH 01/16] feat(editor): add offscreen block skeletons --- package.json | 2 +- src/Draft.ts | 11 +- src/component/base/DraftEditor.react.tsx | 6 +- src/component/base/DraftEditorProps.ts | 8 + .../base/__tests__/DraftEditor.react-test.tsx | 128 +++++++++++- .../contents/DraftEditorBlock.react.tsx | 16 +- .../DraftEditorContents-core.react.tsx | 194 +++++++++++++----- .../DraftEditorSkeletonContents.react.tsx | 35 ++++ .../DraftEditorContents.react-test.tsx | 70 ++++++- .../hooks/useDraftEditorBlockSkeleton.ts | 102 +++++++++ src/model/decorators/DraftDecoratorType.ts | 20 ++ 11 files changed, 533 insertions(+), 59 deletions(-) create mode 100644 src/component/contents/DraftEditorSkeletonContents.react.tsx create mode 100644 src/component/hooks/useDraftEditorBlockSkeleton.ts diff --git a/package.json b/package.json index 86ae6227f..db6741ed6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@descript/draft-js", "description": "A React framework for building text editors.", - "version": "0.11.6-descript.34", + "version": "0.11.6-descript.36", "keywords": [ "draftjs", "editor", diff --git a/src/Draft.ts b/src/Draft.ts index 5c36d4e13..08b96171e 100644 --- a/src/Draft.ts +++ b/src/Draft.ts @@ -10,7 +10,10 @@ import Editor from './component/base/DraftEditor.react'; export {Editor}; import DraftEditorBlock from './component/contents/DraftEditorBlock.react'; -export type {DraftEditorProps} from './component/base/DraftEditorProps'; +export type { + DraftEditorBlockSkeletonOptions, + DraftEditorProps, +} from './component/base/DraftEditorProps'; export {CompositeDecorator} from './model/decorators/CompositeDraftDecorator'; import DraftEntity from './model/entity/DraftEntity'; import AtomicBlockUtils from './model/modifier/AtomicBlockUtils'; @@ -38,7 +41,11 @@ export type {RawDraftContentState} from './model/encoding/convertFromDraftStateT export type {DraftEditorCommand} from './model/constants/DraftEditorCommand'; export type {DraftHandleValue} from './model/constants/DraftHandleValue'; -export type {DraftDecoratorType} from './model/decorators/DraftDecoratorType'; +export type { + DraftDecoratorSkeletonAttributes, + DraftDecoratorSkeletonRange, + DraftDecoratorType, +} from './model/decorators/DraftDecoratorType'; export type {DraftDecorator, DraftDecoratorComponentProps} from './model/decorators/DraftDecorator'; export const EditorBlock = DraftEditorBlock; diff --git a/src/component/base/DraftEditor.react.tsx b/src/component/base/DraftEditor.react.tsx index b30d4a6e2..d8e7a637a 100644 --- a/src/component/base/DraftEditor.react.tsx +++ b/src/component/base/DraftEditor.react.tsx @@ -35,7 +35,7 @@ import {hasText} from '../../model/immutable/ContentState'; import {nullthrows} from '../../fbjs/nullthrows'; import DraftEditorPlaceholder from './DraftEditorPlaceholder.react'; import {DefaultDraftInlineStyle} from '../../model/immutable/DefaultDraftInlineStyle'; -import DraftEditorContents from '../contents/DraftEditorContents-core.react'; +import DraftEditorSkeletonContents from '../contents/DraftEditorSkeletonContents.react'; const isIE = UserAgent.isBrowser('IE'); @@ -336,6 +336,7 @@ export default class DraftEditor extends React.Component< blockRenderMap, blockRendererFn, blockStyleFn, + blockSkeleton, customStyleFn, customStyleMap, editorState, @@ -377,6 +378,7 @@ export default class DraftEditor extends React.Component< blockRenderMap, blockRendererFn, blockStyleFn, + blockSkeleton, customStyleMap: { ...DefaultDraftInlineStyle, ...customStyleMap, @@ -471,7 +473,7 @@ export default class DraftEditor extends React.Component< all DraftEditorLeaf nodes so it's first in postorder traversal. */} - diff --git a/src/component/base/DraftEditorProps.ts b/src/component/base/DraftEditorProps.ts index 94f216581..f734474ca 100644 --- a/src/component/base/DraftEditorProps.ts +++ b/src/component/base/DraftEditorProps.ts @@ -26,6 +26,12 @@ import { } from '../utils/eventTypes'; import {BlockNode} from '../../model/immutable/BlockNode'; +export type DraftEditorBlockSkeletonOptions = Readonly<{ + enabled: boolean; + scrollContainerRef: RefObject; + pinnedBlockKeys?: ReadonlySet; +}>; + export type DraftEditorProps = { /** * The two most critical props are `editorState` and `onChange`. @@ -60,6 +66,8 @@ export type DraftEditorProps = { blockRendererFn: (block: BlockNode) => any | null; // Function that returns a cx map corresponding to block-level styles. blockStyleFn: (block: BlockNode) => string | CSSProperties | undefined; + // Replace offscreen block contents with text-only skeletons. + blockSkeleton?: DraftEditorBlockSkeletonOptions; // If supplied, a ref which will be passed to the contenteditable. // Currently, only object refs are supported. editorRef?: RefObject | Ref; diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index f1e148183..47e30c555 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -9,10 +9,15 @@ */ import React from 'react'; -import {createEmpty, EditorState} from '../../../model/immutable/EditorState'; +import { + createEmpty, + createWithContent, + EditorState, +} from '../../../model/immutable/EditorState'; import DraftEditor from '../DraftEditor.react'; import {createRoot, Root} from 'react-dom/client'; import {flushSync} from 'react-dom'; +import {createFromText} from '../../../model/immutable/ContentState'; let container: HTMLElement; let root: Root; @@ -72,6 +77,127 @@ test('must has editorKey same as props', () => { expect(editorInstance?.getEditorKey()).toBe('hash'); }); +test('promotes intersecting skeleton blocks to full rendering', () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + let observerCallback: IntersectionObserverCallback | undefined; + const observedElements: Element[] = []; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '1200px 0px'; + readonly thresholds = [0]; + + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + + disconnect(): void {} + observe(target: Element): void { + observedElements.push(target); + } + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve(): void {} + } + + globalThis.IntersectionObserver = MockIntersectionObserver; + try { + editorState = createWithContent(createFromText('zero\none\ntwo')); + flushSync(() => { + root.render( + {}} + blockSkeleton={{ + enabled: true, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + + expect(observedElements).toHaveLength(3); + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(2); + + const secondBlock = observedElements[1]; + expect(secondBlock).toBeDefined(); + flushSync(() => { + observerCallback?.( + [ + { + isIntersecting: true, + target: secondBlock, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(1); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + } +}); + +test('renders newly introduced block keys as skeletons immediately', () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '1200px 0px'; + readonly thresholds = [0]; + + disconnect(): void {} + observe(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve(): void {} + } + + globalThis.IntersectionObserver = MockIntersectionObserver; + try { + const blockRendererFn = jest.fn(() => null); + editorState = createWithContent(createFromText('zero\none')); + flushSync(() => { + root.render( + {}} + blockRendererFn={blockRendererFn} + blockSkeleton={{ + enabled: true, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + expect(blockRendererFn).toHaveBeenCalledTimes(1); + + blockRendererFn.mockClear(); + editorState = createWithContent(createFromText('zero\none\ntwo')); + flushSync(() => { + root.render( + {}} + blockRendererFn={blockRendererFn} + blockSkeleton={{ + enabled: true, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(2); + expect(blockRendererFn).toHaveBeenCalledTimes(1); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + } +}); + describe('ariaDescribedBy', () => { function getProps(elem: React.ReactElement): Element { flushSync(() => { diff --git a/src/component/contents/DraftEditorBlock.react.tsx b/src/component/contents/DraftEditorBlock.react.tsx index ae84160a4..e564b2531 100644 --- a/src/component/contents/DraftEditorBlock.react.tsx +++ b/src/component/contents/DraftEditorBlock.react.tsx @@ -100,6 +100,16 @@ const getNodeScrollTopAndBottom = ( * A `DraftEditorBlock` is able to render a given `ContentBlock` to its * appropriate decorator and inline style components. */ +export function getDraftEditorBlockClassName( + direction: BidiDirection, +): string { + return cx({ + 'public/DraftStyleDefault/block': true, + 'public/DraftStyleDefault/ltr': direction === 'LTR', + 'public/DraftStyleDefault/rtl': direction === 'RTL', + }); +} + export default class DraftEditorBlock extends React.Component { _node: HTMLDivElement | null = null; @@ -362,11 +372,7 @@ export default class DraftEditorBlock extends React.Component { render(): React.ReactNode { const {direction, offsetKey} = this.props; - const className = cx({ - 'public/DraftStyleDefault/block': true, - 'public/DraftStyleDefault/ltr': direction === 'LTR', - 'public/DraftStyleDefault/rtl': direction === 'RTL', - }); + const className = getDraftEditorBlockClassName(direction); return (
Record | null; blockStyleFn?: (block: BlockNode) => string | CSSProperties | undefined; + blockSkeleton?: DraftEditorBlockSkeletonState; customStyleFn?: ( style: DraftInlineStyle, block: BlockNode, ) => Record | null; customStyleMap?: Record; + contentsRef?: (node: HTMLDivElement | null) => void; editorKey?: string; editorState: EditorState; preventScroll?: boolean; @@ -77,6 +83,63 @@ const getListItemClasses = ( }); }; +function renderSkeletonChildren({ + block, + contentState, + decorator, + tree, +}: { + block: BlockNode; + contentState: EditorState['currentContent']; + decorator: EditorState['decorator']; + tree: ReturnType; +}): ReactNode { + if (!decorator?.getSkeletonAttributesForRange) { + return block.text ||
; + } + + const children: ReactNode[] = []; + let plainTextStart = 0; + for (const range of tree) { + if (range.decoratorKey === null) { + continue; + } + const skeletonAttributes = decorator.getSkeletonAttributesForRange({ + block, + contentState, + decoratorKey: range.decoratorKey, + start: range.start, + end: range.end, + entityKey: getEntityAt(block, range.start), + }); + if (!skeletonAttributes?.length) { + continue; + } + + if (plainTextStart < range.start) { + children.push(block.text.slice(plainTextStart, range.start)); + } + let decoratedText: ReactNode = block.text.slice(range.start, range.end); + for (const [index, attributes] of skeletonAttributes.entries()) { + decoratedText = React.createElement( + 'span', + { + ...attributes, + key: `${range.start}-${index}`, + }, + decoratedText, + ); + } + children.push(decoratedText); + plainTextStart = range.end; + } + + if (plainTextStart < block.text.length) { + children.push(block.text.slice(plainTextStart)); + } + return children.length ? children : block.text ||
; +} + /** * `DraftEditorContents` is the container component for all block components * rendered for a `DraftEditor`. It is optimized to aggressively avoid @@ -91,7 +154,7 @@ export default class DraftEditorContents extends React.Component { anchor?: DOMLocation; focus?: DOMLocation; } = {anchor: undefined, focus: undefined}; - private ref = React.createRef(); + private contentsElement: HTMLDivElement | null = null; shouldComponentUpdate(nextProps: Props): boolean { const prevEditorState = this.props.editorState; @@ -128,6 +191,13 @@ export default class DraftEditorContents extends React.Component { const wasComposing = prevEditorState.inCompositionMode; const nowComposing = nextEditorState.inCompositionMode; + if ( + !(wasComposing && nowComposing) && + this.props.blockSkeleton !== nextProps.blockSkeleton + ) { + return true; + } + // If the state is unchanged or we're currently rendering a natively // rendered state, there's nothing new to be done. if ( @@ -158,7 +228,7 @@ export default class DraftEditorContents extends React.Component { } _updateDomSelection() { - const thisNode = this.ref.current; + const thisNode = this.contentsElement; if (thisNode) { const selection = getDOMSelection(thisNode); if (selection) { @@ -182,6 +252,11 @@ export default class DraftEditorContents extends React.Component { this.scheduledDomSelectionUpdates[type] = loc; }; + _setContentsRef = (node: HTMLDivElement | null) => { + this.contentsElement = node; + this.props.contentsRef?.(node); + }; + render(): React.ReactNode { // Reset the DOM selection updates before each render this._clearDomSelectionUpdates(); @@ -190,6 +265,7 @@ export default class DraftEditorContents extends React.Component { blockRenderMap, blockRendererFn, blockStyleFn, + blockSkeleton, customStyleMap, customStyleFn, editorState, @@ -225,40 +301,10 @@ export default class DraftEditorContents extends React.Component { const key = block.key; const blockType = block.type; - const customRenderer = blockRendererFn(block); - let CustomComponent, customProps, customEditable; - if (customRenderer) { - CustomComponent = customRenderer.component; - customProps = customRenderer.props; - customEditable = customRenderer.editable; - } - const direction = textDirectionality ? textDirectionality : directionMap.get(key); const offsetKey = DraftOffsetKey.encode(key, 0, 0); - const componentProps = { - contentState: content, - block, - blockProps: customProps, - blockStyleFn, - customStyleMap, - customStyleFn, - decorator, - direction, - forceSelection, - offsetKey, - preventScroll, - selection, - tree: getBlockTree(editorState, key), - scrollUpThreshold, - scrollUpHeight, - scrollDownThreshold, - scrollDownHeight, - scheduleDomSelectionUpdate: this.props.globalDomSelectionUpdate - ? this.scheduleDomSelectionUpdate - : undefined, - }; const configForType = blockRenderMap[blockType] || blockRenderMap['unstyled']; @@ -293,33 +339,89 @@ export default class DraftEditorContents extends React.Component { ); } - const Component = CustomComponent || DraftEditorBlock; let childProps: Record = { className, 'data-block': true, + 'data-block-key': key, 'data-editor': editorKey, 'data-offset-key': offsetKey, id: `block-${key}`, key, style: inlineStyle, }; - if (customEditable !== undefined) { + let child: ReactNode; + if (blockSkeleton && !blockSkeleton.fullBlockKeys.has(key)) { childProps = { ...childProps, - contentEditable: customEditable, + className: joinClasses( + className, + getDraftEditorBlockClassName(direction), + ), + contentEditable: false, + 'data-block-skeleton': true, suppressContentEditableWarning: true, }; + const tree = decorator?.getSkeletonAttributesForRange + ? getBlockTree(editorState, key) + : []; + child = React.createElement( + Element, + childProps, + renderSkeletonChildren({ + block, + contentState: content, + decorator, + tree, + }), + ); + } else { + const customRenderer = blockRendererFn(block); + let CustomComponent, customProps, customEditable; + if (customRenderer) { + CustomComponent = customRenderer.component; + customProps = customRenderer.props; + customEditable = customRenderer.editable; + } + const componentProps = { + contentState: content, + block, + blockProps: customProps, + blockStyleFn, + customStyleMap, + customStyleFn, + decorator, + direction, + forceSelection, + offsetKey, + preventScroll, + selection, + tree: getBlockTree(editorState, key), + scrollUpThreshold, + scrollUpHeight, + scrollDownThreshold, + scrollDownHeight, + scheduleDomSelectionUpdate: this.props.globalDomSelectionUpdate + ? this.scheduleDomSelectionUpdate + : undefined, + }; + const Component = CustomComponent || DraftEditorBlock; + if (customEditable !== undefined) { + childProps = { + ...childProps, + contentEditable: customEditable, + suppressContentEditableWarning: true, + }; + } + child = React.createElement( + Element, + childProps, + /* $FlowFixMe(>=0.112.0 site=www,mobile) This comment suppresses an + * error found when Flow v0.112 was deployed. To see the error delete + * this comment and run Flow. */ + , + ); } - const child = React.createElement( - Element, - childProps, - /* $FlowFixMe(>=0.112.0 site=www,mobile) This comment suppresses an - * error found when Flow v0.112 was deployed. To see the error delete - * this comment and run Flow. */ - , - ); - processedBlocks.push({ block: child, wrapperTemplate, @@ -364,7 +466,7 @@ export default class DraftEditorContents extends React.Component { } return ( -
+
{outputBlocks}
); diff --git a/src/component/contents/DraftEditorSkeletonContents.react.tsx b/src/component/contents/DraftEditorSkeletonContents.react.tsx new file mode 100644 index 000000000..d6beea351 --- /dev/null +++ b/src/component/contents/DraftEditorSkeletonContents.react.tsx @@ -0,0 +1,35 @@ +import React, {useRef} from 'react'; +import {DraftEditorBlockSkeletonOptions} from '../base/DraftEditorProps'; +import DraftEditorContents from './DraftEditorContents-core.react'; +import {useDraftEditorBlockSkeleton} from '../hooks/useDraftEditorBlockSkeleton'; + +type Props = Omit< + React.ComponentProps, + 'blockSkeleton' +> & { + blockSkeleton?: DraftEditorBlockSkeletonOptions; +}; + +export default function DraftEditorSkeletonContents({ + blockSkeleton, + ...contentsProps +}: Props): React.ReactNode { + const contentsRef = useRef(null); + const skeletonState = useDraftEditorBlockSkeleton({ + enabled: blockSkeleton?.enabled ?? false, + editorState: contentsProps.editorState, + contentsRef, + scrollContainerRef: blockSkeleton?.scrollContainerRef ?? contentsRef, + pinnedBlockKeys: blockSkeleton?.pinnedBlockKeys, + }); + + return ( + { + contentsRef.current = node; + }} + /> + ); +} diff --git a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx index 428277017..16c67a75c 100644 --- a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx +++ b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx @@ -8,13 +8,21 @@ * @format */ -import {createEmpty, EditorState} from '../../../model/immutable/EditorState'; +import { + createEmpty, + createWithContent, + EditorState, +} from '../../../model/immutable/EditorState'; import React from 'react'; import RichTextEditorUtil from '../../../model/modifier/RichTextEditorUtil'; import {createRoot, Root} from 'react-dom/client'; import {flushSync} from 'react-dom'; import DraftEditor from '../../base/DraftEditor.react'; +import DraftEditorContents from '../DraftEditorContents-core.react'; +import {createFromText} from '../../../model/immutable/ContentState'; +import {DefaultDraftBlockRenderMap} from '../../../model/immutable/DefaultDraftBlockRenderMap'; +import {DraftDecoratorType} from '../../../model/decorators/DraftDecoratorType'; let container: HTMLElement; let root: Root; @@ -109,4 +117,62 @@ test('defaults to "unstyled" block type for unknown block types', () => { expect(() => { editorInstance?.toggleCustomBlock(); }).not.toThrow(); -}); \ No newline at end of file +}); + +test('renders offscreen blocks as text skeletons with persistent decorator attributes', () => { + const decorator: DraftDecoratorType = { + getDecorations: block => + block.text.split('').map((_, index) => (index < 3 ? 'linked' : null)), + getComponentForKey: () => + function FullDecorator({children}) { + return {children}; + }, + getPropsForKey: () => null, + getSkeletonAttributesForRange: ({block}) => [ + {id: `persistent-${block.key}`}, + ], + }; + const editorState = createWithContent( + createFromText('zero\none\ntwo'), + decorator, + ); + const blocks = Array.from(editorState.currentContent.blockMap.values()); + const fullBlock = blocks[0]; + const blockRendererFn = jest.fn(() => null); + + flushSync(() => { + root.render( + , + ); + }); + + expect(container.textContent).toBe('zeroonetwo'); + expect(container.querySelectorAll('[data-block]')).toHaveLength(3); + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(2); + expect(container.querySelectorAll('[data-full-decoration]')).toHaveLength(1); + expect(blockRendererFn).toHaveBeenCalledTimes(1); + + for (const block of blocks) { + const skeleton = container.querySelector( + `[data-block-key="${block.key}"]`, + ); + expect(skeleton?.id).toBe(`block-${block.key}`); + } + + for (const block of blocks.slice(1)) { + const skeleton = container.querySelector( + `[data-block-key="${block.key}"]`, + ); + expect(skeleton?.getAttribute('contenteditable')).toBe('false'); + expect( + skeleton?.querySelector(`#persistent-${block.key}`)?.textContent, + ).toBe(block.text.slice(0, 3)); + } +}); diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts new file mode 100644 index 000000000..6bc69ad1b --- /dev/null +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -0,0 +1,102 @@ +import {RefObject, useLayoutEffect, useMemo, useState} from 'react'; +import {EditorState} from '../../model/immutable/EditorState'; + +export type DraftEditorBlockSkeletonState = Readonly<{ + fullBlockKeys: ReadonlySet; +}>; + +type Options = Readonly<{ + enabled: boolean; + editorState: EditorState; + contentsRef: RefObject; + scrollContainerRef: RefObject; + pinnedBlockKeys?: ReadonlySet; +}>; + +const OBSERVER_MARGIN = '1200px 0px'; + +export function useDraftEditorBlockSkeleton({ + enabled, + editorState, + contentsRef, + scrollContainerRef, + pinnedBlockKeys, +}: Options): DraftEditorBlockSkeletonState | undefined { + const [visibleBlockKeys, setVisibleBlockKeys] = useState>( + new Set(), + ); + const blockMap = editorState.currentContent.blockMap; + const canObserve = + typeof window !== 'undefined' && typeof IntersectionObserver !== 'undefined'; + + useLayoutEffect(() => { + if (!enabled || !canObserve) { + return; + } + + const contents = contentsRef.current; + if (!contents) { + return; + } + + const observer = new IntersectionObserver( + entries => { + setVisibleBlockKeys(previousKeys => { + const nextKeys = new Set(previousKeys); + let changed = false; + for (const entry of entries) { + const blockKey = (entry.target as HTMLElement).dataset.blockKey; + if (!blockKey) { + continue; + } + if (entry.isIntersecting) { + if (!nextKeys.has(blockKey)) { + nextKeys.add(blockKey); + changed = true; + } + } else if (nextKeys.delete(blockKey)) { + changed = true; + } + } + return changed ? nextKeys : previousKeys; + }); + }, + { + root: scrollContainerRef.current, + rootMargin: OBSERVER_MARGIN, + }, + ); + + for (const blockElement of contents.querySelectorAll('[data-block-key]')) { + observer.observe(blockElement); + } + return () => observer.disconnect(); + }, [ + blockMap, + canObserve, + contentsRef, + enabled, + scrollContainerRef, + ]); + + return useMemo(() => { + if (!enabled || !canObserve) { + return undefined; + } + + const fullBlockKeys = new Set(visibleBlockKeys); + fullBlockKeys.add(editorState.selection.anchorKey); + fullBlockKeys.add(editorState.selection.focusKey); + for (const blockKey of pinnedBlockKeys || []) { + fullBlockKeys.add(blockKey); + } + return {fullBlockKeys}; + }, [ + canObserve, + editorState.selection.anchorKey, + editorState.selection.focusKey, + enabled, + pinnedBlockKeys, + visibleBlockKeys, + ]); +} diff --git a/src/model/decorators/DraftDecoratorType.ts b/src/model/decorators/DraftDecoratorType.ts index d6439263a..b08615d94 100644 --- a/src/model/decorators/DraftDecoratorType.ts +++ b/src/model/decorators/DraftDecoratorType.ts @@ -13,6 +13,19 @@ import {BlockNode} from '../immutable/BlockNode'; import {ComponentType} from 'react'; import {DraftDecoratorComponentProps} from './DraftDecorator'; +export type DraftDecoratorSkeletonAttributes = Readonly< + Record +>; + +export type DraftDecoratorSkeletonRange = Readonly<{ + block: BlockNode; + contentState: ContentState; + decoratorKey: string; + start: number; + end: number; + entityKey: string | null; +}>; + /** * An interface for document decorator classes, allowing the creation of * custom decorator classes. @@ -39,4 +52,11 @@ export type DraftDecoratorType = { * this decorated range. */ getPropsForKey: (key: string) => Record | null; + /** + * Return the DOM attributes that must remain available when a decorated + * range is rendered as part of an offscreen block skeleton. + */ + getSkeletonAttributesForRange?: ( + range: DraftDecoratorSkeletonRange, + ) => readonly DraftDecoratorSkeletonAttributes[] | undefined; }; From 129f446332ed5b05b584d82f80987f74536b5a90 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 24 Jul 2026 17:57:32 -0700 Subject: [PATCH 02/16] fix(editor): stabilize block skeleton scrolling --- .../contents/DraftEditorBlock.react.tsx | 4 +- .../DraftEditorContents-core.react.tsx | 15 ++--- .../DraftEditorContents.react-test.tsx | 8 ++- .../hooks/useDraftEditorBlockSkeleton.ts | 61 +++++++++++-------- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/component/contents/DraftEditorBlock.react.tsx b/src/component/contents/DraftEditorBlock.react.tsx index e564b2531..b31db7f8c 100644 --- a/src/component/contents/DraftEditorBlock.react.tsx +++ b/src/component/contents/DraftEditorBlock.react.tsx @@ -100,9 +100,7 @@ const getNodeScrollTopAndBottom = ( * A `DraftEditorBlock` is able to render a given `ContentBlock` to its * appropriate decorator and inline style components. */ -export function getDraftEditorBlockClassName( - direction: BidiDirection, -): string { +function getDraftEditorBlockClassName(direction: BidiDirection): string { return cx({ 'public/DraftStyleDefault/block': true, 'public/DraftStyleDefault/ltr': direction === 'LTR', diff --git a/src/component/contents/DraftEditorContents-core.react.tsx b/src/component/contents/DraftEditorContents-core.react.tsx index e42790768..eda299493 100644 --- a/src/component/contents/DraftEditorContents-core.react.tsx +++ b/src/component/contents/DraftEditorContents-core.react.tsx @@ -17,9 +17,7 @@ import cx from 'fbjs/lib/cx'; import joinClasses from 'fbjs/lib/joinClasses'; import {nullthrows} from '../../fbjs/nullthrows'; import DraftOffsetKey from '../selection/DraftOffsetKey'; -import DraftEditorBlock, { - getDraftEditorBlockClassName, -} from './DraftEditorBlock.react'; +import DraftEditorBlock from './DraftEditorBlock.react'; import {BlockNode} from '../../model/immutable/BlockNode'; import {getEntityAt} from '../../model/immutable/ContentBlock'; import {DraftEditorBlockSkeletonState} from '../hooks/useDraftEditorBlockSkeleton'; @@ -353,12 +351,15 @@ export default class DraftEditorContents extends React.Component { if (blockSkeleton && !blockSkeleton.fullBlockKeys.has(key)) { childProps = { ...childProps, - className: joinClasses( - className, - getDraftEditorBlockClassName(direction), - ), contentEditable: false, 'data-block-skeleton': true, + style: { + ...inlineStyle, + direction: direction === 'RTL' ? 'rtl' : 'ltr', + position: 'relative', + textAlign: direction === 'RTL' ? 'right' : 'left', + whiteSpace: 'pre-wrap', + }, suppressContentEditableWarning: true, }; const tree = decorator?.getSkeletonAttributesForRange diff --git a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx index 16c67a75c..b880b5d83 100644 --- a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx +++ b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx @@ -146,6 +146,7 @@ test('renders offscreen blocks as text skeletons with persistent decorator attri editorState={editorState} blockRenderMap={DefaultDraftBlockRenderMap} blockRendererFn={blockRendererFn} + blockStyleFn={() => ({marginBottom: '16px'})} blockSkeleton={{ fullBlockKeys: new Set(fullBlock ? [fullBlock.key] : []), }} @@ -167,10 +168,15 @@ test('renders offscreen blocks as text skeletons with persistent decorator attri } for (const block of blocks.slice(1)) { - const skeleton = container.querySelector( + const skeleton = container.querySelector( `[data-block-key="${block.key}"]`, ); expect(skeleton?.getAttribute('contenteditable')).toBe('false'); + expect(skeleton?.classList.contains('public-DraftStyleDefault-block')).toBe( + false, + ); + expect(skeleton?.style.marginBottom).toBe('16px'); + expect(skeleton?.style.whiteSpace).toBe('pre-wrap'); expect( skeleton?.querySelector(`#persistent-${block.key}`)?.textContent, ).toBe(block.text.slice(0, 3)); diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index 6bc69ad1b..980fb3057 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -1,4 +1,4 @@ -import {RefObject, useLayoutEffect, useMemo, useState} from 'react'; +import {RefObject, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {EditorState} from '../../model/immutable/EditorState'; export type DraftEditorBlockSkeletonState = Readonly<{ @@ -25,12 +25,14 @@ export function useDraftEditorBlockSkeleton({ const [visibleBlockKeys, setVisibleBlockKeys] = useState>( new Set(), ); + const hasRefreshedGeometry = useRef(false); const blockMap = editorState.currentContent.blockMap; const canObserve = typeof window !== 'undefined' && typeof IntersectionObserver !== 'undefined'; useLayoutEffect(() => { if (!enabled || !canObserve) { + hasRefreshedGeometry.current = false; return; } @@ -39,35 +41,41 @@ export function useDraftEditorBlockSkeleton({ return; } - const observer = new IntersectionObserver( - entries => { - setVisibleBlockKeys(previousKeys => { - const nextKeys = new Set(previousKeys); - let changed = false; - for (const entry of entries) { - const blockKey = (entry.target as HTMLElement).dataset.blockKey; - if (!blockKey) { - continue; - } - if (entry.isIntersecting) { - if (!nextKeys.has(blockKey)) { - nextKeys.add(blockKey); - changed = true; - } - } else if (nextKeys.delete(blockKey)) { + const observerOptions = { + root: scrollContainerRef.current, + rootMargin: OBSERVER_MARGIN, + }; + const handleEntries: IntersectionObserverCallback = entries => { + setVisibleBlockKeys(previousKeys => { + const nextKeys = new Set(previousKeys); + let changed = false; + for (const entry of entries) { + const blockKey = (entry.target as HTMLElement).dataset.blockKey; + if (!blockKey) { + continue; + } + if (entry.isIntersecting) { + if (!nextKeys.has(blockKey)) { + nextKeys.add(blockKey); changed = true; } + } else if (nextKeys.delete(blockKey)) { + changed = true; } - return changed ? nextKeys : previousKeys; - }); - }, - { - root: scrollContainerRef.current, - rootMargin: OBSERVER_MARGIN, - }, - ); + } + return changed ? nextKeys : previousKeys; + }); + }; + const observer = new IntersectionObserver(handleEntries, observerOptions); - for (const blockElement of contents.querySelectorAll('[data-block-key]')) { + const blockElements = contents.querySelectorAll('[data-block-key]'); + if (!hasRefreshedGeometry.current && visibleBlockKeys.size > 0) { + hasRefreshedGeometry.current = true; + for (const blockElement of blockElements) { + blockElement.getBoundingClientRect(); + } + } + for (const blockElement of blockElements) { observer.observe(blockElement); } return () => observer.disconnect(); @@ -77,6 +85,7 @@ export function useDraftEditorBlockSkeleton({ contentsRef, enabled, scrollContainerRef, + visibleBlockKeys, ]); return useMemo(() => { From 38eff1a3736e9021306bacac211770bc8a03453d Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Sat, 25 Jul 2026 07:11:48 -0700 Subject: [PATCH 03/16] fix(editor): select all across skeleton blocks Native browser selection stops at contentEditable boundaries, so skeletonized editors could select only the current rendered block. Build the full-editor selection in Draft while leaving custom key bindings in control. --- .../base/__tests__/DraftEditor.react-test.tsx | 87 +++++++++++++++++++ src/component/handlers/edit/editOnKeyDown.ts | 31 ++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index 47e30c555..d2ab32977 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -198,6 +198,93 @@ test('renders newly introduced block keys as skeletons immediately', () => { } }); +test('selects all content across skeleton blocks', () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '1200px 0px'; + readonly thresholds = [0]; + + disconnect(): void {} + observe(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve(): void {} + } + + globalThis.IntersectionObserver = MockIntersectionObserver; + try { + editorState = createWithContent(createFromText('zero\none\ntwo')); + const onChange = jest.fn(); + flushSync(() => { + root.render( + , + ); + }); + + const contentEditable = container.querySelector( + '[contenteditable="true"]', + ); + expect(contentEditable).not.toBeNull(); + + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: 'a', + metaKey: true, + }); + Object.defineProperties(event, { + keyCode: {value: 65}, + which: {value: 65}, + }); + contentEditable!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(onChange).toHaveBeenCalledTimes(1); + const nextEditorState = onChange.mock.calls[0][0] as EditorState; + const blocks = [...nextEditorState.currentContent.blockMap.values()]; + const firstBlock = blocks[0]; + const lastBlock = blocks[blocks.length - 1]; + expect(nextEditorState.selection).toEqual({ + anchorKey: firstBlock.key, + anchorOffset: 0, + focusKey: lastBlock.key, + focusOffset: lastBlock.text.length, + hasFocus: true, + isBackward: false, + }); + + flushSync(() => { + root.render( + , + ); + }); + const renderedBlocks = container.querySelectorAll('[data-block-key]'); + expect(renderedBlocks[0].hasAttribute('data-block-skeleton')).toBe(false); + expect(renderedBlocks[1].hasAttribute('data-block-skeleton')).toBe(true); + expect(renderedBlocks[2].hasAttribute('data-block-skeleton')).toBe(false); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + } +}); + describe('ariaDescribedBy', () => { function getProps(elem: React.ReactElement): Element { flushSync(() => { diff --git a/src/component/handlers/edit/editOnKeyDown.ts b/src/component/handlers/edit/editOnKeyDown.ts index 05a92a6a0..9d7ab4540 100644 --- a/src/component/handlers/edit/editOnKeyDown.ts +++ b/src/component/handlers/edit/editOnKeyDown.ts @@ -15,9 +15,15 @@ import KeyBindingUtil from '../../utils/KeyBindingUtil'; import {DraftEditorCommand} from '../../../model/constants/DraftEditorCommand'; import { EditorState, + forceSelection, pushContent, redo, } from '../../../model/immutable/EditorState'; +import { + getFirstBlock, + getLastBlock, +} from '../../../model/immutable/ContentState'; +import {makeSelectionState} from '../../../model/immutable/SelectionState'; import isEventHandled from '../../utils/isEventHandled'; import keyCommandPlainDelete from './commands/keyCommandPlainDelete'; import keyCommandDeleteWord from './commands/keyCommandDeleteWord'; @@ -33,7 +39,7 @@ import DraftEditor from '../../base/DraftEditor.react'; import DraftModifier from '../../../model/modifier/DraftModifier'; import keyCommandUndo from './commands/keyCommandUndo'; -const {isOptionKeyCommand} = KeyBindingUtil; +const {hasCommandModifier, isOptionKeyCommand} = KeyBindingUtil; const isChromium = UserAgent.isBrowser('Chrome') || UserAgent.isBrowser('Electron'); @@ -169,6 +175,29 @@ export function editOnKeyDown( // If no command is specified, allow keydown event to continue. if (command == null || command === '') { + if ( + editor.props.blockSkeleton?.enabled && + keyCode === 65 && + hasCommandModifier(e) + ) { + e.preventDefault(); + const content = editorState.currentContent; + const firstBlock = getFirstBlock(content); + const lastBlock = getLastBlock(content); + editor.update( + forceSelection( + editorState, + makeSelectionState({ + anchorKey: firstBlock.key, + anchorOffset: 0, + focusKey: lastBlock.key, + focusOffset: lastBlock.text.length, + }), + ), + ); + return; + } + if (keyCode === Keys.SPACE && isChromium && isOptionKeyCommand(e)) { // The default keydown event has already been prevented in order to stop // Chrome from scrolling. Insert a nbsp into the editor as OSX would for From 0b31b8db439675b2c9a9ec5e1812bf86268c6d17 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Mon, 3 Aug 2026 16:07:56 -0700 Subject: [PATCH 04/16] perf(editor): reduce block skeleton overscan --- src/component/base/__tests__/DraftEditor.react-test.tsx | 6 +++--- src/component/hooks/useDraftEditorBlockSkeleton.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index d2ab32977..694fdd676 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -84,7 +84,7 @@ test('promotes intersecting skeleton blocks to full rendering', () => { class MockIntersectionObserver implements IntersectionObserver { readonly root = container; - readonly rootMargin = '1200px 0px'; + readonly rootMargin = '500px 0px'; readonly thresholds = [0]; constructor(callback: IntersectionObserverCallback) { @@ -145,7 +145,7 @@ test('renders newly introduced block keys as skeletons immediately', () => { class MockIntersectionObserver implements IntersectionObserver { readonly root = container; - readonly rootMargin = '1200px 0px'; + readonly rootMargin = '500px 0px'; readonly thresholds = [0]; disconnect(): void {} @@ -203,7 +203,7 @@ test('selects all content across skeleton blocks', () => { class MockIntersectionObserver implements IntersectionObserver { readonly root = container; - readonly rootMargin = '1200px 0px'; + readonly rootMargin = '500px 0px'; readonly thresholds = [0]; disconnect(): void {} diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index 980fb3057..58c175f43 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -13,7 +13,7 @@ type Options = Readonly<{ pinnedBlockKeys?: ReadonlySet; }>; -const OBSERVER_MARGIN = '1200px 0px'; +const OBSERVER_MARGIN = '500px 0px'; export function useDraftEditorBlockSkeleton({ enabled, From 9ed68e25039d5ea4c0b4838e904064332251a96a Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Mon, 3 Aug 2026 16:21:29 -0700 Subject: [PATCH 05/16] refactor(editor): remove skeleton block pinning --- src/component/base/DraftEditorProps.ts | 1 - .../contents/DraftEditorSkeletonContents.react.tsx | 1 - src/component/hooks/useDraftEditorBlockSkeleton.ts | 6 ------ 3 files changed, 8 deletions(-) diff --git a/src/component/base/DraftEditorProps.ts b/src/component/base/DraftEditorProps.ts index f734474ca..f42db7302 100644 --- a/src/component/base/DraftEditorProps.ts +++ b/src/component/base/DraftEditorProps.ts @@ -29,7 +29,6 @@ import {BlockNode} from '../../model/immutable/BlockNode'; export type DraftEditorBlockSkeletonOptions = Readonly<{ enabled: boolean; scrollContainerRef: RefObject; - pinnedBlockKeys?: ReadonlySet; }>; export type DraftEditorProps = { diff --git a/src/component/contents/DraftEditorSkeletonContents.react.tsx b/src/component/contents/DraftEditorSkeletonContents.react.tsx index d6beea351..63142ea67 100644 --- a/src/component/contents/DraftEditorSkeletonContents.react.tsx +++ b/src/component/contents/DraftEditorSkeletonContents.react.tsx @@ -20,7 +20,6 @@ export default function DraftEditorSkeletonContents({ editorState: contentsProps.editorState, contentsRef, scrollContainerRef: blockSkeleton?.scrollContainerRef ?? contentsRef, - pinnedBlockKeys: blockSkeleton?.pinnedBlockKeys, }); return ( diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index 58c175f43..fd028d94b 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -10,7 +10,6 @@ type Options = Readonly<{ editorState: EditorState; contentsRef: RefObject; scrollContainerRef: RefObject; - pinnedBlockKeys?: ReadonlySet; }>; const OBSERVER_MARGIN = '500px 0px'; @@ -20,7 +19,6 @@ export function useDraftEditorBlockSkeleton({ editorState, contentsRef, scrollContainerRef, - pinnedBlockKeys, }: Options): DraftEditorBlockSkeletonState | undefined { const [visibleBlockKeys, setVisibleBlockKeys] = useState>( new Set(), @@ -96,16 +94,12 @@ export function useDraftEditorBlockSkeleton({ const fullBlockKeys = new Set(visibleBlockKeys); fullBlockKeys.add(editorState.selection.anchorKey); fullBlockKeys.add(editorState.selection.focusKey); - for (const blockKey of pinnedBlockKeys || []) { - fullBlockKeys.add(blockKey); - } return {fullBlockKeys}; }, [ canObserve, editorState.selection.anchorKey, editorState.selection.focusKey, enabled, - pinnedBlockKeys, visibleBlockKeys, ]); } From 1d3f2eaa31f8c87f6c14c906d600c74ca940e710 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Tue, 4 Aug 2026 15:42:10 -0700 Subject: [PATCH 06/16] refactor(editor): simplify block skeleton rendering --- src/Draft.ts | 3 +- .../base/__tests__/DraftEditor.react-test.tsx | 101 +++++++++--------- .../DraftDecoratorDOMAnchors.react.tsx | 16 +++ .../contents/DraftEditorBlock.react.tsx | 15 ++- .../DraftEditorContents-core.react.tsx | 26 ++--- .../DraftEditorSkeletonContents.react.tsx | 14 +-- .../DraftEditorContents.react-test.tsx | 18 ++-- src/component/handlers/edit/editOnKeyDown.ts | 31 +----- .../hooks/useDraftEditorBlockSkeleton.ts | 91 +++++++++++----- src/model/decorators/DraftDecoratorType.ts | 17 ++- 10 files changed, 171 insertions(+), 161 deletions(-) create mode 100644 src/component/contents/DraftDecoratorDOMAnchors.react.tsx diff --git a/src/Draft.ts b/src/Draft.ts index 08b96171e..5b1ddde63 100644 --- a/src/Draft.ts +++ b/src/Draft.ts @@ -42,8 +42,7 @@ export type {RawDraftContentState} from './model/encoding/convertFromDraftStateT export type {DraftEditorCommand} from './model/constants/DraftEditorCommand'; export type {DraftHandleValue} from './model/constants/DraftHandleValue'; export type { - DraftDecoratorSkeletonAttributes, - DraftDecoratorSkeletonRange, + DraftDecoratorDOMAnchorRange, DraftDecoratorType, } from './model/decorators/DraftDecoratorType'; export type {DraftDecorator, DraftDecoratorComponentProps} from './model/decorators/DraftDecorator'; diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index 694fdd676..f5e41259b 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -80,6 +80,7 @@ test('must has editorKey same as props', () => { test('promotes intersecting skeleton blocks to full rendering', () => { const originalIntersectionObserver = globalThis.IntersectionObserver; let observerCallback: IntersectionObserverCallback | undefined; + let observerCount = 0; const observedElements: Element[] = []; class MockIntersectionObserver implements IntersectionObserver { @@ -88,6 +89,7 @@ test('promotes intersecting skeleton blocks to full rendering', () => { readonly thresholds = [0]; constructor(callback: IntersectionObserverCallback) { + observerCount++; observerCallback = callback; } @@ -118,6 +120,7 @@ test('promotes intersecting skeleton blocks to full rendering', () => { }); expect(observedElements).toHaveLength(3); + expect(observerCount).toBe(1); expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(2); const secondBlock = observedElements[1]; @@ -135,6 +138,7 @@ test('promotes intersecting skeleton blocks to full rendering', () => { }); expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(1); + expect(observerCount).toBe(1); } finally { globalThis.IntersectionObserver = originalIntersectionObserver; } @@ -142,6 +146,9 @@ test('promotes intersecting skeleton blocks to full rendering', () => { test('renders newly introduced block keys as skeletons immediately', () => { const originalIntersectionObserver = globalThis.IntersectionObserver; + const originalMutationObserver = globalThis.MutationObserver; + const observedElements: Element[] = []; + let mutationCallback: MutationCallback | undefined; class MockIntersectionObserver implements IntersectionObserver { readonly root = container; @@ -149,14 +156,29 @@ test('renders newly introduced block keys as skeletons immediately', () => { readonly thresholds = [0]; disconnect(): void {} - observe(): void {} + observe(target: Element): void { + observedElements.push(target); + } takeRecords(): IntersectionObserverEntry[] { return []; } unobserve(): void {} } + class MockMutationObserver implements MutationObserver { + constructor(callback: MutationCallback) { + mutationCallback = callback; + } + + disconnect(): void {} + observe(): void {} + takeRecords(): MutationRecord[] { + return []; + } + } + globalThis.IntersectionObserver = MockIntersectionObserver; + globalThis.MutationObserver = MockMutationObserver; try { const blockRendererFn = jest.fn(() => null); editorState = createWithContent(createFromText('zero\none')); @@ -193,12 +215,26 @@ test('renders newly introduced block keys as skeletons immediately', () => { expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(2); expect(blockRendererFn).toHaveBeenCalledTimes(1); + + const newBlock = container.querySelectorAll('[data-block-key]')[2]; + mutationCallback?.( + [ + ({ + addedNodes: [newBlock], + removedNodes: [], + } as unknown) as MutationRecord, + ], + {} as MutationObserver, + ); + expect(observedElements).toContain(newBlock); } finally { globalThis.IntersectionObserver = originalIntersectionObserver; + globalThis.MutationObserver = originalMutationObserver; } }); -test('selects all content across skeleton blocks', () => { +test('renders full blocks when native scroll anchoring is unsupported', () => { + const originalCSS = globalThis.CSS; const originalIntersectionObserver = globalThis.IntersectionObserver; class MockIntersectionObserver implements IntersectionObserver { @@ -215,14 +251,17 @@ test('selects all content across skeleton blocks', () => { } globalThis.IntersectionObserver = MockIntersectionObserver; + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: {supports: () => false}, + }); try { editorState = createWithContent(createFromText('zero\none\ntwo')); - const onChange = jest.fn(); flushSync(() => { root.render( {}} blockSkeleton={{ enabled: true, scrollContainerRef: {current: container}, @@ -231,57 +270,13 @@ test('selects all content across skeleton blocks', () => { ); }); - const contentEditable = container.querySelector( - '[contenteditable="true"]', - ); - expect(contentEditable).not.toBeNull(); - - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - ctrlKey: true, - key: 'a', - metaKey: true, - }); - Object.defineProperties(event, { - keyCode: {value: 65}, - which: {value: 65}, - }); - contentEditable!.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(onChange).toHaveBeenCalledTimes(1); - const nextEditorState = onChange.mock.calls[0][0] as EditorState; - const blocks = [...nextEditorState.currentContent.blockMap.values()]; - const firstBlock = blocks[0]; - const lastBlock = blocks[blocks.length - 1]; - expect(nextEditorState.selection).toEqual({ - anchorKey: firstBlock.key, - anchorOffset: 0, - focusKey: lastBlock.key, - focusOffset: lastBlock.text.length, - hasFocus: true, - isBackward: false, - }); - - flushSync(() => { - root.render( - , - ); - }); - const renderedBlocks = container.querySelectorAll('[data-block-key]'); - expect(renderedBlocks[0].hasAttribute('data-block-skeleton')).toBe(false); - expect(renderedBlocks[1].hasAttribute('data-block-skeleton')).toBe(true); - expect(renderedBlocks[2].hasAttribute('data-block-skeleton')).toBe(false); + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(0); } finally { globalThis.IntersectionObserver = originalIntersectionObserver; + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: originalCSS, + }); } }); diff --git a/src/component/contents/DraftDecoratorDOMAnchors.react.tsx b/src/component/contents/DraftDecoratorDOMAnchors.react.tsx new file mode 100644 index 000000000..2e26e776f --- /dev/null +++ b/src/component/contents/DraftDecoratorDOMAnchors.react.tsx @@ -0,0 +1,16 @@ +import React, {ReactNode} from 'react'; + +export default function wrapInDOMAnchors( + children: ReactNode, + anchorIds: readonly string[] | undefined, +): ReactNode { + let result = children; + for (const anchorId of anchorIds ?? []) { + result = ( + + {result} + + ); + } + return result; +} diff --git a/src/component/contents/DraftEditorBlock.react.tsx b/src/component/contents/DraftEditorBlock.react.tsx index b31db7f8c..e0df47025 100644 --- a/src/component/contents/DraftEditorBlock.react.tsx +++ b/src/component/contents/DraftEditorBlock.react.tsx @@ -39,6 +39,7 @@ import { import {DraftDecoratorComponentProps} from '../../model/decorators/DraftDecorator'; import {BlockNode} from '../../model/immutable/BlockNode'; import {DOMSelectionUpdateFn} from '../selection/DOMSelectionUpdate'; +import wrapInDOMAnchors from './DraftDecoratorDOMAnchors.react'; const DEFAULT_SCROLL_BUFFER = 10; @@ -360,11 +361,23 @@ export default class DraftEditorBlock extends React.Component { offsetKey: decoratorOffsetKey, }; - return ( + const anchorIds = decorator.getDOMAnchorIdsForRange?.({ + block, + contentState: this.props.contentState, + decoratorKey, + start, + end, + entityKey, + }); + + const decoratedRange = ( {leaves} ); + return anchorIds?.length + ? wrapInDOMAnchors(decoratedRange, anchorIds) + : decoratedRange; }); } diff --git a/src/component/contents/DraftEditorContents-core.react.tsx b/src/component/contents/DraftEditorContents-core.react.tsx index eda299493..8fd7f8e2c 100644 --- a/src/component/contents/DraftEditorContents-core.react.tsx +++ b/src/component/contents/DraftEditorContents-core.react.tsx @@ -21,6 +21,7 @@ import DraftEditorBlock from './DraftEditorBlock.react'; import {BlockNode} from '../../model/immutable/BlockNode'; import {getEntityAt} from '../../model/immutable/ContentBlock'; import {DraftEditorBlockSkeletonState} from '../hooks/useDraftEditorBlockSkeleton'; +import wrapInDOMAnchors from './DraftDecoratorDOMAnchors.react'; import { DOMLocation, DOMSelectionUpdateFn, @@ -92,7 +93,7 @@ function renderSkeletonChildren({ decorator: EditorState['decorator']; tree: ReturnType; }): ReactNode { - if (!decorator?.getSkeletonAttributesForRange) { + if (!decorator?.getDOMAnchorIdsForRange) { return block.text ||
; } @@ -102,7 +103,7 @@ function renderSkeletonChildren({ if (range.decoratorKey === null) { continue; } - const skeletonAttributes = decorator.getSkeletonAttributesForRange({ + const anchorIds = decorator.getDOMAnchorIdsForRange({ block, contentState, decoratorKey: range.decoratorKey, @@ -110,25 +111,16 @@ function renderSkeletonChildren({ end: range.end, entityKey: getEntityAt(block, range.start), }); - if (!skeletonAttributes?.length) { + if (!anchorIds?.length) { continue; } if (plainTextStart < range.start) { children.push(block.text.slice(plainTextStart, range.start)); } - let decoratedText: ReactNode = block.text.slice(range.start, range.end); - for (const [index, attributes] of skeletonAttributes.entries()) { - decoratedText = React.createElement( - 'span', - { - ...attributes, - key: `${range.start}-${index}`, - }, - decoratedText, - ); - } - children.push(decoratedText); + children.push( + wrapInDOMAnchors(block.text.slice(range.start, range.end), anchorIds), + ); plainTextStart = range.end; } @@ -351,7 +343,6 @@ export default class DraftEditorContents extends React.Component { if (blockSkeleton && !blockSkeleton.fullBlockKeys.has(key)) { childProps = { ...childProps, - contentEditable: false, 'data-block-skeleton': true, style: { ...inlineStyle, @@ -360,9 +351,8 @@ export default class DraftEditorContents extends React.Component { textAlign: direction === 'RTL' ? 'right' : 'left', whiteSpace: 'pre-wrap', }, - suppressContentEditableWarning: true, }; - const tree = decorator?.getSkeletonAttributesForRange + const tree = decorator?.getDOMAnchorIdsForRange ? getBlockTree(editorState, key) : []; child = React.createElement( diff --git a/src/component/contents/DraftEditorSkeletonContents.react.tsx b/src/component/contents/DraftEditorSkeletonContents.react.tsx index 63142ea67..3c17b83ff 100644 --- a/src/component/contents/DraftEditorSkeletonContents.react.tsx +++ b/src/component/contents/DraftEditorSkeletonContents.react.tsx @@ -1,4 +1,4 @@ -import React, {useRef} from 'react'; +import React, {useState} from 'react'; import {DraftEditorBlockSkeletonOptions} from '../base/DraftEditorProps'; import DraftEditorContents from './DraftEditorContents-core.react'; import {useDraftEditorBlockSkeleton} from '../hooks/useDraftEditorBlockSkeleton'; @@ -14,21 +14,21 @@ export default function DraftEditorSkeletonContents({ blockSkeleton, ...contentsProps }: Props): React.ReactNode { - const contentsRef = useRef(null); + const [contentsElement, setContentsElement] = useState( + null, + ); const skeletonState = useDraftEditorBlockSkeleton({ enabled: blockSkeleton?.enabled ?? false, editorState: contentsProps.editorState, - contentsRef, - scrollContainerRef: blockSkeleton?.scrollContainerRef ?? contentsRef, + contentsElement, + scrollContainerRef: blockSkeleton?.scrollContainerRef, }); return ( { - contentsRef.current = node; - }} + contentsRef={setContentsElement} /> ); } diff --git a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx index b880b5d83..7cf37d462 100644 --- a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx +++ b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx @@ -119,7 +119,7 @@ test('defaults to "unstyled" block type for unknown block types', () => { }).not.toThrow(); }); -test('renders offscreen blocks as text skeletons with persistent decorator attributes', () => { +test('renders offscreen blocks as text skeletons with persistent DOM anchors', () => { const decorator: DraftDecoratorType = { getDecorations: block => block.text.split('').map((_, index) => (index < 3 ? 'linked' : null)), @@ -128,9 +128,7 @@ test('renders offscreen blocks as text skeletons with persistent decorator attri return {children}; }, getPropsForKey: () => null, - getSkeletonAttributesForRange: ({block}) => [ - {id: `persistent-${block.key}`}, - ], + getDOMAnchorIdsForRange: ({block}) => [`persistent-${block.key}`], }; const editorState = createWithContent( createFromText('zero\none\ntwo'), @@ -161,24 +159,22 @@ test('renders offscreen blocks as text skeletons with persistent decorator attri expect(blockRendererFn).toHaveBeenCalledTimes(1); for (const block of blocks) { - const skeleton = container.querySelector( - `[data-block-key="${block.key}"]`, - ); + const skeleton = container.querySelector(`[data-block-key="${block.key}"]`); expect(skeleton?.id).toBe(`block-${block.key}`); + expect( + skeleton?.querySelector(`#persistent-${block.key}`)?.textContent, + ).toBe(block.text.slice(0, 3)); } for (const block of blocks.slice(1)) { const skeleton = container.querySelector( `[data-block-key="${block.key}"]`, ); - expect(skeleton?.getAttribute('contenteditable')).toBe('false'); + expect(skeleton?.hasAttribute('contenteditable')).toBe(false); expect(skeleton?.classList.contains('public-DraftStyleDefault-block')).toBe( false, ); expect(skeleton?.style.marginBottom).toBe('16px'); expect(skeleton?.style.whiteSpace).toBe('pre-wrap'); - expect( - skeleton?.querySelector(`#persistent-${block.key}`)?.textContent, - ).toBe(block.text.slice(0, 3)); } }); diff --git a/src/component/handlers/edit/editOnKeyDown.ts b/src/component/handlers/edit/editOnKeyDown.ts index 9d7ab4540..05a92a6a0 100644 --- a/src/component/handlers/edit/editOnKeyDown.ts +++ b/src/component/handlers/edit/editOnKeyDown.ts @@ -15,15 +15,9 @@ import KeyBindingUtil from '../../utils/KeyBindingUtil'; import {DraftEditorCommand} from '../../../model/constants/DraftEditorCommand'; import { EditorState, - forceSelection, pushContent, redo, } from '../../../model/immutable/EditorState'; -import { - getFirstBlock, - getLastBlock, -} from '../../../model/immutable/ContentState'; -import {makeSelectionState} from '../../../model/immutable/SelectionState'; import isEventHandled from '../../utils/isEventHandled'; import keyCommandPlainDelete from './commands/keyCommandPlainDelete'; import keyCommandDeleteWord from './commands/keyCommandDeleteWord'; @@ -39,7 +33,7 @@ import DraftEditor from '../../base/DraftEditor.react'; import DraftModifier from '../../../model/modifier/DraftModifier'; import keyCommandUndo from './commands/keyCommandUndo'; -const {hasCommandModifier, isOptionKeyCommand} = KeyBindingUtil; +const {isOptionKeyCommand} = KeyBindingUtil; const isChromium = UserAgent.isBrowser('Chrome') || UserAgent.isBrowser('Electron'); @@ -175,29 +169,6 @@ export function editOnKeyDown( // If no command is specified, allow keydown event to continue. if (command == null || command === '') { - if ( - editor.props.blockSkeleton?.enabled && - keyCode === 65 && - hasCommandModifier(e) - ) { - e.preventDefault(); - const content = editorState.currentContent; - const firstBlock = getFirstBlock(content); - const lastBlock = getLastBlock(content); - editor.update( - forceSelection( - editorState, - makeSelectionState({ - anchorKey: firstBlock.key, - anchorOffset: 0, - focusKey: lastBlock.key, - focusOffset: lastBlock.text.length, - }), - ), - ); - return; - } - if (keyCode === Keys.SPACE && isChromium && isOptionKeyCommand(e)) { // The default keydown event has already been prevented in order to stop // Chrome from scrolling. Insert a nbsp into the editor as OSX would for diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index fd028d94b..dcd9618c2 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -1,4 +1,4 @@ -import {RefObject, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {RefObject, useLayoutEffect, useMemo, useState} from 'react'; import {EditorState} from '../../model/immutable/EditorState'; export type DraftEditorBlockSkeletonState = Readonly<{ @@ -8,39 +8,44 @@ export type DraftEditorBlockSkeletonState = Readonly<{ type Options = Readonly<{ enabled: boolean; editorState: EditorState; - contentsRef: RefObject; - scrollContainerRef: RefObject; + contentsElement: HTMLElement | null; + scrollContainerRef?: RefObject; }>; const OBSERVER_MARGIN = '500px 0px'; +function supportsNativeScrollAnchoring(): boolean { + return ( + typeof CSS === 'undefined' || + typeof CSS.supports !== 'function' || + CSS.supports('overflow-anchor: auto') + ); +} + export function useDraftEditorBlockSkeleton({ enabled, editorState, - contentsRef, + contentsElement, scrollContainerRef, }: Options): DraftEditorBlockSkeletonState | undefined { const [visibleBlockKeys, setVisibleBlockKeys] = useState>( new Set(), ); - const hasRefreshedGeometry = useRef(false); - const blockMap = editorState.currentContent.blockMap; const canObserve = - typeof window !== 'undefined' && typeof IntersectionObserver !== 'undefined'; + typeof window !== 'undefined' && + typeof IntersectionObserver !== 'undefined' && + supportsNativeScrollAnchoring(); useLayoutEffect(() => { if (!enabled || !canObserve) { - hasRefreshedGeometry.current = false; return; } - - const contents = contentsRef.current; - if (!contents) { + if (!contentsElement) { return; } const observerOptions = { - root: scrollContainerRef.current, + root: scrollContainerRef?.current ?? contentsElement, rootMargin: OBSERVER_MARGIN, }; const handleEntries: IntersectionObserverCallback = entries => { @@ -66,25 +71,55 @@ export function useDraftEditorBlockSkeleton({ }; const observer = new IntersectionObserver(handleEntries, observerOptions); - const blockElements = contents.querySelectorAll('[data-block-key]'); - if (!hasRefreshedGeometry.current && visibleBlockKeys.size > 0) { - hasRefreshedGeometry.current = true; - for (const blockElement of blockElements) { - blockElement.getBoundingClientRect(); + const getBlockElements = (node: Node): Element[] => { + if (!(node instanceof Element)) { + return []; } - } - for (const blockElement of blockElements) { + const elements = Array.from(node.querySelectorAll('[data-block-key]')); + if (node.matches('[data-block-key]')) { + elements.unshift(node); + } + return elements; + }; + for (const blockElement of getBlockElements(contentsElement)) { observer.observe(blockElement); } - return () => observer.disconnect(); - }, [ - blockMap, - canObserve, - contentsRef, - enabled, - scrollContainerRef, - visibleBlockKeys, - ]); + + const mutationObserver = new MutationObserver(mutations => { + const removedKeys = new Set(); + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + for (const blockElement of getBlockElements(node)) { + observer.observe(blockElement); + } + } + for (const node of mutation.removedNodes) { + for (const blockElement of getBlockElements(node)) { + observer.unobserve(blockElement); + const blockKey = (blockElement as HTMLElement).dataset.blockKey; + if (blockKey) { + removedKeys.add(blockKey); + } + } + } + } + if (removedKeys.size > 0) { + setVisibleBlockKeys(previousKeys => { + const nextKeys = new Set(previousKeys); + for (const key of removedKeys) { + nextKeys.delete(key); + } + return nextKeys.size === previousKeys.size ? previousKeys : nextKeys; + }); + } + }); + mutationObserver.observe(contentsElement, {childList: true, subtree: true}); + + return () => { + observer.disconnect(); + mutationObserver.disconnect(); + }; + }, [canObserve, contentsElement, enabled, scrollContainerRef]); return useMemo(() => { if (!enabled || !canObserve) { diff --git a/src/model/decorators/DraftDecoratorType.ts b/src/model/decorators/DraftDecoratorType.ts index b08615d94..e5c8343b4 100644 --- a/src/model/decorators/DraftDecoratorType.ts +++ b/src/model/decorators/DraftDecoratorType.ts @@ -13,11 +13,7 @@ import {BlockNode} from '../immutable/BlockNode'; import {ComponentType} from 'react'; import {DraftDecoratorComponentProps} from './DraftDecorator'; -export type DraftDecoratorSkeletonAttributes = Readonly< - Record ->; - -export type DraftDecoratorSkeletonRange = Readonly<{ +export type DraftDecoratorDOMAnchorRange = Readonly<{ block: BlockNode; contentState: ContentState; decoratorKey: string; @@ -50,13 +46,12 @@ export type DraftDecoratorType = { /** * Given a decorator key, optionally return the props to use when rendering * this decorated range. - */ + */ getPropsForKey: (key: string) => Record | null; /** - * Return the DOM attributes that must remain available when a decorated - * range is rendered as part of an offscreen block skeleton. + * Return stable DOM ids that Draft renders around this decorated range. */ - getSkeletonAttributesForRange?: ( - range: DraftDecoratorSkeletonRange, - ) => readonly DraftDecoratorSkeletonAttributes[] | undefined; + getDOMAnchorIdsForRange?: ( + range: DraftDecoratorDOMAnchorRange, + ) => readonly string[] | undefined; }; From 55ceb34c6c3262bf13bb91f5b642834e20142c7b Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Tue, 4 Aug 2026 22:26:56 -0700 Subject: [PATCH 07/16] fix(editor): paint promoted skeleton blocks immediately --- .../base/__tests__/DraftEditor.react-test.tsx | 12 ++++++++++++ .../contents/DraftEditorContents-core.react.tsx | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index f5e41259b..fcd33d3d1 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -111,6 +111,10 @@ test('promotes intersecting skeleton blocks to full rendering', () => { {}} + blockStyleFn={() => ({ + containIntrinsicSize: '1px 20px', + contentVisibility: 'auto', + })} blockSkeleton={{ enabled: true, scrollContainerRef: {current: container}, @@ -125,6 +129,10 @@ test('promotes intersecting skeleton blocks to full rendering', () => { const secondBlock = observedElements[1]; expect(secondBlock).toBeDefined(); + expect((secondBlock as HTMLElement).style.contentVisibility).toBe('auto'); + expect((secondBlock as HTMLElement).style.containIntrinsicSize).toBe( + '1px 20px', + ); flushSync(() => { observerCallback?.( [ @@ -138,6 +146,10 @@ test('promotes intersecting skeleton blocks to full rendering', () => { }); expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(1); + expect((secondBlock as HTMLElement).style.contentVisibility).toBe( + 'visible', + ); + expect((secondBlock as HTMLElement).style.containIntrinsicSize).toBe(''); expect(observerCount).toBe(1); } finally { globalThis.IntersectionObserver = originalIntersectionObserver; diff --git a/src/component/contents/DraftEditorContents-core.react.tsx b/src/component/contents/DraftEditorContents-core.react.tsx index 8fd7f8e2c..54d4c6dc2 100644 --- a/src/component/contents/DraftEditorContents-core.react.tsx +++ b/src/component/contents/DraftEditorContents-core.react.tsx @@ -316,6 +316,20 @@ export default class DraftEditorContents extends React.Component { } } + const shouldRenderSkeleton = + blockSkeleton && !blockSkeleton.fullBlockKeys.has(key); + if ( + blockSkeleton && + !shouldRenderSkeleton && + inlineStyle?.contentVisibility === 'auto' + ) { + inlineStyle = { + ...inlineStyle, + containIntrinsicSize: undefined, + contentVisibility: 'visible', + }; + } + // List items are special snowflakes, since we handle nesting and // counters manually. if (Element === 'li') { @@ -340,7 +354,7 @@ export default class DraftEditorContents extends React.Component { style: inlineStyle, }; let child: ReactNode; - if (blockSkeleton && !blockSkeleton.fullBlockKeys.has(key)) { + if (shouldRenderSkeleton) { childProps = { ...childProps, 'data-block-skeleton': true, From 17f403aa9654088be406808892f98c0eeba4ef70 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Thu, 6 Aug 2026 15:10:26 -0700 Subject: [PATCH 08/16] pnpm --- package.json | 285 +++++++++++++++++++++++++-------------------------- 1 file changed, 140 insertions(+), 145 deletions(-) diff --git a/package.json b/package.json index db6741ed6..974263677 100644 --- a/package.json +++ b/package.json @@ -1,152 +1,147 @@ { - "name": "@descript/draft-js", - "description": "A React framework for building text editors.", - "version": "0.11.6-descript.36", - "keywords": [ - "draftjs", - "editor", - "react", - "richtext" - ], - "homepage": "http://draftjs.org/", - "bugs": "https://github.com/facebook/draft-js/issues", - "files": [ - "dist/", - "lib/", - "LICENSE" - ], - "main": "./lib/cjs/Draft.js", - "module": "./lib/esm/Draft.js", - "types": "./lib/types/Draft.d.ts", - "style": "./dist/Draft.css", - "exports": { - ".": { - "types": "./lib/types/Draft.d.ts", - "import": "./lib/esm/Draft.js", - "require": "./lib/cjs/Draft.js" + "name": "@descript/draft-js", + "description": "A React framework for building text editors.", + "version": "0.11.6-descript.36", + "keywords": [ + "draftjs", + "editor", + "react", + "richtext" + ], + "homepage": "http://draftjs.org/", + "bugs": "https://github.com/facebook/draft-js/issues", + "files": [ + "dist/", + "lib/", + "LICENSE" + ], + "main": "./lib/cjs/Draft.js", + "module": "./lib/esm/Draft.js", + "types": "./lib/types/Draft.d.ts", + "style": "./dist/Draft.css", + "exports": { + ".": { + "types": "./lib/types/Draft.d.ts", + "import": "./lib/esm/Draft.js", + "require": "./lib/cjs/Draft.js" + }, + "./dist/Draft.css": "./dist/Draft.css" }, - "./dist/Draft.css": "./dist/Draft.css" - }, - "repository": "facebook/draft-js", - "license": "MIT", - "scripts": { - "prepublish": "npm run build", - "pretest": "node node_modules/fbjs-scripts/node/check-dev-engines.js package.json", - "build": "npm run clean && npm run build:lib && npm run build:umd && npm run build:css", - "build:lib": "vite build", - "build:umd": "vite build --config vite.config.umd.ts", - "build:css": "vite build --config vite.config.css.ts && rm -f dist/dummy.js", - "build:watch": "vite build --watch", - "clean": "rm -rf lib dist", - "dev": "vite build --watch", - "typecheck": "tsc --noEmit", - "examples": "pnpm --filter @descript/draft-js-examples dev", - "examples:build": "pnpm --filter @descript/draft-js-examples build", - "examples:preview": "pnpm --filter @descript/draft-js-examples preview", - "lint": "eslint src/.", - "lint-docs": "alex . && pnpm format-docs:diff", - "format": "eslint . --fix", - "format-docs": "prettier --config prettier.config.js --write \"docs/**/*.md\"", - "format-docs:diff": "prettier --config prettier.config.js --list-different \"docs/**/*.md\"", - "test": "cross-env NODE_ENV=test jest", - "test-ci": "cross-env NODE_ENV=test npm run lint && npm run test" - }, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fbjs": "^1.0.0", - "memoize-one": "^5.1.1", - "object-assign": "^4.1.1" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true + "repository": "facebook/draft-js", + "license": "MIT", + "scripts": { + "prepublish": "pnpm run build", + "build": "pnpm run clean && pnpm run build:lib && pnpm run build:umd && pnpm run build:css", + "build:lib": "vite build", + "build:umd": "vite build --config vite.config.umd.ts", + "build:css": "vite build --config vite.config.css.ts && rm -f dist/dummy.js", + "build:watch": "vite build --watch", + "clean": "rm -rf lib dist", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit", + "examples": "pnpm --filter @descript/draft-js-examples dev", + "examples:build": "pnpm --filter @descript/draft-js-examples build", + "examples:preview": "pnpm --filter @descript/draft-js-examples preview", + "lint": "eslint src/.", + "lint-docs": "alex . && pnpm format-docs:diff", + "format": "eslint . --fix", + "format-docs": "prettier --config prettier.config.js --write \"docs/**/*.md\"", + "format-docs:diff": "prettier --config prettier.config.js --list-different \"docs/**/*.md\"", + "test": "cross-env NODE_ENV=test jest", + "test-ci": "cross-env NODE_ENV=test pnpm run lint && pnpm run test" }, - "@types/react-dom": { - "optional": true - } - }, - "devDependencies": { - "@babel/core": "^7.6.4", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4", - "@babel/plugin-proposal-optional-chaining": "^7.9.0", - "@types/jest": "^29.5.4", - "@types/react": "^18.3.24", - "@types/react-dom": "^18.3.7", - "@typescript-eslint/eslint-plugin": "^8.44.1", - "@typescript-eslint/parser": "^8.44.1", - "@vitejs/plugin-react": "^4.3.3", - "alex": "^8.0.0", - "babel-eslint": "^10.1.0", - "babel-preset-fbjs": "^3.3.0", - "cross-env": "^7.0.2", - "es6-shim": "^0.35.5", - "eslint": "^8.49.0", - "eslint-config-fbjs": "^4.0.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-babel": "^5.3.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "fbjs-scripts": "^1.2.0", - "jest": "^29.6.4", - "jest-environment-jsdom": "^29.6.4", - "postcss": "^8.4.49", - "prettier": "1.19.1", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-test-renderer": "^18.3.1", - "rollup-plugin-visualizer": "^5.12.0", - "ts-jest": "^29.1.1", - "typescript": "^5.9.2", - "vite": "^5.4.11", - "vite-plugin-dts": "^4.3.0" - }, - "devEngines": { - "node": "14.x || 16.x || 18.x || 20.x", - "npm": "2.x || 3.x || 5.x || 6.x || 7.x || 8.x || 9.x || 10.x" - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "jsdom", - "globals": { - "__DEV__": true, - "ts-jest": { - "diagnostics": { - "warnOnly": true + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fbjs": "^1.0.0", + "memoize-one": "^5.1.1", + "object-assign": "^4.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true } - } }, - "rootDir": "./", - "roots": [ - "/src/" - ], - "setupFiles": [ - "/scripts/jest/shims.js" - ], - "setupFilesAfterEnv": [ - "/scripts/jest/setupAfterEnv.ts" - ], - "haste": { - "hasteImplModulePath": "/scripts/jest/hasteImpl.js" + "devDependencies": { + "@babel/core": "^7.6.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.4.4", + "@babel/plugin-proposal-optional-chaining": "^7.9.0", + "@types/jest": "^29.5.4", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^8.44.1", + "@typescript-eslint/parser": "^8.44.1", + "@vitejs/plugin-react": "^4.3.3", + "alex": "^8.0.0", + "babel-eslint": "^10.1.0", + "babel-preset-fbjs": "^3.3.0", + "cross-env": "^7.0.2", + "es6-shim": "^0.35.5", + "eslint": "^8.49.0", + "eslint-config-fbjs": "^4.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-babel": "^5.3.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "fbjs-scripts": "^1.2.0", + "jest": "^29.6.4", + "jest-environment-jsdom": "^29.6.4", + "postcss": "^8.4.49", + "prettier": "1.19.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-test-renderer": "^18.3.1", + "rollup-plugin-visualizer": "^5.12.0", + "ts-jest": "^29.1.1", + "typescript": "^5.9.2", + "vite": "^5.4.11", + "vite-plugin-dts": "^4.3.0" }, - "modulePathIgnorePatterns": [ - "/lib/", - "/node_modules/" - ], - "transformIgnorePatterns": [ - "/node_modules/" - ], - "unmockedModulePathPatterns": [ - "/node_modules/fbjs/node_modules/", - "/node_modules/fbjs/lib/UserAgent.js", - "/node_modules/fbjs/lib/UserAgentData.js", - "/node_modules/fbjs-scripts/", - "/node_modules/object-assign/", - "/node_modules/react/", - "/node_modules/react-dom/" - ] - } + "jest": { + "preset": "ts-jest", + "testEnvironment": "jsdom", + "globals": { + "__DEV__": true, + "ts-jest": { + "diagnostics": { + "warnOnly": true + } + } + }, + "rootDir": "./", + "roots": [ + "/src/" + ], + "setupFiles": [ + "/scripts/jest/shims.js" + ], + "setupFilesAfterEnv": [ + "/scripts/jest/setupAfterEnv.ts" + ], + "haste": { + "hasteImplModulePath": "/scripts/jest/hasteImpl.js" + }, + "modulePathIgnorePatterns": [ + "/lib/", + "/node_modules/" + ], + "transformIgnorePatterns": [ + "/node_modules/" + ], + "unmockedModulePathPatterns": [ + "/node_modules/fbjs/node_modules/", + "/node_modules/fbjs/lib/UserAgent.js", + "/node_modules/fbjs/lib/UserAgentData.js", + "/node_modules/fbjs-scripts/", + "/node_modules/object-assign/", + "/node_modules/react/", + "/node_modules/react-dom/" + ] + } } From daefd86451d5a994c56637ea1ede2af68feed126 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Thu, 6 Aug 2026 19:39:07 -0700 Subject: [PATCH 09/16] perf(editor): promote visible skeleton blocks during scroll --- .../base/__tests__/DraftEditor.react-test.tsx | 53 +++++++++++ .../hooks/useDraftEditorBlockSkeleton.ts | 91 +++++++++++++++++-- 2 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index fcd33d3d1..643503a8e 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -156,6 +156,59 @@ test('promotes intersecting skeleton blocks to full rendering', () => { } }); +test('promotes viewport skeleton blocks synchronously while scrolling', () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '500px 0px'; + readonly thresholds = [0]; + + disconnect(): void {} + observe(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve(): void {} + } + + globalThis.IntersectionObserver = MockIntersectionObserver; + try { + editorState = createWithContent(createFromText('zero\none\ntwo')); + flushSync(() => { + root.render( + {}} + blockSkeleton={{ + enabled: true, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + + container.getBoundingClientRect = () => + ({top: 0, bottom: 100} as DOMRect); + const blocks = Array.from( + container.querySelectorAll('[data-block-key]'), + ); + blocks[0]!.getBoundingClientRect = () => + ({top: -40, bottom: -20} as DOMRect); + blocks[1]!.getBoundingClientRect = () => + ({top: 20, bottom: 40} as DOMRect); + blocks[2]!.getBoundingClientRect = () => + ({top: 120, bottom: 140} as DOMRect); + + container.dispatchEvent(new Event('scroll')); + + expect(blocks[1]!.hasAttribute('data-block-skeleton')).toBe(false); + expect(blocks[2]!.hasAttribute('data-block-skeleton')).toBe(true); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + } +}); + test('renders newly introduced block keys as skeletons immediately', () => { const originalIntersectionObserver = globalThis.IntersectionObserver; const originalMutationObserver = globalThis.MutationObserver; diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index dcd9618c2..5c83c51b9 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -1,4 +1,5 @@ import {RefObject, useLayoutEffect, useMemo, useState} from 'react'; +import * as ReactDOM from 'react-dom'; import {EditorState} from '../../model/immutable/EditorState'; export type DraftEditorBlockSkeletonState = Readonly<{ @@ -22,6 +23,56 @@ function supportsNativeScrollAnchoring(): boolean { ); } +function getBlockElements(node: Node): HTMLElement[] { + if (!(node instanceof Element)) { + return []; + } + const elements = Array.from( + node.querySelectorAll('[data-block-key]'), + ); + if (node instanceof HTMLElement && node.matches('[data-block-key]')) { + elements.unshift(node); + } + return elements; +} + +function getViewportSkeletonBlockKeys( + blockElements: readonly HTMLElement[], + scrollContainer: HTMLElement, +): Set { + const viewport = scrollContainer.getBoundingClientRect(); + let lowerBound = 0; + let upperBound = blockElements.length; + while (lowerBound < upperBound) { + const midpoint = Math.floor((lowerBound + upperBound) / 2); + const block = blockElements[midpoint]; + if (block && block.getBoundingClientRect().bottom <= viewport.top) { + lowerBound = midpoint + 1; + } else { + upperBound = midpoint; + } + } + + const blockKeys = new Set(); + for (let index = lowerBound; index < blockElements.length; index += 1) { + const block = blockElements[index]; + if (!block) { + continue; + } + const bounds = block.getBoundingClientRect(); + if (bounds.top >= viewport.bottom) { + break; + } + const blockKey = block.hasAttribute('data-block-skeleton') + ? block.dataset.blockKey + : undefined; + if (blockKey) { + blockKeys.add(blockKey); + } + } + return blockKeys; +} + export function useDraftEditorBlockSkeleton({ enabled, editorState, @@ -71,19 +122,37 @@ export function useDraftEditorBlockSkeleton({ }; const observer = new IntersectionObserver(handleEntries, observerOptions); - const getBlockElements = (node: Node): Element[] => { - if (!(node instanceof Element)) { - return []; + let blockElements = getBlockElements(contentsElement); + for (const blockElement of blockElements) { + observer.observe(blockElement); + } + + const scrollContainer = scrollContainerRef?.current ?? contentsElement; + const handleScroll = () => { + const blockKeys = getViewportSkeletonBlockKeys( + blockElements, + scrollContainer, + ); + if (blockKeys.size === 0) { + return; } - const elements = Array.from(node.querySelectorAll('[data-block-key]')); - if (node.matches('[data-block-key]')) { - elements.unshift(node); + const updateVisibleBlockKeys = () => { + setVisibleBlockKeys(previousKeys => { + if ([...blockKeys].every(blockKey => previousKeys.has(blockKey))) { + return previousKeys; + } + return new Set([...previousKeys, ...blockKeys]); + }); + }; + const flush = + 'flushSync' in ReactDOM ? ReactDOM.flushSync : undefined; + if (flush) { + flush(updateVisibleBlockKeys); + } else { + updateVisibleBlockKeys(); } - return elements; }; - for (const blockElement of getBlockElements(contentsElement)) { - observer.observe(blockElement); - } + scrollContainer.addEventListener('scroll', handleScroll, {passive: true}); const mutationObserver = new MutationObserver(mutations => { const removedKeys = new Set(); @@ -112,10 +181,12 @@ export function useDraftEditorBlockSkeleton({ return nextKeys.size === previousKeys.size ? previousKeys : nextKeys; }); } + blockElements = getBlockElements(contentsElement); }); mutationObserver.observe(contentsElement, {childList: true, subtree: true}); return () => { + scrollContainer.removeEventListener('scroll', handleScroll); observer.disconnect(); mutationObserver.disconnect(); }; From 519a0feac15b9105ad30a8bf88a6d8fc9788ba73 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 7 Aug 2026 11:24:52 -0700 Subject: [PATCH 10/16] update version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 974263677..089d715dd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@descript/draft-js", "description": "A React framework for building text editors.", - "version": "0.11.6-descript.36", + "version": "0.11.6-descript.37", "keywords": [ "draftjs", "editor", From 53f9acc5f98623a8f34c3c3e7336281c716d27c8 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 7 Aug 2026 13:14:19 -0700 Subject: [PATCH 11/16] fix(skeletons): retain visibility for moved block nodes Do not demote a visible block when the same block key is removed and added in one DOM mutation batch. This prevents an unnecessary full-to-skeleton transition during keyed DOM moves. --- .../base/__tests__/DraftEditor.react-test.tsx | 89 +++++++++++++++++++ .../hooks/useDraftEditorBlockSkeleton.ts | 9 +- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index 643503a8e..3c944c92c 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -298,6 +298,95 @@ test('renders newly introduced block keys as skeletons immediately', () => { } }); +test('keeps a promoted block full when its DOM node is moved', () => { + const originalIntersectionObserver = globalThis.IntersectionObserver; + const originalMutationObserver = globalThis.MutationObserver; + let intersectionCallback: IntersectionObserverCallback | undefined; + let mutationCallback: MutationCallback | undefined; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '500px 0px'; + readonly thresholds = [0]; + + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + + disconnect(): void {} + observe(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve(): void {} + } + + class MockMutationObserver implements MutationObserver { + constructor(callback: MutationCallback) { + mutationCallback = callback; + } + + disconnect(): void {} + observe(): void {} + takeRecords(): MutationRecord[] { + return []; + } + } + + globalThis.IntersectionObserver = MockIntersectionObserver; + globalThis.MutationObserver = MockMutationObserver; + try { + editorState = createWithContent(createFromText('zero\none\ntwo')); + flushSync(() => { + root.render( + {}} + blockSkeleton={{ + enabled: true, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + + const secondBlock = container.querySelectorAll('[data-block-key]')[1]!; + flushSync(() => { + intersectionCallback?.( + [ + { + isIntersecting: true, + target: secondBlock, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + expect(secondBlock.hasAttribute('data-block-skeleton')).toBe(false); + + flushSync(() => { + mutationCallback?.( + [ + ({ + addedNodes: [secondBlock], + removedNodes: [secondBlock], + } as unknown) as MutationRecord, + ], + {} as MutationObserver, + ); + }); + + expect( + container.querySelectorAll('[data-block-key]')[1]!.hasAttribute( + 'data-block-skeleton', + ), + ).toBe(false); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + globalThis.MutationObserver = originalMutationObserver; + } +}); + test('renders full blocks when native scroll anchoring is unsupported', () => { const originalCSS = globalThis.CSS; const originalIntersectionObserver = globalThis.IntersectionObserver; diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index 5c83c51b9..476c4f99b 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -155,11 +155,16 @@ export function useDraftEditorBlockSkeleton({ scrollContainer.addEventListener('scroll', handleScroll, {passive: true}); const mutationObserver = new MutationObserver(mutations => { + const addedKeys = new Set(); const removedKeys = new Set(); for (const mutation of mutations) { for (const node of mutation.addedNodes) { for (const blockElement of getBlockElements(node)) { observer.observe(blockElement); + const blockKey = blockElement.dataset.blockKey; + if (blockKey) { + addedKeys.add(blockKey); + } } } for (const node of mutation.removedNodes) { @@ -176,7 +181,9 @@ export function useDraftEditorBlockSkeleton({ setVisibleBlockKeys(previousKeys => { const nextKeys = new Set(previousKeys); for (const key of removedKeys) { - nextKeys.delete(key); + if (!addedKeys.has(key)) { + nextKeys.delete(key); + } } return nextKeys.size === previousKeys.size ? previousKeys : nextKeys; }); From 74b60b251598cfd3fd2c667116fad5385382e7b4 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Mon, 10 Aug 2026 10:25:58 -0700 Subject: [PATCH 12/16] update lockfile --- pnpm-workspace.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cc00d0d27..1f8b10926 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ packages: - '.' - - 'examples' \ No newline at end of file + - 'examples' +allowBuilds: + core-js: set this to true or false + esbuild: set this to true or false From b1e85f676bd973561017903e647fb9c3c8e81b0b Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Mon, 10 Aug 2026 11:34:27 -0700 Subject: [PATCH 13/16] refactor(editor): separate skeleton render notifications --- package.json | 2 +- src/component/base/DraftEditorProps.ts | 1 + .../base/__tests__/DraftEditor.react-test.tsx | 20 ++++++++----------- .../DraftEditorContents-core.react.tsx | 11 ---------- .../DraftEditorSkeletonContents.react.tsx | 1 + .../hooks/useDraftEditorBlockSkeleton.ts | 12 ++++++++++- 6 files changed, 22 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 089d715dd..501df97b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@descript/draft-js", "description": "A React framework for building text editors.", - "version": "0.11.6-descript.37", + "version": "0.11.6-descript.38", "keywords": [ "draftjs", "editor", diff --git a/src/component/base/DraftEditorProps.ts b/src/component/base/DraftEditorProps.ts index f42db7302..d8b67695a 100644 --- a/src/component/base/DraftEditorProps.ts +++ b/src/component/base/DraftEditorProps.ts @@ -29,6 +29,7 @@ import {BlockNode} from '../../model/immutable/BlockNode'; export type DraftEditorBlockSkeletonOptions = Readonly<{ enabled: boolean; scrollContainerRef: RefObject; + onBlockSkeletonsRendered?: () => void; }>; export type DraftEditorProps = { diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index 3c944c92c..314998536 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -82,6 +82,7 @@ test('promotes intersecting skeleton blocks to full rendering', () => { let observerCallback: IntersectionObserverCallback | undefined; let observerCount = 0; const observedElements: Element[] = []; + const renderedSkeletonCounts: number[] = []; class MockIntersectionObserver implements IntersectionObserver { readonly root = container; @@ -111,12 +112,13 @@ test('promotes intersecting skeleton blocks to full rendering', () => { {}} - blockStyleFn={() => ({ - containIntrinsicSize: '1px 20px', - contentVisibility: 'auto', - })} blockSkeleton={{ enabled: true, + onBlockSkeletonsRendered: () => { + renderedSkeletonCounts.push( + container.querySelectorAll('[data-block-skeleton]').length, + ); + }, scrollContainerRef: {current: container}, }} />, @@ -126,13 +128,10 @@ test('promotes intersecting skeleton blocks to full rendering', () => { expect(observedElements).toHaveLength(3); expect(observerCount).toBe(1); expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(2); + expect(renderedSkeletonCounts).toEqual([2]); const secondBlock = observedElements[1]; expect(secondBlock).toBeDefined(); - expect((secondBlock as HTMLElement).style.contentVisibility).toBe('auto'); - expect((secondBlock as HTMLElement).style.containIntrinsicSize).toBe( - '1px 20px', - ); flushSync(() => { observerCallback?.( [ @@ -146,10 +145,7 @@ test('promotes intersecting skeleton blocks to full rendering', () => { }); expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(1); - expect((secondBlock as HTMLElement).style.contentVisibility).toBe( - 'visible', - ); - expect((secondBlock as HTMLElement).style.containIntrinsicSize).toBe(''); + expect(renderedSkeletonCounts).toEqual([2, 1]); expect(observerCount).toBe(1); } finally { globalThis.IntersectionObserver = originalIntersectionObserver; diff --git a/src/component/contents/DraftEditorContents-core.react.tsx b/src/component/contents/DraftEditorContents-core.react.tsx index 54d4c6dc2..7394da928 100644 --- a/src/component/contents/DraftEditorContents-core.react.tsx +++ b/src/component/contents/DraftEditorContents-core.react.tsx @@ -318,17 +318,6 @@ export default class DraftEditorContents extends React.Component { const shouldRenderSkeleton = blockSkeleton && !blockSkeleton.fullBlockKeys.has(key); - if ( - blockSkeleton && - !shouldRenderSkeleton && - inlineStyle?.contentVisibility === 'auto' - ) { - inlineStyle = { - ...inlineStyle, - containIntrinsicSize: undefined, - contentVisibility: 'visible', - }; - } // List items are special snowflakes, since we handle nesting and // counters manually. diff --git a/src/component/contents/DraftEditorSkeletonContents.react.tsx b/src/component/contents/DraftEditorSkeletonContents.react.tsx index 3c17b83ff..b662602e3 100644 --- a/src/component/contents/DraftEditorSkeletonContents.react.tsx +++ b/src/component/contents/DraftEditorSkeletonContents.react.tsx @@ -21,6 +21,7 @@ export default function DraftEditorSkeletonContents({ enabled: blockSkeleton?.enabled ?? false, editorState: contentsProps.editorState, contentsElement, + onBlockSkeletonsRendered: blockSkeleton?.onBlockSkeletonsRendered, scrollContainerRef: blockSkeleton?.scrollContainerRef, }); diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index 476c4f99b..b3d03db64 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -10,6 +10,7 @@ type Options = Readonly<{ enabled: boolean; editorState: EditorState; contentsElement: HTMLElement | null; + onBlockSkeletonsRendered?: () => void; scrollContainerRef?: RefObject; }>; @@ -77,6 +78,7 @@ export function useDraftEditorBlockSkeleton({ enabled, editorState, contentsElement, + onBlockSkeletonsRendered, scrollContainerRef, }: Options): DraftEditorBlockSkeletonState | undefined { const [visibleBlockKeys, setVisibleBlockKeys] = useState>( @@ -199,7 +201,7 @@ export function useDraftEditorBlockSkeleton({ }; }, [canObserve, contentsElement, enabled, scrollContainerRef]); - return useMemo(() => { + const skeletonState = useMemo(() => { if (!enabled || !canObserve) { return undefined; } @@ -215,4 +217,12 @@ export function useDraftEditorBlockSkeleton({ enabled, visibleBlockKeys, ]); + + useLayoutEffect(() => { + if (skeletonState) { + onBlockSkeletonsRendered?.(); + } + }, [onBlockSkeletonsRendered, skeletonState]); + + return skeletonState; } From 09fef54ed431af5e3a443430310891009cf31994 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Wed, 12 Aug 2026 11:25:12 -0700 Subject: [PATCH 14/16] feat(skeletons): expose browser capability --- package.json | 2 +- src/Draft.ts | 1 + .../base/__tests__/DraftEditor.react-test.tsx | 2 ++ .../hooks/useDraftEditorBlockSkeleton.ts | 15 +++++++-------- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 501df97b7..b9564fced 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@descript/draft-js", "description": "A React framework for building text editors.", - "version": "0.11.6-descript.38", + "version": "0.11.6-descript.39", "keywords": [ "draftjs", "editor", diff --git a/src/Draft.ts b/src/Draft.ts index 5b1ddde63..074f63c3f 100644 --- a/src/Draft.ts +++ b/src/Draft.ts @@ -14,6 +14,7 @@ export type { DraftEditorBlockSkeletonOptions, DraftEditorProps, } from './component/base/DraftEditorProps'; +export {supportsBlockSkeletonRendering} from './component/hooks/useDraftEditorBlockSkeleton'; export {CompositeDecorator} from './model/decorators/CompositeDraftDecorator'; import DraftEntity from './model/entity/DraftEntity'; import AtomicBlockUtils from './model/modifier/AtomicBlockUtils'; diff --git a/src/component/base/__tests__/DraftEditor.react-test.tsx b/src/component/base/__tests__/DraftEditor.react-test.tsx index 314998536..3d6f94c01 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -18,6 +18,7 @@ import DraftEditor from '../DraftEditor.react'; import {createRoot, Root} from 'react-dom/client'; import {flushSync} from 'react-dom'; import {createFromText} from '../../../model/immutable/ContentState'; +import {supportsBlockSkeletonRendering} from '../../hooks/useDraftEditorBlockSkeleton'; let container: HTMLElement; let root: Root; @@ -406,6 +407,7 @@ test('renders full blocks when native scroll anchoring is unsupported', () => { value: {supports: () => false}, }); try { + expect(supportsBlockSkeletonRendering()).toBe(false); editorState = createWithContent(createFromText('zero\none\ntwo')); flushSync(() => { root.render( diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts index b3d03db64..8f5946abf 100644 --- a/src/component/hooks/useDraftEditorBlockSkeleton.ts +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -16,11 +16,13 @@ type Options = Readonly<{ const OBSERVER_MARGIN = '500px 0px'; -function supportsNativeScrollAnchoring(): boolean { +export function supportsBlockSkeletonRendering(): boolean { return ( - typeof CSS === 'undefined' || - typeof CSS.supports !== 'function' || - CSS.supports('overflow-anchor: auto') + typeof window !== 'undefined' && + typeof IntersectionObserver !== 'undefined' && + (typeof CSS === 'undefined' || + typeof CSS.supports !== 'function' || + CSS.supports('overflow-anchor: auto')) ); } @@ -84,10 +86,7 @@ export function useDraftEditorBlockSkeleton({ const [visibleBlockKeys, setVisibleBlockKeys] = useState>( new Set(), ); - const canObserve = - typeof window !== 'undefined' && - typeof IntersectionObserver !== 'undefined' && - supportsNativeScrollAnchoring(); + const canObserve = supportsBlockSkeletonRendering(); useLayoutEffect(() => { if (!enabled || !canObserve) { From 62fea64dbd5398312f6cfb06151a95de9f384578 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 28 Aug 2026 10:03:14 -0700 Subject: [PATCH 15/16] fix(selection): normalize skeleton DOM offsets Persistent marker and card anchors split skeleton block text into multiple DOM nodes. Convert selection endpoints inside skeletons to cumulative block offsets so native selections, including Select All, cover the complete Draft content. --- package.json | 2 +- .../__tests__/getDraftEditorSelection-test.ts | 157 +++++++++++++++++- .../getDraftEditorSelectionWithNodes.ts | 78 ++++++--- 3 files changed, 214 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index b9564fced..f26555e06 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@descript/draft-js", "description": "A React framework for building text editors.", - "version": "0.11.6-descript.39", + "version": "0.11.6-descript.40", "keywords": [ "draftjs", "editor", diff --git a/src/component/selection/__tests__/getDraftEditorSelection-test.ts b/src/component/selection/__tests__/getDraftEditorSelection-test.ts index 587d512b8..d43b2fde0 100644 --- a/src/component/selection/__tests__/getDraftEditorSelection-test.ts +++ b/src/component/selection/__tests__/getDraftEditorSelection-test.ts @@ -10,7 +10,11 @@ import getSampleSelectionMocksForTesting from '../getSampleSelectionMocksForTesting'; import getDraftEditorSelection from '../getDraftEditorSelection'; -import {EditorState} from '../../../model/immutable/EditorState'; +import { + createWithContent, + EditorState, +} from '../../../model/immutable/EditorState'; +import {createFromText} from '../../../model/immutable/ContentState'; let editorState: EditorState; let root: HTMLDivElement; @@ -42,6 +46,34 @@ const assertGetDraftEditorSelection = (getSelectionReturnValue: any) => { }).toMatchSnapshot(); }; +const getSelectionState = (getSelectionReturnValue: any) => { + (document as any).selection = null; + window.getSelection = jest.fn().mockReturnValueOnce(getSelectionReturnValue); + return getDraftEditorSelection(editorState, root).selectionState; +}; + +const replaceLastBlockWithSkeleton = () => { + const skeleton = document.createElement('div'); + skeleton.setAttribute('data-block', 'true'); + skeleton.setAttribute('data-block-skeleton', 'true'); + skeleton.setAttribute('data-offset-key', 'c-0-0'); + + const leadingText = document.createTextNode('Kennedy'); + const marker = document.createElement('span'); + marker.id = 'marker'; + const card = document.createElement('span'); + card.id = 'card'; + const anchoredText = document.createTextNode('Oba'); + const trailingText = document.createTextNode('ma'); + + card.appendChild(anchoredText); + marker.appendChild(card); + skeleton.append(leadingText, marker, trailingText); + contents.replaceChild(skeleton, blocks[2]); + + return {anchoredText, skeleton, trailingText}; +}; + beforeEach(() => { resetRootNodeMocks(); }); @@ -543,6 +575,129 @@ test('is reversed from above', () => { }); }); +test('maps a text endpoint after skeleton DOM anchors to its block offset', () => { + const {trailingText} = replaceLastBlockWithSkeleton(); + const selection = getSelectionState({ + rangeCount: 1, + anchorNode: textNodes[0], + anchorOffset: 0, + focusNode: trailingText, + focusOffset: trailingText.length, + }); + + expect(selection).toMatchObject({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'c', + focusOffset: 'KennedyObama'.length, + isBackward: false, + }); +}); + +test('maps a skeleton element endpoint after DOM anchors to its block offset', () => { + const {skeleton} = replaceLastBlockWithSkeleton(); + const selection = getSelectionState({ + rangeCount: 1, + anchorNode: textNodes[0], + anchorOffset: 0, + focusNode: skeleton, + focusOffset: skeleton.childNodes.length, + }); + + expect(selection).toMatchObject({ + anchorKey: 'a', + anchorOffset: 0, + focusKey: 'c', + focusOffset: 'KennedyObama'.length, + isBackward: false, + }); +}); + +test('maps nested skeleton DOM anchor text to its block offset', () => { + const {anchoredText} = replaceLastBlockWithSkeleton(); + const selection = getSelectionState({ + rangeCount: 1, + anchorNode: textNodes[0], + anchorOffset: 0, + focusNode: anchoredText, + focusOffset: 2, + }); + + expect(selection).toMatchObject({ + focusKey: 'c', + focusOffset: 'KennedyOb'.length, + }); +}); + +test('maps a backward selection from a skeleton endpoint', () => { + const {skeleton} = replaceLastBlockWithSkeleton(); + const selection = getSelectionState({ + rangeCount: 1, + anchorNode: skeleton, + anchorOffset: skeleton.childNodes.length, + focusNode: textNodes[0], + focusOffset: 0, + }); + + expect(selection).toMatchObject({ + anchorKey: 'c', + anchorOffset: 'KennedyObama'.length, + focusKey: 'a', + focusOffset: 0, + isBackward: true, + }); +}); + +test('maps an unsplit skeleton text endpoint normally', () => { + const {skeleton} = replaceLastBlockWithSkeleton(); + const text = document.createTextNode('KennedyObama'); + skeleton.replaceChildren(text); + const selection = getSelectionState({ + rangeCount: 1, + anchorNode: text, + anchorOffset: text.length, + focusNode: text, + focusOffset: text.length, + }); + + expect(selection).toMatchObject({ + anchorKey: 'c', + anchorOffset: text.length, + focusKey: 'c', + focusOffset: text.length, + }); +}); + +test('maps an empty skeleton endpoint to the start of its block', () => { + editorState = createWithContent(createFromText('')); + const blockKey = [...editorState.currentContent.blockMap.keys()][0]!; + root = document.createElement('div'); + contents = document.createElement('div'); + contents.setAttribute('data-contents', 'true'); + root.appendChild(contents); + const skeleton = document.createElement('div'); + skeleton.setAttribute('data-block', 'true'); + skeleton.setAttribute('data-block-skeleton', 'true'); + skeleton.setAttribute('data-offset-key', `${blockKey}-0-0`); + skeleton.appendChild(document.createElement('br')); + contents.appendChild(skeleton); + + const selection = getSelectionState({ + rangeCount: 1, + anchorNode: skeleton, + anchorOffset: skeleton.childNodes.length, + focusNode: skeleton, + focusOffset: skeleton.childNodes.length, + }); + + expect(selection).toMatchObject({ + anchorKey: blockKey, + anchorOffset: 0, + focusKey: blockKey, + focusOffset: 0, + }); +}); + /** * A selection possibility that defies logic. In IE11, triple clicking a * block leads to the text node being selected as the anchor, and the diff --git a/src/component/selection/getDraftEditorSelectionWithNodes.ts b/src/component/selection/getDraftEditorSelectionWithNodes.ts index 03c073135..01357fec7 100644 --- a/src/component/selection/getDraftEditorSelectionWithNodes.ts +++ b/src/component/selection/getDraftEditorSelectionWithNodes.ts @@ -14,12 +14,60 @@ import isElement from '../utils/isElement'; import getSelectionOffsetKeyForNode from './getSelectionOffsetKeyForNode'; import invariant from '../../fbjs/invariant'; import findAncestorOffsetKey from './findAncestorOffsetKey'; +import getCorrectDocumentFromNode from '../utils/getCorrectDocumentFromNode'; type SelectionPoint = { key: string; offset: number; }; +function getSkeletonSelectionPoint( + node: Node, + offset: number, +): SelectionPoint | null { + let searchNode: Node | null = node; + while (searchNode) { + if (isElement(searchNode)) { + const skeleton = searchNode as Element; + if (skeleton.getAttribute('data-block-skeleton') !== 'true') { + searchNode = searchNode.parentNode; + continue; + } + const key = skeleton.getAttribute('data-offset-key'); + if (key == null) { + return null; + } + + const range = getCorrectDocumentFromNode(skeleton).createRange(); + range.setStart(skeleton, 0); + range.setEnd(node, offset); + const blockOffset = range.toString().length; + range.detach(); + return {key, offset: blockOffset}; + } + searchNode = searchNode.parentNode; + } + return null; +} + +function getSelectionPoint( + editorRoot: HTMLElement | null, + node: Node, + offset: number, +): SelectionPoint { + const skeletonPoint = getSkeletonSelectionPoint(node, offset); + if (skeletonPoint) { + return skeletonPoint; + } + if (node.nodeType === Node.TEXT_NODE) { + return { + key: nullthrows(findAncestorOffsetKey(node)), + offset, + }; + } + return getPointForNonTextNode(editorRoot, node, offset); +} + /** * Convert the current selection range to an anchor/focus pair of offset keys * and values that can be interpreted by components. @@ -39,20 +87,20 @@ export default function getDraftEditorSelectionWithNodes( // Find the nearest offset-aware elements and use the // offset values supplied by the selection range. if (anchorIsTextNode && focusIsTextNode) { + const anchorPoint = getSelectionPoint(root, anchorNode, anchorOffset); + const focusPoint = getSelectionPoint(root, focusNode, focusOffset); return { selectionState: getUpdatedSelectionState( editorState, - nullthrows(findAncestorOffsetKey(anchorNode)), - anchorOffset, - nullthrows(findAncestorOffsetKey(focusNode)), - focusOffset, + anchorPoint.key, + anchorPoint.offset, + focusPoint.key, + focusPoint.offset, ), needsRecovery: false, }; } - let anchorPoint: SelectionPoint | null = null; - let focusPoint: SelectionPoint | null = null; let needsRecovery = true; // An element is selected. Convert this selection range into leaf offset @@ -73,22 +121,10 @@ export default function getDraftEditorSelectionWithNodes( // for manually setting the selection state on the rendered document to // ensure proper selection state maintenance. - if (anchorIsTextNode) { - anchorPoint = { - key: nullthrows(findAncestorOffsetKey(anchorNode)), - offset: anchorOffset, - }; - focusPoint = getPointForNonTextNode(root, focusNode, focusOffset); - } else if (focusIsTextNode) { - focusPoint = { - key: nullthrows(findAncestorOffsetKey(focusNode)), - offset: focusOffset, - }; - anchorPoint = getPointForNonTextNode(root, anchorNode, anchorOffset); - } else { - anchorPoint = getPointForNonTextNode(root, anchorNode, anchorOffset); - focusPoint = getPointForNonTextNode(root, focusNode, focusOffset); + const anchorPoint = getSelectionPoint(root, anchorNode, anchorOffset); + const focusPoint = getSelectionPoint(root, focusNode, focusOffset); + if (!anchorIsTextNode && !focusIsTextNode) { // If the selection is collapsed on an empty block, don't force recovery. // This way, on arrow key selection changes, the browser can move the // cursor from a non-zero offset on one block, through empty blocks, From b6a7312927592867ddb74d2f618f9b72ad517cf8 Mon Sep 17 00:00:00 2001 From: Steve Rubin Date: Fri, 28 Aug 2026 10:49:27 -0700 Subject: [PATCH 16/16] Codex auto-fixes Automated fixes applied by autofix-pr: - install the pinned pnpm toolchain in CI - run the build on Node 24 with current setup actions Agent-Harness: Codex --- .github/workflows/pr.yml | 16 ++++++---------- package.json | 1 + 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b49090236..925761bc1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,17 +6,13 @@ jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [16.x, 18.x, 20.x] - steps: - - uses: actions/checkout@v1 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node-version }} - - run: pnpm i - - run: pnpm lint + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test-ci diff --git a/package.json b/package.json index f26555e06..4e4241488 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@descript/draft-js", "description": "A React framework for building text editors.", "version": "0.11.6-descript.40", + "packageManager": "pnpm@10.28.1", "keywords": [ "draftjs", "editor",