feat!: Allow using Blockly in web components/shadow DOM (#9611)

* feat!: Allow using Blockly in web components/shadow DOM

* test: Fix tests

* chore: Add a playground to exercise web component support

* fix: Remove JSDoc argument

* chore: Format playground

* fix: Hopefully fix tests in CI

* fix: Improve test performance

* fix: Fix test failure

* fix: Allow changing the theme
This commit is contained in:
Aaron Dodson
2026-03-06 12:53:18 -08:00
committed by GitHub
parent a5a18d3894
commit 09d19d8f7b
15 changed files with 307 additions and 67 deletions
+9 -2
View File
@@ -141,8 +141,15 @@ let parentContainer: Element | null;
*
* @returns The parent container.
*/
export function getParentContainer(): Element | null {
return parentContainer;
export function getParentContainer(
workspace = getMainWorkspace(),
): Element | null {
if (parentContainer) return parentContainer;
if (workspace && workspace.rendered) {
return (workspace as WorkspaceSvg).getInjectionDiv();
}
return null;
}
/**
+27 -22
View File
@@ -6,7 +6,8 @@
// Former goog.module ID: Blockly.Css
/** Has CSS already been injected? */
let injected = false;
const injectionSites = new WeakSet<Document | ShadowRoot>();
const registeredStyleSheets: Array<CSSStyleSheet> = [];
/**
* Add some CSS to the blob that will be injected later. Allows optional
@@ -15,10 +16,11 @@ let injected = false;
* @param cssContent Multiline CSS string or an array of single lines of CSS.
*/
export function register(cssContent: string) {
if (injected) {
throw Error('CSS already injected');
}
content += '\n' + cssContent;
if (typeof window === 'undefined' || !window.CSSStyleSheet) return;
const sheet = new CSSStyleSheet();
sheet.replace(cssContent);
registeredStyleSheets.push(sheet);
}
/**
@@ -28,37 +30,40 @@ export function register(cssContent: string) {
* b) It speeds up loading by not blocking on a separate HTTP transfer.
* c) The CSS content may be made dynamic depending on init options.
*
* @param container The div or other HTML element into which Blockly was injected.
* @param hasCss If false, don't inject CSS (providing CSS becomes the
* document's responsibility).
* @param pathToMedia Path from page to the Blockly media directory.
*/
export function inject(hasCss: boolean, pathToMedia: string) {
export function inject(
container: HTMLElement,
hasCss: boolean,
pathToMedia: string,
) {
if (!hasCss || typeof window === 'undefined' || !window.CSSStyleSheet) {
return;
}
const root = container.getRootNode() as Document | ShadowRoot;
// Only inject the CSS once.
if (injected) {
return;
}
injected = true;
if (!hasCss) {
return;
}
if (injectionSites.has(root)) return;
injectionSites.add(root);
// Strip off any trailing slash (either Unix or Windows).
const mediaPath = pathToMedia.replace(/[\\/]$/, '');
const cssContent = content.replace(/<<<PATH>>>/g, mediaPath);
// Cleanup the collected css content after injecting it to the DOM.
content = '';
// Inject CSS tag at start of head.
const cssNode = document.createElement('style');
cssNode.id = 'blockly-common-style';
const cssTextNode = document.createTextNode(cssContent);
cssNode.appendChild(cssTextNode);
document.head.insertBefore(cssNode, document.head.firstChild);
const sheet = new CSSStyleSheet();
sheet.replace(cssContent);
root.adoptedStyleSheets.push(sheet);
registeredStyleSheets.forEach((sheet) => root.adoptedStyleSheets.push(sheet));
}
/**
* The CSS content for Blockly.
*/
let content = `
const content = `
:is(
.injectionDiv,
.blocklyWidgetDiv,
+16 -4
View File
@@ -370,6 +370,9 @@ export function show<T>(
manageEphemeralFocus: boolean,
opt_onHide?: () => void,
): boolean {
const parentDiv = common.getParentContainer();
parentDiv?.appendChild(div);
owner = newOwner as Field;
onHide = opt_onHide || null;
// Set direction.
@@ -738,10 +741,19 @@ function positionInternal(
arrow.style.display = 'none';
}
const initialX = Math.floor(metrics.initialX);
const initialY = Math.floor(metrics.initialY);
const finalX = Math.floor(metrics.finalX);
const finalY = Math.floor(metrics.finalY);
let initialX = Math.floor(metrics.initialX);
let initialY = Math.floor(metrics.initialY);
let finalX = Math.floor(metrics.finalX);
let finalY = Math.floor(metrics.finalY);
const parentElement = div.parentElement;
if (parentElement) {
const bounds = parentElement.getBoundingClientRect();
initialX -= bounds.left + window.scrollX;
finalX -= bounds.left + window.scrollX;
initialY -= bounds.top + window.scrollY;
finalY -= bounds.top + window.scrollY;
}
// First apply initial translation.
div.style.left = initialX + 'px';
+9 -2
View File
@@ -702,8 +702,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
// In RTL mode block fields and LTR input fields the left edge moves,
// whereas the right edge is fixed. Reposition the editor.
const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
const y = bBox.top;
let x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
let y = bBox.top;
const parentElement = div?.parentElement;
if (parentElement) {
const bounds = parentElement.getBoundingClientRect();
x -= bounds.left + window.scrollX;
y -= bounds.top + window.scrollY;
}
div!.style.left = `${x}px`;
div!.style.top = `${y}px`;
+1 -1
View File
@@ -95,7 +95,7 @@ function createDom(container: HTMLElement, options: Options): SVGElement {
container.setAttribute('dir', 'LTR');
// Load CSS.
Css.inject(options.hasCss, options.pathToMedia);
Css.inject(container, options.hasCss, options.pathToMedia);
// Build the SVG DOM.
/*
@@ -116,6 +116,8 @@ export function isNotch(shape: Shape): shape is Notch {
);
}
const injectionSites = new Map<string, WeakSet<Document | ShadowRoot>>();
/**
* An object that provides constants for rendering blocks.
*/
@@ -327,9 +329,6 @@ export class ConstantProvider {
*/
private debugFilter: SVGElement | null = null;
/** The <style> element to use for injecting renderer specific CSS. */
private cssNode: HTMLStyleElement | null = null;
/**
* Cursor colour.
*/
@@ -696,7 +695,6 @@ export class ConstantProvider {
if (this.debugFilter) {
dom.removeNode(this.debugFilter);
}
this.cssNode = null;
}
/**
@@ -924,7 +922,6 @@ export class ConstantProvider {
* Create any DOM elements that this renderer needs (filters, patterns, etc).
*
* @param svg The root of the workspace's SVG.
* @param tagName The name to use for the CSS style tag.
* @param selector The CSS selector to use.
* @param injectionDivIfIsParent The div containing the parent workspace and
* all related workspaces and block containers, if this renderer is for the
@@ -934,11 +931,15 @@ export class ConstantProvider {
*/
createDom(
svg: SVGElement,
tagName: string,
selector: string,
injectionDivIfIsParent?: HTMLElement,
) {
this.injectCSS_(tagName, selector);
if (injectionDivIfIsParent) {
const root = injectionDivIfIsParent.getRootNode() as
| Document
| ShadowRoot;
this.injectCSS_(root, selector);
}
/*
<defs>
@@ -1121,26 +1122,26 @@ export class ConstantProvider {
/**
* Inject renderer specific CSS into the page.
*
* @param tagName The name of the style tag to use.
* @param selector The CSS selector to use.
* @param root The document root to inject the CSS into.
* @param selector The CSS selector to interpolate into the stylesheet.
*/
protected injectCSS_(tagName: string, selector: string) {
const cssArray = this.getCSS_(selector);
const cssNodeId = 'blockly-renderer-style-' + tagName;
this.cssNode = document.getElementById(cssNodeId) as HTMLStyleElement;
const text = cssArray.join('\n');
if (this.cssNode) {
// Already injected, update if the theme changed.
this.cssNode.firstChild!.textContent = text;
protected injectCSS_(root: Document | ShadowRoot, selector: string) {
if (
typeof window === 'undefined' ||
!window.CSSStyleSheet ||
injectionSites.get(selector)?.has(root)
) {
return;
}
// Inject CSS tag at start of head.
const cssNode = document.createElement('style');
cssNode.id = cssNodeId;
const cssTextNode = document.createTextNode(text);
cssNode.appendChild(cssTextNode);
document.head.insertBefore(cssNode, document.head.firstChild);
this.cssNode = cssNode;
const sheet = new CSSStyleSheet();
sheet.replace(this.getCSS_(selector).join('\n'));
root.adoptedStyleSheets.push(sheet);
const sitesForSelector =
injectionSites.get(selector) ?? new WeakSet<Document | ShadowRoot>();
sitesForSelector.add(root);
injectionSites.set(selector, sitesForSelector);
}
/**
@@ -89,7 +89,6 @@ export class Renderer implements IRegistrable {
) {
this.constants_.createDom(
svg,
this.name + '-' + theme.name,
'.' + this.getClassName() + '.' + theme.getClassName(),
injectionDivIfIsParent,
);
@@ -647,11 +647,10 @@ export class ConstantProvider extends BaseConstantProvider {
override createDom(
svg: SVGElement,
tagName: string,
selector: string,
injectionDivIfIsParent?: HTMLElement,
) {
super.createDom(svg, tagName, selector, injectionDivIfIsParent);
super.createDom(svg, selector, injectionDivIfIsParent);
/*
<defs>
... filters go here ...
+31 -4
View File
@@ -9,6 +9,7 @@
import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import * as blocklyString from './utils/string.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
* A type which can define a tooltip.
@@ -287,7 +288,7 @@ function onMouseOut(_e: PointerEvent) {
*
* @param e Mouse event.
*/
function onMouseMove(e: Event) {
function onMouseMove(this: any, e: Event) {
if (!element || !(element as AnyDuringMigration).tooltip) {
// No tooltip here to show.
return;
@@ -318,7 +319,20 @@ function onMouseMove(e: Event) {
// AnyDuringMigration because: Property 'pageY' does not exist on type
// 'Event'.
lastY = (e as AnyDuringMigration).pageY;
showPid = setTimeout(show, HOVER_MS);
showPid = setTimeout(() => {
let workspace: WorkspaceSvg | undefined;
if (this instanceof Element) {
for (const ws of common.getAllWorkspaces()) {
if (!ws.rendered) continue;
if ((ws as WorkspaceSvg).getInjectionDiv()?.contains(this)) {
workspace = ws as WorkspaceSvg;
break;
}
}
}
show(workspace);
}, HOVER_MS);
}
}
@@ -416,7 +430,16 @@ function getPosition(rtl: boolean): {x: number; y: number} {
}
let anchorY = lastY + OFFSET_Y;
if (anchorY + containerDiv!.offsetHeight > windowHeight + window.scrollY) {
const parentElement = containerDiv?.parentElement;
if (parentElement) {
const parentBounds = parentElement.getBoundingClientRect();
anchorX -= parentBounds.left + window.scrollX;
anchorY -= parentBounds.top + window.scrollY;
}
const tooltipBottom = anchorY + containerDiv!.offsetHeight;
if (tooltipBottom > windowHeight + window.scrollY) {
// Falling off the bottom of the screen; shift the tooltip up.
anchorY -= containerDiv!.offsetHeight + 2 * OFFSET_Y;
}
@@ -439,7 +462,7 @@ function getPosition(rtl: boolean): {x: number; y: number} {
}
/** Create the tooltip and show it. */
function show() {
function show(workspace?: WorkspaceSvg) {
if (blocked) {
// Someone doesn't want us to show tooltips.
return;
@@ -448,6 +471,10 @@ function show() {
if (!containerDiv) {
return;
}
const parentDiv = common.getParentContainer(workspace);
parentDiv?.appendChild(containerDiv);
// Erase all existing text.
containerDiv.textContent = '';
+16 -3
View File
@@ -112,6 +112,10 @@ export function show(
dispose = newDispose;
const div = containerDiv;
if (!div) return;
const parentDiv = common.getParentContainer();
parentDiv?.appendChild(div);
div.style.direction = rtl ? 'rtl' : 'ltr';
div.style.display = 'block';
if (!workspace && newOwner instanceof Field) {
@@ -225,9 +229,18 @@ export function hideIfOwnerIsInWorkspace(workspace: WorkspaceSvg) {
* @param height The height of the widget div (pixels).
*/
function positionInternal(x: number, y: number, height: number) {
containerDiv!.style.left = x + 'px';
containerDiv!.style.top = y + 'px';
containerDiv!.style.height = height + 'px';
if (!containerDiv) return;
const parentElement = containerDiv.parentElement;
if (parentElement) {
const bounds = parentElement.getBoundingClientRect();
x -= bounds.left + window.scrollX;
y -= bounds.top + window.scrollY;
}
containerDiv.style.left = x + 'px';
containerDiv.style.top = y + 'px';
containerDiv.style.height = height + 'px';
}
/**
@@ -85,7 +85,7 @@ suite('Context Menu', function () {
const menu = Blockly.ContextMenu.getMenu();
assert.instanceOf(menu, Blockly.Menu, 'getMenu() should return a Menu');
assert.include(menu.getElement().innerText, 'Test option');
assert.include(menu.getElement().textContent, 'Test option');
Blockly.ContextMenu.hide();
assert.isNull(Blockly.ContextMenu.getMenu());
@@ -15,6 +15,7 @@ import {
suite('DropDownDiv', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.common.setParentContainer(document.firstElementChild);
this.workspace = Blockly.inject('blocklyDiv');
this.setUpBlockWithField = function () {
const blockJson = {
@@ -701,7 +701,9 @@ suite('Navigation', function () {
suite('In', function () {
setup(function () {
this.emptyWorkspace = Blockly.inject(document.createElement('div'), {});
const container = document.createElement('div');
document.body.appendChild(container);
this.emptyWorkspace = Blockly.inject(container, {});
});
teardown(function () {
workspaceTeardown.call(this, this.emptyWorkspace);
@@ -13,6 +13,7 @@ import {
suite('WidgetDiv', function () {
setup(function () {
sharedTestSetup.call(this);
Blockly.common.setParentContainer(document.firstElementChild);
this.workspace = Blockly.inject('blocklyDiv');
this.setUpBlockWithField = function () {
const blockJson = {
@@ -0,0 +1,166 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Blockly Web Component/Shadow DOM Playground</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: sans-serif;
background: #f5f5f5;
}
#page-header {
background: #1a73e8;
color: white;
padding: 12px 16px;
}
#page-header h1 {
margin: 0 0 4px 0;
font-size: 18px;
}
#workspaces {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 16px;
height: calc(100% - 120px);
}
.workspace-card {
background: white;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
}
.workspace-card-header {
padding: 8px 12px;
font-size: 13px;
font-weight: bold;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
gap: 8px;
}
.workspace-card-body {
flex: 1;
position: relative;
}
#blocklyDiv-normal,
blockly-editor {
position: absolute;
inset: 0;
}
</style>
<script type="module">
import {loadScript} from '../scripts/load.mjs';
import * as Blockly from '../../build/blockly.loader.mjs';
import '../../build/blocks.loader.mjs';
await loadScript('../../build/msg/en.js');
const TOOLBOX = {
kind: 'flyoutToolbox',
contents: [
{kind: 'block', type: 'controls_ifelse'},
{kind: 'block', type: 'logic_compare'},
{kind: 'block', type: 'logic_operation'},
{kind: 'block', type: 'logic_negate'},
{kind: 'block', type: 'logic_boolean'},
{kind: 'block', type: 'math_number'},
{kind: 'block', type: 'math_arithmetic'},
{kind: 'block', type: 'text'},
{kind: 'block', type: 'text_print'},
],
};
const INJECT_OPTIONS = {
media: '../../media/',
toolbox: TOOLBOX,
zoom: {
controls: true,
wheel: true,
},
grid: {
spacing: 25,
length: 3,
colour: '#ccc',
snap: true,
},
};
class BlocklyEditor extends HTMLElement {
constructor() {
super();
// Attach shadow root; Blockly will be injected into a div inside here.
this._shadow = this.attachShadow({mode: 'open'});
this._container = document.createElement('div');
this._container.style.cssText =
'position:absolute;inset:0;width:100%;height:100%;';
this._shadow.appendChild(this._container);
this._workspace = null;
}
connectedCallback() {
// Give the element a chance to size itself before injecting.
requestAnimationFrame(() => this._init());
}
_init() {
this._workspace = Blockly.inject(this._container, INJECT_OPTIONS);
}
disconnectedCallback() {
if (this._workspace) {
this._workspace.dispose();
this._workspace = null;
}
}
}
customElements.define('blockly-editor', BlocklyEditor);
Blockly.inject('blocklyDiv-normal', INJECT_OPTIONS);
</script>
</head>
<body>
<div id="page-header">
<h1>Blockly Web Component/Shadow DOM Playground</h1>
</div>
<div id="workspaces">
<div class="workspace-card">
<div class="workspace-card-header">Light DOM</div>
<div class="workspace-card-body">
<div id="blocklyDiv-normal"></div>
</div>
</div>
<div class="workspace-card">
<div class="workspace-card-header">
Shadow DOM via &lt;blockly-editor&gt;
</div>
<div class="workspace-card-body">
<blockly-editor></blockly-editor>
</div>
</div>
</div>
</body>
</html>