Skip to content

Commit 09d19d8

Browse files
authored
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
1 parent a5a18d3 commit 09d19d8

15 files changed

Lines changed: 306 additions & 66 deletions

File tree

packages/blockly/core/common.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,15 @@ let parentContainer: Element | null;
141141
*
142142
* @returns The parent container.
143143
*/
144-
export function getParentContainer(): Element | null {
145-
return parentContainer;
144+
export function getParentContainer(
145+
workspace = getMainWorkspace(),
146+
): Element | null {
147+
if (parentContainer) return parentContainer;
148+
if (workspace && workspace.rendered) {
149+
return (workspace as WorkspaceSvg).getInjectionDiv();
150+
}
151+
152+
return null;
146153
}
147154

148155
/**

packages/blockly/core/css.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
// Former goog.module ID: Blockly.Css
88
/** Has CSS already been injected? */
9-
let injected = false;
9+
const injectionSites = new WeakSet<Document | ShadowRoot>();
10+
const registeredStyleSheets: Array<CSSStyleSheet> = [];
1011

1112
/**
1213
* Add some CSS to the blob that will be injected later. Allows optional
@@ -15,10 +16,11 @@ let injected = false;
1516
* @param cssContent Multiline CSS string or an array of single lines of CSS.
1617
*/
1718
export function register(cssContent: string) {
18-
if (injected) {
19-
throw Error('CSS already injected');
20-
}
21-
content += '\n' + cssContent;
19+
if (typeof window === 'undefined' || !window.CSSStyleSheet) return;
20+
21+
const sheet = new CSSStyleSheet();
22+
sheet.replace(cssContent);
23+
registeredStyleSheets.push(sheet);
2224
}
2325

2426
/**
@@ -28,37 +30,40 @@ export function register(cssContent: string) {
2830
* b) It speeds up loading by not blocking on a separate HTTP transfer.
2931
* c) The CSS content may be made dynamic depending on init options.
3032
*
33+
* @param container The div or other HTML element into which Blockly was injected.
3134
* @param hasCss If false, don't inject CSS (providing CSS becomes the
3235
* document's responsibility).
3336
* @param pathToMedia Path from page to the Blockly media directory.
3437
*/
35-
export function inject(hasCss: boolean, pathToMedia: string) {
36-
// Only inject the CSS once.
37-
if (injected) {
38-
return;
39-
}
40-
injected = true;
41-
if (!hasCss) {
38+
export function inject(
39+
container: HTMLElement,
40+
hasCss: boolean,
41+
pathToMedia: string,
42+
) {
43+
if (!hasCss || typeof window === 'undefined' || !window.CSSStyleSheet) {
4244
return;
4345
}
46+
47+
const root = container.getRootNode() as Document | ShadowRoot;
48+
// Only inject the CSS once.
49+
if (injectionSites.has(root)) return;
50+
injectionSites.add(root);
51+
4452
// Strip off any trailing slash (either Unix or Windows).
4553
const mediaPath = pathToMedia.replace(/[\\/]$/, '');
4654
const cssContent = content.replace(/<<<PATH>>>/g, mediaPath);
47-
// Cleanup the collected css content after injecting it to the DOM.
48-
content = '';
4955

50-
// Inject CSS tag at start of head.
51-
const cssNode = document.createElement('style');
52-
cssNode.id = 'blockly-common-style';
53-
const cssTextNode = document.createTextNode(cssContent);
54-
cssNode.appendChild(cssTextNode);
55-
document.head.insertBefore(cssNode, document.head.firstChild);
56+
const sheet = new CSSStyleSheet();
57+
sheet.replace(cssContent);
58+
root.adoptedStyleSheets.push(sheet);
59+
60+
registeredStyleSheets.forEach((sheet) => root.adoptedStyleSheets.push(sheet));
5661
}
5762

5863
/**
5964
* The CSS content for Blockly.
6065
*/
61-
let content = `
66+
const content = `
6267
:is(
6368
.injectionDiv,
6469
.blocklyWidgetDiv,

packages/blockly/core/dropdowndiv.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,9 @@ export function show<T>(
370370
manageEphemeralFocus: boolean,
371371
opt_onHide?: () => void,
372372
): boolean {
373+
const parentDiv = common.getParentContainer();
374+
parentDiv?.appendChild(div);
375+
373376
owner = newOwner as Field;
374377
onHide = opt_onHide || null;
375378
// Set direction.
@@ -738,10 +741,19 @@ function positionInternal(
738741
arrow.style.display = 'none';
739742
}
740743

741-
const initialX = Math.floor(metrics.initialX);
742-
const initialY = Math.floor(metrics.initialY);
743-
const finalX = Math.floor(metrics.finalX);
744-
const finalY = Math.floor(metrics.finalY);
744+
let initialX = Math.floor(metrics.initialX);
745+
let initialY = Math.floor(metrics.initialY);
746+
let finalX = Math.floor(metrics.finalX);
747+
let finalY = Math.floor(metrics.finalY);
748+
749+
const parentElement = div.parentElement;
750+
if (parentElement) {
751+
const bounds = parentElement.getBoundingClientRect();
752+
initialX -= bounds.left + window.scrollX;
753+
finalX -= bounds.left + window.scrollX;
754+
initialY -= bounds.top + window.scrollY;
755+
finalY -= bounds.top + window.scrollY;
756+
}
745757

746758
// First apply initial translation.
747759
div.style.left = initialX + 'px';

packages/blockly/core/field_input.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -702,8 +702,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
702702

703703
// In RTL mode block fields and LTR input fields the left edge moves,
704704
// whereas the right edge is fixed. Reposition the editor.
705-
const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
706-
const y = bBox.top;
705+
let x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
706+
let y = bBox.top;
707+
708+
const parentElement = div?.parentElement;
709+
if (parentElement) {
710+
const bounds = parentElement.getBoundingClientRect();
711+
x -= bounds.left + window.scrollX;
712+
y -= bounds.top + window.scrollY;
713+
}
707714

708715
div!.style.left = `${x}px`;
709716
div!.style.top = `${y}px`;

packages/blockly/core/inject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ function createDom(container: HTMLElement, options: Options): SVGElement {
9595
container.setAttribute('dir', 'LTR');
9696

9797
// Load CSS.
98-
Css.inject(options.hasCss, options.pathToMedia);
98+
Css.inject(container, options.hasCss, options.pathToMedia);
9999

100100
// Build the SVG DOM.
101101
/*

packages/blockly/core/renderers/common/constants.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export function isNotch(shape: Shape): shape is Notch {
116116
);
117117
}
118118

119+
const injectionSites = new Map<string, WeakSet<Document | ShadowRoot>>();
120+
119121
/**
120122
* An object that provides constants for rendering blocks.
121123
*/
@@ -327,9 +329,6 @@ export class ConstantProvider {
327329
*/
328330
private debugFilter: SVGElement | null = null;
329331

330-
/** The <style> element to use for injecting renderer specific CSS. */
331-
private cssNode: HTMLStyleElement | null = null;
332-
333332
/**
334333
* Cursor colour.
335334
*/
@@ -696,7 +695,6 @@ export class ConstantProvider {
696695
if (this.debugFilter) {
697696
dom.removeNode(this.debugFilter);
698697
}
699-
this.cssNode = null;
700698
}
701699

702700
/**
@@ -924,7 +922,6 @@ export class ConstantProvider {
924922
* Create any DOM elements that this renderer needs (filters, patterns, etc).
925923
*
926924
* @param svg The root of the workspace's SVG.
927-
* @param tagName The name to use for the CSS style tag.
928925
* @param selector The CSS selector to use.
929926
* @param injectionDivIfIsParent The div containing the parent workspace and
930927
* all related workspaces and block containers, if this renderer is for the
@@ -934,11 +931,15 @@ export class ConstantProvider {
934931
*/
935932
createDom(
936933
svg: SVGElement,
937-
tagName: string,
938934
selector: string,
939935
injectionDivIfIsParent?: HTMLElement,
940936
) {
941-
this.injectCSS_(tagName, selector);
937+
if (injectionDivIfIsParent) {
938+
const root = injectionDivIfIsParent.getRootNode() as
939+
| Document
940+
| ShadowRoot;
941+
this.injectCSS_(root, selector);
942+
}
942943

943944
/*
944945
<defs>
@@ -1121,26 +1122,26 @@ export class ConstantProvider {
11211122
/**
11221123
* Inject renderer specific CSS into the page.
11231124
*
1124-
* @param tagName The name of the style tag to use.
1125-
* @param selector The CSS selector to use.
1125+
* @param root The document root to inject the CSS into.
1126+
* @param selector The CSS selector to interpolate into the stylesheet.
11261127
*/
1127-
protected injectCSS_(tagName: string, selector: string) {
1128-
const cssArray = this.getCSS_(selector);
1129-
const cssNodeId = 'blockly-renderer-style-' + tagName;
1130-
this.cssNode = document.getElementById(cssNodeId) as HTMLStyleElement;
1131-
const text = cssArray.join('\n');
1132-
if (this.cssNode) {
1133-
// Already injected, update if the theme changed.
1134-
this.cssNode.firstChild!.textContent = text;
1128+
protected injectCSS_(root: Document | ShadowRoot, selector: string) {
1129+
if (
1130+
typeof window === 'undefined' ||
1131+
!window.CSSStyleSheet ||
1132+
injectionSites.get(selector)?.has(root)
1133+
) {
11351134
return;
11361135
}
1137-
// Inject CSS tag at start of head.
1138-
const cssNode = document.createElement('style');
1139-
cssNode.id = cssNodeId;
1140-
const cssTextNode = document.createTextNode(text);
1141-
cssNode.appendChild(cssTextNode);
1142-
document.head.insertBefore(cssNode, document.head.firstChild);
1143-
this.cssNode = cssNode;
1136+
1137+
const sheet = new CSSStyleSheet();
1138+
sheet.replace(this.getCSS_(selector).join('\n'));
1139+
root.adoptedStyleSheets.push(sheet);
1140+
1141+
const sitesForSelector =
1142+
injectionSites.get(selector) ?? new WeakSet<Document | ShadowRoot>();
1143+
sitesForSelector.add(root);
1144+
injectionSites.set(selector, sitesForSelector);
11441145
}
11451146

11461147
/**

packages/blockly/core/renderers/common/renderer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ export class Renderer implements IRegistrable {
8989
) {
9090
this.constants_.createDom(
9191
svg,
92-
this.name + '-' + theme.name,
9392
'.' + this.getClassName() + '.' + theme.getClassName(),
9493
injectionDivIfIsParent,
9594
);

packages/blockly/core/renderers/zelos/constants.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -647,11 +647,10 @@ export class ConstantProvider extends BaseConstantProvider {
647647

648648
override createDom(
649649
svg: SVGElement,
650-
tagName: string,
651650
selector: string,
652651
injectionDivIfIsParent?: HTMLElement,
653652
) {
654-
super.createDom(svg, tagName, selector, injectionDivIfIsParent);
653+
super.createDom(svg, selector, injectionDivIfIsParent);
655654
/*
656655
<defs>
657656
... filters go here ...

packages/blockly/core/tooltip.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import * as browserEvents from './browser_events.js';
1010
import * as common from './common.js';
1111
import * as blocklyString from './utils/string.js';
12+
import type {WorkspaceSvg} from './workspace_svg.js';
1213

1314
/**
1415
* A type which can define a tooltip.
@@ -287,7 +288,7 @@ function onMouseOut(_e: PointerEvent) {
287288
*
288289
* @param e Mouse event.
289290
*/
290-
function onMouseMove(e: Event) {
291+
function onMouseMove(this: any, e: Event) {
291292
if (!element || !(element as AnyDuringMigration).tooltip) {
292293
// No tooltip here to show.
293294
return;
@@ -318,7 +319,20 @@ function onMouseMove(e: Event) {
318319
// AnyDuringMigration because: Property 'pageY' does not exist on type
319320
// 'Event'.
320321
lastY = (e as AnyDuringMigration).pageY;
321-
showPid = setTimeout(show, HOVER_MS);
322+
showPid = setTimeout(() => {
323+
let workspace: WorkspaceSvg | undefined;
324+
if (this instanceof Element) {
325+
for (const ws of common.getAllWorkspaces()) {
326+
if (!ws.rendered) continue;
327+
if ((ws as WorkspaceSvg).getInjectionDiv()?.contains(this)) {
328+
workspace = ws as WorkspaceSvg;
329+
break;
330+
}
331+
}
332+
}
333+
334+
show(workspace);
335+
}, HOVER_MS);
322336
}
323337
}
324338

@@ -416,7 +430,16 @@ function getPosition(rtl: boolean): {x: number; y: number} {
416430
}
417431

418432
let anchorY = lastY + OFFSET_Y;
419-
if (anchorY + containerDiv!.offsetHeight > windowHeight + window.scrollY) {
433+
434+
const parentElement = containerDiv?.parentElement;
435+
if (parentElement) {
436+
const parentBounds = parentElement.getBoundingClientRect();
437+
anchorX -= parentBounds.left + window.scrollX;
438+
anchorY -= parentBounds.top + window.scrollY;
439+
}
440+
441+
const tooltipBottom = anchorY + containerDiv!.offsetHeight;
442+
if (tooltipBottom > windowHeight + window.scrollY) {
420443
// Falling off the bottom of the screen; shift the tooltip up.
421444
anchorY -= containerDiv!.offsetHeight + 2 * OFFSET_Y;
422445
}
@@ -439,7 +462,7 @@ function getPosition(rtl: boolean): {x: number; y: number} {
439462
}
440463

441464
/** Create the tooltip and show it. */
442-
function show() {
465+
function show(workspace?: WorkspaceSvg) {
443466
if (blocked) {
444467
// Someone doesn't want us to show tooltips.
445468
return;
@@ -448,6 +471,10 @@ function show() {
448471
if (!containerDiv) {
449472
return;
450473
}
474+
475+
const parentDiv = common.getParentContainer(workspace);
476+
parentDiv?.appendChild(containerDiv);
477+
451478
// Erase all existing text.
452479
containerDiv.textContent = '';
453480

packages/blockly/core/widgetdiv.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export function show(
112112
dispose = newDispose;
113113
const div = containerDiv;
114114
if (!div) return;
115+
116+
const parentDiv = common.getParentContainer();
117+
parentDiv?.appendChild(div);
118+
115119
div.style.direction = rtl ? 'rtl' : 'ltr';
116120
div.style.display = 'block';
117121
if (!workspace && newOwner instanceof Field) {
@@ -225,9 +229,18 @@ export function hideIfOwnerIsInWorkspace(workspace: WorkspaceSvg) {
225229
* @param height The height of the widget div (pixels).
226230
*/
227231
function positionInternal(x: number, y: number, height: number) {
228-
containerDiv!.style.left = x + 'px';
229-
containerDiv!.style.top = y + 'px';
230-
containerDiv!.style.height = height + 'px';
232+
if (!containerDiv) return;
233+
234+
const parentElement = containerDiv.parentElement;
235+
if (parentElement) {
236+
const bounds = parentElement.getBoundingClientRect();
237+
x -= bounds.left + window.scrollX;
238+
y -= bounds.top + window.scrollY;
239+
}
240+
241+
containerDiv.style.left = x + 'px';
242+
containerDiv.style.top = y + 'px';
243+
containerDiv.style.height = height + 'px';
231244
}
232245

233246
/**

0 commit comments

Comments
 (0)