From ebba7b4f68d8ad0b79f7d92ff5c7640cb7207cb3 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 15:57:35 +0200 Subject: [PATCH 01/38] Add interactivity runtime Co-authored-by: David Arenas --- package-lock.json | 64 +++++-- packages/block-library/package.json | 3 + .../src/utils/interactivity/constants.js | 1 + .../src/utils/interactivity/directives.js | 178 ++++++++++++++++++ .../src/utils/interactivity/hooks.js | 76 ++++++++ .../src/utils/interactivity/hydration.js | 22 +++ .../src/utils/interactivity/index.js | 11 ++ .../src/utils/interactivity/store.js | 45 +++++ .../src/utils/interactivity/utils.js | 20 ++ .../src/utils/interactivity/vdom.js | 94 +++++++++ 10 files changed, 502 insertions(+), 12 deletions(-) create mode 100644 packages/block-library/src/utils/interactivity/constants.js create mode 100644 packages/block-library/src/utils/interactivity/directives.js create mode 100644 packages/block-library/src/utils/interactivity/hooks.js create mode 100644 packages/block-library/src/utils/interactivity/hydration.js create mode 100644 packages/block-library/src/utils/interactivity/index.js create mode 100644 packages/block-library/src/utils/interactivity/store.js create mode 100644 packages/block-library/src/utils/interactivity/utils.js create mode 100644 packages/block-library/src/utils/interactivity/vdom.js diff --git a/package-lock.json b/package-lock.json index 5154297bb17116..3a1bf1574cf812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7095,6 +7095,28 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz", "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==" }, + "@preact/signals": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.3.tgz", + "integrity": "sha512-N09DuAVvc90bBZVRwD+aFhtGyHAmJLhS3IFoawO/bYJRcil4k83nBOchpCEoS0s5+BXBpahgp0Mjf+IOqP57Og==", + "requires": { + "@preact/signals-core": "^1.2.3" + } + }, + "@preact/signals-core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.3.0.tgz", + "integrity": "sha512-M+M3ZOtd1dtV/uasyk4SZu1vbfEJ4NeENv0F7F12nijZYedB5wSgbtZcuACyssnTznhF4ctUyrR0dZHuHfyWKA==" + }, + "@preact/signals-react": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@preact/signals-react/-/signals-react-1.3.1.tgz", + "integrity": "sha512-YHWoGAT2Mmv2OGlGx7CCCbaLjAH/InV9ytGAR+esX8Y0HJmMAw51QlqGYOD5GPA5LwimV7Ht1x7KEIegDZIoxg==", + "requires": { + "@preact/signals-core": "^1.3.0", + "use-sync-external-store": "^1.2.0" + } + }, "@radix-ui/primitive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", @@ -16862,6 +16884,7 @@ "version": "file:packages/block-library", "requires": { "@babel/runtime": "^7.16.0", + "@preact/signals": "^1.1.3", "@wordpress/a11y": "file:packages/a11y", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", @@ -16894,12 +16917,14 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", + "deepsignal": "^1.3.0", "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", + "preact": "^10.13.2", "remove-accents": "^0.4.2" } }, @@ -25510,7 +25535,7 @@ "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true }, "array-includes": { @@ -30679,7 +30704,7 @@ "debuglog": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", + "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", "dev": true }, "decache": { @@ -30794,6 +30819,16 @@ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "dev": true }, + "deepsignal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.3.0.tgz", + "integrity": "sha512-tMh3g3F7Ka6gKALqu7uOzi/3Xm00mGWgWR3hp1GUzGGnTz2J926ime5aOe1haz233v4encyjTkZESr5R6hr8oQ==", + "requires": { + "@preact/signals": "^1.0.0", + "@preact/signals-core": "^1.0.0", + "@preact/signals-react": "^1.0.0" + } + }, "default-browser-id": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz", @@ -35511,7 +35546,7 @@ "git-remote-origin-url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", - "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=", + "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==", "dev": true, "requires": { "gitconfiglocal": "^1.0.0", @@ -35558,7 +35593,7 @@ "gitconfiglocal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", - "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=", + "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==", "dev": true, "requires": { "ini": "^1.3.2" @@ -36832,7 +36867,7 @@ "humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, "requires": { "ms": "^2.0.0" @@ -37848,7 +37883,7 @@ "is-text-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=", + "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", "dev": true, "requires": { "text-extensions": "^1.0.0" @@ -39621,7 +39656,7 @@ "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true }, "jsprim": { @@ -40721,7 +40756,7 @@ "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", - "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", + "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", "dev": true }, "lodash.isplainobject": { @@ -47601,6 +47636,11 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, + "preact": { + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz", + "integrity": "sha512-q44QFLhOhty2Bd0Y46fnYW0gD/cbVM9dUVtNTDKPcdXSMA7jfY+Jpd6rk3GB0lcQss0z5s/6CmVP0Z/hV+g6pw==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -48219,7 +48259,7 @@ "promzard": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz", - "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", + "integrity": "sha512-JZeYqd7UAcHCwI+sTOeUDYkvEU+1bQ7iE0UT1MgB/tERkAPkesW46MrpIySzODi+owTjZtiF8Ay5j9m60KmMBw==", "dev": true, "requires": { "read": "1" @@ -48253,7 +48293,7 @@ "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, "protocols": { @@ -49810,7 +49850,7 @@ "read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", "dev": true, "requires": { "mute-stream": "~0.0.4" @@ -55167,7 +55207,7 @@ "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", "dev": true }, "terminal-link": { diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 9a34e8ae084fa7..79552d1becb66f 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -31,6 +31,7 @@ ], "dependencies": { "@babel/runtime": "^7.16.0", + "@preact/signals": "^1.1.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", @@ -63,12 +64,14 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", + "deepsignal": "^1.3.0", "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", + "preact": "^10.13.2", "remove-accents": "^0.4.2" }, "peerDependencies": { diff --git a/packages/block-library/src/utils/interactivity/constants.js b/packages/block-library/src/utils/interactivity/constants.js new file mode 100644 index 00000000000000..f462753c9f8179 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/constants.js @@ -0,0 +1 @@ +export const directivePrefix = 'data-wp-'; diff --git a/packages/block-library/src/utils/interactivity/directives.js b/packages/block-library/src/utils/interactivity/directives.js new file mode 100644 index 00000000000000..07874d4f91d072 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/directives.js @@ -0,0 +1,178 @@ +/** + * External dependencies + */ +import { useContext, useMemo, useEffect } from 'preact/hooks'; +import { useSignalEffect } from '@preact/signals'; +import { deepSignal, peek } from 'deepsignal'; +/** + * Internal dependencies + */ +import { directive } from './hooks'; + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const mergeDeepSignals = ( target, source ) => { + for ( const k in source ) { + if ( typeof peek( target, k ) === 'undefined' ) { + target[ `$${ k }` ] = source[ `$${ k }` ]; + } else if ( + isObject( peek( target, k ) ) && + isObject( peek( source, k ) ) + ) { + mergeDeepSignals( + target[ `$${ k }` ].peek(), + source[ `$${ k }` ].peek() + ); + } + } +}; + +export default () => { + // data-wp-context + directive( + 'context', + ( { + directives: { + context: { default: context }, + }, + props: { children }, + context: inherited, + } ) => { + const { Provider } = inherited; + const inheritedValue = useContext( inherited ); + const value = useMemo( () => { + const localValue = deepSignal( context ); + mergeDeepSignals( localValue, inheritedValue ); + return localValue; + }, [ context, inheritedValue ] ); + + return { children }; + } + ); + + // data-wp-effect.[name] + directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( effect ).forEach( ( path ) => { + useSignalEffect( () => { + return evaluate( path, { context: contextValue } ); + } ); + } ); + } ); + + // data-wp-init.[name] + directive( 'init', ( { directives: { init }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( init ).forEach( ( path ) => { + useEffect( () => { + return evaluate( path, { context: contextValue } ); + }, [] ); + } ); + } ); + + // data-wp-on.[event] + directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { + const contextValue = useContext( context ); + Object.entries( on ).forEach( ( [ name, path ] ) => { + element.props[ `on${ name }` ] = ( event ) => { + return evaluate( path, { event, context: contextValue } ); + }; + } ); + } ); + + // data-wp-class.[classname] + directive( + 'class', + ( { + directives: { class: className }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + Object.keys( className ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( name ) => { + const result = evaluate( className[ name ], { + className: name, + context: contextValue, + } ); + const currentClass = element.props.class || ''; + const classFinder = new RegExp( + `(^|\\s)${ name }(\\s|$)`, + 'g' + ); + if ( ! result ) + element.props.class = currentClass + .replace( classFinder, ' ' ) + .trim(); + else if ( ! classFinder.test( currentClass ) ) + element.props.class = currentClass + ? `${ currentClass } ${ name }` + : name; + + useEffect( () => { + // This seems necessary because Preact doesn't change the class + // names on the hydration, so we have to do it manually. It doesn't + // need deps because it only needs to do it the first time. + if ( ! result ) { + element.ref.current.classList.remove( name ); + } else { + element.ref.current.classList.add( name ); + } + }, [] ); + } ); + } + ); + + // data-wp-bind.[attribute] + directive( + 'bind', + ( { directives: { bind }, element, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.entries( bind ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( [ attribute, path ] ) => { + const result = evaluate( path, { + context: contextValue, + } ); + element.props[ attribute ] = result; + + useEffect( () => { + // This seems necessary because Preact doesn't change the attributes + // on the hydration, so we have to do it manually. It doesn't need + // deps because it only needs to do it the first time. + if ( result === false ) { + element.ref.current.removeAttribute( attribute ); + } else { + element.ref.current.setAttribute( + attribute, + result === true ? '' : result + ); + } + }, [] ); + } ); + } + ); + + // data-wp-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + + ); + } + ); +}; diff --git a/packages/block-library/src/utils/interactivity/hooks.js b/packages/block-library/src/utils/interactivity/hooks.js new file mode 100644 index 00000000000000..ca3bd20964d511 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/hooks.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { h, options, createContext } from 'preact'; +import { useRef, useMemo } from 'preact/hooks'; +/** + * Internal dependencies + */ +import { rawStore as store } from './store'; + +// Main context. +const context = createContext( {} ); + +// WordPress Directives. +const directiveMap = {}; +export const directive = ( name, cb ) => { + directiveMap[ name ] = cb; +}; + +// Resolve the path to some property of the store object. +const resolve = ( path, ctx ) => { + // If path starts with !, remove it and save a flag. + const hasNegationOperator = + path[ 0 ] === '!' && !! ( path = path.slice( 1 ) ); + let current = { ...store, context: ctx }; + path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); + return hasNegationOperator ? ! current : current; +}; + +// Generate the evaluate function. +const getEvaluate = + ( { ref } = {} ) => + ( path, extraArgs = {} ) => { + const value = resolve( path, extraArgs.context ); + return typeof value === 'function' + ? value( { + ref: ref.current, + ...store, + ...extraArgs, + } ) + : value; + }; + +// Directive wrapper. +const Directive = ( { type, directives, props: originalProps } ) => { + const ref = useRef( null ); + const element = h( type, { ...originalProps, ref } ); + const props = { ...originalProps, children: element }; + const evaluate = useMemo( () => getEvaluate( { ref } ), [] ); + const directiveArgs = { directives, props, element, context, evaluate }; + + for ( const d in directives ) { + const wrapper = directiveMap[ d ]?.( directiveArgs ); + if ( wrapper !== undefined ) props.children = wrapper; + } + + return props.children; +}; + +// Preact Options Hook called each time a vnode is created. +const old = options.vnode; +options.vnode = ( vnode ) => { + if ( vnode.props.__directives ) { + const props = vnode.props; + const directives = props.__directives; + delete props.__directives; + vnode.props = { + type: vnode.type, + directives, + props, + }; + vnode.type = Directive; + } + + if ( old ) old( vnode ); +}; diff --git a/packages/block-library/src/utils/interactivity/hydration.js b/packages/block-library/src/utils/interactivity/hydration.js new file mode 100644 index 00000000000000..2fc34eeb64b9b5 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/hydration.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { hydrate } from 'preact'; +/** + * Internal dependencies + */ +import { toVdom, hydratedIslands } from './vdom'; +import { createRootFragment } from './utils'; +import { directivePrefix } from './constants'; + +export const init = async () => { + document + .querySelectorAll( `[${ directivePrefix }island]` ) + .forEach( ( node ) => { + if ( ! hydratedIslands.has( node ) ) { + const fragment = createRootFragment( node.parentNode, node ); + const vdom = toVdom( node ); + hydrate( vdom, fragment ); + } + } ); +}; diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js new file mode 100644 index 00000000000000..683be59c3fca83 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/index.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import registerDirectives from './directives'; +import { init } from './hydration'; + +/** + * Initialize the Interactivity API. + */ +registerDirectives(); +init(); diff --git a/packages/block-library/src/utils/interactivity/store.js b/packages/block-library/src/utils/interactivity/store.js new file mode 100644 index 00000000000000..d11af901352017 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/store.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { deepSignal } from 'deepsignal'; + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const deepMerge = ( target, source ) => { + if ( isObject( target ) && isObject( source ) ) { + for ( const key in source ) { + if ( isObject( source[ key ] ) ) { + if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); + deepMerge( target[ key ], source[ key ] ); + } else { + Object.assign( target, { [ key ]: source[ key ] } ); + } + } + } +}; + +const getSerializedState = () => { + // TODO: change the store tag ID for a better one. + const storeTag = document.querySelector( + `script[type="application/json"]#store` + ); + if ( ! storeTag ) return {}; + try { + const { state } = JSON.parse( storeTag.textContent ); + if ( isObject( state ) ) return state; + throw Error( 'Parsed state is not an object' ); + } catch ( e ) { + // eslint-disable-next-line no-console + console.log( e ); + } + return {}; +}; + +const rawState = getSerializedState(); +export const rawStore = { state: deepSignal( rawState ) }; + +export const store = ( { state, ...block } ) => { + deepMerge( rawStore, block ); + deepMerge( rawState, state ); +}; diff --git a/packages/block-library/src/utils/interactivity/utils.js b/packages/block-library/src/utils/interactivity/utils.js new file mode 100644 index 00000000000000..48c50fc537c054 --- /dev/null +++ b/packages/block-library/src/utils/interactivity/utils.js @@ -0,0 +1,20 @@ +// For wrapperless hydration. +// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c +export const createRootFragment = ( parent, replaceNode ) => { + replaceNode = [].concat( replaceNode ); + const s = replaceNode[ replaceNode.length - 1 ].nextSibling; + function insert( c, r ) { + parent.insertBefore( c, r || s ); + } + return ( parent.__k = { + nodeType: 1, + parentNode: parent, + firstChild: replaceNode[ 0 ], + childNodes: replaceNode, + insertBefore: insert, + appendChild: insert, + removeChild( c ) { + parent.removeChild( c ); + }, + } ); +}; diff --git a/packages/block-library/src/utils/interactivity/vdom.js b/packages/block-library/src/utils/interactivity/vdom.js new file mode 100644 index 00000000000000..07640319b88a8a --- /dev/null +++ b/packages/block-library/src/utils/interactivity/vdom.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { h } from 'preact'; +/** + * Internal dependencies + */ +import { directivePrefix as p } from './constants'; + +const ignoreAttr = `${ p }ignore`; +const islandAttr = `${ p }island`; +const directiveParser = new RegExp( `${ p }([^.]+)\.?(.*)$` ); + +export const hydratedIslands = new WeakSet(); + +// Recursive function that transforms a DOM tree into vDOM. +export function toVdom( root ) { + const treeWalker = document.createTreeWalker( + root, + 205 // ELEMENT + TEXT + COMMENT + CDATA_SECTION + PROCESSING_INSTRUCTION + ); + + function walk( node ) { + const { attributes, nodeType } = node; + + if ( nodeType === 3 ) return [ node.data ]; + if ( nodeType === 4 ) { + const next = treeWalker.nextSibling(); + node.replaceWith( new window.Text( node.nodeValue ) ); + return [ node.nodeValue, next ]; + } + if ( nodeType === 8 || nodeType === 7 ) { + const next = treeWalker.nextSibling(); + node.remove(); + return [ null, next ]; + } + + const props = {}; + const children = []; + const directives = {}; + let hasDirectives = false; + let ignore = false; + let island = false; + + for ( let i = 0; i < attributes.length; i++ ) { + const n = attributes[ i ].name; + if ( n[ p.length ] && n.slice( 0, p.length ) === p ) { + if ( n === ignoreAttr ) { + ignore = true; + } else if ( n === islandAttr ) { + island = true; + } else { + hasDirectives = true; + let val = attributes[ i ].value; + try { + val = JSON.parse( val ); + } catch ( e ) {} + const [ , prefix, suffix ] = directiveParser.exec( n ); + directives[ prefix ] = directives[ prefix ] || {}; + directives[ prefix ][ suffix || 'default' ] = val; + } + } else if ( n === 'ref' ) { + continue; + } + props[ n ] = attributes[ i ].value; + } + + if ( ignore && ! island ) + return [ + h( node.localName, { + ...props, + innerHTML: node.innerHTML, + __directives: { ignore: true }, + } ), + ]; + if ( island ) hydratedIslands.add( node ); + + if ( hasDirectives ) props.__directives = directives; + + let child = treeWalker.firstChild(); + if ( child ) { + while ( child ) { + const [ vnode, nextChild ] = walk( child ); + if ( vnode ) children.push( vnode ); + child = nextChild || treeWalker.nextSibling(); + } + treeWalker.parentNode(); + } + + return [ h( node.localName, props, children ) ]; + } + + return walk( treeWalker.currentNode ); +} From af59781fb1c2ba7729901806782fe5fbc4062203 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 16:11:32 +0200 Subject: [PATCH 02/38] Add it to the image block This is still pretty basic, just to check that it works. Co-authored-by: David Arenas --- packages/block-library/src/image/block.json | 3 ++- packages/block-library/src/image/index.php | 7 ++++++- packages/block-library/src/image/view.js | 16 ++++++++++++++++ .../src/utils/interactivity/index.js | 10 ++++++++-- 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 packages/block-library/src/image/view.js diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 92931455c1144c..3a5c8a18c9af75 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -118,5 +118,6 @@ { "name": "rounded", "label": "Rounded" } ], "editorStyle": "wp-block-image-editor", - "style": "wp-block-image" + "style": "wp-block-image", + "viewScript": [ "file:./view.min.js" ] } diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index e05939a4d0feac..b775a6d6e085ce 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -15,7 +15,10 @@ */ function render_block_core_image( $attributes, $content ) { $processor = new WP_HTML_Tag_Processor( $content ); + $processor->next_tag( 'figure' ); + $processor->set_attribute( 'data-wp-island', '' ); $processor->next_tag( 'img' ); + $processor->set_attribute( 'data-wp-effect', 'effects.alert' ); if ( $processor->get_attribute( 'src' ) === null ) { return ''; @@ -27,8 +30,10 @@ function render_block_core_image( $attributes, $content ) { // which now wraps Image Blocks within innerBlocks. // The data-id attribute is added in a core/gallery `render_block_data` hook. $processor->set_attribute( 'data-id', $attributes['data-id'] ); - $content = $processor->get_updated_html(); } + + $content = $processor->get_updated_html(); + return $content; } diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js new file mode 100644 index 00000000000000..0b4b69df466e5a --- /dev/null +++ b/packages/block-library/src/image/view.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { store } from '../utils/interactivity/store'; +import init from '../utils/interactivity'; + +store( { + effects: { + alert: () => { + // eslint-disable-next-line no-console + console.log( 'image hydrated!' ); + }, + }, +} ); + +init(); diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index 683be59c3fca83..351c0bc5424f3b 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -7,5 +7,11 @@ import { init } from './hydration'; /** * Initialize the Interactivity API. */ -registerDirectives(); -init(); +export default () => { + window.addEventListener( 'DOMContentLoaded', () => { + registerDirectives(); + init(); + // eslint-disable-next-line no-console + console.log( 'hydrated!' ); + } ); +}; From cca3d600da2504fb714182c7869cce2681631ab2 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 16:53:24 +0200 Subject: [PATCH 03/38] Add a separate webpack config Co-authored-by: David Arenas --- packages/block-library/src/image/block.json | 3 +- packages/block-library/src/image/index.php | 5 + .../block-library/src/image/interactivity.js | 13 + packages/block-library/src/image/view.js | 16 - .../src/utils/interactivity/index.js | 15 +- tools/webpack/blocks.js | 300 +++++++++++------- webpack.config.js | 2 +- 7 files changed, 212 insertions(+), 142 deletions(-) create mode 100644 packages/block-library/src/image/interactivity.js delete mode 100644 packages/block-library/src/image/view.js diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 3a5c8a18c9af75..92931455c1144c 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -118,6 +118,5 @@ { "name": "rounded", "label": "Rounded" } ], "editorStyle": "wp-block-image-editor", - "style": "wp-block-image", - "viewScript": [ "file:./view.min.js" ] + "style": "wp-block-image" } diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index b775a6d6e085ce..db69a61dc1026a 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -14,9 +14,14 @@ * @return string Returns the block content with the data-id attribute added. */ function render_block_core_image( $attributes, $content ) { + wp_enqueue_script( 'interactivity', plugins_url( '../interactive-blocks/interactivity.min.js', __FILE__ ) ); + wp_enqueue_script( 'interactivity-vendors', plugins_url( '../interactive-blocks/vendors.min.js', __FILE__ ) ); + wp_enqueue_script( 'interactivity-image', plugins_url( '../interactive-blocks/image.min.js', __FILE__ ) ); + $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag( 'figure' ); $processor->set_attribute( 'data-wp-island', '' ); + $processor->set_attribute( 'data-wp-context', '{ "text": "image hydrated" }' ); $processor->next_tag( 'img' ); $processor->set_attribute( 'data-wp-effect', 'effects.alert' ); diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js new file mode 100644 index 00000000000000..dd29ee4a7d3689 --- /dev/null +++ b/packages/block-library/src/image/interactivity.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { store } from '../utils/interactivity'; + +store( { + effects: { + alert: ( { context } ) => { + // eslint-disable-next-line no-console + console.log( context.text ); + }, + }, +} ); diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js deleted file mode 100644 index 0b4b69df466e5a..00000000000000 --- a/packages/block-library/src/image/view.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Internal dependencies - */ -import { store } from '../utils/interactivity/store'; -import init from '../utils/interactivity'; - -store( { - effects: { - alert: () => { - // eslint-disable-next-line no-console - console.log( 'image hydrated!' ); - }, - }, -} ); - -init(); diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index 351c0bc5424f3b..b09a9669b6c074 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -3,15 +3,14 @@ */ import registerDirectives from './directives'; import { init } from './hydration'; +export { store } from './store'; /** * Initialize the Interactivity API. */ -export default () => { - window.addEventListener( 'DOMContentLoaded', () => { - registerDirectives(); - init(); - // eslint-disable-next-line no-console - console.log( 'hydrated!' ); - } ); -}; +window.addEventListener( 'DOMContentLoaded', () => { + registerDirectives(); + init(); + // eslint-disable-next-line no-console + console.log( 'Interactivity API started' ); +} ); diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index f2c30984141fb2..96d78776655acc 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -74,134 +74,204 @@ const createEntrypoints = () => { }, {} ); }; -module.exports = { - ...baseConfig, - name: 'blocks', - entry: createEntrypoints(), - output: { - devtoolNamespace: 'wp', - filename: './build/block-library/blocks/[name].min.js', - path: join( __dirname, '..', '..' ), - }, - plugins: [ - ...plugins, - new DependencyExtractionWebpackPlugin( { injectPolyfill: false } ), - new CopyWebpackPlugin( { - patterns: [].concat( - [ - 'style', - 'style-rtl', - 'editor', - 'editor-rtl', - 'theme', - 'theme-rtl', - ].map( ( filename ) => ( { - from: `./packages/block-library/build-style/*/${ filename }.css`, - to( { absoluteFilename } ) { - const [ , dirname ] = absoluteFilename.match( - new RegExp( - `([\\w-]+)${ escapeRegExp( - sep - ) }${ filename }\\.css$` - ) - ); - - return join( - 'build/block-library/blocks', - dirname, - filename + '.css' - ); - }, - transform: stylesTransform, - } ) ), - Object.entries( { - './packages/block-library/src/': - 'build/block-library/blocks/', - './packages/edit-widgets/src/blocks/': - 'build/edit-widgets/blocks/', - './packages/widgets/src/blocks/': 'build/widgets/blocks/', - } ).flatMap( ( [ from, to ] ) => [ - { - from: `${ from }/**/index.php`, +module.exports = [ + { + ...baseConfig, + name: 'blocks', + entry: createEntrypoints(), + output: { + devtoolNamespace: 'wp', + filename: './build/block-library/blocks/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + plugins: [ + ...plugins, + new DependencyExtractionWebpackPlugin( { injectPolyfill: false } ), + new CopyWebpackPlugin( { + patterns: [].concat( + [ + 'style', + 'style-rtl', + 'editor', + 'editor-rtl', + 'theme', + 'theme-rtl', + ].map( ( filename ) => ( { + from: `./packages/block-library/build-style/*/${ filename }.css`, to( { absoluteFilename } ) { const [ , dirname ] = absoluteFilename.match( new RegExp( `([\\w-]+)${ escapeRegExp( sep - ) }index\\.php$` + ) }${ filename }\\.css$` ) ); - return join( to, `${ dirname }.php` ); + return join( + 'build/block-library/blocks', + dirname, + filename + '.css' + ); }, - transform: ( content ) => { - const prefix = 'gutenberg_'; - content = content.toString(); + transform: stylesTransform, + } ) ), + Object.entries( { + './packages/block-library/src/': + 'build/block-library/blocks/', + './packages/edit-widgets/src/blocks/': + 'build/edit-widgets/blocks/', + './packages/widgets/src/blocks/': + 'build/widgets/blocks/', + } ).flatMap( ( [ from, to ] ) => [ + { + from: `${ from }/**/index.php`, + to( { absoluteFilename } ) { + const [ , dirname ] = absoluteFilename.match( + new RegExp( + `([\\w-]+)${ escapeRegExp( + sep + ) }index\\.php$` + ) + ); - // Within content, search and prefix any function calls from - // `prefixFunctions` list. This is needed because some functions - // are called inside block files, but have been declared elsewhere. - // So with the rename we can call Gutenberg override functions, but the - // block will still call the core function when updates are back ported. - content = content.replace( - new RegExp( prefixFunctions.join( '|' ), 'g' ), - ( match ) => - `${ prefix }${ match.replace( - /^wp_/, - '' - ) }` - ); + return join( to, `${ dirname }.php` ); + }, + transform: ( content ) => { + const prefix = 'gutenberg_'; + content = content.toString(); - // Within content, search for any function definitions. For - // each, replace every other reference to it in the file. - return ( - Array.from( - content.matchAll( - /^\s*function ([^\(]+)/gm - ) - ) - .reduce( ( result, [ , functionName ] ) => { - // Prepend the Gutenberg prefix, substituting any - // other core prefix (e.g. "wp_"). - return result.replace( - new RegExp( functionName, 'g' ), - ( match ) => - prefix + - match.replace( /^wp_/, '' ) - ); - }, content ) - // The core blocks override procedure takes place in - // the init action default priority to ensure that core - // blocks would have been registered already. Since the - // blocks implementations occur at the default priority - // and due to WordPress hooks behavior not considering - // mutations to the same priority during another's - // callback, the Gutenberg build blocks are modified - // to occur at a later priority. - .replace( - /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, - '$1, 20' + // Within content, search and prefix any function calls from + // `prefixFunctions` list. This is needed because some functions + // are called inside block files, but have been declared elsewhere. + // So with the rename we can call Gutenberg override functions, but the + // block will still call the core function when updates are back ported. + content = content.replace( + new RegExp( + prefixFunctions.join( '|' ), + 'g' + ), + ( match ) => + `${ prefix }${ match.replace( + /^wp_/, + '' + ) }` + ); + + // Within content, search for any function definitions. For + // each, replace every other reference to it in the file. + return ( + Array.from( + content.matchAll( + /^\s*function ([^\(]+)/gm + ) ) - ); + .reduce( + ( result, [ , functionName ] ) => { + // Prepend the Gutenberg prefix, substituting any + // other core prefix (e.g. "wp_"). + return result.replace( + new RegExp( + functionName, + 'g' + ), + ( match ) => + prefix + + match.replace( + /^wp_/, + '' + ) + ); + }, + content + ) + // The core blocks override procedure takes place in + // the init action default priority to ensure that core + // blocks would have been registered already. Since the + // blocks implementations occur at the default priority + // and due to WordPress hooks behavior not considering + // mutations to the same priority during another's + // callback, the Gutenberg build blocks are modified + // to occur at a later priority. + .replace( + /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/, + '$1, 20' + ) + ); + }, + noErrorOnMissing: true, }, - noErrorOnMissing: true, - }, - { - from: `${ from }/*/block.json`, - to( { absoluteFilename } ) { - const [ , dirname ] = absoluteFilename.match( - new RegExp( - `([\\w-]+)${ escapeRegExp( - sep - ) }block\\.json$` - ) - ); + { + from: `${ from }/*/block.json`, + to( { absoluteFilename } ) { + const [ , dirname ] = absoluteFilename.match( + new RegExp( + `([\\w-]+)${ escapeRegExp( + sep + ) }block\\.json$` + ) + ); - return join( to, dirname, 'block.json' ); + return join( to, dirname, 'block.json' ); + }, }, + ] ) + ), + } ), + ].filter( Boolean ), + }, + { + entry: { + interactivity: + './packages/block-library/src/utils/interactivity/index.js', + image: './packages/block-library/src/image/interactivity.js', + }, + output: { + devtoolNamespace: 'wp', + filename: './build/block-library/interactive-blocks/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + optimization: { + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendors: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + minSize: 0, + chunks: 'all', }, - ] ) - ), - } ), - ].filter( Boolean ), -}; + }, + }, + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve( 'babel-loader' ), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], + ], + }, + }, + ], + }, + ], + }, + }, +]; diff --git a/webpack.config.js b/webpack.config.js index 9a29ed7782268d..f1c5ce803adc1b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,4 +5,4 @@ const blocksConfig = require( './tools/webpack/blocks' ); const developmentConfigs = require( './tools/webpack/development' ); const packagesConfig = require( './tools/webpack/packages' ); -module.exports = [ blocksConfig, packagesConfig, ...developmentConfigs ]; +module.exports = [ ...blocksConfig, packagesConfig, ...developmentConfigs ]; From 160a029bc4fbbdd64ca1c86329dce9a066eba677 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 17:20:41 +0200 Subject: [PATCH 04/38] Make sure the runtime is imported only once Co-authored-by: David Arenas --- packages/block-library/src/image/index.php | 1 + .../block-library/src/image/interactivity.js | 4 +++- .../block-library/src/image/interactivity2.js | 15 +++++++++++++++ .../src/utils/interactivity/index.js | 19 ++++++++++++------- tools/webpack/blocks.js | 12 +++++++++--- 5 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 packages/block-library/src/image/interactivity2.js diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index db69a61dc1026a..b97dff5f4f6ea4 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -17,6 +17,7 @@ function render_block_core_image( $attributes, $content ) { wp_enqueue_script( 'interactivity', plugins_url( '../interactive-blocks/interactivity.min.js', __FILE__ ) ); wp_enqueue_script( 'interactivity-vendors', plugins_url( '../interactive-blocks/vendors.min.js', __FILE__ ) ); wp_enqueue_script( 'interactivity-image', plugins_url( '../interactive-blocks/image.min.js', __FILE__ ) ); + wp_enqueue_script( 'interactivity-image-2', plugins_url( '../interactive-blocks/image2.min.js', __FILE__ ) ); $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag( 'figure' ); diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js index dd29ee4a7d3689..fc56b5069d3003 100644 --- a/packages/block-library/src/image/interactivity.js +++ b/packages/block-library/src/image/interactivity.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { store } from '../utils/interactivity'; +import { store, init } from '../utils/interactivity'; store( { effects: { @@ -11,3 +11,5 @@ store( { }, }, } ); + +init(); diff --git a/packages/block-library/src/image/interactivity2.js b/packages/block-library/src/image/interactivity2.js new file mode 100644 index 00000000000000..fc56b5069d3003 --- /dev/null +++ b/packages/block-library/src/image/interactivity2.js @@ -0,0 +1,15 @@ +/** + * Internal dependencies + */ +import { store, init } from '../utils/interactivity'; + +store( { + effects: { + alert: ( { context } ) => { + // eslint-disable-next-line no-console + console.log( context.text ); + }, + }, +} ); + +init(); diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index b09a9669b6c074..6ff7a86127313a 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -2,15 +2,20 @@ * Internal dependencies */ import registerDirectives from './directives'; -import { init } from './hydration'; +import { init as hydrate } from './hydration'; export { store } from './store'; /** * Initialize the Interactivity API. */ -window.addEventListener( 'DOMContentLoaded', () => { - registerDirectives(); - init(); - // eslint-disable-next-line no-console - console.log( 'Interactivity API started' ); -} ); +let started = false; +export const init = () => { + if ( ! started ) + window.addEventListener( 'DOMContentLoaded', () => { + registerDirectives(); + hydrate(); + // eslint-disable-next-line no-console + console.log( 'Interactivity API started' ); + } ); + started = true; +}; diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 96d78776655acc..5bc27223b76008 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -221,9 +221,8 @@ module.exports = [ }, { entry: { - interactivity: - './packages/block-library/src/utils/interactivity/index.js', image: './packages/block-library/src/image/interactivity.js', + image2: './packages/block-library/src/image/interactivity2.js', }, output: { devtoolNamespace: 'wp', @@ -237,11 +236,18 @@ module.exports = [ splitChunks: { cacheGroups: { vendors: { - test: /[\\/]node_modules[\\/]/, name: 'vendors', + test: /[\\/]node_modules[\\/]/, minSize: 0, chunks: 'all', }, + interactivity: { + name: 'interactivity', + test: /[\\/]utils\/interactivity[\\/]/, + chunks: 'all', + minSize: 0, + priority: -10, + }, }, }, }, From 7f16f273f30a708564deb0de26c55d1c2a45f5e2 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 17:30:24 +0200 Subject: [PATCH 05/38] Use sideEffects instead of init Co-authored-by: David Arenas --- packages/block-library/package.json | 3 ++- .../block-library/src/image/interactivity.js | 4 +--- .../block-library/src/image/interactivity2.js | 4 +--- .../src/utils/interactivity/index.js | 17 ++++++----------- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 79552d1becb66f..000f904a4f7d4b 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -27,7 +27,8 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/*/init.js" + "{src,build,build-module}/*/init.js", + "src/utils/interactivity/index.js" ], "dependencies": { "@babel/runtime": "^7.16.0", diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js index fc56b5069d3003..dd29ee4a7d3689 100644 --- a/packages/block-library/src/image/interactivity.js +++ b/packages/block-library/src/image/interactivity.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { store, init } from '../utils/interactivity'; +import { store } from '../utils/interactivity'; store( { effects: { @@ -11,5 +11,3 @@ store( { }, }, } ); - -init(); diff --git a/packages/block-library/src/image/interactivity2.js b/packages/block-library/src/image/interactivity2.js index fc56b5069d3003..dd29ee4a7d3689 100644 --- a/packages/block-library/src/image/interactivity2.js +++ b/packages/block-library/src/image/interactivity2.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { store, init } from '../utils/interactivity'; +import { store } from '../utils/interactivity'; store( { effects: { @@ -11,5 +11,3 @@ store( { }, }, } ); - -init(); diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index 6ff7a86127313a..9acf78ac1037bd 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -8,14 +8,9 @@ export { store } from './store'; /** * Initialize the Interactivity API. */ -let started = false; -export const init = () => { - if ( ! started ) - window.addEventListener( 'DOMContentLoaded', () => { - registerDirectives(); - hydrate(); - // eslint-disable-next-line no-console - console.log( 'Interactivity API started' ); - } ); - started = true; -}; +window.addEventListener( 'DOMContentLoaded', () => { + registerDirectives(); + hydrate(); + // eslint-disable-next-line no-console + console.log( 'Interactivity API started' ); +} ); From f4b2ee88c5b8cd6fc30a065db8ff58bb49b5c504 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 17:47:50 +0200 Subject: [PATCH 06/38] Move script registration to a general file Co-authored-by: David Arenas --- lib/client-assets.php | 29 ++++++++++++++++++++++ packages/block-library/src/image/index.php | 9 ++++--- tools/webpack/blocks.js | 1 - 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index 9c0483ea539b93..82139786b612df 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -577,3 +577,32 @@ function gutenberg_register_vendor_scripts( $scripts ) { // Enqueue stored styles. add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_stored_styles' ); add_action( 'wp_footer', 'gutenberg_enqueue_stored_styles', 1 ); + +/** + * Registers interactivity scripts for Gutenberg. + * + * This function registers interactivity scripts for Gutenberg when not in the + * admin panel. + */ +function gutenberg_register_interactivity_scripts() { + if ( ! is_admin() ) { + wp_register_script( + 'interactivity-runtime', + plugins_url( + '../build/block-library/interactive-blocks/interactivity.min.js', + __FILE__ + ), + array( 'interactivity-vendors') + ); + + wp_register_script( + 'interactivity-vendors', + plugins_url( + '../build/block-library/interactive-blocks/vendors.min.js', + __FILE__ + ) + ); + } +} +// Register interactivity scripts +add_action( 'wp_enqueue_scripts', 'gutenberg_register_interactivity_scripts' ); diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index b97dff5f4f6ea4..331ae3982ff987 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -14,10 +14,11 @@ * @return string Returns the block content with the data-id attribute added. */ function render_block_core_image( $attributes, $content ) { - wp_enqueue_script( 'interactivity', plugins_url( '../interactive-blocks/interactivity.min.js', __FILE__ ) ); - wp_enqueue_script( 'interactivity-vendors', plugins_url( '../interactive-blocks/vendors.min.js', __FILE__ ) ); - wp_enqueue_script( 'interactivity-image', plugins_url( '../interactive-blocks/image.min.js', __FILE__ ) ); - wp_enqueue_script( 'interactivity-image-2', plugins_url( '../interactive-blocks/image2.min.js', __FILE__ ) ); + wp_enqueue_script( + 'interactivity-image', + plugins_url('../interactive-blocks/image.min.js', __FILE__ ), + array( 'interactivity-runtime' ) + ); $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag( 'figure' ); diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 5bc27223b76008..928f5049031e9e 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -222,7 +222,6 @@ module.exports = [ { entry: { image: './packages/block-library/src/image/interactivity.js', - image2: './packages/block-library/src/image/interactivity2.js', }, output: { devtoolNamespace: 'wp', From 378b041aa7a2f5f941f55a1cc9653e40b63ec4ad Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 17:57:30 +0200 Subject: [PATCH 07/38] Add `defer` to the interactivity scripts Co-authored-by: David Arenas --- lib/client-assets.php | 20 ++++++++++++++++++- .../src/utils/interactivity/index.js | 10 ++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index 82139786b612df..a0dc00cbcd6767 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -604,5 +604,23 @@ function gutenberg_register_interactivity_scripts() { ); } } -// Register interactivity scripts add_action( 'wp_enqueue_scripts', 'gutenberg_register_interactivity_scripts' ); + +/** + * Adds the "defer" attribute to all the interactivity script tags. + * + * @param string $tag The generated script tag. + * @param string $handle The script's registered handle. + * + * @return string The modified script tag. + */ +function add_defer_attribute( $tag, $handle ) { + if ( 0 === strpos( $handle, 'interactivity-' ) ) { + $p = new WP_HTML_Tag_Processor( $tag ); + $p->next_tag( array( 'tag' => 'script' ) ); + $p->set_attribute( 'defer', true ); + return $p->get_updated_html(); + } + return $tag; +} +add_filter( 'script_loader_tag', 'add_defer_attribute', 10, 2 ); diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index 9acf78ac1037bd..63e9c0aca0597a 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -8,9 +8,7 @@ export { store } from './store'; /** * Initialize the Interactivity API. */ -window.addEventListener( 'DOMContentLoaded', () => { - registerDirectives(); - hydrate(); - // eslint-disable-next-line no-console - console.log( 'Interactivity API started' ); -} ); +registerDirectives(); +hydrate(); +// eslint-disable-next-line no-console +console.log( 'Interactivity API started' ); From 409d161081a80b2e3cab94666bf14d756f885419 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 18:01:26 +0200 Subject: [PATCH 08/38] Revert changes of the image block Co-authored-by: David Arenas --- packages/block-library/src/image/index.php | 14 +------------- packages/block-library/src/image/interactivity.js | 13 ------------- packages/block-library/src/image/interactivity2.js | 13 ------------- tools/webpack/blocks.js | 2 +- 4 files changed, 2 insertions(+), 40 deletions(-) delete mode 100644 packages/block-library/src/image/interactivity.js delete mode 100644 packages/block-library/src/image/interactivity2.js diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 331ae3982ff987..e05939a4d0feac 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -14,18 +14,8 @@ * @return string Returns the block content with the data-id attribute added. */ function render_block_core_image( $attributes, $content ) { - wp_enqueue_script( - 'interactivity-image', - plugins_url('../interactive-blocks/image.min.js', __FILE__ ), - array( 'interactivity-runtime' ) - ); - $processor = new WP_HTML_Tag_Processor( $content ); - $processor->next_tag( 'figure' ); - $processor->set_attribute( 'data-wp-island', '' ); - $processor->set_attribute( 'data-wp-context', '{ "text": "image hydrated" }' ); $processor->next_tag( 'img' ); - $processor->set_attribute( 'data-wp-effect', 'effects.alert' ); if ( $processor->get_attribute( 'src' ) === null ) { return ''; @@ -37,10 +27,8 @@ function render_block_core_image( $attributes, $content ) { // which now wraps Image Blocks within innerBlocks. // The data-id attribute is added in a core/gallery `render_block_data` hook. $processor->set_attribute( 'data-id', $attributes['data-id'] ); + $content = $processor->get_updated_html(); } - - $content = $processor->get_updated_html(); - return $content; } diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js deleted file mode 100644 index dd29ee4a7d3689..00000000000000 --- a/packages/block-library/src/image/interactivity.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal dependencies - */ -import { store } from '../utils/interactivity'; - -store( { - effects: { - alert: ( { context } ) => { - // eslint-disable-next-line no-console - console.log( context.text ); - }, - }, -} ); diff --git a/packages/block-library/src/image/interactivity2.js b/packages/block-library/src/image/interactivity2.js deleted file mode 100644 index dd29ee4a7d3689..00000000000000 --- a/packages/block-library/src/image/interactivity2.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Internal dependencies - */ -import { store } from '../utils/interactivity'; - -store( { - effects: { - alert: ( { context } ) => { - // eslint-disable-next-line no-console - console.log( context.text ); - }, - }, -} ); diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 928f5049031e9e..35081eb3b12dda 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -221,7 +221,7 @@ module.exports = [ }, { entry: { - image: './packages/block-library/src/image/interactivity.js', + // blockname: './packages/block-library/src/blockname/interactivity.js', }, output: { devtoolNamespace: 'wp', From 29e11ab90064cfce03c3ed99e65ff29eb2a91b60 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 21 Apr 2023 18:13:15 +0200 Subject: [PATCH 09/38] Fix init import name Co-authored-by: David Arenas --- packages/block-library/src/utils/interactivity/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index 63e9c0aca0597a..55cf73342013b4 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -2,13 +2,13 @@ * Internal dependencies */ import registerDirectives from './directives'; -import { init as hydrate } from './hydration'; +import { init } from './hydration'; export { store } from './store'; /** * Initialize the Interactivity API. */ registerDirectives(); -hydrate(); +init(); // eslint-disable-next-line no-console console.log( 'Interactivity API started' ); From f4b6c0a9536bf878ba12f54032bcf4186dc3997a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 28 Apr 2023 14:39:48 +0200 Subject: [PATCH 10/38] Move and refactor the interactive scritps registration --- lib/client-assets.php | 47 ----------------- .../interactivity-api/script-loader.php | 50 +++++++++++++++++++ lib/load.php | 1 + 3 files changed, 51 insertions(+), 47 deletions(-) create mode 100644 lib/experimental/interactivity-api/script-loader.php diff --git a/lib/client-assets.php b/lib/client-assets.php index a0dc00cbcd6767..9c0483ea539b93 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -577,50 +577,3 @@ function gutenberg_register_vendor_scripts( $scripts ) { // Enqueue stored styles. add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_stored_styles' ); add_action( 'wp_footer', 'gutenberg_enqueue_stored_styles', 1 ); - -/** - * Registers interactivity scripts for Gutenberg. - * - * This function registers interactivity scripts for Gutenberg when not in the - * admin panel. - */ -function gutenberg_register_interactivity_scripts() { - if ( ! is_admin() ) { - wp_register_script( - 'interactivity-runtime', - plugins_url( - '../build/block-library/interactive-blocks/interactivity.min.js', - __FILE__ - ), - array( 'interactivity-vendors') - ); - - wp_register_script( - 'interactivity-vendors', - plugins_url( - '../build/block-library/interactive-blocks/vendors.min.js', - __FILE__ - ) - ); - } -} -add_action( 'wp_enqueue_scripts', 'gutenberg_register_interactivity_scripts' ); - -/** - * Adds the "defer" attribute to all the interactivity script tags. - * - * @param string $tag The generated script tag. - * @param string $handle The script's registered handle. - * - * @return string The modified script tag. - */ -function add_defer_attribute( $tag, $handle ) { - if ( 0 === strpos( $handle, 'interactivity-' ) ) { - $p = new WP_HTML_Tag_Processor( $tag ); - $p->next_tag( array( 'tag' => 'script' ) ); - $p->set_attribute( 'defer', true ); - return $p->get_updated_html(); - } - return $tag; -} -add_filter( 'script_loader_tag', 'add_defer_attribute', 10, 2 ); diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php new file mode 100644 index 00000000000000..206a1d8e5e0b78 --- /dev/null +++ b/lib/experimental/interactivity-api/script-loader.php @@ -0,0 +1,50 @@ +next_tag( array( 'tag' => 'script' ) ); + $p->set_attribute( 'defer', true ); + return $p->get_updated_html(); + } + return $tag; +} +add_filter( 'script_loader_tag', 'gutenberg_interactivity_scripts_add_defer_attribute', 10, 2 ); diff --git a/lib/load.php b/lib/load.php index 8cceb21906293b..785ac2fb1cbf57 100644 --- a/lib/load.php +++ b/lib/load.php @@ -97,6 +97,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/block-editor-settings-mobile.php'; require __DIR__ . '/experimental/block-editor-settings.php'; require __DIR__ . '/experimental/blocks.php'; +require __DIR__ . '/experimental/interactivity-api/script-loader.php'; require __DIR__ . '/experimental/navigation-theme-opt-in.php'; require __DIR__ . '/experimental/kses.php'; require __DIR__ . '/experimental/l10n.php'; From 3d9447302e1066baddda6c9c5b6841759e7ea301 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 28 Apr 2023 14:48:32 +0200 Subject: [PATCH 11/38] Fix code style violations --- .../interactivity-api/script-loader.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php index 206a1d8e5e0b78..74ea388316f11d 100644 --- a/lib/experimental/interactivity-api/script-loader.php +++ b/lib/experimental/interactivity-api/script-loader.php @@ -17,7 +17,7 @@ function gutenberg_register_interactivity_scripts( $scripts ) { gutenberg_url( 'build/block-library/interactive-blocks/interactivity.min.js' ), - array( 'interactivity-vendors') + array( 'interactivity-vendors' ) ); gutenberg_override_script( @@ -31,20 +31,20 @@ function gutenberg_register_interactivity_scripts( $scripts ) { add_action( 'wp_default_scripts', 'gutenberg_register_interactivity_scripts', 10, 1 ); /** - * Adds the "defer" attribute to all the interactivity script tags. - * - * @param string $tag The generated script tag. - * @param string $handle The script's registered handle. - * - * @return string The modified script tag. - */ + * Adds the "defer" attribute to all the interactivity script tags. + * + * @param string $tag The generated script tag. + * @param string $handle The script's registered handle. + * + * @return string The modified script tag. + */ function gutenberg_interactivity_scripts_add_defer_attribute( $tag, $handle ) { - if ( 0 === strpos( $handle, 'interactivity-' ) ) { - $p = new WP_HTML_Tag_Processor( $tag ); - $p->next_tag( array( 'tag' => 'script' ) ); - $p->set_attribute( 'defer', true ); - return $p->get_updated_html(); - } - return $tag; + if ( 0 === strpos( $handle, 'interactivity-' ) ) { + $p = new WP_HTML_Tag_Processor( $tag ); + $p->next_tag( array( 'tag' => 'script' ) ); + $p->set_attribute( 'defer', true ); + return $p->get_updated_html(); + } + return $tag; } add_filter( 'script_loader_tag', 'gutenberg_interactivity_scripts_add_defer_attribute', 10, 2 ); From 6ae760f5e92147285da1cf8dbb8aa44ce8264272 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 28 Apr 2023 14:59:57 +0200 Subject: [PATCH 12/38] Use `wp-interactivity-` prefix for script handles --- lib/experimental/interactivity-api/script-loader.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php index 74ea388316f11d..f03debc4c7fa18 100644 --- a/lib/experimental/interactivity-api/script-loader.php +++ b/lib/experimental/interactivity-api/script-loader.php @@ -13,16 +13,16 @@ function gutenberg_register_interactivity_scripts( $scripts ) { gutenberg_override_script( $scripts, - 'interactivity-runtime', + 'wp-interactivity-runtime', gutenberg_url( 'build/block-library/interactive-blocks/interactivity.min.js' ), - array( 'interactivity-vendors' ) + array( 'wp-interactivity-vendors' ) ); gutenberg_override_script( $scripts, - 'interactivity-vendors', + 'wp-interactivity-vendors', gutenberg_url( 'build/block-library/interactive-blocks/vendors.min.js' ) @@ -39,7 +39,7 @@ function gutenberg_register_interactivity_scripts( $scripts ) { * @return string The modified script tag. */ function gutenberg_interactivity_scripts_add_defer_attribute( $tag, $handle ) { - if ( 0 === strpos( $handle, 'interactivity-' ) ) { + if ( 0 === strpos( $handle, 'wp-interactivity-' ) ) { $p = new WP_HTML_Tag_Processor( $tag ); $p->next_tag( array( 'tag' => 'script' ) ); $p->set_attribute( 'defer', true ); From 9d6869e5ad21ee7ea8b8120854939bbbf1af32b1 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 28 Apr 2023 15:00:46 +0200 Subject: [PATCH 13/38] Improve the matcher for side effects in `package.json` --- packages/block-library/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 000f904a4f7d4b..3b6ab1f0140804 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -28,7 +28,7 @@ "build-style/**", "src/**/*.scss", "{src,build,build-module}/*/init.js", - "src/utils/interactivity/index.js" + "{src,build,build-module}/utils/interactivity/index.js" ], "dependencies": { "@babel/runtime": "^7.16.0", From c6d02d88aaa3aab9195b03b176f5c2780b874dc9 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 May 2023 11:36:27 +0200 Subject: [PATCH 14/38] Add custom useSignalEffect --- .../src/utils/interactivity/directives.js | 5 +- .../src/utils/interactivity/utils.js | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/utils/interactivity/directives.js b/packages/block-library/src/utils/interactivity/directives.js index 07874d4f91d072..d7583fa8eaa33e 100644 --- a/packages/block-library/src/utils/interactivity/directives.js +++ b/packages/block-library/src/utils/interactivity/directives.js @@ -2,11 +2,12 @@ * External dependencies */ import { useContext, useMemo, useEffect } from 'preact/hooks'; -import { useSignalEffect } from '@preact/signals'; import { deepSignal, peek } from 'deepsignal'; + /** * Internal dependencies */ +import { useSignalEffect } from './utils'; import { directive } from './hooks'; const isObject = ( item ) => @@ -76,7 +77,7 @@ export default () => { const contextValue = useContext( context ); Object.entries( on ).forEach( ( [ name, path ] ) => { element.props[ `on${ name }` ] = ( event ) => { - return evaluate( path, { event, context: contextValue } ); + evaluate( path, { event, context: contextValue } ); }; } ); } ); diff --git a/packages/block-library/src/utils/interactivity/utils.js b/packages/block-library/src/utils/interactivity/utils.js index 48c50fc537c054..21d15da2f94ff9 100644 --- a/packages/block-library/src/utils/interactivity/utils.js +++ b/packages/block-library/src/utils/interactivity/utils.js @@ -1,3 +1,49 @@ +/** + * External dependencies + */ +import { useRef, useEffect } from 'preact/hooks'; +import { effect } from '@preact/signals'; + +function afterNextFrame( callback ) { + const done = () => { + window.cancelAnimationFrame( raf ); + setTimeout( callback ); + }; + const raf = window.requestAnimationFrame( done ); +} + +// Using the mangled properties: +// this.c: this._callback +// this.x: this._compute +// https://github.com/preactjs/signals/blob/main/mangle.json +function createFlusher( compute, notify ) { + let flush; + const dispose = effect( function () { + flush = this.c.bind( this ); + this.x = compute; + this.c = notify; + return compute(); + } ); + return { flush, dispose }; +} + +// Version of `useSignalEffect` with a `useEffect`-like execution. This hook +// implementation comes from this PR: +// https://github.com/preactjs/signals/pull/290. +// +// We need to include it here in this repo until the mentioned PR is merged. +export function useSignalEffect( cb ) { + const callback = useRef( cb ); + callback.current = cb; + + useEffect( () => { + const execute = () => callback.current(); + const notify = () => afterNextFrame( eff.flush ); + const eff = createFlusher( execute, notify ); + return eff.dispose; + }, [] ); +} + // For wrapperless hydration. // See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c export const createRootFragment = ( parent, replaceNode ) => { From af559179750a14fc532fb3b3a87249dfbba35466 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 4 May 2023 19:11:26 +0200 Subject: [PATCH 15/38] Call `init` after `store` has been initialized --- packages/block-library/src/utils/interactivity/index.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/block-library/src/utils/interactivity/index.js index 55cf73342013b4..6dbac1a45e88ca 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/block-library/src/utils/interactivity/index.js @@ -9,6 +9,9 @@ export { store } from './store'; * Initialize the Interactivity API. */ registerDirectives(); -init(); -// eslint-disable-next-line no-console -console.log( 'Interactivity API started' ); + +document.addEventListener( 'DOMContentLoaded', async () => { + await init(); + // eslint-disable-next-line no-console + console.log( 'Interactivity API started' ); +} ); From 7c1f2d1f3564d6063bfa853ee4eda78cd543a1ba Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Mon, 3 Apr 2023 16:38:15 +0200 Subject: [PATCH 16/38] Add lightbox to image block First pass at adding lightbox. Note: Added custom implementation of Preact Portal because the children[0] declaration in the render method was erroneous and undefined, preventing it from working as expected. --- package-lock.json | 22 ++ package.json | 2 + packages/block-library/package.json | 3 - packages/block-library/src/image/block.json | 4 +- packages/block-library/src/image/index.php | 9 +- .../src/image/runtime/constants.js | 3 + .../src/image/runtime/directives.js | 256 ++++++++++++++++++ .../block-library/src/image/runtime/hooks.js | 85 ++++++ .../block-library/src/image/runtime/index.js | 2 + .../block-library/src/image/runtime/init.js | 11 + .../block-library/src/image/runtime/portal.js | 73 +++++ .../block-library/src/image/runtime/router.js | 171 ++++++++++++ .../block-library/src/image/runtime/store.js | 46 ++++ .../block-library/src/image/runtime/utils.js | 20 ++ .../block-library/src/image/runtime/vdom.js | 71 +++++ packages/block-library/src/image/style.scss | 20 ++ packages/block-library/src/image/view.js | 38 +++ 17 files changed, 831 insertions(+), 5 deletions(-) create mode 100644 packages/block-library/src/image/runtime/constants.js create mode 100644 packages/block-library/src/image/runtime/directives.js create mode 100644 packages/block-library/src/image/runtime/hooks.js create mode 100644 packages/block-library/src/image/runtime/index.js create mode 100644 packages/block-library/src/image/runtime/init.js create mode 100644 packages/block-library/src/image/runtime/portal.js create mode 100644 packages/block-library/src/image/runtime/router.js create mode 100644 packages/block-library/src/image/runtime/store.js create mode 100644 packages/block-library/src/image/runtime/utils.js create mode 100644 packages/block-library/src/image/runtime/vdom.js create mode 100644 packages/block-library/src/image/view.js diff --git a/package-lock.json b/package-lock.json index 3a1bf1574cf812..21251971fe606b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7275,6 +7275,28 @@ "@babel/runtime": "^7.13.10" } }, + "@preact/signals": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.3.tgz", + "integrity": "sha512-N09DuAVvc90bBZVRwD+aFhtGyHAmJLhS3IFoawO/bYJRcil4k83nBOchpCEoS0s5+BXBpahgp0Mjf+IOqP57Og==", + "requires": { + "@preact/signals-core": "^1.2.3" + } + }, + "@preact/signals-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.3.tgz", + "integrity": "sha512-Kui4p7PMcEQevBgsTO0JBo3gyQ88Q3qzEvsVCuSp11t0JcN4DmGCTJcGRVSCq7Bn7lGxJBO+57jNSzDoDJ+QmA==" + }, + "@preact/signals-react": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@preact/signals-react/-/signals-react-1.2.2.tgz", + "integrity": "sha512-GoESQ9n1bns2FD+8yqH7lBvQMavboKLCNEW+s0hs3Wcp5B1VHvVxwJo6aFs6rpxoh1/q8Tvwbi4vIeehBD2mzA==", + "requires": { + "@preact/signals-core": "^1.2.3", + "use-sync-external-store": "^1.2.0" + } + }, "@react-native-clipboard/clipboard": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.9.0.tgz", diff --git a/package.json b/package.json index 45b1e5ef319d25..79f77b87c07484 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,8 @@ "@wordpress/warning": "file:packages/warning", "@wordpress/widgets": "file:packages/widgets", "@wordpress/wordcount": "file:packages/wordcount", + "deepsignal": "1.3.0", + "preact": "10.13.2", "wicg-inert": "3.1.2" }, "devDependencies": { diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 3b6ab1f0140804..efcb4477634299 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -32,7 +32,6 @@ ], "dependencies": { "@babel/runtime": "^7.16.0", - "@preact/signals": "^1.1.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", @@ -65,14 +64,12 @@ "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", - "deepsignal": "^1.3.0", "escape-html": "^1.0.3", "fast-average-color": "^9.1.1", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", - "preact": "^10.13.2", "remove-accents": "^0.4.2" }, "peerDependencies": { diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 92931455c1144c..693ce2bf29e6ac 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -83,6 +83,7 @@ } }, "supports": { + "interactivity": true, "anchor": true, "color": { "text": false, @@ -118,5 +119,6 @@ { "name": "rounded", "label": "Rounded" } ], "editorStyle": "wp-block-image-editor", - "style": "wp-block-image" + "style": "wp-block-image", + "viewScript": [ "file:./view.min.js" ] } diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index e05939a4d0feac..8d7182fdb34319 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -15,6 +15,9 @@ */ function render_block_core_image( $attributes, $content ) { $processor = new WP_HTML_Tag_Processor( $content ); + $processor->next_tag( 'figure' ); + $processor->set_attribute( 'data-wp-context', '{ "core": { "isZoomed": false } }' ); + $processor->next_tag( 'img' ); if ( $processor->get_attribute( 'src' ) === null ) { @@ -29,7 +32,11 @@ function render_block_core_image( $attributes, $content ) { $processor->set_attribute( 'data-id', $attributes['data-id'] ); $content = $processor->get_updated_html(); } - return $content; + $processor->set_attribute( 'data-wp-on.click', 'actions.core.imageZoom'); + $processor->set_attribute( 'data-wp-class.isZoomed', 'context.core.isZoomed'); + $content = $processor->get_updated_html(); + + return $content . '
'; } diff --git a/packages/block-library/src/image/runtime/constants.js b/packages/block-library/src/image/runtime/constants.js new file mode 100644 index 00000000000000..66aa781744c5e7 --- /dev/null +++ b/packages/block-library/src/image/runtime/constants.js @@ -0,0 +1,3 @@ +export const csnMetaTagItemprop = 'wp-client-side-navigation'; +export const componentPrefix = 'wp-'; +export const directivePrefix = 'data-wp-'; diff --git a/packages/block-library/src/image/runtime/directives.js b/packages/block-library/src/image/runtime/directives.js new file mode 100644 index 00000000000000..91ec1a32004b18 --- /dev/null +++ b/packages/block-library/src/image/runtime/directives.js @@ -0,0 +1,256 @@ +/** @jsx h */ + +/** + * External dependencies + */ +import { h } from 'preact'; + +import { useContext, useMemo, useEffect } from 'preact/hooks'; +import { useSignalEffect } from '@preact/signals'; +import { deepSignal, peek } from 'deepsignal'; +/** + * Internal dependencies + */ +import { directive } from './hooks'; +import { prefetch, navigate, canDoClientSideNavigation } from './router'; +import Portal from './portal.js'; + +// Until useSignalEffects is fixed: +// https://github.com/preactjs/signals/issues/228 +const raf = window.requestAnimationFrame; +const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) ); + +// Check if current page can do client-side navigation. +const clientSideNavigation = canDoClientSideNavigation( document.head ); + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const mergeDeepSignals = ( target, source ) => { + for ( const k in source ) { + if ( typeof peek( target, k ) === 'undefined' ) { + target[ `$${ k }` ] = source[ `$${ k }` ]; + } else if ( + isObject( peek( target, k ) ) && + isObject( peek( source, k ) ) + ) { + mergeDeepSignals( + target[ `$${ k }` ].peek(), + source[ `$${ k }` ].peek() + ); + } + } +}; + +export default () => { + // data-wp-context + directive( + 'context', + ( { + directives: { + context: { default: context }, + }, + props: { children }, + context: inherited, + } ) => { + const { Provider } = inherited; + const inheritedValue = useContext( inherited ); + const value = useMemo( () => { + const localValue = deepSignal( context ); + mergeDeepSignals( localValue, inheritedValue ); + return localValue; + }, [ context, inheritedValue ] ); + + return { children }; + } + ); + + directive( + 'portal', + ( { + directives: { + portal: { default: portal }, + }, + props: { children }, + } ) => { + return { children }; + } + ); + + // data-wp-effect.[name] + directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( effect ).forEach( ( path ) => { + useSignalEffect( () => { + evaluate( path, { context: contextValue } ); + } ); + } ); + } ); + + // data-wp-on.[event] + directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { + const contextValue = useContext( context ); + Object.entries( on ).forEach( ( [ name, path ] ) => { + element.props[ `on${ name }` ] = ( event ) => { + evaluate( path, { event, context: contextValue } ); + }; + } ); + } ); + + // data-wp-class.[classname] + directive( + 'class', + ( { + directives: { class: className }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + Object.keys( className ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( name ) => { + const result = evaluate( className[ name ], { + className: name, + context: contextValue, + } ); + const currentClass = element.props.class || ''; + const classFinder = new RegExp( + `(^|\\s)${ name }(\\s|$)`, + 'g' + ); + if ( ! result ) + element.props.class = currentClass + .replace( classFinder, ' ' ) + .trim(); + else if ( ! classFinder.test( currentClass ) ) + element.props.class = currentClass + ? `${ currentClass } ${ name }` + : name; + + useEffect( () => { + // This seems necessary because Preact doesn't change the class names + // on the hydration, so we have to do it manually. It doesn't need + // deps because it only needs to do it the first time. + if ( ! result ) { + element.ref.current.classList.remove( name ); + } else { + element.ref.current.classList.add( name ); + } + }, [] ); + } ); + } + ); + + // data-wp-bind.[attribute] + directive( + 'bind', + ( { directives: { bind }, element, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.entries( bind ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( [ attribute, path ] ) => { + element.props[ attribute ] = evaluate( path, { + context: contextValue, + } ); + } ); + } + ); + + // data-wp-link + directive( + 'link', + ( { + directives: { + link: { default: link }, + }, + props: { href }, + element, + } ) => { + useEffect( () => { + // Prefetch the page if it is in the directive options. + if ( clientSideNavigation && link?.prefetch ) { + prefetch( href ); + } + } ); + + // Don't do anything if it's falsy. + if ( clientSideNavigation && link !== false ) { + element.props.onclick = async ( event ) => { + event.preventDefault(); + + // Fetch the page (or return it from cache). + await navigate( href ); + + // Update the scroll, depending on the option. True by default. + if ( link?.scroll === 'smooth' ) { + window.scrollTo( { + top: 0, + left: 0, + behavior: 'smooth', + } ); + } else if ( link?.scroll !== false ) { + window.scrollTo( 0, 0 ); + } + }; + } + } + ); + + // data-wp-show + directive( + 'show', + ( { + directives: { + show: { default: show }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + if ( ! evaluate( show, { context: contextValue } ) ) + element.props.children = ( + + ); + } + ); + + // data-wp-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + + ); + } + ); + + // data-wp-text + directive( + 'text', + ( { + directives: { + text: { default: text }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + element.props.children = evaluate( text, { + context: contextValue, + } ); + } + ); +}; diff --git a/packages/block-library/src/image/runtime/hooks.js b/packages/block-library/src/image/runtime/hooks.js new file mode 100644 index 00000000000000..40b9f320952e1c --- /dev/null +++ b/packages/block-library/src/image/runtime/hooks.js @@ -0,0 +1,85 @@ +import { h, options, createContext } from 'preact'; +import { useRef } from 'preact/hooks'; +import { rawStore as store } from './store'; +import { componentPrefix } from './constants'; + +// Main context. +const context = createContext({}); + +// WordPress Directives. +const directiveMap = {}; +export const directive = (name, cb) => { + directiveMap[name] = cb; +}; + +// WordPress Components. +const componentMap = {}; +export const component = (name, Comp) => { + componentMap[name] = Comp; +}; + +// Resolve the path to some property of the store object. +const resolve = (path, context) => { + let current = { ...store, context }; + path.split('.').forEach((p) => (current = current[p])); + return current; +}; + +// Generate the evaluate function. +const getEvaluate = + ({ ref } = {}) => + (path, extraArgs = {}) => { + const value = resolve(path, extraArgs.context); + return typeof value === 'function' + ? value({ + state: store.state, + ...(ref !== undefined ? { ref } : {}), + ...extraArgs, + }) + : value; + }; + +// Directive wrapper. +const Directive = ({ type, directives, props: originalProps }) => { + const ref = useRef(null); + const element = h(type, { ...originalProps, ref, _wrapped: true }); + const props = { ...originalProps, children: element }; + const evaluate = getEvaluate({ ref: ref.current }); + const directiveArgs = { directives, props, element, context, evaluate }; + + for (const d in directives) { + const wrapper = directiveMap[d]?.(directiveArgs); + if (wrapper !== undefined) props.children = wrapper; + } + + return props.children; +}; + +// Preact Options Hook called each time a vnode is created. +const old = options.vnode; +options.vnode = (vnode) => { + const type = vnode.type; + const { directives } = vnode.props; + + if ( + typeof type === 'string' && + type.slice(0, componentPrefix.length) === componentPrefix + ) { + vnode.props.children = h( + componentMap[type.slice(componentPrefix.length)], + { ...vnode.props, context, evaluate: getEvaluate() }, + vnode.props.children + ); + } else if (directives) { + const props = vnode.props; + delete props.directives; + if (!props._wrapped) { + vnode.props = { type: vnode.type, directives, props }; + vnode.type = Directive; + } else { + delete props._wrapped; + } + } + + if (old) old(vnode); +}; diff --git a/packages/block-library/src/image/runtime/index.js b/packages/block-library/src/image/runtime/index.js new file mode 100644 index 00000000000000..926021bb579790 --- /dev/null +++ b/packages/block-library/src/image/runtime/index.js @@ -0,0 +1,2 @@ +export { store } from './store'; +export { navigate } from './router'; diff --git a/packages/block-library/src/image/runtime/init.js b/packages/block-library/src/image/runtime/init.js new file mode 100644 index 00000000000000..772a690c3279fd --- /dev/null +++ b/packages/block-library/src/image/runtime/init.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import registerDirectives from './directives'; +import { init } from './router'; + +document.addEventListener( 'DOMContentLoaded', async () => { + registerDirectives(); + await init(); + console.log( 'hydrated!' ); +} ); diff --git a/packages/block-library/src/image/runtime/portal.js b/packages/block-library/src/image/runtime/portal.js new file mode 100644 index 00000000000000..4b7cf67693dbc7 --- /dev/null +++ b/packages/block-library/src/image/runtime/portal.js @@ -0,0 +1,73 @@ +/** @jsx h */ + +/** + * External dependencies + */ +import { h, Component, render } from 'preact'; + +/** Redirect rendering of descendants into the given CSS selector. + * @example + * + *
I am rendered into document.body
+ *
+ */ +export default class Portal extends Component { + componentDidUpdate(props) { + for (let i in props) { + if (props[i]!==this.props[i]) { + return setTimeout(this.renderLayer); + } + } + } + + componentDidMount() { + this.isMounted=true; + this.renderLayer = this.renderLayer.bind(this); + this.renderLayer(); + } + + componentWillUnmount() { + this.renderLayer(false); + this.isMounted=false; + if (this.remote) this.remote.parentNode.removeChild(this.remote); + } + + findNode(node) { + return typeof node==='string' ? document.querySelector(node) : node; + } + + renderLayer(show=true) { + if (!this.isMounted) return; + + // clean up old node if moving bases: + if (this.props.into!==this.intoPointer) { + this.intoPointer = this.props.into; + if (this.into && this.remote) { + this.remote = render(, this.into, this.remote); + } + this.into = this.findNode(this.props.into); + } + + this.remote = render(( + + { show && this.props.children || null } + + ), this.into, this.remote); + } + + render() { + return null; + } +} + + +// high-order component that renders its first child if it exists. +// used as a conditional rendering proxy. +class PortalProxy extends Component { + getChildContext() { + return this.props.context; + } + render({ children }) { + return children || null; + } +} diff --git a/packages/block-library/src/image/runtime/router.js b/packages/block-library/src/image/runtime/router.js new file mode 100644 index 00000000000000..e813e1c1f19b92 --- /dev/null +++ b/packages/block-library/src/image/runtime/router.js @@ -0,0 +1,171 @@ +/** + * External dependencies + */ +import { hydrate, render } from 'preact'; +/** + * Internal dependencies + */ +import { toVdom, hydratedIslands } from './vdom'; +import { createRootFragment } from './utils'; +import { csnMetaTagItemprop, directivePrefix } from './constants'; + +// The root to render the vdom (document.body). +let rootFragment; + +// The cache of visited and prefetched pages, stylesheets and scripts. +const pages = new Map(); +const stylesheets = new Map(); +const scripts = new Map(); + +// Helper to remove domain and hash from the URL. We are only interesting in +// caching the path and the query. +const cleanUrl = ( url ) => { + const u = new URL( url, window.location ); + return u.pathname + u.search; +}; + +// Helper to check if a page can do client-side navigation. +export const canDoClientSideNavigation = ( dom ) => + dom + .querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` ) + ?.getAttribute( 'content' ) === 'active'; + +/** + * Finds the elements in the document that match the selector and fetch them. + * For each element found, fetch the content and store it in the cache. + * Returns an array of elements to add to the document. + * + * @param document + * @param {string} selector - CSS selector used to find the elements. + * @param {'href'|'src'} attribute - Attribute that determines where to fetch + * the styles or scripts from. Also used as the key for the cache. + * @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`. + * @param {'style'|'script'} elementToCreate - Element to create for each fetched + * item. Can be 'style' or 'script'. + * @return {Promise>} - Array of elements to add to the document. + */ +const fetchScriptOrStyle = async ( + document, + selector, + attribute, + cache, + elementToCreate +) => { + const fetchedItems = await Promise.all( + [].map.call( document.querySelectorAll( selector ), ( el ) => { + const attributeValue = el.getAttribute( attribute ); + if ( ! cache.has( attributeValue ) ) + cache.set( + attributeValue, + fetch( attributeValue ).then( ( r ) => r.text() ) + ); + return cache.get( attributeValue ); + } ) + ); + + return fetchedItems.map( ( item ) => { + const element = document.createElement( elementToCreate ); + element.textContent = item; + return element; + } ); +}; + +// Fetch styles of a new page. +const fetchAssets = async ( document ) => { + const stylesFromSheets = await fetchScriptOrStyle( + document, + 'link[rel=stylesheet]', + 'href', + stylesheets, + 'style' + ); + const scriptTags = await fetchScriptOrStyle( + document, + 'script[src]', + 'src', + scripts, + 'script' + ); + const moduleScripts = await fetchScriptOrStyle( + document, + 'script[type=module]', + 'src', + scripts, + 'script' + ); + moduleScripts.forEach( ( script ) => + script.setAttribute( 'type', 'module' ) + ); + + return [ + ...scriptTags, + document.querySelector( 'title' ), + ...document.querySelectorAll( 'style' ), + ...stylesFromSheets, + ]; +}; + +// Fetch a new page and convert it to a static virtual DOM. +const fetchPage = async ( url, options = {} ) => { + const html = + options.html || ( await window.fetch( url ).then( ( r ) => r.text() ) ); + const dom = new window.DOMParser().parseFromString( html, 'text/html' ); + const head = await fetchAssets( dom ); + return { head, body: toVdom( dom.body ) }; +}; + +// Prefetch a page. We store the promise to avoid triggering a second fetch for +// a page if a fetching has already started. +export const prefetch = ( url, options = {} ) => { + url = cleanUrl( url ); + if ( options.force || ! pages.has( url ) ) { + pages.set( url, fetchPage( url, options ) ); + } +}; + +// Navigate to a new page. +export const navigate = async ( href, options = {} ) => { + const url = cleanUrl( href ); + prefetch( url, options ); + const page = await pages.get( url ); + if ( page ) { + document.head.replaceChildren( ...page.head ); + render( page.body, rootFragment ); + window.history.pushState( {}, '', href ); + } else { + window.location.assign( href ); + } +}; + +// Listen to the back and forward buttons and restore the page if it's in the +// cache. +window.addEventListener( 'popstate', async () => { + const url = cleanUrl( window.location ); // Remove hash. + const page = pages.has( url ) && ( await pages.get( url ) ); + if ( page ) { + document.head.replaceChildren( ...page.head ); + render( page.body, rootFragment ); + } else { + window.location.reload(); + } +} ); + +// Initialize the router with the initial DOM. +export const init = async () => { + // Create the root fragment to hydrate everything. + rootFragment = createRootFragment( + document.documentElement, + document.body + ); + + const body = toVdom( document.body ); + hydrate( body, rootFragment ); + + // Cache the scripts. Has to be called before fetching the assets. + [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { + scripts.set( script.getAttribute( 'src' ), script.textContent ); + } ); + + const head = await fetchAssets( document ); + pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) ); +}; diff --git a/packages/block-library/src/image/runtime/store.js b/packages/block-library/src/image/runtime/store.js new file mode 100644 index 00000000000000..eca682a194e844 --- /dev/null +++ b/packages/block-library/src/image/runtime/store.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { deepSignal } from 'deepsignal'; + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +export const deepMerge = ( target, source ) => { + if ( isObject( target ) && isObject( source ) ) { + for ( const key in source ) { + if ( isObject( source[ key ] ) ) { + if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); + deepMerge( target[ key ], source[ key ] ); + } else { + Object.assign( target, { [ key ]: source[ key ] } ); + } + } + } +}; + +const getSerializedState = () => { + // TODO: change the store tag ID for a better one. + const storeTag = document.querySelector( + `script[type="application/json"]#store` + ); + if ( ! storeTag ) return {}; + try { + const { state } = JSON.parse( storeTag.textContent ); + if ( isObject( state ) ) return state; + throw Error( 'Parsed state is not an object' ); + } catch ( e ) { + console.log( e ); + } + return {}; +}; + +const rawState = getSerializedState(); +export const rawStore = { state: deepSignal( rawState ) }; + +if ( typeof window !== 'undefined' ) window.store = rawStore; + +export const store = ( { state, ...block } ) => { + deepMerge( rawStore, block ); + deepMerge( rawState, state ); +}; diff --git a/packages/block-library/src/image/runtime/utils.js b/packages/block-library/src/image/runtime/utils.js new file mode 100644 index 00000000000000..f1748293cad99f --- /dev/null +++ b/packages/block-library/src/image/runtime/utils.js @@ -0,0 +1,20 @@ +// For wrapperless hydration of document.body. +// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c +export const createRootFragment = (parent, replaceNode) => { + replaceNode = [].concat(replaceNode); + const s = replaceNode[replaceNode.length - 1].nextSibling; + function insert(c, r) { + parent.insertBefore(c, r || s); + } + return (parent.__k = { + nodeType: 1, + parentNode: parent, + firstChild: replaceNode[0], + childNodes: replaceNode, + insertBefore: insert, + appendChild: insert, + removeChild(c) { + parent.removeChild(c); + }, + }); +}; diff --git a/packages/block-library/src/image/runtime/vdom.js b/packages/block-library/src/image/runtime/vdom.js new file mode 100644 index 00000000000000..f1cd54420a86a4 --- /dev/null +++ b/packages/block-library/src/image/runtime/vdom.js @@ -0,0 +1,71 @@ +import { h } from 'preact'; +import { directivePrefix as p } from './constants'; + +const ignoreAttr = `${p}ignore`; +const islandAttr = `${p}island`; +const directiveParser = new RegExp(`${p}([^.]+)\.?(.*)$`); + +export const hydratedIslands = new WeakSet(); + +// Recursive function that transfoms a DOM tree into vDOM. +export function toVdom(node) { + const props = {}; + const { attributes, childNodes } = node; + const directives = {}; + let hasDirectives = false; + let ignore = false; + let island = false; + + if (node.nodeType === 3) return node.data; + if (node.nodeType === 4) { + node.replaceWith(new Text(node.nodeValue)); + return node.nodeValue; + } + + for (let i = 0; i < attributes.length; i++) { + const n = attributes[i].name; + if (n[p.length] && n.slice(0, p.length) === p) { + if (n === ignoreAttr) { + ignore = true; + } else if (n === islandAttr) { + island = true; + } else { + hasDirectives = true; + let val = attributes[i].value; + try { + val = JSON.parse(val); + } catch (e) {} + const [, prefix, suffix] = directiveParser.exec(n); + directives[prefix] = directives[prefix] || {}; + directives[prefix][suffix || 'default'] = val; + } + } else if (n === 'ref') { + continue; + } else { + props[n] = attributes[i].value; + } + } + + if (ignore && !island) + return h(node.localName, { + ...props, + innerHTML: node.innerHTML, + directives: { ignore: true }, + }); + if (island) hydratedIslands.add(node); + + if (hasDirectives) props.directives = directives; + + const children = []; + for (let i = 0; i < childNodes.length; i++) { + const child = childNodes[i]; + if (child.nodeType === 8 || child.nodeType === 7) { + child.remove(); + i--; + } else { + children.push(toVdom(child)); + } + } + + return h(node.localName, props, children); +} diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 2dfb86228567a2..16015eed7c9107 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -4,6 +4,12 @@ max-width: 100%; vertical-align: bottom; box-sizing: border-box; + + &.iszoomed { + z-index: 200000; + position: absolute; + width: 100vw; + } } // The following style maintains border radius application for deprecated @@ -151,3 +157,17 @@ .wp-block-image figure { margin: 0; } + +.overlay { + position: fixed; + top: 0; + left: 0; + background: rgba(255, 255, 255, .95); + z-index: 100000; + overflow: hidden; + + div { + width: 100vw; + height: 100vh; + } +} diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js new file mode 100644 index 00000000000000..992005c4cbbbab --- /dev/null +++ b/packages/block-library/src/image/view.js @@ -0,0 +1,38 @@ +import './runtime/init.js'; +import { store } from './runtime'; + +store({ + state: { + core: { + isZoomed: false, + }, + }, + actions: { + core: { + imageZoom: ( { context, state } ) => { + context.core.isZoomed = !context.core.isZoomed; + state.core.isZoomed = context.core.isZoomed; + context.core.handleScroll = () => { + context.core.isZoomed = false; + state.core.isZoomed = context.core.isZoomed; + window.removeEventListener( + 'scroll', + context.core.handleScroll + ); + } + + if ( context.core.isZoomed ) { + window.addEventListener( + 'scroll', + context.core.handleScroll + ); + } else if ( context.core.handleScroll ) { + window.removeEventListener( + 'scroll', + context.core.handleScroll + ); + } + }, + }, + }, +}); From 950719911da549cb764b691dca2d3afc1b562b5c Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Mon, 3 Apr 2023 17:46:00 +0200 Subject: [PATCH 17/38] Add logic for hiding lightbox on esc key press and overlay click --- packages/block-library/src/image/index.php | 14 ++++++-- .../src/image/runtime/directives.js | 19 +++++++++- packages/block-library/src/image/style.scss | 27 ++++++++++---- packages/block-library/src/image/view.js | 36 ++++++++++++------- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 8d7182fdb34319..3edf2db37e655d 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -16,7 +16,8 @@ function render_block_core_image( $attributes, $content ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag( 'figure' ); - $processor->set_attribute( 'data-wp-context', '{ "core": { "isZoomed": false } }' ); + $processor->set_attribute( 'data-wp-class.isZoomed', 'context.core.isZoomed'); + $processor->set_attribute( 'data-wp-init', 'actions.core.closeZoomOnEsc'); $processor->next_tag( 'img' ); @@ -33,10 +34,17 @@ function render_block_core_image( $attributes, $content ) { $content = $processor->get_updated_html(); } $processor->set_attribute( 'data-wp-on.click', 'actions.core.imageZoom'); - $processor->set_attribute( 'data-wp-class.isZoomed', 'context.core.isZoomed'); + $content = $processor->get_updated_html(); - return $content . '
'; + return << + $content +
+
+
+ + HTML; } diff --git a/packages/block-library/src/image/runtime/directives.js b/packages/block-library/src/image/runtime/directives.js index 91ec1a32004b18..fb24aa20e3684e 100644 --- a/packages/block-library/src/image/runtime/directives.js +++ b/packages/block-library/src/image/runtime/directives.js @@ -72,11 +72,28 @@ export default () => { portal: { default: portal }, }, props: { children }, + context: inherited, } ) => { - return { children }; + const { Provider } = inherited; + const inheritedValue = useContext( inherited ); + return ( + + { children } + + ); } ); + // data-wp-init.[name] + directive( 'init', ( { directives: { init }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( init ).forEach( ( path ) => { + useEffect( () => { + evaluate( path, { context: contextValue } ); + }, [] ); + } ); + } ); + // data-wp-effect.[name] directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { const contextValue = useContext( context ); diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 16015eed7c9107..2f1b7fbc976a46 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -4,12 +4,6 @@ max-width: 100%; vertical-align: bottom; box-sizing: border-box; - - &.iszoomed { - z-index: 200000; - position: absolute; - width: 100vw; - } } // The following style maintains border radius application for deprecated @@ -158,6 +152,27 @@ margin: 0; } +figure.wp-block-image { + margin: 0; + + img { + transition: transform 300ms cubic-bezier(0.2, 0, 0.2, 1); + } + + &.iszoomed { + z-index: 200000; + position: absolute; + + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } +} + .overlay { position: fixed; top: 0; diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 992005c4cbbbab..2ecb4e87e70e60 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -1,25 +1,18 @@ import './runtime/init.js'; import { store } from './runtime'; -store({ - state: { - core: { - isZoomed: false, - }, - }, +store( { actions: { core: { - imageZoom: ( { context, state } ) => { - context.core.isZoomed = !context.core.isZoomed; - state.core.isZoomed = context.core.isZoomed; + imageZoom: ( { context } ) => { + context.core.isZoomed = ! context.core.isZoomed; context.core.handleScroll = () => { context.core.isZoomed = false; - state.core.isZoomed = context.core.isZoomed; window.removeEventListener( 'scroll', context.core.handleScroll ); - } + }; if ( context.core.isZoomed ) { window.addEventListener( @@ -33,6 +26,25 @@ store({ ); } }, + closeZoom: ( { context } ) => { + console.log( 'closing zoom' ); + context.core.isZoomed = false; + }, + closeZoomOnEsc: ( { context } ) => { + // Function to handle the ESC key press + function handleEscKey( event ) { + if ( event.key === 'Escape' || event.keyCode === 27 ) { + console.log( 'ESC key pressed' ); + // Add any custom logic you want to execute when the ESC key is pressed + context.core.isZoomed = false; + } + } + // Add the event listener for the 'keydown' event on the document + document.addEventListener( 'keydown', handleEscKey ); + return () => { + document.removeEventListener( 'keydown', handleEscKey ); + }; + }, }, }, -}); +} ); From 3f89d9b9dae67e672508dc36ff934613c64716d3 Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Wed, 5 Apr 2023 18:20:10 +0200 Subject: [PATCH 18/38] Improve styles and add note to add conditional for lightbox markup --- packages/block-library/src/image/index.php | 10 ++++--- packages/block-library/src/image/style.scss | 33 +++++++++++---------- packages/block-library/src/image/view.js | 1 - 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 3edf2db37e655d..d9702cdddb18d0 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -16,8 +16,10 @@ function render_block_core_image( $attributes, $content ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag( 'figure' ); + + /// Include conditional to detect if user has activated lightbox in settings #1 $processor->set_attribute( 'data-wp-class.isZoomed', 'context.core.isZoomed'); - $processor->set_attribute( 'data-wp-init', 'actions.core.closeZoomOnEsc'); + $processor->set_attribute( 'data-wp-init.closeZoomOnEsc', 'actions.core.closeZoomOnEsc'); $processor->next_tag( 'img' ); @@ -33,12 +35,12 @@ function render_block_core_image( $attributes, $content ) { $processor->set_attribute( 'data-id', $attributes['data-id'] ); $content = $processor->get_updated_html(); } - $processor->set_attribute( 'data-wp-on.click', 'actions.core.imageZoom'); + /// Include conditional to detect if user has activated lightbox in settings #2 + $processor->set_attribute( 'data-wp-on.click', 'actions.core.imageZoom'); $content = $processor->get_updated_html(); - return << + @@ -83,7 +84,7 @@ function register_block_core_image() { wp_enqueue_script( 'interactivity-image', - plugins_url('../interactive-blocks/image.min.js', __FILE__ ), + plugins_url( '../interactive-blocks/image.min.js', __FILE__ ), array( 'interactivity-runtime' ) ); diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js index 26865ba0a99711..b74c745944cd12 100644 --- a/packages/block-library/src/image/interactivity.js +++ b/packages/block-library/src/image/interactivity.js @@ -8,92 +8,55 @@ const raf = window.requestAnimationFrame; const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) ); store( { - effects: { - alert: ( { context } ) => { - // eslint-disable-next-line no-console - console.log( context.text ); - }, - }, actions: { core: { - showLightbox: ( { context, event } ) => { + showLightbox: ( { context } ) => { context.core.initialized = true; - context.core.lightboxEnabled = ! context.core.lightboxEnabled; - context.core.lastFocusedElement = - event.target.ownerDocument.activeElement; - - context.core.handleScroll = () => { - context.core.lightboxEnabled = false; - window.removeEventListener( - 'scroll', - context.core.handleScroll - ); - }; - - if ( context.core.lightboxEnabled ) { - window.addEventListener( - 'scroll', - context.core.handleScroll - ); - } else if ( context.core.handleScroll ) { - window.removeEventListener( - 'scroll', - context.core.handleScroll - ); - } + context.core.lightboxEnabled = true; + context.core.lastFocusedElement = window.document.activeElement; }, hideLightbox: ( { context, event } ) => { - context.core.lightboxEnabled = false; - if ( event.pointerType === '' ) { - context.core.lastFocusedElement.focus(); - } - }, - hideLightboxOnEsc: ( { context } ) => { - function handleEscKey( event ) { + if ( context.core.lightboxEnabled ) { + context.core.lightboxEnabled = false; + + // We only want to focus the last focused element + // if the lightbox was closed by the keyboard. + // Note: Pressing enter on a button will trigger + // a click event with a blank pointerType. if ( - context.core.lightboxEnabled && - ( event.key === 'Escape' || event.keyCode === 27 ) + ( event.key && event.type === 'keydown' ) || + ( event.type === 'click' && event.pointerType === '' ) ) { - context.core.lightboxEnabled = false; context.core.lastFocusedElement.focus(); } } - // Add the event listener for the 'keydown' event on the document - document.addEventListener( 'keydown', handleEscKey ); - return () => { - document.removeEventListener( 'keydown', handleEscKey ); - }; }, - hideLightboxOnTab: ( { context } ) => { - async function handleTab( event ) { - if ( - context.core.lightboxEnabled && - ( event.key === 'Tab' || event.keyCode === 9 ) - ) { + handleKeydown: ( { context, actions, event } ) => { + if ( context.core.lightboxEnabled ) { + const isTabKeyPressed = + event.key === 'Tab' || event.keyCode === 9; + const escapeKeyPressed = + event.key === 'Escape' || event.keyCode === 27; + + if ( isTabKeyPressed ) { event.preventDefault(); - context.core.lightboxEnabled = false; - context.core.lastFocusedElement.focus(); + } + + if ( escapeKeyPressed || isTabKeyPressed ) { + actions.core.hideLightbox( { context, event } ); } } - // Add the event listener for the 'keydown' event on the document - document.addEventListener( 'keydown', handleTab ); - return () => { - document.removeEventListener( 'keydown', handleTab ); - }; }, - toggleAriaHidden: ( { context, ref } ) => { - ref.setAttribute( - 'aria-hidden', - ! context.core.lightboxEnabled - ); - }, - focusOnClose: async ( { context, ref } ) => { + }, + }, + effects: { + core: { + initLightbox: async ( { context, ref } ) => { if ( context.core.lightboxEnabled ) { - // We need to wait until the DOM is updated and able + // We need to wait until the DOM is able // to receive focus updates for accessibility await tick(); - await tick(); - ref.focus(); + ref.querySelector( '.close-button' ).focus(); } }, }, From c388721810901907eb1e4a2946bc3d727636f68d Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Wed, 3 May 2023 12:49:51 +0200 Subject: [PATCH 30/38] Fix formatting in SCSS file --- packages/block-library/src/image/style.scss | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 7ddfe7bae41c31..86d6252c0f0005 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -155,7 +155,7 @@ .wp-lightbox-container { button { border: none; - background: none; + background: none; cursor: zoom-in; } } @@ -206,22 +206,21 @@ .scrim { width: 100%; height: 100%; - opacity: 0; position: absolute; z-index: 2000000; - background-color:rgb(255, 255, 255); - opacity: .9; + background-color: rgb(255, 255, 255); + opacity: 0.9; } &.initialized { animation: both turn-off-visibility 300ms; img { - animation: both turn-off-visibility 250ms; + animation: both turn-off-visibility 250ms; } &.active { - animation: both turn-on-visibility 250ms; + animation: both turn-on-visibility 250ms; img { animation: both turn-on-visibility 300ms; From 9754edbf325461cab95058e3e20e55e09abf41b3 Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Wed, 3 May 2023 13:00:48 +0200 Subject: [PATCH 31/38] Change CheckboxControl to a ToggleControl; update API docs --- docs/reference-guides/core-blocks.md | 4 ++-- packages/block-library/src/image/image.js | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 6c6879d36107ab..febbc4dd88ca8a 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -338,8 +338,8 @@ Insert an image to make a visual statement. ([Source](https://github.com/WordPre - **Name:** core/image - **Category:** media -- **Supports:** anchor, color (~~background~~, ~~text~~), filter (duotone) -- **Attributes:** align, alt, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width +- **Supports:** anchor, color (~~background~~, ~~text~~), filter (duotone), interactivity +- **Attributes:** align, alt, caption, enableLightbox, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width ## Latest Comments diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index 8872b8e841f99e..a1d981da93bbfa 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -15,7 +15,7 @@ import { TextareaControl, TextControl, ToolbarButton, - CheckboxControl, + ToggleControl, __experimentalHeading as Heading, } from '@wordpress/components'; import { useViewportMatch, usePrevious } from '@wordpress/compose'; @@ -470,13 +470,12 @@ export default function Image( { > Behaviors - { + onChange={ () => { setAttributes( { - enableLightbox: value, + enableLightbox: ! enableLightbox, } ); } } /> From b3372727033918039b975b86115a01e25e43c9ec Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Wed, 3 May 2023 13:29:58 +0200 Subject: [PATCH 32/38] Update wp_enqueue_script to correctly add interactivity runtime --- packages/block-library/src/image/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index fcdd36133db173..688a5ce27ebc03 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -83,9 +83,9 @@ function render_block_core_image( $attributes, $content ) { function register_block_core_image() { wp_enqueue_script( - 'interactivity-image', + 'wp-interactivity-image', plugins_url( '../interactive-blocks/image.min.js', __FILE__ ), - array( 'interactivity-runtime' ) + array( 'wp-interactivity-runtime' ) ); register_block_type_from_metadata( From ee679e688bf95a0d19d3dc9e3932ca60a531a64e Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Thu, 4 May 2023 10:48:00 +0200 Subject: [PATCH 33/38] Fix linter errors --- packages/block-library/src/image/index.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 688a5ce27ebc03..1dc7f6429c2364 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -31,16 +31,16 @@ function render_block_core_image( $attributes, $content ) { } $link_destination = isset( $attributes['linkDestination'] ) ? $attributes['linkDestination'] : 'none'; - $enable_lightbox = isset( $attributes['enableLightbox'] ) ? $attributes['enableLightbox'] : false; + $enable_lightbox = isset( $attributes['enableLightbox'] ) ? $attributes['enableLightbox'] : false; - if ( $link_destination === 'none' && $enable_lightbox ) { + if ( 'none' === $link_destination && $enable_lightbox ) { $aria_label = 'Open image lightbox'; - if($processor->get_attribute( 'alt' )) { + if ( $processor->get_attribute( 'alt' ) ) { $aria_label .= ' - ' . $processor->get_attribute( 'alt' ); } - $background_color = wp_get_global_styles(['color', 'background']); + $background_color = wp_get_global_styles( array( 'color', 'background' ) ); $close_button_icon = ''; $content = $processor->get_updated_html(); From ddbf32cd699e46d5d664d1658981d7e4b5c4d97d Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Thu, 4 May 2023 12:55:14 +0200 Subject: [PATCH 34/38] Update to use core.image namespace --- packages/block-library/src/image/index.php | 18 ++-- .../block-library/src/image/interactivity.js | 82 ++++++++++--------- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 1dc7f6429c2364..b02986c93e03c8 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -49,22 +49,22 @@ function render_block_core_image( $attributes, $content ) { << -