From 13adf4054d05652a0bde2cf032b2b93939d2916f Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Sat, 22 Jun 2019 19:39:01 -0700 Subject: [PATCH 1/2] [jsdom-compat] Optionally allow use of external DOM --- package.json | 5 +- src/build-layout.js | 23 ++-- src/components/App.js | 1 + src/dom.js | 103 +++++++++++++++-- src/instance.js | 19 +++- src/reconciler.js | 208 ++++++++++++++++++----------------- src/render-node-to-output.js | 40 ++++--- src/renderer.js | 46 ++++---- 8 files changed, 283 insertions(+), 162 deletions(-) diff --git a/package.json b/package.json index b48200a55..637ea0f23 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "cli-cursor": "^2.1.0", "cli-truncate": "^1.1.0", "is-ci": "^2.0.0", + "jsdom": "^15.1.1", "lodash.throttle": "^4.1.1", "log-update": "^3.0.0", "prop-types": "^15.6.2", @@ -72,6 +73,7 @@ "eslint-plugin-react": "^7.11.1", "eslint-plugin-react-hooks": "^1.4.0", "import-jsx": "^1.3.0", + "inspect-process": "^0.5.0", "ms": "^2.1.1", "node-pty": "^0.8.1", "p-queue": "^3.0.0", @@ -82,7 +84,8 @@ "xo": "^0.24.0" }, "peerDependencies": { - "react": ">=16.8.0" + "react": ">=16.8.0", + "react-dom": "^16.8.6" }, "babel": { "plugins": [ diff --git a/src/build-layout.js b/src/build-layout.js index e9f3eb1cd..c13f1c094 100644 --- a/src/build-layout.js +++ b/src/build-layout.js @@ -3,7 +3,7 @@ import applyStyles from './apply-styles'; import measureText from './measure-text'; // Traverse the node tree, create Yoga nodes and assign styles to each Yoga node -const buildLayout = (node, options) => { +const buildLayout = (documentHelpers, node, options) => { const {config, terminalWidth, skipStaticElements} = options; const yogaNode = Yoga.Node.create(config); node.yogaNode = yogaNode; @@ -15,13 +15,15 @@ const buildLayout = (node, options) => { // `terminalWidth` can be `undefined` if env isn't a TTY yogaNode.setWidth(terminalWidth || 100); - if (node.childNodes.length > 0) { - const childNodes = node.childNodes.filter(childNode => { + const childNodes1 = documentHelpers.getChildNodes(node); + + if (childNodes1.length > 0) { + const childNodes = childNodes1.filter(childNode => { return skipStaticElements ? !childNode.unstable__static : true; }); for (const [index, childNode] of Object.entries(childNodes)) { - const childYogaNode = buildLayout(childNode, options).yogaNode; + const childYogaNode = buildLayout(documentHelpers, childNode, options).yogaNode; yogaNode.insertChild(childYogaNode, index); } } @@ -33,21 +35,24 @@ const buildLayout = (node, options) => { applyStyles(yogaNode, style); // Nodes with only text have a child Yoga node dedicated for that text - if (node.textContent || node.nodeValue) { - const {width, height} = measureText(node.textContent || node.nodeValue); + const textContent = documentHelpers.getTextContent(node); + if (textContent || node.nodeValue) { + const {width, height} = measureText(textContent || node.nodeValue); yogaNode.setWidth(style.width || width); yogaNode.setHeight(style.height || height); return node; } - if (Array.isArray(node.childNodes) && node.childNodes.length > 0) { - const childNodes = node.childNodes.filter(childNode => { + const childNodes1 = documentHelpers.getChildNodes(node); + + if (Array.isArray(childNodes1) && childNodes1.length > 0) { + const childNodes = childNodes1.filter(childNode => { return skipStaticElements ? !childNode.unstable__static : true; }); for (const [index, childNode] of Object.entries(childNodes)) { - const {yogaNode: childYogaNode} = buildLayout(childNode, options); + const {yogaNode: childYogaNode} = buildLayout(documentHelpers, childNode, options); yogaNode.insertChild(childYogaNode, index); } } diff --git a/src/components/App.js b/src/components/App.js index 6bec457a5..748c7572f 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -6,6 +6,7 @@ import AppContext from './AppContext'; import StdinContext from './StdinContext'; import StdoutContext from './StdoutContext'; + // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility diff --git a/src/dom.js b/src/dom.js index eb24e9752..7f59f6b52 100644 --- a/src/dom.js +++ b/src/dom.js @@ -1,5 +1,54 @@ // Helper utilities implementing some common DOM methods to simplify reconciliation code -export const createNode = tagName => ({ +const _documentCreateNode = (document, tagName) => { + return document.createElement(tagName); +}; + +const _documentAppendChildNode = (node, childNode) => { + if (childNode.parentNode) { + childNode.parentNode.removeChild(childNode); + } + + node.append(childNode); +}; // Same as `appendChildNode`, but without removing child node from parent node + +const _documentAppendStaticNode = (node, childNode) => { + node.append(childNode); +}; + +const _documentInsertBeforeNode = (node, newChildNode, beforeChildNode) => { + if (newChildNode.parentNode) { + newChildNode.parentNode.removeChild(newChildNode); + } + + node.insertBefore(newChildNode, beforeChildNode); +}; + +const _documentRemoveChildNode = (node, removeNode) => { + node.removeChild(removeNode); +}; + +const _documentSetAttribute = (node, key, value) => { + node.setAttribute(key, value); +}; + +const _documentCreateTextNode = (document, text) => { + return document.createTextNode(text); +}; + +const _documentGetChildNodes = node => { + return [...node.childNodes]; +}; + +const _documentGetTextContent = node => { + if (node.nodeType === 3) { + return node.data; + } + + return null; +}; + +// Helper utilities implementing some common DOM methods to simplify reconciliation code +const _createNode = tagName => ({ nodeName: tagName.toUpperCase(), style: {}, attributes: {}, @@ -7,9 +56,9 @@ export const createNode = tagName => ({ parentNode: null }); -export const appendChildNode = (node, childNode) => { +const _appendChildNode = (node, childNode) => { if (childNode.parentNode) { - removeChildNode(childNode.parentNode, childNode); + _removeChildNode(childNode.parentNode, childNode); } childNode.parentNode = node; @@ -18,13 +67,13 @@ export const appendChildNode = (node, childNode) => { }; // Same as `appendChildNode`, but without removing child node from parent node -export const appendStaticNode = (node, childNode) => { +const _appendStaticNode = (node, childNode) => { node.childNodes.push(childNode); }; -export const insertBeforeNode = (node, newChildNode, beforeChildNode) => { +const _insertBeforeNode = (node, newChildNode, beforeChildNode) => { if (newChildNode.parentNode) { - removeChildNode(newChildNode.parentNode, newChildNode); + _removeChildNode(newChildNode.parentNode, newChildNode); } newChildNode.parentNode = node; @@ -38,7 +87,7 @@ export const insertBeforeNode = (node, newChildNode, beforeChildNode) => { node.childNodes.push(newChildNode); }; -export const removeChildNode = (node, removeNode) => { +const _removeChildNode = (node, removeNode) => { removeNode.parentNode = null; const index = node.childNodes.indexOf(removeNode); @@ -47,11 +96,47 @@ export const removeChildNode = (node, removeNode) => { } }; -export const setAttribute = (node, key, value) => { +const _setAttribute = (node, key, value) => { node.attributes[key] = value; }; -export const createTextNode = text => ({ +const _createTextNode = text => ({ nodeName: '#text', nodeValue: text }); + +const _getChildNodes = node => { + return node.childNodes; +}; + +const _getTextContent = node => { + return node.textContent; +}; + +export const createDocumentHelpers = document => { + if (document) { + return Object.freeze({ + createNode: tagName => _documentCreateNode(document, tagName), + appendChildNode: _documentAppendChildNode, + appendStaticNode: _documentAppendStaticNode, + insertBeforeNode: _documentInsertBeforeNode, + removeChildNode: _documentRemoveChildNode, + setAttribute: _documentSetAttribute, + createTextNode: text => _documentCreateTextNode(document, text), + getChildNodes: _documentGetChildNodes, + getTextContent: _documentGetTextContent + }); + } + + return Object.freeze({ + createNode: _createNode, + appendChildNode: _appendChildNode, + appendStaticNode: _appendStaticNode, + insertBeforeNode: _insertBeforeNode, + removeChildNode: _removeChildNode, + setAttribute: _setAttribute, + createTextNode: _createTextNode, + getChildNodes: _getChildNodes, + getTextContent: _getTextContent + }); +}; diff --git a/src/instance.js b/src/instance.js index 1733b1cd9..703d1b664 100644 --- a/src/instance.js +++ b/src/instance.js @@ -4,9 +4,9 @@ import autoBind from 'auto-bind'; import logUpdate from 'log-update'; import isCI from 'is-ci'; import signalExit from 'signal-exit'; -import reconciler from './reconciler'; +import {createReconciler} from './reconciler'; import createRenderer from './renderer'; -import {createNode} from './dom'; +import {createDocumentHelpers} from './dom'; import instances from './instances'; import App from './components/App'; @@ -15,10 +15,12 @@ export default class Instance { autoBind(this); this.options = options; + this.documentHelpers = createDocumentHelpers(options.document); - this.rootNode = createNode('root'); + this.rootNode = this.documentHelpers.createNode('root'); this.rootNode.onRender = this.onRender; this.renderer = createRenderer({ + documentHelpers: this.documentHelpers, terminalWidth: options.stdout.columns }); @@ -38,7 +40,12 @@ export default class Instance { // so that it's rerendered every time, not just new static parts, like in non-debug mode this.fullStaticOutput = ''; - this.container = reconciler.createContainer(this.rootNode, false, false); + this.reconciler = createReconciler(this.documentHelpers); + this.container = this.reconciler.createContainer(this.rootNode, false, false); + + if (options.document) { + options.document.body.append(this.rootNode); + } this.exitPromise = new Promise((resolve, reject) => { this.resolveExitPromise = resolve; @@ -102,7 +109,7 @@ export default class Instance { ); - reconciler.updateContainer(tree, this.container); + this.reconciler.updateContainer(tree, this.container); } unmount(error) { @@ -122,7 +129,7 @@ export default class Instance { } this.isUnmounted = true; - reconciler.updateContainer(null, this.container); + this.reconciler.updateContainer(null, this.container); instances.delete(this.options.stdout); if (error instanceof Error) { diff --git a/src/reconciler.js b/src/reconciler.js index e094b5554..72cbbb3e1 100644 --- a/src/reconciler.js +++ b/src/reconciler.js @@ -3,122 +3,128 @@ import { unstable_cancelCallback as cancelPassiveEffects } from 'scheduler'; import ReactReconciler from 'react-reconciler'; -import { - createNode, - createTextNode, - appendChildNode, - insertBeforeNode, - removeChildNode, - setAttribute -} from './dom'; const NO_CONTEXT = true; -const hostConfig = { - schedulePassiveEffects, - cancelPassiveEffects, - now: Date.now, - getRootHostContext: () => NO_CONTEXT, - prepareForCommit: () => {}, - resetAfterCommit: rootNode => { - rootNode.onRender(); - }, - getChildHostContext: () => NO_CONTEXT, - shouldSetTextContent: (type, props) => { - return typeof props.children === 'string' || typeof props.children === 'number'; - }, - createInstance: (type, newProps) => { - const node = createNode(type); +function createHostConfig(documentHelpers) { + return { + schedulePassiveEffects, + cancelPassiveEffects, + now: Date.now, + getRootHostContext: () => NO_CONTEXT, + prepareForCommit: () => {}, + resetAfterCommit: rootNode => { + rootNode.onRender(); + }, + getChildHostContext: () => NO_CONTEXT, + shouldSetTextContent: (type, props) => { + return typeof props.children === 'string' || typeof props.children === 'number'; + }, + createInstance: (type, newProps) => { + const node = documentHelpers.createNode(type); - for (const [key, value] of Object.entries(newProps)) { - if (key === 'children') { - if (typeof value === 'string' || typeof value === 'number') { - if (type === 'div') { - // Text node must be wrapped in another node, so that text can be aligned within container - const textElement = createNode('div'); - textElement.textContent = String(value); - appendChildNode(node, textElement); - } + for (const [key, value] of Object.entries(newProps)) { + if (key === 'children') { + if (typeof value === 'string' || typeof value === 'number') { + if (type === 'div') { + // Text node must be wrapped in another node, so that text can be aligned within container + const textElement = documentHelpers.createNode('div'); + textElement.textContent = String(value); + documentHelpers.appendChildNode(node, textElement); + } - if (type === 'span') { - node.textContent = String(value); + if (type === 'span') { + node.textContent = String(value); + } } + } else if (key === 'style') { + Object.assign(node.style, value); + } else if (key === 'unstable__transformChildren') { + node.unstable__transformChildren = value; // eslint-disable-line camelcase + } else if (key === 'unstable__static') { + node.unstable__static = true; // eslint-disable-line camelcase + } else { + documentHelpers.setAttribute(node, key, value); } - } else if (key === 'style') { - Object.assign(node.style, value); - } else if (key === 'unstable__transformChildren') { - node.unstable__transformChildren = value; // eslint-disable-line camelcase - } else if (key === 'unstable__static') { - node.unstable__static = true; // eslint-disable-line camelcase - } else { - setAttribute(node, key, value); } - } - return node; - }, - createTextInstance: createTextNode, - resetTextContent: node => { - if (node.textContent) { - node.textContent = ''; - } + return node; + }, + createTextInstance: documentHelpers.createTextNode, + resetTextContent: node => { + if (node.textContent) { + node.textContent = ''; + } + + const childNodes = documentHelpers.getChildNodes(node); - if (node.childNodes.length > 0) { - for (const childNode of node.childNodes) { - childNode.yogaNode.free(); - removeChildNode(node, childNode); + if (childNodes.length > 0) { + for (const childNode of childNodes) { + childNode.yogaNode.free(); + documentHelpers.removeChildNode(node, childNode); + } } - } - }, - getPublicInstance: instance => instance, - appendInitialChild: appendChildNode, - appendChild: appendChildNode, - insertBefore: insertBeforeNode, - finalizeInitialChildren: () => {}, - supportsMutation: true, - appendChildToContainer: appendChildNode, - insertInContainerBefore: insertBeforeNode, - removeChildFromContainer: removeChildNode, - prepareUpdate: () => true, - commitUpdate: (node, updatePayload, type, oldProps, newProps) => { - for (const [key, value] of Object.entries(newProps)) { - if (key === 'children') { - if (typeof value === 'string' || typeof value === 'number') { - if (type === 'div') { - // Text node must be wrapped in another node, so that text can be aligned within container - // If there's no such node, a new one must be created - if (node.childNodes.length === 0) { - const textElement = createNode('div'); - textElement.textContent = String(value); - appendChildNode(node, textElement); - } else { - node.childNodes[0].textContent = String(value); + }, + getPublicInstance: instance => instance, + appendInitialChild: documentHelpers.appendChildNode, + appendChild: documentHelpers.appendChildNode, + insertBefore: documentHelpers.insertBeforeNode, + finalizeInitialChildren: () => {}, + supportsMutation: true, + appendChildToContainer: documentHelpers.appendChildNode, + insertInContainerBefore: documentHelpers.insertBeforeNode, + removeChildFromContainer: documentHelpers.removeChildNode, + prepareUpdate: () => true, + commitUpdate: (node, updatePayload, type, oldProps, newProps) => { + for (const [key, value] of Object.entries(newProps)) { + if (key === 'children') { + if (typeof value === 'string' || typeof value === 'number') { + if (type === 'div') { + // Text node must be wrapped in another node, so that text can be aligned within container + // If there's no such node, a new one must be created + if (node.childNodes.length === 0) { + const textElement = documentHelpers.createNode('div'); + textElement.textContent = String(value); + documentHelpers.appendChildNode(node, textElement); + } else { + node.childNodes[0].textContent = String(value); + } } - } - if (type === 'span') { - node.textContent = String(value); + if (type === 'span') { + node.textContent = String(value); + } } + } else if (key === 'style') { + Object.assign(node.style, value); + } else if (key === 'unstable__transformChildren') { + node.unstable__transformChildren = value; // eslint-disable-line camelcase + } else if (key === 'unstable__static') { + node.unstable__static = true; // eslint-disable-line camelcase + } else { + documentHelpers.setAttribute(node, key, value); } - } else if (key === 'style') { - Object.assign(node.style, value); - } else if (key === 'unstable__transformChildren') { - node.unstable__transformChildren = value; // eslint-disable-line camelcase - } else if (key === 'unstable__static') { - node.unstable__static = true; // eslint-disable-line camelcase + } + }, + commitTextUpdate: (node, oldText, newText) => { + if (node.nodeName === '#text') { + node.nodeValue = newText; } else { - setAttribute(node, key, value); + node.textContent = newText; } - } - }, - commitTextUpdate: (node, oldText, newText) => { - if (node.nodeName === '#text') { - node.nodeValue = newText; - } else { - node.textContent = newText; - } - }, - removeChild: removeChildNode -}; + }, + removeChild: documentHelpers.removeChildNode + }; +} + +var _cachedReconciler = null; // eslint-disable-line no-var -export default ReactReconciler(hostConfig); // eslint-disable-line new-cap +export const createReconciler = documentHelpers => { + if (_cachedReconciler) { + return _cachedReconciler; + } + + // Hopefully documentHelpers is the same... + _cachedReconciler = ReactReconciler(createHostConfig(documentHelpers)); // eslint-disable-line new-cap + return _cachedReconciler; +}; diff --git a/src/render-node-to-output.js b/src/render-node-to-output.js index 89f49f17f..462746118 100644 --- a/src/render-node-to-output.js +++ b/src/render-node-to-output.js @@ -2,18 +2,21 @@ import widestLine from 'widest-line'; import wrapText from './wrap-text'; import getMaxWidth from './get-max-width'; -const isAllTextNodes = node => { +const isAllTextNodes = (documentHelpers, node) => { if (node.nodeName === '#text') { return true; } if (node.nodeName === 'SPAN') { - if (node.textContent) { + if (documentHelpers.getTextContent(node)) { return true; } - if (Array.isArray(node.childNodes)) { - return node.childNodes.every(isAllTextNodes); + const childNodes = documentHelpers.getChildNodes(node); + + if (Array.isArray(childNodes)) { + const fn = node => isAllTextNodes(documentHelpers, node); + return childNodes.every(fn); } } @@ -26,10 +29,12 @@ const isAllTextNodes = node => { // // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link), // which need to wrap all children at once, instead of wrapping 3 text nodes separately. -const squashTextNodes = node => { +const squashTextNodes = (documentHelpers, node) => { let text = ''; - for (const childNode of node.childNodes) { + const childNodes = documentHelpers.getChildNodes(node); + + for (const childNode of childNodes) { let nodeText; if (childNode.nodeName === '#text') { @@ -37,7 +42,7 @@ const squashTextNodes = node => { } if (childNode.nodeName === 'SPAN') { - nodeText = childNode.textContent || squashTextNodes(childNode); + nodeText = documentHelpers.getTextContent(childNode) || squashTextNodes(documentHelpers, childNode); } // Since these text nodes are being concatenated, `Output` instance won't be able to @@ -53,7 +58,7 @@ const squashTextNodes = node => { }; // After nodes are laid out, render each to output object, which later gets rendered to terminal -const renderNodeToOutput = (node, output, {offsetX = 0, offsetY = 0, transformers = [], skipStaticElements}) => { +const renderNodeToOutput = (documentHelpers, node, output, {offsetX = 0, offsetY = 0, transformers = [], skipStaticElements}) => { if (node.unstable__static && skipStaticElements) { return; } @@ -72,12 +77,12 @@ const renderNodeToOutput = (node, output, {offsetX = 0, offsetY = 0, transformer } // Nodes with only text inside - if (node.textContent) { - let text = node.textContent; + if (documentHelpers.getTextContent(node)) { + let text = documentHelpers.getTextContent(node); // Since text nodes are always wrapped in an additional node, parent node // is where we should look for attributes - if (node.parentNode.style.textWrap) { + if (node.parentNode && node.parentNode.style && node.parentNode.style.textWrap) { const currentWidth = widestLine(text); const maxWidth = getMaxWidth(node.parentNode.yogaNode); @@ -99,11 +104,14 @@ const renderNodeToOutput = (node, output, {offsetX = 0, offsetY = 0, transformer } // Nodes that have other nodes as children - if (Array.isArray(node.childNodes) && node.childNodes.length > 0) { + const childNodes = documentHelpers.getChildNodes(node); + if (Array.isArray(childNodes) && childNodes.length > 0) { const isFlexDirectionRow = node.style.flexDirection === 'row'; - if (isFlexDirectionRow && node.childNodes.every(isAllTextNodes)) { - let text = squashTextNodes(node); + const fn = node => isAllTextNodes(documentHelpers, node); + + if (isFlexDirectionRow && childNodes.every(fn)) { + let text = squashTextNodes(documentHelpers, node); if (node.style.textWrap) { const currentWidth = widestLine(text); @@ -120,8 +128,8 @@ const renderNodeToOutput = (node, output, {offsetX = 0, offsetY = 0, transformer return; } - for (const childNode of node.childNodes) { - renderNodeToOutput(childNode, output, { + for (const childNode of childNodes) { + renderNodeToOutput(documentHelpers, childNode, output, { offsetX: x, offsetY: y, transformers: newTransformers, diff --git a/src/renderer.js b/src/renderer.js index 1b850f120..30a42df9f 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1,6 +1,5 @@ import Yoga from 'yoga-layout-prebuilt'; import Output from './output'; -import {createNode, appendStaticNode} from './dom'; import buildLayout from './build-layout'; import renderNodeToOutput from './render-node-to-output'; import measureText from './measure-text'; @@ -9,8 +8,9 @@ import getMaxWidth from './get-max-width'; // Since we need to know the width of text container to wrap text, we have to calculate layout twice // This function is executed after first layout calculation to reassign width and height of text nodes -const calculateWrappedText = node => { - if (node.textContent && typeof node.parentNode.style.textWrap === 'string') { +const calculateWrappedText = (documentHelpers, node) => { + const textContent = documentHelpers.getTextContent(node); + if (textContent && node.parentNode && node.parentNode.style && typeof node.parentNode.style.textWrap === 'string') { const {yogaNode} = node; const parentYogaNode = node.parentNode.yogaNode; const maxWidth = getMaxWidth(parentYogaNode); @@ -28,24 +28,30 @@ const calculateWrappedText = node => { return; } - if (Array.isArray(node.childNodes) && node.childNodes.length > 0) { - for (const childNode of node.childNodes) { - calculateWrappedText(childNode); + const childNodes = documentHelpers.getChildNodes(node); + + if (childNodes && childNodes.length > 0) { + for (const childNode of childNodes) { + calculateWrappedText(documentHelpers, childNode); } } }; // Since components can be placed anywhere in the tree, this helper finds and returns them -const getStaticNodes = element => { +const getStaticNodes = (documentHelpers, element) => { const staticNodes = []; - for (const childNode of element.childNodes) { + const elementChildNodes = documentHelpers.getChildNodes(element); + + for (const childNode of elementChildNodes) { if (childNode.unstable__static) { staticNodes.push(childNode); } - if (Array.isArray(childNode.childNodes) && childNode.childNodes.length > 0) { - staticNodes.push(...getStaticNodes(childNode)); + const childNodes = documentHelpers.getChildNodes(childNode); + + if (Array.isArray(childNodes) && childNodes.length > 0) { + staticNodes.push(...getStaticNodes(documentHelpers, childNode)); } } @@ -53,7 +59,7 @@ const getStaticNodes = element => { }; // Build layout, apply styles, build text output of all nodes and return it -export default ({terminalWidth}) => { +export default ({documentHelpers, terminalWidth}) => { const config = Yoga.Config.create(); // Used to free up memory used by last Yoga node tree @@ -69,7 +75,7 @@ export default ({terminalWidth}) => { lastStaticYogaNode.freeRecursive(); } - const staticElements = getStaticNodes(node); + const staticElements = getStaticNodes(documentHelpers, node); if (staticElements.length > 1) { if (process.env.NODE_ENV !== 'production') { console.error('Warning: There can only be one component'); @@ -79,17 +85,17 @@ export default ({terminalWidth}) => { // component must be built and rendered separately, so that the layout of the other output is unaffected let staticOutput; if (staticElements.length === 1) { - const rootNode = createNode('root'); - appendStaticNode(rootNode, staticElements[0]); + const rootNode = documentHelpers.createNode('root'); + documentHelpers.appendStaticNode(rootNode, staticElements[0]); - const {yogaNode: staticYogaNode} = buildLayout(rootNode, { + const {yogaNode: staticYogaNode} = buildLayout(documentHelpers, rootNode, { config, terminalWidth, skipStaticElements: false }); staticYogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR); - calculateWrappedText(rootNode); + calculateWrappedText(documentHelpers, rootNode); staticYogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR); // Save current Yoga node tree to free up memory later @@ -100,17 +106,17 @@ export default ({terminalWidth}) => { height: staticYogaNode.getComputedHeight() }); - renderNodeToOutput(rootNode, staticOutput, {skipStaticElements: false}); + renderNodeToOutput(documentHelpers, rootNode, staticOutput, {skipStaticElements: false}); } - const {yogaNode} = buildLayout(node, { + const {yogaNode} = buildLayout(documentHelpers, node, { config, terminalWidth, skipStaticElements: true }); yogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR); - calculateWrappedText(node); + calculateWrappedText(documentHelpers, node); yogaNode.calculateLayout(Yoga.UNDEFINED, Yoga.UNDEFINED, Yoga.DIRECTION_LTR); // Save current node tree to free up memory later @@ -121,7 +127,7 @@ export default ({terminalWidth}) => { height: yogaNode.getComputedHeight() }); - renderNodeToOutput(node, output, {skipStaticElements: true}); + renderNodeToOutput(documentHelpers, node, output, {skipStaticElements: true}); return { output: output.get(), From 5d54a3c5df1a18c53cf4224506b65f0bca7de76e Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Tue, 25 Jun 2019 02:38:45 -0700 Subject: [PATCH 2/2] [jsdom-compat] Dispatch keypress events when DOM is available --- package.json | 1 + src/components/App.js | 85 ++++++++++++++++++++++++++++++++++++++++++- src/instance.js | 2 + 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 637ea0f23..3108a4815 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "cli-truncate": "^1.1.0", "is-ci": "^2.0.0", "jsdom": "^15.1.1", + "keycode": "^2.2.0", "lodash.throttle": "^4.1.1", "log-update": "^3.0.0", "prop-types": "^15.6.2", diff --git a/src/components/App.js b/src/components/App.js index 748c7572f..7d7e779f2 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -2,10 +2,82 @@ import readline from 'readline'; import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import cliCursor from 'cli-cursor'; +import {default as keycode} from 'keycode'; import AppContext from './AppContext'; import StdinContext from './StdinContext'; import StdoutContext from './StdoutContext'; +class DOMKeypressDispatcher extends PureComponent { + static propTypes = { + stdin: PropTypes.object.isRequired, + setRawMode: PropTypes.func.isRequired, + document: PropTypes.any, + window: PropTypes.any + }; + + componentDidMount() { + if (this.props.document) { + const {stdin, setRawMode} = this.props; + setRawMode(true); + stdin.on('keypress', this.dispatchInput); + } + } + + componentWillUnmount() { + if (this.props.document) { + const {stdin, setRawMode} = this.props; + stdin.removeListener('keypress', this.dispatchInput); + setRawMode(false); + } + } + + render() { + return (null); + } + + dispatchInput = (str, key) => { + const code = keycode(key.name); + const downEvent = new this.props.window.KeyboardEvent('keydown', { + key: key.name, + charCode: code, + ctrlKey: key.ctrl, + shiftKey: key.shift, + keyCode: code, + which: code, + bubbles: true, + repeat: false, + location: 0, + isComposing: false + }); + this.props.document.activeElement.dispatchEvent(downEvent); + const pressEvent = new this.props.window.KeyboardEvent('keypress', { + key: key.name, + charCode: code, + ctrlKey: key.ctrl, + shiftKey: key.shift, + keyCode: code, + which: code, + bubbles: true, + repeat: false, + location: 0, + isComposing: false + }); + this.props.document.activeElement.dispatchEvent(pressEvent); + const upEvent = new this.props.window.KeyboardEvent('keyup', { + key: key.name, + charCode: code, + ctrlKey: key.ctrl, + shiftKey: key.shift, + keyCode: code, + which: code, + bubbles: true, + repeat: false, + location: 0, + isComposing: false + }); + this.props.document.activeElement.dispatchEvent(upEvent); + } +} // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed @@ -16,7 +88,9 @@ export default class App extends PureComponent { stdin: PropTypes.object.isRequired, stdout: PropTypes.object.isRequired, exitOnCtrlC: PropTypes.bool.isRequired, - onExit: PropTypes.func.isRequired + onExit: PropTypes.func.isRequired, + window: PropTypes.object, + document: PropTypes.object }; // Determines if TTY is supported on the provided stdin @@ -33,6 +107,14 @@ export default class App extends PureComponent { } render() { + const keyboardEventDispatcher = (this.props.window && this.props.document) ? ( + + ) : null; return ( + {keyboardEventDispatcher} {this.props.children} diff --git a/src/instance.js b/src/instance.js index 703d1b664..e6c95b147 100644 --- a/src/instance.js +++ b/src/instance.js @@ -102,6 +102,8 @@ export default class Instance {