diff --git a/.flowconfig b/.flowconfig index e3a3073ef..0e22f35c6 100644 --- a/.flowconfig +++ b/.flowconfig @@ -33,6 +33,7 @@ module.name_mapper='^outline/keys' -> '/packages/outline/src/helpe module.name_mapper='^outline/nodes' -> '/packages/outline/src/helpers/OutlineNodeHelpers.js' module.name_mapper='^outline/events' -> '/packages/outline/src/helpers/OutlineEventHelpers.js' module.name_mapper='^outline/offsets' -> '/packages/outline/src/helpers/OutlineOffsetHelpers.js' +module.name_mapper='^outline/validation' -> '/packages/outline/src/helpers/OutlineValidationHelpers.js' module.name_mapper='^outline-react/OutlineTreeView' -> '/packages/outline-react/src/OutlineTreeView.js' module.name_mapper='^outline-react/useOutlineEditor' -> '/packages/outline-react/src/useOutlineEditor.js' diff --git a/jest.config.js b/jest.config.js index 8ad37c279..2964a28f8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -49,6 +49,8 @@ module.exports = { '/packages/outline/src/helpers/OutlineNodeHelpers.js', '^outline/events$': '/packages/outline/src/helpers/OutlineEventHelpers.js', + '^outline/validation$': + '/packages/outline/src/helpers/OutlineValidationHelpers.js', '^shared/getDOMTextNodeFromElement$': '/packages/shared/src/getDOMTextNodeFromElement.js', '^shared/isImmutableOrInert$': diff --git a/packages/outline-playground/craco.config.js b/packages/outline-playground/craco.config.js index dba069b88..7fcd89a69 100644 --- a/packages/outline-playground/craco.config.js +++ b/packages/outline-playground/craco.config.js @@ -18,6 +18,7 @@ module.exports = { 'outline/nodes': 'outline/dist/OutlineNodeHelpers', 'outline/events': 'outline/dist/OutlineEventHelpers', 'outline/offsets': 'outline/dist/OutlineOffsetHelpers', + 'outline/validation': 'outline/dist/OutlineValidationHelpers', // Outline React 'outline-react/OutlineTreeView': 'outline-react/dist/OutlineTreeView', 'outline-react/useOutlineEditor': 'outline-react/dist/useOutlineEditor', diff --git a/packages/outline-react/src/useOutlineEditor.js b/packages/outline-react/src/useOutlineEditor.js index 73d121b37..3571cbc8b 100644 --- a/packages/outline-react/src/useOutlineEditor.js +++ b/packages/outline-react/src/useOutlineEditor.js @@ -10,6 +10,7 @@ import type {OutlineEditor, EditorThemeClasses, EditorState} from 'outline'; import {createEditor} from 'outline'; +import {canShowPlaceholder} from 'outline/validation'; import {useCallback, useMemo, useRef, useState} from 'react'; import useLayoutEffect from './shared/useLayoutEffect'; @@ -40,11 +41,14 @@ export default function useOutlineEditor(editorConfig?: { return editor.addListener('error', onError); }, [editor, onError]); useLayoutEffect(() => { - return editor.addListener('update', () => { - const canShowPlaceholder = editor.canShowPlaceholder(); - if (showPlaceholderRef.current !== canShowPlaceholder) { - showPlaceholderRef.current = canShowPlaceholder; - setShowPlaceholder(canShowPlaceholder); + return editor.addListener('update', ({editorState}) => { + const currentCanShowPlaceholder = canShowPlaceholder( + editorState, + editor.isComposing(), + ); + if (showPlaceholderRef.current !== currentCanShowPlaceholder) { + showPlaceholderRef.current = currentCanShowPlaceholder; + setShowPlaceholder(currentCanShowPlaceholder); } }); }, [editor]); diff --git a/packages/outline-react/src/useOutlineIsBlank.js b/packages/outline-react/src/useOutlineIsBlank.js new file mode 100644 index 000000000..7f86229bd --- /dev/null +++ b/packages/outline-react/src/useOutlineIsBlank.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {OutlineEditor} from 'outline'; + +import useLayoutEffect from './shared/useLayoutEffect'; +import {useState} from 'react'; +import {isBlank} from 'outline/validation'; + +/** + * DEPRECATED. Use useOutlineIsBlank + */ +export default function useCometOutlineIsBlank(editor: OutlineEditor): boolean { + const [isCurrentlyBlank, setIsBlank] = useState(true); + + useLayoutEffect(() => { + return editor.addListener('update', ({editorState}) => { + const isComposing = editor.isComposing(); + setIsBlank(isBlank(editorState, isComposing)); + }); + }, [editor]); + return isCurrentlyBlank; +} diff --git a/packages/outline-react/src/useOutlineIsEmpty.js b/packages/outline-react/src/useOutlineIsEmpty.js index 36e7a16fd..af4812e5c 100644 --- a/packages/outline-react/src/useOutlineIsEmpty.js +++ b/packages/outline-react/src/useOutlineIsEmpty.js @@ -11,13 +11,18 @@ import type {OutlineEditor} from 'outline'; import useLayoutEffect from './shared/useLayoutEffect'; import {useState} from 'react'; +import {isBlank} from 'outline/validation'; +/** + * DEPRECATED. Use useOutlineIsBlank + */ export default function useCometOutlineIsEmpty(editor: OutlineEditor): boolean { const [isCurrentlyEmpty, setIsEmpty] = useState(true); useLayoutEffect(() => { - return editor.addListener('update', () => { - setIsEmpty(editor.isEmpty()); + return editor.addListener('update', ({editorState}) => { + const isComposing = editor.isComposing(); + setIsEmpty(isBlank(editorState, isComposing)); }); }, [editor]); return isCurrentlyEmpty; diff --git a/packages/outline/package.json b/packages/outline/package.json index 1489549ef..d0aa06662 100644 --- a/packages/outline/package.json +++ b/packages/outline/package.json @@ -30,6 +30,7 @@ "./history": "./OutlineHistoryHelpers.js", "./offsets": "./OutlineOffsetHelpers.js", "./nodes": "./OutlineNodeHelpers.js", + "./validation": "./OutlineValidationHelpers.js", "./CodeNode": "./OutlineCodeNode.js", "./ParagraphNode": "./OutlineParagraphNode.js", "./QuoteNode": "./OutlineQuoteNode.js", diff --git a/packages/outline/src/__tests__/unit/OutlineEditor.test.js b/packages/outline/src/__tests__/unit/OutlineEditor.test.js index 127cd89c5..be35adb8c 100644 --- a/packages/outline/src/__tests__/unit/OutlineEditor.test.js +++ b/packages/outline/src/__tests__/unit/OutlineEditor.test.js @@ -950,14 +950,5 @@ describe('OutlineEditor tests', () => { '

