Skip to content
Snippets Groups Projects
Commit fa2ece58 authored by Ben Mullins's avatar Ben Mullins
Browse files

added a bespoke JSX theme engine to render node forms

parent bfd9fbd5
No related branches found
No related tags found
No related merge requests found
Showing
with 434 additions and 115 deletions
## Iframe related proof of concepts
## React form rendering proof of concept (added in this branch: `eb-form-sandbox`
)
This is accomplished by borrowing and customizing select parts of the [JSX Theme Engine Project](https://www.drupal.org/project/jsx)
Enable the `eb_form_sandbox` module and go to a node form. React will be rendering the form (but in this demo isn't really using any React features beyond that). Use the [React developer tools](https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?pli=1) browser extension to see the components that were rendered.
JSX templates are used instead of twig when the following conditions are met:
-It is a node edit or add form
-A `<templatename>.template.info` file is found in the module's templates directory
It is fine if a JSX template has a Twig child template. It all becomes part of the markup within a `<template>` which React then uses to render the page.
This is managed in jsx.engine. The logic for finding twig templates when a JSX version
is not available can probably be less messy.
## Iframe related proof of concepts (added in the `eb-ui-sandbox` branch)
Enable the same_page_preview module (which is a custom fork) and
go to a node form.
......
......@@ -222,131 +222,140 @@
// @ben added everything underneath here
// Add an outline class and scrll to an element with the name attribute
// matching `locator`
const outlineAndScroll = (locator) => {
const formField = document.querySelector(`[name="${locator}"]`);
if (formField) {
formField.classList.add('outline-in-form')
const y = formField.getBoundingClientRect().top + window.scrollY - 160;
window.scrollTo({top: y, behavior: 'smooth'});
const initIframeInteraction = () => {
// Add an outline class and scrll to an element with the name attribute
// matching `locator`
const outlineAndScroll = (locator) => {
const formField = document.querySelector(`[name="${locator}"]`);
if (formField) {
formField.classList.add('outline-in-form')
const y = formField.getBoundingClientRect().top + window.scrollY - 160;
window.scrollTo({top: y, behavior: 'smooth'});
}
}
}
// Events sent from the iframe are handled here/
const managePreviewEvents = (e) => {
const previewWrapper = document.querySelector('#preview-iframe-wrapper');
const {type, original, additional} = e.detail;
// When an item is hovered in the iframe, create an element in the primary
// DOM that is positioned over the iframe to outlines the hovered item.
if (type === 'itemHoverEnter') {
const locator = original.target.getAttribute('data-spp-field-locator');
if (!document.querySelector(`[data-hover-outline="${locator}"]`)) {
setTimeout(() => {
// Remove any existing hover outlines.
document.querySelectorAll('[data-hover-outline], .outline-in-form').forEach((outline) => {
outline.remove();
})
// Events sent from the iframe are handled here/
const managePreviewEvents = (e) => {
const previewWrapper = document.querySelector('#preview-iframe-wrapper');
const {type, original, additional} = e.detail;
// When an item is hovered in the iframe, create an element in the primary
// DOM that is positioned over the iframe to outlines the hovered item.
if (type === 'itemHoverEnter') {
const locator = original.target.getAttribute('data-spp-field-locator');
if (!document.querySelector(`[data-hover-outline="${locator}"]`)) {
setTimeout(() => {
// Remove any existing hover outlines.
document.querySelectorAll('[data-hover-outline], .outline-in-form').forEach((outline) => {
outline.remove();
})
const div = document.createElement('div');
div.setAttribute('data-hover-outline', locator);
// Get position info from the iframe element to replicate the
// positioning of the overlaid div.
const {x, y} = original.target.getBoundingClientRect();
const {offsetHeight, offsetWidth} = original.target;
div.style.left = `${x}px`
div.style.top = `${y}px`
div.style.width = `${offsetWidth}px`;
div.style.height = `${offsetHeight}px`;
div.style.position = `absolute`;
div.style.outline = '2px solid #ff69ba';
previewWrapper.append(div);
// When the mouse is no longer over an outline. remove it from the DOM.
div.addEventListener('mouseleave', (e) => {
const input = document.querySelector(`[name="${locator}"]`)
setTimeout(() => {;
if(input) {
input.classList.remove('outline-in-form')
}
div.remove();
}, 10)
})
// If the iframe scrolls. remove the outline to avoid it being in a position that
// no longer corresponds to the element it outlines.
previewWrapper.querySelector('iframe').contentDocument.addEventListener('scroll', () => {
document.querySelector(`[name="${locator}"]`)?.classList.remove('outline-in-form')
div.remove();
})
outlineAndScroll(locator);
}, 30)
}
}
// Given a NodeList of dropzones in the iframe, create DOM dropzones that are
// positioned directly above them.
if (type === 'bindZones') {
// Remove existing dropzones to avoid duplicates.
document.querySelectorAll('[data-zone-id]').forEach((dropZone) => {
dropZone.remove();
})
// Place a DOM drop zone directly above the iframe drop zone.
additional.zones.forEach((zone) => {
const div = document.createElement('div');
div.setAttribute('data-hover-outline', locator);
div.setAttribute('data-dom-drop', zone.getAttribute('data-zone-id'));
const {x, y} = zone.getBoundingClientRect();
const {offsetHeight, offsetWidth} = zone;
// Get position info from the iframe element to replicate the
// positioning of the overlaid div.
const {x, y} = original.target.getBoundingClientRect();
const {offsetHeight, offsetWidth} = original.target;
div.style.left = `${x}px`
div.style.top = `${y}px`
div.style.width = `${offsetWidth}px`;
div.style.height = `${offsetHeight}px`;
div.style.position = `absolute`;
div.style.outline = '2px solid #ff69ba';
div.style.opacity = '0.4';
// When the dropzone has a valid drop element over it, add the 'can-drop'
// class.
div.addEventListener('dragover', (e) => {
e.preventDefault();
e.target.classList.add('can-drop');
});
// On drop, parse the dataTransfer data and send it to the iframe. This also
// includes the drop zone id, which is stored in the 'data-dom-drop' attribute.
div.addEventListener('drop', (e) => {
const data = JSON.parse(unescape(e.dataTransfer.getData("content")));
const detail = {
original: e,
type: 'zoneUpdate',
additional: {zoneInfo: {zoneId: e.target.getAttribute('data-dom-drop'), ...data}}
}
const event = new CustomEvent('parentMessage', { detail })
document.querySelector('#preview-iframe-wrapper iframe').contentDocument.dispatchEvent(event)
e.target.classList.remove('can-drop');
});
previewWrapper.append(div);
// When the mouse is no longer over an outline. remove it from the DOM.
div.addEventListener('mouseleave', (e) => {
document.querySelector(`[name="${locator}"]`)?.classList.remove('outline-in-form')
div.remove();
})
// If the iframe scrolls. remove the outline to avoid it being in a position that
// no longer corresponds to the element it outlines.
previewWrapper.querySelector('iframe').contentDocument.addEventListener('scroll', () => {
document.querySelector(`[name="${locator}"]`)?.classList.remove('outline-in-form')
div.remove();
})
outlineAndScroll(locator);
}, 30)
})
}
}
// Given a NodeList of dropzones in the iframe, create DOM dropzones that are
// positioned directly above them.
if (type === 'bindZones') {
// Remove existing dropzones to avoid duplicates.
document.querySelectorAll('[data-zone-id]').forEach((dropZone) => {
dropZone.remove();
})
// Place a DOM drop zone directly above the iframe drop zone.
additional.zones.forEach((zone) => {
const div = document.createElement('div');
div.setAttribute('data-dom-drop', zone.getAttribute('data-zone-id'));
const {x, y} = zone.getBoundingClientRect();
const {offsetHeight, offsetWidth} = zone;
div.style.left = `${x}px`
div.style.top = `${y}px`
div.style.width = `${offsetWidth}px`;
div.style.height = `${offsetHeight}px`;
div.style.position = `absolute`;
div.style.opacity = '0.4';
// When the dropzone has a valid drop element over it, add the 'can-drop'
// class.
div.addEventListener('dragover', (e) => {
e.preventDefault();
e.target.classList.add('can-drop');
});
// On drop, parse the dataTransfer data and send it to the iframe. This also
// includes the drop zone id, which is stored in the 'data-dom-drop' attribute.
div.addEventListener('drop', (e) => {
const data = JSON.parse(unescape(e.dataTransfer.getData("content")));
const detail = {
original: e,
type: 'zoneUpdate',
additional: {zoneInfo: {zoneId: e.target.getAttribute('data-dom-drop'), ...data}}
}
const event = new CustomEvent('parentMessage', { detail })
document.querySelector('#preview-iframe-wrapper iframe').contentDocument.dispatchEvent(event)
e.target.classList.remove('can-drop');
});
previewWrapper.append(div);
})
}
}
// When the preview iframe communciates with the DOM, it will do so via a
// 'previewAction' event. managePreviewEvents will triage the event into the
// appropriate callback.
document.addEventListener('previewAction', managePreviewEvents, false)
// Create a list of draggable items to demonstrate drag/drop.
// data-drag-content is an object of data that will be sent to the drop
// callback.
const ul = document.createElement('ul');
ul.classList.add('drag-items');
ul.innerHTML = `
// When the preview iframe communciates with the DOM, it will do so via a
// 'previewAction' event. managePreviewEvents will triage the event into the
// appropriate callback.
document.addEventListener('previewAction', managePreviewEvents, false)
// Create a list of draggable items to demonstrate drag/drop.
// data-drag-content is an object of data that will be sent to the drop
// callback.
const ul = document.createElement('ul');
ul.classList.add('drag-items');
ul.innerHTML = `
<li draggable="true" data-drag-content="%7B%22tag%22%3A%22h2%22%2C%22content%22%3A%22This%20is%20content%20that%20we%20shall%20present%20in%20an%20h2%20tag%21%22%7D">Drag H2</li>
<li draggable="true" data-drag-content="%7B%22tag%22%3A%22h3%22%2C%22content%22%3A%22Howdy%20I%20am%20an%20h3%20how%20do%20you%20do%3F%22%7D">Drag H3</li>
<li draggable="true" data-drag-content="%7B%22tag%22%3A%22h4%22%2C%22content%22%3A%22And%20dont%20overlook%20the%20h4%2C%20I%20have%20much%20to%20offer%21%22%7D">Drag H4</li>`;
ul.querySelectorAll('li').forEach((li) => {
li.addEventListener('dragstart', (e) => {
// data-drag-content should be transferred to the drop event.
e.dataTransfer.setData('content', e.target.getAttribute('data-drag-content'))
})
});
document.querySelector('main').append(ul);
ul.querySelectorAll('li').forEach((li) => {
li.addEventListener('dragstart', (e) => {
// data-drag-content should be transferred to the drop event.
e.dataTransfer.setData('content', e.target.getAttribute('data-drag-content'))
})
});
document.querySelector('main').append(ul);
}
window.addEventListener('first-render', initIframeInteraction);
})(jQuery, Drupal, once);
name: Same Page Preview (forked by Ben)
name: Same Page Preview
type: module
description: Extends Drupal to provide side-by-side preview for node editing. Manage settings per content type by clicking "Edit" on the content type page.
package: Preview
......
......@@ -248,10 +248,8 @@ function same_page_preview_preprocess_field(&$variables) {
}
if( count($variables['items']) === 1) {
$variables['attributes']['data-spp-field-locator'] = $locator;
$variables['attributes']['class'][] = 'hover-box';
} else {
$variables['items'][$key]['attributes']['data-spp-field-locator'] = $locator;
$variables['items'][$key]['attributes']['class'][] = 'hover-box';
}
}
}
......
......@@ -56,7 +56,7 @@ class PreviewNodeForm extends NodeForm {
$element['preview']['#weight'] = 100;
$element['preview']['#value'] = $this->t('Refresh Preview');
$element['preview']['#attributes']['style'] = 'display: none;';
// $element['preview']['#attributes']['style'] = 'display: none;';
}
$count = 0;
......
js/app/dist.* -diff
**/node_modules/
**package-lock.json
.DS_Store
name: Experience Builder Form Sandbox
type: module
description: Trying out form related thigns
core_version_requirement: ^10 | ^11
jsxapp:
js:
js/app/dist.js:
# Weight must be less than 0, because other JS code with 0 or higher
# weight can execute query selectors on the document, so the app must be
# rendered before then.
weight: -1
# If the app is using (client-side) React, then it cannot be aggregated
# with other JS code, because React defers DOM operations to a fiber, so
# later JS code within the same aggregate would run before those
# operations are performed, and their document query selectors would
# fail. Setting preprocess to FALSE is not required if the app is
# rendered with Preact or Solid, or with React SSR/RSC.
preprocess: false
<?php
use Drupal\Core\Form\FormStateInterface;
function eb_form_sandbox_system_info_alter(array &$info, \Drupal\Core\Extension\Extension $file, $type) {
if ($type === 'theme') {
if (isset($info['engine']) && $info['engine'] === 'twig') {
$admin_theme = \Drupal::config('system.theme')->get('admin');
// For now just jsx-ify the admin theme.
if (strtolower($info['name']) === $admin_theme) {
$info['engine'] = 'jsx';
}
}
}
}
function eb_form_sandbox_form_node_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
// Only add the React library to node forms.
$form['#attached']['library'][] = 'eb_form_sandbox/jsxapp';
}
.a6gf70q{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;width:100%;padding:1rem;border:1px solid #fcece7;background:#fff;color:red}.h7b0n4y{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1}.h7b0n4y .field--name-title{-webkit-text-decoration:none;text-decoration:none;color:#000;font-size:1.424rem;font-weight:400;padding-top:.5rem}.pj6cg06{content:"";position:absolute;top:25%;right:0;width:14px;height:14px;background-repeat:no-repeat;background-position:0 0;background-size:contain;display:block}.l19u2iiw{position:relative;display:inline-block;box-sizing:border-box;padding-right:20px;-webkit-text-decoration:none;text-decoration:none;text-transform:uppercase;border-bottom:1px solid transparent;background:inherit}.l19u2iiw:hover{-webkit-text-decoration:none;text-decoration:none;color:#008068;border-bottom:1px solid #008068;background:transparent}.l19u2iiw:focus{-webkit-text-decoration:none;text-decoration:none;color:#008068;border-bottom:1px solid #008068;background:transparent}.c1rd6vs0{margin-bottom:1em}.c126658c{-webkit-order:-1;-ms-flex-order:-1;order:-1}.s14yc4v5 .disclaimer__disclaimer,.s14yc4v5 .disclaimer__copyright{display:block;text-align:center;font-size:.94rem}@media screen and (min-width:75rem){.s14yc4v5{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between}.s14yc4v5 .disclaimer__disclaimer,.s14yc4v5 .disclaimer__copyright{margin-bottom:0;text-align:start}.s14yc4v5 .disclaimer__disclaimer{max-width:40%;margin-left:.5rem}.s14yc4v5 [dir=rtl] .disclaimer__disclaimer{margin-right:.5rem;margin-left:0}.s14yc4v5 .disclaimer__copyright{width:25%}}.b18fq79k .block-views-blockrecipe-collections-block{padding:3rem 4%;color:#fff;background:#767775}.b18fq79k .block-views-blockrecipe-collections-block .block__title{margin-bottom:1.5rem;text-align:center}.b18fq79k .block-views-blockrecipe-collections-block .views-view-grid{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center}.b18fq79k .block-views-blockrecipe-collections-block .views-col{width:100%;padding:0 14px;text-align:center}@media screen and (min-width:48em){.b18fq79k .block-views-blockrecipe-collections-block .views-col{width:25%;max-width:13rem;text-align:left}[dir=rtl] .b18fq79k .block-views-blockrecipe-collections-block .views-col{text-align:right}}.b18fq79k .block-views-blockrecipe-collections-block .views-row{margin-bottom:.5rem;font-size:.9rem}.b18fq79k .block-views-blockrecipe-collections-block .views-row a{-webkit-text-decoration:none;text-decoration:none;color:#fff;font-weight:700}.b18fq79k .block-views-blockrecipe-collections-block .views-row a:active,.b18fq79k .block-views-blockrecipe-collections-block .views-row a:focus,.b18fq79k .block-views-blockrecipe-collections-block .views-row a:hover{-webkit-text-decoration:underline;text-decoration:underline;outline-color:#fff;background:transparent}.cdraeav{max-width:100%}
This diff is collapsed.
module.exports = {
evaluate: true,
displayName: false,
};
MIT License
Copyright (c) 2023 effulgentsia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# hyperscriptify
\ No newline at end of file
<html>
<body>
<!--
The contents of <template> elements is parsed by the browser, but not rendered
by the browser. Its purpose is solely for JavaScript to do something with. In
this case, when the JavaScript in dist.js (compiled from src/index.js) calls:
```
App = hyperscriptify(document.getElementById('example').content, ...)
```
it gives the equivalent result as what it would get if it called:
```
App =
<>
<Box sx={{ height: 320, transform: 'translateZ(0px)', flexGrow: 1 }}>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 16, right: 16 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction key="Copy" tooltipTitle="Copy" icon={<FileCopyIcon />} />
<SpeedDialAction key="Save" tooltipTitle="Save" icon={<SaveIcon />} />
<SpeedDialAction key="Print" tooltipTitle="Print" icon={<PrintIcon />} />
<SpeedDialAction key="Share" tooltipTitle="Share" icon={<ShareIcon />} />
</SpeedDial>
</Box>
</>
```
This example is adapted from https://mui.com/material-ui/react-speed-dial/.
Although the HTML representation is less compact than the equivalent JSX
representation, its benefit is that it can be output by a web application
back-end that's not written in JavaScript.
-->
<template id="example">
<mui-box sx='{ "height": 320, "transform": "translateZ(0px)", "flexGrow": 1 }'>
<mui-speed-dial aria-label="SpeedDial basic example" sx='{ "position": "absolute", "bottom": 16, "right": 16 }'>
<mui-speed-dial-icon slot="icon"></mui-speed-dial-icon>
<mui-speed-dial-action key="Copy" tooltip-title="Copy">
<mui-file-copy-icon slot="icon"></mui-file-copy-icon>
</mui-speed-dial-action>
<mui-speed-dial-action key="Save" tooltip-title="Save">
<mui-save-icon slot="icon"></mui-save-icon>
</mui-speed-dial-action>
<mui-speed-dial-action key="Print" tooltip-title="Print">
<mui-print-icon slot="icon"></mui-print-icon>
</mui-speed-dial-action>
<mui-speed-dial-action key="Share" tooltip-title="Share">
<mui-share-icon slot="icon"></mui-share-icon>
</mui-speed-dial-action>
</mui-speed-dial>
</mui-box>
</template>
<!-- The container that dist.js renders, with React, the above "App" into. -->
<div id="root"></div>
<script src="dist.js"></script>
</body>
</html>
{
"scripts": {
"build": "./node_modules/.bin/esbuild ./src --bundle --outfile=dist.js --loader:.js=jsx --jsx=automatic --minify"
},
"dependencies": {
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.8",
"@mui/material": "^5.14.8",
"esbuild": "^0.19.2",
"hyperscriptify": "github:effulgentsia/hyperscriptify",
"hyperscriptify-propsify-standard": "file:node_modules/hyperscriptify/propsify/standard",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
import Box from '@mui/material/Box';
import SpeedDial from '@mui/material/SpeedDial';
import SpeedDialIcon from '@mui/material/SpeedDialIcon';
import SpeedDialAction from '@mui/material/SpeedDialAction';
import FileCopyIcon from '@mui/icons-material/FileCopyOutlined';
import SaveIcon from '@mui/icons-material/Save';
import PrintIcon from '@mui/icons-material/Print';
import ShareIcon from '@mui/icons-material/Share';
export default {
'mui-box': Box,
'mui-speed-dial': SpeedDial,
'mui-speed-dial-icon': SpeedDialIcon,
'mui-speed-dial-action': SpeedDialAction,
'mui-file-copy-icon': FileCopyIcon,
'mui-save-icon': SaveIcon,
'mui-print-icon': PrintIcon,
'mui-share-icon': ShareIcon
};
import hyperscriptify from 'hyperscriptify';
import propsify from 'hyperscriptify-propsify-standard';
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import components from './components';
const App = hyperscriptify(document.getElementById('example').content, React.createElement, React.Fragment, components, { propsify });
const root = createRoot(document.getElementById('root'));
root.render(App);
<html>
<body>
<!--
See ../../_introduction/index.html for more detailed docs. This is the same
HTML contents, but using Preact instead of React for dist.js.
-->
<template id="example">
<mui-box sx='{ "height": 320, "transform": "translateZ(0px)", "flexGrow": 1 }'>
<mui-speed-dial aria-label="SpeedDial basic example" sx='{ "position": "absolute", "bottom": 16, "right": 16 }'>
<mui-speed-dial-icon slot="icon"></mui-speed-dial-icon>
<mui-speed-dial-action key="Copy" tooltip-title="Copy">
<mui-file-copy-icon slot="icon"></mui-file-copy-icon>
</mui-speed-dial-action>
<mui-speed-dial-action key="Save" tooltip-title="Save">
<mui-save-icon slot="icon"></mui-save-icon>
</mui-speed-dial-action>
<mui-speed-dial-action key="Print" tooltip-title="Print">
<mui-print-icon slot="icon"></mui-print-icon>
</mui-speed-dial-action>
<mui-speed-dial-action key="Share" tooltip-title="Share">
<mui-share-icon slot="icon"></mui-share-icon>
</mui-speed-dial-action>
</mui-speed-dial>
</mui-box>
</template>
<div id="root"></div>
<script src="dist.js"></script>
</body>
</html>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment