diff --git a/packages/block/src/blocks/procedures.ts b/packages/block/src/blocks/procedures.ts index 36a159e26..52155ee80 100644 --- a/packages/block/src/blocks/procedures.ts +++ b/packages/block/src/blocks/procedures.ts @@ -1119,6 +1119,12 @@ Blockly.Blocks['procedures_definition'] = { }); this.hat = Constants.SHAPE_BOWLER_HAT; }, + setStyle: function(blockStyleName: string) { + // equivalent to super.setStyle() + const proto: Blockly.Block = Object.getPrototypeOf(this); + proto.setStyle.call(this, blockStyleName); + this.hat = Constants.SHAPE_BOWLER_HAT; + }, /** * The method called during disposal. */ diff --git a/packages/block/src/colours.ts b/packages/block/src/colours.ts deleted file mode 100644 index 507947ba9..000000000 --- a/packages/block/src/colours.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @license - * Visual Blocks Editor - * - * Copyright 2016 Massachusetts Institute of Technology - * All rights reserved. - * - * 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. - */ - -import * as Blockly from 'blockly/core'; - -export const Colours: Record | string | number> = { - // SVG colours: these must be specificed in #RRGGBB style - // To add an opacity, this must be specified as a separate property (for SVG fill-opacity) - motion: { - primary: '#4C97FF', - secondary: '#4280D7', - tertiary: '#3373CC', - quaternary: '#3373CC' - }, - looks: { - primary: '#9966FF', - secondary: '#855CD6', - tertiary: '#774DCB', - quaternary: '#774DCB' - }, - sounds: { - primary: '#CF63CF', - secondary: '#C94FC9', - tertiary: '#BD42BD', - quaternary: '#BD42BD' - }, - control: { - primary: '#FFAB19', - secondary: '#EC9C13', - tertiary: '#CF8B17', - quaternary: '#CF8B17' - }, - event: { - primary: '#FFBF00', - secondary: '#E6AC00', - tertiary: '#CC9900', - quaternary: '#CC9900' - }, - sensing: { - primary: '#5CB1D6', - secondary: '#47A8D1', - tertiary: '#2E8EB8', - quaternary: '#2E8EB8' - }, - pen: { - primary: '#0fBD8C', - secondary: '#0DA57A', - tertiary: '#0B8E69', - quaternary: '#0B8E69' - }, - operators: { - primary: '#59C059', - secondary: '#46B946', - tertiary: '#389438', - quaternary: '#389438' - }, - data: { - primary: '#FF8C1A', - secondary: '#FF8000', - tertiary: '#DB6E00', - quaternary: '#DB6E00' - }, - // This is not a new category, but rather for differentiation - // between lists and scalar variables. - data_lists: { - primary: '#FF661A', - secondary: '#FF5500', - tertiary: '#E64D00', - quaternary: '#E64D00' - }, - more: { - primary: '#FF6680', - secondary: '#FF4D6A', - tertiary: '#FF3355', - quaternary: '#FF3355' - }, - argument: { - primary: '#F47983', - secondary: '#F15764', - tertiary: '#EE3645', - quaternary: '#EE3645' - }, - text: '#FFFFFF', - workspace: '#F9F9F9', - toolboxHover: '#4C97FF', - toolboxSelected: '#e9eef2', - toolboxText: '#575E75', - toolbox: '#FFFFFF', - flyout: '#F9F9F9', - scrollbar: '#CECDCE', - scrollbarHover: '#CECDCE', - textField: '#FFFFFF', - textFieldText: '#575E75', - insertionMarker: '#000000', - insertionMarkerOpacity: 0.2, - dragShadowOpacity: 0.3, - stackGlow: '#FFF200', - stackGlowSize: 4, - stackGlowOpacity: 1, - replacementGlow: '#FFFFFF', - replacementGlowSize: 2, - replacementGlowOpacity: 1, - colourPickerStroke: '#FFFFFF', - // CSS colours: support RGBA - fieldShadow: 'rgba(0,0,0,0.1)', - dropDownShadow: 'rgba(0, 0, 0, .3)', - numPadBackground: '#547AB2', - numPadBorder: '#435F91', - numPadActiveBackground: '#435F91', - numPadText: 'white', // Do not use hex here, it cannot be inlined with data-uri SVG - valueReportBackground: '#FFFFFF', - valueReportBorder: '#AAAAAA', - menuHover: 'rgba(0, 0, 0, 0.2)' -}; - -const blockStyles: {[key: string]: Partial} = { - motion: { - colourPrimary: '#4C97FF', - colourSecondary: '#4280D7', - colourTertiary: '#3373CC' - }, - looks: { - colourPrimary: '#9966FF', - colourSecondary: '#855CD6', - colourTertiary: '#774DCB' - }, - sounds: { - colourPrimary: '#CF63CF', - colourSecondary: '#C94FC9', - colourTertiary: '#BD42BD' - }, - control: { - colourPrimary: '#FFAB19', - colourSecondary: '#EC9C13', - colourTertiary: '#CF8B17' - }, - event: { - colourPrimary: '#FFBF00', - colourSecondary: '#E6AC00', - colourTertiary: '#CC9900' - }, - sensing: { - colourPrimary: '#5CB1D6', - colourSecondary: '#47A8D1', - colourTertiary: '#2E8EB8' - }, - pen: { - colourPrimary: '#0fBD8C', - colourSecondary: '#0DA57A', - colourTertiary: '#0B8E69' - }, - operators: { - colourPrimary: '#59C059', - colourSecondary: '#46B946', - colourTertiary: '#389438' - }, - data: { - colourPrimary: '#FF8C1A', - colourSecondary: '#FF8000', - colourTertiary: '#DB6E00' - }, - data_lists: { - colourPrimary: '#FF661A', - colourSecondary: '#FF5500', - colourTertiary: '#E64D00' - }, - more: { - colourPrimary: '#FF6680', - colourSecondary: '#FF4D6A', - colourTertiary: '#FF3355' - }, - argument: { - colourPrimary: '#F47983', - colourSecondary: '#F15764', - colourTertiary: '#EE3645' - }, - textField: { - colourPrimary: '#FFFFFF' - } -}; - -/** - * Build category styles from existing block styles. - * @returns The category styles. - */ -function buildCategoryStyles(): {[key: string]: Blockly.Theme.CategoryStyle} { - const keys = [ - 'motion', 'looks', 'sounds', 'control', 'event', - 'sensing', 'operators', 'data', 'more' - ]; - const categoryStyles: {[key: string]: Blockly.Theme.CategoryStyle} = {}; - for (const key of keys) { - if (key in blockStyles && blockStyles[key].colourPrimary) { - categoryStyles[key] = { - colour: blockStyles[key].colourPrimary - }; - } - } - return categoryStyles; -} - -/** - * Override the colours in Colours with new values basded on the - * given dictionary. - * @param colours Dictionary of colour properties and new values. - * @package - */ -export const overrideColours = function(colours?: typeof Colours) { - // Colour overrides provided by the injection - if (colours) { - for (const colourProperty in colours) { - if (Object.prototype.hasOwnProperty.call(colours, colourProperty) && - Object.prototype.hasOwnProperty.call(Colours, colourProperty)) { - // If a property is in both colours option and Colours, - // set the Colours value to the override. - // Override Blockly category color object properties with those - // provided. - const colourPropertyValue = colours[colourProperty]; - if (typeof colourPropertyValue === 'object') { - for (const colourSequence in colourPropertyValue) { - if (Object.prototype.hasOwnProperty.call(colourPropertyValue, colourSequence) && - typeof Colours[colourProperty] === 'object' && - Object.prototype.hasOwnProperty.call(Colours[colourProperty], colourSequence)) { - Colours[colourProperty][colourSequence] = - colourPropertyValue[colourSequence]; - } - } - } else { - Colours[colourProperty] = colourPropertyValue; - } - } - } - } -}; - -/** - * Create the scratch theme. - * @returns The newly created theme. - */ -export function createTheme(): Blockly.Theme { - return Blockly.Theme.defineTheme('scratch', { - name: 'scratch', - blockStyles, - categoryStyles: buildCategoryStyles() - }); -} diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 7da7a9d6c..ffa6dac25 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -7,7 +7,7 @@ import * as Blockly from 'blockly/core'; import * as Constants from './constants'; -import {createTheme} from './colours'; +import {injectCssVariables, Scratch} from './theme'; import {registerScratchContextMenu} from './contextmenu_items'; import {registerFieldAngle} from './fields/angle'; import {registerFieldButton} from './fields/button'; @@ -20,6 +20,7 @@ import {registerFieldVerticalSeparator} from './fields/vertical_separator'; import {flyoutCategory as variableCategory} from './data_category'; import {flyoutCategory as procedureCategory} from './procedures_category'; import {isProcedureCallBlock, isProcedurePrototypeBlock} from './blocks/procedures'; +import {ZoomControls} from './zoom_controls'; import styles from './styles/blockly.css'; import commentStyles from './styles/comment.css'; @@ -85,7 +86,7 @@ export function inject(container: Element | string, options?: Blockly.BlocklyOpt registerScratchContextMenu(); // Register styles. - + injectCssVariables(); Blockly.Css.register(styles); Blockly.Css.register(commentStyles); @@ -146,10 +147,12 @@ export function inject(container: Element | string, options?: Blockly.BlocklyOpt export function injectWorkspace(container: Element | string, options?: Blockly.BlocklyOptions) { const defaultOptions: Blockly.BlocklyOptions = { renderer: 'scratch', - theme: createTheme() + theme: Scratch }; options = Object.assign(defaultOptions, options); - return Blockly.inject(container, options); + const workspace = Blockly.inject(container, options); + + return workspace; } /** @@ -176,13 +179,21 @@ export function loadWorkspace( Blockly.serialization.workspaces.load(state, workspace, {recordUndo}); } -export {reportValue} from './report_value'; -export const setLocale = Blockly.setLocale; -export * as callbackRegistry from './callback_registry'; - // Monkey-patches Blockly.Scrollbar.scrollbarThickness = Blockly.Touch.TOUCH_ENABLED ? 14 : 11; Blockly.FlyoutButton.TEXT_MARGIN_X = 40; Blockly.FlyoutButton.TEXT_MARGIN_Y = 10; Blockly.comments.CommentView.defaultCommentSize = new Blockly.utils.Size(200, 200); Blockly.ToolboxCategory.nestedPadding = 6; + +Blockly.WorkspaceSvg.prototype.addZoomControls = function() { + this.zoomControls_ = new ZoomControls(this) as unknown as Blockly.ZoomControls; + const svgZoomControls = this.zoomControls_.createDom(); + this.svgGroup_.appendChild(svgZoomControls); +}; + +export {reportValue} from './report_value'; +export const setLocale = Blockly.setLocale; +export * as callbackRegistry from './callback_registry'; +export * as Theme from './theme'; + diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index 76d960ff2..2b6c54f91 100644 --- a/packages/block/src/renderer/constants.ts +++ b/packages/block/src/renderer/constants.ts @@ -5,6 +5,7 @@ */ import * as Blockly from 'blockly/core'; +import {Colours} from '../theme'; /** * An object that provides constants for rendering blocks in Scratch mode. @@ -54,7 +55,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `}`, ``, `${selector} .blocklyFlyoutButtonBackground {`, - `stroke: #c6c6c6;`, + `stroke: var(--clipcc-block-flyoutBorder);`, `}`, ``, `${selector} .blocklyFlyoutButtonShadow {`, @@ -67,13 +68,28 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `}`, ``, `${selector} .blocklyFlyoutButton .blocklyText {`, - `fill: #575E75;`, + `fill: var(--clipcc-block-toolboxText, ${Colours.textFieldText});`, `font-weight: 500;`, `}`, ``, `${selector} .blocklyCommentText.blocklyText {`, `font-weight: 400;`, - `color: #575e75;`, // @TODO: Use CSS variable. (same as --clipcc-text-primary) + `color: var(--clipcc-block-textFieldText, ${Colours.textFieldText});`, + `}`, + ``, + `${selector} .blocklyHighlightedConnectionPath {`, + `stroke: transparent;`, + `}`, + ``, + // Boolean connection highlight override + `${selector} .blocklyOutlinePath ~ .blocklyHighlightedConnectionPath,`, + `${selector} .blocklyHighlightedConnectionPath:has(~ .blocklyOutlinePath) {`, + `stroke: var(--clipcc-block-replacementGlow, ${Colours.replacementGlow});`, + `}`, + `${selector} .blocklyFlyoutLabelText {`, + `font-family: "Helvetica Neue", Helvetica, sans-serif;`, + `font-size: 14pt;`, + `font-weight: bold;`, `}` ]; return css.concat(flyoutButtonStyle); diff --git a/packages/block/src/report_value.ts b/packages/block/src/report_value.ts index b9a44c3ac..2055d1c30 100644 --- a/packages/block/src/report_value.ts +++ b/packages/block/src/report_value.ts @@ -6,7 +6,7 @@ */ import * as Blockly from 'blockly/core'; -import {Colours} from './colours'; +import {Colours} from './theme'; import styles from './styles/report_value.css'; /** diff --git a/packages/block/src/styles/blockly.css b/packages/block/src/styles/blockly.css index 5a8443c53..38fdc7e13 100644 --- a/packages/block/src/styles/blockly.css +++ b/packages/block/src/styles/blockly.css @@ -22,10 +22,49 @@ box-shadow: 0 8px 8px 0 hsla(0, 0%, 0%, 0.15); } -.blocklyWidgetDiv .blocklyMenuItem:hover { - background-color: #d6e9f8; +.blocklyWidgetDiv .blocklyMenuItemHighlight { + background-color: var(--clipcc-block-menuHover); } -.blocklyMenuItemDisabled:hover { - background-color: #fff; +.blocklyWidgetDiv .blocklyMenuItemDisabled .blocklyMenuItemHighlight { + background: none; +} + +.blocklyDragging>.blocklyPath { + fill-opacity: 1; + stroke-opacity: 1; +} + +.blocklyBlockDragSurface :not(.blocklyDragging)>.blocklyDragging { + filter: drop-shadow(0 0px 6px hsla(0, 0%, 0%, 0.6)); +} + +.blocklyBlockDragSurface .blocklyComment { + filter: drop-shadow(0 0px 6px hsla(0, 0%, 0%, 0.6)); +} + +.blocklyDisabledPattern>.blocklyPath { + fill: revert-layer; + fill-opacity: .5; + stroke-opacity: .5; +} + +.blocklyDropDownDiv { + border-radius: var(--clipcc-block-dropdownRadius); + outline: none; +} + +.blocklyZoom>image, +.blocklyZoom>svg>image { + opacity: 0.7; +} + +.blocklyZoom>image:hover, +.blocklyZoom>svg>image:hover { + opacity: 0.9; +} + +.blocklyZoom>image:active, +.blocklyZoom>svg>image:active { + opacity: 1; } diff --git a/packages/block/src/styles/checkbox.css b/packages/block/src/styles/checkbox.css index 1b847441c..a7b63530c 100644 --- a/packages/block/src/styles/checkbox.css +++ b/packages/block/src/styles/checkbox.css @@ -4,8 +4,8 @@ } .checked > .blocklyFlyoutCheckbox { - fill: #4C97FF; - stroke: #3373CC; + fill: var(--clipcc-block-toolboxHover); + stroke: var(--clipcc-block-toolboxHoverStroke); } .blocklyFlyoutCheckboxPath { diff --git a/packages/block/src/styles/colour_slider.css b/packages/block/src/styles/colour_slider.css index c7ca0b924..30ac4df6e 100644 --- a/packages/block/src/styles/colour_slider.css +++ b/packages/block/src/styles/colour_slider.css @@ -1,7 +1,7 @@ .scratchColourPickerLabel { font-family: "Helvetica Neue", Helvetica, sans-serif; font-size: 0.65rem; - color: var(--colour-toolboxText); + color: var(--clipcc-block-toolboxText); margin: 8px; } diff --git a/packages/block/src/styles/comment.css b/packages/block/src/styles/comment.css index 22b43ebff..4ddcf7620 100644 --- a/packages/block/src/styles/comment.css +++ b/packages/block/src/styles/comment.css @@ -1,4 +1,5 @@ -.blocklyWorkspace, .blocklyBlockDragSurface { +.blocklyWorkspace, +.blocklyBlockDragSurface { --commentFillColour: #fef49c; --commentTopBarColour: #e4db8c; --commentBorderColour: #bca903; @@ -8,41 +9,54 @@ transform: rotate(-180deg); } +.blocklyCommentText::placeholder { + font-style: italic; +} + .blocklyComment .blocklyCommentTopbarBackground { height: 32px; - fill: var(--commentTopBarColour); + fill: none; } -.blocklyCollapsed .blocklyCommentTopbarBackground { - outline: 1px solid var(--commentBorderColour); +.blocklyCollapsed .blocklyCommentTopbarBackground, +.blocklySelected.blocklyCollapsed .blocklyCommentTopbarBackground { + rx: 4px; + ry: 4px; + stroke: var(--commentBorderColour); + stroke-width: 1px; + fill: var(--commentTopBarColour); } -.blocklyComment { - border: 1px solid var(--commentBorderColour); - border-radius: 4px; +.blocklyDragging.blocklyComment { + filter: drop-shadow(0 0px 6px hsla(0, 0%, 0%, 0.6)); } .blocklyCommentHighlight { + rx: 4px; + ry: 4px; stroke: var(--commentBorderColour); - stroke-width: 2px; + stroke-width: 1px; + fill: var(--commentTopBarColour); } .blocklySelected .blocklyCommentHighlight { stroke: var(--commentBorderColour); - stroke-width: 2px; + stroke-width: 1px; } .blocklyCollapsed .blocklyCommentHighlight { - stroke-width: 0; - stroke: var(--commentBorderColour); + stroke: none; + fill: none; } -.blocklySelected.blocklyCollapsed .blocklyCommentTopbarBackground { - stroke-width: 0; +.blocklyCollapsed.blocklySelected .blocklyCommentHighlight { + stroke: none; + fill: none; } .blocklyComment .blocklyTextarea { border: none; + border-radius: 0px 0px 4px 4px; padding: 12px; } @@ -62,3 +76,11 @@ height: 32px; transform-origin: 16px 16px; } + +.blocklyCommentForeignObject { + padding: 1px; +} + +.blocklyCommentForeignObject>body { + background: none; +} diff --git a/packages/block/src/styles/flyout.css b/packages/block/src/styles/flyout.css new file mode 100644 index 000000000..3649af705 --- /dev/null +++ b/packages/block/src/styles/flyout.css @@ -0,0 +1,11 @@ +.blocklyFlyout { + border-right: 1px solid var(--clipcc-block-flyoutBorder); + box-sizing: content-box; + border-radius: 0 8px 8px 0; +} + +[dir="rtl"] .blocklyFlyout { + border-right: none; + border-left: 1px solid var(--clipcc-block-flyoutBorder); + border-radius: 8px 0 0 8px; +} diff --git a/packages/block/src/styles/note.css b/packages/block/src/styles/note.css index aebf5858b..b80118654 100644 --- a/packages/block/src/styles/note.css +++ b/packages/block/src/styles/note.css @@ -1,6 +1,6 @@ .scratchNotePickerKeyLabel { font-family: "Helvetica Neue", Helvetica, sans-serif; font-size: 0.75rem; - fill: var(--colour-textFieldText); + fill: var(--clipcc-block-textFieldText); pointer-events: none; } diff --git a/packages/block/src/styles/toolbox.css b/packages/block/src/styles/toolbox.css index 33a12f0f0..84420d766 100644 --- a/packages/block/src/styles/toolbox.css +++ b/packages/block/src/styles/toolbox.css @@ -2,10 +2,13 @@ width: 100px; overflow-y: auto; scrollbar-width: none; + border-right: 1px solid var(--clipcc-block-toolboxBorder); + box-sizing: content-box; } .clipccToolboxCategoryContainer { font-size: 0.8rem; + outline: none; } .clipccToolboxCategory { diff --git a/packages/block/src/theme.ts b/packages/block/src/theme.ts new file mode 100644 index 000000000..688579ce7 --- /dev/null +++ b/packages/block/src/theme.ts @@ -0,0 +1,262 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2016 Massachusetts Institute of Technology + * All rights reserved. + * + * 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. + */ + +import * as Blockly from 'blockly/core'; + +export const Colours: Record | string | number> = { + text: '#FFFFFF', + workspace: '#F9F9F9', + toolboxHover: '#4C97FF', + toolboxHoverStroke: '#4280D7', + toolboxSelected: '#3373CC', + toolboxText: '#575E75', + toolbox: '#FFFFFF', + flyout: '#F9F9F9', + scrollbar: '#CECDCE', + scrollbarHover: '#CECDCE', + textField: '#FFFFFF', + textFieldText: '#575E75', + insertionMarker: '#000000', + insertionMarkerOpacity: 0.2, + dragShadowOpacity: 0.3, + stackGlow: '#FFF200', + stackGlowSize: 4, + stackGlowOpacity: 1, + replacementGlow: '#FFFFFF', + replacementGlowSize: 2, + replacementGlowOpacity: 1, + colourPickerStroke: '#FFFFFF', + // CSS colours: support RGBA + flyoutBorder: 'hsla(0, 0%, 0%, 0.15)', + toolboxBorder: 'hsla(0, 0%, 0%, 0.15)', + fieldShadow: 'rgba(0,0,0,0.1)', + dropDownShadow: 'rgba(0, 0, 0, .3)', + numPadBackground: '#547AB2', + numPadBorder: '#435F91', + numPadActiveBackground: '#435F91', + numPadText: 'white', // Do not use hex here, it cannot be inlined with data-uri SVG + valueReportBackground: '#FFFFFF', + valueReportBorder: '#AAAAAA', + menuHover: 'rgba(76, 151, 255, 0.2)', + dropdownRadius: '.2em' +}; + +/** + * Inject CSS variables for clipcc-block colors. + */ +export function injectCssVariables(): void { + let root = document.querySelector('#clipcc-block-theme'); + if (!root) { + root = document.createElement('style'); + root.id = 'clipcc-block-theme'; + document.head.appendChild(root); + } + + const cssVars: string[] = []; + cssVars.push(':root {'); + for (const prop in Colours) { + if (!Object.prototype.hasOwnProperty.call(Colours, prop)) { + continue; + } + cssVars.push(` --clipcc-block-${prop}: ${Colours[prop]};`); + } + cssVars.push('}'); + + root.textContent = cssVars.join('\n'); +} + +export interface ThemeDefinition { + blockStyles?: { + [key: string]: Partial; + }; + categoryStyles?: { + [key: string]: Blockly.Theme.CategoryStyle; + }; + componentStyles?: Blockly.Theme.ComponentStyle; + fontStyle?: Blockly.Theme.FontStyle; + startHats?: boolean; + base?: string | Blockly.Theme; + name?: string; +} + +const defaultBlockStyles: Record> = { + motion: { + colourPrimary: '#4C97FF', + colourSecondary: '#4280D7', + colourTertiary: '#3373CC' + }, + looks: { + colourPrimary: '#9966FF', + colourSecondary: '#855CD6', + colourTertiary: '#774DCB' + }, + sounds: { + colourPrimary: '#CF63CF', + colourSecondary: '#C94FC9', + colourTertiary: '#BD42BD' + }, + control: { + colourPrimary: '#FFAB19', + colourSecondary: '#EC9C13', + colourTertiary: '#CF8B17' + }, + event: { + colourPrimary: '#FFBF00', + colourSecondary: '#E6AC00', + colourTertiary: '#CC9900' + }, + sensing: { + colourPrimary: '#5CB1D6', + colourSecondary: '#47A8D1', + colourTertiary: '#2E8EB8' + }, + pen: { + colourPrimary: '#0fBD8C', + colourSecondary: '#0DA57A', + colourTertiary: '#0B8E69' + }, + operators: { + colourPrimary: '#59C059', + colourSecondary: '#46B946', + colourTertiary: '#389438' + }, + data: { + colourPrimary: '#FF8C1A', + colourSecondary: '#FF8000', + colourTertiary: '#DB6E00' + }, + data_lists: { + colourPrimary: '#FF661A', + colourSecondary: '#FF5500', + colourTertiary: '#E64D00' + }, + more: { + colourPrimary: '#FF6680', + colourSecondary: '#FF4D6A', + colourTertiary: '#FF3355' + }, + argument: { + colourPrimary: '#F47983', + colourSecondary: '#F15764', + colourTertiary: '#EE3645' + }, + textField: { + colourPrimary: '#FFFFFF' + } +}; + +/** + * Build category styles from existing block styles. + * @param blockStyles The block styles to build from. + * @returns The category styles. + */ +function buildCategoryStyles( + blockStyles: Record> +): Record { + const keys = [ + 'motion', 'looks', 'sounds', 'control', 'event', + 'sensing', 'operators', 'data', 'more' + ]; + const categoryStyles: Record = {}; + for (const key of keys) { + if (key in blockStyles && blockStyles[key].colourPrimary) { + categoryStyles[key] = { + colour: blockStyles[key].colourPrimary + }; + } + } + return categoryStyles; +} + +const scratchTheme = { + name: 'scratch', + blockStyles: defaultBlockStyles, + categoryStyles: buildCategoryStyles(defaultBlockStyles), + componentStyles: { + selectedGlowColour: 'transparent', + insertionMarkerColour: Colours.insertionMarker as string, + insertionMarkerOpacity: Colours.insertionMarkerOpacity as number, + replacementGlowColour: Colours.replacementGlow as string, + scrollbarColour: Colours.scrollbar as string, + toolboxBackgroundColour: Colours.toolbox as string, + toolboxForegroundColour: Colours.toolboxText as string, + flyoutBackgroundColour: Colours.flyout as string, + workspaceBackgroundColour: Colours.workspace as string + }, + fontStyle: { + weight: '500' + }, + startHats: true +}; + +/** + * Create a custom theme based on the scratch theme. + * @param name Name of the theme. + * @param themeDef The theme object to override default scratch theme. + * @returns The newly created theme. + */ +export function createTheme(name: string, themeDef: ThemeDefinition): Blockly.Theme { + if (themeDef.blockStyles) { + themeDef.categoryStyles = Object.assign( + buildCategoryStyles(themeDef.blockStyles), + themeDef.categoryStyles || {} + ); + } + if (!themeDef.name) themeDef.name = name; + if (!themeDef.base) themeDef.base = 'scratch'; + if (!Object.prototype.hasOwnProperty.call(themeDef, 'startHats')) { + themeDef.startHats = true; + } + + const theme = Blockly.Theme.defineTheme(name, themeDef as Required); + Blockly.registry.register(Blockly.registry.Type.THEME, name, theme, true); + return theme; +} + +/** + * Get a defined theme by name. + * @param name Name of the theme. + * @returns The theme object, or null if not found. + */ +export function getTheme(name: string): Blockly.Theme | null { + return Blockly.registry.getObject(Blockly.registry.Type.THEME, name); +} + +/** + * Set the theme of the workspace. + * @param name The theme's name. + * @param workspace The workspace to set the theme to. use main workspace by default. + */ +export function setTheme(name: string, workspace?: Blockly.WorkspaceSvg) { + if (!workspace) { + workspace = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg; + if (!workspace.rendered) return; + } + const theme = getTheme(name) ?? getTheme('scratch')!; + workspace.setTheme(theme); + // Refresh CSS variables. + injectCssVariables(); +} + +export type BlockStyle = Blockly.Theme.BlockStyle; +export type CategoryStyle = Blockly.Theme.CategoryStyle; +export type ComponentStyle = Blockly.Theme.ComponentStyle; +export type FontStyle = Blockly.Theme.FontStyle; +export const Scratch = Blockly.Theme.defineTheme('scratch', scratchTheme); diff --git a/packages/block/src/toolbox/flyout.ts b/packages/block/src/toolbox/flyout.ts index 820f34180..ed4150354 100644 --- a/packages/block/src/toolbox/flyout.ts +++ b/packages/block/src/toolbox/flyout.ts @@ -9,6 +9,7 @@ import {Toolbox} from './toolbox'; import {FlyoutMetrics} from './flyout_metrics'; import type {FlyoutButton} from './flyout_button'; import {FlyoutStatusIndicatorLabel} from './flyout_status_indicator_label'; +import styles from '../styles/flyout.css'; /** * Class for customized flyout. @@ -287,3 +288,5 @@ Blockly.registry.register( VerticalFlyout, true ); + +Blockly.Css.register(styles); diff --git a/packages/block/src/zoom_controls.ts b/packages/block/src/zoom_controls.ts new file mode 100644 index 000000000..0a78fb40d --- /dev/null +++ b/packages/block/src/zoom_controls.ts @@ -0,0 +1,399 @@ +/** + * @license + * Copyright 2015 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +/** + * Interface for zoom controls. + * Should keep up with Blockly.ZoomControls's public fields. + */ +export interface IZoomControls extends Blockly.IPositionable { + /** + * The unique ID for this component that is used to register with the + * ComponentManager. + */ + id: string; + + /** + * Create the zoom controls. + * @returns The zoom controls SVG group. + */ + createDom(): SVGElement; + + /** + * Initializes the zoom controls. + */ + init(): void; + + /** + * Disposes of this zoom controls. + * Unlink from all DOM elements to prevent memory leaks. + */ + dispose(): void; + + /** + * Returns the bounding rectangle of the UI element in pixel units relative to + * the Blockly injection div. + * @returns The UI elements's bounding box. Null if bounding box should be + * ignored by other UI elements. + */ + getBoundingRectangle(): Blockly.utils.Rect | null; + + /** + * Positions the zoom controls. + * It is positioned in the opposite corner to the corner the + * categories/toolbox starts at. + * @param metrics The workspace metrics. + * @param savedPositions List of rectangles that are already on the workspace. + */ + position(metrics: Blockly.MetricsManager.UiMetrics, savedPositions: Blockly.utils.Rect[]): void; +} + +/** + * Class for zoom controls. + * Copied from Blockly.ZoomControls and make it Scratch-styled. + */ +export class ZoomControls implements IZoomControls { + static readonly XLINK_NS = 'http://www.w3.org/1999/xlink'; + /** + * The unique ID for this component that is used to register with the + * ComponentManager. + */ + id = 'zoomControls'; + + /** + * Array holding info needed to unbind events. + * Used for disposing. + * Ex: [[node, name, func], [node, name, func]]. + */ + private boundEvents: Blockly.browserEvents.Data[] = []; + + /** The zoom in svg element. */ + private zoomInGroup: SVGGElement | null = null; + + /** The zoom out svg element. */ + private zoomOutGroup: SVGGElement | null = null; + + /** The zoom reset svg element. */ + private zoomResetGroup: SVGGElement | null = null; + + private readonly ICON_SIZE = 36; + private readonly ICON_SPACING = 8; + private readonly ICON_MARGIN = 12; + private readonly TOTAL_HEIGHT = + this.ICON_SIZE * 3 + this.ICON_SPACING * 2; + + /** + * Zoom in icon path. + */ + private readonly ZOOM_IN_PATH_ = 'zoom-in.svg'; + + /** + * Zoom out icon path. + */ + private readonly ZOOM_OUT_PATH_ = 'zoom-out.svg'; + + /** + * Zoom reset icon path. + */ + private readonly ZOOM_RESET_PATH_ = 'zoom-reset.svg'; + + /** The SVG group containing the zoom controls. */ + private svgGroup: SVGElement | null = null; + + /** Left coordinate of the zoom controls. */ + private left = 0; + + /** Top coordinate of the zoom controls. */ + private top = 0; + + /** Whether this has been initialized. */ + private initialized = false; + + /** @param workspace The workspace to sit in. */ + constructor(private readonly workspace: Blockly.WorkspaceSvg) { } + + /** + * Create the zoom controls. + * @returns The zoom controls SVG group. + */ + createDom(): SVGElement { + this.svgGroup = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.G, {}); + + // 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); + this.createZoomOutSvg(rnd); + this.createZoomInSvg(rnd); + if (this.workspace.isMovable()) { + // If we zoom to the center and the workspace isn't movable we could + // lose blocks at the edges of the workspace. + this.createZoomResetSvg(rnd); + } + return this.svgGroup; + } + + /** Initializes the zoom controls. */ + init() { + this.workspace.getComponentManager().addComponent({ + component: this, + weight: Blockly.ComponentManager.ComponentWeight.ZOOM_CONTROLS_WEIGHT, + capabilities: [Blockly.ComponentManager.Capability.POSITIONABLE] + }); + this.initialized = true; + } + + /** + * Disposes of this zoom controls. + * Unlink from all DOM elements to prevent memory leaks. + */ + dispose() { + this.workspace.getComponentManager().removeComponent('zoomControls'); + if (this.svgGroup) { + Blockly.utils.dom.removeNode(this.svgGroup); + } + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + } + + /** + * Returns the bounding rectangle of the UI element in pixel units relative to + * the Blockly injection div. + * @returns The UI elements's bounding box. Null if bounding box should be + * ignored by other UI elements. + */ + getBoundingRectangle(): Blockly.utils.Rect | null { + let height = this.ICON_SPACING + 2 * this.ICON_SIZE; + if (this.zoomResetGroup) { + height += this.ICON_SPACING + this.ICON_SIZE; + } + const bottom = this.top + height; + const right = this.left + this.ICON_SIZE; + return new Blockly.utils.Rect(this.top, bottom, this.left, right); + } + + /** + * Positions the zoom controls. + * use Scratch-style ordering: zoom-in, zoom-out, reset (top to bottom). + * @param metrics The workspace metrics. + * @param savedPositions List of rectangles that are already on the workspace. + */ + position( + metrics: Blockly.MetricsManager.UiMetrics, + savedPositions: Blockly.utils.Rect[] + ): void { + // Not yet initialized. + if (!this.initialized) { + return; + } + + const cornerPosition = + Blockly.uiPosition.getCornerOppositeToolbox( + this.workspace, + metrics + ); + + const startRect = Blockly.uiPosition.getStartPositionRect( + cornerPosition, + new Blockly.utils.Size(this.ICON_SIZE, this.TOTAL_HEIGHT), + this.ICON_MARGIN, + this.ICON_MARGIN, + metrics, + this.workspace + ); + + const verticalPosition = cornerPosition.vertical; + const bumpDirection = + verticalPosition === Blockly.uiPosition.verticalPosition.TOP ? + Blockly.uiPosition.bumpDirection.DOWN : + Blockly.uiPosition.bumpDirection.UP; + const positionRect = Blockly.uiPosition.bumpPositionRect( + startRect, + this.ICON_MARGIN, + bumpDirection, + savedPositions + ); + + // Position is always the same regardless of vertical position + this.zoomInGroup?.setAttribute('transform', 'translate(0, 0)'); + this.zoomOutGroup?.setAttribute( + 'transform', + `translate(0, ${this.ICON_SIZE + this.ICON_SPACING})` + ); + this.zoomResetGroup?.setAttribute( + 'transform', + `translate(0, ${(this.ICON_SIZE + this.ICON_SPACING) * 2})` + ); + + this.top = positionRect.top; + this.left = positionRect.left; + this.svgGroup?.setAttribute( + 'transform', + `translate(${this.left}, ${this.top})` + ); + } + + /** + * Appends an icon image to the parent SVG group. + * @param parent The parent SVG group element to append the icon to. + * @param fileName The file name of the icon image. + */ + private appendIcon_(parent: SVGGElement | null, fileName: string) { + if (!parent) return; + const image = Blockly.utils.dom.createSvgElement( + 'image', + { + width: this.ICON_SIZE, + height: this.ICON_SIZE + }, + parent + ); + image.setAttributeNS( + ZoomControls.XLINK_NS, + 'xlink:href', + this.workspace.options.pathToMedia + fileName + ); + } + + /** + * Create the zoom out icon and its event handler. + * The Scratch Blocks implementation of this function is different from the + * Blockly implementation. + * @param _rnd The random string to use as a suffix in the clip path's ID. + * These IDs must be unique in case there are multiple Blockly instances + * on the same page. + */ + protected createZoomOutSvg(_rnd: string): void { + if (!this.svgGroup) return; + this.zoomOutGroup = Blockly.utils.dom.createSvgElement( + 'g', + {class: 'blocklyZoom blocklyZoomOut'}, + this.svgGroup + ) as SVGGElement; + const zoomOutGroup = this.zoomOutGroup; + if (!zoomOutGroup) return; + this.appendIcon_(zoomOutGroup, this.ZOOM_OUT_PATH_); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + zoomOutGroup, + 'pointerdown', + null, + this.zoom.bind(this, -1) + ) + ); + } + + /** + * Create the zoom in icon and its event handler. + * The Scratch Blocks implementation of this function is different from the + * Blockly implementation. + * @param _rnd The random string to use as a suffix in the clip path's ID. + * These IDs must be unique in case there are multiple Blockly instances + * on the same page. + */ + protected createZoomInSvg(_rnd: string): void { + if (!this.svgGroup) return; + this.zoomInGroup = Blockly.utils.dom.createSvgElement( + 'g', + {class: 'blocklyZoom blocklyZoomIn'}, + this.svgGroup + ) as SVGGElement; + const zoomInGroup = this.zoomInGroup; + if (!zoomInGroup) return; + this.appendIcon_(zoomInGroup, this.ZOOM_IN_PATH_); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + zoomInGroup, + 'pointerdown', + null, + this.zoom.bind(this, 1) + ) + ); + } + + /** + * Handles a mouse down event on the zoom in or zoom out buttons on the + * workspace. + * @param amount Amount of zooming. Negative amount values zoom out, and + * positive amount values zoom in. + * @param e A mouse down event. + */ + protected zoom(amount: number, e: PointerEvent) { + this.workspace.markFocused(); + this.workspace.zoomCenter(amount); + this.fireZoomEvent(); + Blockly.Touch.clearTouchIdentifier(); // Don't block future drags. + e.stopPropagation(); // Don't start a workspace scroll. + e.preventDefault(); // Stop double-clicking from selecting text. + } + + /** + * Create the zoom reset icon and its event handler. + * The Scratch Blocks implementation of this function is different from the + * Blockly implementation. + * @param _rnd The random string to use as a suffix in the clip path's ID. + */ + protected createZoomResetSvg(_rnd: string): void { + if (!this.svgGroup) return; + this.zoomResetGroup = Blockly.utils.dom.createSvgElement( + 'g', + {class: 'blocklyZoom blocklyZoomReset'}, + this.svgGroup + ) as SVGGElement; + const zoomResetGroup = this.zoomResetGroup; + if (!zoomResetGroup) return; + this.appendIcon_(zoomResetGroup, this.ZOOM_RESET_PATH_); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + zoomResetGroup, + 'pointerdown', + null, + this.resetZoom.bind(this) + ) + ); + } + + /** + * Handles a mouse down event on the reset zoom button on the workspace. + * @param e A mouse down event. + */ + protected resetZoom(e: PointerEvent) { + this.workspace.markFocused(); + + // zoom is passed amount and computes the new scale using the formula: + // targetScale = currentScale * Math.pow(speed, amount) + const targetScale = this.workspace.options.zoomOptions.startScale; + const currentScale = this.workspace.scale; + const speed = this.workspace.options.zoomOptions.scaleSpeed; + // To compute amount: + // amount = log(speed, (targetScale / currentScale)) + // Math.log computes natural logarithm (ln), to change the base, use + // formula: log(base, value) = ln(value) / ln(base) + const amount = Math.log(targetScale / currentScale) / Math.log(speed); + this.workspace.beginCanvasTransition(); + this.workspace.zoomCenter(amount); + this.workspace.scrollCenter(); + + setTimeout(this.workspace.endCanvasTransition.bind(this.workspace), 500); + this.fireZoomEvent(); + Blockly.Touch.clearTouchIdentifier(); // Don't block future drags. + e.stopPropagation(); // Don't start a workspace scroll. + e.preventDefault(); // Stop double-clicking from selecting text. + } + + /** Fires a zoom control UI event. */ + private fireZoomEvent() { + const uiEvent = new (Blockly.Events.get(Blockly.Events.CLICK))( + null, + this.workspace.id, + 'zoom_controls' + ); + Blockly.Events.fire(uiEvent); + } +} diff --git a/packages/block/tests/playground.html b/packages/block/tests/playground.html index 10abc07d8..4c44148e1 100644 --- a/packages/block/tests/playground.html +++ b/packages/block/tests/playground.html @@ -17,7 +17,7 @@ } body { - background-color: #fff; + background: #fff; font-family: sans-serif; overflow: hidden; display: flex; @@ -127,11 +127,84 @@ let lastScrollTop = 0; function start() { + registerNostalgicTheme(); fetch('toolbox.json').then(response => response.json()).then(toolbox => { injectWorkspaces(toolbox); }); } + function registerNostalgicTheme() { + const nostalgicThemeDef = { + blockStyles: { + motion: { + colourPrimary: "#4a6cd4", + colourSecondary: "#4361bf", + colourTertiary: "#3b56aa" + }, + looks: { + colourPrimary: "#8a55d7", + colourSecondary: "#7c4dc2", + colourTertiary: "#6e44ac" + }, + sounds: { + colourPrimary: "#bb42c3", + colourSecondary: "#a83bb0", + colourTertiary: "#96359c" + }, + event: { + colourPrimary: "#c88330", + colourSecondary: "#b4762b", + colourTertiary: "#a06926" + }, + control: { + colourPrimary: "#e1a91a", + colourSecondary: "#cb9817", + colourTertiary: "#b48715" + }, + sensing: { + colourPrimary: "#2ca5e2", + colourSecondary: "#2895cb", + colourTertiary: "#2a85bb" + }, + operators: { + colourPrimary: "#5cb712", + colourSecondary: "#4a920e", + colourTertiary: "#4a920e" + }, + data: { + colourPrimary: "#ee7d16", + colourSecondary: "#be6412", + colourTertiary: "#be6412" + }, + data_lists: { + colourPrimary: "#cc5b22", + colourSecondary: "#a3491b", + colourTertiary: "#a3491b" + }, + more: { + colourPrimary: "#632d99", + colourSecondary: "#4f247a", + colourTertiary: "#4f247a" + }, + pen: { + colourPrimary: "#0e9a6c", + colourSecondary: "#0c845a", + colourTertiary: "#0b7b56" + } + }, + componentStyles: { + workspaceBackgroundColour: '#e1e1e1', + toolboxBackgroundColour: '#d3d3d3', + flyoutBackgroundColour: '#d3d3d3' + }, + fontStyle: { + weight: '700', + size: '12' + } + }; + ScratchBlocks.Theme.createTheme('nostalgic', nostalgicThemeDef); + } + function injectWorkspaces(toolbox) { loadTime = Number(new Date()); @@ -141,6 +214,7 @@ media: '../media/', collapse: false, disable: false, + trashcan: false, toolbox: toolbox, horizontalLayout: false, toolboxPosition: 'left', @@ -155,6 +229,11 @@ maxScale: 4, minScale: 0.25, scaleSpeed: 1.1 + }, + grid: { + spacing: 40, + length: 2, + colour: '#ddd' } }); @@ -359,6 +438,10 @@ } }); } + + function changeTheme(themeName) { + ScratchBlocks.Theme.setTheme(themeName, mainWorkspace); + } @@ -368,6 +451,10 @@

Blocks Playground

+

Events