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..d2ab32977 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,214 @@ 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; + } +}); + +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/contents/DraftEditorBlock.react.tsx b/src/component/contents/DraftEditorBlock.react.tsx index ae84160a4..b31db7f8c 100644 --- a/src/component/contents/DraftEditorBlock.react.tsx +++ b/src/component/contents/DraftEditorBlock.react.tsx @@ -100,6 +100,14 @@ const getNodeScrollTopAndBottom = ( * A `DraftEditorBlock` is able to render a given `ContentBlock` to its * appropriate decorator and inline style components. */ +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 +370,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 +81,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 +152,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 +189,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 +226,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 +250,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 +263,7 @@ export default class DraftEditorContents extends React.Component { blockRenderMap, blockRendererFn, blockStyleFn, + blockSkeleton, customStyleMap, customStyleFn, editorState, @@ -225,40 +299,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 +337,92 @@ 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, + 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 + ? 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 +467,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..b880b5d83 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,68 @@ 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( + ({marginBottom: '16px'})} + blockSkeleton={{ + fullBlockKeys: new Set(fullBlock ? [fullBlock.key] : []), + }} + />, + ); + }); + + 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?.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 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 diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts new file mode 100644 index 000000000..980fb3057 --- /dev/null +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -0,0 +1,111 @@ +import {RefObject, useLayoutEffect, useMemo, useRef, 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 hasRefreshedGeometry = useRef(false); + const blockMap = editorState.currentContent.blockMap; + const canObserve = + typeof window !== 'undefined' && typeof IntersectionObserver !== 'undefined'; + + useLayoutEffect(() => { + if (!enabled || !canObserve) { + hasRefreshedGeometry.current = false; + return; + } + + const contents = contentsRef.current; + if (!contents) { + return; + } + + 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; + }); + }; + 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(); + } + } + for (const blockElement of blockElements) { + observer.observe(blockElement); + } + return () => observer.disconnect(); + }, [ + blockMap, + canObserve, + contentsRef, + enabled, + scrollContainerRef, + visibleBlockKeys, + ]); + + 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; };