Skip to content
This repository has been archived by the owner on Jul 28, 2023. It is now read-only.

⚛️ Add directives to handle providesContext and usesContext #68

Merged
merged 5 commits into from
Sep 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/directives/wp-block-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
createContext,
useContext,
useMemo,
createElement as h,
} from 'preact/compat';
import { directive } from '../gutenberg-packages/directives';

// Single context reused by all providers.
const blockContexts = createContext({});
blockContexts.displayName = 'blockContexts';

/**
* Wrapper that provides the specified attributes. If there are ancestor that are also providers, it
* merges all context together.
*
* @param {*} props Component props.
* @param {*} props.provides Map of context names and block attributes.
* @param {*} props.attributes Block attributes.
*
* @returns Block context provider.
*/
const BlockContextProvider = ({ provides, attributes, children }) => {
// Get previous context.
const allContexts = useContext(blockContexts);

// Get provided context from attributes.
const context = {};
for (const key in provides) {
context[key] = attributes[provides[key]];
}

// Provide merged contexts.
return (
<blockContexts.Provider value={{ ...allContexts, ...context }}>
{children}
</blockContexts.Provider>
);
};

/**
* HOC that injects only the required attributes from `blockContexts` value.
*
* @param {*} Comp Component.
* @param {*} options Options.
* @param {*} options.uses Array of required contexts.
*
* @returns HOC function.
*/
const withBlockContext = (Comp, { uses }) => {
const hoc = (props) => {
const allContexts = useContext(blockContexts);

// Memoize only those attributes that are needed.
const context = useMemo(
() =>
uses.reduce((acc, attribute) => {
acc[attribute] = allContexts[attribute];
return acc;
}, {}),
uses.map((attribute) => allContexts[attribute])
);

// Inject context.
return <Comp {...props} context={context} />;
};

hoc.displayName = 'withBlockContext';
return hoc;
};

directive('providesBlockContext', (props) => {
const { providesBlockContext: provides, attributes } = props.wpBlock;
const [block] = props.children;

// The property `provides` can be null...
if (!provides || !Object.keys(provides).length) return;

block.props.children = h(
BlockContextProvider,
{ provides, attributes },
block.props.children
);
});

directive('usesBlockContext', (props) => {
const { usesBlockContext: uses } = props.wpBlock;

if (!uses.length) return;

const [block] = props.children;
block.type = withBlockContext(block.type, { uses });
});
25 changes: 25 additions & 0 deletions src/directives/wp-block.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createElement as h } from 'preact/compat';
import { createGlobal } from '../gutenberg-packages/utils';
import { directive } from '../gutenberg-packages/directives';

const blockViews = createGlobal('blockViews', new Map());

// Handle block components.
directive('type', (props) => {
const {
type,
attributes,
context = {},
props: blockProps,
innerBlocks: children,
} = props.wpBlock;

// Do nothing if there's no component for this block.
if (!blockViews.has(type)) return;

const { Component } = blockViews.get(type);

props.children = [
h(Component, { context, attributes, blockProps, children }),
];
});
36 changes: 36 additions & 0 deletions src/gutenberg-packages/directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { h, options } from 'preact';

// WordPress Directives.
const directives = {};

// Expose function to add directives.
export const directive = (name, cb) => {
directives[name] = cb;
};

const WpDirective = (props) => {
for (const d in props.wpBlock) {
directives[d]?.(props);
}
props._wrapped = true;
const { wp, tag, children, ...rest } = props;
return h(tag, rest, children);
};

const old = options.vnode;

options.vnode = (vnode) => {
const wpBlock = vnode.props.wpBlock;
const wrapped = vnode.props._wrapped;

if (wpBlock) {
if (!wrapped) {
vnode.props.tag = vnode.type;
vnode.type = WpDirective;
}
} else if (wrapped) {
delete vnode.props._wrapped;
}

if (old) old(vnode);
};
17 changes: 5 additions & 12 deletions src/gutenberg-packages/hydration.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { hydrate, createElement } from 'preact/compat';
import { createGlobal } from './utils';
import 'preact/debug';
import { hydrate } from 'preact/compat';
import toVdom from './to-vdom';
import visitor from './visitor';

const blockViews = createGlobal('blockViews', new Map());

const components = Object.fromEntries(
[...blockViews.entries()].map(([k, v]) => [k, v.Component])
);

visitor.map = components;
import '../directives/wp-block';
import '../directives/wp-block-context';

