diff --git a/.flowconfig b/.flowconfig index 41dd05361..75b76c26c 100644 --- a/.flowconfig +++ b/.flowconfig @@ -30,6 +30,7 @@ module.name_mapper='^outline/HistoryHelpers' -> '/packages/outline module.name_mapper='^outline/SelectionHelpers' -> '/packages/outline/src/helpers/OutlineSelectionHelpers.js' module.name_mapper='^outline/TextHelpers' -> '/packages/outline/src/helpers/OutlineTextHelpers.js' module.name_mapper='^outline/KeyHelpers' -> '/packages/outline/src/helpers/OutlineKeyHelpers.js' +module.name_mapper='^outline/NodeHelpers' -> '/packages/outline/src/helpers/OutlineNodeHelpers.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 06c77778c..20734ce20 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,6 +45,8 @@ module.exports = { '/packages/outline/src/helpers/OutlineTextHelpers.js', '^outline/KeyHelpers$': '/packages/outline/src/helpers/OutlineKeyHelpers.js', + '^outline/NodeHelpers$': + '/packages/outline/src/helpers/OutlineNodeHelpers.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 720a6d08f..dfff81e35 100644 --- a/packages/outline-playground/craco.config.js +++ b/packages/outline-playground/craco.config.js @@ -15,6 +15,7 @@ module.exports = { 'outline/TextHelpers': 'outline/dist/OutlineTextHelpers', 'outline/HistoryHelpers': 'outline/dist/OutlineHistoryHelpers', 'outline/KeyHelpers': 'outline/dist/OutlineKeyHelpers', + 'outline/NodeHelpers': 'outline/dist/OutlineNodeHelpers', // Outline React 'outline-react/OutlineTreeView': 'outline-react/dist/OutlineTreeView', 'outline-react/useOutlineEditor': 'outline-react/dist/useOutlineEditor', diff --git a/packages/outline/src/__tests__/unit/OutlineBlockNode.test.js b/packages/outline/src/__tests__/unit/OutlineBlockNode.test.js index f33e1290d..cccf04708 100644 --- a/packages/outline/src/__tests__/unit/OutlineBlockNode.test.js +++ b/packages/outline/src/__tests__/unit/OutlineBlockNode.test.js @@ -12,19 +12,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; -import {BlockNode, createEditor, createTextNode} from 'outline'; - -class TestBlockNode extends BlockNode { - static clone(node: BlockNode) { - return new TestBlockNode(node.__key); - } - createDOM() { - return document.createElement('div'); - } - updateDOM() { - return false; - } -} +import {createEditor, createTextNode} from 'outline'; +import {createTestBlockNode} from '../utils'; describe('OutlineBlockNode tests', () => { let container = null; @@ -76,7 +65,7 @@ describe('OutlineBlockNode tests', () => { // Insert initial block await update((view) => { - const block = new TestBlockNode(); + const block = createTestBlockNode(); const text = createTextNode('Foo'); const text2 = createTextNode('Bar'); // Prevent text nodes from combining. @@ -96,7 +85,7 @@ describe('OutlineBlockNode tests', () => { describe('getChildren()', () => { test('no children', async () => { await update(() => { - const block = new TestBlockNode(); + const block = createTestBlockNode(); const children = block.getChildren(); expect(children).toHaveLength(0); expect(children).toEqual([]); @@ -121,8 +110,8 @@ describe('OutlineBlockNode tests', () => { test('nested', async () => { await update((view) => { - const block = new TestBlockNode(); - const innerBlock = new TestBlockNode(); + const block = createTestBlockNode(); + const innerBlock = createTestBlockNode(); const text = createTextNode('Foo'); text.select(0, 0); const text2 = createTextNode('Bar'); @@ -140,7 +129,7 @@ describe('OutlineBlockNode tests', () => { expect(children).toHaveLength(4); expect(children).toEqual([text, text2, text3, text4]); - const innerInnerBlock = new TestBlockNode(); + const innerInnerBlock = createTestBlockNode(); const text5 = createTextNode('More'); const text6 = createTextNode('Stuff'); innerInnerBlock.append(text5); @@ -168,7 +157,7 @@ describe('OutlineBlockNode tests', () => { test('empty', async () => { await update(() => { - const block = new TestBlockNode(); + const block = createTestBlockNode(); expect(block.getFirstChild()).toBe(null); }); }); @@ -185,7 +174,7 @@ describe('OutlineBlockNode tests', () => { test('empty', async () => { await update(() => { - const block = new TestBlockNode(); + const block = createTestBlockNode(); expect(block.getLastChild()).toBe(null); }); }); @@ -202,15 +191,15 @@ describe('OutlineBlockNode tests', () => { test('empty', async () => { await update(() => { - const block = new TestBlockNode(); + const block = createTestBlockNode(); expect(block.getTextContent()).toBe(''); }); }); test('nested', async () => { await update((view) => { - const block = new TestBlockNode(); - const innerBlock = new TestBlockNode(); + const block = createTestBlockNode(); + const innerBlock = createTestBlockNode(); const text = createTextNode('Foo'); text.select(0, 0); const text2 = createTextNode('Bar'); @@ -228,7 +217,7 @@ describe('OutlineBlockNode tests', () => { expect(block.getTextContent()).toEqual('FooBar\n\nQux'); expect(block.getTextContent(true)).toEqual('FooBarBaz\n\nQux'); - const innerInnerBlock = new TestBlockNode(); + const innerInnerBlock = createTestBlockNode(); const text5 = createTextNode('More'); text5.makeInert(); const text6 = createTextNode('Stuff'); diff --git a/packages/outline/src/__tests__/unit/OutlineCodeNode.test.js b/packages/outline/src/__tests__/unit/OutlineCodeNode.test.js index 11834d255..3bf411316 100644 --- a/packages/outline/src/__tests__/unit/OutlineCodeNode.test.js +++ b/packages/outline/src/__tests__/unit/OutlineCodeNode.test.js @@ -7,8 +7,8 @@ */ import {createCodeNode, CodeNode} from 'outline/CodeNode'; -import {ParagraphNode} from 'outline/ParagraphNode'; -import {TextNode} from 'outline'; +import {createParagraphNode} from 'outline/ParagraphNode'; +import {createTextNode} from 'outline'; import {initializeUnitTest} from '../utils'; const editorConfig = Object.freeze({ @@ -60,8 +60,8 @@ describe('OutlineCodeNode tests', () => { const {editor} = testEnv; await editor.update((view) => { const root = view.getRoot(); - const paragraphNode = new ParagraphNode(); - const textNode = new TextNode('foo'); + const paragraphNode = createParagraphNode(); + const textNode = createTextNode('foo'); paragraphNode.append(textNode); root.append(paragraphNode); textNode.select(0, 0); diff --git a/packages/outline/src/__tests__/unit/OutlineEditor.test.js b/packages/outline/src/__tests__/unit/OutlineEditor.test.js index 16d882083..b8f195a30 100644 --- a/packages/outline/src/__tests__/unit/OutlineEditor.test.js +++ b/packages/outline/src/__tests__/unit/OutlineEditor.test.js @@ -22,22 +22,7 @@ import { import {createParagraphNode, ParagraphNode} from 'outline/ParagraphNode'; import useOutlineRichText from 'outline-react/useOutlineRichText'; import {getNodeByKey} from '../../core/OutlineNode'; - -class TestBlockNode extends BlockNode { - static clone(node: TestBlockNode) { - return new TestBlockNode(node.__key); - } - createDOM() { - return document.createElement('div'); - } - updateDOM() { - return false; - } -} - -function createTestBlockNode() { - return new TestBlockNode(); -} +import {createTestBlockNode} from '../utils'; function sanitizeHTML(html) { // Remove zero width characters diff --git a/packages/outline/src/__tests__/utils/index.js b/packages/outline/src/__tests__/utils/index.js index 6e2aad05b..c523a157f 100644 --- a/packages/outline/src/__tests__/utils/index.js +++ b/packages/outline/src/__tests__/utils/index.js @@ -6,15 +6,15 @@ * */ +import type {OutlineEditor} from 'outline'; + import React from 'react'; import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; -import {createEditor} from 'outline'; +import {createEditor, BlockNode} from 'outline'; import {resetRandomKey} from '../../core/OutlineUtils'; -import type {OutlineEditor} from 'outline'; - type TestEnv = { editor: OutlineEditor | null, container: HTMLDivElement | null, @@ -80,3 +80,19 @@ export const initializeUnitTest = (runTests: (testEnv: TestEnv) => void) => { runTests(testEnv); }; + +export class TestBlockNode extends BlockNode { + static clone(node: BlockNode) { + return new TestBlockNode(node.__key); + } + createDOM() { + return document.createElement('div'); + } + updateDOM() { + return false; + } +} + +export function createTestBlockNode(): TestBlockNode { + return new TestBlockNode(); +} diff --git a/packages/outline/src/helpers/OutlineNodeHelpers.js b/packages/outline/src/helpers/OutlineNodeHelpers.js new file mode 100644 index 000000000..1b3b4edf4 --- /dev/null +++ b/packages/outline/src/helpers/OutlineNodeHelpers.js @@ -0,0 +1,39 @@ +/** + * 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 {OutlineNode} from 'outline'; + +import {isBlockNode} from 'outline'; + +export function dfs( + startingNode: OutlineNode, + nextNode: (OutlineNode) => OutlineNode | null, +) { + let node = startingNode; + nextNode(node); + while (node !== null) { + if (isBlockNode(node) && node.getChildrenSize() > 0) { + node = node.getFirstChild(); + } else { + // Find immediate sibling or nearest parent sibling + let sibling = null; + while (sibling === null && node !== null) { + sibling = node.getNextSibling(); + if (sibling === null) { + node = node.getParent(); + } else { + node = sibling; + } + } + } + if (node !== null) { + node = nextNode(node); + } + } +} diff --git a/packages/outline/src/helpers/__tests__/unit/OutlineNodeHelpers.test.js b/packages/outline/src/helpers/__tests__/unit/OutlineNodeHelpers.test.js new file mode 100644 index 000000000..c9a9c9376 --- /dev/null +++ b/packages/outline/src/helpers/__tests__/unit/OutlineNodeHelpers.test.js @@ -0,0 +1,120 @@ +/** + * 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 {OutlineEditor, View, NodeKey, OutlineNode} from 'outline'; + +import { + initializeUnitTest, + createTestBlockNode, +} from '../../../__tests__/utils'; +import {dfs} from '../../OutlineNodeHelpers'; +import {createParagraphNode, isParagraphNode} from 'outline/ParagraphNode'; +import {createTextNode} from 'outline'; + +describe('OutlineNodeHelpers tests', () => { + initializeUnitTest((testEnv) => { + /** + * R + * P1 P2 + * B1 B2 T4 T5 B3 + * T1 T2 T3 T6 + * + * DFS: R, P1, B1, T1, B2, T2, T3, P2, T4, T5, B3, T6 + */ + test('DFS node order', async () => { + const editor: OutlineEditor = testEnv.editor; + let expectedKeys: Array = []; + await editor.update((view: View) => { + const root = view.getRoot(); + const paragraph1 = createParagraphNode(); + const paragraph2 = createParagraphNode(); + const block1 = createTestBlockNode(); + const block2 = createTestBlockNode(); + const block3 = createTestBlockNode(); + const text1 = createTextNode('text1'); + const text2 = createTextNode('text2'); + const text3 = createTextNode('text3'); + const text4 = createTextNode('text4'); + const text5 = createTextNode('text5'); + const text6 = createTextNode('text6'); + root.append(paragraph1); + root.append(paragraph2); + paragraph1.append(block1); + paragraph1.append(block2); + paragraph2.append(text4); + paragraph2.append(text5); + text5.toggleBold(); // Prevent from merging with text 4 + paragraph2.append(block3); + block1.append(text1); + block2.append(text2); + block2.append(text3); + text3.toggleBold(); // Prevent from merging with text2 + block3.append(text6); + + expectedKeys = [ + root.getKey(), + paragraph1.getKey(), + block1.getKey(), + text1.getKey(), + block2.getKey(), + text2.getKey(), + text3.getKey(), + paragraph2.getKey(), + text4.getKey(), + text5.getKey(), + block3.getKey(), + text6.getKey(), + ]; + }); + + const dfsKeys = []; + await editor.update((view: View) => { + const root = view.getRoot(); + dfs(root, (node: OutlineNode) => { + dfsKeys.push(node.getKey()); + return node; + }); + }); + expect(dfsKeys).toEqual(expectedKeys); + }); + + test('Skip some DFS nodes', async () => { + const editor: OutlineEditor = testEnv.editor; + let expectedKeys: Array = []; + await editor.update((view: View) => { + const root = view.getRoot(); + const paragraph1 = createParagraphNode(); + const block1 = createTestBlockNode(); + const block2 = createTestBlockNode(); + const block3 = createTestBlockNode(); + root.append(paragraph1); + paragraph1.append(block1); + paragraph1.append(block2); + paragraph1.append(block3); + + expectedKeys = [root.getKey(), paragraph1.getKey(), block3.getKey()]; + }); + + const dfsKeys = []; + await editor.update((view: View) => { + const root = view.getRoot(); + dfs(root, (node: OutlineNode) => { + dfsKeys.push(node.getKey()); + if (isParagraphNode(node)) { + return ( + node.getLastChild() && node.getLastChild().getPreviousSibling() + ); + } + return node; + }); + }); + expect(dfsKeys).toEqual(expectedKeys); + }); + }); +}); diff --git a/scripts/build.js b/scripts/build.js index ea896333b..d9f3d3248 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -143,6 +143,12 @@ async function build(name, inputFile, outputFile) { 'packages/outline/src/helpers/OutlineKeyHelpers', ), }, + { + find: isWWW ? 'Outline/NodeHelpers' : 'outline/NodeHelpers', + replacement: path.resolve( + 'packages/outline/src/helpers/OutlineNodeHelpers', + ), + }, { find: isWWW ? 'Outline/TextHelpers' : 'outline/TextHelpers', replacement: path.resolve(