From 439d01433c447653db1c510f9ad31e3434fcd6f2 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 27 Nov 2025 22:05:22 +0800 Subject: [PATCH 01/21] :sparkles: feat(block): Scratch-styled zoom controls Signed-off-by: SimonShiki --- packages/block/src/index.ts | 19 ++- packages/block/src/zoom_controls.ts | 247 +++++++++++++++++++++++++++ packages/block/tests/playground.html | 1 + 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/block/src/zoom_controls.ts diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 62f47e1d7..f1b201a97 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -22,6 +22,7 @@ import {ContinuousVerticalFlyout} from './toolbox/flyout'; 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 {FuncChange} from './events/func_change'; @@ -132,7 +133,23 @@ export function injectWorkspace(container: Element | string, options?: Blockly.B } }; options = Object.assign(defaultOptions, options); - return Blockly.inject(container, options); + let initZoomControl = false; + if (options.zoom?.controls) { + // Use our ZoomControls implementation. + options.zoom.controls = false; + initZoomControl = true; + } + const workspace = Blockly.inject(container, options); + if (initZoomControl) { + workspace.zoomControls_ = new ZoomControls(workspace) as unknown as Blockly.ZoomControls; + const svgZoomControls = workspace.zoomControls_.createDom(); + workspace.svgGroup_.appendChild(svgZoomControls); + workspace.zoomControls_!.init(); + // To trigger zoom controls positioning. + workspace.resize(); + } + + return workspace; } /** diff --git a/packages/block/src/zoom_controls.ts b/packages/block/src/zoom_controls.ts new file mode 100644 index 000000000..fd21a496a --- /dev/null +++ b/packages/block/src/zoom_controls.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2025 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +/** + * Type definition for the private fields in Blockly.ZoomControls. + */ +type ZoomControlsInternals = { + WIDTH: number; + HEIGHT: number; + SMALL_SPACING: number; + LARGE_SPACING: number; + MARGIN_VERTICAL: number; + MARGIN_HORIZONTAL: number; + svgGroup: SVGElement | null; + zoomInGroup: SVGGElement | null; + zoomOutGroup: SVGGElement | null; + zoomResetGroup: SVGGElement | null; + boundEvents: Blockly.browserEvents.Data[]; + workspace: Blockly.WorkspaceSvg; + initialized: boolean; + left: number; + top: number; + + zoom(amount: number, e: PointerEvent): void; + resetZoom(e: PointerEvent): void; +}; + +// @ts-expect-error dirty hack to override Blockly ZoomControls in minimal changes +export class ZoomControls extends Blockly.ZoomControls { + static readonly XLINK_NS = 'http://www.w3.org/1999/xlink'; + + static readonly ICON_SIZE = 36; + static readonly ICON_SPACING = 8; + static readonly ICON_MARGIN = 12; + static readonly TOTAL_HEIGHT = ZoomControls.ICON_SIZE * 3 + ZoomControls.ICON_SPACING * 2; + + /** + * Zoom in icon path. + */ + ZOOM_IN_PATH_ = 'zoom-in.svg'; + + /** + * Zoom out icon path. + */ + ZOOM_OUT_PATH_ = 'zoom-out.svg'; + + /** + * Zoom reset icon path. + */ + ZOOM_RESET_PATH_ = 'zoom-reset.svg'; + + constructor(workspace: Blockly.WorkspaceSvg) { + super(workspace); + const internals = this.getInternals_(); + + // Override the default sizes with Scratch Blocks flavor sizes. + internals.WIDTH = ZoomControls.ICON_SIZE; + internals.HEIGHT = ZoomControls.ICON_SIZE; + internals.SMALL_SPACING = ZoomControls.ICON_SPACING; + internals.LARGE_SPACING = ZoomControls.ICON_SPACING; + internals.MARGIN_VERTICAL = ZoomControls.ICON_MARGIN; + internals.MARGIN_HORIZONTAL = ZoomControls.ICON_MARGIN; + } + + /** + * 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 override createZoomOutSvg(_rnd: string): void { + const internals = this.getInternals_(); + if (!internals.svgGroup) return; + internals.zoomOutGroup = Blockly.utils.dom.createSvgElement( + 'g', + {class: 'blocklyZoom blocklyZoomOut'}, + internals.svgGroup + ) as SVGGElement; + const zoomOutGroup = internals.zoomOutGroup; + if (!zoomOutGroup) return; + this.appendIcon_(zoomOutGroup, this.ZOOM_OUT_PATH_); + internals.boundEvents.push( + Blockly.browserEvents.conditionalBind( + zoomOutGroup, + 'pointerdown', + null, + internals.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 override createZoomInSvg(_rnd: string): void { + const internals = this.getInternals_(); + if (!internals.svgGroup) return; + internals.zoomInGroup = Blockly.utils.dom.createSvgElement( + 'g', + {class: 'blocklyZoom blocklyZoomIn'}, + internals.svgGroup + ) as SVGGElement; + const zoomInGroup = internals.zoomInGroup; + if (!zoomInGroup) return; + this.appendIcon_(zoomInGroup, this.ZOOM_IN_PATH_); + internals.boundEvents.push( + Blockly.browserEvents.conditionalBind( + zoomInGroup, + 'pointerdown', + null, + internals.zoom.bind(this, 1) + ) + ); + } + + /** + * 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 override createZoomResetSvg(_rnd: string): void { + const internals = this.getInternals_(); + if (!internals.svgGroup) return; + internals.zoomResetGroup = Blockly.utils.dom.createSvgElement( + 'g', + {class: 'blocklyZoom blocklyZoomReset'}, + internals.svgGroup + ) as SVGGElement; + const zoomResetGroup = internals.zoomResetGroup; + if (!zoomResetGroup) return; + this.appendIcon_(zoomResetGroup, this.ZOOM_RESET_PATH_); + internals.boundEvents.push( + Blockly.browserEvents.conditionalBind( + zoomResetGroup, + 'pointerdown', + null, + internals.resetZoom.bind(this) + ) + ); + } + + /** + * Positions the zoom controls. + * Override to 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. + */ + override position( + metrics: Blockly.MetricsManager.UiMetrics, + savedPositions: Blockly.utils.Rect[] + ): void { + const internals = this.getInternals_(); + // Not yet initialized. + if (!internals.initialized) { + return; + } + + const cornerPosition = + Blockly.uiPosition.getCornerOppositeToolbox( + internals.workspace, + metrics + ); + + const startRect = Blockly.uiPosition.getStartPositionRect( + cornerPosition, + new Blockly.utils.Size(ZoomControls.ICON_SIZE, ZoomControls.TOTAL_HEIGHT), + ZoomControls.ICON_MARGIN, + ZoomControls.ICON_MARGIN, + metrics, + internals.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, + ZoomControls.ICON_MARGIN, + bumpDirection, + savedPositions + ); + + // Position is always the same regardless of vertical position + internals.zoomInGroup?.setAttribute('transform', 'translate(0, 0)'); + internals.zoomOutGroup?.setAttribute( + 'transform', + `translate(0, ${ZoomControls.ICON_SIZE + ZoomControls.ICON_SPACING})` + ); + internals.zoomResetGroup?.setAttribute( + 'transform', + `translate(0, ${(ZoomControls.ICON_SIZE + ZoomControls.ICON_SPACING) * 2})` + ); + + internals.top = positionRect.top; + internals.left = positionRect.left; + internals.svgGroup?.setAttribute( + 'transform', + `translate(${internals.left}, ${internals.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) { + const internals = this.getInternals_(); + if (!parent) return; + const image = Blockly.utils.dom.createSvgElement( + 'image', + { + width: internals.WIDTH, + height: internals.HEIGHT + }, + parent + ); + image.setAttributeNS( + ZoomControls.XLINK_NS, + 'xlink:href', + internals.workspace.options.pathToMedia + fileName + ); + } + + /** + * A more elegant way to get the internals with type safety. + * @returns instanced Blockly.ZoomControls + */ + private getInternals_(): ZoomControlsInternals { + return this as unknown as ZoomControlsInternals; + } +} diff --git a/packages/block/tests/playground.html b/packages/block/tests/playground.html index cf789271c..986678acf 100644 --- a/packages/block/tests/playground.html +++ b/packages/block/tests/playground.html @@ -128,6 +128,7 @@ media: '../media/', collapse: false, disable: false, + trashcan: false, toolbox: toolbox, horizontalLayout: false, toolboxPosition: 'left', From 1751738b083b2ba48c297d36badb6f99e716cec6 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Fri, 28 Nov 2025 09:18:22 +0800 Subject: [PATCH 02/21] :recycle: chore(block): copied zoom controls implementation Signed-off-by: SimonShiki --- packages/block/src/zoom_controls.ts | 404 ++++++++++++++++++---------- 1 file changed, 262 insertions(+), 142 deletions(-) diff --git a/packages/block/src/zoom_controls.ts b/packages/block/src/zoom_controls.ts index fd21a496a..fc58f7ffa 100644 --- a/packages/block/src/zoom_controls.ts +++ b/packages/block/src/zoom_controls.ts @@ -7,180 +7,173 @@ import * as Blockly from 'blockly/core'; /** - * Type definition for the private fields in Blockly.ZoomControls. + * Class for a zoom controls. + * Copied from Blockly.ZoomControls and make it Scratch-styled. */ -type ZoomControlsInternals = { - WIDTH: number; - HEIGHT: number; - SMALL_SPACING: number; - LARGE_SPACING: number; - MARGIN_VERTICAL: number; - MARGIN_HORIZONTAL: number; - svgGroup: SVGElement | null; - zoomInGroup: SVGGElement | null; - zoomOutGroup: SVGGElement | null; - zoomResetGroup: SVGGElement | null; - boundEvents: Blockly.browserEvents.Data[]; - workspace: Blockly.WorkspaceSvg; - initialized: boolean; - left: number; - top: number; - - zoom(amount: number, e: PointerEvent): void; - resetZoom(e: PointerEvent): void; -}; - -// @ts-expect-error dirty hack to override Blockly ZoomControls in minimal changes -export class ZoomControls extends Blockly.ZoomControls { +export class ZoomControls implements Blockly.IPositionable { 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[] = []; - static readonly ICON_SIZE = 36; - static readonly ICON_SPACING = 8; - static readonly ICON_MARGIN = 12; - static readonly TOTAL_HEIGHT = ZoomControls.ICON_SIZE * 3 + ZoomControls.ICON_SPACING * 2; + /** 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. */ - ZOOM_IN_PATH_ = 'zoom-in.svg'; + private readonly ZOOM_IN_PATH_ = 'zoom-in.svg'; /** * Zoom out icon path. */ - ZOOM_OUT_PATH_ = 'zoom-out.svg'; + private readonly ZOOM_OUT_PATH_ = 'zoom-out.svg'; /** * Zoom reset icon path. */ - ZOOM_RESET_PATH_ = 'zoom-reset.svg'; + private readonly ZOOM_RESET_PATH_ = 'zoom-reset.svg'; - constructor(workspace: Blockly.WorkspaceSvg) { - super(workspace); - const internals = this.getInternals_(); + /** Width of the zoom controls. */ + private readonly WIDTH = this.ICON_SIZE; - // Override the default sizes with Scratch Blocks flavor sizes. - internals.WIDTH = ZoomControls.ICON_SIZE; - internals.HEIGHT = ZoomControls.ICON_SIZE; - internals.SMALL_SPACING = ZoomControls.ICON_SPACING; - internals.LARGE_SPACING = ZoomControls.ICON_SPACING; - internals.MARGIN_VERTICAL = ZoomControls.ICON_MARGIN; - internals.MARGIN_HORIZONTAL = ZoomControls.ICON_MARGIN; - } + /** Height of each zoom control. */ + private readonly HEIGHT = this.ICON_SIZE; + + /** Small spacing used between the zoom in and out control, in pixels. */ + private readonly SMALL_SPACING = this.ICON_SPACING; /** - * 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. + * Large spacing used between the zoom in and reset control, in pixels. */ - protected override createZoomOutSvg(_rnd: string): void { - const internals = this.getInternals_(); - if (!internals.svgGroup) return; - internals.zoomOutGroup = Blockly.utils.dom.createSvgElement( - 'g', - {class: 'blocklyZoom blocklyZoomOut'}, - internals.svgGroup - ) as SVGGElement; - const zoomOutGroup = internals.zoomOutGroup; - if (!zoomOutGroup) return; - this.appendIcon_(zoomOutGroup, this.ZOOM_OUT_PATH_); - internals.boundEvents.push( - Blockly.browserEvents.conditionalBind( - zoomOutGroup, - 'pointerdown', - null, - internals.zoom.bind(this, -1) - ) - ); + private readonly LARGE_SPACING = this.ICON_SPACING; + + /** 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 + // loose 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; } /** - * 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. + * Disposes of this zoom controls. + * Unlink from all DOM elements to prevent memory leaks. */ - protected override createZoomInSvg(_rnd: string): void { - const internals = this.getInternals_(); - if (!internals.svgGroup) return; - internals.zoomInGroup = Blockly.utils.dom.createSvgElement( - 'g', - {class: 'blocklyZoom blocklyZoomIn'}, - internals.svgGroup - ) as SVGGElement; - const zoomInGroup = internals.zoomInGroup; - if (!zoomInGroup) return; - this.appendIcon_(zoomInGroup, this.ZOOM_IN_PATH_); - internals.boundEvents.push( - Blockly.browserEvents.conditionalBind( - zoomInGroup, - 'pointerdown', - null, - internals.zoom.bind(this, 1) - ) - ); + 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; } /** - * 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. + * 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. */ - protected override createZoomResetSvg(_rnd: string): void { - const internals = this.getInternals_(); - if (!internals.svgGroup) return; - internals.zoomResetGroup = Blockly.utils.dom.createSvgElement( - 'g', - {class: 'blocklyZoom blocklyZoomReset'}, - internals.svgGroup - ) as SVGGElement; - const zoomResetGroup = internals.zoomResetGroup; - if (!zoomResetGroup) return; - this.appendIcon_(zoomResetGroup, this.ZOOM_RESET_PATH_); - internals.boundEvents.push( - Blockly.browserEvents.conditionalBind( - zoomResetGroup, - 'pointerdown', - null, - internals.resetZoom.bind(this) - ) - ); + getBoundingRectangle(): Blockly.utils.Rect | null { + let height = this.SMALL_SPACING + 2 * this.HEIGHT; + if (this.zoomResetGroup) { + height += this.LARGE_SPACING + this.HEIGHT; + } + const bottom = this.top + height; + const right = this.left + this.WIDTH; + return new Blockly.utils.Rect(this.top, bottom, this.left, right); } /** * Positions the zoom controls. - * Override to use Scratch-style ordering: zoom-in, zoom-out, reset (top to bottom). + * 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. */ - override position( + position( metrics: Blockly.MetricsManager.UiMetrics, savedPositions: Blockly.utils.Rect[] ): void { - const internals = this.getInternals_(); // Not yet initialized. - if (!internals.initialized) { + if (!this.initialized) { return; } const cornerPosition = Blockly.uiPosition.getCornerOppositeToolbox( - internals.workspace, + this.workspace, metrics ); const startRect = Blockly.uiPosition.getStartPositionRect( cornerPosition, - new Blockly.utils.Size(ZoomControls.ICON_SIZE, ZoomControls.TOTAL_HEIGHT), - ZoomControls.ICON_MARGIN, - ZoomControls.ICON_MARGIN, + new Blockly.utils.Size(this.ICON_SIZE, this.TOTAL_HEIGHT), + this.ICON_MARGIN, + this.ICON_MARGIN, metrics, - internals.workspace + this.workspace ); const verticalPosition = cornerPosition.vertical; @@ -190,27 +183,27 @@ export class ZoomControls extends Blockly.ZoomControls { Blockly.uiPosition.bumpDirection.UP; const positionRect = Blockly.uiPosition.bumpPositionRect( startRect, - ZoomControls.ICON_MARGIN, + this.ICON_MARGIN, bumpDirection, savedPositions ); // Position is always the same regardless of vertical position - internals.zoomInGroup?.setAttribute('transform', 'translate(0, 0)'); - internals.zoomOutGroup?.setAttribute( + this.zoomInGroup?.setAttribute('transform', 'translate(0, 0)'); + this.zoomOutGroup?.setAttribute( 'transform', - `translate(0, ${ZoomControls.ICON_SIZE + ZoomControls.ICON_SPACING})` + `translate(0, ${this.ICON_SIZE + this.ICON_SPACING})` ); - internals.zoomResetGroup?.setAttribute( + this.zoomResetGroup?.setAttribute( 'transform', - `translate(0, ${(ZoomControls.ICON_SIZE + ZoomControls.ICON_SPACING) * 2})` + `translate(0, ${(this.ICON_SIZE + this.ICON_SPACING) * 2})` ); - internals.top = positionRect.top; - internals.left = positionRect.left; - internals.svgGroup?.setAttribute( + this.top = positionRect.top; + this.left = positionRect.left; + this.svgGroup?.setAttribute( 'transform', - `translate(${internals.left}, ${internals.top})` + `translate(${this.left}, ${this.top})` ); } @@ -220,28 +213,155 @@ export class ZoomControls extends Blockly.ZoomControls { * @param fileName The file name of the icon image. */ private appendIcon_(parent: SVGGElement | null, fileName: string) { - const internals = this.getInternals_(); if (!parent) return; const image = Blockly.utils.dom.createSvgElement( 'image', { - width: internals.WIDTH, - height: internals.HEIGHT + width: this.WIDTH, + height: this.HEIGHT }, parent ); image.setAttributeNS( ZoomControls.XLINK_NS, 'xlink:href', - internals.workspace.options.pathToMedia + fileName + 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) + ) ); } /** - * A more elegant way to get the internals with type safety. - * @returns instanced Blockly.ZoomControls + * Handles a mouse down event on the reset zoom button on the workspace. + * @param e A mouse down event. */ - private getInternals_(): ZoomControlsInternals { - return this as unknown as ZoomControlsInternals; + 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); } } From be0a62f4060828276d254e0e7dd19d4e9d992e81 Mon Sep 17 00:00:00 2001 From: Alex Cui Date: Sat, 29 Nov 2025 17:46:33 +0800 Subject: [PATCH 03/21] :lipstick: style(block): make comment rounded Signed-off-by: SimonShiki --- packages/block/src/styles/comment.css | 44 +++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/block/src/styles/comment.css b/packages/block/src/styles/comment.css index 22b43ebff..49bae10fd 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; @@ -10,11 +11,23 @@ .blocklyComment .blocklyCommentTopbarBackground { height: 32px; - fill: var(--commentTopBarColour); + fill: none; } .blocklyCollapsed .blocklyCommentTopbarBackground { - outline: 1px solid var(--commentBorderColour); + rx: 4px; + ry: 4px; + stroke: var(--commentBorderColour); + stroke-width: 1px; + fill: var(--commentTopBarColour); +} + +.blocklySelected.blocklyCollapsed .blocklyCommentTopbarBackground { + rx: 4px; + ry: 4px; + stroke: var(--commentBorderColour); + stroke-width: 1px; + fill: var(--commentTopBarColour); } .blocklyComment { @@ -23,26 +36,31 @@ } .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 3px 3px; padding: 12px; } @@ -62,3 +80,11 @@ height: 32px; transform-origin: 16px 16px; } + +.blocklyCommentForeignObject { + padding: 1px; +} + +.blocklyCommentForeignObject>body { + background: none; +} From c20e2af3ac4ad6804ac8cbdf5097ddfe5d8b2069 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sat, 29 Nov 2025 18:47:17 +0800 Subject: [PATCH 04/21] :wrench: chore(block): use css vars to manage styles Signed-off-by: SimonShiki --- packages/block/src/colours.ts | 145 +++++++++----------------- packages/block/src/index.ts | 4 +- packages/block/src/styles/blockly.css | 8 +- packages/block/src/styles/comment.css | 4 + packages/block/tests/playground.html | 5 + 5 files changed, 63 insertions(+), 103 deletions(-) diff --git a/packages/block/src/colours.ts b/packages/block/src/colours.ts index 507947ba9..629f825ce 100644 --- a/packages/block/src/colours.ts +++ b/packages/block/src/colours.ts @@ -21,82 +21,6 @@ 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', @@ -127,9 +51,33 @@ export const Colours: Record | string | number> = 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)' + menuHover: 'rgba(76, 151, 255, 0.2)' }; +/** + * 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'); + } +} + const blockStyles: {[key: string]: Partial} = { motion: { colourPrimary: '#4C97FF', @@ -222,32 +170,35 @@ function buildCategoryStyles(): {[key: string]: Blockly.Theme.CategoryStyle} { * @param colours Dictionary of colour properties and new values. * @package */ -export const overrideColours = function(colours?: typeof Colours) { +export function overrideColours(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]; - } + if (!colours) return; + + 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; } + } else { + Colours[colourProperty] = colourPropertyValue; } } } + + // Refresh CSS variables. + injectCssVariables(); }; /** diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index eb8acefdd..9b80e3a22 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 {createTheme, injectCssVariables} from './colours'; import {registerScratchContextMenu} from './contextmenu_items'; import {registerFieldAngle} from './fields/angle'; import {registerFieldButton} from './fields/button'; @@ -84,7 +84,7 @@ export function inject(container: Element | string, options?: Blockly.BlocklyOpt registerScratchContextMenu(); // Register styles. - + injectCssVariables(); Blockly.Css.register(styles); Blockly.Css.register(commentStyles); diff --git a/packages/block/src/styles/blockly.css b/packages/block/src/styles/blockly.css index 5a8443c53..4be105e23 100644 --- a/packages/block/src/styles/blockly.css +++ b/packages/block/src/styles/blockly.css @@ -22,10 +22,10 @@ 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; } diff --git a/packages/block/src/styles/comment.css b/packages/block/src/styles/comment.css index 49bae10fd..ecfeb0436 100644 --- a/packages/block/src/styles/comment.css +++ b/packages/block/src/styles/comment.css @@ -9,6 +9,10 @@ transform: rotate(-180deg); } +.blocklyCommentText::placeholder { + font-style: italic; +} + .blocklyComment .blocklyCommentTopbarBackground { height: 32px; fill: none; diff --git a/packages/block/tests/playground.html b/packages/block/tests/playground.html index 33966d46d..888d73ae7 100644 --- a/packages/block/tests/playground.html +++ b/packages/block/tests/playground.html @@ -156,6 +156,11 @@ maxScale: 4, minScale: 0.25, scaleSpeed: 1.1 + }, + grid: { + spacing: 40, + length: 2, + colour: '#ddd' } }); From 509cd5283c53d2dbd76aa0c3620bb9242770ad08 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sat, 29 Nov 2025 19:40:59 +0800 Subject: [PATCH 05/21] :wrench: chore(block): apply some styles Signed-off-by: SimonShiki --- packages/block/src/index.ts | 2 +- packages/block/src/renderer/constants.ts | 4 ++++ packages/block/src/report_value.ts | 2 +- packages/block/src/{colours.ts => themes.ts} | 14 +++++++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) rename packages/block/src/{colours.ts => themes.ts} (90%) diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 9b80e3a22..53d9071fc 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, injectCssVariables} from './colours'; +import {createTheme, injectCssVariables} from './themes'; import {registerScratchContextMenu} from './contextmenu_items'; import {registerFieldAngle} from './fields/angle'; import {registerFieldButton} from './fields/button'; diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index 76d960ff2..94cae83a9 100644 --- a/packages/block/src/renderer/constants.ts +++ b/packages/block/src/renderer/constants.ts @@ -74,6 +74,10 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `${selector} .blocklyCommentText.blocklyText {`, `font-weight: 400;`, `color: #575e75;`, // @TODO: Use CSS variable. (same as --clipcc-text-primary) + `}`, + ``, + `${selector} .blocklyHighlightedConnectionPath {`, + `stroke: var(--clipcc-block-replacementGlow);`, `}` ]; return css.concat(flyoutButtonStyle); diff --git a/packages/block/src/report_value.ts b/packages/block/src/report_value.ts index b9a44c3ac..4f88f3bb1 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 './themes'; import styles from './styles/report_value.css'; /** diff --git a/packages/block/src/colours.ts b/packages/block/src/themes.ts similarity index 90% rename from packages/block/src/colours.ts rename to packages/block/src/themes.ts index 629f825ce..760941fc6 100644 --- a/packages/block/src/colours.ts +++ b/packages/block/src/themes.ts @@ -209,6 +209,18 @@ export function createTheme(): Blockly.Theme { return Blockly.Theme.defineTheme('scratch', { name: 'scratch', blockStyles, - categoryStyles: buildCategoryStyles() + categoryStyles: buildCategoryStyles(), + componentStyles: { + selectedGlowColour: 'transparent', + insertionMarkerColour: 'transparent', + 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 + }, + startHats: true }); } From 3778911472f35a67d5aef8a985a8d066d7423c15 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sat, 29 Nov 2025 23:32:32 +0800 Subject: [PATCH 06/21] :wrench: chore(block): show boolean connection highlight correctly Signed-off-by: SimonShiki --- packages/block/src/renderer/constants.ts | 6 ++++++ packages/block/src/themes.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index 94cae83a9..ab204f929 100644 --- a/packages/block/src/renderer/constants.ts +++ b/packages/block/src/renderer/constants.ts @@ -77,6 +77,12 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `}`, ``, `${selector} .blocklyHighlightedConnectionPath {`, + `stroke: transparent;`, + `}`, + ``, + // Boolean connection highlight override + `${selector} .blocklyOutlinePath ~ .blocklyHighlightedConnectionPath,`, + `${selector} .blocklyHighlightedConnectionPath:has(~ .blocklyOutlinePath) {`, `stroke: var(--clipcc-block-replacementGlow);`, `}` ]; diff --git a/packages/block/src/themes.ts b/packages/block/src/themes.ts index 760941fc6..2e66942b7 100644 --- a/packages/block/src/themes.ts +++ b/packages/block/src/themes.ts @@ -212,7 +212,7 @@ export function createTheme(): Blockly.Theme { categoryStyles: buildCategoryStyles(), componentStyles: { selectedGlowColour: 'transparent', - insertionMarkerColour: 'transparent', + insertionMarkerColour: Colours.insertionMarker as string, insertionMarkerOpacity: Colours.insertionMarkerOpacity as number, replacementGlowColour: Colours.replacementGlow as string, scrollbarColour: Colours.scrollbar as string, From dadf95b29bade606e84308316ee3a78a450d4142 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 30 Nov 2025 00:13:34 +0800 Subject: [PATCH 07/21] :wrench: chore(block): add drop shadow for block/workspace comment Signed-off-by: SimonShiki --- packages/block/src/styles/blockly.css | 9 +++++++++ packages/block/src/styles/comment.css | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/packages/block/src/styles/blockly.css b/packages/block/src/styles/blockly.css index 4be105e23..e274b3b4f 100644 --- a/packages/block/src/styles/blockly.css +++ b/packages/block/src/styles/blockly.css @@ -29,3 +29,12 @@ .blocklyWidgetDiv .blocklyMenuItemDisabled .blocklyMenuItemHighlight { background: none; } + +.blocklyDragging>.blocklyPath { + fill-opacity: 1; + stroke-opacity: 1; +} + +.blocklyDragging:not(.blocklyDragging .blocklyDragging)>.blocklyPath { + filter: drop-shadow(0 0px 6px hsla(0, 0%, 0%, 0.6)); +} diff --git a/packages/block/src/styles/comment.css b/packages/block/src/styles/comment.css index ecfeb0436..89f88cc50 100644 --- a/packages/block/src/styles/comment.css +++ b/packages/block/src/styles/comment.css @@ -39,6 +39,10 @@ border-radius: 4px; } +.blocklyDragging.blocklyComment { + filter: drop-shadow(0 0px 6px hsla(0, 0%, 0%, 0.6)); +} + .blocklyCommentHighlight { rx: 4px; ry: 4px; From 85be289ce0ab26ba3ce724408404c688eb9903a8 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 30 Nov 2025 12:07:06 +0800 Subject: [PATCH 08/21] :lipstick: style(block): add various styles Signed-off-by: SimonShiki --- packages/block/src/renderer/constants.ts | 5 +++++ packages/block/src/styles/blockly.css | 17 ++++++++++++++++- packages/block/src/styles/toolbox.css | 1 + packages/block/src/themes.ts | 6 +++++- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index ab204f929..74232d75e 100644 --- a/packages/block/src/renderer/constants.ts +++ b/packages/block/src/renderer/constants.ts @@ -84,6 +84,11 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `${selector} .blocklyOutlinePath ~ .blocklyHighlightedConnectionPath,`, `${selector} .blocklyHighlightedConnectionPath:has(~ .blocklyOutlinePath) {`, `stroke: var(--clipcc-block-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/styles/blockly.css b/packages/block/src/styles/blockly.css index e274b3b4f..cc0d30c33 100644 --- a/packages/block/src/styles/blockly.css +++ b/packages/block/src/styles/blockly.css @@ -35,6 +35,21 @@ stroke-opacity: 1; } -.blocklyDragging:not(.blocklyDragging .blocklyDragging)>.blocklyPath { +.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; +} diff --git a/packages/block/src/styles/toolbox.css b/packages/block/src/styles/toolbox.css index 33a12f0f0..d7bddc806 100644 --- a/packages/block/src/styles/toolbox.css +++ b/packages/block/src/styles/toolbox.css @@ -6,6 +6,7 @@ .clipccToolboxCategoryContainer { font-size: 0.8rem; + outline: none; } .clipccToolboxCategory { diff --git a/packages/block/src/themes.ts b/packages/block/src/themes.ts index 2e66942b7..b59e82301 100644 --- a/packages/block/src/themes.ts +++ b/packages/block/src/themes.ts @@ -51,7 +51,8 @@ export const Colours: Record | string | number> = 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)' + menuHover: 'rgba(76, 151, 255, 0.2)', + dropdownRadius: '.2em' }; /** @@ -221,6 +222,9 @@ export function createTheme(): Blockly.Theme { flyoutBackgroundColour: Colours.flyout as string, workspaceBackgroundColour: Colours.workspace as string }, + fontStyle: { + weight: '500' + }, startHats: true }); } From 6efe54d130c245e31eadf06979a958f2241d24be Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 30 Nov 2025 13:14:22 +0800 Subject: [PATCH 09/21] :lipstick: chore(block): css-variable controlled styles Signed-off-by: SimonShiki --- packages/block/src/renderer/constants.ts | 5 +++-- packages/block/src/styles/checkbox.css | 4 ++-- packages/block/src/styles/colour_slider.css | 2 +- packages/block/src/styles/comment.css | 2 +- packages/block/src/styles/note.css | 2 +- packages/block/src/themes.ts | 3 ++- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index 74232d75e..08d00b3d8 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 '../themes'; /** * An object that provides constants for rendering blocks in Scratch mode. @@ -73,7 +74,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { ``, `${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 {`, @@ -83,7 +84,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { // Boolean connection highlight override `${selector} .blocklyOutlinePath ~ .blocklyHighlightedConnectionPath,`, `${selector} .blocklyHighlightedConnectionPath:has(~ .blocklyOutlinePath) {`, - `stroke: var(--clipcc-block-replacementGlow);`, + `stroke: var(--clipcc-block-replacementGlow, ${Colours.replacementGlow});`, `}`, `${selector} .blocklyFlyoutLabelText {`, `font-family: "Helvetica Neue", Helvetica, sans-serif;`, diff --git a/packages/block/src/styles/checkbox.css b/packages/block/src/styles/checkbox.css index e7b8290bf..416984b6c 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 89f88cc50..7ebfc0f78 100644 --- a/packages/block/src/styles/comment.css +++ b/packages/block/src/styles/comment.css @@ -68,7 +68,7 @@ .blocklyComment .blocklyTextarea { border: none; - border-radius: 0px 0px 3px 3px; + border-radius: 0px 0px 4px 4px; padding: 12px; } 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/themes.ts b/packages/block/src/themes.ts index b59e82301..3b684cbb5 100644 --- a/packages/block/src/themes.ts +++ b/packages/block/src/themes.ts @@ -24,7 +24,8 @@ export const Colours: Record | string | number> = text: '#FFFFFF', workspace: '#F9F9F9', toolboxHover: '#4C97FF', - toolboxSelected: '#e9eef2', + toolboxHoverStroke: '#4280D7', + toolboxSelected: '#3373CC', toolboxText: '#575E75', toolbox: '#FFFFFF', flyout: '#F9F9F9', From 2b2757e43548b71ccee1e8636eb6e83ea9ac0218 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sun, 30 Nov 2025 13:22:14 +0800 Subject: [PATCH 10/21] :wrench: chore(block): make zoom control more recognizable Signed-off-by: SimonShiki --- packages/block/src/styles/blockly.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/block/src/styles/blockly.css b/packages/block/src/styles/blockly.css index cc0d30c33..38fdc7e13 100644 --- a/packages/block/src/styles/blockly.css +++ b/packages/block/src/styles/blockly.css @@ -53,3 +53,18 @@ 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; +} From d60eb96fe61912b67fde2b491a17b78f5c734781 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 3 Dec 2025 11:23:21 +0800 Subject: [PATCH 11/21] :sparkles: feat(block): configurable theme Signed-off-by: SimonShiki --- packages/block/src/index.ts | 14 +-- packages/block/src/renderer/constants.ts | 2 +- packages/block/src/report_value.ts | 2 +- packages/block/src/{themes.ts => theme.ts} | 108 ++++++++++----------- packages/block/src/zoom_controls.ts | 2 +- packages/block/tests/playground.html | 61 +++++++++++- 6 files changed, 124 insertions(+), 65 deletions(-) rename packages/block/src/{themes.ts => theme.ts} (68%) diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 53d9071fc..8f2485f7f 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, injectCssVariables} from './themes'; +import {scratchTheme, injectCssVariables} from './theme'; import {registerScratchContextMenu} from './contextmenu_items'; import {registerFieldAngle} from './fields/angle'; import {registerFieldButton} from './fields/button'; @@ -145,7 +145,7 @@ 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: scratchTheme }; options = Object.assign(defaultOptions, options); let initZoomControl = false; @@ -191,13 +191,15 @@ export function loadWorkspace( Blockly.serialization.workspaces.load(state, workspace, {recordUndo}); } -export {reportValue} from './report_value'; -export {setExternalProcedureDefCallback} from './procedures_category'; -export {setGetCheckboxState} from './utils'; - // 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; + +export {reportValue} from './report_value'; +export {setExternalProcedureDefCallback} from './procedures_category'; +export {setGetCheckboxState} from './utils'; +export {createTheme, getTheme, setTheme} from './theme'; + diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index 08d00b3d8..097024fb3 100644 --- a/packages/block/src/renderer/constants.ts +++ b/packages/block/src/renderer/constants.ts @@ -5,7 +5,7 @@ */ import * as Blockly from 'blockly/core'; -import {Colours} from '../themes'; +import {Colours} from '../theme'; /** * An object that provides constants for rendering blocks in Scratch mode. diff --git a/packages/block/src/report_value.ts b/packages/block/src/report_value.ts index 4f88f3bb1..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 './themes'; +import {Colours} from './theme'; import styles from './styles/report_value.css'; /** diff --git a/packages/block/src/themes.ts b/packages/block/src/theme.ts similarity index 68% rename from packages/block/src/themes.ts rename to packages/block/src/theme.ts index 3b684cbb5..5e8d318e6 100644 --- a/packages/block/src/themes.ts +++ b/packages/block/src/theme.ts @@ -166,66 +166,64 @@ function buildCategoryStyles(): {[key: string]: Blockly.Theme.CategoryStyle} { return categoryStyles; } +export const defaultTheme = { + name: 'scratch', + blockStyles, + categoryStyles: buildCategoryStyles(), + 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 +}; /** - * Override the colours in Colours with new values basded on the - * given dictionary. - * @param colours Dictionary of colour properties and new values. - * @package + * 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 overrideColours(colours?: typeof Colours) { - // Colour overrides provided by the injection - if (!colours) return; +export function createTheme(name: string, themeDef: object): Blockly.Theme { + const customTheme = Object.assign({base: 'scratch', name}, themeDef); + const theme = Blockly.Theme.defineTheme(name, customTheme); + Blockly.registry.register(Blockly.registry.Type.THEME, name, theme, true); + return theme; +} - 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; - } - } +/** + * 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 { + if (!Blockly.registry.hasItem(Blockly.registry.Type.THEME, name)) { + return null; } - - // Refresh CSS variables. - injectCssVariables(); -}; + return Blockly.registry.getObject(Blockly.registry.Type.THEME, name); +} /** - * Create the scratch theme. - * @returns The newly created theme. + * 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 createTheme(): Blockly.Theme { - return Blockly.Theme.defineTheme('scratch', { - name: 'scratch', - blockStyles, - categoryStyles: buildCategoryStyles(), - 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 - }); +export function setTheme(name: string, workspace: Blockly.WorkspaceSvg) { + if (!workspace) { + workspace = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg; + } + const theme = getTheme(name) ?? getTheme('scratch')!; + workspace.setTheme(theme); + // Refresh CSS variables. + injectCssVariables(); } + +export const scratchTheme = Blockly.Theme.defineTheme('scratch', defaultTheme); diff --git a/packages/block/src/zoom_controls.ts b/packages/block/src/zoom_controls.ts index fc58f7ffa..87807d2b6 100644 --- a/packages/block/src/zoom_controls.ts +++ b/packages/block/src/zoom_controls.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Clip Team + * Copyright 2024 Google LLC * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/block/tests/playground.html b/packages/block/tests/playground.html index 888d73ae7..2b307882d 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,62 @@ let lastScrollTop = 0; function start() { + registerNostalgicTheme(); fetch('toolbox.json').then(response => response.json()).then(toolbox => { injectWorkspaces(toolbox); }); } + function registerNostalgicTheme() { + const nostalgicThemeDef = { + categoryStyles: { + motion: { + colour: "#4a6cd4" + }, + looks: { + colour: "#8a55d7" + }, + sounds: { + colour: "#bb42c3" + }, + events: { + colour: "#c88330" + }, + control: { + colour: "#e1a91a" + }, + sensing: { + colour: "#2ca5e2" + }, + operators: { + colour: "#5cb712" + }, + data: { + colour: "#ee7d16" + }, + data_lists: { + colour: "#cc5b22" + }, + more: { + colour: "#632d99" + }, + pen: { + colour: "#0e9a6c" + } + }, + componentStyles: { + workspaceBackgroundColour: '#e1e1e1', + toolboxBackgroundColour: '#d3d3d3', + flyoutBackgroundColour: '#d3d3d3' + }, + fontStyle: { + weight: '600', + size: '12' + } + }; + ScratchBlocks.createTheme('nostalgic', nostalgicThemeDef); + } + function injectWorkspaces(toolbox) { loadTime = Number(new Date()); @@ -365,6 +416,10 @@ } }); } + + function changeTheme(themeName) { + ScratchBlocks.setTheme(themeName, mainWorkspace); + } @@ -374,6 +429,10 @@

Blocks Playground

+

Events

From 03b23ec37d3d19fb9a9ff56142afcc3078290615 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 3 Dec 2025 11:26:58 +0800 Subject: [PATCH 12/21] :wrench: chore(block): use monkey-patch to apply Scratch-styled zoom controls Signed-off-by: SimonShiki --- packages/block/src/index.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 8f2485f7f..31b7de81c 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -148,21 +148,7 @@ export function injectWorkspace(container: Element | string, options?: Blockly.B theme: scratchTheme }; options = Object.assign(defaultOptions, options); - let initZoomControl = false; - if (options.zoom?.controls) { - // Use our ZoomControls implementation. - options.zoom.controls = false; - initZoomControl = true; - } const workspace = Blockly.inject(container, options); - if (initZoomControl) { - workspace.zoomControls_ = new ZoomControls(workspace) as unknown as Blockly.ZoomControls; - const svgZoomControls = workspace.zoomControls_.createDom(); - workspace.svgGroup_.appendChild(svgZoomControls); - workspace.zoomControls_!.init(); - // To trigger zoom controls positioning. - workspace.resize(); - } return workspace; } @@ -198,6 +184,12 @@ 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 {setExternalProcedureDefCallback} from './procedures_category'; export {setGetCheckboxState} from './utils'; From 6c35b89d91df78c667211f52fe4304e4eee6cbcc Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 3 Dec 2025 12:21:35 +0800 Subject: [PATCH 13/21] :lipstick: chore(block): add flyout&toolbox border Signed-off-by: SimonShiki --- packages/block/src/styles/flyout.css | 10 ++++++++++ packages/block/src/styles/toolbox.css | 2 ++ packages/block/src/theme.ts | 22 ++++++++++++---------- packages/block/src/toolbox/flyout.ts | 3 +++ 4 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 packages/block/src/styles/flyout.css diff --git a/packages/block/src/styles/flyout.css b/packages/block/src/styles/flyout.css new file mode 100644 index 000000000..2289322a2 --- /dev/null +++ b/packages/block/src/styles/flyout.css @@ -0,0 +1,10 @@ +.blocklyFlyout { + border-right: 1px solid var(--clipcc-block-toolboxBorder); + box-sizing: content-box; + border-radius: 0 8px 8px 0; +} + +[dir="rtl"] .blocklyFlyout { + border-right: none; + border-left: 1px solid var(--clipcc-block-toolboxBorder); +} diff --git a/packages/block/src/styles/toolbox.css b/packages/block/src/styles/toolbox.css index d7bddc806..84420d766 100644 --- a/packages/block/src/styles/toolbox.css +++ b/packages/block/src/styles/toolbox.css @@ -2,6 +2,8 @@ width: 100px; overflow-y: auto; scrollbar-width: none; + border-right: 1px solid var(--clipcc-block-toolboxBorder); + box-sizing: content-box; } .clipccToolboxCategoryContainer { diff --git a/packages/block/src/theme.ts b/packages/block/src/theme.ts index 5e8d318e6..64cdb2932 100644 --- a/packages/block/src/theme.ts +++ b/packages/block/src/theme.ts @@ -44,6 +44,8 @@ export const Colours: Record | string | number> = 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', @@ -65,19 +67,19 @@ export function injectCssVariables(): void { 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]};`); + const cssVars: string[] = []; + cssVars.push(':root {'); + for (const prop in Colours) { + if (!Object.prototype.hasOwnProperty.call(Colours, prop)) { + continue; } - cssVars.push('}'); - - root.textContent = cssVars.join('\n'); + cssVars.push(` --clipcc-block-${prop}: ${Colours[prop]};`); } + cssVars.push('}'); + + root.textContent = cssVars.join('\n'); } const blockStyles: {[key: string]: Partial} = { diff --git a/packages/block/src/toolbox/flyout.ts b/packages/block/src/toolbox/flyout.ts index c4f590f1e..cabdc30e5 100644 --- a/packages/block/src/toolbox/flyout.ts +++ b/packages/block/src/toolbox/flyout.ts @@ -8,6 +8,7 @@ import * as Blockly from 'blockly/core'; import {Toolbox} from './toolbox'; import {FlyoutMetrics} from './flyout_metrics'; import type {FlyoutButton} from './flyout_button'; +import styles from '../styles/flyout.css'; /** * Class for customized flyout. @@ -275,3 +276,5 @@ Blockly.registry.register( VerticalFlyout, true ); + +Blockly.Css.register(styles); From f6177aa8e3f267a970357c65af07548c28dc7c8c Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 3 Dec 2025 12:28:32 +0800 Subject: [PATCH 14/21] :wrench: chore(block): correct css var usage Signed-off-by: SimonShiki --- packages/block/src/renderer/constants.ts | 4 ++-- packages/block/src/styles/flyout.css | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/block/src/renderer/constants.ts b/packages/block/src/renderer/constants.ts index 097024fb3..2b6c54f91 100644 --- a/packages/block/src/renderer/constants.ts +++ b/packages/block/src/renderer/constants.ts @@ -55,7 +55,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `}`, ``, `${selector} .blocklyFlyoutButtonBackground {`, - `stroke: #c6c6c6;`, + `stroke: var(--clipcc-block-flyoutBorder);`, `}`, ``, `${selector} .blocklyFlyoutButtonShadow {`, @@ -68,7 +68,7 @@ export class ConstantProvider extends Blockly.zelos.ConstantProvider { `}`, ``, `${selector} .blocklyFlyoutButton .blocklyText {`, - `fill: #575E75;`, + `fill: var(--clipcc-block-toolboxText, ${Colours.textFieldText});`, `font-weight: 500;`, `}`, ``, diff --git a/packages/block/src/styles/flyout.css b/packages/block/src/styles/flyout.css index 2289322a2..8a706d29e 100644 --- a/packages/block/src/styles/flyout.css +++ b/packages/block/src/styles/flyout.css @@ -1,10 +1,10 @@ .blocklyFlyout { - border-right: 1px solid var(--clipcc-block-toolboxBorder); + 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-toolboxBorder); + border-left: 1px solid var(--clipcc-block-flyoutBorder); } From 9bd9c47b04602011632be4a35f32cb398413f8eb Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 3 Dec 2025 14:41:09 +0800 Subject: [PATCH 15/21] :wrench: chore(block): typo & rebundant codes Signed-off-by: SimonShiki --- packages/block/src/styles/comment.css | 9 +-------- packages/block/src/styles/flyout.css | 3 ++- packages/block/src/zoom_controls.ts | 28 +++++++-------------------- 3 files changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/block/src/styles/comment.css b/packages/block/src/styles/comment.css index 7ebfc0f78..c197e768a 100644 --- a/packages/block/src/styles/comment.css +++ b/packages/block/src/styles/comment.css @@ -18,14 +18,7 @@ fill: none; } -.blocklyCollapsed .blocklyCommentTopbarBackground { - rx: 4px; - ry: 4px; - stroke: var(--commentBorderColour); - stroke-width: 1px; - fill: var(--commentTopBarColour); -} - +.blocklyCollapsed .blocklyCommentTopbarBackground, .blocklySelected.blocklyCollapsed .blocklyCommentTopbarBackground { rx: 4px; ry: 4px; diff --git a/packages/block/src/styles/flyout.css b/packages/block/src/styles/flyout.css index 8a706d29e..3649af705 100644 --- a/packages/block/src/styles/flyout.css +++ b/packages/block/src/styles/flyout.css @@ -1,10 +1,11 @@ .blocklyFlyout { border-right: 1px solid var(--clipcc-block-flyoutBorder); box-sizing: content-box; - border-radius: 0 8px 8px 0; + 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/zoom_controls.ts b/packages/block/src/zoom_controls.ts index 87807d2b6..be2e8e06a 100644 --- a/packages/block/src/zoom_controls.ts +++ b/packages/block/src/zoom_controls.ts @@ -7,7 +7,7 @@ import * as Blockly from 'blockly/core'; /** - * Class for a zoom controls. + * Class for zoom controls. * Copied from Blockly.ZoomControls and make it Scratch-styled. */ export class ZoomControls implements Blockly.IPositionable { @@ -55,20 +55,6 @@ export class ZoomControls implements Blockly.IPositionable { */ private readonly ZOOM_RESET_PATH_ = 'zoom-reset.svg'; - /** Width of the zoom controls. */ - private readonly WIDTH = this.ICON_SIZE; - - /** Height of each zoom control. */ - private readonly HEIGHT = this.ICON_SIZE; - - /** Small spacing used between the zoom in and out control, in pixels. */ - private readonly SMALL_SPACING = this.ICON_SPACING; - - /** - * Large spacing used between the zoom in and reset control, in pixels. - */ - private readonly LARGE_SPACING = this.ICON_SPACING; - /** The SVG group containing the zoom controls. */ private svgGroup: SVGElement | null = null; @@ -99,7 +85,7 @@ export class ZoomControls implements Blockly.IPositionable { this.createZoomInSvg(rnd); if (this.workspace.isMovable()) { // If we zoom to the center and the workspace isn't movable we could - // loose blocks at the edges of the workspace. + // lose blocks at the edges of the workspace. this.createZoomResetSvg(rnd); } return this.svgGroup; @@ -137,12 +123,12 @@ export class ZoomControls implements Blockly.IPositionable { * ignored by other UI elements. */ getBoundingRectangle(): Blockly.utils.Rect | null { - let height = this.SMALL_SPACING + 2 * this.HEIGHT; + let height = this.ICON_SPACING + 2 * this.ICON_SIZE; if (this.zoomResetGroup) { - height += this.LARGE_SPACING + this.HEIGHT; + height += this.ICON_SPACING + this.ICON_SIZE; } const bottom = this.top + height; - const right = this.left + this.WIDTH; + const right = this.left + this.ICON_SIZE; return new Blockly.utils.Rect(this.top, bottom, this.left, right); } @@ -217,8 +203,8 @@ export class ZoomControls implements Blockly.IPositionable { const image = Blockly.utils.dom.createSvgElement( 'image', { - width: this.WIDTH, - height: this.HEIGHT + width: this.ICON_SIZE, + height: this.ICON_SIZE }, parent ); From 699c0f008fe133dd14b0907578c0669285e19cdc Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 4 Dec 2025 15:44:49 +0800 Subject: [PATCH 16/21] :wrench: chore(block): simplify css Signed-off-by: SimonShiki --- packages/block/src/styles/comment.css | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/block/src/styles/comment.css b/packages/block/src/styles/comment.css index c197e768a..4ddcf7620 100644 --- a/packages/block/src/styles/comment.css +++ b/packages/block/src/styles/comment.css @@ -27,11 +27,6 @@ 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)); } From 347acfc6dfe1712f704e237a52f8380052b43615 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Mon, 15 Dec 2025 18:54:10 +0800 Subject: [PATCH 17/21] :wrench: chore(block): build category styles from block styles Signed-off-by: SimonShiki --- packages/block/src/theme.ts | 48 +++++++++++++++++++++----- packages/block/tests/playground.html | 50 ++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/packages/block/src/theme.ts b/packages/block/src/theme.ts index 64cdb2932..c5fd236fd 100644 --- a/packages/block/src/theme.ts +++ b/packages/block/src/theme.ts @@ -82,7 +82,21 @@ export function injectCssVariables(): void { root.textContent = cssVars.join('\n'); } -const blockStyles: {[key: string]: Partial} = { +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', @@ -150,14 +164,17 @@ const blockStyles: {[key: string]: Partial} = { /** * Build category styles from existing block styles. + * @param blockStyles The block styles to build from. * @returns The category styles. */ -function buildCategoryStyles(): {[key: string]: Blockly.Theme.CategoryStyle} { +function buildCategoryStyles( + blockStyles: Record> +): Record { const keys = [ 'motion', 'looks', 'sounds', 'control', 'event', 'sensing', 'operators', 'data', 'more' ]; - const categoryStyles: {[key: string]: Blockly.Theme.CategoryStyle} = {}; + const categoryStyles: Record = {}; for (const key of keys) { if (key in blockStyles && blockStyles[key].colourPrimary) { categoryStyles[key] = { @@ -170,8 +187,8 @@ function buildCategoryStyles(): {[key: string]: Blockly.Theme.CategoryStyle} { export const defaultTheme = { name: 'scratch', - blockStyles, - categoryStyles: buildCategoryStyles(), + blockStyles: defaultBlockStyles, + categoryStyles: buildCategoryStyles(defaultBlockStyles), componentStyles: { selectedGlowColour: 'transparent', insertionMarkerColour: Colours.insertionMarker as string, @@ -188,15 +205,27 @@ export const defaultTheme = { }, 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: object): Blockly.Theme { - const customTheme = Object.assign({base: 'scratch', name}, themeDef); - const theme = Blockly.Theme.defineTheme(name, customTheme); +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; } @@ -218,9 +247,10 @@ export function getTheme(name: string): Blockly.Theme | null { * @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) { +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); diff --git a/packages/block/tests/playground.html b/packages/block/tests/playground.html index e86ce1b27..5ac14fab2 100644 --- a/packages/block/tests/playground.html +++ b/packages/block/tests/playground.html @@ -135,39 +135,61 @@ function registerNostalgicTheme() { const nostalgicThemeDef = { - categoryStyles: { + blockStyles: { motion: { - colour: "#4a6cd4" + colourPrimary: "#4a6cd4", + colourSecondary: "#4361bf", + colourTertiary: "#3b56aa" }, looks: { - colour: "#8a55d7" + colourPrimary: "#8a55d7", + colourSecondary: "#7c4dc2", + colourTertiary: "#6e44ac" }, sounds: { - colour: "#bb42c3" + colourPrimary: "#bb42c3", + colourSecondary: "#a83bb0", + colourTertiary: "#96359c" }, - events: { - colour: "#c88330" + event: { + colourPrimary: "#c88330", + colourSecondary: "#b4762b", + colourTertiary: "#a06926" }, control: { - colour: "#e1a91a" + colourPrimary: "#e1a91a", + colourSecondary: "#cb9817", + colourTertiary: "#b48715" }, sensing: { - colour: "#2ca5e2" + colourPrimary: "#2ca5e2", + colourSecondary: "#2895cb", + colourTertiary: "#2a85bb" }, operators: { - colour: "#5cb712" + colourPrimary: "#5cb712", + colourSecondary: "#4a920e", + colourTertiary: "#4a920e" }, data: { - colour: "#ee7d16" + colourPrimary: "#ee7d16", + colourSecondary: "#be6412", + colourTertiary: "#be6412" }, data_lists: { - colour: "#cc5b22" + colourPrimary: "#cc5b22", + colourSecondary: "#a3491b", + colourTertiary: "#a3491b" }, more: { - colour: "#632d99" + colourPrimary: "#632d99", + colourSecondary: "#4f247a", + colourTertiary: "#4f247a" }, pen: { - colour: "#0e9a6c" + colourPrimary: "#0e9a6c", + colourSecondary: "#0c845a", + colourTertiary: "#0b7b56" } }, componentStyles: { @@ -176,7 +198,7 @@ flyoutBackgroundColour: '#d3d3d3' }, fontStyle: { - weight: '600', + weight: '700', size: '12' } }; From bd0f264216d04ba11ef114a138f6ae8fc5175b30 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Mon, 15 Dec 2025 19:24:32 +0800 Subject: [PATCH 18/21] :bug: fix(block): keep bowler hat Signed-off-by: SimonShiki --- packages/block/src/blocks/procedures.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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. */ From 05a6f92fd01df21d15bed8ad7fb57fdbe4f9114d Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Mon, 15 Dec 2025 19:53:25 +0800 Subject: [PATCH 19/21] :wrench: chore(block): use lower camel case Signed-off-by: SimonShiki --- packages/block/tests/playground.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block/tests/playground.html b/packages/block/tests/playground.html index 5ac14fab2..64cc12cc6 100644 --- a/packages/block/tests/playground.html +++ b/packages/block/tests/playground.html @@ -451,7 +451,7 @@

Blocks Playground

- From a425235b13a9cfc42fa398c4c64453c315df7ddd Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sat, 20 Dec 2025 09:38:54 +0800 Subject: [PATCH 20/21] :wrench: chore(block): refine theme exports Signed-off-by: SimonShiki --- packages/block/src/index.ts | 6 +++--- packages/block/src/theme.ts | 11 ++++++----- packages/block/tests/playground.html | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 37107efc7..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 {scratchTheme, injectCssVariables} from './theme'; +import {injectCssVariables, Scratch} from './theme'; import {registerScratchContextMenu} from './contextmenu_items'; import {registerFieldAngle} from './fields/angle'; import {registerFieldButton} from './fields/button'; @@ -147,7 +147,7 @@ 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: scratchTheme + theme: Scratch }; options = Object.assign(defaultOptions, options); const workspace = Blockly.inject(container, options); @@ -195,5 +195,5 @@ Blockly.WorkspaceSvg.prototype.addZoomControls = function() { export {reportValue} from './report_value'; export const setLocale = Blockly.setLocale; export * as callbackRegistry from './callback_registry'; -export {createTheme, getTheme, setTheme} from './theme'; +export * as Theme from './theme'; diff --git a/packages/block/src/theme.ts b/packages/block/src/theme.ts index c5fd236fd..688579ce7 100644 --- a/packages/block/src/theme.ts +++ b/packages/block/src/theme.ts @@ -185,7 +185,7 @@ function buildCategoryStyles( return categoryStyles; } -export const defaultTheme = { +const scratchTheme = { name: 'scratch', blockStyles: defaultBlockStyles, categoryStyles: buildCategoryStyles(defaultBlockStyles), @@ -236,9 +236,6 @@ export function createTheme(name: string, themeDef: ThemeDefinition): Blockly.Th * @returns The theme object, or null if not found. */ export function getTheme(name: string): Blockly.Theme | null { - if (!Blockly.registry.hasItem(Blockly.registry.Type.THEME, name)) { - return null; - } return Blockly.registry.getObject(Blockly.registry.Type.THEME, name); } @@ -258,4 +255,8 @@ export function setTheme(name: string, workspace?: Blockly.WorkspaceSvg) { injectCssVariables(); } -export const scratchTheme = Blockly.Theme.defineTheme('scratch', defaultTheme); +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/tests/playground.html b/packages/block/tests/playground.html index 64cc12cc6..4c44148e1 100644 --- a/packages/block/tests/playground.html +++ b/packages/block/tests/playground.html @@ -202,7 +202,7 @@ size: '12' } }; - ScratchBlocks.createTheme('nostalgic', nostalgicThemeDef); + ScratchBlocks.Theme.createTheme('nostalgic', nostalgicThemeDef); } function injectWorkspaces(toolbox) { @@ -440,7 +440,7 @@ } function changeTheme(themeName) { - ScratchBlocks.setTheme(themeName, mainWorkspace); + ScratchBlocks.Theme.setTheme(themeName, mainWorkspace); } From c4580e190f7e572bd184433c7bebd47bae5a4c25 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Sat, 20 Dec 2025 09:39:17 +0800 Subject: [PATCH 21/21] :wrench: chore(block): abstract IZoomControls Signed-off-by: SimonShiki --- packages/block/src/zoom_controls.ts | 50 +++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/block/src/zoom_controls.ts b/packages/block/src/zoom_controls.ts index be2e8e06a..0a78fb40d 100644 --- a/packages/block/src/zoom_controls.ts +++ b/packages/block/src/zoom_controls.ts @@ -1,16 +1,62 @@ /** * @license - * Copyright 2024 Google LLC + * 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 Blockly.IPositionable { +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