mirror of
https://github.com/google/blockly.git
synced 2026-05-01 17:40:11 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 ...
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
@@ -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 <blockly-editor>
|
||||
</div>
|
||||
<div class="workspace-card-body">
|
||||
<blockly-editor></blockly-editor>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user