diff --git a/src-editor/src/Components/BlocklyEditor.tsx b/src-editor/src/Components/BlocklyEditor.tsx index 5159d22e..40d7ffa2 100644 --- a/src-editor/src/Components/BlocklyEditor.tsx +++ b/src-editor/src/Components/BlocklyEditor.tsx @@ -1,25 +1,14 @@ import React from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material'; +import { Cancel as IconCancel, Check as IconOk } from '@mui/icons-material'; + import { I18n, Message as DialogMessage, type ThemeType } from '@iobroker/adapter-react-v5'; + import DialogError from '../Dialogs/Error'; import DialogExport from '../Dialogs/Export'; import DialogImport from '../Dialogs/Import'; - -// Used only types of blockly, no code -import type { WorkspaceSvg } from 'blockly/core/workspace_svg'; -import type { BlockSvg } from 'blockly/core/block_svg'; -import type { FlyoutDefinition } from 'blockly/core/utils/toolbox'; -import type { Block, BlocklyOptions, ISelectable, Theme } from 'blockly'; -import type { ConnectionType } from 'blockly/core'; -import type { ITheme } from 'blockly/core/theme'; -import type { JavascriptGenerator } from 'blockly/javascript'; - -// Multiline is now plugin. Together with FieldColor -import { FieldMultilineInput, installAllBlocks as installMultiBlocks } from '@blockly/field-multilineinput'; -import { FieldColour, installAllBlocks as installColourBlocks } from '@blockly/field-colour'; -import { common as BlocklyCommon } from 'blockly/core'; -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material'; -import { Cancel as IconCancel, Check as IconOk } from '@mui/icons-material'; +import { type BlocklyType, type BlockSvg, type WorkspaceSvg, type CustomBlock, initBlockly } from './blockly-plugins'; let languageBlocklyLoaded = false; let languageOwnLoaded = false; @@ -27,71 +16,6 @@ let toolboxText: string | null = null; let toolboxXml: Element | null = null; const scriptsLoaded: string[] = []; -interface CustomBlock { - HUE: number; - blocks: Record; -} - -interface BlocklyType { - CustomBlocks: string[]; - Words: Record>; - Action: CustomBlock; - Blocks: Record; - JavaScript: JavascriptGenerator; - Procedures: { - flyoutCategoryNew: (workspace: WorkspaceSvg) => FlyoutDefinition; - }; - Xml: { - workspaceToDom: (workspace: WorkspaceSvg) => Element; - domToText: (dom: Node) => string; - blockToDom: (block: Block, opt_noId?: boolean) => Element | DocumentFragment; - domToPrettyText: (dom: Node) => string; - domToWorkspace: (xml: Element, workspace: WorkspaceSvg) => string[]; - }; - svgResize: (workspace: WorkspaceSvg) => void; - INPUT_VALUE: ConnectionType.INPUT_VALUE; - OUTPUT_VALUE: ConnectionType.OUTPUT_VALUE; - NEXT_STATEMENT: ConnectionType.NEXT_STATEMENT; - PREVIOUS_STATEMENT: ConnectionType.PREVIOUS_STATEMENT; - getSelected(): ISelectable | null; - utils: { - xml: { - textToDom: (text: string) => Element; - }; - }; - Theme: { - defineTheme: (name: string, themeObj: ITheme) => Theme; - }; - inject: (container: Element | string, opt_options?: BlocklyOptions) => WorkspaceSvg; - Themes: { - Classic: Theme; - }; - Events: { - VIEWPORT_CHANGE: 'viewport_change'; - CREATE: 'create'; - UI: 'ui'; - }; - FieldMultilineInput: typeof FieldMultilineInput; - FieldColour: typeof FieldColour; - dialog: { - prompt: (promptText: string, defaultText: string, callback: (p1: string | null) => void) => void; - setPrompt: (promptFunction: (p1: string, p2: string, p3: (p1: string | null) => void) => void) => void; - }; -} - -declare global { - interface Window { - ActiveXObject: any; - MSG: string[]; - scripts: { - loading?: boolean; - blocklyWorkspace: WorkspaceSvg; - scripts?: string[]; - }; - Blockly: BlocklyType; - } -} - // BF (2020-10-31) I have no Idea, why it does not work as static in BlocklyEditor, but outside BlocklyEditor it works function searchXml(root: Element, text: string, _id?: string, _result?: string[]): string[] { _result = _result || []; @@ -180,7 +104,7 @@ class BlocklyEditor extends React.Component void, location?: HTMLElement): void { const scriptTag = document.createElement('script'); try { diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourBlend.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourBlend.ts new file mode 100644 index 00000000..d8ec4cf0 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourBlend.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Block } from 'blockly/core'; +import type { JavascriptGenerator } from 'blockly/javascript'; +import { type Generators } from './generatorsType'; +import { registerFieldColour } from '../field_colour'; + +declare enum JavascriptOrder { + ATOMIC = 0, // 0 "" ... + NEW = 1.1, // new + MEMBER = 1.2, // . [] + FUNCTION_CALL = 2, // () + INCREMENT = 3, // ++ + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + DECREMENT = 3, // -- + BITWISE_NOT = 4.1, // ~ + UNARY_PLUS = 4.2, // + + UNARY_NEGATION = 4.3, // - + LOGICAL_NOT = 4.4, // ! + TYPEOF = 4.5, // typeof + VOID = 4.6, // void + DELETE = 4.7, // delete + AWAIT = 4.8, // await + EXPONENTIATION = 5, // ** + MULTIPLICATION = 5.1, // * + DIVISION = 5.2, // / + MODULUS = 5.3, // % + SUBTRACTION = 6.1, // - + ADDITION = 6.2, // + + BITWISE_SHIFT = 7, // << >> >>> + RELATIONAL = 8, // < <= > >= + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + IN = 8, // in + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + INSTANCEOF = 8, // instanceof + EQUALITY = 9, // == != === !== + BITWISE_AND = 10, // & + BITWISE_XOR = 11, // ^ + BITWISE_OR = 12, // | + LOGICAL_AND = 13, // && + LOGICAL_OR = 14, // || + CONDITIONAL = 15, // ?: + ASSIGNMENT = 16, // = += -= **= *= /= %= <<= >>= ... + YIELD = 17, // yield + COMMA = 18, // , + NONE = 99, +} + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_blend'; + +// Block for blending two colours together. +const jsonDefinition = { + type: BLOCK_NAME, + message0: + '%{BKY_COLOUR_BLEND_TITLE} %{BKY_COLOUR_BLEND_COLOUR1} ' + + '%1 %{BKY_COLOUR_BLEND_COLOUR2} %2 %{BKY_COLOUR_BLEND_RATIO} %3', + args0: [ + { + type: 'input_value', + name: 'COLOUR1', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'COLOUR2', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'RATIO', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_BLEND_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_BLEND_TOOLTIP}', +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript(block: Block, generator: JavascriptGenerator): [string, JavascriptOrder] { + // Blend two colours together. + const colour1 = generator.valueToCode(block, 'COLOUR1', 99 /* JavascriptOrder.NONE */) || "'#000000'"; + const colour2 = generator.valueToCode(block, 'COLOUR2', 99 /* JavascriptOrder.NONE */) || "'#000000'"; + const ratio = generator.valueToCode(block, 'RATIO', 99 /* JavascriptOrder.NONE */) || 0.5; + const functionName = generator.provideFunction_( + 'colourBlend', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}(c1, c2, ratio) { + ratio = Math.max(Math.min(Number(ratio), 1), 0); + var r1 = parseInt(c1.substring(1, 3), 16); + var g1 = parseInt(c1.substring(3, 5), 16); + var b1 = parseInt(c1.substring(5, 7), 16); + var r2 = parseInt(c2.substring(1, 3), 16); + var g2 = parseInt(c2.substring(3, 5), 16); + var b2 = parseInt(c2.substring(5, 7), 16); + var r = Math.round(r1 * (1 - ratio) + r2 * ratio); + var g = Math.round(g1 * (1 - ratio) + g2 * ratio); + var b = Math.round(b1 * (1 - ratio) + b2 * ratio); + r = ('0' + (r || 0).toString(16)).slice(-2); + g = ('0' + (g || 0).toString(16)).slice(-2); + b = ('0' + (b || 0).toString(16)).slice(-2); + return '#' + r + g + b; +} +`, + ); + const code = `${functionName}(${colour1}, ${colour2}, ${ratio})`; + return [code, 2 /* JavascriptOrder.FUNCTION_CALL */]; +} + +const definitionsDict = window.Blockly.common.createBlockDefinitionsFromJsonArray([jsonDefinition]); + +/** The colour_blend BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_blend` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}): void { + registerFieldColour(); + window.Blockly.common.defineBlocks(definitionsDict); + if (gens.javascript) { + gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + } +} diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourPicker.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourPicker.ts new file mode 100644 index 00000000..ace74e4f --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourPicker.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Block } from 'blockly/core'; +import type { JavascriptGenerator } from 'blockly/javascript'; +import { type Generators } from './generatorsType'; +import { registerFieldColour } from '../field_colour'; + +declare enum JavascriptOrder { + ATOMIC = 0, // 0 "" ... + NEW = 1.1, // new + MEMBER = 1.2, // . [] + FUNCTION_CALL = 2, // () + INCREMENT = 3, // ++ + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + DECREMENT = 3, // -- + BITWISE_NOT = 4.1, // ~ + UNARY_PLUS = 4.2, // + + UNARY_NEGATION = 4.3, // - + LOGICAL_NOT = 4.4, // ! + TYPEOF = 4.5, // typeof + VOID = 4.6, // void + DELETE = 4.7, // delete + AWAIT = 4.8, // await + EXPONENTIATION = 5, // ** + MULTIPLICATION = 5.1, // * + DIVISION = 5.2, // / + MODULUS = 5.3, // % + SUBTRACTION = 6.1, // - + ADDITION = 6.2, // + + BITWISE_SHIFT = 7, // << >> >>> + RELATIONAL = 8, // < <= > >= + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + IN = 8, // in + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + INSTANCEOF = 8, // instanceof + EQUALITY = 9, // == != === !== + BITWISE_AND = 10, // & + BITWISE_XOR = 11, // ^ + BITWISE_OR = 12, // | + LOGICAL_AND = 13, // && + LOGICAL_OR = 14, // || + CONDITIONAL = 15, // ?: + ASSIGNMENT = 16, // = += -= **= *= /= %= <<= >>= ... + YIELD = 17, // yield + COMMA = 18, // , + NONE = 99, +} + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_picker'; + +// Block for colour picker. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%1', + args0: [ + { + type: 'field_colour', + name: 'COLOUR', + colour: '#ff0000', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_PICKER_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_PICKER_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript(block: Block, generator: JavascriptGenerator): [string, JavascriptOrder] { + // Colour picker. + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, 0 /* JavascriptOrder.ATOMIC */]; +} + +const definitionsDict = window.Blockly.common.createBlockDefinitionsFromJsonArray([jsonDefinition]); + +/** The colour_picker BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_picker` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}): void { + registerFieldColour(); + window.Blockly.common.defineBlocks(definitionsDict); + if (gens.javascript) { + gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + } +} diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourRandom.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourRandom.ts new file mode 100644 index 00000000..df99241e --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourRandom.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Block } from 'blockly/core'; +import type { JavascriptGenerator } from 'blockly/javascript'; +import { type Generators } from './generatorsType'; +import { registerFieldColour } from '../field_colour'; + +declare enum JavascriptOrder { + ATOMIC = 0, // 0 "" ... + NEW = 1.1, // new + MEMBER = 1.2, // . [] + FUNCTION_CALL = 2, // () + INCREMENT = 3, // ++ + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + DECREMENT = 3, // -- + BITWISE_NOT = 4.1, // ~ + UNARY_PLUS = 4.2, // + + UNARY_NEGATION = 4.3, // - + LOGICAL_NOT = 4.4, // ! + TYPEOF = 4.5, // typeof + VOID = 4.6, // void + DELETE = 4.7, // delete + AWAIT = 4.8, // await + EXPONENTIATION = 5, // ** + MULTIPLICATION = 5.1, // * + DIVISION = 5.2, // / + MODULUS = 5.3, // % + SUBTRACTION = 6.1, // - + ADDITION = 6.2, // + + BITWISE_SHIFT = 7, // << >> >>> + RELATIONAL = 8, // < <= > >= + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + IN = 8, // in + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + INSTANCEOF = 8, // instanceof + EQUALITY = 9, // == != === !== + BITWISE_AND = 10, // & + BITWISE_XOR = 11, // ^ + BITWISE_OR = 12, // | + LOGICAL_AND = 13, // && + LOGICAL_OR = 14, // || + CONDITIONAL = 15, // ?: + ASSIGNMENT = 16, // = += -= **= *= /= %= <<= >>= ... + YIELD = 17, // yield + COMMA = 18, // , + NONE = 99, +} + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_random'; + +// Block for random colour. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%{BKY_COLOUR_RANDOM_TITLE}', + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RANDOM_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RANDOM_TOOLTIP}', +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript(block: Block, generator: JavascriptGenerator): [string, JavascriptOrder] { + // Generate a random colour. + const functionName = generator.provideFunction_( + 'colourRandom', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}() { + var num = Math.floor(Math.random() * 0x1000000); + return '#' + ('00000' + num.toString(16)).substr(-6); +} +`, + ); + const code = `${functionName}()`; + return [code, 2 /* JavascriptOrder.FUNCTION_CALL */]; +} + +const definitionsDict = window.Blockly.common.createBlockDefinitionsFromJsonArray([jsonDefinition]); + +/** The colour_random BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_picker` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}): void { + registerFieldColour(); + window.Blockly.common.defineBlocks(definitionsDict); + if (gens.javascript) { + gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + } +} diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourRgb.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourRgb.ts new file mode 100644 index 00000000..d407d3e6 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/colourRgb.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Block } from 'blockly/core'; +import type { JavascriptGenerator } from 'blockly/javascript'; +import { type Generators } from './generatorsType'; +import { registerFieldColour } from '../field_colour'; + +declare enum JavascriptOrder { + ATOMIC = 0, // 0 "" ... + NEW = 1.1, // new + MEMBER = 1.2, // . [] + FUNCTION_CALL = 2, // () + INCREMENT = 3, // ++ + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + DECREMENT = 3, // -- + BITWISE_NOT = 4.1, // ~ + UNARY_PLUS = 4.2, // + + UNARY_NEGATION = 4.3, // - + LOGICAL_NOT = 4.4, // ! + TYPEOF = 4.5, // typeof + VOID = 4.6, // void + DELETE = 4.7, // delete + AWAIT = 4.8, // await + EXPONENTIATION = 5, // ** + MULTIPLICATION = 5.1, // * + DIVISION = 5.2, // / + MODULUS = 5.3, // % + SUBTRACTION = 6.1, // - + ADDITION = 6.2, // + + BITWISE_SHIFT = 7, // << >> >>> + RELATIONAL = 8, // < <= > >= + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + IN = 8, // in + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + INSTANCEOF = 8, // instanceof + EQUALITY = 9, // == != === !== + BITWISE_AND = 10, // & + BITWISE_XOR = 11, // ^ + BITWISE_OR = 12, // | + LOGICAL_AND = 13, // && + LOGICAL_OR = 14, // || + CONDITIONAL = 15, // ?: + ASSIGNMENT = 16, // = += -= **= *= /= %= <<= >>= ... + YIELD = 17, // yield + COMMA = 18, // , + NONE = 99, +} + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_rgb'; + +// Block for composing a colour from RGB components. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%{BKY_COLOUR_RGB_TITLE} %{BKY_COLOUR_RGB_RED} %1 %{BKY_COLOUR_RGB_GREEN} %2 %{BKY_COLOUR_RGB_BLUE} %3', + args0: [ + { + type: 'input_value', + name: 'RED', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'GREEN', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'BLUE', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RGB_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RGB_TOOLTIP}', +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript(block: Block, generator: JavascriptGenerator): [string, JavascriptOrder] { + // Compose a colour from RGB components expressed as percentages. + const red = generator.valueToCode(block, 'RED', 99 /* JavascriptOrder.NONE */) || 0; + const green = generator.valueToCode(block, 'GREEN', 99 /* JavascriptOrder.NONE */) || 0; + const blue = generator.valueToCode(block, 'BLUE', 99 /* JavascriptOrder.NONE */) || 0; + const functionName = generator.provideFunction_( + 'colourRgb', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}(r, g, b) { + r = Math.max(Math.min(Number(r), 100), 0) * 2.55; + g = Math.max(Math.min(Number(g), 100), 0) * 2.55; + b = Math.max(Math.min(Number(b), 100), 0) * 2.55; + r = ('0' + (Math.round(r) || 0).toString(16)).slice(-2); + g = ('0' + (Math.round(g) || 0).toString(16)).slice(-2); + b = ('0' + (Math.round(b) || 0).toString(16)).slice(-2); + return '#' + r + g + b; +} +`, + ); + const code = `${functionName}(${red}, ${green}, ${blue})`; + return [code, 2 /* JavascriptOrder.FUNCTION_CALL*/]; +} + +const definitionsDict = window.Blockly.common.createBlockDefinitionsFromJsonArray([jsonDefinition]); + +/** The colour_rgb BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_rgb` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}): void { + registerFieldColour(); + window.Blockly.common.defineBlocks(definitionsDict); + if (gens.javascript) { + gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + } +} diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/generatorsType.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/generatorsType.ts new file mode 100644 index 00000000..a925ede1 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/blocks/generatorsType.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { JavascriptGenerator } from 'blockly/javascript'; + +/** + * An object containing zero or more generators. This is passed + * to block installation functions so that they may install + * per-block generators on any languages they support. + */ +export interface Generators { + javascript?: JavascriptGenerator; +} diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/field_colour.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/field_colour.ts new file mode 100644 index 00000000..e07f84ba --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/field_colour.ts @@ -0,0 +1,773 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Colour input field. + */ +import type { BlockSvg } from 'blockly/core/block_svg'; +import type { Data } from 'blockly/core/browser_events'; +import type { FieldValidator, FieldConfig } from 'blockly/core/field'; + +const Blockly = (window as any).Blockly; + +/** + * Class for a colour input field. + */ +export class FieldColour extends Blockly.Field { + /** The field's colour picker element. */ + private picker: HTMLElement | null = null; + + /** Index of the currently highlighted element. */ + private highlightedIndex: number | null = null; + + /** + * Array holding info needed to unbind events. + * Used for disposing. + * Ex: [[node, name, func], [node, name, func]]. + */ + private boundEvents: Data[] = []; + + /** + * Serializable fields are saved by the serializer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + SERIALIZABLE = true; + + /** Mouse cursor style when over the hotspot that initiates the editor. */ + CURSOR = 'default'; + + /** + * Used to tell if the field needs to be rendered the next time the block is + * rendered. Colour fields are statically sized, and only need to be + * rendered at initialization. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected isDirty_ = false; + + /** + * An array of colour strings for the palette. + * Copied from goog.ui.ColorPicker.SIMPLE_GRID_COLORS + */ + private colours: string[] = [ + // grays + '#ffffff', + '#cccccc', + '#c0c0c0', + '#999999', + '#666666', + '#333333', + '#000000', + // reds + '#ffcccc', + '#ff6666', + '#ff0000', + '#cc0000', + '#990000', + '#660000', + '#330000', + // oranges + '#ffcc99', + '#ff9966', + '#ff9900', + '#ff6600', + '#cc6600', + '#993300', + '#663300', + // yellows + '#ffff99', + '#ffff66', + '#ffcc66', + '#ffcc33', + '#cc9933', + '#996633', + '#663333', + // olives + '#ffffcc', + '#ffff33', + '#ffff00', + '#ffcc00', + '#999900', + '#666600', + '#333300', + // greens + '#99ff99', + '#66ff99', + '#33ff33', + '#33cc00', + '#009900', + '#006600', + '#003300', + // turquoises + '#99ffff', + '#33ffff', + '#66cccc', + '#00cccc', + '#339999', + '#336666', + '#003333', + // blues + '#ccffff', + '#66ffff', + '#33ccff', + '#3366ff', + '#3333ff', + '#000099', + '#000066', + // purples + '#ccccff', + '#9999ff', + '#6666cc', + '#6633ff', + '#6600cc', + '#333399', + '#330099', + // violets + '#ffccff', + '#ff99ff', + '#cc66cc', + '#cc33cc', + '#993399', + '#663366', + '#330033', + ]; + + /** + * An array of tooltip strings for the palette. If not the same length as + * COLOURS, the colour's hex code will be used for any missing titles. + */ + private titles: string[] = []; + + /** + * Number of columns in the palette. + */ + private columns = 7; + + /** + * @param value The initial value of the field. Should be in '#rrggbb' + * format. Defaults to the first value in the default colour array. Also + * accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a colour string & returns a validated colour + * string ('#rrggbb' format), or null to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour} + * for a list of properties this parameter supports. + */ + constructor(value?: string | symbol, validator?: FieldColourValidator, config?: FieldColourConfig) { + super(value); + + if (value === Symbol('SKIP_SETUP')) { + return; + } + if (config) { + this.configure_(config); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected configure_(config: FieldColourConfig): void { + super.configure_(config); + if (config.colourOptions) { + this.colours = config.colourOptions; + } + if (config.colourTitles) { + this.titles = config.colourTitles; + } + if (config.columns) { + this.columns = config.columns; + } + } + + /** + * Create the block UI for this colour field. + * + * @internal + */ + initView(): void { + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) { + throw Error('Constants not found'); + } + this.size_ = new Blockly.utils.Size( + constants.FIELD_COLOUR_DEFAULT_WIDTH, + constants.FIELD_COLOUR_DEFAULT_HEIGHT, + ); + this.createBorderRect_(); + this.getBorderRect().style.fillOpacity = '1'; + this.getBorderRect().setAttribute('stroke', '#fff'); + if (this.isFullBlockField()) { + this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot(); + } + } + + /** + * Defines whether this field should take up the full block or not. + * + * @returns True if this field should take up the full block. False otherwise. + */ + protected isFullBlockField(): boolean { + const block = this.getSourceBlock(); + if (!block) { + throw new Blockly.UnattachedFieldError(); + } + + const constants = this.getConstants(); + return this.blockIsSimpleReporter() && Boolean(constants?.FIELD_COLOUR_FULL_BLOCK); + } + + /** + * @returns True if the source block is a value block with a single editable + * field. + * @internal + */ + blockIsSimpleReporter(): boolean { + const block = this.getSourceBlock(); + if (!block) { + throw new Blockly.UnattachedFieldError(); + } + + if (!block.outputConnection) { + return false; + } + + for (const input of block.inputList) { + if (input.connection || input.fieldRow.length > 1) { + return false; + } + } + return true; + } + + /** + * Updates text field to match the colour/style of the block. + * + * @internal + */ + applyColour(): void { + const block = this.getSourceBlock() as BlockSvg | null; + if (!block) { + throw new Blockly.UnattachedFieldError(); + } + + if (!this.fieldGroup_) { + return; + } + + const borderRect = this.borderRect_; + if (!borderRect) { + throw new Error('The border rect has not been initialized'); + } + + if (!this.isFullBlockField()) { + borderRect.style.display = 'block'; + borderRect.style.fill = this.getValue() as string; + } else { + borderRect.style.display = 'none'; + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + block.pathObject.svgPath.setAttribute('fill', this.getValue() as string); + block.pathObject.svgPath.setAttribute('stroke', '#fff'); + } + } + + /** + * Returns the height and width of the field. + * + * This should *in general* be the only place render_ gets called from. + * + * @returns Height and width. + */ + getSize(): any { + if (this.getConstants()?.FIELD_COLOUR_FULL_BLOCK) { + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + // Full block fields have more control of the block than they should + // (i.e. updating fill colour) so they always need to be rerendered. + this.render_(); + this.isDirty_ = false; + } + return super.getSize(); + } + + /** + * Updates the colour of the block to reflect whether this is a full + * block field or not. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected render_(): void { + super.render_(); + + const block = this.getSourceBlock() as BlockSvg | null; + if (!block) { + throw new Blockly.UnattachedFieldError(); + } + // Calling applyColour updates the UI (full-block vs non-full-block) for the + // colour field, and the colour of the field/block. + block.applyColour(); + } + + /** + * Updates the size of the field based on whether it is a full block field + * or not. + * + * @param margin margin to use when positioning the field. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected updateSize_(margin?: number): void { + const constants = this.getConstants(); + if (!constants) { + return; + } + let totalWidth; + let totalHeight; + if (this.isFullBlockField()) { + const xOffset = margin ?? 0; + totalWidth = xOffset * 2; + totalHeight = constants.FIELD_TEXT_HEIGHT; + } else { + totalWidth = constants.FIELD_COLOUR_DEFAULT_WIDTH; + totalHeight = constants.FIELD_COLOUR_DEFAULT_HEIGHT; + } + + this.size_.height = totalHeight; + this.size_.width = totalWidth; + + this.positionBorderRect_(); + } + + /** + * Ensure that the input value is a valid colour. + * + * @param newValue The input value. + * @returns A valid colour, or null if invalid. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected doClassValidation_(newValue: string): string | null | undefined; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected doClassValidation_(newValue?: string): string | null; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected doClassValidation_(newValue?: string): string | null | undefined { + if (typeof newValue !== 'string') { + return null; + } + return Blockly.utils.colour.parse(newValue); + } + + /** + * Get the text for this field. Used when the block is collapsed. + * + * @returns Text representing the value of this field. + */ + getText(): string { + let colour = this.value_ as string; + // Try to use #rgb format if possible, rather than #rrggbb. + if (/^#(.)\1(.)\2(.)\3$/.test(colour)) { + colour = `#${colour[1]}${colour[3]}${colour[5]}`; + } + return colour; + } + + /** + * Set a custom colour grid for this field. + * + * @param colours Array of colours for this block, or null to use default + * (FieldColour.COLOURS). + * @param titles Optional array of colour tooltips, or null to use default + * (FieldColour.TITLES). + * @returns Returns itself (for method chaining). + */ + setColours(colours: string[], titles?: string[]): FieldColour { + this.colours = colours; + if (titles) { + this.titles = titles; + } + return this; + } + + /** + * Set a custom grid size for this field. + * + * @param columns Number of columns for this block, or 0 to use default + * (FieldColour.COLUMNS). + * @returns Returns itself (for method chaining). + */ + setColumns(columns: number): FieldColour { + this.columns = columns; + return this; + } + + /** Create and show the colour field's editor. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected showEditor_(): void { + this.dropdownCreate(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!this.picker) { + throw Error('Picker not found'); + } + Blockly.DropDownDiv.getContentDiv().appendChild(this.picker); + + Blockly.DropDownDiv.showPositionedByField(this, this.dropdownDispose.bind(this)); + + // Focus so we can start receiving keyboard events. + this.picker.focus({ preventScroll: true }); + } + + /** + * Handle a click on a colour cell. + * + * @param e Mouse event. + */ + private onClick(e: PointerEvent): void { + const cell = e.target as Element; + const colour = cell?.getAttribute('data-colour'); + if (colour !== null) { + this.setValue(colour); + Blockly.DropDownDiv.hideIfOwner(this); + } + } + + /** + * Handle a key down event. Navigate around the grid with the + * arrow keys. Enter selects the highlighted colour. + * + * @param e Keyboard event. + */ + private onKeyDown(e: KeyboardEvent): void { + let handled = true; + let highlighted: HTMLElement | null; + switch (e.key) { + case 'ArrowUp': + this.moveHighlightBy(0, -1); + break; + case 'ArrowDown': + this.moveHighlightBy(0, 1); + break; + case 'ArrowLeft': + this.moveHighlightBy(-1, 0); + break; + case 'ArrowRight': + this.moveHighlightBy(1, 0); + break; + case 'Enter': + // Select the highlighted colour. + highlighted = this.getHighlighted(); + if (highlighted) { + const colour = highlighted.getAttribute('data-colour'); + if (colour !== null) { + this.setValue(colour); + } + } + Blockly.DropDownDiv.hideWithoutAnimation(); + break; + default: + handled = false; + } + if (handled) { + e.stopPropagation(); + } + } + + /** + * Move the currently highlighted position by dx and dy. + * + * @param dx Change of x. + * @param dy Change of y. + */ + private moveHighlightBy(dx: number, dy: number): void { + if (!this.highlightedIndex) { + return; + } + + const colours = this.colours; + const columns = this.columns; + + // Get the current x and y coordinates. + let x = this.highlightedIndex % columns; + let y = Math.floor(this.highlightedIndex / columns); + + // Add the offset. + x += dx; + y += dy; + + if (dx < 0) { + // Move left one grid cell, even in RTL. + // Loop back to the end of the previous row if we have room. + if (x < 0 && y > 0) { + x = columns - 1; + y--; + } else if (x < 0) { + x = 0; + } + } else if (dx > 0) { + // Move right one grid cell, even in RTL. + // Loop to the start of the next row, if there's room. + if (x > columns - 1 && y < Math.floor(colours.length / columns) - 1) { + x = 0; + y++; + } else if (x > columns - 1) { + x--; + } + } else if (dy < 0) { + // Move up one grid cell, stop at the top. + if (y < 0) { + y = 0; + } + } else if (dy > 0) { + // Move down one grid cell, stop at the bottom. + if (y > Math.floor(colours.length / columns) - 1) { + y = Math.floor(colours.length / columns) - 1; + } + } + + // Move the highlight to the new coordinates. + const cell = (this.picker as HTMLElement).childNodes[y].childNodes[x] as Element; + const index = y * columns + x; + this.setHighlightedCell(cell, index); + } + + /** + * Handle a mouse move event. Highlight the hovered colour. + * + * @param e Mouse event. + */ + private onMouseMove(e: PointerEvent): void { + const cell = e.target as Element; + const index = cell && Number(cell.getAttribute('data-index')); + if (index !== null && index !== this.highlightedIndex) { + this.setHighlightedCell(cell, index); + } + } + + /** Handle a mouse enter event. Focus the picker. */ + private onMouseEnter(): void { + this.picker?.focus({ preventScroll: true }); + } + + /** + * Handle a mouse leave event. Blur the picker and unhighlight + * the currently highlighted colour. + */ + private onMouseLeave(): void { + this.picker?.blur(); + const highlighted = this.getHighlighted(); + if (highlighted) { + Blockly.utils.dom.removeClass(highlighted, 'blocklyColourHighlighted'); + } + } + + /** + * Returns the currently highlighted item (if any). + * + * @returns Highlighted item (null if none). + */ + private getHighlighted(): HTMLElement | null { + if (!this.highlightedIndex) { + return null; + } + + const x = this.highlightedIndex % this.columns; + const y = Math.floor(this.highlightedIndex / this.columns); + const row = this.picker?.childNodes[y]; + if (!row) { + return null; + } + return row.childNodes[x] as HTMLElement; + } + + /** + * Update the currently highlighted cell. + * + * @param cell The new cell to highlight. + * @param index The index of the new cell. + */ + private setHighlightedCell(cell: Element, index: number): void { + // Un-highlight the current item. + const highlighted = this.getHighlighted(); + if (highlighted) { + Blockly.utils.dom.removeClass(highlighted, 'blocklyColourHighlighted'); + } + // Highlight new item. + Blockly.utils.dom.addClass(cell, 'blocklyColourHighlighted'); + // Set new highlighted index. + this.highlightedIndex = index; + + // Update accessibility roles. + const cellId = cell.getAttribute('id'); + if (cellId && this.picker) { + Blockly.utils.aria.setState(this.picker, Blockly.utils.aria.State.ACTIVEDESCENDANT, cellId); + } + } + + /** Create a colour picker dropdown editor. */ + private dropdownCreate(): void { + const columns = this.columns; + const colours = this.colours; + const selectedColour = this.getValue(); + // Create the palette. + const table = document.createElement('table'); + table.className = 'blocklyColourTable'; + table.tabIndex = 0; + table.dir = 'ltr'; + Blockly.utils.aria.setRole(table, Blockly.utils.aria.Role.GRID); + Blockly.utils.aria.setState(table, Blockly.utils.aria.State.EXPANDED, true); + Blockly.utils.aria.setState(table, Blockly.utils.aria.State.ROWCOUNT, Math.floor(colours.length / columns)); + Blockly.utils.aria.setState(table, Blockly.utils.aria.State.COLCOUNT, columns); + let row: Element | null = null; + for (let i = 0; i < colours.length; i++) { + if (i % columns === 0) { + row = document.createElement('tr'); + Blockly.utils.aria.setRole(row, Blockly.utils.aria.Role.ROW); + table.appendChild(row); + } + const cell = document.createElement('td'); + (row as Element).appendChild(cell); + // This becomes the value, if clicked. + cell.setAttribute('data-colour', colours[i]); + cell.title = this.titles[i] || colours[i]; + cell.id = Blockly.utils.idGenerator.getNextUniqueId(); + cell.setAttribute('data-index', `${i}`); + Blockly.utils.aria.setRole(cell, Blockly.utils.aria.Role.GRIDCELL); + Blockly.utils.aria.setState(cell, Blockly.utils.aria.State.LABEL, colours[i]); + Blockly.utils.aria.setState(cell, Blockly.utils.aria.State.SELECTED, colours[i] === selectedColour); + cell.style.backgroundColor = colours[i]; + if (colours[i] === selectedColour) { + cell.className = 'blocklyColourSelected'; + this.highlightedIndex = i; + } + } + + // Configure event handler on the table to listen for any event in a cell. + this.boundEvents.push(Blockly.browserEvents.conditionalBind(table, 'pointerdown', this, this.onClick, true)); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind(table, 'pointermove', this, this.onMouseMove, true), + ); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind(table, 'pointerenter', this, this.onMouseEnter, true), + ); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind(table, 'pointerleave', this, this.onMouseLeave, true), + ); + this.boundEvents.push(Blockly.browserEvents.conditionalBind(table, 'keydown', this, this.onKeyDown, false)); + + this.picker = table; + } + + /** Disposes of events and DOM-references belonging to the colour editor. */ + private dropdownDispose(): void { + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + this.picker = null; + this.highlightedIndex = null; + } + + /** + * Construct a FieldColour from a JSON arg object. + * + * @param options A JSON object with options (colour). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static fromJson(options: FieldColourFromJsonConfig): FieldColour { + // `this` might be a subclass of FieldColour if that class doesn't override + // the static fromJson method. + return new this(options.colour, undefined, options); + } +} + +/** The default value for this field. */ +FieldColour.prototype.DEFAULT_VALUE = '#ffffff'; + +/** + * Register the field and any dependencies. + */ +export function registerFieldColour(): void { + Blockly.fieldRegistry.register('field_colour', FieldColour); +} + +/** + * CSS for colour picker. + */ +Blockly.Css.register(` +.blocklyColourTable { + border-collapse: collapse; + display: block; + outline: none; + padding: 1px; +} + +.blocklyColourTable>tr>td { + border: 0.5px solid #888; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + height: 20px; + padding: 0; + width: 20px; +} + +.blocklyColourTable>tr>td.blocklyColourHighlighted { + border-color: #eee; + box-shadow: 2px 2px 7px 2px rgba(0, 0, 0, 0.3); + position: relative; +} + +.blocklyColourSelected, .blocklyColourSelected:hover { + border-color: #eee !important; + outline: 1px solid #333; + position: relative; +} +`); + +/** + * Config options for the colour field. + */ +export interface FieldColourConfig extends FieldConfig { + colourOptions?: string[]; + colourTitles?: string[]; + columns?: number; +} + +/** + * fromJson config options for the colour field. + */ +export interface FieldColourFromJsonConfig extends FieldColourConfig { + colour?: string; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldColourValidator = FieldValidator; diff --git a/src-editor/src/Components/blockly-plugins/field-colour/src/index.ts b/src-editor/src/Components/blockly-plugins/field-colour/src/index.ts new file mode 100644 index 00000000..0a327612 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-colour/src/index.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as colourPicker from './blocks/colourPicker'; +import * as colourRandom from './blocks/colourRandom'; +import * as colourRgb from './blocks/colourRgb'; +import * as colourBlend from './blocks/colourBlend'; +import type { Generators } from './blocks/generatorsType'; + +export * from './field_colour'; + +// Re-export all parts of the definition. +export * as colourPicker from './blocks/colourPicker'; +export * as colourRandom from './blocks/colourRandom'; +export * as colourRgb from './blocks/colourRgb'; +export * as colourBlend from './blocks/colourBlend'; + +/** + * Install all the blocks defined in this file and all of their + * dependencies. + * + * @param generators The CodeGenerators to install per-block + * generators on. + */ +export function installAllBlocks(generators: Generators = {}): void { + colourPicker.installBlock(generators); + colourRgb.installBlock(generators); + colourRandom.installBlock(generators); + colourBlend.installBlock(generators); +} diff --git a/src-editor/src/Components/blockly-plugins/field-multilineinput/src/blocks/generatorsType.ts b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/blocks/generatorsType.ts new file mode 100644 index 00000000..a925ede1 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/blocks/generatorsType.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { JavascriptGenerator } from 'blockly/javascript'; + +/** + * An object containing zero or more generators. This is passed + * to block installation functions so that they may install + * per-block generators on any languages they support. + */ +export interface Generators { + javascript?: JavascriptGenerator; +} diff --git a/src-editor/src/Components/blockly-plugins/field-multilineinput/src/blocks/textMultiline.ts b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/blocks/textMultiline.ts new file mode 100644 index 00000000..e7a551f6 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/blocks/textMultiline.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Block } from 'blockly/core'; +import type { JavascriptGenerator } from 'blockly/javascript'; +import { type Generators } from './generatorsType'; +import { registerFieldMultilineInput } from '../field_multilineinput'; + +declare enum JavascriptOrder { + ATOMIC = 0, // 0 "" ... + NEW = 1.1, // new + MEMBER = 1.2, // . [] + FUNCTION_CALL = 2, // () + INCREMENT = 3, // ++ + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + DECREMENT = 3, // -- + BITWISE_NOT = 4.1, // ~ + UNARY_PLUS = 4.2, // + + UNARY_NEGATION = 4.3, // - + LOGICAL_NOT = 4.4, // ! + TYPEOF = 4.5, // typeof + VOID = 4.6, // void + DELETE = 4.7, // delete + AWAIT = 4.8, // await + EXPONENTIATION = 5, // ** + MULTIPLICATION = 5.1, // * + DIVISION = 5.2, // / + MODULUS = 5.3, // % + SUBTRACTION = 6.1, // - + ADDITION = 6.2, // + + BITWISE_SHIFT = 7, // << >> >>> + RELATIONAL = 8, // < <= > >= + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + IN = 8, // in + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + INSTANCEOF = 8, // instanceof + EQUALITY = 9, // == != === !== + BITWISE_AND = 10, // & + BITWISE_XOR = 11, // ^ + BITWISE_OR = 12, // | + LOGICAL_AND = 13, // && + LOGICAL_OR = 14, // || + CONDITIONAL = 15, // ?: + ASSIGNMENT = 16, // = += -= **= *= /= %= <<= >>= ... + YIELD = 17, // yield + COMMA = 18, // , + NONE = 99, +} + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'text_multiline'; + +// Block for multiline text input. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%1 %2', + args0: [ + { + type: 'field_image', + src: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAARCAYAAADpP' + + 'U2iAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAdhgAAHYYBXaITgQAAABh0RVh0' + + 'U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAP1JREFUOE+Vks0KQUEYhjm' + + 'RIja4ABtZ2dm5A3t3Ia6AUm7CylYuQRaUhZSlLZJiQbFAyRnPN33y01HOW08z88' + + '73zpwzM4F3GWOCruvGIE4/rLaV+Nq1hVGMBqzhqlxgCys4wJA65xnogMHsQ5luj' + + 'nYHTejBBCK2mE4abjCgMGhNxHgDFWjDSG07kdfVa2pZMf4ZyMAdWmpZMfYOsLiD' + + 'MYMjlMB+K613QISRhTnITnsYg5yUd0DETmEoMlkFOeIT/A58iyK5E18BuTBfgYX' + + 'fwNJv4P9/oEBerLylOnRhygmGdPpTTBZAPkde61lbQe4moWUvYUZYLfUNftIY4z' + + 'wA5X2Z9AYnQrEAAAAASUVORK5CYII=', + width: 12, + height: 17, + alt: '\u00B6', + }, + { + type: 'field_multilinetext', + name: 'TEXT', + text: '', + }, + ], + output: 'String', + style: 'text_blocks', + helpUrl: '%{BKY_TEXT_TEXT_HELPURL}', + tooltip: '%{BKY_TEXT_TEXT_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript(block: Block, generator: JavascriptGenerator): [string, JavascriptOrder] { + // Text value. + const code = generator.multiline_quote_(block.getFieldValue('TEXT')); + const order = code.indexOf('+') !== -1 ? JavascriptOrder.ADDITION : JavascriptOrder.ATOMIC; + return [code, order]; +} + +const definitionsDict = window.Blockly.common.createBlockDefinitionsFromJsonArray([jsonDefinition]); + +/** The text_multiline BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `text_multiline` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}): void { + registerFieldMultilineInput(); + + window.Blockly.common.defineBlocks(definitionsDict); + + if (gens.javascript) { + gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + } +} diff --git a/src-editor/src/Components/blockly-plugins/field-multilineinput/src/field_multilineinput.ts b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/field_multilineinput.ts new file mode 100644 index 00000000..26cfe4bc --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/field_multilineinput.ts @@ -0,0 +1,781 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Multiline text input field. + */ + +import type { WorkspaceSvg } from 'blockly/core/workspace_svg'; +import type { BlockSvg } from 'blockly/core/block_svg'; +import type { FieldTextInputConfig, FieldTextInputValidator } from 'blockly/core/field_textinput'; +import type { Rect } from 'blockly/core/utils/rect'; + +const Blockly = (window as any).Blockly; + +export class UnattachedFieldError extends Error { + /** @internal */ + constructor() { + super(`The field has not yet been attached to its input. Call appendField to attach it.`); + } +} + +/** + * Class for an editable text area input field. + */ +export class FieldMultilineInput extends Blockly.Field { + /** + * The SVG group element that will contain a text element for each text row + * when initialized. + */ + textGroup: SVGGElement | null = null; + + protected borderRect_: SVGRectElement | null = null; + + /** + * Defines the maximum number of lines of field. + * If exceeded, scrolling functionality is enabled. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected maxLines_ = Infinity; + + /** Whether Y overflow is currently occurring. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected isOverflowedY_ = false; + + /** + * @param value The initial content of the field. Should cast to a string. + * Defaults to an empty string if null or undefined. Also accepts + * Field.SKIP_SETUP if you wish to skip setup (only used by subclasses + * that want to handle configuration and setting the field value after + * their own constructors have run). + * @param validator An optional function that is called to validate any + * constraints on what the user entered. Takes the new text as an + * argument and returns either the accepted text, a replacement text, or + * null to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation} + * for a list of properties this parameter supports. + */ + constructor(value?: string | symbol, validator?: FieldMultilineInputValidator, config?: FieldMultilineInputConfig) { + super(value); + + if (value === Symbol('SKIP_SETUP')) { + return; + } + if (config) { + this.configure_(config); + } + + this.SERIALIZABLE = true; + + this.setValue(value?.toString() || ''); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected configure_(config: FieldMultilineInputConfig): void { + super.configure_(config); + if (config.maxLines) { + this.setMaxLines(config.maxLines); + } + } + + /** + * Serializes this field's value to XML. + * Should only be called by Blockly.Xml. + * + * @param fieldElement The element to populate with info about the field's + * state. + * @returns The element containing info about the field's state. + */ + toXml(fieldElement: Element): Element { + // Replace '\n' characters with HTML-escaped equivalent ' '. This is + // needed so the plain-text representation of the XML produced by + // `Blockly.Xml.domToText` will appear on a single line (this is a + // limitation of the plain-text format). + fieldElement.textContent = (this.getValue() as string).replace(/\n/g, ' '); + return fieldElement; + } + + /** + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * + * @param fieldElement The element containing info about the field's state. + */ + fromXml(fieldElement: Element): void { + this.setValue((fieldElement.textContent as string).replace(/ /g, '\n')); + } + + /** + * Saves this field's value. + * This function only exists for subclasses of FieldMultilineInput which + * predate the load/saveState API and only define to/fromXml. + * + * @returns The state of this field. + */ + saveState(): string { + const legacyState = this.saveLegacyState(FieldMultilineInput); + if (legacyState !== null) { + return legacyState; + } + return this.getValue(); + } + + /** + * Sets the field's value based on the given state. + * This function only exists for subclasses of FieldMultilineInput which + * predate the load/saveState API and only define to/fromXml. + * + * @param state The state of the variable to assign to this variable field. + */ + loadState(state: unknown): void { + if (this.loadLegacyState(Blockly.Field, state)) { + return; + } + this.setValue(state); + } + + /** + * Create the block UI for this field. + */ + initView(): void { + this.createBorderRect_(); + this.textGroup = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + { + class: 'blocklyEditableText', + }, + this.fieldGroup_, + ); + } + + /** + * Handle key down to the editor. + * + * @param e Keyboard event. + */ + protected onHtmlInputKeyDownSuper_(e: KeyboardEvent): void { + if (e.key === 'Enter') { + Blockly.WidgetDiv.hideIfOwner(this); + Blockly.dropDownDiv.hideWithoutAnimation(); + } else if (e.key === 'Escape') { + this.setValue(this.htmlInput_!.getAttribute('data-untyped-default-value'), false); + Blockly.WidgetDiv.hideIfOwner(this); + Blockly.dropDownDiv.hideWithoutAnimation(); + } else if (e.key === 'Tab') { + Blockly.WidgetDiv.hideIfOwner(this); + Blockly.dropDownDiv.hideWithoutAnimation(); + // @ts-expect-error + (this.sourceBlock_ as BlockSvg).tab(this, !e.shiftKey); + e.preventDefault(); + } + } + /** + * Handle a change to the editor. + * + * @param _e Keyboard event. + */ + private onHtmlInputChange_(_e: Event): void { + // Intermediate value changes from user input are not confirmed until the + // user closes the editor, and may be numerous. Inhibit reporting these as + // normal block change events, and instead report them as special + // intermediate changes that do not get recorded in undo history. + const oldValue = this.value_; + // Change the field's value without firing the normal change event. + this.setValue(this.getValueFromEditorText_(this.htmlInput_!.value), /* fireChangeEvent= */ false); + if (this.sourceBlock_ && Blockly.Events.isEnabled() && this.value_ !== oldValue) { + // Fire a special event indicating that the value changed but the change + // isn't complete yet and normal field change listeners can wait. + Blockly.Events.fire( + new (Blockly.Events.get( + 'block_field_intermediate_change' /* EventType.BLOCK_FIELD_INTERMEDIATE_CHANGE */, + ))(this.sourceBlock_, this.name || null, oldValue, this.value_), + ); + } + } + + /** + * A callback triggered when the user is done editing the field via the UI. + * + * @param _value The new value of the field. + */ + // eslint-disable-next-line class-methods-use-this + onFinishEditing_(_value: any): void {} + + // eslint-disable-next-line class-methods-use-this + protected getValueFromEditorText_(text: string): any { + return text; + } + + /** + * Bind handlers for user input on the text input field's editor. + * + * @param htmlInput The htmlInput to which event handlers will be bound. + */ + protected bindInputEvents_(htmlInput: HTMLElement): void { + // Trap Enter without IME and Esc to hide. + this.onKeyDownWrapper_ = Blockly.browserEvents.conditionalBind( + htmlInput, + 'keydown', + this, + this.onHtmlInputKeyDown_, + ); + // Resize after every input change. + this.onKeyInputWrapper_ = Blockly.browserEvents.conditionalBind( + htmlInput, + 'input', + this, + this.onHtmlInputChange_, + ); + } + + /** + * Get the text from this field as displayed on screen. May differ from + * getText due to ellipsis, and other formatting. + * + * @returns Currently displayed text. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected getDisplayText_(): string { + const block = this.getSourceBlock(); + if (!block) { + throw new Error(`The field has not yet been attached to its input. Call appendField to attach it.`); + } + let textLines = this.getText(); + if (!textLines) { + // Prevent the field from disappearing if empty. + return Blockly.Field.NBSP; + } + const lines = textLines.split('\n'); + textLines = ''; + const displayLinesNumber = this.isOverflowedY_ ? this.maxLines_ : lines.length; + for (let i = 0; i < displayLinesNumber; i++) { + let text: string = lines[i] || ''; + if (text.length > this.maxDisplayLength) { + // Truncate displayed string and add an ellipsis ('...'). + text = `${text.substring(0, this.maxDisplayLength - 4)}...`; + } else if (this.isOverflowedY_ && i === displayLinesNumber - 1) { + text = `${text.substring(0, text.length - 3)}...`; + } + // Replace whitespace with non-breaking spaces so the text doesn't + // collapse. + text = text.replace(/\s/g, Blockly.Field.NBSP); + + textLines += text; + if (i !== displayLinesNumber - 1) { + textLines += '\n'; + } + } + if (block.RTL) { + // The SVG is LTR, force value to be RTL. + textLines += '\u200F'; + } + return textLines; + } + + /** + * Called by setValue if the text input is valid. Updates the value of the + * field, and updates the text of the field if it is not currently being + * edited (i.e. handled by the htmlInput_). Is being redefined here to update + * overflow state of the field. + * + * @param newValue The value to be saved. The default validator guarantees + * that this is a string. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected doValueUpdate_(newValue: string): void { + super.doValueUpdate_(newValue); + if (this.value_ !== null) { + this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_; + } + } + + /** Updates the text of the textElement. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected render_(): void { + const block = this.getSourceBlock(); + if (!block) { + throw new Error(`The field has not yet been attached to its input. Call appendField to attach it.`); + } + // Remove all text group children. + let currentChild; + const textGroup = this.textGroup as SVGElement; + while ((currentChild = textGroup.firstChild)) { + textGroup.removeChild(currentChild); + } + + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) { + throw Error('Constants not found'); + } + // Add in text elements into the group. + const lines = this.getDisplayText_().split('\n'); + let y = 0; + for (let i = 0; i < lines.length; i++) { + const lineHeight = constants.FIELD_TEXT_HEIGHT + constants.FIELD_BORDER_RECT_Y_PADDING; + const span = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + { + class: 'blocklyText blocklyMultilineText', + x: constants.FIELD_BORDER_RECT_X_PADDING, + y: y + constants.FIELD_BORDER_RECT_Y_PADDING, + dy: constants.FIELD_TEXT_BASELINE, + }, + textGroup, + ); + span.appendChild(document.createTextNode(lines[i])); + y += lineHeight; + } + + if (this.isBeingEdited_) { + const htmlInput = this.htmlInput_ as HTMLElement; + if (this.isOverflowedY_) { + Blockly.utils.dom.addClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY'); + } else { + Blockly.utils.dom.removeClass(htmlInput, 'blocklyHtmlTextAreaInputOverflowedY'); + } + } + + this.updateSize_(); + + if (this.isBeingEdited_) { + if (block.RTL) { + // in RTL, we need to let the browser reflow before resizing + // in order to get the correct bounding box of the borderRect + // avoiding issue #2777. + setTimeout(this.resizeEditor_.bind(this), 0); + } else { + this.resizeEditor_(); + } + const htmlInput = this.htmlInput_ as HTMLElement; + if (!this.isTextValid_) { + Blockly.utils.dom.addClass(htmlInput, 'blocklyInvalidInput'); + Blockly.utils.aria.setState(htmlInput, Blockly.utils.aria.State.INVALID, true); + } else { + Blockly.utils.dom.removeClass(htmlInput, 'blocklyInvalidInput'); + Blockly.utils.aria.setState(htmlInput, Blockly.utils.aria.State.INVALID, false); + } + } + } + + /** Updates the size of the field based on the text. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected updateSize_(): void { + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) { + throw Error('Constants not found'); + } + const nodes = (this.textGroup as SVGElement).childNodes; + const fontSize = constants.FIELD_TEXT_FONTSIZE; + const fontWeight = constants.FIELD_TEXT_FONTWEIGHT; + const fontFamily = constants.FIELD_TEXT_FONTFAMILY; + let totalWidth = 0; + let totalHeight = 0; + for (let i = 0; i < nodes.length; i++) { + const tspan = nodes[i] as SVGTextElement; + const textWidth = Blockly.utils.dom.getFastTextWidth(tspan, fontSize, fontWeight, fontFamily); + if (textWidth > totalWidth) { + totalWidth = textWidth; + } + totalHeight += constants.FIELD_TEXT_HEIGHT + (i > 0 ? constants.FIELD_BORDER_RECT_Y_PADDING : 0); + } + if (this.isBeingEdited_) { + // The default width is based on the longest line in the display text, + // but when it's being edited, width should be calculated based on the + // absolute longest line, even if it would be truncated after editing. + // Otherwise we would get wrong editor width when there are more + // lines than this.maxLines_. + const actualEditorLines = String(this.value_).split('\n'); + const dummyTextElement = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.TEXT, { + class: 'blocklyText blocklyMultilineText', + }); + + for (let i = 0; i < actualEditorLines.length; i++) { + if (actualEditorLines[i].length > this.maxDisplayLength) { + actualEditorLines[i] = actualEditorLines[i].substring(0, this.maxDisplayLength); + } + dummyTextElement.textContent = actualEditorLines[i]; + const lineWidth = Blockly.utils.dom.getFastTextWidth( + dummyTextElement, + fontSize, + fontWeight, + fontFamily, + ); + if (lineWidth > totalWidth) { + totalWidth = lineWidth; + } + } + + const htmlInput = this.htmlInput_ as HTMLElement; + const scrollbarWidth = htmlInput.offsetWidth - htmlInput.clientWidth; + totalWidth += scrollbarWidth; + } + if (this.borderRect_) { + totalHeight += constants.FIELD_BORDER_RECT_Y_PADDING * 2; + // NOTE: Adding 1 extra px to prevent wrapping. Based on browser zoom, + // the rounding of the calculated value can result in the line wrapping + // unintentionally. + totalWidth += constants.FIELD_BORDER_RECT_X_PADDING * 2 + 1; + this.borderRect_.setAttribute('width', `${totalWidth}`); + this.borderRect_.setAttribute('height', `${totalHeight}`); + } + this.size_.width = totalWidth; + this.size_.height = totalHeight; + + this.positionBorderRect_(); + } + + private showInlineEditor_(quietInput: boolean): void { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + Blockly.WidgetDiv.show(this, block.RTL, this.widgetDispose_.bind(this), this.workspace_); + this.htmlInput_ = this.widgetCreate_(); + this.isBeingEdited_ = true; + this.valueWhenEditorWasOpened_ = this.value_; + + if (!quietInput) { + (this.htmlInput_ as HTMLElement).focus({ + preventScroll: true, + }); + this.htmlInput_.select(); + } + } + + protected getEditorText_(value: any): string { + return `${value}`; + } + + /** + * Returns the bounding box of the rendered field, accounting for workspace + * scaling. + * + * @returns An object with top, bottom, left, and right in pixels relative to + * the top left corner of the page (window coordinates). + * @internal + */ + getScaledBBox(): Rect { + let scaledWidth; + let scaledHeight; + let xy; + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + + if (this.isFullBlockField()) { + // Browsers are inconsistent in what they return for a bounding box. + // - Webkit / Blink: fill-box / object bounding box + // - Gecko: stroke-box + const bBox = (this.sourceBlock_ as BlockSvg).getHeightWidth(); + const scale = (block.workspace as WorkspaceSvg).scale; + xy = this.getAbsoluteXY_(); + scaledWidth = (bBox.width + 1) * scale; + scaledHeight = (bBox.height + 1) * scale; + + if (Blockly.utils.userAgent.GECKO) { + xy.x += 1.5 * scale; + xy.y += 1.5 * scale; + } else { + xy.x -= 0.5 * scale; + xy.y -= 0.5 * scale; + } + } else { + const bBox = this.borderRect_!.getBoundingClientRect(); + xy = Blockly.utils.style.getPageOffset(this.borderRect_!); + scaledWidth = bBox.width; + scaledHeight = bBox.height; + } + return new Blockly.utils.Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth); + } + + /** Resize the editor to fit the text. */ + protected resizeEditor_(): void { + Blockly.renderManagement.finishQueuedRenders().then(() => { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + const div = Blockly.WidgetDiv.getDiv(); + const bBox = this.getScaledBBox(); + div!.style.width = `${bBox.right - bBox.left}px`; + div!.style.height = `${bBox.bottom - bBox.top}px`; + + // In RTL mode block fields and LTR input fields the left edge moves, + // whereas the right edge is fixed. Reposition the editor. + const x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left; + const y = bBox.top; + + div!.style.left = `${x}px`; + div!.style.top = `${y}px`; + }); + } + + /** Unbind handlers for user input and workspace size changes. */ + protected unbindInputEvents_(): void { + if (this.onKeyDownWrapper_) { + Blockly.browserEvents.unbind(this.onKeyDownWrapper_); + this.onKeyDownWrapper_ = null; + } + if (this.onKeyInputWrapper_) { + Blockly.browserEvents.unbind(this.onKeyInputWrapper_); + this.onKeyInputWrapper_ = null; + } + } + + /** + * The element to bind the click handler to. If not set explicitly, defaults + * to the SVG root of the field. When this element is + * clicked on an editable field, the editor will open. + * + * @returns Element to bind click handler to. + */ + protected getClickTarget_(): Element | null { + return this.clickTarget_ || this.getSvgRoot(); + } + + /** + * Closes the editor, saves the results, and disposes of any events or + * DOM-references belonging to the editor. + */ + protected widgetDispose_(): void { + // Non-disposal related things that we do when the editor closes. + this.isBeingEdited_ = false; + this.isTextValid_ = true; + // Make sure the field's node matches the field's internal value. + this.forceRerender(); + this.onFinishEditing_(this.value_); + + if ( + this.sourceBlock_ && + Blockly.Events.isEnabled() && + this.valueWhenEditorWasOpened_ !== null && + this.valueWhenEditorWasOpened_ !== this.value_ + ) { + // When closing a field input widget, fire an event indicating that the + // user has completed a sequence of changes. The value may have changed + // multiple times while the editor was open, but this will fire an event + // containing the value when the editor was opened as well as the new one. + Blockly.Events.fire( + new (Blockly.Events.get('change' /* EventType.BLOCK_CHANGE */))( + this.sourceBlock_, + 'field', + this.name || null, + this.valueWhenEditorWasOpened_, + this.value_, + ), + ); + this.valueWhenEditorWasOpened_ = null; + } + + Blockly.Events.setGroup(false); + + // Actual disposal. + this.unbindInputEvents_(); + const style = Blockly.WidgetDiv.getDiv()!.style; + style.width = 'auto'; + style.height = 'auto'; + style.fontSize = ''; + style.transition = ''; + style.boxShadow = ''; + this.htmlInput_ = null; + + const clickTarget = this.getClickTarget_(); + if (!clickTarget) { + throw new Error('A click target has not been set.'); + } + Blockly.utils.dom.removeClass(clickTarget, 'editing'); + } + + /** + * Show the inline free-text editor on top of the text. + * Overrides the default behaviour to force rerender in order to + * correct block size, based on editor text. + * + * @param e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + * @param quietInput True if editor should be created without focus. + * Defaults to false. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + showEditor_(e?: Event, quietInput?: boolean): void { + // super.showEditor_(e, quietInput); + this.workspace_ = (this.sourceBlock_ as BlockSvg).workspace; + if ( + !quietInput && + this.workspace_.options.modalInputs && + (Blockly.utils.userAgent.MOBILE || Blockly.utils.userAgent.ANDROID || Blockly.utils.userAgent.IPAD) + ) { + this.showPromptEditor_(); + } else { + this.showInlineEditor_(!!quietInput); + } + this.forceRerender(); + } + + /** + * Create the text input editor widget. + * + * @returns The newly created text input editor. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected widgetCreate_(): HTMLTextAreaElement { + const div = Blockly.WidgetDiv.getDiv() as HTMLDivElement; + const scale = (this.workspace_ as WorkspaceSvg).getScale(); + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) { + throw Error('Constants not found'); + } + + const htmlInput = document.createElement('textarea'); + htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput'; + htmlInput.setAttribute('spellcheck', String(this.spellcheck_)); + const fontSize = `${constants.FIELD_TEXT_FONTSIZE * scale}pt`; + div.style.fontSize = fontSize; + htmlInput.style.fontSize = fontSize; + const borderRadius = `${Blockly.FieldTextInput.BORDERRADIUS * scale}px`; + htmlInput.style.borderRadius = borderRadius; + const paddingX = constants.FIELD_BORDER_RECT_X_PADDING * scale; + const paddingY = (constants.FIELD_BORDER_RECT_Y_PADDING * scale) / 2; + htmlInput.style.padding = `${paddingY}px ${paddingX}px ${paddingY}px ${paddingX}px`; + const lineHeight = constants.FIELD_TEXT_HEIGHT + constants.FIELD_BORDER_RECT_Y_PADDING; + htmlInput.style.lineHeight = `${lineHeight * scale}px`; + + div.appendChild(htmlInput); + + htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); + htmlInput.setAttribute('data-untyped-default-value', String(this.value_)); + htmlInput.setAttribute('data-old-value', ''); + if (Blockly.utils.userAgent.GECKO) { + // In FF, ensure the browser reflows before resizing to avoid issue #2777. + setTimeout(this.resizeEditor_.bind(this), 0); + } else { + this.resizeEditor_(); + } + + this.bindInputEvents_(htmlInput); + + return htmlInput; + } + + /** + * Sets the maxLines config for this field. + * + * @param maxLines Defines the maximum number of lines allowed, before + * scrolling functionality is enabled. + */ + setMaxLines(maxLines: number): void { + if (typeof maxLines === 'number' && maxLines > 0 && maxLines !== this.maxLines_) { + this.maxLines_ = maxLines; + this.forceRerender(); + } + } + + /** + * Returns the maxLines config of this field. + * + * @returns The maxLines config value. + */ + getMaxLines(): number { + return this.maxLines_; + } + + /** + * Handle key down to the editor. Override the text input definition of this + * so as to not close the editor when enter is typed in. + * + * @param e Keyboard event. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected onHtmlInputKeyDown_(e: KeyboardEvent): void { + if (e.key !== 'Enter') { + this.onHtmlInputKeyDownSuper_(e); + } + } + + /** + * Construct a FieldMultilineInput from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (text, and spellcheck). + * @returns The new field instance. + * @nocollapse + */ + static fromJson(options: FieldMultilineInputFromJsonConfig): FieldMultilineInput { + const text = Blockly.utils.parsing.replaceMessageReferences(options.text); + // `this` might be a subclass of FieldMultilineInput if that class doesn't + // the static fromJson method. + return new this(text, undefined, options); + } +} + +/** + * Register the field and any dependencies. + */ +export function registerFieldMultilineInput(): void { + Blockly.fieldRegistry.register('field_multilinetext', FieldMultilineInput); +} + +/** + * CSS for multiline field. + */ +Blockly.Css.register(` +.blocklyHtmlTextAreaInput { + font-family: monospace; + resize: none; + overflow: hidden; + height: 100%; + text-align: left; +} + +.blocklyHtmlTextAreaInputOverflowedY { + overflow-y: scroll; +} +`); + +/** + * Config options for the multiline input field. + */ +export interface FieldMultilineInputConfig extends FieldTextInputConfig { + maxLines?: number; +} + +/** + * fromJson config options for the multiline input field. + */ +export interface FieldMultilineInputFromJsonConfig extends FieldMultilineInputConfig { + text?: string; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldMultilineInputValidator = FieldTextInputValidator; diff --git a/src-editor/src/Components/blockly-plugins/field-multilineinput/src/index.ts b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/index.ts new file mode 100644 index 00000000..051fded1 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/field-multilineinput/src/index.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as textMultiline from './blocks/textMultiline'; + +export * from './field_multilineinput'; + +// Re-export all parts of the block definition. +export * as textMultiline from './blocks/textMultiline'; + +// This package currently exports a single block. More may +// be added later. +export const installAllBlocks = textMultiline.installBlock; diff --git a/src-editor/src/Components/blockly-plugins/index.ts b/src-editor/src/Components/blockly-plugins/index.ts new file mode 100644 index 00000000..e7ed4bf2 --- /dev/null +++ b/src-editor/src/Components/blockly-plugins/index.ts @@ -0,0 +1,300 @@ +// Used only types of blockly, no code +import type { WorkspaceSvg as WorkspaceSvgType } from 'blockly/core/workspace_svg'; +import type { BlockSvg as BlockSvgType } from 'blockly/core/block_svg'; +import type { FlyoutDefinition } from 'blockly/core/utils/toolbox'; +import type { Block as BlockType, BlocklyOptions, ISelectable, Theme } from 'blockly'; +import type { ConnectionType } from 'blockly/core'; +import type { ITheme } from 'blockly/core/theme'; +import type { JavascriptGenerator as JavascriptGeneratorType } from 'blockly/javascript'; +import type { RegistrableField } from 'blockly/core/field_registry'; + +// Multiline is now plugin. Together with FieldColor +import { FieldMultilineInput } from './field-multilineinput/src'; +import { FieldColour } from './field-colour/src'; +import { toJavascript as toJavascriptMultiline } from './field-multilineinput/src/blocks/textMultiline'; +import { toJavascript as toJavascriptColourBlend } from './field-colour/src/blocks/colourBlend'; +import { toJavascript as toJavascriptColourRandom } from './field-colour/src/blocks/colourRandom'; +import { toJavascript as toJavascriptColourPicker } from './field-colour/src/blocks/colourPicker'; +import { toJavascript as toJavascriptColourRgb } from './field-colour/src/blocks/colourRgb'; + +declare enum JavascriptOrder { + ATOMIC = 0, // 0 "" ... + NEW = 1.1, // new + MEMBER = 1.2, // . [] + FUNCTION_CALL = 2, // () + INCREMENT = 3, // ++ + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + DECREMENT = 3, // -- + BITWISE_NOT = 4.1, // ~ + UNARY_PLUS = 4.2, // + + UNARY_NEGATION = 4.3, // - + LOGICAL_NOT = 4.4, // ! + TYPEOF = 4.5, // typeof + VOID = 4.6, // void + DELETE = 4.7, // delete + AWAIT = 4.8, // await + EXPONENTIATION = 5, // ** + MULTIPLICATION = 5.1, // * + DIVISION = 5.2, // / + MODULUS = 5.3, // % + SUBTRACTION = 6.1, // - + ADDITION = 6.2, // + + BITWISE_SHIFT = 7, // << >> >>> + RELATIONAL = 8, // < <= > >= + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + IN = 8, // in + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + INSTANCEOF = 8, // instanceof + EQUALITY = 9, // == != === !== + BITWISE_AND = 10, // & + BITWISE_XOR = 11, // ^ + BITWISE_OR = 12, // | + LOGICAL_AND = 13, // && + LOGICAL_OR = 14, // || + CONDITIONAL = 15, // ?: + ASSIGNMENT = 16, // = += -= **= *= /= %= <<= >>= ... + YIELD = 17, // yield + COMMA = 18, // , + NONE = 99, +} + +export type JavascriptGenerator = JavascriptGeneratorType; +export type BlockSvg = BlockSvgType; +export type WorkspaceSvg = WorkspaceSvgType; +export type Block = BlockType; + +export interface CustomBlock { + HUE: number; + blocks: Record; +} + +export interface BlocklyType { + CustomBlocks: string[]; + Words: Record>; + Action: CustomBlock; + Blocks: Record; + JavaScript: JavascriptGeneratorType; + Procedures: { + flyoutCategoryNew: (workspace: WorkspaceSvg) => FlyoutDefinition; + }; + Xml: { + workspaceToDom: (workspace: WorkspaceSvg) => Element; + domToText: (dom: Node) => string; + blockToDom: (block: BlockType, opt_noId?: boolean) => Element | DocumentFragment; + domToPrettyText: (dom: Node) => string; + domToWorkspace: (xml: Element, workspace: WorkspaceSvg) => string[]; + }; + svgResize: (workspace: WorkspaceSvg) => void; + INPUT_VALUE: ConnectionType.INPUT_VALUE; + OUTPUT_VALUE: ConnectionType.OUTPUT_VALUE; + NEXT_STATEMENT: ConnectionType.NEXT_STATEMENT; + PREVIOUS_STATEMENT: ConnectionType.PREVIOUS_STATEMENT; + getSelected(): ISelectable | null; + utils: { + xml: { + textToDom: (text: string) => Element; + }; + }; + Theme: { + defineTheme: (name: string, themeObj: ITheme) => Theme; + }; + inject: (container: Element | string, opt_options?: BlocklyOptions) => WorkspaceSvg; + Themes: { + Classic: Theme; + }; + Events: { + VIEWPORT_CHANGE: 'viewport_change'; + CREATE: 'create'; + UI: 'ui'; + }; + FieldMultilineInput: typeof FieldMultilineInput; + FieldColour: typeof FieldColour; + dialog: { + prompt: (promptText: string, defaultText: string, callback: (p1: string | null) => void) => void; + setPrompt: (promptFunction: (p1: string, p2: string, p3: (p1: string | null) => void) => void) => void; + }; + fieldRegistry: { + register: (type: string, fieldClass: RegistrableField) => void; + unregister: (type: string) => void; + }; + common: { + createBlockDefinitionsFromJsonArray: (jsonArray: any[]) => Record; + defineBlocks: (blocks: { [key: string]: any }) => void; + }; +} + +declare global { + interface Window { + ActiveXObject: any; + MSG: string[]; + scripts: { + loading?: boolean; + blocklyWorkspace: WorkspaceSvg; + scripts?: string[]; + }; + Blockly: BlocklyType; + } +} + +export function initBlockly(): void { + if (!window.Blockly.FieldMultilineInput) { + window.Blockly.fieldRegistry.register( + 'field_multilinetext', + FieldMultilineInput as unknown as RegistrableField, + ); + window.Blockly.JavaScript.forBlock.text_multiline = toJavascriptMultiline; + + window.Blockly.FieldMultilineInput = FieldMultilineInput; + + Object.assign( + window.Blockly.Blocks, + window.Blockly.common.createBlockDefinitionsFromJsonArray([ + { + type: 'text_multiline', + message0: '%1 %2', + args0: [ + { + type: 'field_image', + src: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAARCAYAAADpP' + + 'U2iAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAdhgAAHYYBXaITgQAAABh0RVh0' + + 'U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAP1JREFUOE+Vks0KQUEYhjm' + + 'RIja4ABtZ2dm5A3t3Ia6AUm7CylYuQRaUhZSlLZJiQbFAyRnPN33y01HOW08z88' + + '73zpwzM4F3GWOCruvGIE4/rLaV+Nq1hVGMBqzhqlxgCys4wJA65xnogMHsQ5luj' + + 'nYHTejBBCK2mE4abjCgMGhNxHgDFWjDSG07kdfVa2pZMf4ZyMAdWmpZMfYOsLiD' + + 'MYMjlMB+K613QISRhTnITnsYg5yUd0DETmEoMlkFOeIT/A58iyK5E18BuTBfgYX' + + 'fwNJv4P9/oEBerLylOnRhygmGdPpTTBZAPkde61lbQe4moWUvYUZYLfUNftIY4z' + + 'wA5X2Z9AYnQrEAAAAASUVORK5CYII=', + width: 12, + height: 17, + alt: '\u00B6', + }, + { + type: 'field_multilinetext', + name: 'TEXT', + text: '', + }, + ], + output: 'String', + style: 'text_blocks', + helpUrl: '%{BKY_TEXT_TEXT_HELPURL}', + tooltip: '%{BKY_TEXT_TEXT_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], + }, + ]), + ); + } + if (!window.Blockly.FieldColour) { + window.Blockly.fieldRegistry.register('field_colour', FieldColour as unknown as RegistrableField); + window.Blockly.JavaScript.forBlock.colour_picker = toJavascriptColourPicker; + window.Blockly.JavaScript.forBlock.colour_blend = toJavascriptColourBlend; + window.Blockly.JavaScript.forBlock.colour_random = toJavascriptColourRandom; + window.Blockly.JavaScript.forBlock.colour_rgb = toJavascriptColourRgb; + + window.Blockly.FieldColour = FieldColour; + Object.assign( + window.Blockly.Blocks, + window.Blockly.common.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_picker', + message0: '%1', + args0: [ + { + type: 'field_colour', + name: 'COLOUR', + colour: '#ff0000', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_PICKER_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_PICKER_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], + }, + ]), + ); + + Object.assign( + window.Blockly.Blocks, + window.Blockly.common.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_random', + message0: '%{BKY_COLOUR_RANDOM_TITLE}', + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RANDOM_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RANDOM_TOOLTIP}', + }, + ]), + ); + Object.assign( + window.Blockly.Blocks, + window.Blockly.common.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_rgb', + message0: + '%{BKY_COLOUR_RGB_TITLE} %{BKY_COLOUR_RGB_RED} %1 %{BKY_COLOUR_RGB_GREEN} %2 %{BKY_COLOUR_RGB_BLUE} %3', + args0: [ + { + type: 'input_value', + name: 'RED', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'GREEN', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'BLUE', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RGB_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RGB_TOOLTIP}', + }, + ]), + ); + Object.assign( + window.Blockly.Blocks, + window.Blockly.common.createBlockDefinitionsFromJsonArray([ + { + type: 'colour_blend', + message0: + '%{BKY_COLOUR_BLEND_TITLE} %{BKY_COLOUR_BLEND_COLOUR1} ' + + '%1 %{BKY_COLOUR_BLEND_COLOUR2} %2 %{BKY_COLOUR_BLEND_RATIO} %3', + args0: [ + { + type: 'input_value', + name: 'COLOUR1', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'COLOUR2', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'RATIO', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_BLEND_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_BLEND_TOOLTIP}', + }, + ]), + ); + } +}