+
{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;
};