Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ COPY package.json /app/package.json
#Packages
COPY packages/commons/package.json /app/packages/commons/package.json
COPY plugins/wp-react-blocks-plugin/blocks/package.json /app/plugins/wp-react-blocks-plugin/blocks/package.json
COPY plugins/tailwind-gutenberg/package.json /app/plugins/tailwind-gutenberg/package.json

RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --shamefully-hoist
Expand All @@ -30,7 +31,7 @@ COPY . /app

# Build the plugins
RUN BLOCKS_CATEGORY=wp-react-lib-blocks BLOCKS_NS=viz \
pnpm -r --filter="@devgateway/dvz-wp-commons" --filter="dg-react-blocks" build
pnpm -r --filter="@devgateway/dvz-wp-commons" --filter="dg-react-blocks" --filter="tailwind-gutenberg" build

# Organize WordPress files to the container
COPY wp-theme wp-content/themes/dg-semantic
Expand Down
1 change: 1 addition & 0 deletions plugins/tailwind-gutenberg/assets/class-index.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions plugins/tailwind-gutenberg/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "devgateway/tailwind-gutenberg",
"description": "Adds Tailwind CSS utility classes to any Gutenberg block, with autocomplete and live preview.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.0"
},
"autoload": {
"psr-4": {
"Twg\\": "src/"
}
}
}
20 changes: 20 additions & 0 deletions plugins/tailwind-gutenberg/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions plugins/tailwind-gutenberg/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
164 changes: 164 additions & 0 deletions plugins/tailwind-gutenberg/js/canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import apiFetch from '@wordpress/api-fetch';
import { subscribe, select } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';

const IFRAME_SELECTOR = 'iframe[name="editor-canvas"]';
const MARKER_ATTRIBUTE = 'data-twg-canvas';
const COMPILED_ATTRIBUTE = 'data-twg-compiled';

// Preflight is opt-in (off by default) since it resets margins/borders and
// would visibly wreck the editor chrome outside the canvas for most themes.
function buildCanvasCss() {
const imports = window.twgCanvasData?.preflight
? '@import "tailwindcss";'
: '@import "tailwindcss/theme" layer(theme);\n@import "tailwindcss/utilities" layer(utilities);';

return `${ imports }\n${ window.twgCanvasData?.themeCss || '' }`;
}

// @tailwindcss/browser only compiles classes it finds on elements actually
// in the DOM, so safelisted classes need a real (if hidden) element to
// live on rather than being handed to the compiler directly.
function injectSafelist( doc ) {
const safelist = window.twgCanvasData?.safelist || [];

if ( ! safelist.length ) {
return;
}

const marker = doc.createElement( 'div' );
marker.setAttribute( 'aria-hidden', 'true' );
marker.setAttribute( 'data-twg-safelist', '1' );
marker.style.display = 'none';
marker.className = safelist.join( ' ' );
doc.body.appendChild( marker );
}

// @tailwindcss/browser writes its compiled output into a <style> tag it
// creates itself, with no id or attribute to identify it by. It appends
// that tag to <head> synchronously as soon as it runs, so watching for the
// next plain <style> element added to <head> right after injection is the
// only way to get a handle on it.
function markCompiledStyleTag( doc ) {
const observer = new window.MutationObserver( ( mutations ) => {
for ( const mutation of mutations ) {
for ( const node of mutation.addedNodes ) {
if (
node.tagName === 'STYLE' &&
! node.hasAttribute( 'type' )
) {
node.setAttribute( COMPILED_ATTRIBUTE, '1' );
observer.disconnect();
return;
}
}
}
} );

observer.observe( doc.head, { childList: true } );
}

function injectIntoIframe( iframe ) {
const doc = iframe.contentDocument;

if (
! doc ||
! doc.head ||
! doc.body ||
doc.head.querySelector( `script[${ MARKER_ATTRIBUTE }]` )
) {
// A missing head/body means the iframe document isn't ready yet
// (e.g. mid-navigation right after a device-preview switch); since
// nothing gets marked below, the observers retry on the next
// mutation/load instead of half-completing the injection.
return;
}

const themeStyle = doc.createElement( 'style' );
themeStyle.setAttribute( 'type', 'text/tailwindcss' );
themeStyle.textContent = buildCanvasCss();
doc.head.appendChild( themeStyle );

injectSafelist( doc );
markCompiledStyleTag( doc );

const script = doc.createElement( 'script' );
script.setAttribute( MARKER_ATTRIBUTE, '1' );
script.src = window.twgCanvasData.tailwindBrowserUrl;
doc.head.appendChild( script );
}

function tryInject() {
const iframe = document.querySelector( IFRAME_SELECTOR );

if ( ! iframe ) {
return;
}

try {
injectIntoIframe( iframe );
} catch ( error ) {
// The iframe's document can be mid-navigation (e.g. right after a
// device-preview switch recreates it); the observers below retry on
// the next mutation/load, so failures here are silently dropped.
}
}

