Skip to content

Commit

Permalink
Add a @byContent selector for reparenting child content (#5)
Browse files Browse the repository at this point in the history
* Initial support for @byContent decorator

* Convert indentation to spaces

* Add beginnings of a test

* Full test for @byContent decorator

* Add documentation for the @byContent decorator

* Change label in test

* Respond to code review for byContent decorator
  • Loading branch information
cuberoot authored and alexmirea committed Dec 6, 2018
1 parent 06d2456 commit d01d2d6
Show file tree
Hide file tree
Showing 5 changed files with 414 additions and 1 deletion.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ This utility is reponsible from converting a DOM node to a model. The model is d
* [byBooleanAttrVal](#bybooleanattrval)
* [byJsonAttrVal](#byjsonattrval)
* [byContentVal](#bycontentval)
* [byContent](#bycontent)
* [byChildContentVal](#bychildcontentval)
* [byChildRef](#bychildref)
* [byModel](#bymodel)
Expand Down Expand Up @@ -218,6 +219,44 @@ model ~ {
}
```
#### byContent
This decorator allows you to capture a DOM node that is matched by a CSS selector.
This can be used to reparent arbitrary child DOM content, which may not have been
rendered with React, into your web component. Once parsing has occurred, the field
in the model will contain a React component that represents the DOM content that
will be reparented.
The DOM content will be moved when the React component is mounted. And, the content
will be put back in its original location if the React component is later unmounted.
```js
@byContent(attrName:selector) - the CSS selector that will match the child node.
```
```js
class Model extends DOMModel {
@byContent('.content') content;
}
<div id="elem">
<div class="content">
This will be reparented
</div>
</div>
<div id="mount-point"/>
const model = new Model().fromDOM(document.getElementById("elem"));
ReactDOM.render(<div>{ model.content }</div>, document.getElementById("mount-point"))
// Once React has rendered the above component, the DOM will look like this
<div id="elem">
<!-- placeholder for DIV -->
</div>
<div id="mount-point">
<div class="content">
This will be reparented
</div>
</div>
```
#### byChildContentVal
Parse the element looking for an element that matches the given selector and sets value to the `innerText` of that element
```js
Expand Down
84 changes: 84 additions & 0 deletions lib/dom-model/DOMDecorators.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
import DOMNode from './DOMNode';
import EmbedNode from './EmbedNode';
import React from 'react';

let _idCount = 0;

function makeDecorator(callback) {
return function (...args) {
Expand All @@ -20,6 +25,41 @@ function makeDecorator(callback) {
}
}

function makeDOMNode(target, selector) {
const dataNode = new DOMNode();
dataNode.node = target;
dataNode.selector = selector;
target._reactComponentDataNode = dataNode;
return dataNode;
}

function findCommentNode(element, selector) {
for(let i = 0; i < element.childNodes.length; i++) {
let node = element.childNodes[i];
if( node.nodeType === Node.COMMENT_NODE
&& node._reactComponentDataNode
&& node._reactComponentDataNode.selector === selector) {
return node;
}
}
}

function queryChildren(element, selector, all = false) {
if (typeof selector !== 'string') {
console.warn('Query selector must be string!');
return;
}
let id = element.id,
guid = element.id = id || 'query_children_' + _idCount++,
attr = '#' + guid + ' > ',
scopedSelector = attr + (selector + '').replace(',', ',' + attr, 'g');
let result = all ? element.querySelectorAll(scopedSelector) : element.querySelector(scopedSelector);
if (!id) {
element.removeAttribute('id');
}
return result;
}

/**
* Parses the element and returns the innerText
*
Expand All @@ -36,6 +76,7 @@ let byContentVal = makeDecorator(function() {
}
});


/**
* Parses the element and returns the value of the provided attribute
*
Expand Down Expand Up @@ -170,6 +211,48 @@ function attachContentObserver(target, key, element, child, valueFn) {
}
}

/**
* Adds to the model a property that returns a react component that, when
* rendered, will produce the node matched by the given selector. The
* React component will "steal" the DOM node from it's original parent.
* If the React component is later removed from the render tree, the DOM
* node that was stolen will be replaced in its original parent.
*
* @param {String} selector - the selector for the child to turn into a React component
*
* @returns {function} - the decorator function
*/
let byContent = makeDecorator(function(selector) {
return function (target, key, descriptor) {
descriptor.writable = true;
if (target.addProperty) {
target.addProperty(key, (element) => {
if (element && (element instanceof HTMLElement)) {
let node = null;

let valueFn = () => {
if (!node) {
let child = queryChildren(element, selector);
if (child) {
node = <EmbedNode item={ makeDOMNode(child, selector) }/>;
}
}
return node;
}
let result = valueFn();
if (!result) {
// We could not match the selector. That is possibly because
// a rendering engine has not filled in the children yet.
// Watch for DOM mutations that add a matching element
attachContentObserver(target, key, element, element, valueFn);
}
return valueFn();
}
});
}
}
});

/**
* Parses the element and returns the innerText of the child element with the provided name
*
Expand Down Expand Up @@ -349,6 +432,7 @@ export {
byAttrVal,
byBooleanAttrVal,
byContentVal,
byContent,
byChildContentVal,
byJsonAttrVal,
byModel,
Expand Down
171 changes: 171 additions & 0 deletions lib/dom-model/DOMNode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
Copyright 2018 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

/* eslint no-constant-condition: "off" */

const domNodeMutationOptions = { childList: true };

function handleDOMNodeMutation(mutations, observer) {
const domNode = observer._domNode;
if (!domNode.stolen) {
return;
}
const node = domNode.node;
const parentNode = node.parentNode;
if (!parentNode) {
domNode.applicationDidRemoveItem();
}
}
/**
* This is a container around a HTMLElement which allows for the element to be removed from the DOM
* replaced with a comment and then returned back to the DOM
*/
export default class DOMNode {

/**
* Removes the node from the DOM and replaces with a comment
* @returns {HTMLElement} - the HTML DOM node
*/
stealNode() {
if (this.stolen) {
return null;
}

const node = this.node;
this.returned = false;

var placeholder = this.placeholder;
if (!placeholder) {
placeholder = this.placeholder = document.createComment('placeholder for ' + node.nodeName);
placeholder._reactComponentDataNode = this;
}

node.parentNode.replaceChild(placeholder, node);
this.stolen = true;
return this.node;
}

/**
* Returns the node to the DOM. It replaceses the comment with the node
*/
returnNode() {
if (!this.stolen) {
return;
}

this.stolen = false;
this.returned = true;
this.stopObserving();

const placeholder = this.placeholder;
const placeholderParent = placeholder.parentNode;
if (placeholderParent) {
placeholderParent.replaceChild(this.node, placeholder);
}
}

/**
* Observes the mutations of the parent node to check if the users are removing the node.
*/
observe() {
if (this.observer) {
this.stopObserving();
}
const observer = this.observer = new MutationObserver(handleDOMNodeMutation);
observer._domNode = this;
observer.observe(this.node.parentNode, domNodeMutationOptions);
}

/**
* Stops the mutation observer
*/
stopObserving() {
const observer = this.observer;
if (observer) {
observer.disconnect();
this.observer = null;
}
}

/**
* Marks that the application removed the item
*/
applicationDidRemoveItem() {
this.stolen = false;
this.returned = true;
this.stopObserving();
this.remove();
}

/**
* Adds the node to the list of child elements.
* It will look for the correct position in the list of the element.
* @param {Array<Object>} - the list of the child element
*/
add(list) {

this.list = list;
var target = this.span || this.node;
do {
target = target.previousSibling;
if (!target) {
// this is the first item, just insert it at the very top.
list.splice(0, 0, this);
return;
}
const data = target._reactComponentDataNode;
if (data) {
// If the element is stolen, then we need to use the placeholder and not
// the actual element. Otherwise the element might actually be added to the same
// list of elements and might use the wrong position.
if (data.stolen && data.placeholder !== target) {
// Continue until we find the right element.
continue;
}
const index = list.indexOf(data);
if (index !== -1) {
list.splice(index + 1, 0, this);
return;
}
}
} while (true);
}

/**
* Removes the node
*/
remove() {
const list = this.list;
if (list) {
list.removeItem(this);
this.list = null;
}
const node = this.node;
if (node._reactComponentDataNode === this) {
node._reactComponentDataNode = null;
}
this.removePlaceholder();
}

/**
* Removes the placeholder of the node
*/
removePlaceholder() {
const placeholder = this.placeholder;
if (placeholder) {
const placeholderParent = placeholder.parentNode;
if (placeholderParent) {
placeholderParent.removeChild(placeholder);
}
}
}
}
41 changes: 41 additions & 0 deletions lib/dom-model/EmbedNode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
Copyright 2018 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

import React, {Component} from 'react';

export default class EmbedNode extends Component {

render() {
return <div ref={ (element) => this.element = element }/>
}

componentDidMount() {
this.parent = this.element.parentElement;
this.stolenNode = this.props.item.stealNode();
this.parent.replaceChild(this.stolenNode, this.element);
}

componentWillUnmount() {
this.parent.replaceChild(this.element, this.stolenNode);
this.props.item.returnNode();
delete this.stolenNode;
}

shouldComponentUpdate() {
// Prevent this node from rendering after it is mounted
return false;
}

get hasStolenNode() {
return this.stolenNode != null
}
}
Loading

0 comments on commit d01d2d6

Please sign in to comment.