idyll-document
Version:
The Idyll runtime, implemented as a React component.
634 lines (574 loc) • 17.8 kB
JavaScript
import React from 'react';
import ReactDOM from 'react-dom';
import scrollparent from 'scrollparent';
import scrollMonitor from 'scrollmonitor';
import ReactJsonSchema from './utils/schema2element';
import entries from 'object.entries';
import values from 'object.values';
import { generatePlaceholder } from './components/placeholder';
import AuthorTool from './components/author-tool';
import { getChildren } from 'idyll-ast';
import equal from 'fast-deep-equal';
import * as layouts from 'idyll-layouts';
import * as themes from 'idyll-themes';
import {
getData,
getVars,
filterASTForDocument,
splitAST,
translate,
findWrapTargets,
filterIdyllProps,
mapTree,
evalExpression,
hooks,
scrollMonitorEvents
} from './utils';
const updatePropsCallbacks = [];
const updateRefsCallbacks = [];
const scrollWatchers = [];
const scrollOffsets = {};
const refCache = {};
const evalContext = {};
let scrollContainer;
const getLayout = layout => {
return layouts[layout.trim()] || {};
};
const getTheme = theme => {
return themes[theme.trim()] || {};
};
const getRefs = () => {
const refs = {};
if (!scrollContainer) {
return refCache;
}
scrollWatchers.forEach(watcher => {
// left and right props assume no horizontal scrolling
const {
watchItem,
callbacks,
container,
recalculateLocation,
offsets,
...watcherProps
} = watcher;
refs[watchItem.dataset.ref] = {
...watcherProps,
...refCache[watchItem.dataset.ref],
domNode: watchItem
};
});
return { ...refCache, ...refs };
};
let wrapperKey = 0;
const createWrapper = ({
theme,
layout,
authorView,
textEditComponent,
userViewComponent,
userInlineViewComponent,
wrapTextComponents
}) => {
return class Wrapper extends React.PureComponent {
constructor(props) {
super(props);
this.key = wrapperKey++;
this.ref = {};
this.onUpdateRefs = this.onUpdateRefs.bind(this);
this.onUpdateProps = this.onUpdateProps.bind(this);
const vars = values(props.__vars__);
const exps = values(props.__expr__);
this.usesRefs = exps.some(v => v && v.includes('refs.'));
this.state = { hasError: false, error: null };
// listen for props updates IF we care about them
if (vars.length || exps.length) {
// called with new doc state
// when any component calls updateProps()
updatePropsCallbacks.push(this.onUpdateProps);
this.state = this.onUpdateProps(
props.initialState,
Object.keys(props),
true
);
}
// listen for ref updates IF we care about them
if (props.hasHook || this.usesRefs) {
updateRefsCallbacks.push(this.onUpdateRefs);
}
}
componentDidCatch(error, info) {
this.setState({ hasError: true, error: error });
}
onUpdateProps(newState, changedKeys, initialRender) {
const { __vars__, __expr__ } = this.props;
// were there changes to any vars we track?
// or vars our expressions reference?
const shouldUpdate =
initialRender ||
changedKeys.some(k => {
return (
values(__vars__).includes(k) ||
values(__expr__).some(expr => expr.includes(k))
);
});
// if nothing we care about changed bail out and don't re-render
if (!shouldUpdate) return;
// update this component's state
const nextState = {};
// pull in the latest value for any tracked vars
Object.keys(__vars__).forEach(key => {
nextState[key] = newState[__vars__[key]];
});
// re-run this component's expressions using the latest doc state
Object.keys(__expr__).forEach(key => {
nextState[key] = evalExpression(
{ ...newState, refs: getRefs() },
__expr__[key],
key,
evalContext
);
});
if (initialRender) {
return Object.assign({ hasError: false }, nextState);
}
// trigger a re-render of this component
// and more importantly, its wrapped component
this.setState(Object.assign({ hasError: false, error: null }, nextState));
}
onUpdateRefs(newState) {
const { __expr__ } = this.props;
if (this.usesRefs) {
const nextState = { refs: newState.refs };
entries(__expr__).forEach(([key, val]) => {
if (!val.includes('refs.')) {
return;
}
nextState[key] = evalExpression(newState, val, key, evalContext);
});
// trigger a render with latest state
this.setState(nextState);
}
}
componentWillUnmount() {
const propsIndex = updatePropsCallbacks.indexOf(this.onUpdateProps);
if (propsIndex > -1) updatePropsCallbacks.splice(propsIndex, 1);
const refsIndex = updateRefsCallbacks.indexOf(this.onUpdateRefs);
if (refsIndex > -1) updateRefsCallbacks.splice(refsIndex, 1);
}
render() {
const state = filterIdyllProps(this.state, this.props.isHTMLNode);
const { children, ...passThruProps } = filterIdyllProps(
this.props,
this.props.isHTMLNode
);
let childComponent = null;
let uniqueKey = `${this.key}-help`;
let returnComponent = React.Children.map(children, (c, i) => {
childComponent = c;
return React.cloneElement(c, {
key: `${this.key}-${i}`,
idyll: {
theme: getTheme(theme),
layout: getLayout(layout),
authorView: authorView
},
...state,
...passThruProps
});
});
if (this.state.hasError) {
returnComponent = (
<div style={{ border: 'solid red 1px', padding: 10 }}>
{this.state.error.message}
</div>
);
}
const metaData = childComponent.type._idyll;
if (authorView) {
// ensure inline elements do not have this overlay
if (
(metaData && metaData.name === 'TextContainer') ||
['TextContainer', 'DragDropContainer'].includes(
childComponent.type.name
)
) {
return returnComponent;
} else if (
textEditComponent &&
metaData &&
wrapTextComponents.includes(metaData.name.toLowerCase())
) {
const ViewComponent = textEditComponent;
return (
<ViewComponent idyllASTNode={this.props.idyllASTNode}>
{childComponent}
</ViewComponent>
);
} else if (
!metaData ||
metaData.displayType === undefined ||
metaData.displayType !== 'inline'
) {
const ViewComponent = userViewComponent || AuthorTool;
return (
<ViewComponent
idyllASTNode={this.props.idyllASTNode}
component={returnComponent}
authorComponent={childComponent}
uniqueKey={uniqueKey}
/>
);
} else if (metaData.displayType === 'inline') {
const InlineViewComponent =
userInlineViewComponent || userViewComponent || AuthorTool;
return (
<InlineViewComponent
idyllASTNode={this.props.idyllASTNode}
component={returnComponent}
authorComponent={childComponent}
uniqueKey={uniqueKey}
/>
);
}
}
return returnComponent;
}
};
};
const getDerivedValues = dVars => {
const o = {};
Object.keys(dVars).forEach(key => (o[key] = dVars[key].value));
return o;
};
class IdyllRuntime extends React.PureComponent {
constructor(props) {
super(props);
this.state = {};
this.scrollListener = this.scrollListener.bind(this);
this.initScrollListener = this.initScrollListener.bind(this);
const ast = filterASTForDocument(props.ast);
const { vars, derived, data, elements } = splitAST(getChildren(ast));
const Wrapper = createWrapper({
theme: props.theme,
layout: props.layout,
authorView: props.authorView,
textEditComponent: props.textEditComponent,
userViewComponent: props.userViewComponent,
userInlineViewComponent: props.userInlineViewComponent,
wrapTextComponents: props.wrapTextComponents
});
let hasInitialized = false;
let initialContext = {};
// Initialize a custom context
let _initializeCallbacks = [];
let _mountCallbacks = [];
let _updateCallbacks = [];
this._onInitializeState = () => {
_initializeCallbacks.forEach(cb => {
cb();
});
};
this._onMount = () => {
_mountCallbacks.forEach(cb => {
cb();
});
};
this._onUpdateState = newData => {
_updateCallbacks.forEach(cb => {
cb(newData);
});
};
if (typeof props.context === 'function') {
props.context({
update: newState => {
if (!hasInitialized) {
initialContext = Object.assign(initialContext, newState);
} else {
this.updateState(newState);
}
},
data: () => {
return this.state;
},
onInitialize: cb => {
_initializeCallbacks.push(cb);
},
onMount: cb => {
_mountCallbacks.push(cb);
},
onUpdate: cb => {
_updateCallbacks.push(cb);
}
});
}
const dataStore = getData(data, props.datasets);
const initialState = Object.assign(
{},
{
...getVars(vars, initialContext),
...dataStore.syncData
},
initialContext,
props.initialState ? props.initialState : {}
);
const { asyncData: asyncDataStore } = dataStore;
const asyncDataStoreKeys = Object.keys(asyncDataStore);
asyncDataStoreKeys.forEach(key => {
this.state[key] = asyncDataStore[key].initialValue;
});
asyncDataStoreKeys.map(key => {
asyncDataStore[key].dataPromise
.then(res => {
this.updateState({
...this.state,
[key]: res
});
})
.catch(e => console.error('Error while resolving the data' + e));
});
const derivedVars = (this.derivedVars = getVars(derived, initialState));
let state = (this.state = {
...this.state,
...initialState,
...getDerivedValues(derivedVars)
});
this.updateState = newState => {
// merge new doc state with old
const newMergedState = { ...this.state, ...newState };
// update derived values
const newDerivedValues = getDerivedValues(
getVars(derived, newMergedState)
);
const nextState = { ...newMergedState, ...newDerivedValues };
const changedMap = {};
const changedKeys = Object.keys(state).reduce((acc, k) => {
if (!equal(state[k], nextState[k])) {
acc.push(k);
changedMap[k] = nextState[k] || state[k];
}
return acc;
}, []);
// Update doc state reference.
// We re-use the same object here so that
// IdyllRuntime.state can be accurately checked in tests
state = Object.assign(state, nextState);
// pass the new doc state to all listeners aka component wrappers
updatePropsCallbacks.forEach(f => f(state, changedKeys));
changedKeys.length &&
this._onUpdateState &&
this._onUpdateState(changedMap);
};
evalContext.__idyllUpdate = this.updateState;
hasInitialized = true;
this._onInitializeState && this._onInitializeState();
// Put these in to avoid hard errors if people are on the latest
// CLI but haven't updated their local default components
const fallbackComponents = {
'text-container': generatePlaceholder('TextContainer'),
'full-width': generatePlaceholder('FullWidth')
};
// Components that the Document needs to function properly
const internalComponents = {
Wrapper
};
Object.keys(internalComponents).forEach(key => {
if (props.components[key]) {
console.warn(
`Warning! You are including a component named ${key}, but this is a reserved Idyll component. Please rename your component.`
);
}
});
const components = Object.assign(
fallbackComponents,
props.components,
internalComponents
);
const rjs = new ReactJsonSchema(components);
const schema = translate(ast);
const wrapTargets = findWrapTargets(schema, this.state, props.components);
let refCounter = 0;
const transformedSchema = mapTree(schema, (node, depth) => {
// console.log('mapoing ', node.component || node.type);
if (!node.component) {
if (node.type && node.type === 'textnode') return node.value;
}
// transform refs from strings to functions and store them
if (node.ref || node.hasHook) {
node.refName = node.ref || node.component + (refCounter++).toString();
node.ref = el => {
if (!el) return;
const domNode = ReactDOM.findDOMNode(el);
domNode.dataset.ref = node.refName;
scrollOffsets[node.refName] = node.scrollOffset || 0;
refCache[node.refName] = {
props: node,
domNode: domNode,
component: el
};
};
refCache[node.refName] = {
props: node,
domNode: null
};
}
//Inspect for isHTMLNode props and to check for dynamic components.
if (!wrapTargets.includes(node)) {
if (
this.props.wrapTextComponents.indexOf(node.component) > -1 &&
this.props.textEditComponent
) {
const { idyllASTNode, ...rest } = node;
return {
component: this.props.textEditComponent,
idyllASTNode: idyllASTNode,
children: [rest]
};
}
// Don't include the AST node reference on unwrapped components
const { idyllASTNode, ...rest } = node;
return rest;
}
let {
component,
children,
idyllASTNode,
key,
__vars__,
__expr__,
...props // actual component props
} = node;
__vars__ = __vars__ || {};
__expr__ = __expr__ || {};
// assign the initial values for tracked vars and expressions
Object.keys(props).forEach(k => {
if (__vars__[k]) {
node[k] = state[__vars__[k]];
}
if (__expr__[k] !== undefined) {
if (hooks.indexOf(k) > -1) {
return;
}
node[k] = evalExpression(
{ ...state, refs: getRefs() },
__expr__[k],
k,
evalContext
);
}
});
const resolvedComponent = rjs.resolveComponent(node);
const isHTMLNode = typeof resolvedComponent === 'string';
return {
component: Wrapper,
__vars__,
__expr__,
idyllASTNode,
isHTMLNode: isHTMLNode,
hasHook: node.hasHook,
refName: node.refName,
initialState: this.state,
updateProps: newProps => {
// init new doc state object
const newState = {};
// iterate over passed in updates
Object.keys(newProps).forEach(k => {
// if a tracked var was updated get its new value
if (__vars__[k]) {
newState[__vars__[k]] = newProps[k];
}
});
this.updateState(newState);
},
children: [filterIdyllProps(node, isHTMLNode)]
};
});
this.kids = rjs.parseSchema(transformedSchema);
}
scrollListener() {
const refs = getRefs();
updateRefsCallbacks.forEach(f => f({ ...this.state, refs }));
}
initScrollListener(el) {
if (!el) return;
let scroller = scrollparent(el);
if (
scroller === document.documentElement ||
scroller === document.body ||
scroller === window
) {
scroller = window;
scrollContainer = scrollMonitor;
} else {
scrollContainer = scrollMonitor.createContainer(scroller);
}
Object.keys(refCache).forEach(key => {
const { props, domNode } = refCache[key];
const watcher = scrollContainer.create(domNode, scrollOffsets[key]);
hooks.forEach(hook => {
if (props[hook]) {
watcher[scrollMonitorEvents[hook]](() => {
evalExpression(
{ ...this.state, refs: getRefs() },
props[hook],
hook,
evalContext
)();
});
}
});
scrollWatchers.push(watcher);
});
scroller.addEventListener('scroll', this.scrollListener);
}
updateDerivedVars(newState) {
const context = {};
Object.keys(this.derivedVars).forEach(dv => {
this.derivedVars[dv].value = this.derivedVars[dv].update(
newState,
this.state,
context
);
context[dv] = this.derivedVars[dv].value;
});
}
getDerivedVars() {
let dvs = {};
Object.keys(this.derivedVars).forEach(dv => {
dvs[dv] = this.derivedVars[dv].value;
});
return dvs;
}
componentDidMount() {
const refs = getRefs();
updateRefsCallbacks.forEach(f => f({ ...this.state, refs }));
this._onMount && this._onMount();
}
render() {
return (
<div className="idyll-root" ref={this.initScrollListener}>
{this.kids}
</div>
);
}
}
IdyllRuntime.defaultProps = {
layout: 'blog',
theme: 'github',
authorView: false,
insertStyles: false,
wrapTextComponents: [
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'pre',
'CodeHighlight'
]
};
export default IdyllRuntime;