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 86ae6227f..4e4241488 100644 --- a/package.json +++ b/package.json @@ -1,152 +1,148 @@ { - "name": "@descript/draft-js", - "description": "A React framework for building text editors.", - "version": "0.11.6-descript.34", - "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.40", + "packageManager": "pnpm@10.28.1", + "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/" + ] + } } 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 diff --git a/src/Draft.ts b/src/Draft.ts index 5c36d4e13..074f63c3f 100644 --- a/src/Draft.ts +++ b/src/Draft.ts @@ -10,7 +10,11 @@ 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 {supportsBlockSkeletonRendering} from './component/hooks/useDraftEditorBlockSkeleton'; export {CompositeDecorator} from './model/decorators/CompositeDraftDecorator'; import DraftEntity from './model/entity/DraftEntity'; import AtomicBlockUtils from './model/modifier/AtomicBlockUtils'; @@ -38,7 +42,10 @@ 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 { + DraftDecoratorDOMAnchorRange, + 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..d8b67695a 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; + onBlockSkeletonsRendered?: () => void; +}>; + 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..3d6f94c01 100644 --- a/src/component/base/__tests__/DraftEditor.react-test.tsx +++ b/src/component/base/__tests__/DraftEditor.react-test.tsx @@ -9,10 +9,16 @@ */ 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'; +import {supportsBlockSkeletonRendering} from '../../hooks/useDraftEditorBlockSkeleton'; let container: HTMLElement; let root: Root; @@ -72,6 +78,360 @@ 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; + let observerCount = 0; + const observedElements: Element[] = []; + const renderedSkeletonCounts: number[] = []; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '500px 0px'; + readonly thresholds = [0]; + + constructor(callback: IntersectionObserverCallback) { + observerCount++; + 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, + onBlockSkeletonsRendered: () => { + renderedSkeletonCounts.push( + container.querySelectorAll('[data-block-skeleton]').length, + ); + }, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + + 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(); + flushSync(() => { + observerCallback?.( + [ + { + isIntersecting: true, + target: secondBlock, + } as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(1); + expect(renderedSkeletonCounts).toEqual([2, 1]); + expect(observerCount).toBe(1); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + } +}); + +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; + const observedElements: Element[] = []; + let mutationCallback: MutationCallback | undefined; + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = container; + readonly rootMargin = '500px 0px'; + readonly thresholds = [0]; + + disconnect(): 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')); + 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); + + 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('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; + + 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; + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: {supports: () => false}, + }); + try { + expect(supportsBlockSkeletonRendering()).toBe(false); + editorState = createWithContent(createFromText('zero\none\ntwo')); + flushSync(() => { + root.render( + {}} + blockSkeleton={{ + enabled: true, + scrollContainerRef: {current: container}, + }} + />, + ); + }); + + expect(container.querySelectorAll('[data-block-skeleton]')).toHaveLength(0); + } finally { + globalThis.IntersectionObserver = originalIntersectionObserver; + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: originalCSS, + }); + } +}); + describe('ariaDescribedBy', () => { function getProps(elem: React.ReactElement): Element { flushSync(() => { 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 ae84160a4..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; @@ -100,6 +101,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; @@ -352,21 +361,29 @@ 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; }); } 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 +82,54 @@ const getListItemClasses = ( }); }; +function renderSkeletonChildren({ + block, + contentState, + decorator, + tree, +}: { + block: BlockNode; + contentState: EditorState['currentContent']; + decorator: EditorState['decorator']; + tree: ReturnType; +}): ReactNode { + if (!decorator?.getDOMAnchorIdsForRange) { + return block.text ||
; + } + + const children: ReactNode[] = []; + let plainTextStart = 0; + for (const range of tree) { + if (range.decoratorKey === null) { + continue; + } + const anchorIds = decorator.getDOMAnchorIdsForRange({ + block, + contentState, + decoratorKey: range.decoratorKey, + start: range.start, + end: range.end, + entityKey: getEntityAt(block, range.start), + }); + if (!anchorIds?.length) { + continue; + } + + if (plainTextStart < range.start) { + children.push(block.text.slice(plainTextStart, range.start)); + } + children.push( + wrapInDOMAnchors(block.text.slice(range.start, range.end), anchorIds), + ); + 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 +144,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 +181,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 +218,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 +242,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 +255,7 @@ export default class DraftEditorContents extends React.Component { blockRenderMap, blockRendererFn, blockStyleFn, + blockSkeleton, customStyleMap, customStyleFn, editorState, @@ -225,40 +291,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']; @@ -280,6 +316,9 @@ export default class DraftEditorContents extends React.Component { } } + const shouldRenderSkeleton = + blockSkeleton && !blockSkeleton.fullBlockKeys.has(key); + // List items are special snowflakes, since we handle nesting and // counters manually. if (Element === 'li') { @@ -293,33 +332,90 @@ 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 (shouldRenderSkeleton) { childProps = { ...childProps, - contentEditable: customEditable, - suppressContentEditableWarning: true, + 'data-block-skeleton': true, + style: { + ...inlineStyle, + direction: direction === 'RTL' ? 'rtl' : 'ltr', + position: 'relative', + textAlign: direction === 'RTL' ? 'right' : 'left', + whiteSpace: 'pre-wrap', + }, }; + const tree = decorator?.getDOMAnchorIdsForRange + ? 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 +460,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..b662602e3 --- /dev/null +++ b/src/component/contents/DraftEditorSkeletonContents.react.tsx @@ -0,0 +1,35 @@ +import React, {useState} 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 [contentsElement, setContentsElement] = useState( + null, + ); + const skeletonState = useDraftEditorBlockSkeleton({ + enabled: blockSkeleton?.enabled ?? false, + editorState: contentsProps.editorState, + contentsElement, + onBlockSkeletonsRendered: blockSkeleton?.onBlockSkeletonsRendered, + scrollContainerRef: blockSkeleton?.scrollContainerRef, + }); + + return ( + + ); +} diff --git a/src/component/contents/__tests__/DraftEditorContents.react-test.tsx b/src/component/contents/__tests__/DraftEditorContents.react-test.tsx index 428277017..7cf37d462 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,64 @@ 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 DOM anchors', () => { + const decorator: DraftDecoratorType = { + getDecorations: block => + block.text.split('').map((_, index) => (index < 3 ? 'linked' : null)), + getComponentForKey: () => + function FullDecorator({children}) { + return {children}; + }, + getPropsForKey: () => null, + getDOMAnchorIdsForRange: ({block}) => [`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}`); + 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?.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'); + } +}); diff --git a/src/component/hooks/useDraftEditorBlockSkeleton.ts b/src/component/hooks/useDraftEditorBlockSkeleton.ts new file mode 100644 index 000000000..8f5946abf --- /dev/null +++ b/src/component/hooks/useDraftEditorBlockSkeleton.ts @@ -0,0 +1,227 @@ +import {RefObject, useLayoutEffect, useMemo, useState} from 'react'; +import * as ReactDOM from 'react-dom'; +import {EditorState} from '../../model/immutable/EditorState'; + +export type DraftEditorBlockSkeletonState = Readonly<{ + fullBlockKeys: ReadonlySet; +}>; + +type Options = Readonly<{ + enabled: boolean; + editorState: EditorState; + contentsElement: HTMLElement | null; + onBlockSkeletonsRendered?: () => void; + scrollContainerRef?: RefObject; +}>; + +const OBSERVER_MARGIN = '500px 0px'; + +export function supportsBlockSkeletonRendering(): boolean { + return ( + typeof window !== 'undefined' && + typeof IntersectionObserver !== 'undefined' && + (typeof CSS === 'undefined' || + typeof CSS.supports !== 'function' || + CSS.supports('overflow-anchor: auto')) + ); +} + +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, + contentsElement, + onBlockSkeletonsRendered, + scrollContainerRef, +}: Options): DraftEditorBlockSkeletonState | undefined { + const [visibleBlockKeys, setVisibleBlockKeys] = useState>( + new Set(), + ); + const canObserve = supportsBlockSkeletonRendering(); + + useLayoutEffect(() => { + if (!enabled || !canObserve) { + return; + } + if (!contentsElement) { + return; + } + + const observerOptions = { + root: scrollContainerRef?.current ?? contentsElement, + 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); + + 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 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(); + } + }; + 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) { + 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) { + if (!addedKeys.has(key)) { + nextKeys.delete(key); + } + } + 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(); + }; + }, [canObserve, contentsElement, enabled, scrollContainerRef]); + + const skeletonState = useMemo(() => { + if (!enabled || !canObserve) { + return undefined; + } + + const fullBlockKeys = new Set(visibleBlockKeys); + fullBlockKeys.add(editorState.selection.anchorKey); + fullBlockKeys.add(editorState.selection.focusKey); + return {fullBlockKeys}; + }, [ + canObserve, + editorState.selection.anchorKey, + editorState.selection.focusKey, + enabled, + visibleBlockKeys, + ]); + + useLayoutEffect(() => { + if (skeletonState) { + onBlockSkeletonsRendered?.(); + } + }, [onBlockSkeletonsRendered, skeletonState]); + + return skeletonState; +} 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, diff --git a/src/model/decorators/DraftDecoratorType.ts b/src/model/decorators/DraftDecoratorType.ts index d6439263a..e5c8343b4 100644 --- a/src/model/decorators/DraftDecoratorType.ts +++ b/src/model/decorators/DraftDecoratorType.ts @@ -13,6 +13,15 @@ import {BlockNode} from '../immutable/BlockNode'; import {ComponentType} from 'react'; import {DraftDecoratorComponentProps} from './DraftDecorator'; +export type DraftDecoratorDOMAnchorRange = 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. @@ -37,6 +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 stable DOM ids that Draft renders around this decorated range. + */ + getDOMAnchorIdsForRange?: ( + range: DraftDecoratorDOMAnchorRange, + ) => readonly string[] | undefined; };