diff --git a/eslint.config.js b/eslint.config.js index f6a70f6b3..0123f1ff2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -218,6 +218,11 @@ module.exports = [ modifiers: ['const'], format: ['camelCase', 'UPPER_CASE', 'PascalCase'], }, + { + selector: 'classProperty', + modifiers: ['static', 'readonly'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + }, ], '@typescript-eslint/consistent-type-assertions': 'error', diff --git a/plugins/field-angle/src/field_angle.ts b/plugins/field-angle/src/field_angle.ts index 88a82d5b7..4c13e2fdd 100644 --- a/plugins/field-angle/src/field_angle.ts +++ b/plugins/field-angle/src/field_angle.ts @@ -23,11 +23,9 @@ export class FieldAngle extends Blockly.FieldNumber { */ static readonly RADIUS: number = FieldAngle.HALF - 1; - /* eslint-disable @typescript-eslint/naming-convention */ static readonly DEFAULT_PRECISION = 15; static readonly DEFAULT_MIN = 0; static readonly DEFAULT_MAX = 360; - /* eslint-enable @typescript-eslint/naming-convention */ /** * Whether the angle should increase as the angle picker is moved clockwise diff --git a/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts b/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts index 621aee14b..6c53bb4a5 100644 --- a/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts +++ b/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts @@ -188,7 +188,6 @@ class HsvColour { * Class for a colour input field that displays HSV slider widgets when clicked. */ export class FieldColourHsvSliders extends FieldColour { - /* eslint-disable @typescript-eslint/naming-convention */ /** The maximum value of the hue slider range. */ private static readonly HUE_SLIDER_MAX = 360; @@ -206,7 +205,6 @@ export class FieldColourHsvSliders extends FieldColour { * the minimum and maximum control points should be. */ static readonly THUMB_RADIUS = 12; - /* eslint-enable @typescript-eslint/naming-convention */ /** Helper colour structures to allow manipulation in the HSV colour space. */ private static readonly helperHsv: HsvColour = new HsvColour(); diff --git a/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts b/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts index 1b5d1db3a..ec8efd5ed 100644 --- a/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts +++ b/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts @@ -53,7 +53,7 @@ export interface DependentDropdownOptionsChangeJson */ export class DependentDropdownOptionsChange extends Blockly.Events.BlockBase { /** The name to register with Blockly for the type of event. */ - // eslint-disable-next-line @typescript-eslint/naming-convention + static readonly EVENT_TYPE: string = 'dropdown_options_change'; /** The name of the change event type for registering with Blockly. */ diff --git a/plugins/field-multilineinput/README.md b/plugins/field-multilineinput/README.md index 5716581e6..002e50a79 100644 --- a/plugins/field-multilineinput/README.md +++ b/plugins/field-multilineinput/README.md @@ -41,11 +41,39 @@ always a valid string, while its text could be any string entered into its editor. Unlike a text input field, this field also supports newline characters entered in the editor. +#### Editor keyboard shortcuts + +The default keyboard mapping is: + +| Key | Action | +| ----------- | ------------------------------------------------- | +| Enter | Commit the value and close the editor | +| Shift+Enter | Insert a newline at the cursor | +| Escape | Revert to the original value and close the editor | + +With `FieldMultilineInput.enterCommits = false` the mapping is swapped: + +| Key | Action | +| ----------- | ------------------------------------------------- | +| Enter | Insert a newline at the cursor | +| Shift+Enter | Commit the value and close the editor | +| Escape | Revert to the original value and close the editor | + +`enterCommits` and `showHint` are global (static) settings that apply to all +fields. Set them once before creating your blocks: + +```js +import {FieldMultilineInput} from '@blockly/field-multilineinput'; + +FieldMultilineInput.enterCommits = false; // default: true +FieldMultilineInput.showHint = false; // default: true +``` + The constructor for this field accepts three optional parameters: - `value`: The default text. Defaults to `""`. - `validator`: A function that is called to validate what the user entered. -- `config`: An object with three optional properties: +- `config`: An object with optional properties: - `maxLines`: The maximum number of lines displayed before scrolling functionality is enabled. Defaults to `Infinity`. - `spellcheck`: Whether spell checking is enabled. Defaults to `true`. @@ -186,6 +214,8 @@ textMultiline.installBlock({ ### API reference +Instance methods: + - `setMaxLines`: Sets the maximum number of displayed lines before scrolling functionality is enabled. - `getMaxLines`: Returns the maximum number of displayed lines before @@ -193,6 +223,15 @@ textMultiline.installBlock({ - `setSpellcheck`: Sets whether spell checking is enabled. - `getSpellcheck`: Returns whether spell checking is enabled. +Static (global) properties: + +- `FieldMultilineInput.enterCommits`: Whether pressing Enter commits the + value (`true`, default) or inserts a newline (`false`). Applies to all + fields. +- `FieldMultilineInput.showHint`: Whether the keyboard-shortcut hint bar is + shown in the editor. When `false`, no space is reserved for the hint bar. + Applies to all fields. + ## License Apache 2.0 diff --git a/plugins/field-multilineinput/src/field_multilineinput.ts b/plugins/field-multilineinput/src/field_multilineinput.ts index f1f62f80f..fd3f60831 100644 --- a/plugins/field-multilineinput/src/field_multilineinput.ts +++ b/plugins/field-multilineinput/src/field_multilineinput.ts @@ -14,6 +14,13 @@ import * as Blockly from 'blockly/core'; * Class for an editable text area input field. */ export class FieldMultilineInput extends Blockly.FieldTextInput { + /** + * Minimum editor width (in SVG/workspace units) when the field is open. + * Prevents narrow editors when the initial text is very short. + */ + + static readonly EDITOR_MIN_WIDTH = 150; + /** * The SVG group element that will contain a text element for each text row * when initialized. @@ -31,6 +38,21 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { // eslint-disable-next-line @typescript-eslint/naming-convention protected isOverflowedY_ = false; + /** Whether pressing Enter commits the value (default) or inserts a newline. */ + static enterCommits = true; + + /** Whether to show the keyboard-shortcut hint bar in the editor. */ + static showHint = true; + + /** The hint bar DOM element while the editor is open. */ + private hintElement: HTMLDivElement | null = null; + + /** + * Cached natural width of the hint bar in px, keyed by scale. + * Invalidated when zoom changes to avoid stale measurements. + */ + private cachedHintWidth: {scale: number; widthPx: number} | null = null; + /** * @param value The initial content of the field. Should cast to a string. * Defaults to an empty string if null or undefined. Also accepts @@ -145,6 +167,9 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { }, this.fieldGroup_, ); + if (this.fieldGroup_) { + Blockly.utils.dom.addClass(this.fieldGroup_, 'blocklyField'); + } } /** @@ -346,6 +371,14 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { const htmlInput = this.htmlInput_ as HTMLElement; const scrollbarWidth = htmlInput.offsetWidth - htmlInput.clientWidth; totalWidth += scrollbarWidth; + + if (FieldMultilineInput.showHint) { + // Reserve a row at the bottom of the editor for the keyboard hint bar. + // The textarea's padding-bottom (set in widgetCreate_) keeps the text + // and caret out of this reserved strip. + totalHeight += + constants.FIELD_TEXT_HEIGHT + constants.FIELD_BORDER_RECT_Y_PADDING; + } } if (this.borderRect_) { totalHeight += constants.FIELD_BORDER_RECT_Y_PADDING * 2; @@ -353,6 +386,30 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { // the rounding of the calculated value can result in the line wrapping // unintentionally. totalWidth += constants.FIELD_BORDER_RECT_X_PADDING * 2 + 1; + } + if (this.isBeingEdited_) { + totalWidth = Math.max(totalWidth, FieldMultilineInput.EDITOR_MIN_WIDTH); + + // Measure the hint bar's natural width to make sure the editor is wide enough. + if (FieldMultilineInput.showHint && this.hintElement?.isConnected) { + const scale = (this.workspace_ as Blockly.WorkspaceSvg).getScale(); + if (!this.cachedHintWidth || this.cachedHintWidth.scale !== scale) { + // Temporarily let the element size to its content to measure its + // natural width, unaffected by the current WidgetDiv width. + this.hintElement.style.width = 'max-content'; + const widthPx = this.hintElement.offsetWidth; + this.hintElement.style.width = ''; + if (widthPx > 0) this.cachedHintWidth = {scale, widthPx}; + } + if (this.cachedHintWidth) { + totalWidth = Math.max( + totalWidth, + this.cachedHintWidth.widthPx / scale, + ); + } + } + } + if (this.borderRect_) { this.borderRect_.setAttribute('width', `${totalWidth}`); this.borderRect_.setAttribute('height', `${totalHeight}`); } @@ -401,12 +458,16 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { 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; + const hintHeightPx = Math.ceil(lineHeight * scale); + // When the hint bar is visible it occupies one text row at the bottom; + // pad the textarea by that row so we don't edit text underneath it. + const paddingBottomPx = FieldMultilineInput.showHint + ? paddingY + hintHeightPx + : paddingY; + htmlInput.style.padding = `${paddingY}px ${paddingX}px ${paddingBottomPx}px ${paddingX}px`; htmlInput.style.lineHeight = lineHeight * scale + 'px'; - div.appendChild(htmlInput); htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); @@ -421,9 +482,102 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { this.bindInputEvents_(htmlInput); + if (FieldMultilineInput.showHint) { + this.hintElement = this.createHint(hintHeightPx); + this.hintElement.style.fontFamily = constants.FIELD_TEXT_FONTFAMILY; + div.appendChild(this.hintElement); + } + return htmlInput; } + /** + * Creates the keyboard hint bar shown at the bottom of the editor. + * + * Keys are rendered as keycaps (bordered boxes). + * + * @param heightPx The height of the hint bar in pixels. + * @returns The hint bar element. + */ + private createHint(heightPx: number): HTMLDivElement { + const shiftKey = '⇧'; + const enterKey = '⏎'; + const hint = document.createElement('div'); + hint.className = 'blocklyMultilineHint'; + hint.setAttribute('aria-hidden', 'true'); + hint.style.height = heightPx + 'px'; + + // Plain Enter + const enterGroup = document.createElement('div'); + enterGroup.className = 'blocklyMultilineHintGroup'; + enterGroup.appendChild(this.createHintKeycap(enterKey)); + enterGroup.appendChild(this.createHintColon()); + enterGroup.appendChild( + this.createHintLabel( + FieldMultilineInput.enterCommits ? 'commit' : 'newline', + ), + ); + hint.appendChild(enterGroup); + + // Modifier+Enter + const modEnterGroup = document.createElement('div'); + modEnterGroup.className = 'blocklyMultilineHintGroup'; + modEnterGroup.appendChild(this.createHintKeycap(shiftKey)); + modEnterGroup.appendChild(this.createHintKeycap(enterKey)); + modEnterGroup.appendChild(this.createHintColon()); + modEnterGroup.appendChild( + this.createHintLabel( + FieldMultilineInput.enterCommits ? 'newline' : 'commit', + ), + ); + hint.appendChild(modEnterGroup); + + return hint; + } + + /** + * Creates a colon separator element used between keycaps and the action icon. + * + * @returns The colon element. + */ + private createHintColon(): HTMLSpanElement { + const colon = document.createElement('span'); + colon.className = 'blocklyMultilineHintColon'; + colon.textContent = ':'; + return colon; + } + + /** + * Creates a keycap element displaying a single key label. + * + * @param label The key symbol to display. + * @returns The keycap element. + */ + private createHintKeycap(label: string): HTMLSpanElement { + const key = document.createElement('span'); + key.className = 'blocklyMultilineHintKey'; + key.textContent = label; + return key; + } + + /** + * Creates a text label describing the result of a key action. + * + * Uses Blockly.Msg for i18n with hardcoded English fallbacks. + * + * @param type Either 'commit' or 'newline'. + * @returns The label element. + */ + private createHintLabel(type: 'commit' | 'newline'): HTMLSpanElement { + const label = document.createElement('span'); + label.className = 'blocklyMultilineHintLabel'; + label.textContent = + type === 'commit' + ? Blockly.Msg['FIELD_MULTILINEINPUT_FINISH_EDITING'] || 'Finish editing' + : Blockly.Msg['FIELD_MULTILINEINPUT_NEW_LINE'] || 'New line'; + return label; + } + /** * Sets the maxLines config for this field. * @@ -451,18 +605,60 @@ export class FieldMultilineInput extends Blockly.FieldTextInput { } /** - * 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. + * Handle key down to the editor. + * + * Enter commits the value and closes the editor (matching FieldTextInput). + * Shift+Enter inserts a newline at the cursor. + * All other keys are handled by the parent class (Escape reverts, Tab navigates). * * @param e Keyboard event. */ // eslint-disable-next-line @typescript-eslint/naming-convention protected override onHtmlInputKeyDown_(e: KeyboardEvent) { - if (e.key !== 'Enter') { + if (e.key === 'Enter') { + if (e.isComposing) { + // Let the IME (input method editor) finalize its composition naturally; + // don't commit or insert a newline ourselves. + return; + } + const shiftPressed = e.shiftKey; + const shouldCommit = FieldMultilineInput.enterCommits + ? !shiftPressed + : shiftPressed; + if (shouldCommit) { + super.onHtmlInputKeyDown_(e); + } else { + this.insertNewline(); + e.preventDefault(); + e.stopPropagation(); + } + } else { super.onHtmlInputKeyDown_(e); } } + /** + * Inserts a newline character at the current cursor position in the textarea. + * + * The browser's default Enter-key behavior inserts a newline, but + * Shift+Enter has no default character-insertion action, so we handle it + * manually here. After splicing the character we dispatch an `input` event + * to trigger Blockly's onHtmlInputChange handler (programmatic `.value` + * assignment doesn't fire `input` on its own). + */ + private insertNewline() { + const htmlInput = this.htmlInput_; + if (!htmlInput) return; + const start = htmlInput.selectionStart ?? htmlInput.value.length; + const end = htmlInput.selectionEnd ?? start; + htmlInput.value = + htmlInput.value.substring(0, start) + + '\n' + + htmlInput.value.substring(end); + htmlInput.selectionStart = htmlInput.selectionEnd = start + 1; + htmlInput.dispatchEvent(new Event('input')); + } + /** * Construct a FieldMultilineInput from a JSON arg object, * dereferencing any string table references. @@ -503,6 +699,53 @@ Blockly.Css.register(` .blocklyHtmlTextAreaInputOverflowedY { overflow-y: scroll; } + +.blocklyMultilineHint { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + justify-content: space-around; + align-items: center; + padding: 0 4px; + box-sizing: border-box; + font-size: 0.85em; + color: rgba(0, 0, 0, 0.6); + background-color: white; + border-top: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0 0 4px 4px; + user-select: none; + pointer-events: none; +} + +.blocklyMultilineHintGroup { + display: inline-flex; + align-items: center; +} + +.blocklyMultilineHintKey { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.3em; + height: 1.3em; + padding: 0 0.25em; + margin: 0 0.1em; + border: 1px solid rgba(0, 0, 0, 0.35); + border-radius: 3px; + background-color: #f5f5f5; + line-height: 1; + box-sizing: border-box; +} + +.blocklyMultilineHintColon { + margin: 0 0.15em; +} + +.blocklyMultilineHintLabel { + white-space: nowrap; +} `); /** diff --git a/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js b/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js index 1d8129647..ce46e94d1 100644 --- a/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js +++ b/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js @@ -9,7 +9,9 @@ const { FieldMultilineInput, registerFieldMultilineInput, } = require('../src/index'); +const Blockly = require('blockly'); const {assert} = require('chai'); +const sinon = require('sinon'); const { assertFieldValue, @@ -119,6 +121,144 @@ suite('FieldMultilineInput', function () { }); }); + suite('Keyboard behavior', function () { + /** + * Dispatches a keydown event on the editor's textarea, setting the + * cursor/selection beforehand. + * @param {!HTMLTextAreaElement} textarea The editor's textarea. + * @param {!Object} options KeyboardEvent options (key, shiftKey, etc.). + * @param {number} [selectionStart] Optional selection start to set first. + * @param {number} [selectionEnd] Optional selection end to set first. + */ + const pressKey = function ( + textarea, + options, + selectionStart, + selectionEnd, + ) { + if (selectionStart !== undefined) { + textarea.selectionStart = selectionStart; + textarea.selectionEnd = selectionEnd ?? selectionStart; + } + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...options, + }), + ); + }; + + setup(function () { + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + // See https://github.com/RaspberryPiFoundation/blockly-samples/issues/2528. + global.SVGElement = window.SVGElement; + // Blockly's focus handling constructs FocusEvent, which jsdom exposes on + // window but not as a global. + global.FocusEvent = window.FocusEvent; + // jsdom doesn't provide requestAnimationFrame/cancelAnimationFrame, which + // block rendering relies on. Route them through (faked) timers so they + // exist and can be flushed deterministically in teardown. + this.clock = sinon.useFakeTimers(); + window.requestAnimationFrame = (cb) => setTimeout(cb, 0); + window.cancelAnimationFrame = (id) => clearTimeout(id); + this.workspace = Blockly.inject('blocklyDiv'); + + if (!Blockly.Blocks['multiline_block']) { + Blockly.defineBlocksWithJsonArray([ + { + type: 'multiline_block', + message0: '%1', + args0: [ + {type: 'field_multilinetext', name: 'FIELD', text: 'hello'}, + ], + }, + ]); + } + this.block = this.workspace.newBlock('multiline_block'); + this.block.initSvg(); + this.block.render(); + this.field = this.block.getField('FIELD'); + + // Open the editor so we exercise the real