diff --git a/packages/outline-playground/src/useTypeahead.js b/packages/outline-playground/src/useTypeahead.js index e65d14e0d..42c9681db 100644 --- a/packages/outline-playground/src/useTypeahead.js +++ b/packages/outline-playground/src/useTypeahead.js @@ -43,7 +43,7 @@ export default function useTypeahead(editor: OutlineEditor): void { // Monitor entered text useEffect(() => { return editor.addListener('update', (viewModel) => { - const text = editor.getTextContent(); + const text = editor.getCurrentTextContent(); setText(text); }); }, [editor]); diff --git a/packages/outline-react/src/useOutlineCharacterLimit.js b/packages/outline-react/src/useOutlineCharacterLimit.js index 5f672c64d..38eb025d2 100644 --- a/packages/outline-react/src/useOutlineCharacterLimit.js +++ b/packages/outline-react/src/useOutlineCharacterLimit.js @@ -41,7 +41,7 @@ export function useCharacterLimit( const Segmenter = Intl.Segmenter; let offsetUtf16 = 0; let offset = 0; - const text = editor.getTextContent(); + const text = editor.getCurrentTextContent(); if (typeof Segmenter === 'function') { const segmenter = new Segmenter(); const graphemes = segmenter.segment(text); @@ -79,7 +79,7 @@ export function useCharacterLimit( useEffect(() => { editor.registerNodeType('overflow', OverflowNode); (() => { - const textLength = strlen(editor.getTextContent()); + const textLength = strlen(editor.getCurrentTextContent()); const diff = maxCharacters - textLength; remainingCharacters(diff); execute(); @@ -90,7 +90,7 @@ export function useCharacterLimit( 'update', (viewModel: ViewModel, dirtyNodes: Set | null) => { const isComposing = editor.isComposing(); - const text = editor.getTextContent(); + const text = editor.getCurrentTextContent(); const utf16TextLength = text.length; const hasDirtyNodes = dirtyNodes !== null && dirtyNodes.size > 0; if ( @@ -99,7 +99,7 @@ export function useCharacterLimit( ) { return; } - const textLength = strlen(editor.getTextContent()); + const textLength = strlen(editor.getCurrentTextContent()); const textLengthAboveThreshold = textLength > maxCharacters || (lastTextLength !== null && lastTextLength > maxCharacters); diff --git a/packages/outline/src/__tests__/unit/OutlineEditor.test.js b/packages/outline/src/__tests__/unit/OutlineEditor.test.js index 649744465..7e94305c9 100644 --- a/packages/outline/src/__tests__/unit/OutlineEditor.test.js +++ b/packages/outline/src/__tests__/unit/OutlineEditor.test.js @@ -507,6 +507,32 @@ describe('OutlineEditor tests', () => { expect(parsedSelection.focus.key).toEqual(parsedText.__key); }); }); + + it('getCurrentTextContent() / getLatestTextContent()', async () => { + editor.update((view: View) => { + const root = view.getRoot(); + const paragraph = createParagraphNode(); + const text1 = createTextNode('1'); + root.append(paragraph); + paragraph.append(text1); + }); + editor.update((view: View) => { + const root = view.getRoot(); + const paragraph = root.getFirstChild(); + const text2 = createTextNode('2'); + paragraph.append(text2); + }); + + expect(editor.getCurrentTextContent()).toBe(''); + expect( + editor.getLatestTextContent((text) => { + expect(text).toBe('12'); + }), + ); + + await Promise.resolve(); + expect(editor.getCurrentTextContent()).toBe('12'); + }); }); describe('Node children', () => { @@ -575,7 +601,7 @@ describe('OutlineEditor tests', () => { textToKey.set(previousText, textNode.__key); } }); - expect(editor.getTextContent()).toBe(previous.join('')); + expect(editor.getCurrentTextContent()).toBe(previous.join('')); // Next editor state const previousSet = new Set(previous); @@ -609,7 +635,7 @@ describe('OutlineEditor tests', () => { }); }); // Expect text content + HTML to be correct - expect(editor.getTextContent()).toBe(next.join('')); + expect(editor.getCurrentTextContent()).toBe(next.join('')); expect(container.innerHTML).toBe( `

${ next.length > 0 diff --git a/packages/outline/src/__tests__/unit/OutlineTextNode.test.js b/packages/outline/src/__tests__/unit/OutlineTextNode.test.js index 9b3ed5c7f..d9a54747e 100644 --- a/packages/outline/src/__tests__/unit/OutlineTextNode.test.js +++ b/packages/outline/src/__tests__/unit/OutlineTextNode.test.js @@ -118,13 +118,13 @@ describe('OutlineTextNode tests', () => { view.getRoot().getFirstChild().append(textNode); }); - expect(editor.getTextContent()).toBe('Text'); + expect(editor.getCurrentTextContent()).toBe('Text'); // Make sure that the editor content is still set after further reconciliations await update((view) => { view.markNodeAsDirty(view.getNodeByKey(nodeKey)); }); - expect(editor.getTextContent()).toBe('Text'); + expect(editor.getCurrentTextContent()).toBe('Text'); }); test('inert nodes', async () => { @@ -140,13 +140,13 @@ describe('OutlineTextNode tests', () => { view.getRoot().getFirstChild().append(textNode); }); - expect(editor.getTextContent()).toBe(''); + expect(editor.getCurrentTextContent()).toBe(''); // Make sure that the editor content is still empty after further reconciliations await update((view) => { view.markNodeAsDirty(view.getNodeByKey(nodeKey)); }); - expect(editor.getTextContent()).toBe(''); + expect(editor.getCurrentTextContent()).toBe(''); }); test('prepend node', async () => { @@ -161,7 +161,7 @@ describe('OutlineTextNode tests', () => { previousTextNode.insertBefore(textNode); }); - expect(editor.getTextContent()).toBe('Hello World'); + expect(editor.getCurrentTextContent()).toBe('Hello World'); }); }); diff --git a/packages/outline/src/core/OutlineEditor.js b/packages/outline/src/core/OutlineEditor.js index ab638d86a..50d394969 100644 --- a/packages/outline/src/core/OutlineEditor.js +++ b/packages/outline/src/core/OutlineEditor.js @@ -107,6 +107,22 @@ export type ListenerType = | 'root' | 'decorator'; +let isPreparingPendingViewUpdate = false; + +export function asyncErrorOnPreparingPendingViewUpdate( + fnName: 'Editor.getLatestTextContent()', +): void { + if ( + isPreparingPendingViewUpdate && + fnName === 'Editor.getLatestTextContent()' + ) { + invariant( + false, + 'Editor.getLatestTextContent() can be asynchronous and cannot be used within Editor.update()', + ); + } +} + export function resetEditor( editor: OutlineEditor, prevRootElement: null | HTMLElement, @@ -167,6 +183,7 @@ function updateEditor( viewModelWasCloned = true; } + isPreparingPendingViewUpdate = true; const error = preparePendingViewUpdate( pendingViewModel, updateFn, @@ -174,6 +191,7 @@ function updateEditor( markAllTextNodesAsDirty, editor, ); + isPreparingPendingViewUpdate = false; if (error !== null) { // Report errors @@ -326,9 +344,17 @@ class BaseOutlineEditor { getRootElement(): null | HTMLElement { return this._rootElement; } - getTextContent(): string { + getCurrentTextContent(): string { return this._textContent; } + getLatestTextContent(callback: (text: string) => void): void { + asyncErrorOnPreparingPendingViewUpdate('Editor.getLatestTextContent()'); + if (this._pendingViewModel === null) { + callback(this._textContent); + return; + } + this._deferred.push(() => callback(this._textContent)); + } setRootElement(nextRootElement: null | HTMLElement): void { const prevRootElement = this._rootElement; if (nextRootElement !== prevRootElement) { @@ -478,7 +504,8 @@ declare export class OutlineEditor { getDecorators(): {[NodeKey]: ReactNode}; getRootElement(): null | HTMLElement; setRootElement(rootElement: null | HTMLElement): void; - getTextContent(): string; + getCurrentTextContent(): string; + getLatestTextContent((text: string) => void): () => void; getElementByKey(key: NodeKey): null | HTMLElement; getViewModel(): ViewModel; setViewModel(viewModel: ViewModel): void; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9b98b2455..80d1312ae 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -46,5 +46,6 @@ "44": "reconcileNode: prevNode or nextNode does not exist in nodeMap", "45": "reconcileNode: parentDOM is null", "46": "Reconciliation: could not find DOM element for node key \"${key}\"", - "47": "clearEditor expected plain text root first child to be a ParagraphNode" + "47": "clearEditor expected plain text root first child to be a ParagraphNode", + "48": "Editor.getLatestTextContent() can be asynchronous and cannot be used within Editor.update()" }