From d6010c073c081fd1a8ba3eba87a33cdf812d7e2f Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 3 Mar 2026 13:32:04 -0800 Subject: [PATCH 1/9] feat!: Allow using Blockly in web components/shadow DOM --- packages/blockly/core/common.ts | 11 ++++- packages/blockly/core/css.ts | 45 ++++++++++--------- packages/blockly/core/dropdowndiv.ts | 20 +++++++-- packages/blockly/core/field_input.ts | 11 ++++- packages/blockly/core/inject.ts | 2 +- .../core/renderers/common/constants.ts | 37 +++++---------- .../blockly/core/renderers/common/renderer.ts | 1 - .../blockly/core/renderers/zelos/constants.ts | 3 +- packages/blockly/core/tooltip.ts | 35 +++++++++++++-- packages/blockly/core/widgetdiv.ts | 19 ++++++-- 10 files changed, 120 insertions(+), 64 deletions(-) diff --git a/packages/blockly/core/common.ts b/packages/blockly/core/common.ts index 7f23779ec93..a87e1aa129a 100644 --- a/packages/blockly/core/common.ts +++ b/packages/blockly/core/common.ts @@ -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; } /** diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index ab1d494ad23..c6ed84780de 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -6,7 +6,8 @@ // Former goog.module ID: Blockly.Css /** Has CSS already been injected? */ -let injected = false; +const injectionSites = new WeakSet(); +const registeredStyleSheets: Array = []; /** * 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.replaceSync(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) { - // Only inject the CSS once. - if (injected) { - return; - } - injected = true; +export function inject( + container: HTMLElement, + hasCss: boolean, + pathToMedia: string, +) { if (!hasCss) { return; } + + const root = container.getRootNode() as Document | ShadowRoot; + // Only inject the CSS once. + 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(/<<>>/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.replaceSync(cssContent); + root.adoptedStyleSheets.push(sheet); + + registeredStyleSheets.forEach((sheet) => root.adoptedStyleSheets.push(sheet)); } /** * The CSS content for Blockly. */ -let content = ` +const content = ` :is( .injectionDiv, .blocklyWidgetDiv, diff --git a/packages/blockly/core/dropdowndiv.ts b/packages/blockly/core/dropdowndiv.ts index ceab467a895..704a767e8cb 100644 --- a/packages/blockly/core/dropdowndiv.ts +++ b/packages/blockly/core/dropdowndiv.ts @@ -370,6 +370,9 @@ export function show( 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'; diff --git a/packages/blockly/core/field_input.ts b/packages/blockly/core/field_input.ts index 55383a4c1d2..a8377ae050f 100644 --- a/packages/blockly/core/field_input.ts +++ b/packages/blockly/core/field_input.ts @@ -702,8 +702,15 @@ export abstract class FieldInput 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`; diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index 1ecefa7c484..ca62eb47f29 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -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. /* diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index c5a7a759c5c..82f76cfa140 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -327,9 +327,6 @@ export class ConstantProvider { */ private debugFilter: SVGElement | null = null; - /** The + + + + + + + +
+
+
+ Light DOM +
+
+
+
+
+ +
+
+ Shadow DOM via <blockly-editor> +
+
+ +
+
+
+ + From 230fe46afb6b1c09a0b3568d975568dd6b15d3da Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 3 Mar 2026 13:54:27 -0800 Subject: [PATCH 4/9] fix: Remove JSDoc argument --- packages/blockly/core/renderers/common/constants.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index 82f76cfa140..9bca334bfb2 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -920,7 +920,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 From 45c0009c86d27edec6bc9b4e933346e80ec625f1 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 3 Mar 2026 13:57:53 -0800 Subject: [PATCH 5/9] chore: Format playground --- packages/blockly/tests/playgrounds/web_component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/blockly/tests/playgrounds/web_component.html b/packages/blockly/tests/playgrounds/web_component.html index 475aa70dc39..0bb08d46b89 100644 --- a/packages/blockly/tests/playgrounds/web_component.html +++ b/packages/blockly/tests/playgrounds/web_component.html @@ -147,9 +147,7 @@

Blockly Web Component/Shadow DOM Playground

-
- Light DOM -
+
Light DOM
From d9f84d70d205fbdcf41bea01d1d26662acc258b0 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 4 Mar 2026 09:41:00 -0800 Subject: [PATCH 6/9] fix: Hopefully fix tests in CI --- packages/blockly/core/renderers/common/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index 9bca334bfb2..c31e61d4773 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -1124,6 +1124,8 @@ export class ConstantProvider { * @param selector The CSS selector to interpolate into the stylesheet. */ protected injectCSS_(root: Document | ShadowRoot, selector: string) { + if (typeof window === 'undefined' || !window.CSSStyleSheet) return; + const sheet = new CSSStyleSheet(); sheet.replaceSync(this.getCSS_(selector).join('\n')); root.adoptedStyleSheets.push(sheet); From 61937725686141070b99720e8fc31a7edce77e61 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 4 Mar 2026 10:44:56 -0800 Subject: [PATCH 7/9] fix: Improve test performance --- packages/blockly/core/css.ts | 6 +++--- packages/blockly/core/renderers/common/constants.ts | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index c6ed84780de..1e796b354ff 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -19,7 +19,7 @@ export function register(cssContent: string) { if (typeof window === 'undefined' || !window.CSSStyleSheet) return; const sheet = new CSSStyleSheet(); - sheet.replaceSync(cssContent); + sheet.replace(cssContent); registeredStyleSheets.push(sheet); } @@ -40,7 +40,7 @@ export function inject( hasCss: boolean, pathToMedia: string, ) { - if (!hasCss) { + if (!hasCss || typeof window === 'undefined' || !window.CSSStyleSheet) { return; } @@ -54,7 +54,7 @@ export function inject( const cssContent = content.replace(/<<>>/g, mediaPath); const sheet = new CSSStyleSheet(); - sheet.replaceSync(cssContent); + sheet.replace(cssContent); root.adoptedStyleSheets.push(sheet); registeredStyleSheets.forEach((sheet) => root.adoptedStyleSheets.push(sheet)); diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index c31e61d4773..8c6a63a4bdd 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -116,6 +116,8 @@ export function isNotch(shape: Shape): shape is Notch { ); } +const injectionSites = new WeakSet(); + /** * An object that provides constants for rendering blocks. */ @@ -1124,11 +1126,18 @@ export class ConstantProvider { * @param selector The CSS selector to interpolate into the stylesheet. */ protected injectCSS_(root: Document | ShadowRoot, selector: string) { - if (typeof window === 'undefined' || !window.CSSStyleSheet) return; + if ( + typeof window === 'undefined' || + !window.CSSStyleSheet || + injectionSites.has(root) + ) { + return; + } const sheet = new CSSStyleSheet(); - sheet.replaceSync(this.getCSS_(selector).join('\n')); + sheet.replace(this.getCSS_(selector).join('\n')); root.adoptedStyleSheets.push(sheet); + injectionSites.add(root); } /** From 6d660ae9ce27f8ed79760da87c054ae3cf6f575d Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 4 Mar 2026 10:47:37 -0800 Subject: [PATCH 8/9] fix: Fix test failure --- packages/blockly/tests/mocha/contextmenu_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/contextmenu_test.js b/packages/blockly/tests/mocha/contextmenu_test.js index 8e8c40e78a2..6f7aeeeab65 100644 --- a/packages/blockly/tests/mocha/contextmenu_test.js +++ b/packages/blockly/tests/mocha/contextmenu_test.js @@ -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()); From 2038aa9c7cfb8527f8a614b7bf99973cba12a350 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 5 Mar 2026 12:21:28 -0800 Subject: [PATCH 9/9] fix: Allow changing the theme --- packages/blockly/core/renderers/common/constants.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/renderers/common/constants.ts b/packages/blockly/core/renderers/common/constants.ts index 8c6a63a4bdd..764cef029c1 100644 --- a/packages/blockly/core/renderers/common/constants.ts +++ b/packages/blockly/core/renderers/common/constants.ts @@ -116,7 +116,7 @@ export function isNotch(shape: Shape): shape is Notch { ); } -const injectionSites = new WeakSet(); +const injectionSites = new Map>(); /** * An object that provides constants for rendering blocks. @@ -1129,7 +1129,7 @@ export class ConstantProvider { if ( typeof window === 'undefined' || !window.CSSStyleSheet || - injectionSites.has(root) + injectionSites.get(selector)?.has(root) ) { return; } @@ -1137,7 +1137,11 @@ export class ConstantProvider { const sheet = new CSSStyleSheet(); sheet.replace(this.getCSS_(selector).join('\n')); root.adoptedStyleSheets.push(sheet); - injectionSites.add(root); + + const sitesForSelector = + injectionSites.get(selector) ?? new WeakSet(); + sitesForSelector.add(root); + injectionSites.set(selector, sitesForSelector); } /**