mirror of
https://github.com/google/blockly.git
synced 2026-01-08 17:40:09 +01:00
_Note: This is a roll forward of #8916 that was reverted in #8933. See Additional Information below._ ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #8913 Fixes #8914 Fixes part of #8771 ### Proposed Changes This updates `WorkspaceSvg` and `BlockSvg` to be focusable, that is, it makes the workspace a `IFocusableTree` and blocks `IFocusableNode`s. Some important details: - While this introduces focusable tree support for `Workspace` it doesn't include two other components that are obviously needed by the keyboard navigation plugin's playground: fields and connections. These will be introduced in subsequent PRs. - Blocks are set up to automatically synchronize their selection state with their focus state. This will eventually help to replace `LineCursor`'s responsibility for managing selection state itself. - The tabindex property for the workspace and its ARIA label have been moved down to the `.blocklyWorkspace` element itself rather than its wrapper. This helps address some tab stop issues that are already addressed in the plugin (via monkey patches), but also to ensure that the workspace's main SVG group interacts correctly with `FocusManager`. - `WorkspaceSvg` is being initially set up to default to its first top block when being focused for the first time. This is to match parity with the keyboard navigation plugin, however the latter also has functionality for defaulting to a position when no blocks are present. It's not clear how to actually support this under the new focus-based system (without adding an ephemeral element on which to focus), or if it's even necessary (since the workspace root can hold focus). - `css.ts` was updated to remove `blocklyActiveFocus` and `blocklyPassiveFocus` since these have unintended highlighting consequences that aren't actually desirable yet. Instead, the exact styling for active/passive focus will be iterated in the keyboard navigation plugin project and moved to Core once finalized. ### Reason for Changes This is part of an ongoing effort to ensure key components of Blockly are focusable so that they can be keyboard-navigable (with other needed changes yet both in Core Blockly and the keyboard navigation plugin). ### Test Coverage No new tests have been added. It's certainly possible to add unit tests for the focusable configurations being introduced in this PR, but it may not be highly beneficial. It's largely assumed that the individual implementations should work due to a highly tested FocusManager, and it may be the case that the interactions of the components working together is far more important to verify (that is, the end user flows). The latter is planned to be tackled as part of #8915. ### Documentation No documentation changes should be needed here. ### Additional Information This includes changes that have been pulled from #8875. This was originally merged in #8916 but was reverted in #8933 due to https://github.com/google/blockly-keyboard-experimentation/issues/481. This actually contains no differences from the original PR except for `css.ts` which are documented above. It does employ a new merge strategy: all of the necessary PRs to move both Core and the plugin over to using `FocusManager` will be staged and merged in quick succession as ensuring the plugin works for each constituent change (vs. the final one) is quite complex. Thus, this PR *does* break the plugin, and won't be merged until its subsequent PRs are approved and also ready for merging. Edit: See https://github.com/google/blockly/pull/8938#issuecomment-2843589525 for why this actually is being merged a bit sooner than originally planned. Keeping the original reasoning above for context.
426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2011 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Former goog.module ID: Blockly.inject
|
|
|
|
import type {BlocklyOptions} from './blockly_options.js';
|
|
import * as browserEvents from './browser_events.js';
|
|
import * as bumpObjects from './bump_objects.js';
|
|
import * as common from './common.js';
|
|
import * as Css from './css.js';
|
|
import * as dropDownDiv from './dropdowndiv.js';
|
|
import {Grid} from './grid.js';
|
|
import {Options} from './options.js';
|
|
import {ScrollbarPair} from './scrollbar_pair.js';
|
|
import {ShortcutRegistry} from './shortcut_registry.js';
|
|
import * as Tooltip from './tooltip.js';
|
|
import * as Touch from './touch.js';
|
|
import * as dom from './utils/dom.js';
|
|
import {Svg} from './utils/svg.js';
|
|
import * as WidgetDiv from './widgetdiv.js';
|
|
import {WorkspaceSvg} from './workspace_svg.js';
|
|
|
|
/**
|
|
* Inject a Blockly editor into the specified container element (usually a div).
|
|
*
|
|
* @param container Containing element, or its ID, or a CSS selector.
|
|
* @param opt_options Optional dictionary of options.
|
|
* @returns Newly created main workspace.
|
|
*/
|
|
export function inject(
|
|
container: Element | string,
|
|
opt_options?: BlocklyOptions,
|
|
): WorkspaceSvg {
|
|
let containerElement: Element | null = null;
|
|
if (typeof container === 'string') {
|
|
containerElement =
|
|
document.getElementById(container) || document.querySelector(container);
|
|
} else {
|
|
containerElement = container;
|
|
}
|
|
// Verify that the container is in document.
|
|
if (
|
|
!document.contains(containerElement) &&
|
|
document !== containerElement?.ownerDocument
|
|
) {
|
|
throw Error('Error: container is not in current document');
|
|
}
|
|
const options = new Options(opt_options || ({} as BlocklyOptions));
|
|
const subContainer = document.createElement('div');
|
|
dom.addClass(subContainer, 'injectionDiv');
|
|
if (opt_options?.rtl) {
|
|
dom.addClass(subContainer, 'blocklyRTL');
|
|
}
|
|
|
|
containerElement!.appendChild(subContainer);
|
|
const svg = createDom(subContainer, options);
|
|
|
|
const workspace = createMainWorkspace(subContainer, svg, options);
|
|
|
|
init(workspace);
|
|
|
|
// Keep focus on the first workspace so entering keyboard navigation looks
|
|
// correct.
|
|
common.setMainWorkspace(workspace);
|
|
|
|
common.svgResize(workspace);
|
|
|
|
subContainer.addEventListener('focusin', function () {
|
|
common.setMainWorkspace(workspace);
|
|
});
|
|
|
|
browserEvents.conditionalBind(subContainer, 'keydown', null, onKeyDown);
|
|
browserEvents.conditionalBind(
|
|
dropDownDiv.getContentDiv(),
|
|
'keydown',
|
|
null,
|
|
onKeyDown,
|
|
);
|
|
const widgetContainer = WidgetDiv.getDiv();
|
|
if (widgetContainer) {
|
|
browserEvents.conditionalBind(widgetContainer, 'keydown', null, onKeyDown);
|
|
}
|
|
|
|
return workspace;
|
|
}
|
|
|
|
/**
|
|
* Create the SVG image.
|
|
*
|
|
* @param container Containing element.
|
|
* @param options Dictionary of options.
|
|
* @returns Newly created SVG image.
|
|
*/
|
|
function createDom(container: HTMLElement, options: Options): SVGElement {
|
|
// Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying
|
|
// out content in RTL mode. Therefore Blockly forces the use of LTR,
|
|
// then manually positions content in RTL as needed.
|
|
container.setAttribute('dir', 'LTR');
|
|
|
|
// Load CSS.
|
|
Css.inject(options.hasCss, options.pathToMedia);
|
|
|
|
// Build the SVG DOM.
|
|
/*
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
xmlns:html="http://www.w3.org/1999/xhtml"
|
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
version="1.1"
|
|
class="blocklySvg">
|
|
...
|
|
</svg>
|
|
*/
|
|
const svg = dom.createSvgElement(
|
|
Svg.SVG,
|
|
{
|
|
'xmlns': dom.SVG_NS,
|
|
'xmlns:html': dom.HTML_NS,
|
|
'xmlns:xlink': dom.XLINK_NS,
|
|
'version': '1.1',
|
|
'class': 'blocklySvg',
|
|
},
|
|
container,
|
|
);
|
|
/*
|
|
<defs>
|
|
... filters go here ...
|
|
</defs>
|
|
*/
|
|
const defs = dom.createSvgElement(Svg.DEFS, {}, svg);
|
|
// Each filter/pattern needs a unique ID for the case of multiple Blockly
|
|
// instances on a page. Browser behaviour becomes undefined otherwise.
|
|
// https://neil.fraser.name/news/2015/11/01/
|
|
const rnd = String(Math.random()).substring(2);
|
|
|
|
options.gridPattern = Grid.createDom(
|
|
rnd,
|
|
options.gridOptions,
|
|
defs,
|
|
container,
|
|
);
|
|
return svg;
|
|
}
|
|
|
|
/**
|
|
* Create a main workspace and add it to the SVG.
|
|
*
|
|
* @param svg SVG element with pattern defined.
|
|
* @param options Dictionary of options.
|
|
* @returns Newly created main workspace.
|
|
*/
|
|
function createMainWorkspace(
|
|
injectionDiv: HTMLElement,
|
|
svg: SVGElement,
|
|
options: Options,
|
|
): WorkspaceSvg {
|
|
options.parentWorkspace = null;
|
|
const mainWorkspace = new WorkspaceSvg(options);
|
|
const wsOptions = mainWorkspace.options;
|
|
mainWorkspace.scale = wsOptions.zoomOptions.startScale;
|
|
svg.appendChild(
|
|
mainWorkspace.createDom('blocklyMainBackground', injectionDiv),
|
|
);
|
|
|
|
// Set the theme name and renderer name onto the injection div.
|
|
const rendererClassName = mainWorkspace.getRenderer().getClassName();
|
|
if (rendererClassName) {
|
|
dom.addClass(injectionDiv, rendererClassName);
|
|
}
|
|
const themeClassName = mainWorkspace.getTheme().getClassName();
|
|
if (themeClassName) {
|
|
dom.addClass(injectionDiv, themeClassName);
|
|
}
|
|
|
|
if (!wsOptions.hasCategories && wsOptions.languageTree) {
|
|
// Add flyout as an <svg> that is a sibling of the workspace SVG.
|
|
const flyout = mainWorkspace.addFlyout(Svg.SVG);
|
|
dom.insertAfter(flyout, svg);
|
|
}
|
|
if (wsOptions.hasTrashcan) {
|
|
mainWorkspace.addTrashcan();
|
|
}
|
|
if (wsOptions.zoomOptions && wsOptions.zoomOptions.controls) {
|
|
mainWorkspace.addZoomControls();
|
|
}
|
|
// Register the workspace svg as a UI component.
|
|
mainWorkspace
|
|
.getThemeManager()
|
|
.subscribe(svg, 'workspaceBackgroundColour', 'background-color');
|
|
|
|
// A null translation will also apply the correct initial scale.
|
|
mainWorkspace.translate(0, 0);
|
|
|
|
mainWorkspace.addChangeListener(
|
|
bumpObjects.bumpIntoBoundsHandler(mainWorkspace),
|
|
);
|
|
|
|
// The SVG is now fully assembled.
|
|
common.svgResize(mainWorkspace);
|
|
WidgetDiv.createDom();
|
|
dropDownDiv.createDom();
|
|
Tooltip.createDom();
|
|
return mainWorkspace;
|
|
}
|
|
|
|
/**
|
|
* Initialize Blockly with various handlers.
|
|
*
|
|
* @param mainWorkspace Newly created main workspace.
|
|
*/
|
|
function init(mainWorkspace: WorkspaceSvg) {
|
|
const options = mainWorkspace.options;
|
|
const svg = mainWorkspace.getParentSvg();
|
|
|
|
// Suppress the browser's context menu.
|
|
browserEvents.conditionalBind(
|
|
svg.parentNode as Element,
|
|
'contextmenu',
|
|
null,
|
|
function (e: Event) {
|
|
if (!browserEvents.isTargetInput(e)) {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
);
|
|
|
|
const workspaceResizeHandler = browserEvents.conditionalBind(
|
|
window,
|
|
'resize',
|
|
null,
|
|
function () {
|
|
// Don't hide all the chaff. Leave the dropdown and widget divs open if
|
|
// possible.
|
|
Tooltip.hide();
|
|
mainWorkspace.hideComponents(true);
|
|
dropDownDiv.repositionForWindowResize();
|
|
WidgetDiv.repositionForWindowResize();
|
|
common.svgResize(mainWorkspace);
|
|
bumpObjects.bumpTopObjectsIntoBounds(mainWorkspace);
|
|
},
|
|
);
|
|
mainWorkspace.setResizeHandlerWrapper(workspaceResizeHandler);
|
|
|
|
bindDocumentEvents();
|
|
|
|
if (options.languageTree) {
|
|
const toolbox = mainWorkspace.getToolbox();
|
|
const flyout = mainWorkspace.getFlyout(true);
|
|
if (toolbox) {
|
|
toolbox.init();
|
|
} else if (flyout) {
|
|
// Build a fixed flyout with the root blocks.
|
|
flyout.init(mainWorkspace);
|
|
flyout.show(options.languageTree);
|
|
if (typeof flyout.scrollToStart === 'function') {
|
|
flyout.scrollToStart();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.hasTrashcan) {
|
|
mainWorkspace.trashcan!.init();
|
|
}
|
|
if (options.zoomOptions && options.zoomOptions.controls) {
|
|
mainWorkspace.zoomControls_!.init();
|
|
}
|
|
|
|
if (options.moveOptions && options.moveOptions.scrollbars) {
|
|
const horizontalScroll =
|
|
options.moveOptions.scrollbars === true ||
|
|
!!options.moveOptions.scrollbars.horizontal;
|
|
const verticalScroll =
|
|
options.moveOptions.scrollbars === true ||
|
|
!!options.moveOptions.scrollbars.vertical;
|
|
mainWorkspace.scrollbar = new ScrollbarPair(
|
|
mainWorkspace,
|
|
horizontalScroll,
|
|
verticalScroll,
|
|
'blocklyMainWorkspaceScrollbar',
|
|
);
|
|
mainWorkspace.scrollbar.resize();
|
|
} else {
|
|
mainWorkspace.setMetrics({x: 0.5, y: 0.5});
|
|
}
|
|
|
|
// Load the sounds.
|
|
if (options.hasSounds) {
|
|
loadSounds(options.pathToMedia, mainWorkspace);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a key-down on SVG drawing surface. Does nothing if the main workspace
|
|
* is not visible.
|
|
*
|
|
* @param e Key down event.
|
|
*/
|
|
// TODO (https://github.com/google/blockly/issues/1998) handle cases where there
|
|
// are multiple workspaces and non-main workspaces are able to accept input.
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg;
|
|
if (!mainWorkspace) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
browserEvents.isTargetInput(e) ||
|
|
(mainWorkspace.rendered && !mainWorkspace.isVisible())
|
|
) {
|
|
// When focused on an HTML text input widget, don't trap any keys.
|
|
// Ignore keypresses on rendered workspaces that have been explicitly
|
|
// hidden.
|
|
return;
|
|
}
|
|
ShortcutRegistry.registry.onKeyDown(mainWorkspace, e);
|
|
}
|
|
|
|
/**
|
|
* Whether event handlers have been bound. Document event handlers will only
|
|
* be bound once, even if Blockly is destroyed and reinjected.
|
|
*/
|
|
let documentEventsBound = false;
|
|
|
|
/**
|
|
* Bind document events, but only once. Destroying and reinjecting Blockly
|
|
* should not bind again.
|
|
* Bind events for scrolling the workspace.
|
|
* Most of these events should be bound to the SVG's surface.
|
|
* However, 'mouseup' has to be on the whole document so that a block dragged
|
|
* out of bounds and released will know that it has been released.
|
|
*/
|
|
function bindDocumentEvents() {
|
|
if (!documentEventsBound) {
|
|
browserEvents.conditionalBind(document, 'scroll', null, function () {
|
|
const workspaces = common.getAllWorkspaces();
|
|
for (let i = 0, workspace; (workspace = workspaces[i]); i++) {
|
|
if (workspace instanceof WorkspaceSvg) {
|
|
workspace.updateInverseScreenCTM();
|
|
}
|
|
}
|
|
});
|
|
// longStop needs to run to stop the context menu from showing up. It
|
|
// should run regardless of what other touch event handlers have run.
|
|
browserEvents.bind(document, 'touchend', null, Touch.longStop);
|
|
browserEvents.bind(document, 'touchcancel', null, Touch.longStop);
|
|
}
|
|
documentEventsBound = true;
|
|
}
|
|
|
|
/**
|
|
* Load sounds for the given workspace.
|
|
*
|
|
* @param pathToMedia The path to the media directory.
|
|
* @param workspace The workspace to load sounds for.
|
|
*/
|
|
function loadSounds(pathToMedia: string, workspace: WorkspaceSvg) {
|
|
const audioMgr = workspace.getAudioManager();
|
|
audioMgr.load(
|
|
[
|
|
pathToMedia + 'click.mp3',
|
|
pathToMedia + 'click.wav',
|
|
pathToMedia + 'click.ogg',
|
|
],
|
|
'click',
|
|
);
|
|
audioMgr.load(
|
|
[
|
|
pathToMedia + 'disconnect.wav',
|
|
pathToMedia + 'disconnect.mp3',
|
|
pathToMedia + 'disconnect.ogg',
|
|
],
|
|
'disconnect',
|
|
);
|
|
audioMgr.load(
|
|
[
|
|
pathToMedia + 'delete.mp3',
|
|
pathToMedia + 'delete.ogg',
|
|
pathToMedia + 'delete.wav',
|
|
],
|
|
'delete',
|
|
);
|
|
|
|
// Bind temporary hooks that preload the sounds.
|
|
const soundBinds: browserEvents.Data[] = [];
|
|
/**
|
|
*
|
|
*/
|
|
function unbindSounds() {
|
|
while (soundBinds.length) {
|
|
const oldSoundBinding = soundBinds.pop();
|
|
if (oldSoundBinding) {
|
|
browserEvents.unbind(oldSoundBinding);
|
|
}
|
|
}
|
|
audioMgr.preload();
|
|
}
|
|
|
|
// These are bound on mouse/touch events with
|
|
// Blockly.browserEvents.conditionalBind, so they restrict the touch
|
|
// identifier that will be recognized. But this is really something that
|
|
// happens on a click, not a drag, so that's not necessary.
|
|
|
|
// Android ignores any sound not loaded as a result of a user action.
|
|
soundBinds.push(
|
|
browserEvents.conditionalBind(
|
|
document,
|
|
'pointermove',
|
|
null,
|
|
unbindSounds,
|
|
true,
|
|
),
|
|
);
|
|
soundBinds.push(
|
|
browserEvents.conditionalBind(
|
|
document,
|
|
'touchstart',
|
|
null,
|
|
unbindSounds,
|
|
true,
|
|
),
|
|
);
|
|
}
|