Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 9 additions & 2 deletions src/Draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/component/base/DraftEditor.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -336,6 +336,7 @@ export default class DraftEditor extends React.Component<
blockRenderMap,
blockRendererFn,
blockStyleFn,
blockSkeleton,
customStyleFn,
customStyleMap,
editorState,
Expand Down Expand Up @@ -377,6 +378,7 @@ export default class DraftEditor extends React.Component<
blockRenderMap,
blockRendererFn,
blockStyleFn,
blockSkeleton,
customStyleMap: {
...DefaultDraftInlineStyle,
...customStyleMap,
Expand Down Expand Up @@ -471,7 +473,7 @@ export default class DraftEditor extends React.Component<
all DraftEditorLeaf nodes so it's first in postorder traversal.
*/}
<UpdateDraftEditorFlags editor={this} editorState={editorState} />
<DraftEditorContents
<DraftEditorSkeletonContents
{...editorContentsProps}
key={'contents' + this.state.contentsKey}
/>
Expand Down
8 changes: 8 additions & 0 deletions src/component/base/DraftEditorProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import {
} from '../utils/eventTypes';
import {BlockNode} from '../../model/immutable/BlockNode';

export type DraftEditorBlockSkeletonOptions = Readonly<{
enabled: boolean;
scrollContainerRef: RefObject<HTMLElement>;
pinnedBlockKeys?: ReadonlySet<string>;
}>;

export type DraftEditorProps = {
/**
* The two most critical props are `editorState` and `onChange`.
Expand Down Expand Up @@ -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<HTMLDivElement> | Ref<HTMLDivElement>;
Expand Down
215 changes: 214 additions & 1 deletion src/component/base/__tests__/DraftEditor.react-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
<DraftEditor
editorState={editorState}
onChange={() => {}}
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(
<DraftEditor
editorState={editorState}
onChange={() => {}}
blockRendererFn={blockRendererFn}
blockSkeleton={{
enabled: true,
scrollContainerRef: {current: container},
}}
/>,
);
});
expect(blockRendererFn).toHaveBeenCalledTimes(1);

blockRendererFn.mockClear();
editorState = createWithContent(createFromText('zero\none\ntwo'));
flushSync(() => {
root.render(
<DraftEditor
editorState={editorState}
onChange={() => {}}
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(
<DraftEditor
editorState={editorState}
onChange={onChange}
blockSkeleton={{
enabled: true,
scrollContainerRef: {current: container},
}}
/>,
);
});

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(
<DraftEditor
editorState={nextEditorState}
onChange={onChange}
blockSkeleton={{
enabled: true,
scrollContainerRef: {current: container},
}}
/>,
);
});
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(() => {
Expand Down
14 changes: 9 additions & 5 deletions src/component/contents/DraftEditorBlock.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> {
_node: HTMLDivElement | null = null;

Expand Down Expand Up @@ -362,11 +370,7 @@ export default class DraftEditorBlock extends React.Component<Props> {

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 (
<div
Expand Down
Loading