UNPKG

@blockly/field-bitmap

Version:

A field that lets users input a pixel grid with their mouse.

703 lines (646 loc) 19.6 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as Blockly from 'blockly/core'; Blockly.Msg['BUTTON_LABEL_RANDOMIZE'] = 'Randomize'; Blockly.Msg['BUTTON_LABEL_CLEAR'] = 'Clear'; export const DEFAULT_HEIGHT = 5; export const DEFAULT_WIDTH = 5; const DEFAULT_PIXEL_SIZE = 15; const DEFAULT_PIXEL_COLOURS: PixelColours = { empty: '#fff', filled: '#363d80', }; const DEFAULT_BUTTONS: Buttons = { randomize: true, clear: true, }; /** * Field for inputting a small bitmap image. * Includes a grid of clickable pixels that's exported as a bitmap. */ export class FieldBitmap extends Blockly.Field<number[][]> { private initialValue: number[][] | null = null; private imgHeight: number; private imgWidth: number; /** * Array holding info needed to unbind events. * Used for disposing. */ private boundEvents: Blockly.browserEvents.Data[] = []; /** References to UI elements */ private editorPixels: HTMLElement[][] | null = null; private blockDisplayPixels: SVGElement[][] | null = null; /** Stateful variables */ private pointerIsDown = false; private valToPaintWith?: number; buttonOptions: Buttons; pixelSize: number; pixelColours: {empty: string; filled: string}; fieldHeight?: number; /** * Constructor for the bitmap field. * * @param value 2D rectangular array of 1s and 0s. * @param validator A function that is called to validate. * @param config Config A map of options used to configure the field. */ constructor( value: number[][] | typeof Blockly.Field.SKIP_SETUP, validator?: Blockly.FieldValidator<number[][]>, config?: FieldBitmapFromJsonConfig, ) { super(value, validator, config); this.SERIALIZABLE = true; this.buttonOptions = {...DEFAULT_BUTTONS, ...config?.buttons}; this.pixelColours = {...DEFAULT_PIXEL_COLOURS, ...config?.colours}; // Configure value, height, and width const currentValue = this.getValue(); if (currentValue !== null) { this.imgHeight = currentValue.length; this.imgWidth = currentValue[0].length || 0; } else { this.imgHeight = config?.height ?? DEFAULT_HEIGHT; this.imgWidth = config?.width ?? DEFAULT_WIDTH; // Set a default empty value this.setValue(this.getEmptyArray()); } this.fieldHeight = config?.fieldHeight; if (this.fieldHeight) { this.pixelSize = this.fieldHeight / this.imgHeight; } else { this.pixelSize = DEFAULT_PIXEL_SIZE; } } /** * Constructs a FieldBitmap from a JSON arg object. * * @param options A JSON object with options. * @returns The new field instance. */ static fromJson(options: FieldBitmapFromJsonConfig) { // `this` might be a subclass of FieldBitmap if that class doesn't override the static fromJson method. return new this( options.value ?? Blockly.Field.SKIP_SETUP, undefined, options, ); } /** * Returns the width of the image in pixels. * * @returns The width in pixels. */ getImageWidth() { return this.imgWidth; } /** * Returns the height of the image in pixels. * * @returns The height in pixels. */ getImageHeight() { return this.imgHeight; } /** * Validates that a new value meets the requirements for a valid bitmap array. * * @param newValue The new value to be tested. * @returns The new value if it's valid, or null. */ // eslint-disable-next-line @typescript-eslint/naming-convention protected override doClassValidation_( newValue: number[][], ): number[][] | null | undefined; // eslint-disable-next-line @typescript-eslint/naming-convention protected override doClassValidation_( newValue?: number[][], ): number[][] | null; // eslint-disable-next-line @typescript-eslint/naming-convention protected override doClassValidation_( newValue?: number[][], ): number[][] | null | undefined { if (!newValue) { return null; } // Check if the new value is an array if (!Array.isArray(newValue)) { return null; } const newHeight = newValue.length; // The empty list is not an acceptable bitmap if (newHeight == 0) { return null; } // Check that the width matches the existing width of the image if it // already has a value. const newWidth = newValue[0].length; for (const row of newValue) { if (!Array.isArray(row)) { return null; } if (row.length !== newWidth) { return null; } } // Check if all contents of the arrays are either 0 or 1 for (const row of newValue) { for (const cell of row) { if (cell !== 0 && cell !== 1) { return null; } } } return newValue; } /** * Called when a new value has been validated and is about to be set. * * @param newValue The value that's about to be set. */ // eslint-disable-next-line protected override doValueUpdate_(newValue: number[][]) { super.doValueUpdate_(newValue); if (newValue) { this.imgHeight = newValue.length; this.imgWidth = newValue[0] ? newValue[0].length : 0; // If the field height is static, adjust the pixel size to fit. if (this.fieldHeight) { this.pixelSize = this.fieldHeight / this.imgHeight; } else { this.pixelSize = DEFAULT_PIXEL_SIZE; } } } /** * Show the bitmap editor dialog. * * @param e Optional mouse event that triggered the field to open, or * undefined if triggered programmatically. */ // eslint-disable-next-line protected override showEditor_(e?: Event) { const editor = this.dropdownCreate(); Blockly.DropDownDiv.getContentDiv().appendChild(editor); Blockly.DropDownDiv.showPositionedByField( this, this.dropdownDispose.bind(this), ); } /** * Updates the block display and editor dropdown when the field re-renders. */ // eslint-disable-next-line protected override render_() { super.render_(); if (!this.getValue()) { return; } if (this.blockDisplayPixels) { this.forAllCells((r, c) => { const pixel = this.getPixel(r, c); if (this.blockDisplayPixels) { this.blockDisplayPixels[r][c].style.fill = pixel ? this.pixelColours.filled : this.pixelColours.empty; } if (this.editorPixels) { this.editorPixels[r][c].style.background = pixel ? this.pixelColours.filled : this.pixelColours.empty; } }); } } /** * Determines whether the field is editable. * * @returns True since it is always editable. */ override updateEditable() { const editable = super.updateEditable(); // Blockly.Field's implementation sets these classes as appropriate, but // since this field has no text they just mess up the rendering of the grid // lines. const svgRoot = this.getSvgRoot(); if (svgRoot) { Blockly.utils.dom.removeClass(svgRoot, 'blocklyNonEditableField'); Blockly.utils.dom.removeClass(svgRoot, 'blocklyEditableField'); } return editable; } /** * Gets the rectangle built out of dimensions matching SVG's <g> element. * * @returns The newly created rectangle of same size as the SVG element. */ override getScaledBBox() { const boundingBox = this.getSvgRoot()?.getBoundingClientRect(); if (!boundingBox) { throw new Error('Tried to retrieve a bounding box without a rect'); } return new Blockly.utils.Rect( boundingBox.top, boundingBox.bottom, boundingBox.left, boundingBox.right, ); } /** * Creates the bitmap editor and add event listeners. * * @returns The newly created dropdown menu. */ private dropdownCreate() { const dropdownEditor = this.createElementWithClassname( 'div', 'dropdownEditor', ); if (this.buttonOptions.randomize || this.buttonOptions.clear) { dropdownEditor.classList.add('has-buttons'); } const pixelContainer = this.createElementWithClassname( 'div', 'pixelContainer', ); dropdownEditor.appendChild(pixelContainer); // This prevents the normal max-height from adding a scroll bar for large images. Blockly.DropDownDiv.getContentDiv().classList.add('contains-bitmap-editor'); this.bindEvent(dropdownEditor, 'pointermove', this.onPointerMove); this.bindEvent(dropdownEditor, 'pointerup', this.onPointerEnd); this.bindEvent(dropdownEditor, 'pointerleave', this.onPointerEnd); this.bindEvent(dropdownEditor, 'pointerdown', this.onPointerStart); this.bindEvent(dropdownEditor, 'pointercancel', this.onPointerEnd); // Stop the browser from handling touch events and cancelling the event. this.bindEvent(dropdownEditor, 'touchmove', (e: Event) => { e.preventDefault(); }); this.editorPixels = []; for (let r = 0; r < this.imgHeight; r++) { this.editorPixels.push([]); const rowDiv = this.createElementWithClassname('div', 'pixelRow'); for (let c = 0; c < this.imgWidth; c++) { // Add the button to the UI and save a reference to it const button = this.createElementWithClassname('div', 'pixelButton'); this.editorPixels[r].push(button); rowDiv.appendChild(button); // Load the current pixel colour const isOn = this.getPixel(r, c); button.style.background = isOn ? this.pixelColours.filled : this.pixelColours.empty; // Set the custom data attributes for row and column indices button.setAttribute('data-row', r.toString()); button.setAttribute('data-col', c.toString()); } pixelContainer.appendChild(rowDiv); } // Add control buttons below the pixel grid if (this.buttonOptions.randomize) { this.addControlButton( dropdownEditor, Blockly.Msg['BUTTON_LABEL_RANDOMIZE'], this.randomizePixels, ); } if (this.buttonOptions.clear) { this.addControlButton( dropdownEditor, Blockly.Msg['BUTTON_LABEL_CLEAR'], this.clearPixels, ); } if (this.blockDisplayPixels) { this.forAllCells((r, c) => { const pixel = this.getPixel(r, c); if (this.editorPixels) { this.editorPixels[r][c].style.background = pixel ? this.pixelColours.filled : this.pixelColours.empty; } }); } // Store the initial value at the start of the edit. this.initialValue = this.getValue(); return dropdownEditor; } /** * Initializes the on-block display. */ override initView() { this.blockDisplayPixels = []; for (let r = 0; r < this.imgHeight; r++) { const row = []; for (let c = 0; c < this.imgWidth; c++) { const square = Blockly.utils.dom.createSvgElement( 'rect', { x: c * this.pixelSize, y: r * this.pixelSize, width: this.pixelSize, height: this.pixelSize, fill: this.pixelColours.empty, fill_opacity: 1, // eslint-disable-line }, this.getSvgRoot(), ); row.push(square); } this.blockDisplayPixels.push(row); } } /** * Updates the size of the block based on the size of the underlying image. */ // eslint-disable-next-line protected override updateSize_() { { const newWidth = this.pixelSize * this.imgWidth; const newHeight = this.pixelSize * this.imgHeight; if (this.borderRect_) { this.borderRect_.setAttribute('width', String(newWidth)); this.borderRect_.setAttribute('height', String(newHeight)); } this.size_.width = newWidth; this.size_.height = newHeight; } } /** * Create control button. * * @param parent Parent HTML element to which control button will be added. * @param buttonText Text of the control button. * @param onClick Callback that will be attached to the control button. */ private addControlButton( parent: HTMLElement, buttonText: string, onClick: () => void, ) { const button = this.createElementWithClassname('button', 'controlButton'); button.innerText = buttonText; parent.appendChild(button); this.bindEvent(button, 'click', onClick); } /** * Disposes of events belonging to the bitmap editor. */ private dropdownDispose() { if ( this.getSourceBlock() && this.initialValue !== null && this.initialValue !== this.getValue() ) { Blockly.Events.fire( new (Blockly.Events.get(Blockly.Events.BLOCK_CHANGE))( this.sourceBlock_, 'field', this.name || null, this.initialValue, this.getValue(), ), ); } for (const event of this.boundEvents) { Blockly.browserEvents.unbind(event); } this.boundEvents.length = 0; this.editorPixels = null; // Set this.initialValue back to null. this.initialValue = null; Blockly.DropDownDiv.getContentDiv().classList.remove( 'contains-bitmap-editor', ); } /** * Constructs an array of zeros with the specified width and height. * * @returns The new value. */ private getEmptyArray(): number[][] { const newVal: number[][] = []; for (let r = 0; r < this.imgHeight; r++) { newVal.push([]); for (let c = 0; c < this.imgWidth; c++) { newVal[r].push(0); } } return newVal; } /** * Checks if a down event is on a pixel in this editor and if it is starts an * edit gesture. * * @param e The down event. */ private onPointerStart(e: PointerEvent) { const currentElement = document.elementFromPoint(e.clientX, e.clientY); const rowIndex = currentElement?.getAttribute('data-row'); const colIndex = currentElement?.getAttribute('data-col'); if (rowIndex && colIndex) { this.onPointerDownInPixel(parseInt(rowIndex), parseInt(colIndex)); this.pointerIsDown = true; e.preventDefault(); } } /** * Updates the editor if we're in an edit gesture and the pointer is over a * pixel. * * @param e The move event. */ private onPointerMove(e: PointerEvent) { if (!this.pointerIsDown) { return; } const currentElement = document.elementFromPoint(e.clientX, e.clientY); const rowIndex = currentElement?.getAttribute('data-row'); const colIndex = currentElement?.getAttribute('data-col'); if (rowIndex && colIndex) { this.updatePixelValue(parseInt(rowIndex), parseInt(colIndex)); } e.preventDefault(); } /** * Starts an interaction with the bitmap dropdown when there's a pointerdown * within one of the pixels in the editor. * * @param r Row number of grid. * @param c Column number of grid. */ private onPointerDownInPixel(r: number, c: number) { // Toggle that pixel to the opposite of its value const newPixelValue = 1 - this.getPixel(r, c); this.setPixel(r, c, newPixelValue); this.pointerIsDown = true; this.valToPaintWith = newPixelValue; } /** * Sets the specified pixel in the editor to the current value being painted. * * @param r Row number of grid. * @param c Column number of grid. */ private updatePixelValue(r: number, c: number) { if ( this.valToPaintWith !== undefined && this.getPixel(r, c) !== this.valToPaintWith ) { this.setPixel(r, c, this.valToPaintWith); } } /** * Resets pointer state (e.g. After either a pointerup event or if the * gesture is canceled). */ private onPointerEnd() { this.pointerIsDown = false; this.valToPaintWith = undefined; } /** * Sets all the pixels in the image to a random value. */ private randomizePixels() { const getRandBinary = () => Math.floor(Math.random() * 2); this.forAllCells((r, c) => { this.setPixel(r, c, getRandBinary()); }); } /** * Sets all the pixels to 0. */ private clearPixels() { const cleared = this.getEmptyArray(); this.fireIntermediateChangeEvent(cleared); this.setValue(cleared, false); } /** * Sets the value of a particular pixel. * * @param r Row number of grid. * @param c Column number of grid. * @param newValue Value of the pixel. */ private setPixel(r: number, c: number, newValue: number) { const newGrid = JSON.parse(JSON.stringify(this.getValue())); newGrid[r][c] = newValue; this.fireIntermediateChangeEvent(newGrid); this.setValue(newGrid, false); } private getPixel(row: number, column: number): number { const value = this.getValue(); if (!value) { throw new Error( 'Attempted to retrieve a pixel value when no value is set', ); } return value[row][column]; } /** * Calls a given function for all cells in the image, with the cell * coordinates as the arguments. * * @param func A function to be applied. */ private forAllCells(func: (row: number, col: number) => void) { for (let r = 0; r < this.imgHeight; r++) { for (let c = 0; c < this.imgWidth; c++) { func(r, c); } } } /** * Creates a new element with the specified type and class. * * @param elementType Type of html element. * @param className ClassName of html element. * @returns The created element. */ private createElementWithClassname(elementType: string, className: string) { const newElt = document.createElement(elementType); newElt.className = className; return newElt; } /** * Binds an event listener to the specified element. * * @param element Specified element. * @param eventName Name of the event to bind. * @param callback Function to be called on specified event. */ private bindEvent( element: HTMLElement, eventName: string, callback: (e: PointerEvent) => void, ) { this.boundEvents.push( Blockly.browserEvents.bind(element, eventName, this, callback), ); } private fireIntermediateChangeEvent(newValue: number[][]) { if (this.getSourceBlock()) { Blockly.Events.fire( new (Blockly.Events.get( Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE, ))(this.getSourceBlock(), this.name || null, this.getValue(), newValue), ); } } } interface Buttons { readonly randomize: boolean; readonly clear: boolean; } interface PixelColours { readonly empty: string; readonly filled: string; } export interface FieldBitmapFromJsonConfig extends Blockly.FieldConfig { value?: number[][]; width?: number; height?: number; buttons?: Buttons; fieldHeight?: number; colours?: PixelColours; } Blockly.fieldRegistry.register('field_bitmap', FieldBitmap); /** * CSS for bitmap field. */ Blockly.Css.register(` .dropdownEditor { align-items: center; flex-direction: column; display: flex; justify-content: center; } .dropdownEditor.has-buttons { margin-bottom: 20px; } .pixelContainer { margin: 20px; } .pixelRow { display: flex; flex-direction: row; padding: 0; margin: 0; height: ${DEFAULT_PIXEL_SIZE} } .pixelButton { width: ${DEFAULT_PIXEL_SIZE}px; height: ${DEFAULT_PIXEL_SIZE}px; border: 1px solid #000; } .pixelDisplay { white-space:pre-wrap; } .controlButton { margin: 5px 0; } .blocklyDropDownContent.contains-bitmap-editor { max-height: none; } `);