const dom = document.querySelector('.wp-site-blocks');
const vdom = toVdom(dom, visitor, createElement).props.children;
const vdom = toVdom(dom).props.children;

setTimeout(() => console.log('hydrated', hydrate(vdom, dom)), 3000);
132 changes: 102 additions & 30 deletions src/gutenberg-packages/to-vdom.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,113 @@
export default function toVdom(node, visitor, h) {
walk.visitor = visitor;
walk.h = h;
return walk(node);
}
import { h } from 'preact';
import { matcherFromSource } from './utils';

// Prefix used by WP directives.
const prefix = 'data-wp-block-';

function walk(n) {
// Reference to the last <inner-blocks> wrapper found.
let innerBlocksFound = null;

// Recursive function that transfoms a DOM tree into vDOM.
export default function toVdom(n) {
if (n.nodeType === 3) return n.data;
if (n.nodeType !== 1) return null;
let nodeName = String(n.nodeName).toLowerCase();

// Do not allow script tags (for now).
if (nodeName === 'script') return null;
// Get the node type.
const type = String(n.nodeName).toLowerCase();

let out = walk.h(
nodeName,
getProps(n.attributes),
walkChildren(n.childNodes)
);
if (walk.visitor) walk.visitor(out, n);
if (type === 'script') return null;

return out;
}

function getProps(attrs) {
let len = attrs && attrs.length;
if (!len) return null;
let props = {};
for (let i = 0; i < len; i++) {
let { name, value } = attrs[i];
// Extract props from node attributes.
const props = {};
const wpBlock = {};
for (const { name, value } of n.attributes) {
// Store block directives in `wpBlock`.
if (name.startsWith(prefix)) {
const propName = getWpBlockPropName(name);
try {
wpBlock[propName] = JSON.parse(value);
} catch (e) {
wpBlock[propName] = value;
}
}
// Add the original property, and the rest of them.
props[name] = value;
}
return props;
}

function walkChildren(children) {
let c = children && Array.prototype.map.call(children, walk).filter(exists);
return c && c.length ? c : null;
// Include wpBlock prop if needed.
if (Object.keys(wpBlock).length) {
props.wpBlock = wpBlock;

// Handle special cases with wpBlock props.
handleSourcedAttributes(props, n);
handleBlockProps(props);
}

// Walk child nodes and return vDOM children.
const children = [].map.call(n.childNodes, toVdom).filter(exists);

// Add inner blocks.
if (type === 'wp-block' && innerBlocksFound) {
wpBlock.innerBlocks = innerBlocksFound;
innerBlocksFound = null;

// Set wpBlock prop again, just in case it's missing.
props.wpBlock = wpBlock;
}

// Create vNode. Note that all `wpBlock` props should exist now to make directives work.
const vNode = h(type, props, children);
Comment on lines +58 to +59
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I've assumed that options.vnode would run every time h() is used. Maybe I'm wrong here. 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Save a renference to this vNode if it's an <inner-blocks>` wrapper.
if (type === 'wp-inner-blocks') {
innerBlocksFound = vNode;
}

return vNode;
}

let exists = (x) => x;
const getWpBlockPropName = (name) => toCamelCase(name.replace(prefix, ''));

// Get sourced attributes and place them in `attributes`.
const handleSourcedAttributes = ({ wpBlock }, domNode) => {
if (wpBlock && wpBlock.sourcedAttributes) {
const { sourcedAttributes, attributes = {} } = wpBlock;
for (const attr in sourcedAttributes) {
attributes[attr] = matcherFromSource(sourcedAttributes[attr])(
domNode
);
}
wpBlock.attributes = attributes;
}
};

// Adapt block props to React/Preact format.
const handleBlockProps = ({ wpBlock }) => {
if (!wpBlock.props) return;

const { class: className, style } = wpBlock.props;
wpBlock.props = { className, style: cssObject(style) };
};

// Return an object of camelCased CSS properties.
const cssObject = (cssText) => {
if (!cssText) return {};

const el = document.createElement('div');
const { style } = el;
style.cssText = cssText;

const output = {};
for (let i = 0; i < style.length; i += 1) {
const key = style.item(0);
output[toCamelCase(key)] = style.getPropertyValue(key);
}

el.remove();
return output;
};

const exists = (x) => x;

const toCamelCase = (str) =>
str.replace(/-(.)/g, (_, initial) => initial.toUpperCase());
Loading