function getCompiledCss() {
const iframe = document.querySelector( IFRAME_SELECTOR );
const style = iframe?.contentDocument?.head?.querySelector(
`style[${ COMPILED_ATTRIBUTE }]`
);

return style?.textContent || '';
}

function uploadCompiledCss() {
const css = getCompiledCss();

if ( ! css ) {
return;
}

apiFetch( {
path: '/twg/v1/css',
method: 'POST',
data: { css },
} ).catch( () => {
// Best-effort: the editor preview already reflects the current
// classes regardless of whether the frontend copy uploaded.
} );
}

// Device-preview switches and other editor UI destroy and recreate the
// iframe, so a one-shot check at boot isn't enough.
new window.MutationObserver( tryInject ).observe( document.body, {
childList: true,
subtree: true,
} );

// iframe 'load' events don't bubble, so this has to be a capturing listener.
document.addEventListener(
'load',
( event ) => {
if ( event.target.matches?.( IFRAME_SELECTOR ) ) {
tryInject();
}
},
true
);

tryInject();

let wasSaving = false;

subscribe( () => {
const isSaving = select( editorStore ).isSavingPost();
const isAutosaving = select( editorStore ).isAutosavingPost();

if ( wasSaving && ! isSaving && ! isAutosaving ) {
uploadCompiledCss();
}

wasSaving = isSaving;
} );
139 changes: 139 additions & 0 deletions plugins/tailwind-gutenberg/js/components/ClassTokenField.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
FormTokenField,
ToggleControl,
TextareaControl,
} from '@wordpress/components';
import { useCallback, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

// One shared worker for every block's token field, created lazily on first
// use so the ~3,000-entry index is never fetched until someone actually
// needs autocomplete.
let worker = null;
let requestCounter = 0;
const pendingRequests = new Map();

function getWorker() {
if ( worker || ! window.twgEditorData ) {
return worker;
}

worker = new window.Worker( window.twgEditorData.workerUrl );
worker.postMessage( {
type: 'init',
indexUrl: window.twgEditorData.classIndexUrl,
maxResults: window.twgEditorData.suggestLimit,
} );

worker.addEventListener( 'message', ( event ) => {
const { requestId, results } = event.data;
const resolve = pendingRequests.get( requestId );

if ( resolve ) {
pendingRequests.delete( requestId );
resolve( results );
}
} );

return worker;
}

function queryWorker( query ) {
const instance = getWorker();

if ( ! instance ) {
return Promise.resolve( [] );
}

const requestId = ++requestCounter;

return new Promise( ( resolve ) => {
pendingRequests.set( requestId, resolve );
instance.postMessage( { type: 'query', query, requestId } );
} );
}

export default function ClassTokenField( { value, onChange } ) {
const [ suggestions, setSuggestions ] = useState( [] );
const [ declaration, setDeclaration ] = useState( '' );
const [ isRaw, setIsRaw ] = useState( false );
const debounceRef = useRef();

const tokens = value ? value.trim().split( /\s+/ ).filter( Boolean ) : [];

const handleInputChange = useCallback( ( input ) => {
window.clearTimeout( debounceRef.current );

const query = input.trim();

if ( ! query ) {
setSuggestions( [] );
setDeclaration( '' );
return;
}

debounceRef.current = window.setTimeout( async () => {
const results = await queryWorker( query );

setSuggestions( results.map( ( r ) => r.c ) );
setDeclaration( results.find( ( r ) => r.c === query )?.d || '' );
}, 150 );
}, [] );

const handleChange = ( nextTokens ) => {
onChange( nextTokens.join( ' ' ) );
setSuggestions( [] );
setDeclaration( '' );
};

if ( isRaw ) {
return (
<>
<TextareaControl
__nextHasNoMarginBottom
label={ __( 'Classes', 'tailwind-gutenberg' ) }
value={ value || '' }
onChange={ onChange }
help={ __(
'Space-separated Tailwind classes.',
'tailwind-gutenberg'
) }
/>
<span style={{ marginBottom: '8px' }}></span>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Paste raw', 'tailwind-gutenberg' ) }
checked={ isRaw }
onChange={ setIsRaw }
/>
</>
);
}

return (
<>
<FormTokenField
__next40pxDefaultSize
__nextHasNoMarginBottom
__experimentalShowHowTo={ false }
__experimentalAutoSelectFirstMatch
label={ __( 'Classes', 'tailwind-gutenberg' ) }
value={ tokens }
suggestions={ suggestions }
onChange={ handleChange }
onInputChange={ handleInputChange }
tokenizeOnSpace
/>
{ declaration && (
<p className="twg-declaration-preview">{ declaration }</p>
) }
<span style={{ marginBottom: '8px' }}></span>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Paste raw', 'tailwind-gutenberg' ) }
checked={ isRaw }
onChange={ setIsRaw }
/>
</>
);
}
Loading