A
C
B

', ); }); - - it('isEmpty', async () => { - expect(editor.isEmpty()).toBe(true); - await update((state: State) => { - const paragraph = state.getRoot().getFirstChild(); - paragraph.append(createTextNode('foo')); - }); - expect(editor.isEmpty()).toBe(false); - }); }); }); diff --git a/packages/outline/src/core/OutlineEditor.js b/packages/outline/src/core/OutlineEditor.js index 67a5a7879..0663880a6 100644 --- a/packages/outline/src/core/OutlineEditor.js +++ b/packages/outline/src/core/OutlineEditor.js @@ -247,6 +247,9 @@ class BaseOutlineEditor { isComposing(): boolean { return this._compositionKey != null; } + /** + * Deprecated. To be removed within a week. + */ isEmpty(trim: boolean = true): boolean { if (this.isComposing()) { return false; @@ -422,6 +425,9 @@ class BaseOutlineEditor { ); } } + /** + * Deprecated. To be removed within a week. + */ canShowPlaceholder(): boolean { if (!this.isEmpty(false)) { return false; @@ -479,7 +485,7 @@ declare export class OutlineEditor { getObserver(): null | MutationObserver; isComposing(): boolean; - isEmpty(trim?: boolean): boolean; + // isEmpty(trim?: boolean): boolean; registerNodeType(nodeType: string, klass: Class): void; addListener(type: 'error', listener: ErrorListener): () => void; addListener(type: 'update', listener: UpdateListener): () => void; @@ -498,7 +504,7 @@ declare export class OutlineEditor { parseEditorState(stringifiedEditorState: string): EditorState; update(updateFn: (state: State) => void, callbackFn?: () => void): boolean; focus(callbackFn?: () => void): void; - canShowPlaceholder(): boolean; + // canShowPlaceholder(): boolean; } export function getEditorFromElement(element: Element): null | OutlineEditor { diff --git a/packages/outline/src/helpers/OutlineValidationHelpers.js b/packages/outline/src/helpers/OutlineValidationHelpers.js new file mode 100644 index 000000000..5fe28ecf4 --- /dev/null +++ b/packages/outline/src/helpers/OutlineValidationHelpers.js @@ -0,0 +1,62 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import type {EditorState} from 'outline'; + +import {getEditorStateTextContent} from '../core/OutlineUtils'; +import {isBlockNode, isTextNode} from 'outline'; + +export function isBlank( + editorState: EditorState, + isEditorComposing: boolean, + trim?: boolean = true, +): boolean { + if (isEditorComposing) { + return false; + } + let text = getEditorStateTextContent(editorState); + if (trim) { + text = text.trim(); + } + return text === ''; +} + +export function canShowPlaceholder( + editorState: EditorState, + isComposing: boolean, +): boolean { + if (!isBlank(editorState, isComposing, false)) { + return false; + } + const nodeMap = editorState._nodeMap; + // $FlowFixMe: root is always in the Map + const root = ((nodeMap.get('root'): any): RootNode); + const topBlockIDs = root.__children; + const topBlockIDsLength = topBlockIDs.length; + if (topBlockIDsLength > 1) { + return false; + } + for (let i = 0; i < topBlockIDsLength; i++) { + const topBlock = nodeMap.get(topBlockIDs[i]); + + if (isBlockNode(topBlock)) { + if (topBlock.__type !== 'paragraph') { + return false; + } + const children = topBlock.__children; + for (let s = 0; s < children.length; s++) { + const child = nodeMap.get(children[s]); + if (!isTextNode(child)) { + return false; + } + } + } + } + return true; +} diff --git a/packages/outline/src/helpers/__tests__/unit/OutlineValidationHelpers.test.js b/packages/outline/src/helpers/__tests__/unit/OutlineValidationHelpers.test.js new file mode 100644 index 000000000..2fc855b87 --- /dev/null +++ b/packages/outline/src/helpers/__tests__/unit/OutlineValidationHelpers.test.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import type {State} from 'outline'; + +import {createTextNode} from 'outline'; +import {createParagraphNode} from 'outline/ParagraphNode'; +import {isBlank} from 'outline/validation'; +import {initializeUnitTest} from '../../../__tests__/utils'; + +describe('OutlineNodeHelpers tests', () => { + initializeUnitTest((testEnv) => { + it('isBlank', async () => { + const editor = testEnv.editor; + expect(isBlank(editor.getEditorState(), editor.isComposing())).toBe(true); + await editor.update((state: State) => { + const root = state.getRoot(); + const paragraph = createParagraphNode(); + const text = createTextNode('foo'); + root.append(paragraph); + paragraph.append(text); + }); + expect(isBlank(editor.getEditorState(), editor.isComposing())).toBe( + false, + ); + }); + }); +}); diff --git a/scripts/build.js b/scripts/build.js index 64fbc4106..63ed3dab2 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -172,6 +172,12 @@ async function build(name, inputFile, outputFile) { 'packages/outline/src/helpers/OutlineOffsetHelpers', ), }, + { + find: isWWW ? 'Outline/validation' : 'outline/validation', + replacement: path.resolve( + 'packages/outline/src/helpers/OutlineValidationHelpers', + ), + }, ], }), // Extract error codes from invariant() messages into a file.