From 870824bc3e802f96b1bcaf63c8256bd749c637f1 Mon Sep 17 00:00:00 2001 From: Sam El-Husseini Date: Thu, 26 Sep 2019 16:52:17 -0700 Subject: [PATCH] Workspace theme (#3093) * Move the theme object so it's on the workspace. * Add support for subscribing UI elements to theme component styles and changes. --- blockly_uncompressed.js | 6 +- core/block.js | 6 +- core/blockly.js | 76 ---------- core/flyout_base.js | 3 + core/flyout_button.js | 7 + core/inject.js | 3 +- core/mutator.js | 3 +- core/options.js | 3 +- core/scrollbar.js | 7 +- core/theme.js | 52 ++++++- core/theme/dark.js | 131 +++++++++++++++++ core/theme_manager.js | 196 ++++++++++++++++++++++++++ core/toolbox.js | 10 +- core/workspace.js | 87 +++++++++++- core/workspace_svg.js | 27 +++- demos/custom-fields/turtle/index.html | 2 +- tests/jsunit/block_test.js | 4 +- tests/jsunit/theme_test.js | 4 +- tests/mocha/procedures_test.js | 46 ++++-- tests/mocha/theme_test.js | 4 +- tests/mocha/trashcan_test.js | 4 + tests/mocha/xml_procedures_test.js | 4 +- tests/playground.html | 11 +- 23 files changed, 566 insertions(+), 130 deletions(-) create mode 100644 core/theme/dark.js create mode 100644 core/theme_manager.js diff --git a/blockly_uncompressed.js b/blockly_uncompressed.js index 868fee22d..3d6f76fb6 100644 --- a/blockly_uncompressed.js +++ b/blockly_uncompressed.js @@ -120,8 +120,10 @@ goog.addDependency("../../core/requires.js", ['Blockly.requires'], ['Blockly', ' goog.addDependency("../../core/scrollbar.js", ['Blockly.Scrollbar', 'Blockly.ScrollbarPair'], ['Blockly.Touch', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.dom']); goog.addDependency("../../core/theme.js", ['Blockly.Theme'], []); goog.addDependency("../../core/theme/classic.js", ['Blockly.Themes.Classic'], ['Blockly.Theme']); +goog.addDependency("../../core/theme/dark.js", ['Blockly.Themes.Dark'], ['Blockly.Theme']); goog.addDependency("../../core/theme/highcontrast.js", ['Blockly.Themes.HighContrast'], ['Blockly.Theme']); goog.addDependency("../../core/theme/modern.js", ['Blockly.Themes.Modern'], ['Blockly.Theme']); +goog.addDependency("../../core/theme_manager.js", ['Blockly.ThemeManager'], ['Blockly.Theme']); goog.addDependency("../../core/toolbox.js", ['Blockly.Toolbox'], ['Blockly.Events', 'Blockly.Events.Ui', 'Blockly.navigation', 'Blockly.Touch', 'Blockly.tree.TreeControl', 'Blockly.tree.TreeNode', 'Blockly.utils', 'Blockly.utils.aria', 'Blockly.utils.colour', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.Rect']); goog.addDependency("../../core/tooltip.js", ['Blockly.Tooltip'], ['Blockly.utils.string']); goog.addDependency("../../core/touch.js", ['Blockly.Touch'], ['Blockly.utils', 'Blockly.utils.global', 'Blockly.utils.string']); @@ -153,7 +155,7 @@ goog.addDependency("../../core/variables.js", ['Blockly.Variables'], ['Blockly.B goog.addDependency("../../core/variables_dynamic.js", ['Blockly.VariablesDynamic'], ['Blockly.Variables', 'Blockly.Blocks', 'Blockly.Msg', 'Blockly.utils.xml', 'Blockly.VariableModel', 'Blockly.Xml']); goog.addDependency("../../core/warning.js", ['Blockly.Warning'], ['Blockly.Bubble', 'Blockly.Events', 'Blockly.Events.Ui', 'Blockly.Icon', 'Blockly.utils.dom', 'Blockly.utils.object']); goog.addDependency("../../core/widgetdiv.js", ['Blockly.WidgetDiv'], ['Blockly.Css', 'Blockly.utils.style']); -goog.addDependency("../../core/workspace.js", ['Blockly.Workspace'], ['Blockly.Cursor', 'Blockly.MarkerCursor', 'Blockly.Events', 'Blockly.Themes.Classic', 'Blockly.utils', 'Blockly.utils.math', 'Blockly.VariableMap', 'Blockly.WorkspaceComment']); +goog.addDependency("../../core/workspace.js", ['Blockly.Workspace'], ['Blockly.Cursor', 'Blockly.MarkerCursor', 'Blockly.Events', 'Blockly.ThemeManager', 'Blockly.utils', 'Blockly.utils.math', 'Blockly.VariableMap', 'Blockly.WorkspaceComment']); goog.addDependency("../../core/workspace_audio.js", ['Blockly.WorkspaceAudio'], ['Blockly.utils', 'Blockly.utils.global', 'Blockly.utils.userAgent']); goog.addDependency("../../core/workspace_comment.js", ['Blockly.WorkspaceComment'], ['Blockly.Events', 'Blockly.Events.CommentChange', 'Blockly.Events.CommentCreate', 'Blockly.Events.CommentDelete', 'Blockly.Events.CommentMove', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.xml']); goog.addDependency("../../core/workspace_comment_render_svg.js", ['Blockly.WorkspaceCommentSvg.render'], ['Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.dom', 'Blockly.WorkspaceCommentSvg']); @@ -247,7 +249,9 @@ goog.require('Blockly.RenderedConnection'); goog.require('Blockly.Scrollbar'); goog.require('Blockly.ScrollbarPair'); goog.require('Blockly.Theme'); +goog.require('Blockly.ThemeManager'); goog.require('Blockly.Themes.Classic'); +goog.require('Blockly.Themes.Dark'); goog.require('Blockly.Themes.HighContrast'); goog.require('Blockly.Themes.Modern'); goog.require('Blockly.Toolbox'); diff --git a/core/block.js b/core/block.js index d1fa6b79a..0aee781d4 100644 --- a/core/block.js +++ b/core/block.js @@ -1006,11 +1006,7 @@ Blockly.Block.prototype.setColour = function(colour) { * @throws {Error} if the block style does not exist. */ Blockly.Block.prototype.setStyle = function(blockStyleName) { - var theme = Blockly.getTheme(); - if (!theme) { - throw Error('Trying to set block style to ' + blockStyleName + - ' before theme was defined via Blockly.setTheme().'); - } + var theme = this.workspace.getTheme(); var blockStyle = theme.getBlockStyle(blockStyleName); this.styleName_ = blockStyleName; diff --git a/core/blockly.js b/core/blockly.js index 15adebfc0..d71e97f01 100644 --- a/core/blockly.js +++ b/core/blockly.js @@ -117,13 +117,6 @@ Blockly.clipboardTypeCounts_ = null; */ Blockly.cache3dSupported_ = null; -/** - * Holds all Blockly style attributes. - * @type {Blockly.Theme} - * @private - */ -Blockly.theme_ = null; - /** * Returns the dimensions of the specified SVG image. * @param {!Element} svg SVG image. @@ -684,72 +677,3 @@ Blockly.checkBlockColourConstant_ = function( console.warn(warning); } }; - - -/** - * Sets the theme for Blockly and refreshes all blocks in the toolbox and - * workspace. - * @param {!Blockly.Theme} theme Theme for Blockly. - */ -Blockly.setTheme = function(theme) { - Blockly.theme_ = theme; - var ws = Blockly.getMainWorkspace(); - - if (ws) { - Blockly.refreshTheme_(ws); - } -}; - -/** - * Refresh the theme for all items on the workspace. - * @param {!Blockly.Workspace} ws Blockly workspace to refresh theme on. - * @private - */ -Blockly.refreshTheme_ = function(ws) { - // Update all blocks in workspace that have a style name. - Blockly.updateBlockStyles_(ws.getAllBlocks().filter( - function(block) { - return block.getStyleName() !== undefined; - } - )); - - // Update blocks in the flyout. - if (!ws.toolbox_ && ws.flyout_ && ws.flyout_.workspace_) { - Blockly.updateBlockStyles_(ws.flyout_.workspace_.getAllBlocks()); - } else { - ws.refreshToolboxSelection(); - } - - // Update colours on the categories. - if (ws.toolbox_) { - ws.toolbox_.updateColourFromTheme(); - } - - var event = new Blockly.Events.Ui(null, 'theme'); - event.workspaceId = ws.id; - Blockly.Events.fire(event); -}; - -/** - * Updates all the blocks with new style. - * @param {!Array.} blocks List of blocks to update the style - * on. - * @private - */ -Blockly.updateBlockStyles_ = function(blocks) { - for (var i = 0, block; block = blocks[i]; i++) { - var blockStyleName = block.getStyleName(); - block.setStyle(blockStyleName); - if (block.mutator) { - block.mutator.updateBlockStyle(blockStyleName); - } - } -}; - -/** - * Gets the theme. - * @return {Blockly.Theme} Theme for Blockly. - */ -Blockly.getTheme = function() { - return Blockly.theme_; -}; diff --git a/core/flyout_base.js b/core/flyout_base.js index 5edbad70f..3ec2872ba 100644 --- a/core/flyout_base.js +++ b/core/flyout_base.js @@ -230,6 +230,8 @@ Blockly.Flyout.prototype.createDom = function(tagName) { this.svgBackground_ = Blockly.utils.dom.createSvgElement('path', {'class': 'blocklyFlyoutBackground'}, this.svgGroup_); this.svgGroup_.appendChild(this.workspace_.createDom()); + this.workspace_.getThemeManager().subscribe(this.svgBackground_, 'flyout', 'fill'); + this.workspace_.getThemeManager().subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); return this.svgGroup_; }; @@ -286,6 +288,7 @@ Blockly.Flyout.prototype.dispose = function() { this.scrollbar_ = null; } if (this.workspace_) { + this.workspace_.getThemeManager().unsubscribe(this.svgBackground_); this.workspace_.targetWorkspace = null; this.workspace_.dispose(); this.workspace_ = null; diff --git a/core/flyout_button.js b/core/flyout_button.js index 9237b62de..3a36ca41a 100644 --- a/core/flyout_button.js +++ b/core/flyout_button.js @@ -156,6 +156,10 @@ Blockly.FlyoutButton.prototype.createDom = function() { }, this.svgGroup_); svgText.textContent = Blockly.utils.replaceMessageReferences(this.text_); + if (this.isLabel_) { + this.svgText_ = svgText; + this.workspace_.getThemeManager().subscribe(this.svgText_, 'flyoutText', 'fill'); + } this.width = Blockly.utils.dom.getTextWidth(svgText); this.height = 20; // Can't compute it :( @@ -235,6 +239,9 @@ Blockly.FlyoutButton.prototype.dispose = function() { Blockly.utils.dom.removeNode(this.svgGroup_); this.svgGroup_ = null; } + if (this.svgText_) { + this.workspace_.getThemeManager().unsubscribe(this.svgText_); + } this.workspace_ = null; this.targetWorkspace_ = null; }; diff --git a/core/inject.js b/core/inject.js index a7d49b95f..3a03d797d 100644 --- a/core/inject.js +++ b/core/inject.js @@ -72,7 +72,6 @@ Blockly.inject = function(container, opt_options) { var workspace = Blockly.createMainWorkspace_(svg, options, blockDragSurface, workspaceDragSurface); - Blockly.setTheme(options.theme); Blockly.user.keyMap.setKeyMap(options.keyMap); Blockly.init_(workspace); @@ -231,6 +230,8 @@ Blockly.createMainWorkspace_ = function(svg, options, blockDragSurface, if (options.zoomOptions && options.zoomOptions.controls) { mainWorkspace.addZoomControls(); } + // Register the workspace svg as a UI component. + mainWorkspace.getThemeManager().subscribe(svg, 'workspace', 'background-color'); // A null translation will also apply the correct initial scale. mainWorkspace.translate(0, 0); diff --git a/core/mutator.js b/core/mutator.js index b641eee32..abfa14e43 100644 --- a/core/mutator.js +++ b/core/mutator.js @@ -157,7 +157,8 @@ Blockly.Mutator.prototype.createEditor_ = function() { Blockly.TOOLBOX_AT_LEFT, horizontalLayout: false, getMetrics: this.getFlyoutMetrics_.bind(this), - setMetrics: null + setMetrics: null, + renderer: this.block_.workspace.options.renderer }; this.workspace_ = new Blockly.WorkspaceSvg(workspaceOptions); this.workspace_.isMutator = true; diff --git a/core/options.js b/core/options.js index 781d12da7..5eb16a0b1 100644 --- a/core/options.js +++ b/core/options.js @@ -26,7 +26,6 @@ goog.provide('Blockly.Options'); -goog.require('Blockly.Themes.Classic'); goog.require('Blockly.utils.userAgent'); goog.require('Blockly.Xml'); @@ -118,7 +117,7 @@ Blockly.Options = function(options) { } else { var oneBasedIndex = !!options['oneBasedIndex']; } - var theme = options['theme'] || Blockly.Themes.Classic; + var theme = options['theme']; var keyMap = options['keyMap'] || Blockly.user.keyMap.createDefaultKeyMap(); var renderer = options['renderer'] || 'geras'; diff --git a/core/scrollbar.js b/core/scrollbar.js index 86d1945b7..2ac539cde 100644 --- a/core/scrollbar.js +++ b/core/scrollbar.js @@ -351,7 +351,10 @@ Blockly.Scrollbar.prototype.dispose = function() { this.outerSvg_ = null; this.svgGroup_ = null; this.svgBackground_ = null; - this.svgHandle_ = null; + if (this.svgHandle_) { + this.workspace_.getThemeManager().unsubscribe(this.svgHandle_); + this.svgHandle_ = null; + } this.workspace_ = null; }; @@ -625,6 +628,8 @@ Blockly.Scrollbar.prototype.createDom_ = function(opt_class) { 'ry': radius }, this.svgGroup_); + this.workspace_.getThemeManager().subscribe(this.svgHandle_, 'scrollbar', 'fill'); + this.workspace_.getThemeManager().subscribe(this.svgHandle_, 'scrollbarOpacity', 'fill-opacity'); Blockly.utils.dom.insertAfter(this.outerSvg_, this.workspace_.getParentSvg()); }; diff --git a/core/theme.js b/core/theme.js index e9fb6d66f..87428fdcb 100644 --- a/core/theme.js +++ b/core/theme.js @@ -28,16 +28,33 @@ goog.provide('Blockly.Theme'); /** * Class for a theme. - * @param {!Object.} blockStyles A map from style - * names (strings) to objects with style attributes relating to blocks. - * @param {!Object.} categoryStyles A map from - * style names (strings) to objects with style attributes relating to + * @param {!Object.} blockStyles A map from + * style names (strings) to objects with style attributes for blocks. + * @param {!Object.} categoryStyles A map + * from style names (strings) to objects with style attributes for * categories. + * @param {!Object.=} opt_componentStyles A map of Blockly component + * names to style value. * @constructor */ -Blockly.Theme = function(blockStyles, categoryStyles) { +Blockly.Theme = function(blockStyles, categoryStyles, opt_componentStyles) { + /** + * The block styles map. + * @type {!Object.} + */ this.blockStyles_ = blockStyles; + + /** + * The category styles map. + * @type {!Object.} + */ this.categoryStyles_ = categoryStyles; + + /** + * The UI components styles map. + * @type {!Object.} + */ + this.componentStyles_ = opt_componentStyles || Object.create(null); }; /** @@ -114,3 +131,28 @@ Blockly.Theme.prototype.setCategoryStyle = function(categoryStyleName, categoryStyle) { this.categoryStyles_[categoryStyleName] = categoryStyle; }; + +/** + * Gets the style for a given Blockly UI component. If the style value is a + * string, we attempt to find the value of any named references. + * @param {string} componentName The name of the component. + * @return {?string} The style value. + */ +Blockly.Theme.prototype.getComponentStyle = function(componentName) { + var style = this.componentStyles_[componentName]; + if (style && typeof propertyValue == 'string' && + this.getComponentStyle(/** @type {string} */ (style))) { + return this.getComponentStyle(/** @type {string} */ (style)); + } + return style ? String(style) : null; +}; + +/** + * Configure a specific Blockly UI component with a style value. + * @param {string} componentName The name of the component. + * @param {*} styleValue The style value. +*/ +Blockly.Theme.prototype.setComponentStyle = function(componentName, + styleValue) { + this.componentStyles_[componentName] = styleValue; +}; diff --git a/core/theme/dark.js b/core/theme/dark.js new file mode 100644 index 000000000..fba1f9a32 --- /dev/null +++ b/core/theme/dark.js @@ -0,0 +1,131 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Dark theme. + * @author samelh@google.com (Sam El-Husseini) + */ +'use strict'; + +goog.provide('Blockly.Themes.Dark'); + +goog.require('Blockly.Theme'); + + +// Temporary holding object. +Blockly.Themes.Dark = {}; + +Blockly.Themes.Dark.defaultBlockStyles = { + "colour_blocks": { + "colourPrimary": "#a5745b", + "colourSecondary": "#dbc7bd", + "colourTertiary": "#845d49" + }, + "list_blocks": { + "colourPrimary": "#745ba5", + "colourSecondary": "#c7bddb", + "colourTertiary": "#5d4984" + }, + "logic_blocks": { + "colourPrimary": "#5b80a5", + "colourSecondary": "#bdccdb", + "colourTertiary": "#496684" + }, + "loop_blocks": { + "colourPrimary": "#5ba55b", + "colourSecondary": "#bddbbd", + "colourTertiary": "#498449" + }, + "math_blocks": { + "colourPrimary": "#5b67a5", + "colourSecondary": "#bdc2db", + "colourTertiary": "#495284" + }, + "procedure_blocks": { + "colourPrimary": "#995ba5", + "colourSecondary": "#d6bddb", + "colourTertiary": "#7a4984" + }, + "text_blocks": { + "colourPrimary": "#5ba58c", + "colourSecondary": "#bddbd1", + "colourTertiary": "#498470" + }, + "variable_blocks": { + "colourPrimary": "#a55b99", + "colourSecondary": "#dbbdd6", + "colourTertiary": "#84497a" + }, + "variable_dynamic_blocks": { + "colourPrimary": "#a55b99", + "colourSecondary": "#dbbdd6", + "colourTertiary": "#84497a" + }, + "hat_blocks": { + "colourPrimary": "#a55b99", + "colourSecondary": "#dbbdd6", + "colourTertiary": "#84497a", + "hat": "cap" + } +}; + +Blockly.Themes.Dark.categoryStyles = { + "colour_category": { + "colour": "#a5745b" + }, + "list_category": { + "colour": "#745ba5" + }, + "logic_category": { + "colour": "#5b80a5" + }, + "loop_category": { + "colour": "#5ba55b" + }, + "math_category": { + "colour": "#5b67a5" + }, + "procedure_category": { + "colour": "#995ba5" + }, + "text_category": { + "colour": "#5ba58c" + }, + "variable_category": { + "colour": "#a55b99" + }, + "variable_dynamic_category": { + "colour": "#a55b99" + } +}; + +// This style is still being fleshed out and may change. +Blockly.Themes.Dark = + new Blockly.Theme(Blockly.Themes.Dark.defaultBlockStyles, + Blockly.Themes.Dark.categoryStyles); + +Blockly.Themes.Dark.setComponentStyle('workspace', '#1e1e1e'); +Blockly.Themes.Dark.setComponentStyle('toolbox', '#333'); +Blockly.Themes.Dark.setComponentStyle('toolboxText', '#fff'); +Blockly.Themes.Dark.setComponentStyle('flyout', '#252526'); +Blockly.Themes.Dark.setComponentStyle('flyoutText', '#ccc'); +Blockly.Themes.Dark.setComponentStyle('flyoutOpacity', 1); +Blockly.Themes.Dark.setComponentStyle('scrollbar', '#797979'); +Blockly.Themes.Dark.setComponentStyle('scrollbarOpacity', 0.4); diff --git a/core/theme_manager.js b/core/theme_manager.js new file mode 100644 index 000000000..cc89785cc --- /dev/null +++ b/core/theme_manager.js @@ -0,0 +1,196 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Object in charge of storing and updating a workspace theme + * and UI components. + * @author aschmiedt@google.com (Abby Schmiedt) + * @author samelh@google.com (Sam El-Husseini) + */ +'use strict'; + +goog.provide('Blockly.ThemeManager'); + +goog.require('Blockly.Theme'); + + +/** + * Class for storing and updating a workspace's theme and UI components. + * @param {!Blockly.Theme} theme The workspace theme. + * @constructor + * @package + */ +Blockly.ThemeManager = function(theme) { + + /** + * The Blockly theme to use. + * @type {!Blockly.Theme} + * @private + */ + this.theme_ = theme; + + /** + * A list of workspaces that are subscribed to this theme. + * @type {!Array.} + * @private + */ + this.subscribedWorkspaces_ = []; + + /** + * A map of subscribed UI components, keyed by component name. + * @type {!Object.>} + * @private + */ + this.componentDB_ = Object.create(null); +}; + +/** + * A Blockly UI component type. + * @typedef {{ + * element:!Element, + * propertyName:string + * }} + */ +Blockly.ThemeManager.Component; + +/** + * Get the workspace theme. + * @return {!Blockly.Theme} The workspace theme. + * @package + */ +Blockly.ThemeManager.prototype.getTheme = function() { + return this.theme_; +}; + +/** + * Set the workspace theme, and refresh the workspace and all components. + * @param {!Blockly.Theme} theme The workspace theme. + * @package + */ +Blockly.ThemeManager.prototype.setTheme = function(theme) { + if (this.theme_ === theme) { + // No change. + return; + } + + this.theme_ = theme; + + // Refresh all subscribed workspaces. + for (var i = 0, workspace; (workspace = this.subscribedWorkspaces_[i]); i++) { + workspace.refreshTheme(); + } + + // Refresh all registered Blockly UI components. + for (var i = 0, keys = Object.keys(this.componentDB_), + key; key = keys[i]; i++) { + for (var j = 0, component; (component = this.componentDB_[key][j]); j++) { + var element = component.element; + var propertyName = component.propertyName; + var style = this.theme_ && this.theme_.getComponentStyle(key); + element.style[propertyName] = style || ''; + } + } +}; + +/** + * Subscribe a workspace to changes to the selected theme. If a new theme is + * set, the workspace is called to refresh its blocks. + * @param {!Blockly.Workspace} workspace The workspace to subscribe. + * @package + */ +Blockly.ThemeManager.prototype.subscribeWorkspace = function(workspace) { + this.subscribedWorkspaces_.push(workspace); +}; + +/** + * Unsubscribe a workspace to changes to the selected theme. + * @param {!Blockly.Workspace} workspace The workspace to unsubscribe. + * @package + */ +Blockly.ThemeManager.prototype.unsubscribeWorkspace = function(workspace) { + var index = this.subscribedWorkspaces_.indexOf(workspace); + if (index < 0) { + throw Error('Cannot unsubscribe a workspace that hasn\'t been subscribed.'); + } + this.subscribedWorkspaces_.splice(index, 1); +}; + +/** + * Subscribe an element to changes to the selected theme. If a new theme is + * selected, the element's style is refreshed with the new theme's style. + * @param {!Element} element The element to subscribe. + * @param {string} componentName The name used to identify the component. This + * must be the same name used to configure the style in the Theme object. + * @param {string} propertyName The inline style property name to update. + * @package + */ +Blockly.ThemeManager.prototype.subscribe = function(element, componentName, + propertyName) { + if (!this.componentDB_[componentName]) { + this.componentDB_[componentName] = []; + } + + // Add the element to our component map. + this.componentDB_[componentName].push({ + element: element, + propertyName: propertyName + }); + + // Initialize the element with its corresponding theme style. + var style = this.theme_ && this.theme_.getComponentStyle(componentName); + element.style[propertyName] = style || ''; +}; + +/** + * Unsubscribe an element to changes to the selected theme. + * @param {Element} element The element to unsubscribe. + * @package + */ +Blockly.ThemeManager.prototype.unsubscribe = function(element) { + if (!element) { + return; + } + // Go through all component, and remove any references to this element. + var componentNames = Object.keys(this.componentDB_); + for (var c = 0, componentName; (componentName = componentNames[c]); c++) { + var elements = this.componentDB_[componentName]; + for (var i = elements.length - 1; i >= 0; i--) { + if (elements[i].element === element) { + elements.splice(i, 1); + } + } + // Clean up the component map entry if the list is empty. + if (!this.componentDB_[componentName].length) { + delete this.componentDB_[componentName]; + } + } +}; + +/** + * Dispose of this theme manager. + * @package + * @suppress {checkTypes} + */ +Blockly.ThemeManager.prototype.dispose = function() { + this.owner_ = null; + this.theme_ = null; + this.subscribedWorkspaces_ = null; + this.componentDB_ = null; +}; diff --git a/core/toolbox.js b/core/toolbox.js index aa679a49d..c5899212f 100644 --- a/core/toolbox.js +++ b/core/toolbox.js @@ -156,6 +156,9 @@ Blockly.Toolbox.prototype.init = function() { this.HtmlDiv.className = 'blocklyToolboxDiv blocklyNonSelectable'; this.HtmlDiv.setAttribute('dir', workspace.RTL ? 'RTL' : 'LTR'); svg.parentNode.insertBefore(this.HtmlDiv, svg); + var themeManager = workspace.getThemeManager(); + themeManager.subscribe(this.HtmlDiv, 'toolbox', 'background-color'); + themeManager.subscribe(this.HtmlDiv, 'toolboxText', 'color'); // Clicking on toolbox closes popups. Blockly.bindEventWithChecks_(this.HtmlDiv, 'mousedown', this, @@ -350,6 +353,7 @@ Blockly.Toolbox.prototype.onBlocklyAction = function(action) { Blockly.Toolbox.prototype.dispose = function() { this.flyout_.dispose(); this.tree_.dispose(); + this.workspace_.getThemeManager().unsubscribe(this.HtmlDiv); Blockly.utils.dom.removeNode(this.HtmlDiv); this.workspace_ = null; this.lastCategory_ = null; @@ -533,8 +537,9 @@ Blockly.Toolbox.prototype.setColour_ = function(colourValue, childOut, Blockly.Toolbox.prototype.setColourFromStyle_ = function( styleName, childOut, categoryName) { childOut.styleName = styleName; - if (styleName && Blockly.getTheme()) { - var style = Blockly.getTheme().getCategoryStyle(styleName); + var theme = this.workspace_.getTheme(); + if (styleName && theme) { + var style = theme.getCategoryStyle(styleName); if (style && style.colour) { this.setColour_(style.colour, childOut, categoryName); } else { @@ -566,6 +571,7 @@ Blockly.Toolbox.prototype.updateColourFromTheme_ = function(opt_tree) { /** * Updates the category colours and background colour of selected categories. + * @package */ Blockly.Toolbox.prototype.updateColourFromTheme = function() { var tree = this.tree_; diff --git a/core/workspace.js b/core/workspace.js index 9810de537..9f266d207 100644 --- a/core/workspace.js +++ b/core/workspace.js @@ -29,6 +29,7 @@ goog.provide('Blockly.Workspace'); goog.require('Blockly.Cursor'); goog.require('Blockly.MarkerCursor'); goog.require('Blockly.Events'); +goog.require('Blockly.ThemeManager'); goog.require('Blockly.Themes.Classic'); goog.require('Blockly.utils'); goog.require('Blockly.utils.math'); @@ -131,11 +132,16 @@ Blockly.Workspace = function(opt_options) { */ this.marker_ = new Blockly.MarkerCursor(); - // Set the default theme. This is for headless workspaces. This will get - // overwritten by the theme passed into the inject call for rendered workspaces. - if (!Blockly.getTheme()) { - Blockly.setTheme(Blockly.Themes.Classic); - } + /** + * Object in charge of storing and updating the workspace theme. + * @type {!Blockly.ThemeManager} + * @protected + */ + this.themeManager_ = this.options.parentWorkspace ? + this.options.parentWorkspace.getThemeManager() : + new Blockly.ThemeManager(this.options.theme || Blockly.Themes.Classic); + + this.themeManager_.subscribeWorkspace(this); }; /** @@ -198,16 +204,78 @@ Blockly.Workspace.prototype.getMarker = function() { return this.marker_; }; +/** + * Get the workspace theme object. + * @return {!Blockly.Theme} The workspace theme object. + */ +Blockly.Workspace.prototype.getTheme = function() { + return this.themeManager_.getTheme(); +}; + +/** + * Set the workspace theme object. + * If no theme is passed, default to the `Blockly.Themes.Classic` theme. + * @param {Blockly.Theme} theme The workspace theme object. + */ +Blockly.Workspace.prototype.setTheme = function(theme) { + if (!theme) { + theme = /** @type {!Blockly.Theme} */ (Blockly.Themes.Classic); + } + this.themeManager_.setTheme(theme); +}; + +/** + * Refresh all blocks on the workspace after a theme update. + * @package + */ +Blockly.Workspace.prototype.refreshTheme = function() { + // Update all blocks in workspace that have a style name. + this.updateBlockStyles_(this.getAllBlocks().filter( + function(block) { + return block.getStyleName() !== undefined; + } + )); + + var event = new Blockly.Events.Ui(null, 'theme', null, null); + event.workspaceId = this.id; + Blockly.Events.fire(event); +}; + +/** + * Updates all the blocks with new style. + * @param {!Array.} blocks List of blocks to update the style + * on. + * @private + */ +Blockly.Workspace.prototype.updateBlockStyles_ = function(blocks) { + for (var i = 0, block; block = blocks[i]; i++) { + var blockStyleName = block.getStyleName(); + block.setStyle(blockStyleName); + if (block.mutator) { + block.mutator.updateBlockStyle(blockStyleName); + } + } +}; /** * Dispose of this workspace. * Unlink from all DOM elements to prevent memory leaks. + * @suppress {checkTypes} */ Blockly.Workspace.prototype.dispose = function() { this.listeners_.length = 0; this.clear(); // Remove from workspace database. delete Blockly.Workspace.WorkspaceDB_[this.id]; + + if (this.themeManager_) { + this.themeManager_.unsubscribeWorkspace(this); + this.themeManager_.unsubscribe(this.svgBackground_); + if (!this.options.parentWorkspace) { + this.themeManager_.dispose(); + this.themeManager_ = null; + } + } }; /** @@ -810,3 +878,12 @@ Blockly.Workspace.getAll = function() { } return workspaces; }; + +/** + * Get the theme manager for this workspace. + * @return {!Blockly.ThemeManager} The theme manager for this workspace. + * @package + */ +Blockly.Workspace.prototype.getThemeManager = function() { + return this.themeManager_; +}; diff --git a/core/workspace_svg.js b/core/workspace_svg.js index fa84b5105..f57caa5f3 100644 --- a/core/workspace_svg.js +++ b/core/workspace_svg.js @@ -97,7 +97,7 @@ Blockly.WorkspaceSvg = function(options, /** * Object in charge of loading, storing, and playing audio for a workspace. - * @type {Blockly.WorkspaceAudio} + * @type {!Blockly.WorkspaceAudio} * @private */ this.audioManager_ = new Blockly.WorkspaceAudio(options.parentWorkspace); @@ -636,6 +636,8 @@ Blockly.WorkspaceSvg.prototype.createDom = function(opt_backgroundClass) { if (opt_backgroundClass == 'blocklyMainBackground' && this.grid_) { this.svgBackground_.style.fill = 'url(#' + this.grid_.getPatternId() + ')'; + } else { + this.themeManager_.subscribe(this.svgBackground_, 'workspace', 'fill'); } } /** @type {SVGElement} */ @@ -686,7 +688,6 @@ Blockly.WorkspaceSvg.prototype.dispose = function() { if (this.currentGesture_) { this.currentGesture_.cancel(); } - Blockly.WorkspaceSvg.superClass_.dispose.call(this); if (this.svgGroup_) { Blockly.utils.dom.removeNode(this.svgGroup_); this.svgGroup_ = null; @@ -732,6 +733,11 @@ Blockly.WorkspaceSvg.prototype.dispose = function() { this.grid_ = null; } + if (this.themeManager_) { + this.themeManager_.unsubscribe(this.svgBackground_); + } + Blockly.WorkspaceSvg.superClass_.dispose.call(this); + this.connectionDBList = null; this.toolboxCategoryCallbacks_ = null; @@ -2508,7 +2514,7 @@ Blockly.WorkspaceSvg.prototype.cancelCurrentGesture = function() { /** * Get the audio manager for this workspace. - * @return {Blockly.WorkspaceAudio} The audio manager for this workspace. + * @return {!Blockly.WorkspaceAudio} The audio manager for this workspace. */ Blockly.WorkspaceSvg.prototype.getAudioManager = function() { return this.audioManager_; @@ -2522,3 +2528,18 @@ Blockly.WorkspaceSvg.prototype.getAudioManager = function() { Blockly.WorkspaceSvg.prototype.getGrid = function() { return this.grid_; }; + +/** + * Refresh all blocks on the workspace, toolbox and flyout after a theme update. + * @package + * @override + */ +Blockly.WorkspaceSvg.prototype.refreshTheme = function() { + Blockly.WorkspaceSvg.superClass_.refreshTheme.call(this); + + // Update current toolbox selection. + this.refreshToolboxSelection(); + if (this.toolbox_) { + this.toolbox_.updateColourFromTheme(); + } +}; diff --git a/demos/custom-fields/turtle/index.html b/demos/custom-fields/turtle/index.html index 67d3354a1..682eb8aa8 100644 --- a/demos/custom-fields/turtle/index.html +++ b/demos/custom-fields/turtle/index.html @@ -90,7 +90,7 @@ function setRandomStyle() { var blocks = workspace.getAllBlocks(); - var styles = Object.keys(Blockly.getTheme().getAllBlockStyles()); + var styles = Object.keys(workspace.getTheme().getAllBlockStyles()); styles.splice(styles.indexOf(blocks[0].getStyleName()), 1); var style = styles[Math.floor(Math.random() * styles.length)]; for(var i = 0, block; block = blocks[i]; i++) { diff --git a/tests/jsunit/block_test.js b/tests/jsunit/block_test.js index b7c9c6cf6..b03f5d036 100644 --- a/tests/jsunit/block_test.js +++ b/tests/jsunit/block_test.js @@ -267,7 +267,7 @@ function test_set_style() { }; } }; - mockControl_ = setUpMockMethod(Blockly, 'getTheme', null, [styleStub]); + mockControl_ = setUpMockMethod(workspace, 'getTheme', null, [styleStub]); var blockA = workspace.newBlock('row_block'); blockA.setStyle('styleOne'); @@ -285,7 +285,7 @@ function test_set_style_throw_exception() { return null; } }; - mockControl_ = setUpMockMethod(Blockly, 'getTheme', null, [styleStub]); + mockControl_ = setUpMockMethod(workspace, 'getTheme', null, [styleStub]); var blockA = workspace.newBlock('row_block'); try { blockA.setStyle('styleOne'); diff --git a/tests/jsunit/theme_test.js b/tests/jsunit/theme_test.js index 9f0edcef6..2b9a987e1 100644 --- a/tests/jsunit/theme_test.js +++ b/tests/jsunit/theme_test.js @@ -131,10 +131,10 @@ function test_setTheme() { var mockControl_ = setUpMockMethod(Blockly, 'getMainWorkspace', null, [workspace]); - Blockly.setTheme(blockStyles); + workspace.setTheme(blockStyles); //Checks that the theme was set correctly on Blockly namespace - stringifyAndCompare(Blockly.getTheme(), blockStyles); + stringifyAndCompare(workspace.getTheme(), blockStyles); //Checks that the setTheme function was called on the block assertEquals(blockA.getStyleName(), 'styleTwo'); diff --git a/tests/mocha/procedures_test.js b/tests/mocha/procedures_test.js index b6fc418cb..06121bf8d 100644 --- a/tests/mocha/procedures_test.js +++ b/tests/mocha/procedures_test.js @@ -23,12 +23,12 @@ goog.require('Blockly.Msg'); suite('Procedures', function() { setup(function() { - Blockly.setTheme(new Blockly.Theme({ + this.workspace = new Blockly.Workspace(); + this.workspace.setTheme(new Blockly.Theme({ "procedure_blocks": { "colourPrimary": "290" } })); - this.workspace = new Blockly.Workspace(); this.callForAllTypes = function(func, startName) { var typesArray = [ @@ -252,7 +252,9 @@ suite('Procedures', function() { }); test('Multiple Workspaces', function() { this.callForAllTypes(function() { - var workspace = new Blockly.Workspace(); + var workspace = new Blockly.Workspace({ + theme: this.workspace.getTheme() + }); var def2 = new Blockly.Block(workspace, this.defType); def2.setFieldValue('name', 'NAME'); var caller2 = new Blockly.Block(workspace, this.callType); @@ -294,7 +296,9 @@ suite('Procedures', function() { }); test('Multiple Workspaces', function() { this.callForAllTypes(function() { - var workspace = new Blockly.Workspace(); + var workspace = new Blockly.Workspace({ + theme: this.workspace.getTheme() + }); var def2 = new Blockly.Block(workspace, this.defType); def2.setFieldValue('name', 'NAME'); var caller2 = new Blockly.Block(workspace, this.callType); @@ -321,8 +325,10 @@ suite('Procedures', function() { }); suite('Composition', function() { suite('Statements', function() { - function setStatementValue(defBlock, value) { - var mutatorWorkspace = new Blockly.Workspace(); + function setStatementValue(mainWorkspace, defBlock, value) { + var mutatorWorkspace = new Blockly.Workspace({ + parentWorkspace: mainWorkspace + }); defBlock.decompose(mutatorWorkspace); var containerBlock = mutatorWorkspace.getTopBlocks()[0]; var statementField = containerBlock.getField('STATEMENTS'); @@ -331,12 +337,12 @@ suite('Procedures', function() { } test('Has Statements', function() { var defBlock = new Blockly.Block(this.workspace, 'procedures_defreturn'); - setStatementValue(defBlock, true); + setStatementValue(this.workspace, defBlock, true); chai.assert.isTrue(defBlock.hasStatements_); }); test('Has No Statements', function() { var defBlock = new Blockly.Block(this.workspace, 'procedures_defreturn'); - setStatementValue(defBlock, false); + setStatementValue(this.workspace, defBlock, false); chai.assert.isFalse(defBlock.hasStatements_); }); test('Saving Statements', function() { @@ -348,9 +354,9 @@ suite('Procedures', function() { '' ); var defBlock = Blockly.Xml.domToBlock(blockXml, this.workspace); - setStatementValue(defBlock, false); + setStatementValue(this.workspace, defBlock, false); chai.assert.isNull(defBlock.getInput('STACK')); - setStatementValue(defBlock, true); + setStatementValue(this.workspace, defBlock, true); chai.assert.isNotNull(defBlock.getInput('STACK')); var statementBlocks = defBlock.getChildren(); chai.assert.equal(statementBlocks.length, 1); @@ -361,7 +367,9 @@ suite('Procedures', function() { }); suite('Untyped Arguments', function() { function createMutator(argArray) { - this.mutatorWorkspace = new Blockly.Workspace(); + this.mutatorWorkspace = new Blockly.Workspace({ + parentWorkspace: this.workspace + }); this.containerBlock = this.defBlock.decompose(this.mutatorWorkspace); this.connection = this.containerBlock.getInput('STACK').connection; for (var i = 0; i < argArray.length; i++) { @@ -496,7 +504,9 @@ suite('Procedures', function() { suite('Statements', function() { test('Has Statement Input', function() { this.callForAllTypes(function() { - var mutatorWorkspace = new Blockly.Workspace(); + var mutatorWorkspace = new Blockly.Workspace({ + parentWorkspace: this.workspace + }); this.defBlock.decompose(mutatorWorkspace); var statementInput = mutatorWorkspace.getTopBlocks()[0] .getInput('STATEMENT_INPUT'); @@ -510,7 +520,9 @@ suite('Procedures', function() { test('Has Statements', function() { var defBlock = new Blockly.Block(this.workspace, 'procedures_defreturn'); defBlock.hasStatements_ = true; - var mutatorWorkspace = new Blockly.Workspace(); + var mutatorWorkspace = new Blockly.Workspace({ + parentWorkspace: this.workspace + }); defBlock.decompose(mutatorWorkspace); var statementValue = mutatorWorkspace.getTopBlocks()[0] .getField('STATEMENTS').getValueBoolean(); @@ -519,7 +531,9 @@ suite('Procedures', function() { test('No Has Statements', function() { var defBlock = new Blockly.Block(this.workspace, 'procedures_defreturn'); defBlock.hasStatements_ = false; - var mutatorWorkspace = new Blockly.Workspace(); + var mutatorWorkspace = new Blockly.Workspace({ + parentWorkspace: this.workspace + }); defBlock.decompose(mutatorWorkspace); var statementValue = mutatorWorkspace.getTopBlocks()[0] .getField('STATEMENTS').getValueBoolean(); @@ -529,7 +543,9 @@ suite('Procedures', function() { suite('Untyped Arguments', function() { function assertArguments(argumentsArray) { this.defBlock.arguments_ = argumentsArray; - var mutatorWorkspace = new Blockly.Workspace(); + var mutatorWorkspace = new Blockly.Workspace({ + parentWorkspace: this.workspace + }); this.defBlock.decompose(mutatorWorkspace); var argBlocks = mutatorWorkspace.getBlocksByType('procedures_mutatorarg'); chai.assert.equal(argBlocks.length, argumentsArray.length); diff --git a/tests/mocha/theme_test.js b/tests/mocha/theme_test.js index e27675a24..94a03fdb4 100644 --- a/tests/mocha/theme_test.js +++ b/tests/mocha/theme_test.js @@ -137,10 +137,10 @@ suite('Theme', function() { var stub = sinon.stub(Blockly, "getMainWorkspace").returns(workspace); - Blockly.setTheme(blockStyles); + workspace.setTheme(blockStyles); // Checks that the theme was set correctly on Blockly namespace - stringifyAndCompare(Blockly.getTheme(), blockStyles); + stringifyAndCompare(workspace.getTheme(), blockStyles); // Checks that the setTheme function was called on the block assertEquals(blockA.getStyleName(), 'styleTwo'); diff --git a/tests/mocha/trashcan_test.js b/tests/mocha/trashcan_test.js index cb21f5377..cb1234bee 100644 --- a/tests/mocha/trashcan_test.js +++ b/tests/mocha/trashcan_test.js @@ -19,6 +19,7 @@ */ suite("Trashcan", function() { + var themeManager = new Blockly.ThemeManager(Blockly.Themes.Classic); var workspace = { addChangeListener: function(func) { this.listener = func; @@ -26,6 +27,9 @@ suite("Trashcan", function() { triggerListener: function(event) { this.listener(event); }, + getThemeManager: function() { + return themeManager; + }, options: { maxTrashcanContents: Infinity } diff --git a/tests/mocha/xml_procedures_test.js b/tests/mocha/xml_procedures_test.js index c1060bd58..caa085435 100644 --- a/tests/mocha/xml_procedures_test.js +++ b/tests/mocha/xml_procedures_test.js @@ -24,12 +24,12 @@ goog.require('Blockly.Msg'); suite('Procedures XML', function() { suite('Deserialization', function() { setup(function() { - Blockly.setTheme(new Blockly.Theme({ + this.workspace = new Blockly.Workspace(); + this.workspace.setTheme(new Blockly.Theme({ "procedure_blocks": { "colourPrimary": "290" } })); - this.workspace = new Blockly.Workspace(); this.callForAllTypes = function(func) { var typesArray = [ diff --git a/tests/playground.html b/tests/playground.html index ac6b31b99..b41fd8f87 100644 --- a/tests/playground.html +++ b/tests/playground.html @@ -185,7 +185,7 @@ function addToolboxButtonCallbacks() { }; var setRandomStyle = function(button) { var blocks = button.workspace_.getAllBlocks(); - var styles = Object.keys(Blockly.getTheme().getAllBlockStyles()); + var styles = Object.keys(workspace.getTheme().getAllBlockStyles()); styles.splice(styles.indexOf(blocks[0].getStyleName()), 1); var style = styles[Math.floor(Math.random() * styles.length)]; for(var i = 0, block; block = blocks[i]; i++) { @@ -314,11 +314,13 @@ function addRenderDebugOptionsCheckboxes() { function changeTheme() { var theme = document.getElementById('themeChanger'); if (theme.value === "modern") { - Blockly.setTheme(Blockly.Themes.Modern); + Blockly.getMainWorkspace().setTheme(Blockly.Themes.Modern); + } else if (theme.value === "dark") { + Blockly.getMainWorkspace().setTheme(Blockly.Themes.Dark); } else if (theme.value === "high_contrast") { - Blockly.setTheme(Blockly.Themes.HighContrast); + Blockly.getMainWorkspace().setTheme(Blockly.Themes.HighContrast); } else { - Blockly.setTheme(Blockly.Themes.Classic); + Blockly.getMainWorkspace().setTheme(Blockly.Themes.Classic); } } @@ -564,6 +566,7 @@ var spaghettiXml = [