diff --git a/.github/workflows/appengine_deploy.yml b/.github/workflows/appengine_deploy.yml index 438d2f094a5..938a16fc714 100644 --- a/.github/workflows/appengine_deploy.yml +++ b/.github/workflows/appengine_deploy.yml @@ -42,7 +42,7 @@ jobs: path: _deploy/ - name: Deploy to App Engine - uses: google-github-actions/deploy-appengine@v2.1.3 + uses: google-github-actions/deploy-appengine@v2.1.4 # For parameters see: # https://github.com/google-github-actions/deploy-appengine#inputs with: diff --git a/blocks/lists.ts b/blocks/lists.ts index 28ff17b3dfe..6754b6847db 100644 --- a/blocks/lists.ts +++ b/blocks/lists.ts @@ -412,6 +412,24 @@ const LISTS_GETINDEX = { this.appendDummyInput() .appendField(modeMenu, 'MODE') .appendField('', 'SPACE'); + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: this.WHERE_OPTIONS, + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as GetIndexBlock; + block.updateAt_(newAt); + } + return undefined; + }, + ); + this.appendDummyInput().appendField(menu, 'WHERE'); this.appendDummyInput('AT'); if (Msg['LISTS_GET_INDEX_TAIL']) { this.appendDummyInput('TAIL').appendField(Msg['LISTS_GET_INDEX_TAIL']); @@ -577,31 +595,6 @@ const LISTS_GETINDEX = { } else { this.appendDummyInput('AT'); } - const menu = fieldRegistry.fromJson({ - type: 'field_dropdown', - options: this.WHERE_OPTIONS, - }) as FieldDropdown; - menu.setValidator( - /** - * @param value The input value. - * @returns Null if the field has been replaced; otherwise undefined. - */ - function (this: FieldDropdown, value: string) { - const newAt = value === 'FROM_START' || value === 'FROM_END'; - // The 'isAt' variable is available due to this function being a - // closure. - if (newAt !== isAt) { - const block = this.getSourceBlock() as GetIndexBlock; - block.updateAt_(newAt); - // This menu has been destroyed and replaced. Update the - // replacement. - block.setFieldValue(value, 'WHERE'); - return null; - } - return undefined; - }, - ); - this.getInput('AT')!.appendField(menu, 'WHERE'); if (Msg['LISTS_GET_INDEX_TAIL']) { this.moveInputBefore('TAIL', null); } @@ -644,6 +637,24 @@ const LISTS_SETINDEX = { this.appendDummyInput() .appendField(operationDropdown, 'MODE') .appendField('', 'SPACE'); + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: this.WHERE_OPTIONS, + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as SetIndexBlock; + block.updateAt_(newAt); + } + return undefined; + }, + ); + this.appendDummyInput().appendField(menu, 'WHERE'); this.appendDummyInput('AT'); this.appendValueInput('TO').appendField(Msg['LISTS_SET_INDEX_INPUT_TO']); this.setInputsInline(true); @@ -756,36 +767,10 @@ const LISTS_SETINDEX = { } else { this.appendDummyInput('AT'); } - const menu = fieldRegistry.fromJson({ - type: 'field_dropdown', - options: this.WHERE_OPTIONS, - }) as FieldDropdown; - menu.setValidator( - /** - * @param value The input value. - * @returns Null if the field has been replaced; otherwise undefined. - */ - function (this: FieldDropdown, value: string) { - const newAt = value === 'FROM_START' || value === 'FROM_END'; - // The 'isAt' variable is available due to this function being a - // closure. - if (newAt !== isAt) { - const block = this.getSourceBlock() as SetIndexBlock; - block.updateAt_(newAt); - // This menu has been destroyed and replaced. Update the - // replacement. - block.setFieldValue(value, 'WHERE'); - return null; - } - return undefined; - }, - ); this.moveInputBefore('AT', 'TO'); if (this.getInput('ORDINAL')) { this.moveInputBefore('ORDINAL', 'TO'); } - - this.getInput('AT')!.appendField(menu, 'WHERE'); }, }; blocks['lists_setIndex'] = LISTS_SETINDEX; @@ -818,7 +803,30 @@ const LISTS_GETSUBLIST = { this.appendValueInput('LIST') .setCheck('Array') .appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST']); + const createMenu = (n: 1 | 2): FieldDropdown => { + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: + this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'], + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: string) { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as GetSublistBlock; + block.updateAt_(n, newAt); + } + return undefined; + }, + ); + return menu; + }; + this.appendDummyInput('WHERE1_INPUT').appendField(createMenu(1), 'WHERE1'); this.appendDummyInput('AT1'); + this.appendDummyInput('WHERE2_INPUT').appendField(createMenu(2), 'WHERE2'); this.appendDummyInput('AT2'); if (Msg['LISTS_GET_SUBLIST_TAIL']) { this.appendDummyInput('TAIL').appendField(Msg['LISTS_GET_SUBLIST_TAIL']); @@ -896,35 +904,10 @@ const LISTS_GETSUBLIST = { } else { this.appendDummyInput('AT' + n); } - const menu = fieldRegistry.fromJson({ - type: 'field_dropdown', - options: - this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'], - }) as FieldDropdown; - menu.setValidator( - /** - * @param value The input value. - * @returns Null if the field has been replaced; otherwise undefined. - */ - function (this: FieldDropdown, value: string) { - const newAt = value === 'FROM_START' || value === 'FROM_END'; - // The 'isAt' variable is available due to this function being a - // closure. - if (newAt !== isAt) { - const block = this.getSourceBlock() as GetSublistBlock; - block.updateAt_(n, newAt); - // This menu has been destroyed and replaced. - // Update the replacement. - block.setFieldValue(value, 'WHERE' + n); - return null; - } - }, - ); - this.getInput('AT' + n)!.appendField(menu, 'WHERE' + n); if (n === 1) { - this.moveInputBefore('AT1', 'AT2'); + this.moveInputBefore('AT1', 'WHERE2_INPUT'); if (this.getInput('ORDINAL1')) { - this.moveInputBefore('ORDINAL1', 'AT2'); + this.moveInputBefore('ORDINAL1', 'WHERE2_INPUT'); } } if (Msg['LISTS_GET_SUBLIST_TAIL']) { diff --git a/blocks/text.ts b/blocks/text.ts index 5ab63183641..a7ad5374ac4 100644 --- a/blocks/text.ts +++ b/blocks/text.ts @@ -216,7 +216,30 @@ const GET_SUBSTRING_BLOCK = { this.appendValueInput('STRING') .setCheck('String') .appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT']); + const createMenu = (n: 1 | 2): FieldDropdown => { + const menu = fieldRegistry.fromJson({ + type: 'field_dropdown', + options: + this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'], + }) as FieldDropdown; + menu.setValidator( + /** @param value The input value. */ + function (this: FieldDropdown, value: any): null | undefined { + const oldValue: string | null = this.getValue(); + const oldAt = oldValue === 'FROM_START' || oldValue === 'FROM_END'; + const newAt = value === 'FROM_START' || value === 'FROM_END'; + if (newAt !== oldAt) { + const block = this.getSourceBlock() as GetSubstringBlock; + block.updateAt_(n, newAt); + } + return undefined; + }, + ); + return menu; + }; + this.appendDummyInput('WHERE1_INPUT').appendField(createMenu(1), 'WHERE1'); this.appendDummyInput('AT1'); + this.appendDummyInput('WHERE2_INPUT').appendField(createMenu(2), 'WHERE2'); this.appendDummyInput('AT2'); if (Msg['TEXT_GET_SUBSTRING_TAIL']) { this.appendDummyInput('TAIL').appendField(Msg['TEXT_GET_SUBSTRING_TAIL']); @@ -288,37 +311,10 @@ const GET_SUBSTRING_BLOCK = { this.removeInput('TAIL', true); this.appendDummyInput('TAIL').appendField(Msg['TEXT_GET_SUBSTRING_TAIL']); } - const menu = fieldRegistry.fromJson({ - type: 'field_dropdown', - options: - this[('WHERE_OPTIONS_' + n) as 'WHERE_OPTIONS_1' | 'WHERE_OPTIONS_2'], - }) as FieldDropdown; - menu.setValidator( - /** - * @param value The input value. - * @returns Null if the field has been replaced; otherwise undefined. - */ - function (this: FieldDropdown, value: any): null | undefined { - const newAt = value === 'FROM_START' || value === 'FROM_END'; - // The 'isAt' variable is available due to this function being a - // closure. - if (newAt !== isAt) { - const block = this.getSourceBlock() as GetSubstringBlock; - block.updateAt_(n, newAt); - // This menu has been destroyed and replaced. - // Update the replacement. - block.setFieldValue(value, 'WHERE' + n); - return null; - } - return undefined; - }, - ); - - this.getInput('AT' + n)!.appendField(menu, 'WHERE' + n); if (n === 1) { - this.moveInputBefore('AT1', 'AT2'); + this.moveInputBefore('AT1', 'WHERE2_INPUT'); if (this.getInput('ORDINAL1')) { - this.moveInputBefore('ORDINAL1', 'AT2'); + this.moveInputBefore('ORDINAL1', 'WHERE2_INPUT'); } } }, diff --git a/core/block_svg.ts b/core/block_svg.ts index 9797a50e72a..605d25fd4ce 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -1472,9 +1472,9 @@ export class BlockSvg if (conn.isConnected() && neighbour.isConnected()) continue; if (conn.isSuperior()) { - neighbour.bumpAwayFrom(conn); + neighbour.bumpAwayFrom(conn, /* initiatedByThis = */ false); } else { - conn.bumpAwayFrom(neighbour); + conn.bumpAwayFrom(neighbour, /* initiatedByThis = */ true); } } } diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 675dbb5398e..5b5278b91ff 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -9,6 +9,7 @@ import * as touch from '../touch.js'; import {browserEvents} from '../utils.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; +import * as drag from '../utils/drag.js'; import {Rect} from '../utils/rect.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; @@ -62,6 +63,8 @@ export class TextInputBubble extends Bubble { 20 + Bubble.DOUBLE_BORDER, ); + private editable = true; + /** * @param workspace The workspace this bubble belongs to. * @param anchor The anchor location of the thing this bubble is attached to. @@ -95,6 +98,21 @@ export class TextInputBubble extends Bubble { this.onTextChange(); } + /** Sets whether or not the text in the bubble is editable. */ + setEditable(editable: boolean) { + this.editable = editable; + if (this.editable) { + this.textArea.removeAttribute('readonly'); + } else { + this.textArea.setAttribute('readonly', ''); + } + } + + /** Returns whether or not the text in the bubble is editable. */ + isEditable(): boolean { + return this.editable; + } + /** Adds a change listener to be notified when this bubble's text changes. */ addTextChangeListener(listener: () => void) { this.textChangeListeners.push(listener); @@ -224,7 +242,8 @@ export class TextInputBubble extends Bubble { return; } - this.workspace.startDrag( + drag.start( + this.workspace, e, new Coordinate( this.workspace.RTL ? -this.getSize().width : this.getSize().width, @@ -264,7 +283,7 @@ export class TextInputBubble extends Bubble { /** Handles pointer move events on the resize target. */ private onResizePointerMove(e: PointerEvent) { - const delta = this.workspace.moveDrag(e); + const delta = drag.move(this.workspace, e); this.setSize( new Size(this.workspace.RTL ? -delta.x : delta.x, delta.y), false, diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 72f72fa38b1..99c14aaa8f2 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -11,6 +11,7 @@ import * as layers from '../layers.js'; import * as touch from '../touch.js'; import {Coordinate} from '../utils/coordinate.js'; import * as dom from '../utils/dom.js'; +import * as drag from '../utils/drag.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import {WorkspaceSvg} from '../workspace_svg.js'; @@ -524,8 +525,8 @@ export class CommentView implements IRenderedElement { this.preResizeSize = this.getSize(); - // TODO(#7926): Move this into a utils file. - this.workspace.startDrag( + drag.start( + this.workspace, e, new Coordinate( this.workspace.RTL ? -this.getSize().width : this.getSize().width, @@ -569,8 +570,7 @@ export class CommentView implements IRenderedElement { /** Resizes the comment in response to a drag on the resize handle. */ private onResizePointerMove(e: PointerEvent) { - // TODO(#7926): Move this into a utils file. - const size = this.workspace.moveDrag(e); + const size = drag.move(this.workspace, e); this.setSizeWithoutFiringEvents( new Size(this.workspace.RTL ? -size.x : size.x, size.y), ); @@ -623,6 +623,7 @@ export class CommentView implements IRenderedElement { * event on the foldout icon. */ private onFoldoutDown(e: PointerEvent) { + touch.clearTouchIdentifier(); this.bringToFront(); if (browserEvents.isRightButton(e)) { e.stopPropagation(); @@ -738,6 +739,7 @@ export class CommentView implements IRenderedElement { * delete icon. */ private onDeleteDown(e: PointerEvent) { + touch.clearTouchIdentifier(); if (browserEvents.isRightButton(e)) { e.stopPropagation(); return; diff --git a/core/connection.ts b/core/connection.ts index 93caf5bcf06..9cc2c28a923 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -214,11 +214,11 @@ export class Connection implements IASTNodeLocationWithBlock { * Called when an attempted connection fails. NOP by default (i.e. for * headless workspaces). * - * @param _otherConnection Connection that this connection failed to connect - * to. + * @param _superiorConnection Connection that this connection failed to connect + * to. The provided connection should be the superior connection. * @internal */ - onFailedConnect(_otherConnection: Connection) {} + onFailedConnect(_superiorConnection: Connection) {} // NOP /** diff --git a/core/field.ts b/core/field.ts index c9d3781a257..c702abadc4a 100644 --- a/core/field.ts +++ b/core/field.ts @@ -1086,57 +1086,68 @@ export abstract class Field return; } - const classValidation = this.doClassValidation_(newValue); - const classValue = this.processValidation_( - newValue, - classValidation, - fireChangeEvent, - ); - if (classValue instanceof Error) { - if (doLogging) console.log('invalid class validation, return'); - return; + // Field validators are allowed to make changes to the workspace, which + // should get grouped with the field value change event. + const existingGroup = eventUtils.getGroup(); + if (!existingGroup) { + eventUtils.setGroup(true); } - const localValidation = this.getValidator()?.call(this, classValue); - const localValue = this.processValidation_( - classValue, - localValidation, - fireChangeEvent, - ); - if (localValue instanceof Error) { - if (doLogging) console.log('invalid local validation, return'); - return; - } + try { + const classValidation = this.doClassValidation_(newValue); + const classValue = this.processValidation_( + newValue, + classValidation, + fireChangeEvent, + ); + if (classValue instanceof Error) { + if (doLogging) console.log('invalid class validation, return'); + return; + } - const source = this.sourceBlock_; - if (source && source.disposed) { - if (doLogging) console.log('source disposed, return'); - return; - } + const localValidation = this.getValidator()?.call(this, classValue); + const localValue = this.processValidation_( + classValue, + localValidation, + fireChangeEvent, + ); + if (localValue instanceof Error) { + if (doLogging) console.log('invalid local validation, return'); + return; + } - const oldValue = this.getValue(); - if (oldValue === localValue) { - if (doLogging) console.log('same, doValueUpdate_, return'); - this.doValueUpdate_(localValue); - return; - } + const source = this.sourceBlock_; + if (source && source.disposed) { + if (doLogging) console.log('source disposed, return'); + return; + } - this.doValueUpdate_(localValue); - if (fireChangeEvent && source && eventUtils.isEnabled()) { - eventUtils.fire( - new (eventUtils.get(EventType.BLOCK_CHANGE))( - source, - 'field', - this.name || null, - oldValue, - localValue, - ), - ); - } - if (this.isDirty_) { - this.forceRerender(); + const oldValue = this.getValue(); + if (oldValue === localValue) { + if (doLogging) console.log('same, doValueUpdate_, return'); + this.doValueUpdate_(localValue); + return; + } + + this.doValueUpdate_(localValue); + if (fireChangeEvent && source && eventUtils.isEnabled()) { + eventUtils.fire( + new (eventUtils.get(EventType.BLOCK_CHANGE))( + source, + 'field', + this.name || null, + oldValue, + localValue, + ), + ); + } + if (this.isDirty_) { + this.forceRerender(); + } + if (doLogging) console.log(this.value_); + } finally { + eventUtils.setGroup(existingGroup); } - if (doLogging) console.log(this.value_); } /** diff --git a/core/field_dropdown.ts b/core/field_dropdown.ts index 71b17326d95..b1e3b5af26c 100644 --- a/core/field_dropdown.ts +++ b/core/field_dropdown.ts @@ -95,6 +95,15 @@ export class FieldDropdown extends Field { private selectedOption!: MenuOption; override clickTarget_: SVGElement | null = null; + /** + * The y offset from the top of the field to the top of the image, if an image + * is selected. + */ + protected static IMAGE_Y_OFFSET = 5; + + /** The total vertical padding above and below an image. */ + protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2; + /** * @param menuGenerator A non-empty array of options for a dropdown list, or a * function which generates these options. Also accepts Field.SKIP_SETUP @@ -128,8 +137,8 @@ export class FieldDropdown extends Field { if (menuGenerator === Field.SKIP_SETUP) return; if (Array.isArray(menuGenerator)) { - validateOptions(menuGenerator); - const trimmed = trimOptions(menuGenerator); + this.validateOptions(menuGenerator); + const trimmed = this.trimOptions(menuGenerator); this.menuGenerator_ = trimmed.options; this.prefixField = trimmed.prefix || null; this.suffixField = trimmed.suffix || null; @@ -401,7 +410,7 @@ export class FieldDropdown extends Field { if (useCache && this.generatedOptions) return this.generatedOptions; this.generatedOptions = this.menuGenerator_(); - validateOptions(this.generatedOptions); + this.validateOptions(this.generatedOptions); return this.generatedOptions; } @@ -520,7 +529,7 @@ export class FieldDropdown extends Field { const hasBorder = !!this.borderRect_; const height = Math.max( hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0, - imageHeight + IMAGE_Y_PADDING, + imageHeight + FieldDropdown.IMAGE_Y_PADDING, ); const xPadding = hasBorder ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING @@ -661,6 +670,127 @@ export class FieldDropdown extends Field { // override the static fromJson method. return new this(options.options, undefined, options); } + + /** + * Factor out common words in statically defined options. + * Create prefix and/or suffix labels. + */ + protected trimOptions(options: MenuOption[]): { + options: MenuOption[]; + prefix?: string; + suffix?: string; + } { + let hasImages = false; + const trimmedOptions = options.map(([label, value]): MenuOption => { + if (typeof label === 'string') { + return [parsing.replaceMessageReferences(label), value]; + } + + hasImages = true; + // Copy the image properties so they're not influenced by the original. + // NOTE: No need to deep copy since image properties are only 1 level deep. + const imageLabel = + label.alt !== null + ? {...label, alt: parsing.replaceMessageReferences(label.alt)} + : {...label}; + return [imageLabel, value]; + }); + + if (hasImages || options.length < 2) return {options: trimmedOptions}; + + const stringOptions = trimmedOptions as [string, string][]; + const stringLabels = stringOptions.map(([label]) => label); + + const shortest = utilsString.shortestStringLength(stringLabels); + const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest); + const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest); + + if ( + (!prefixLength && !suffixLength) || + shortest <= prefixLength + suffixLength + ) { + // One or more strings will entirely vanish if we proceed. Abort. + return {options: stringOptions}; + } + + const prefix = prefixLength + ? stringLabels[0].substring(0, prefixLength - 1) + : undefined; + const suffix = suffixLength + ? stringLabels[0].substr(1 - suffixLength) + : undefined; + return { + options: this.applyTrim(stringOptions, prefixLength, suffixLength), + prefix, + suffix, + }; + } + + /** + * Use the calculated prefix and suffix lengths to trim all of the options in + * the given array. + * + * @param options Array of option tuples: + * (human-readable text or image, language-neutral name). + * @param prefixLength The length of the common prefix. + * @param suffixLength The length of the common suffix + * @returns A new array with all of the option text trimmed. + */ + private applyTrim( + options: [string, string][], + prefixLength: number, + suffixLength: number, + ): MenuOption[] { + return options.map(([text, value]) => [ + text.substring(prefixLength, text.length - suffixLength), + value, + ]); + } + + /** + * Validates the data structure to be processed as an options list. + * + * @param options The proposed dropdown options. + * @throws {TypeError} If proposed options are incorrectly structured. + */ + protected validateOptions(options: MenuOption[]) { + if (!Array.isArray(options)) { + throw TypeError('FieldDropdown options must be an array.'); + } + if (!options.length) { + throw TypeError('FieldDropdown options must not be an empty array.'); + } + let foundError = false; + for (let i = 0; i < options.length; i++) { + const tuple = options[i]; + if (!Array.isArray(tuple)) { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option must be an array. + Found: ${tuple}`, + ); + } else if (typeof tuple[1] !== 'string') { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option id must be a string. + Found ${tuple[1]} in: ${tuple}`, + ); + } else if ( + tuple[0] && + typeof tuple[0] !== 'string' && + typeof tuple[0].src !== 'string' + ) { + foundError = true; + console.error( + `Invalid option[${i}]: Each FieldDropdown option must have a string + label or image description. Found ${tuple[0]} in: ${tuple}`, + ); + } + } + if (foundError) { + throw TypeError('Found invalid FieldDropdown options.'); + } + } } /** @@ -721,147 +851,4 @@ export interface FieldDropdownFromJsonConfig extends FieldDropdownConfig { */ export type FieldDropdownValidator = FieldValidator; -/** - * The y offset from the top of the field to the top of the image, if an image - * is selected. - */ -const IMAGE_Y_OFFSET = 5; - -/** The total vertical padding above and below an image. */ -const IMAGE_Y_PADDING: number = IMAGE_Y_OFFSET * 2; - -/** - * Factor out common words in statically defined options. - * Create prefix and/or suffix labels. - */ -function trimOptions(options: MenuOption[]): { - options: MenuOption[]; - prefix?: string; - suffix?: string; -} { - let hasImages = false; - const trimmedOptions = options.map(([label, value]): MenuOption => { - if (typeof label === 'string') { - return [parsing.replaceMessageReferences(label), value]; - } - - hasImages = true; - // Copy the image properties so they're not influenced by the original. - // NOTE: No need to deep copy since image properties are only 1 level deep. - const imageLabel = - label.alt !== null - ? {...label, alt: parsing.replaceMessageReferences(label.alt)} - : {...label}; - return [imageLabel, value]; - }); - - if (hasImages || options.length < 2) return {options: trimmedOptions}; - - const stringOptions = trimmedOptions as [string, string][]; - const stringLabels = stringOptions.map(([label]) => label); - - const shortest = utilsString.shortestStringLength(stringLabels); - const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest); - const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest); - - if ( - (!prefixLength && !suffixLength) || - shortest <= prefixLength + suffixLength - ) { - // One or more strings will entirely vanish if we proceed. Abort. - return {options: stringOptions}; - } - - const prefix = prefixLength - ? stringLabels[0].substring(0, prefixLength - 1) - : undefined; - const suffix = suffixLength - ? stringLabels[0].substr(1 - suffixLength) - : undefined; - return { - options: applyTrim(stringOptions, prefixLength, suffixLength), - prefix, - suffix, - }; -} - -/** - * Use the calculated prefix and suffix lengths to trim all of the options in - * the given array. - * - * @param options Array of option tuples: - * (human-readable text or image, language-neutral name). - * @param prefixLength The length of the common prefix. - * @param suffixLength The length of the common suffix - * @returns A new array with all of the option text trimmed. - */ -function applyTrim( - options: [string, string][], - prefixLength: number, - suffixLength: number, -): MenuOption[] { - return options.map(([text, value]) => [ - text.substring(prefixLength, text.length - suffixLength), - value, - ]); -} - -/** - * Validates the data structure to be processed as an options list. - * - * @param options The proposed dropdown options. - * @throws {TypeError} If proposed options are incorrectly structured. - */ -function validateOptions(options: MenuOption[]) { - if (!Array.isArray(options)) { - throw TypeError('FieldDropdown options must be an array.'); - } - if (!options.length) { - throw TypeError('FieldDropdown options must not be an empty array.'); - } - let foundError = false; - for (let i = 0; i < options.length; i++) { - const tuple = options[i]; - if (!Array.isArray(tuple)) { - foundError = true; - console.error( - 'Invalid option[' + - i + - ']: Each FieldDropdown option must be an ' + - 'array. Found: ', - tuple, - ); - } else if (typeof tuple[1] !== 'string') { - foundError = true; - console.error( - 'Invalid option[' + - i + - ']: Each FieldDropdown option id must be ' + - 'a string. Found ' + - tuple[1] + - ' in: ', - tuple, - ); - } else if ( - tuple[0] && - typeof tuple[0] !== 'string' && - typeof tuple[0].src !== 'string' - ) { - foundError = true; - console.error( - 'Invalid option[' + - i + - ']: Each FieldDropdown option must have a ' + - 'string label or image description. Found' + - tuple[0] + - ' in: ', - tuple, - ); - } - } - if (foundError) { - throw TypeError('Found invalid FieldDropdown options.'); - } -} - fieldRegistry.register('field_dropdown', FieldDropdown); diff --git a/core/field_input.ts b/core/field_input.ts index 7ef5a01bc7a..722316f4f46 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -28,8 +28,8 @@ import { UnattachedFieldError, } from './field.js'; import {Msg} from './msg.js'; +import * as renderManagement from './render_management.js'; import * as aria from './utils/aria.js'; -import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; import {Size} from './utils/size.js'; import * as userAgent from './utils/useragent.js'; @@ -630,22 +630,22 @@ export abstract class FieldInput extends Field< /** Resize the editor to fit the text. */ protected resizeEditor_() { - const block = this.getSourceBlock(); - if (!block) { - throw new UnattachedFieldError(); - } - const div = WidgetDiv.getDiv(); - const bBox = this.getScaledBBox(); - div!.style.width = bBox.right - bBox.left + 'px'; - div!.style.height = bBox.bottom - bBox.top + 'px'; + renderManagement.finishQueuedRenders().then(() => { + const block = this.getSourceBlock(); + if (!block) throw new UnattachedFieldError(); + const div = 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 xy = new Coordinate(x, bBox.top); + // 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 = xy.x + 'px'; - div!.style.top = xy.y + 'px'; + div!.style.left = `${x}px`; + div!.style.top = `${y}px`; + }); } /** @@ -657,7 +657,7 @@ export abstract class FieldInput extends Field< * div. */ override repositionForWindowResize(): boolean { - const block = this.getSourceBlock(); + const block = this.getSourceBlock()?.getRootBlock(); // This shouldn't be possible. We should never have a WidgetDiv if not using // rendered blocks. if (!(block instanceof BlockSvg)) return false; diff --git a/core/icons/comment_icon.ts b/core/icons/comment_icon.ts index 7cf5431d7ac..8cdf779187c 100644 --- a/core/icons/comment_icon.ts +++ b/core/icons/comment_icon.ts @@ -8,7 +8,6 @@ import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; -import {TextBubble} from '../bubbles/text_bubble.js'; import {TextInputBubble} from '../bubbles/textinput_bubble.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; @@ -47,12 +46,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { */ static readonly WEIGHT = 3; - /** The bubble used to show editable text to the user. */ + /** The bubble used to show comment text to the user. */ private textInputBubble: TextInputBubble | null = null; - /** The bubble used to show non-editable text to the user. */ - private textBubble: TextBubble | null = null; - /** The text of this comment. */ private text = ''; @@ -118,7 +114,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { override dispose() { super.dispose(); this.textInputBubble?.dispose(); - this.textBubble?.dispose(); } override getWeight(): number { @@ -133,7 +128,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { super.applyColour(); const colour = (this.sourceBlock as BlockSvg).style.colourPrimary; this.textInputBubble?.setColour(colour); - this.textBubble?.setColour(colour); } /** @@ -153,7 +147,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { super.onLocationChange(blockOrigin); const anchorLocation = this.getAnchorLocation(); this.textInputBubble?.setAnchorLocation(anchorLocation); - this.textBubble?.setAnchorLocation(anchorLocation); } /** Sets the text of this comment. Updates any bubbles if they are visible. */ @@ -170,7 +163,6 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { ); this.text = text; this.textInputBubble?.setText(this.text); - this.textBubble?.setText(this.text); } /** Returns the text of this comment. */ @@ -302,33 +294,31 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable { * to update the state of this icon in response to changes in the bubble. */ private showEditableBubble() { - this.textInputBubble = new TextInputBubble( - this.sourceBlock.workspace as WorkspaceSvg, - this.getAnchorLocation(), - this.getBubbleOwnerRect(), - ); - this.textInputBubble.setText(this.getText()); - this.textInputBubble.setSize(this.bubbleSize, true); - this.textInputBubble.addTextChangeListener(() => this.onTextChange()); - this.textInputBubble.addSizeChangeListener(() => this.onSizeChange()); + this.createBubble(); + this.textInputBubble?.addTextChangeListener(() => this.onTextChange()); + this.textInputBubble?.addSizeChangeListener(() => this.onSizeChange()); } /** Shows the non editable text bubble for this comment. */ private showNonEditableBubble() { - this.textBubble = new TextBubble( - this.getText(), + this.createBubble(); + this.textInputBubble?.setEditable(false); + } + + protected createBubble() { + this.textInputBubble = new TextInputBubble( this.sourceBlock.workspace as WorkspaceSvg, this.getAnchorLocation(), this.getBubbleOwnerRect(), ); + this.textInputBubble.setText(this.getText()); + this.textInputBubble.setSize(this.bubbleSize, true); } /** Hides any open bubbles owned by this comment. */ private hideBubble() { this.textInputBubble?.dispose(); this.textInputBubble = null; - this.textBubble?.dispose(); - this.textBubble = null; } /** diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 27c532839d2..f73dc0628cc 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -117,59 +117,85 @@ export class RenderedConnection extends Connection { * Move the block(s) belonging to the connection to a point where they don't * visually interfere with the specified connection. * - * @param staticConnection The connection to move away from. + * @param superiorConnection The connection to move away from. The provided + * connection should be the superior connection and should not be + * connected to this connection. + * @param initiatedByThis Whether or not the block group that was manipulated + * recently causing bump checks is associated with the inferior + * connection. Defaults to false. * @internal */ - bumpAwayFrom(staticConnection: RenderedConnection) { + bumpAwayFrom( + superiorConnection: RenderedConnection, + initiatedByThis = false, + ) { if (this.sourceBlock_.workspace.isDragging()) { // Don't move blocks around while the user is doing the same. return; } - // Move the root block. - let rootBlock = this.sourceBlock_.getRootBlock(); - if (rootBlock.isInFlyout) { + let offsetX = + config.snapRadius + Math.floor(Math.random() * BUMP_RANDOMNESS); + let offsetY = + config.snapRadius + Math.floor(Math.random() * BUMP_RANDOMNESS); + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + const inferiorConnection = this; + const superiorRootBlock = superiorConnection.sourceBlock_.getRootBlock(); + const inferiorRootBlock = inferiorConnection.sourceBlock_.getRootBlock(); + + if (superiorRootBlock.isInFlyout || inferiorRootBlock.isInFlyout) { // Don't move blocks around in a flyout. return; } - let reverse = false; - if (!rootBlock.isMovable()) { - // Can't bump an uneditable block away. + let moveInferior = true; + if (!inferiorRootBlock.isMovable()) { + // Can't bump an immovable block away. // Check to see if the other block is movable. - rootBlock = staticConnection.getSourceBlock().getRootBlock(); - if (!rootBlock.isMovable()) { + if (!superiorRootBlock.isMovable()) { + // Neither block is movable, abort operation. return; + } else { + // Only the superior block group is movable. + moveInferior = false; + // The superior block group moves in the opposite direction. + offsetX = -offsetX; + offsetY = -offsetY; + } + } else if (superiorRootBlock.isMovable()) { + // Both block groups are movable. The one on the inferior side will be + // moved to make space for the superior one. However, it's possible that + // both groups of blocks have an inferior connection that bumps into a + // superior connection on the other group, which could result in both + // groups moving in the same direction and eventually bumping each other + // again. It would be better if one group of blocks could consistently + // move in an orthogonal direction from the other, so that they become + // separated in the end. We can designate one group the "initiator" if + // it's the one that was most recently manipulated, causing inputs to be + // checked for bumpable neighbors. As a useful heuristic, in the case + // where the inferior connection belongs to the initiator group, moving it + // in the orthogonal direction will separate the blocks better. + if (initiatedByThis) { + offsetY = -offsetY; } - // Swap the connections and move the 'static' connection instead. - /* eslint-disable-next-line @typescript-eslint/no-this-alias */ - staticConnection = this; - reverse = true; } + const staticConnection = moveInferior + ? superiorConnection + : inferiorConnection; + const dynamicConnection = moveInferior + ? inferiorConnection + : superiorConnection; + const dynamicRootBlock = moveInferior + ? inferiorRootBlock + : superiorRootBlock; // Raise it to the top for extra visibility. - const selected = common.getSelected() == rootBlock; - if (!selected) rootBlock.addSelect(); - let dx = - staticConnection.x + - config.snapRadius + - Math.floor(Math.random() * BUMP_RANDOMNESS) - - this.x; - let dy = - staticConnection.y + - config.snapRadius + - Math.floor(Math.random() * BUMP_RANDOMNESS) - - this.y; - if (reverse) { - // When reversing a bump due to an uneditable block, bump up. - dy = -dy; - } - if (rootBlock.RTL) { - dx = - staticConnection.x - - config.snapRadius - - Math.floor(Math.random() * BUMP_RANDOMNESS) - - this.x; + const selected = common.getSelected() === dynamicRootBlock; + if (!selected) dynamicRootBlock.addSelect(); + if (dynamicRootBlock.RTL) { + offsetX = -offsetX; } - rootBlock.moveBy(dx, dy, ['bump']); - if (!selected) rootBlock.removeSelect(); + const dx = staticConnection.x + offsetX - dynamicConnection.x; + const dy = staticConnection.y + offsetY - dynamicConnection.y; + dynamicRootBlock.moveBy(dx, dy, ['bump']); + if (!selected) dynamicRootBlock.removeSelect(); } /** @@ -413,11 +439,11 @@ export class RenderedConnection extends Connection { * Bumps this connection away from the other connection. Called when an * attempted connection fails. * - * @param otherConnection Connection that this connection failed to connect - * to. + * @param superiorConnection Connection that this connection failed to connect + * to. The provided connection should be the superior connection. * @internal */ - override onFailedConnect(otherConnection: Connection) { + override onFailedConnect(superiorConnection: Connection) { const block = this.getSourceBlock(); if (eventUtils.getRecordUndo()) { const group = eventUtils.getGroup(); @@ -425,7 +451,7 @@ export class RenderedConnection extends Connection { function (this: RenderedConnection) { if (!block.isDisposed() && !block.getParent()) { eventUtils.setGroup(group); - this.bumpAwayFrom(otherConnection as RenderedConnection); + this.bumpAwayFrom(superiorConnection as RenderedConnection); eventUtils.setGroup(false); } }.bind(this), diff --git a/core/shortcut_registry.ts b/core/shortcut_registry.ts index 161a2ed1495..32615c86686 100644 --- a/core/shortcut_registry.ts +++ b/core/shortcut_registry.ts @@ -45,31 +45,27 @@ export class ShortcutRegistry { * Registers a keyboard shortcut. * * @param shortcut The shortcut for this key code. - * @param opt_allowOverrides True to prevent a warning when overriding an + * @param allowOverrides True to prevent a warning when overriding an * already registered item. * @throws {Error} if a shortcut with the same name already exists. */ - register(shortcut: KeyboardShortcut, opt_allowOverrides?: boolean) { + register(shortcut: KeyboardShortcut, allowOverrides?: boolean) { const registeredShortcut = this.shortcuts.get(shortcut.name); - if (registeredShortcut && !opt_allowOverrides) { + if (registeredShortcut && !allowOverrides) { throw new Error(`Shortcut named "${shortcut.name}" already exists.`); } this.shortcuts.set(shortcut.name, shortcut); const keyCodes = shortcut.keyCodes; - if (keyCodes && keyCodes.length > 0) { - for (let i = 0; i < keyCodes.length; i++) { - this.addKeyMapping( - keyCodes[i], - shortcut.name, - !!shortcut.allowCollision, - ); + if (keyCodes?.length) { + for (const keyCode of keyCodes) { + this.addKeyMapping(keyCode, shortcut.name, !!shortcut.allowCollision); } } } /** - * Unregisters a keyboard shortcut registered with the given key code. This + * Unregisters a keyboard shortcut registered with the given name. This * will also remove any key mappings that reference this shortcut. * * @param shortcutName The name of the shortcut to unregister. @@ -92,27 +88,34 @@ export class ShortcutRegistry { /** * Adds a mapping between a keycode and a keyboard shortcut. * + * Normally only one shortcut can be mapped to any given keycode, + * but setting allowCollisions to true allows a keyboard to be + * mapped to multiple shortcuts. In that case, when onKeyDown is + * called with the given keystroke, it will process the mapped + * shortcuts in reverse order, from the most- to least-recently + * mapped). + * * @param keyCode The key code for the keyboard shortcut. If registering a key * code with a modifier (ex: ctrl+c) use * ShortcutRegistry.registry.createSerializedKey; * @param shortcutName The name of the shortcut to execute when the given * keycode is pressed. - * @param opt_allowCollision True to prevent an error when adding a shortcut + * @param allowCollision True to prevent an error when adding a shortcut * to a key that is already mapped to a shortcut. * @throws {Error} if the given key code is already mapped to a shortcut. */ addKeyMapping( keyCode: string | number | KeyCodes, shortcutName: string, - opt_allowCollision?: boolean, + allowCollision?: boolean, ) { keyCode = `${keyCode}`; const shortcutNames = this.keyMap.get(keyCode); - if (shortcutNames && !opt_allowCollision) { + if (shortcutNames && !allowCollision) { throw new Error( `Shortcut named "${shortcutName}" collides with shortcuts "${shortcutNames}"`, ); - } else if (shortcutNames && opt_allowCollision) { + } else if (shortcutNames && allowCollision) { shortcutNames.unshift(shortcutName); } else { this.keyMap.set(keyCode, [shortcutName]); @@ -127,19 +130,19 @@ export class ShortcutRegistry { * ShortcutRegistry.registry.createSerializedKey; * @param shortcutName The name of the shortcut to execute when the given * keycode is pressed. - * @param opt_quiet True to not console warn when there is no shortcut to + * @param quiet True to not console warn when there is no shortcut to * remove. * @returns True if a key mapping was removed, false otherwise. */ removeKeyMapping( keyCode: string, shortcutName: string, - opt_quiet?: boolean, + quiet?: boolean, ): boolean { const shortcutNames = this.keyMap.get(keyCode); if (!shortcutNames) { - if (!opt_quiet) { + if (!quiet) { console.warn( `No keyboard shortcut named "${shortcutName}" registered with key code "${keyCode}"`, ); @@ -155,7 +158,7 @@ export class ShortcutRegistry { } return true; } - if (!opt_quiet) { + if (!quiet) { console.warn( `No keyboard shortcut named "${shortcutName}" registered with key code "${keyCode}"`, ); @@ -172,7 +175,7 @@ export class ShortcutRegistry { */ removeAllKeyMappings(shortcutName: string) { for (const keyCode of this.keyMap.keys()) { - this.removeKeyMapping(keyCode, shortcutName, true); + this.removeKeyMapping(keyCode, shortcutName, /* quiet= */ true); } } @@ -219,6 +222,21 @@ export class ShortcutRegistry { /** * Handles key down events. * + * - Any `KeyboardShortcut`(s) mapped to the keycodes that cause + * event `e` to be fired will be processed, in order from least- + * to most-recently registered. + * - If the shortcut's `preconditionFn` exists it will be called. + * If `preconditionFn` returns false the shortcut's `callback` + * function will be skipped. Processing will continue with the + * next shortcut, if any. + * - The shortcut's `callback` function will then be called. If it + * returns true, processing will terminate and `onKeyDown` will + * return true. If it returns false, processing will continue + * with with the next shortcut, if any. + * - If all registered shortcuts for the given keycode have been + * processed without any having returned true, `onKeyDown` will + * return false. + * * @param workspace The main workspace where the event was captured. * @param e The key down event. * @returns True if the event was handled, false otherwise. @@ -226,17 +244,17 @@ export class ShortcutRegistry { onKeyDown(workspace: WorkspaceSvg, e: KeyboardEvent): boolean { const key = this.serializeKeyEvent_(e); const shortcutNames = this.getShortcutNamesByKeyCode(key); - if (!shortcutNames) { - return false; - } - for (let i = 0, shortcutName; (shortcutName = shortcutNames[i]); i++) { + if (!shortcutNames) return false; + for (const shortcutName of shortcutNames) { const shortcut = this.shortcuts.get(shortcutName); - if (!shortcut?.preconditionFn || shortcut?.preconditionFn(workspace)) { - // If the key has been handled, stop processing shortcuts. - if (shortcut?.callback && shortcut?.callback(workspace, e, shortcut)) { - return true; - } + if ( + !shortcut || + (shortcut.preconditionFn && !shortcut.preconditionFn(workspace)) + ) { + continue; } + // If the key has been handled, stop processing shortcuts. + if (shortcut.callback?.(workspace, e, shortcut)) return true; } return false; } @@ -301,7 +319,7 @@ export class ShortcutRegistry { * @throws {Error} if the modifier is not in the valid modifiers list. */ private checkModifiers_(modifiers: KeyCodes[]) { - for (let i = 0, modifier; (modifier = modifiers[i]); i++) { + for (const modifier of modifiers) { if (!(modifier in ShortcutRegistry.modifierKeys)) { throw new Error(modifier + ' is not a valid modifier key.'); } @@ -313,7 +331,7 @@ export class ShortcutRegistry { * * @param keyCode Number code representing the key. * @param modifiers List of modifier key codes to be used with the key. All - * valid modifiers can be found in the ShortcutRegistry.modifierKeys. + * valid modifiers can be found in the `ShortcutRegistry.modifierKeys`. * @returns The serialized key code for the given modifiers and key. */ createSerializedKey(keyCode: number, modifiers: KeyCodes[] | null): string { @@ -344,12 +362,59 @@ export class ShortcutRegistry { } export namespace ShortcutRegistry { + /** Interface defining a keyboard shortcut. */ export interface KeyboardShortcut { - callback?: (p1: WorkspaceSvg, p2: Event, p3: KeyboardShortcut) => boolean; + /** + * The function to be called when the shorctut is invoked. + * + * @param workspace The `WorkspaceSvg` when the shortcut was + * invoked. + * @param e The event that caused the shortcut to be activated. + * @param shortcut The `KeyboardShortcut` that was activated + * (i.e., the one this callback is attached to). + * @returns Returning true ends processing of the invoked keycode. + * Returning false causes processing to continue with the + * next-most-recently registered shortcut for the invoked + * keycode. + */ + callback?: ( + workspace: WorkspaceSvg, + e: Event, + shortcut: KeyboardShortcut, + ) => boolean; + + /** The name of the shortcut. Should be unique. */ name: string; - preconditionFn?: (p1: WorkspaceSvg) => boolean; + + /** + * A function to be called when the shortcut is invoked, before + * calling `callback`, to decide if this shortcut is applicable in + * the current situation. + * + * @param workspace The `WorkspaceSvg` where the shortcut was + * invoked. + * @returns True iff `callback` function should be called. + */ + preconditionFn?: (workspace: WorkspaceSvg) => boolean; + + /** Optional arbitray extra data attached to the shortcut. */ metadata?: object; + + /** + * Optional list of key codes to be bound (via + * ShortcutRegistry.prototype.addKeyMapping) to this shortcut. + */ keyCodes?: (number | string)[]; + + /** + * Value of `allowCollision` to pass to `addKeyMapping` when + * binding this shortcut's `.keyCodes` (if any). + * + * N.B.: this is only used for binding keycodes at the time this + * shortcut is initially registered, not for any subsequent + * `addKeyMapping` calls that happen to reference this shortcut's + * name. + */ allowCollision?: boolean; } diff --git a/core/utils/drag.ts b/core/utils/drag.ts new file mode 100644 index 00000000000..a6322933bf9 --- /dev/null +++ b/core/utils/drag.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {Coordinate} from './coordinate.js'; + +const workspaceToDragDelta: WeakMap = new WeakMap(); + +/** + * Convert from mouse coordinates to workspace coordinates. + * + * @param workspace The workspace where the pointer event is occurring. + * @param e The pointer event with the source coordinates. + */ +function mouseToWorkspacePoint( + workspace: WorkspaceSvg, + e: PointerEvent, +): SVGPoint { + const point = browserEvents.mouseToSvg( + e, + workspace.getParentSvg(), + workspace.getInverseScreenCTM(), + ); + // Fix scale of mouse event. + point.x /= workspace.scale; + point.y /= workspace.scale; + return point; +} + +/** + * Start tracking a drag of an object on this workspace by recording the offset + * between the pointer's current location and the object's starting location. + * + * Used for resizing block comments and workspace comments. + * + * @param workspace The workspace where the drag is occurring. + * @param e Pointer down event. + * @param xy Starting location of object. + */ +export function start( + workspace: WorkspaceSvg, + e: PointerEvent, + xy: Coordinate, +) { + const point = mouseToWorkspacePoint(workspace, e); + // Record the starting offset between the bubble's location and the mouse. + workspaceToDragDelta.set(workspace, Coordinate.difference(xy, point)); +} + +/** + * Compute the new position of a dragged object in this workspace based on the + * current pointer position and the offset between the pointer's starting + * location and the object's starting location. + * + * The start function should have be called previously, when the drag started. + * + * Used for resizing block comments and workspace comments. + * + * @param workspace The workspace where the drag is occurring. + * @param e Pointer move event. + * @returns New location of object. + */ +export function move(workspace: WorkspaceSvg, e: PointerEvent): Coordinate { + const point = mouseToWorkspacePoint(workspace, e); + const dragDelta = workspaceToDragDelta.get(workspace); + if (!dragDelta) { + throw new Error('Drag not initialized'); + } + return Coordinate.sum(dragDelta, point); +} diff --git a/core/utils/rect.ts b/core/utils/rect.ts index 817462b699d..c7da2a6860b 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -13,6 +13,8 @@ */ // Former goog.module ID: Blockly.utils.Rect +import {Coordinate} from './coordinate.js'; + /** * Class for representing rectangular regions. */ @@ -30,10 +32,21 @@ export class Rect { public right: number, ) {} + /** + * Creates a new copy of this rectangle. + * + * @returns A copy of this Rect. + */ + clone(): Rect { + return new Rect(this.top, this.bottom, this.left, this.right); + } + + /** Returns the height of this rectangle. */ getHeight(): number { return this.bottom - this.top; } + /** Returns the width of this rectangle. */ getWidth(): number { return this.right - this.left; } @@ -59,11 +72,56 @@ export class Rect { * @returns Whether this rectangle intersects the provided rectangle. */ intersects(other: Rect): boolean { - return !( - this.left > other.right || - this.right < other.left || - this.top > other.bottom || - this.bottom < other.top + // The following logic can be derived and then simplified from a longer form symmetrical check + // of verifying each rectangle's borders with the other rectangle by checking if either end of + // the border's line segment is contained within the other rectangle. The simplified version + // used here can be logically interpreted as ensuring that each line segment of 'this' rectangle + // is not outside the bounds of the 'other' rectangle (proving there's an intersection). + return ( + this.left <= other.right && + this.right >= other.left && + this.bottom >= other.top && + this.top <= other.bottom ); } + + /** + * Compares bounding rectangles for equality. + * + * @param a A Rect. + * @param b A Rect. + * @returns True iff the bounding rectangles are equal, or if both are null. + */ + static equals(a?: Rect | null, b?: Rect | null): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return ( + a.top === b.top && + a.bottom === b.bottom && + a.left === b.left && + a.right === b.right + ); + } + + /** + * Creates a new Rect using a position and supplied dimensions. + * + * @param position The upper left coordinate of the new rectangle. + * @param width The width of the rectangle, in pixels. + * @param height The height of the rectangle, in pixels. + * @returns A newly created Rect using the provided Coordinate and dimensions. + */ + static createFromPoint( + position: Coordinate, + width: number, + height: number, + ): Rect { + const left = position.x; + const top = position.y; + return new Rect(top, top + height, left, left + width); + } } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index a7d56d1005e..fed5e3cb16b 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -63,6 +63,7 @@ import type {Trashcan} from './trashcan.js'; import * as arrayUtils from './utils/array.js'; import {Coordinate} from './utils/coordinate.js'; import * as dom from './utils/dom.js'; +import * as drag from './utils/drag.js'; import type {Metrics} from './utils/metrics.js'; import {Rect} from './utils/rect.js'; import {Size} from './utils/size.js'; @@ -181,9 +182,6 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { /** Vertical scroll value when scrolling started in pixel units. */ startScrollY = 0; - /** Distance from mouse to object being dragged. */ - private dragDeltaXY: Coordinate | null = null; - /** Current scale. */ scale = 1; @@ -1447,16 +1445,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * @param xy Starting location of object. */ startDrag(e: PointerEvent, xy: Coordinate) { - // Record the starting offset between the bubble's location and the mouse. - const point = browserEvents.mouseToSvg( - e, - this.getParentSvg(), - this.getInverseScreenCTM(), - ); - // Fix scale of mouse event. - point.x /= this.scale; - point.y /= this.scale; - this.dragDeltaXY = Coordinate.difference(xy, point); + drag.start(this, e, xy); } /** @@ -1466,15 +1455,7 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { * @returns New location of object. */ moveDrag(e: PointerEvent): Coordinate { - const point = browserEvents.mouseToSvg( - e, - this.getParentSvg(), - this.getInverseScreenCTM(), - ); - // Fix scale of mouse event. - point.x /= this.scale; - point.y /= this.scale; - return Coordinate.sum(this.dragDeltaXY!, point); + return drag.move(this, e); } /** @@ -1645,23 +1626,56 @@ export class WorkspaceSvg extends Workspace implements IASTNodeLocationSvg { return boundary; } - /** Clean up the workspace by ordering all the blocks in a column. */ + /** Clean up the workspace by ordering all the blocks in a column such that none overlap. */ cleanUp() { this.setResizesEnabled(false); eventUtils.setGroup(true); + const topBlocks = this.getTopBlocks(true); - let cursorY = 0; - for (let i = 0, block; (block = topBlocks[i]); i++) { - if (!block.isMovable()) { - continue; + const movableBlocks = topBlocks.filter((block) => block.isMovable()); + const immovableBlocks = topBlocks.filter((block) => !block.isMovable()); + + const immovableBlockBounds = immovableBlocks.map((block) => + block.getBoundingRectangle(), + ); + + const getNextIntersectingImmovableBlock = function ( + rect: Rect, + ): Rect | null { + for (const immovableRect of immovableBlockBounds) { + if (rect.intersects(immovableRect)) { + return immovableRect; + } } - const xy = block.getRelativeToSurfaceXY(); - block.moveBy(-xy.x, cursorY - xy.y, ['cleanup']); + return null; + }; + + let cursorY = 0; + const minBlockHeight = this.renderer.getConstants().MIN_BLOCK_HEIGHT; + for (const block of movableBlocks) { + // Make the initial movement of shifting the block to its best possible position. + let boundingRect = block.getBoundingRectangle(); + block.moveBy(-boundingRect.left, cursorY - boundingRect.top, ['cleanup']); block.snapToGrid(); + + boundingRect = block.getBoundingRectangle(); + let conflictingRect = getNextIntersectingImmovableBlock(boundingRect); + while (conflictingRect != null) { + // If the block intersects with an immovable block, move it down past that immovable block. + cursorY = + conflictingRect.top + conflictingRect.getHeight() + minBlockHeight; + block.moveBy(0, cursorY - boundingRect.top, ['cleanup']); + block.snapToGrid(); + boundingRect = block.getBoundingRectangle(); + conflictingRect = getNextIntersectingImmovableBlock(boundingRect); + } + + // Ensure all next blocks start past the most recent (which will also put them past all + // previously intersecting immovable blocks). cursorY = block.getRelativeToSurfaceXY().y + block.getHeightWidth().height + - this.renderer.getConstants().MIN_BLOCK_HEIGHT; + minBlockHeight; } eventUtils.setGroup(false); this.setResizesEnabled(true); diff --git a/demos/blockfactory/block_library_controller.js b/demos/blockfactory/block_library_controller.js index 7bb34e8d623..8eed54db02c 100644 --- a/demos/blockfactory/block_library_controller.js +++ b/demos/blockfactory/block_library_controller.js @@ -109,9 +109,9 @@ BlockLibraryController.prototype.clearBlockLibrary = function() { BlockLibraryController.prototype.saveToBlockLibrary = function() { var blockType = this.getCurrentBlockType(); // If user has not changed the name of the starter block. - if (blockType === 'block_type') { + if (reservedBlockFactoryBlocks.has(blockType) || blockType === 'block_type') { // Do not save block if it has the default type, 'block_type'. - var msg = 'You cannot save a block under the name "block_type". Try ' + + var msg = `You cannot save a block under the name "${blockType}". Try ` + 'changing the name before saving. Then, click on the "Block Library"' + ' button to view your saved blocks.'; alert(msg); diff --git a/demos/blockfactory/block_library_view.js b/demos/blockfactory/block_library_view.js index a479804080b..2c91ce3782e 100644 --- a/demos/blockfactory/block_library_view.js +++ b/demos/blockfactory/block_library_view.js @@ -104,36 +104,36 @@ BlockLibraryView.prototype.updateButtons = // User is editing a block. if (!isInLibrary) { - // Block type has not been saved to library yet. Disable the delete button - // and allow user to save. + // Block type has not been saved to the library yet. + // Disable the delete button. this.saveButton.textContent = 'Save "' + blockType + '"'; - this.saveButton.disabled = false; this.deleteButton.disabled = true; } else { - // Block type has already been saved. Disable the save button unless the - // there are unsaved changes (checked below). + // A version of the block type has already been saved. + // Enable the delete button. this.saveButton.textContent = 'Update "' + blockType + '"'; - this.saveButton.disabled = true; this.deleteButton.disabled = false; } this.deleteButton.textContent = 'Delete "' + blockType + '"'; - // If changes to block have been made and are not saved, make button - // green to encourage user to save the block. + this.saveButton.classList.remove('button_alert', 'button_warn'); if (!savedChanges) { - var buttonFormatClass = 'button_warn'; + var buttonFormatClass; - // If block type is the default, 'block_type', make button red to alert - // user. - if (blockType === 'block_type') { + var isReserved = reservedBlockFactoryBlocks.has(blockType); + if (isReserved || blockType === 'block_type') { + // Make button red to alert user that the block type can't be saved. buttonFormatClass = 'button_alert'; + } else { + // Block type has not been saved to library yet or has unsaved changes. + // Make the button green to encourage the user to save the block. + buttonFormatClass = 'button_warn'; } this.saveButton.classList.add(buttonFormatClass); this.saveButton.disabled = false; } else { // No changes to save. - this.saveButton.classList.remove('button_alert', 'button_warn'); this.saveButton.disabled = true; } diff --git a/demos/blockfactory/blocks.js b/demos/blockfactory/blocks.js index 34313cad14f..9a983460f5f 100644 --- a/demos/blockfactory/blocks.js +++ b/demos/blockfactory/blocks.js @@ -914,3 +914,7 @@ function inputNameCheck(referenceBlock) { 'There are ' + count + ' input blocks\n with this name.' : null; referenceBlock.setWarningText(msg); } + +// Make a set of all of block types that are required for the block factory. +var reservedBlockFactoryBlocks = + new Set(Object.getOwnPropertyNames(Blockly.Blocks)); diff --git a/demos/blockfactory/factory.js b/demos/blockfactory/factory.js index 07ee889de7f..2e6ebc924e1 100644 --- a/demos/blockfactory/factory.js +++ b/demos/blockfactory/factory.js @@ -187,8 +187,9 @@ BlockFactory.updatePreview = function() { // Don't let the user create a block type that already exists, // because it doesn't work. var warnExistingBlock = function(blockType) { - if (blockType in Blockly.Blocks) { - var text = `You can't make a block called ${blockType} in this tool because that name already exists.`; + if (reservedBlockFactoryBlocks.has(blockType)) { + var text = `You can't make a block called ${blockType} in this tool ` + + `because that name is reserved.`; FactoryUtils.getRootBlock(BlockFactory.mainWorkspace).setWarningText(text); console.error(text); return true; diff --git a/package-lock.json b/package-lock.json index ba2b59f2015..8529053530a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "jsdom": "25.0.0" + "jsdom": "25.0.1" }, "devDependencies": { "@blockly/block-test": "^6.0.4", @@ -24,11 +24,11 @@ "@typescript-eslint/parser": "^8.1.0", "async-done": "^2.0.0", "chai": "^5.1.1", - "concurrently": "^8.0.1", + "concurrently": "^9.0.1", "eslint": "^8.4.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-jsdoc": "^48.0.2", + "eslint-plugin-jsdoc": "^50.4.3", "glob": "^10.3.4", "google-closure-compiler": "^20240317.0.0", "gulp": "^5.0.0", @@ -68,18 +68,6 @@ "node": ">=0.10.0" } }, - "node_modules/@babel/runtime": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", - "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@blockly/block-test": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-6.0.8.tgz", @@ -223,11 +211,10 @@ } }, "node_modules/@blockly/theme-modern": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@blockly/theme-modern/-/theme-modern-6.0.3.tgz", - "integrity": "sha512-pGtwrxqUHfFmT2s8DRZ/FGuBo3hdoVZt66FDFWicripRv5OteXlmLiw3zjbVnca34LHwJ0lKCvUvARAVbPVnHg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@blockly/theme-modern/-/theme-modern-6.0.7.tgz", + "integrity": "sha512-RUEmunGe1L6So0sTpBd1yUz3foUAzjTj1x0y3P4iyuGu0HzfLIacqUpdU4wQNteGPbKSBp7qDFRXaH/V2eJ6QA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, @@ -248,17 +235,14 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.43.1", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.43.1.tgz", - "integrity": "sha512-I238eDtOolvCuvtxrnqtlBaw0BwdQuYqK7eA6XIonicMdOOOb75mqdIzkGDUbS04+1Di007rgm9snFRNeVrOog==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", + "integrity": "sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==", "dev": true, "dependencies": { - "@types/eslint": "^8.56.5", - "@types/estree": "^1.0.5", - "@typescript-eslint/types": "^7.2.0", "comment-parser": "1.4.1", - "esquery": "^1.5.0", - "jsdoc-type-pratt-parser": "~4.0.0" + "esquery": "^1.6.0", + "jsdoc-type-pratt-parser": "~4.1.0" }, "engines": { "node": ">=16" @@ -472,11 +456,10 @@ "dev": true }, "node_modules/@hyperjump/browser": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.1.4.tgz", - "integrity": "sha512-85rfa3B79MssMOxNChvXJhfgvIXqA2FEzwrxKe9iMpCKZVQIxQe54w210VeFM0D33pVOeNskg7TyptSjenY2+w==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.1.6.tgz", + "integrity": "sha512-i27uPV7SxK1GOn7TLTRxTorxchYa5ur9JHgtl6TxZ1MHuyb9ROAnXxEeu4q4H1836Xb7lL2PGPsaa5Jl3p+R6g==", "dev": true, - "license": "MIT", "dependencies": { "@hyperjump/json-pointer": "^1.1.0", "@hyperjump/uri": "^1.2.0", @@ -502,9 +485,9 @@ } }, "node_modules/@hyperjump/json-schema": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.9.6.tgz", - "integrity": "sha512-tv0JLDESJCGEPR1LNDNHEdr/AV+kjmfwpsayNPnk8Qy55VPZvXpr5SpExlq8HwB9IXJp1ScfIcrcVrpaWN2Yxw==", + "version": "1.9.8", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.9.8.tgz", + "integrity": "sha512-qmdMpYn8CpYR7z3fxkL6fgkDvMaAEFKtmYu3XDi6hWW2BT+rLl7T4Y4QpafEIR4wkcmCxcJf9me9FmxKpv3i9g==", "dev": true, "dependencies": { "@hyperjump/json-pointer": "^1.1.0", @@ -631,16 +614,16 @@ } }, "node_modules/@microsoft/api-documenter": { - "version": "7.25.10", - "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.25.10.tgz", - "integrity": "sha512-GYc5AALrP9gxYPpkPc/BXXdekg+Ge8p9yyO1aRVwJDGzCXR7XRUvh6gc2jay/DmBx4KfyMx0LFWJ0HcUXudqgQ==", + "version": "7.25.14", + "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.25.14.tgz", + "integrity": "sha512-nysAB+j4l5Al3XvCdee6tw0rw4fXpnlIq9En2opcc3DgITeoehiaYYoZZqoqOQSKlSUDWF7Z55GGsvntVrcBkg==", "dev": true, "dependencies": { - "@microsoft/api-extractor-model": "7.29.4", + "@microsoft/api-extractor-model": "7.29.8", "@microsoft/tsdoc": "~0.15.0", - "@rushstack/node-core-library": "5.5.1", - "@rushstack/terminal": "0.13.3", - "@rushstack/ts-command-line": "4.22.4", + "@rushstack/node-core-library": "5.9.0", + "@rushstack/terminal": "0.14.2", + "@rushstack/ts-command-line": "4.22.8", "js-yaml": "~3.13.1", "resolve": "~1.22.1" }, @@ -648,87 +631,6 @@ "api-documenter": "bin/api-documenter" } }, - "node_modules/@microsoft/api-documenter/node_modules/@microsoft/api-extractor-model": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.29.4.tgz", - "integrity": "sha512-LHOMxmT8/tU1IiiiHOdHFF83Qsi+V8d0kLfscG4EvQE9cafiR8blOYr8SfkQKWB1wgEilQgXJX3MIA4vetDLZw==", - "dev": true, - "dependencies": { - "@microsoft/tsdoc": "~0.15.0", - "@microsoft/tsdoc-config": "~0.17.0", - "@rushstack/node-core-library": "5.5.1" - } - }, - "node_modules/@microsoft/api-documenter/node_modules/@rushstack/node-core-library": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.5.1.tgz", - "integrity": "sha512-ZutW56qIzH8xIOlfyaLQJFx+8IBqdbVCZdnj+XT1MorQ1JqqxHse8vbCpEM+2MjsrqcbxcgDIbfggB1ZSQ2A3g==", - "dev": true, - "dependencies": { - "ajv": "~8.13.0", - "ajv-draft-04": "~1.0.0", - "ajv-formats": "~3.0.1", - "fs-extra": "~7.0.1", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", - "resolve": "~1.22.1", - "semver": "~7.5.4" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@microsoft/api-documenter/node_modules/@rushstack/terminal": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.13.3.tgz", - "integrity": "sha512-fc3zjXOw8E0pXS5t9vTiIPx9gHA0fIdTXsu9mT4WbH+P3mYvnrX0iAQ5a6NvyK1+CqYWBTw/wVNx7SDJkI+WYQ==", - "dev": true, - "dependencies": { - "@rushstack/node-core-library": "5.5.1", - "supports-color": "~8.1.1" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@microsoft/api-documenter/node_modules/@rushstack/ts-command-line": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.22.4.tgz", - "integrity": "sha512-QoyhbWfyF9Ixg5DWdPzxO3h2RmJ7i5WH9b7qLzD5h5WFya/ZqicjdPrVwQiGtrFvAbBj8jhcC9DhbzU9xAk78g==", - "dev": true, - "dependencies": { - "@rushstack/terminal": "0.13.3", - "@types/argparse": "1.0.38", - "argparse": "~1.0.9", - "string-argv": "~0.3.1" - } - }, - "node_modules/@microsoft/api-documenter/node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@microsoft/api-documenter/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -751,40 +653,19 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@microsoft/api-documenter/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/@microsoft/api-documenter/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/@microsoft/api-extractor": { - "version": "7.47.0", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.47.0.tgz", - "integrity": "sha512-LT8yvcWNf76EpDC+8/ArTVSYePvuDQ+YbAUrsTcpg3ptiZ93HIcMCozP/JOxDt+rrsFfFHcpfoselKfPyRI0GQ==", + "version": "7.47.11", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.47.11.tgz", + "integrity": "sha512-lrudfbPub5wzBhymfFtgZKuBvXxoSIAdrvS2UbHjoMT2TjIEddq6Z13pcve7A03BAouw0x8sW8G4txdgfiSwpQ==", "dev": true, "dependencies": { - "@microsoft/api-extractor-model": "7.29.2", + "@microsoft/api-extractor-model": "7.29.8", "@microsoft/tsdoc": "~0.15.0", "@microsoft/tsdoc-config": "~0.17.0", - "@rushstack/node-core-library": "5.4.1", - "@rushstack/rig-package": "0.5.2", - "@rushstack/terminal": "0.13.0", - "@rushstack/ts-command-line": "4.22.0", + "@rushstack/node-core-library": "5.9.0", + "@rushstack/rig-package": "0.5.3", + "@rushstack/terminal": "0.14.2", + "@rushstack/ts-command-line": "4.23.0", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", @@ -797,14 +678,35 @@ } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.29.2.tgz", - "integrity": "sha512-hAYajOjQan3uslhKJRwvvHIdLJ+ZByKqdSsJ/dgHFxPtEbdKpzMDO8zuW4K5gkSMYl5D0LbNwxkhxr51P2zsmw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.29.8.tgz", + "integrity": "sha512-t3Z/xcO6TRbMcnKGVMs4uMzv/gd5j0NhMiJIGjD4cJMeFJ1Hf8wnLSx37vxlRlL0GWlGJhnFgxvnaL6JlS+73g==", "dev": true, "dependencies": { "@microsoft/tsdoc": "~0.15.0", "@microsoft/tsdoc-config": "~0.17.0", - "@rushstack/node-core-library": "5.4.1" + "@rushstack/node-core-library": "5.9.0" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/@rushstack/ts-command-line": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.0.tgz", + "integrity": "sha512-jYREBtsxduPV6ptNq8jOKp9+yx0ld1Tb/Tkdnlj8gTjazl1sF3DwX2VbluyYrNd0meWIL0bNeer7WDf5tKFjaQ==", + "dev": true, + "dependencies": { + "@rushstack/terminal": "0.14.2", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" } }, "node_modules/@microsoft/api-extractor/node_modules/minimatch": { @@ -939,9 +841,9 @@ } }, "node_modules/@promptbook/utils": { - "version": "0.68.0", - "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.68.0.tgz", - "integrity": "sha512-EaV8YtUrbFLAjwOx9JcJqnfSiF+dm4kLrB2umzVJn/yEFMIOoC0GTWz1mX328HSA5cBvqC7+SBeWubplL9THcg==", + "version": "0.70.0-1", + "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.70.0-1.tgz", + "integrity": "sha512-qd2lLRRN+sE6UuNMi2tEeUUeb4zmXnxY5EMdfHVXNE+bqBDpUC7/aEfXgA3jnUXEr+xFjQ8PTFQgWvBMaKvw0g==", "dev": true, "funding": [ { @@ -958,9 +860,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.1.tgz", - "integrity": "sha512-uK7o3hHkK+naEobMSJ+2ySYyXtQkBxIH8Gn4MK9ciePjNV+Pf+PgY/W7iPzn2MTjl3stcYB5AlcTmPYw7AXDwA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.4.0.tgz", + "integrity": "sha512-x8J1csfIygOwf6D6qUAZ0ASk3z63zPb7wkNeHRerCMh82qWKUrOgkuP005AJC8lDL6/evtXETGEJVcwykKT4/g==", "dev": true, "dependencies": { "debug": "^4.3.6", @@ -992,9 +894,9 @@ } }, "node_modules/@rushstack/node-core-library": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.4.1.tgz", - "integrity": "sha512-WNnwdS8r9NZ/2K3u29tNoSRldscFa7SxU0RT+82B6Dy2I4Hl2MeCSKm4EXLXPKeNzLGvJ1cqbUhTLviSF8E6iA==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.9.0.tgz", + "integrity": "sha512-MMsshEWkTbXqxqFxD4gcIUWQOCeBChlGczdZbHfqmNZQFLHB3yWxDFSMHFUdu2/OB9NUk7Awn5qRL+rws4HQNg==", "dev": true, "dependencies": { "ajv": "~8.13.0", @@ -1038,9 +940,9 @@ "dev": true }, "node_modules/@rushstack/rig-package": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.2.tgz", - "integrity": "sha512-mUDecIJeH3yYGZs2a48k+pbhM6JYwWlgjs2Ca5f2n1G2/kgdgP9D/07oglEGf6mRyXEnazhEENeYTSNDRCwdqA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.3.tgz", + "integrity": "sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==", "dev": true, "dependencies": { "resolve": "~1.22.1", @@ -1048,12 +950,12 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.13.0.tgz", - "integrity": "sha512-Ou44Q2s81BqJu3dpYedAX54am9vn245F0HzqVrfJCMQk5pGgoKKOBOjkbfZC9QKcGNaECh6pwH2s5noJt7X6ew==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.14.2.tgz", + "integrity": "sha512-2fC1wqu1VCExKC0/L+0noVcFQEXEnoBOtCIex1TOjBzEDWcw8KzJjjj7aTP6mLxepG0XIyn9OufeFb6SFsa+sg==", "dev": true, "dependencies": { - "@rushstack/node-core-library": "5.4.1", + "@rushstack/node-core-library": "5.9.0", "supports-color": "~8.1.1" }, "peerDependencies": { @@ -1081,12 +983,12 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.22.0.tgz", - "integrity": "sha512-Qj28t6MO3HRgAZ72FDeFsrpdE6wBWxF3VENgvrXh7JF2qIT+CrXiOJIesW80VFZB9QwObSpkB1ilx794fGQg6g==", + "version": "4.22.8", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.22.8.tgz", + "integrity": "sha512-XbFjOoV7qZHJnSuFUHv0pKaFA4ixyCuki+xMjsMfDwfvQjs5MYG0IK5COal3tRnG7KCDe2l/G+9LrzYE/RJhgg==", "dev": true, "dependencies": { - "@rushstack/terminal": "0.13.0", + "@rushstack/terminal": "0.14.2", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -1163,34 +1065,12 @@ "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", "dev": true }, - "node_modules/@types/eslint": { - "version": "8.56.10", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", - "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true - }, "node_modules/@types/expect": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz", "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", "dev": true }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, "node_modules/@types/node": { "version": "20.16.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", @@ -1242,17 +1122,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.1.0.tgz", - "integrity": "sha512-LlNBaHFCEBPHyD4pZXb35mzjGkuGKXU5eeCA1SxvHfiRES0E82dOounfVpL4DCqYvJEKab0bZIA0gCRpdLKkCw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.1.0", - "@typescript-eslint/type-utils": "8.1.0", - "@typescript-eslint/utils": "8.1.0", - "@typescript-eslint/visitor-keys": "8.1.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1276,15 +1155,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.2.0.tgz", - "integrity": "sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", + "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.2.0", - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/typescript-estree": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4" }, "engines": { @@ -1304,13 +1183,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.2.0.tgz", - "integrity": "sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0" + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1321,9 +1200,9 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.2.0.tgz", - "integrity": "sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1334,15 +1213,15 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.2.0.tgz", - "integrity": "sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.2.0", - "@typescript-eslint/visitor-keys": "8.2.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -1362,12 +1241,12 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.2.0.tgz", - "integrity": "sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.2.0", + "@typescript-eslint/types": "8.11.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1415,14 +1294,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.1.0.tgz", - "integrity": "sha512-DsuOZQji687sQUjm4N6c9xABJa7fjvfIdjqpSIIVOgaENf2jFXiM9hIBZOL3hb6DHK9Nvd2d7zZnoMLf9e0OtQ==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.1.0", - "@typescript-eslint/visitor-keys": "8.1.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1433,11 +1311,10 @@ } }, "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.1.0.tgz", - "integrity": "sha512-q2/Bxa0gMOu/2/AKALI0tCKbG2zppccnRIRCW6BaaTlRVaPKft4oVYPp7WOPpcnsgbr0qROAVCVKCvIQ0tbWog==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1447,14 +1324,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.1.0.tgz", - "integrity": "sha512-oLYvTxljVvsMnldfl6jIKxTaU7ok7km0KDrwOt1RHYu6nxlhN3TIx8k5Q52L6wR33nOwDgM7VwW1fT1qMNfFIA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.1.0", - "@typescript-eslint/utils": "8.1.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1471,31 +1347,16 @@ } } }, - "node_modules/@typescript-eslint/types": { - "version": "7.16.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.1.tgz", - "integrity": "sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.1.0.tgz", - "integrity": "sha512-NTHhmufocEkMiAord/g++gWKb0Fr34e9AExBRdqgWdVBaKoei2dIyYKD9Q0jBnvfbEA5zaf8plUFMUH6kQ0vGg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.1.0", - "@typescript-eslint/visitor-keys": "8.1.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -1515,11 +1376,10 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.1.0.tgz", - "integrity": "sha512-q2/Bxa0gMOu/2/AKALI0tCKbG2zppccnRIRCW6BaaTlRVaPKft4oVYPp7WOPpcnsgbr0qROAVCVKCvIQ0tbWog==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1533,7 +1393,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1543,7 +1402,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1559,7 +1417,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -1568,16 +1425,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.1.0.tgz", - "integrity": "sha512-ypRueFNKTIFwqPeJBfeIpxZ895PQhNyH4YID6js0UoBImWYoSjBsahUn9KMiJXh94uOjVBgHD9AmkyPsPnFwJA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.1.0", - "@typescript-eslint/types": "8.1.0", - "@typescript-eslint/typescript-estree": "8.1.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1591,11 +1447,10 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.1.0.tgz", - "integrity": "sha512-q2/Bxa0gMOu/2/AKALI0tCKbG2zppccnRIRCW6BaaTlRVaPKft4oVYPp7WOPpcnsgbr0qROAVCVKCvIQ0tbWog==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1605,13 +1460,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.1.0.tgz", - "integrity": "sha512-ba0lNI19awqZ5ZNKh6wCModMwoZs457StTebQ0q1NP58zSi2F6MOZRXwfKZy+jB78JNJ/WH8GSh2IQNzXX8Nag==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.1.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1623,11 +1477,10 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.1.0.tgz", - "integrity": "sha512-q2/Bxa0gMOu/2/AKALI0tCKbG2zppccnRIRCW6BaaTlRVaPKft4oVYPp7WOPpcnsgbr0qROAVCVKCvIQ0tbWog==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1643,27 +1496,27 @@ "dev": true }, "node_modules/@wdio/config": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.0.6.tgz", - "integrity": "sha512-WsACM5QjT3ZsoPVqHroYt8pOkZx4/6PTdNKm45VL8NHhQe5w9IFbl1fKxFHQ7ZkPI3F+EFvFvubO8puJ0OcSmQ==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.0.8.tgz", + "integrity": "sha512-37L+hd+A1Nyehd/pgfTrLC6w+Ngbu0CIoFh9Vv6v8Cgu5Hih0TLofvlg+J1BNbcTd5eQ2tFKZBDeFMhQaIiTpg==", "dev": true, "dependencies": { - "@wdio/logger": "9.0.4", - "@wdio/types": "9.0.4", - "@wdio/utils": "9.0.6", + "@wdio/logger": "9.0.8", + "@wdio/types": "9.0.8", + "@wdio/utils": "9.0.8", "decamelize": "^6.0.0", "deepmerge-ts": "^7.0.3", "glob": "^10.2.2", "import-meta-resolve": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" } }, "node_modules/@wdio/logger": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.0.4.tgz", - "integrity": "sha512-b6gcu0PTVb3fgK4kyAH/k5UUWN5FOUdAfhA4PAY/IZvxZTMFYMqnrZb0WRWWWqL6nu9pcrOVtCOdPBvj0cb+Nw==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.0.8.tgz", + "integrity": "sha512-uIyYIDBwLczmsp9JE5hN3ME8Xg+9WNBfSNXD69ICHrY9WPTzFf94UeTuavK7kwSKF3ro2eJbmNZItYOfnoovnw==", "dev": true, "dependencies": { "chalk": "^5.1.2", @@ -1672,7 +1525,7 @@ "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" } }, "node_modules/@wdio/logger/node_modules/chalk": { @@ -1703,44 +1556,44 @@ } }, "node_modules/@wdio/protocols": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.0.4.tgz", - "integrity": "sha512-T9v8Jkp94NxLLil5J7uJ/+YHk5/7fhOggzGcN+LvuCHS6jbJFZ/11c4SUEuXw27Yqk01fFXPBbF6TwcTTOuW/Q==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.0.8.tgz", + "integrity": "sha512-xRH54byFf623/w/KW62xkf/C2mGyigSfMm+UT3tNEAd5ZA9X2VAWQWQBPzdcrsck7Fxk4zlQX8Kb34RSs7Cy4Q==", "dev": true }, "node_modules/@wdio/repl": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.0.4.tgz", - "integrity": "sha512-5Bc5ARjWA7t6MZNnVJ9WvO1iDsy6iOsrSDWiP7APWAdaF/SJCP3SFE2c+PdQJpJWhr2Kk0fRGuyDM+GdsmZhwg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.0.8.tgz", + "integrity": "sha512-3iubjl4JX5zD21aFxZwQghqC3lgu+mSs8c3NaiYYNCC+IT5cI/8QuKlgh9s59bu+N3gG988jqMJeCYlKuUv/iw==", "dev": true, "dependencies": { "@types/node": "^20.1.0" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" } }, "node_modules/@wdio/types": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.0.4.tgz", - "integrity": "sha512-MN7O4Uk3zPWvkN8d6SNdIjd7qHUlTxS7j0QfRPu6TdlYbHu6BJJ8Rr84y7GcUzCnTAJ1nOIpvUyR8MY3hOaVKg==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.0.8.tgz", + "integrity": "sha512-pmz2iRWddTanrv8JC7v3wUGm17KRv2WyyJhQfklMSANn9V1ep6pw1RJG2WJnKq4NojMvH1nVv1sMZxXrYPhpYw==", "dev": true, "dependencies": { "@types/node": "^20.1.0" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" } }, "node_modules/@wdio/utils": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.0.6.tgz", - "integrity": "sha512-cnPXeW/sfqyKFuRRmADRZDNvFwEBMr7j7wwWLO6q5opoW++dwOdJW4WV0wDZbPcXTtGFCSrGCDLLdGcTAWMb3A==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.0.8.tgz", + "integrity": "sha512-p3EgOdkhCvMxJFd3WTtSChqYFQu2mz69/5tOsljDaL+4QYwnRR7O8M9wFsL3/9XMVcHdnC4Ija2VRxQ/lb+hHQ==", "dev": true, "dependencies": { "@puppeteer/browsers": "^2.2.0", - "@wdio/logger": "9.0.4", - "@wdio/types": "9.0.4", + "@wdio/logger": "9.0.8", + "@wdio/types": "9.0.8", "decamelize": "^6.0.0", "deepmerge-ts": "^7.0.3", "edgedriver": "^5.6.1", @@ -1753,7 +1606,7 @@ "wait-port": "^1.1.0" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" } }, "node_modules/@yarnpkg/lockfile": { @@ -1786,9 +1639,9 @@ } }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2205,16 +2058,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2310,9 +2153,9 @@ } }, "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==", "dev": true }, "node_modules/bach": { @@ -2343,9 +2186,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.3.tgz", - "integrity": "sha512-7RYKL+vZVCyAsMLi5SPu7QGauGGT8avnP/HO571ndEuV4MYdGXvLhtW67FuLPeEI8EiIY7zbbRR9x7x7HU0kgw==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz", + "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==", "dev": true, "optional": true, "dependencies": { @@ -2355,9 +2198,9 @@ } }, "node_modules/bare-os": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.2.tgz", - "integrity": "sha512-HZoJwzC+rZ9lqEemTMiO0luOePoGYNBgsLLgegKR/cljiJvcDNhDZQkzC+NC5Oh0aHbdBNSOHpghwMuB5tqhjg==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz", + "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==", "dev": true, "optional": true }, @@ -2372,13 +2215,14 @@ } }, "node_modules/bare-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.2.0.tgz", - "integrity": "sha512-+o9MG5bPRRBlkVSpfFlMag3n7wMaIZb4YZasU2+/96f+3HTQ4F9DKQeu3K/Sjz1W0umu6xvVq1ON0ipWdMlr3A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz", + "integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==", "dev": true, "optional": true, "dependencies": { - "streamx": "^2.18.0" + "b4a": "^1.6.6", + "streamx": "^2.20.0" } }, "node_modules/base64-js": { @@ -2570,9 +2414,9 @@ } }, "node_modules/chai": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", - "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", "dev": true, "dependencies": { "assertion-error": "^2.0.1", @@ -2951,17 +2795,15 @@ } }, "node_modules/concurrently": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", - "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.0.1.tgz", + "integrity": "sha512-wYKvCd/f54sTXJMSfV6Ln/B8UrfLBKOYa+lzc6CHay3Qek+LorVSBdMVfyewFhRbH0Rbabsk4D+3PL/VjQ5gzg==", "dev": true, "dependencies": { "chalk": "^4.1.2", - "date-fns": "^2.30.0", "lodash": "^4.17.21", "rxjs": "^7.8.1", "shell-quote": "^1.8.1", - "spawn-command": "0.0.2", "supports-color": "^8.1.1", "tree-kill": "^1.2.2", "yargs": "^17.7.2" @@ -2971,7 +2813,7 @@ "concurrently": "dist/bin/concurrently.js" }, "engines": { - "node": "^14.13.0 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" @@ -3206,6 +3048,17 @@ "node": ">=0.10.0" } }, + "node_modules/cssstyle": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dependencies": { + "rrweb-cssom": "^0.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", @@ -3266,22 +3119,6 @@ "node": ">=18" } }, - "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" - } - }, "node_modules/debug": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -3426,19 +3263,6 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3859,21 +3683,22 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "48.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.5.0.tgz", - "integrity": "sha512-ukXPNpGby3KjCveCizIS8t1EbuJEHYEu/tBg8GCbn/YbHcXwphyvYCdvRZ/oMRfTscGSSzfsWoZ+ZkAP0/6YMQ==", + "version": "50.4.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.4.3.tgz", + "integrity": "sha512-uWtwFxGRv6B8sU63HZM5dAGDhgsatb+LONwmILZJhdRALLOkCX2HFZhdL/Kw2ls8SQMAVEfK+LmnEfxInRN8HA==", "dev": true, "dependencies": { - "@es-joy/jsdoccomment": "~0.43.1", + "@es-joy/jsdoccomment": "~0.49.0", "are-docs-informative": "^0.0.2", "comment-parser": "1.4.1", - "debug": "^4.3.4", + "debug": "^4.3.6", "escape-string-regexp": "^4.0.0", - "esquery": "^1.5.0", - "parse-imports": "^2.1.0", - "semver": "^7.6.2", + "espree": "^10.1.0", + "esquery": "^1.6.0", + "parse-imports": "^2.1.1", + "semver": "^7.6.3", "spdx-expression-parse": "^4.0.0", - "synckit": "^0.9.0" + "synckit": "^0.9.1" }, "engines": { "node": ">=18" @@ -3882,10 +3707,39 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -3975,9 +3829,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -4173,9 +4027,9 @@ "dev": true }, "node_modules/fast-xml-parser": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", - "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", "dev": true, "funding": [ { @@ -4839,27 +4693,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glogg": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/glogg/-/glogg-2.2.0.tgz", @@ -5980,20 +5813,20 @@ "dev": true }, "node_modules/jsdoc-type-pratt-parser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", - "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", + "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", "dev": true, "engines": { "node": ">=12.0.0" } }, "node_modules/jsdom": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.0.tgz", - "integrity": "sha512-OhoFVT59T7aEq75TVw9xxEfkXgacpqAhQaYgP9y/fDqWQCMB/b1H66RfmPm/MaeaAIU9nDwMOVTlPN51+ao6CQ==", + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dependencies": { - "cssstyle": "^4.0.1", + "cssstyle": "^4.1.0", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", @@ -6006,7 +5839,7 @@ "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.4", + "tough-cookie": "^5.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", @@ -6027,22 +5860,6 @@ } } }, - "node_modules/jsdom/node_modules/cssstyle": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", - "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", - "dependencies": { - "rrweb-cssom": "^0.6.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/jsdom/node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" - }, "node_modules/jsdom/node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -6054,11 +5871,6 @@ "node": ">=18" } }, - "node_modules/jsdom/node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==" - }, "node_modules/jsdom/node_modules/tr46": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", @@ -6293,9 +6105,9 @@ } }, "node_modules/locate-app": { - "version": "2.4.35", - "resolved": "https://registry.npmjs.org/locate-app/-/locate-app-2.4.35.tgz", - "integrity": "sha512-LmAKEZ5UR4yja7YTYyapV1eNG3Yc/W9N28xTX5tzOJ68NyWs4Laf6wmH3l8wST7T4kaaYaDqdW1UQ+5nOFVBpw==", + "version": "2.4.43", + "resolved": "https://registry.npmjs.org/locate-app/-/locate-app-2.4.43.tgz", + "integrity": "sha512-BX6NEdECUGcDQw8aqqg02qLyF9rF8V+dAfyAnBzL2AofIlIvf4Q6EGXnzVWpWot9uBE+x/o8CjXHo7Zlegu91Q==", "dev": true, "funding": [ { @@ -6308,7 +6120,7 @@ } ], "dependencies": { - "@promptbook/utils": "0.68.0", + "@promptbook/utils": "0.70.0-1", "type-fest": "2.13.0", "userhome": "1.0.0" } @@ -6406,9 +6218,9 @@ } }, "node_modules/loglevel": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", - "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", "dev": true, "engines": { "node": ">= 0.6.0" @@ -7037,9 +6849,9 @@ } }, "node_modules/parse-imports": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.1.0.tgz", - "integrity": "sha512-JQWgmK2o4w8leUkZeZPatWdAny6vXGU/3siIUvMF6J2rDCud9aTt8h/px9oZJ6U3EcfhngBJ635uPFI0q0VAeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.2.1.tgz", + "integrity": "sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==", "dev": true, "dependencies": { "es-module-lexer": "^1.5.3", @@ -7283,16 +7095,6 @@ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", "dev": true }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", @@ -7515,15 +7317,10 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "dev": true, "dependencies": { "end-of-stream": "^1.1.0", @@ -7553,11 +7350,6 @@ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", "dev": true }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7670,12 +7462,6 @@ "node": ">= 10.13.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -7756,7 +7542,8 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", + "dev": true }, "node_modules/resolve": { "version": "1.22.8", @@ -7855,6 +7642,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8053,16 +7845,6 @@ "node": ">=0.3.1" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/slashes": { "version": "3.0.12", "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", @@ -8151,12 +7933,6 @@ "node": ">= 10.13.0" } }, - "node_modules/spawn-command": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", - "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", - "dev": true - }, "node_modules/spdx-exceptions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", @@ -8245,9 +8021,9 @@ "dev": true }, "node_modules/streamx": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.18.0.tgz", - "integrity": "sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==", + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz", + "integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==", "dev": true, "dependencies": { "fast-fifo": "^1.3.2", @@ -8424,9 +8200,9 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, "node_modules/synckit": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.0.tgz", - "integrity": "sha512-7RnqIMq572L8PeEzKeBINYEJDDxpcH8JEgLwUqBd3TkofhFRbkq4QLR0u+36avGAhCRbk2nnmjcW9SE531hPDg==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", + "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", "dev": true, "dependencies": { "@pkgr/core": "^0.1.0", @@ -8440,9 +8216,9 @@ } }, "node_modules/synckit/node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true }, "node_modules/tar-fs": { @@ -8531,6 +8307,22 @@ "next-tick": "1" } }, + "node_modules/tldts": { + "version": "6.1.48", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.48.tgz", + "integrity": "sha512-SPbnh1zaSzi/OsmHb1vrPNnYuwJbdWjwo5TbBYYMlTtH3/1DSb41t8bcSxkwDmmbG2q6VLPVvQc7Yf23T+1EEw==", + "dependencies": { + "tldts-core": "^6.1.48" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.48", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.48.tgz", + "integrity": "sha512-3gD9iKn/n2UuFH1uilBviK9gvTNT6iYwdqrj1Vr5mh8FuelvpRNaYVH4pNYqUgOGU4aAdL9X35eLuuj0gRsx+A==" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -8568,25 +8360,14 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tree-kill": { @@ -8662,9 +8443,9 @@ "dev": true }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -8777,15 +8558,6 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/urlpattern-polyfill": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", @@ -9016,39 +8788,39 @@ } }, "node_modules/webdriver": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.0.7.tgz", - "integrity": "sha512-0PN4omqCGlgi3RG0LyrQXr0RUmlDCenNtpN+dfzikfYoV+CHiCw2GMnZp2XCuYUnU01MaCKgRQxLuGobyZov+A==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.0.8.tgz", + "integrity": "sha512-UnV0ANriSTUgypGk0pz8lApeQuHt+72WEDQG5hFwkkSvggtKLyWdT7+PQkNoXvDajTmiLIqUOq8XPI/Pm71rtw==", "dev": true, "dependencies": { "@types/node": "^20.1.0", "@types/ws": "^8.5.3", - "@wdio/config": "9.0.6", - "@wdio/logger": "9.0.4", - "@wdio/protocols": "9.0.4", - "@wdio/types": "9.0.4", - "@wdio/utils": "9.0.6", + "@wdio/config": "9.0.8", + "@wdio/logger": "9.0.8", + "@wdio/protocols": "9.0.8", + "@wdio/types": "9.0.8", + "@wdio/utils": "9.0.8", "deepmerge-ts": "^7.0.3", "ws": "^8.8.0" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" } }, "node_modules/webdriverio": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.0.7.tgz", - "integrity": "sha512-/6CvJkKpUWYbX/59PNJCHXGLPwulQ/bXZwlIUrsF6MWKdf8Eb6yTaXkMJBaBy5x496b50PQcXkbe+qpfsnqXsg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.0.9.tgz", + "integrity": "sha512-IwvKzhcJ9NjOL55xwj27uTTKkfxsg77dmAfqoKFSP5dQ70JzU+NgxiALEjjWQDybtt1yGIkHk7wjjxjboMU1uw==", "dev": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", - "@wdio/config": "9.0.6", - "@wdio/logger": "9.0.4", - "@wdio/protocols": "9.0.4", - "@wdio/repl": "9.0.4", - "@wdio/types": "9.0.4", - "@wdio/utils": "9.0.6", + "@wdio/config": "9.0.8", + "@wdio/logger": "9.0.8", + "@wdio/protocols": "9.0.8", + "@wdio/repl": "9.0.8", + "@wdio/types": "9.0.8", + "@wdio/utils": "9.0.8", "archiver": "^7.0.1", "aria-query": "^5.3.0", "cheerio": "^1.0.0-rc.12", @@ -9067,10 +8839,10 @@ "rgb2hex": "0.2.5", "serialize-error": "^11.0.3", "urlpattern-polyfill": "^10.0.0", - "webdriver": "9.0.7" + "webdriver": "9.0.8" }, "engines": { - "node": ">=18" + "node": ">=18.20.0" }, "peerDependencies": { "puppeteer-core": "^22.3.0" diff --git a/package.json b/package.json index 5066fa1f1b4..70b70380c64 100644 --- a/package.json +++ b/package.json @@ -111,11 +111,11 @@ "@typescript-eslint/parser": "^8.1.0", "async-done": "^2.0.0", "chai": "^5.1.1", - "concurrently": "^8.0.1", + "concurrently": "^9.0.1", "eslint": "^8.4.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-jsdoc": "^48.0.2", + "eslint-plugin-jsdoc": "^50.4.3", "glob": "^10.3.4", "google-closure-compiler": "^20240317.0.0", "gulp": "^5.0.0", @@ -143,7 +143,7 @@ "yargs": "^17.2.1" }, "dependencies": { - "jsdom": "25.0.0" + "jsdom": "25.0.1" }, "engines": { "node": ">=18" diff --git a/tests/mocha/comment_test.js b/tests/mocha/comment_test.js index 8024fa5e33e..79b3d7de662 100644 --- a/tests/mocha/comment_test.js +++ b/tests/mocha/comment_test.js @@ -41,12 +41,12 @@ suite('Comments', function () { }); function assertEditable(comment) { - assert.isNotOk(comment.textBubble); assert.isOk(comment.textInputBubble); + assert.isTrue(comment.textInputBubble.isEditable()); } function assertNotEditable(comment) { - assert.isNotOk(comment.textInputBubble); - assert.isOk(comment.textBubble); + assert.isOk(comment.textInputBubble); + assert.isFalse(comment.textInputBubble.isEditable()); } test('Editable', async function () { await this.comment.setBubbleVisible(true); diff --git a/tests/mocha/contextmenu_items_test.js b/tests/mocha/contextmenu_items_test.js index b5d480c37b0..a9e2bb3de62 100644 --- a/tests/mocha/contextmenu_items_test.js +++ b/tests/mocha/contextmenu_items_test.js @@ -123,7 +123,7 @@ suite('Context Menu Items', function () { suite('Cleanup', function () { setup(function () { this.cleanupOption = this.registry.getItem('cleanWorkspace'); - this.cleanupStub = sinon.stub(this.workspace, 'cleanUp'); + this.cleanUpStub = sinon.stub(this.workspace, 'cleanUp'); }); test('Enabled when multiple blocks', function () { @@ -153,9 +153,9 @@ suite('Context Menu Items', function () { ); }); - test('Calls workspace cleanup', function () { + test('Calls workspace cleanUp', function () { this.cleanupOption.callback(this.scope); - sinon.assert.calledOnce(this.cleanupStub); + sinon.assert.calledOnce(this.cleanUpStub); }); test('Has correct label', function () { diff --git a/tests/mocha/index.html b/tests/mocha/index.html index ff3467907d7..adc63da4a12 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -110,6 +110,7 @@ import './old_workspace_comment_test.js'; import './procedure_map_test.js'; import './blocks/procedures_test.js'; + import './rect_test.js'; import './registry_test.js'; import './render_management_test.js'; import './serializer_test.js'; diff --git a/tests/mocha/jso_serialization_test.js b/tests/mocha/jso_serialization_test.js index 7e68edb989b..7cf415e676a 100644 --- a/tests/mocha/jso_serialization_test.js +++ b/tests/mocha/jso_serialization_test.js @@ -533,7 +533,7 @@ suite('JSO Serialization', function () { }, 'block': { 'type': 'text', - 'id': 'id3', + 'id': 'id4', 'fields': { 'TEXT': '', }, diff --git a/tests/mocha/rect_test.js b/tests/mocha/rect_test.js new file mode 100644 index 00000000000..37712dff3a0 --- /dev/null +++ b/tests/mocha/rect_test.js @@ -0,0 +1,1668 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Rect', function () { + setup(function () { + sharedTestSetup.call(this); + this.createCoord = function (x, y) { + return new Blockly.utils.Coordinate(x, y); + }; + }); + teardown(function () { + sharedTestTeardown.call(this); + }); + + suite('Rect()', function () { + test('initializes properties correctly', function () { + const rect = new Blockly.utils.Rect(1, 2, 3, 4); + + assert.equal(rect.top, 1, 'top should be initialized'); + assert.equal(rect.bottom, 2, 'bottom should be initialized'); + assert.equal(rect.left, 3, 'left should be initialized'); + assert.equal(rect.right, 4, 'right should be initialized'); + }); + }); + + suite('createFromPoint()', function () { + test('initializes properties correctly', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + assert.equal(rect.top, 2, 'top should be initialized'); + assert.equal(rect.bottom, 47, 'bottom should be initialized'); + assert.equal(rect.left, 1, 'left should be initialized'); + assert.equal(rect.right, 24, 'right should be initialized'); + }); + }); + + suite('clone()', function () { + test('copies properties correctly', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const clonedRect = rect.clone(); + + assert.equal(clonedRect.top, rect.top, 'top should be cloned'); + assert.equal(clonedRect.bottom, rect.bottom, 'bottom should be cloned'); + assert.equal(clonedRect.left, rect.left, 'left should be cloned'); + assert.equal(clonedRect.right, rect.right, 'right should be cloned'); + }); + }); + + suite('equals()', function () { + test('same object instance should equal itself', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect, rect); + + assert.isTrue(areEqual, 'an instance should equal itself'); + }); + + test('null instances should be equal', function () { + const areEqual = Blockly.utils.Rect.equals(null, null); + + assert.isTrue(areEqual, 'null should equal null'); + }); + + test('an object and null should not be equal', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect, null); + + assert.isFalse(areEqual, 'non-null should not equal null'); + }); + + test('null and an object should not be equal', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(null, rect); + + assert.isFalse(areEqual, 'null should not equal non-null'); + }); + + test('object should equal its clone', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect, rect.clone()); + + assert.isTrue(areEqual, 'an instance and its clone should be equal'); + }); + + test('object should equal an exact explicit copy', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isTrue( + areEqual, + 'two objects constructed in the same way should be equal', + ); + }); + + test('object should not equal object with different x position', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 2), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different x positions should not be equal', + ); + }); + + test('object should not equal object with different y position', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 4), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different y positions should not be equal', + ); + }); + + test('object should not equal object with different x and y positions', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 4), + 23, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different x/y positions should not be equal', + ); + }); + + test('object should not equal object with different width', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 46, + 45, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different widths should not be equal', + ); + }); + + test('object should not equal object with different height', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 89, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with different heights should not be equal', + ); + }); + + test('object should not equal object with all different properties', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 4), + 46, + 89, + ); + + const areEqual = Blockly.utils.Rect.equals(rect1, rect2); + + assert.isFalse( + areEqual, + 'two objects with all different properties should not be equal', + ); + }); + }); + + suite('getHeight()', function () { + test('computes zero height for empty rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + assert.equal(rect.getHeight(), 0, 'height should be 0'); + }); + + test('computes height of 1 for unit square rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 1, + 1, + ); + + assert.equal(rect.getHeight(), 1, 'height should be 1'); + }); + + test('computes height of 1 for unit square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 1, + 1, + ); + + assert.equal(rect.getHeight(), 1, 'height should be 1'); + }); + + test('computes height of 1 for unit square rectangle with negative position', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-1, -2), + 1, + 1, + ); + + assert.equal(rect.getHeight(), 1, 'height should be 1'); + }); + + test('computes decimal height for non-square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.1, 2.2), + 3.3, + 4.4, + ); + + assert.approximately(rect.getHeight(), 4.4, 1e-5, 'height should be 4.4'); + }); + }); + + suite('getWidth()', function () { + test('computes zero width for empty rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + assert.equal(rect.getWidth(), 0, 'width should be 0'); + }); + + test('computes width of 1 for unit square rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 1, + 1, + ); + + assert.equal(rect.getWidth(), 1, 'width should be 1'); + }); + + test('computes width of 1 for unit square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 1, + 1, + ); + + assert.equal(rect.getWidth(), 1, 'width should be 1'); + }); + + test('computes width of 1 for unit square rectangle with negative position', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-1, -2), + 1, + 1, + ); + + assert.equal(rect.getWidth(), 1, 'width should be 1'); + }); + + test('computes decimal width for non-square rectangle not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.1, 2.2), + 3.3, + 4.4, + ); + + assert.approximately(rect.getWidth(), 3.3, 1e-5, 'width should be 3.3'); + }); + }); + + suite('contains()', function () { + suite('point contained within rect', function () { + test('origin for zero-sized square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + const isContained = rect.contains(0, 0); + + assert.isTrue(isContained, 'Rect contains (0, 0)'); + }); + + test('whole number centroid for square at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 2, + 2, + ); + + const isContained = rect.contains(1, 1); + + assert.isTrue(isContained, 'Rect contains (1, 1)'); + }); + + test('decimal number centroid for square at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 1, + 1, + ); + + const isContained = rect.contains(0.5, 0.5); + + assert.isTrue(isContained, 'Rect contains (0.5, 0.5)'); + }); + + test('centroid for non-square not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 4); + + assert.isTrue(isContained, 'Rect contains (2.5, 4)'); + }); + + test('negative centroid for non-square not at origin', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-10, -20), + 3, + 5, + ); + + const isContained = rect.contains(-8.5, -17.5); + + assert.isTrue(isContained, 'Rect contains (-8.5, -17.5)'); + }); + + test('NW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(1, 2); + + assert.isTrue(isContained, 'Rect contains (1, 2)'); + }); + + test('NE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4, 2); + + assert.isTrue(isContained, 'Rect contains (4, 2)'); + }); + + test('SW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(1, 6); + + assert.isTrue(isContained, 'Rect contains (1, 6)'); + }); + + test('SE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4, 6); + + assert.isTrue(isContained, 'Rect contains (4, 6)'); + }); + + test('left edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(1, 4); + + assert.isTrue(isContained, 'Rect contains (1, 4)'); + }); + + test('right edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4, 4); + + assert.isTrue(isContained, 'Rect contains (4, 4)'); + }); + + test('top edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 2); + + assert.isTrue(isContained, 'Rect contains (2.5, 2)'); + }); + + test('bottom edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 6); + + assert.isTrue(isContained, 'Rect contains (2.5, 6)'); + }); + }); + suite('point not contained within rect', function () { + test('non-origin for zero-sized square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0, 0), + 0, + 0, + ); + + const isContained = rect.contains(0.1, 0.1); + + assert.isFalse(isContained, 'Rect does not contain (0.1, 0.1)'); + }); + + test('point at midpoint x but above unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(2, 0); + + assert.isFalse(isContained, 'Rect does not contain (2, 0)'); + }); + + test('point at midpoint x but below unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(2, 4); + + assert.isFalse(isContained, 'Rect does not contain (2, 4)'); + }); + + test('point at midpoint y but left of unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(0, 2); + + assert.isFalse(isContained, 'Rect does not contain (0, 2)'); + }); + + test('point at midpoint y but right of unit square', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + + const isContained = rect.contains(4, 2); + + assert.isFalse(isContained, 'Rect does not contain (4, 2)'); + }); + + test('positive point far outside positive rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(45, 89); + + assert.isFalse(isContained, 'Rect does not contain (45, 89)'); + }); + + test('negative point far outside positive rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(-45, -89); + + assert.isFalse(isContained, 'Rect does not contain (-45, -89)'); + }); + + test('positive point far outside negative rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-10, -20), + 3, + 5, + ); + + const isContained = rect.contains(45, 89); + + assert.isFalse(isContained, 'Rect does not contain (45, 89)'); + }); + + test('negative point far outside negative rectangle', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(-10, -20), + 3, + 5, + ); + + const isContained = rect.contains(-45, -89); + + assert.isFalse(isContained, 'Rect does not contain (-45, -89)'); + }); + + test('Point just outside NW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(0.9, 1.9); + + assert.isFalse(isContained, 'Rect does not contain (0.9, 1.9)'); + }); + + test('Point just outside NE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4.1, 1.9); + + assert.isFalse(isContained, 'Rect does not contain (4.1, 1.9)'); + }); + + test('Point just outside SW corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(0.9, 6.1); + + assert.isFalse(isContained, 'Rect does not contain (0.9, 6.1)'); + }); + + test('Point just outside SE corner', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4.1, 6.1); + + assert.isFalse(isContained, 'Rect does not contain (4.1, 6.1)'); + }); + + test('Point just outside left edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(0.9, 4); + + assert.isFalse(isContained, 'Rect does not contain (0.9, 4)'); + }); + + test('Point just outside right edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(4.1, 4); + + assert.isFalse(isContained, 'Rect does not contain (4.1, 4)'); + }); + + test('Point just outside top edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 1.9); + + assert.isFalse(isContained, 'Rect does not contain (2.5, 1.9)'); + }); + + test('Point just outside bottom edge midpoint', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 3, + 4, + ); + + const isContained = rect.contains(2.5, 6.1); + + assert.isFalse(isContained, 'Rect does not contain (2.5, 6.1)'); + }); + }); + }); + + // NOTE TO DEVELOPER: For intersection tests, rects are either large (dimension size '2') or small + // (dimension size '1'). For compactness, the comments denoting the scenario being tested try to + // label larger rects with '2' where they can fit, but smaller rects are generally too small to + // fit any text. + suite('intersects()', function () { + suite('does intersect', function () { + test('rect and itself', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const doIntersect = rect.intersects(rect); + + assert.isTrue(doIntersect, 'a rect always intersects with itself'); + }); + + test('rect and its clone', function () { + const rect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const doIntersect = rect.intersects(rect.clone()); + + assert.isTrue(doIntersect, 'a rect always intersects with its clone'); + }); + + test('two rects of the same positions and dimensions', function () { + const rect1 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + const rect2 = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 23, + 45, + ); + + const doIntersect = rect1.intersects(rect2); + + assert.isTrue( + doIntersect, + 'two rects with the same positions and dimensions intersect', + ); + }); + + test('upper left/lower right', function () { + // ┌───┐ + // │2┌───┐ + // └─│─┘2│ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 2), + 2, + 2, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isTrue( + doIntersectOneWay, + 'SE corner of NW rect intersects with SE rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NW corner of SE rect intersects with NW rect', + ); + }); + + test('upper right/lower left', function () { + // ┌───┐ + // ┌───┐2│ + // │2└─│─┘ + // └───┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 2, + 2, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 2, + 2, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isTrue( + doIntersectOneWay, + 'SW corner of NE rect intersects with SW rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NE corner of SW rect intersects with NE rect', + ); + }); + + test('small rect overlapping left side of big rect', function () { + // ┌────┐ + // ┌───┐2 │ + // └───┘ │ + // └────┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(0.5, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects top/bottom sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects left side of big rect', + ); + }); + + test('small rect overlapping right side of big rect', function () { + // ┌────┐ + // │ 2┌───┐ + // │ └───┘ + // └────┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects top/bottom sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects right side of big rect', + ); + }); + + test('small rect overlapping top side of big rect', function () { + // ┌─┐ + // ┌│─│┐ + // │└─┘│ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 0.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects left/right sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects top side of big rect', + ); + }); + + test('small rect overlapping bottom side of big rect', function () { + // ┌───┐ + // │┌─┐│ + // └│─│┘ + // └─┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 2.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects left/right sides of small rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects bottom side of big rect', + ); + }); + + test('edge only intersection with all corners outside each rect', function () { + // ┌─┐ + // │ │ + // ┌─────┐ + // └─────┘ + // │ │ + // └─┘ + const tallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 1, + 2, + ); + const wideRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 2, + 1, + ); + + const doIntersectOneWay = tallRect.intersects(wideRect); + const doIntersectOtherWay = wideRect.intersects(tallRect); + + assert.isTrue( + doIntersectOneWay, + 'tall rect intersects top/bottom of wide rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'wide rect intersects left/right of tall rect', + ); + }); + + test('small rect within larger rect', function () { + // ┌─────┐ + // │ ┌─┐ │ + // │ └─┘ │ + // └─────┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + 'big rect intersects small rect since it is contained', + ); + assert.isTrue( + doIntersectOtherWay, + 'small rect intersects big rect since it is contained', + ); + }); + + test('rects overlapping on left/right sides', function () { + // ┌──┌────┐ + // │ 2│ │2 │ + // └──└────┘ + const leftRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const rightRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 2, + 2, + ); + + const doIntersectOneWay = leftRect.intersects(rightRect); + const doIntersectOtherWay = rightRect.intersects(leftRect); + + assert.isTrue( + doIntersectOneWay, + "Left rect's right overlaps with right rect's left", + ); + assert.isTrue( + doIntersectOtherWay, + "Right rect's left overlaps with left rect's right", + ); + }); + + test('rects overlapping on top/bottom sides', function () { + // ┌───┐ + // ┌───┐ + // │───│ + // └───┘ + const topRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const bottomRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2), + 2, + 2, + ); + + const doIntersectOneWay = topRect.intersects(bottomRect); + const doIntersectOtherWay = bottomRect.intersects(topRect); + + assert.isTrue( + doIntersectOneWay, + "Top rect's bottom overlaps with bottom rect's top", + ); + assert.isTrue( + doIntersectOtherWay, + "Bottom rect's top overlaps with top rect's bottom", + ); + }); + + test('rects adjacent on left/right sides', function () { + // ┌───┬───┐ + // │ 2 │ 2 │ + // └───┴───┘ + const leftRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const rightRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 1), + 2, + 2, + ); + + const doIntersectOneWay = leftRect.intersects(rightRect); + const doIntersectOtherWay = rightRect.intersects(leftRect); + + assert.isTrue( + doIntersectOneWay, + "Left rect's right intersects with right rect's left", + ); + assert.isTrue( + doIntersectOtherWay, + "Right rect's left intersects with left rect's right", + ); + }); + + test('rects adjacent on top/bottom sides', function () { + // ┌───┐ + // │ 2 │ + // ├───┤ + // │ 2 │ + // └───┘ + const topRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const bottomRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3), + 2, + 2, + ); + + const doIntersectOneWay = topRect.intersects(bottomRect); + const doIntersectOtherWay = bottomRect.intersects(topRect); + + assert.isTrue( + doIntersectOneWay, + "Top rect's bottom intersects with bottom rect's top", + ); + assert.isTrue( + doIntersectOtherWay, + "Bottom rect's top intersects with top rect's bottom", + ); + }); + + test('small left rect adjacent to big right rect', function () { + // ┌───┐ + // ┌─┐ 2 │ + // └─┘ │ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's left intersects small rect's right", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's right intersects big rect's left", + ); + }); + + test('small right rect adjacent to big left rect', function () { + // ┌───┐ + // │ 2 ┌─┐ + // │ └─┘ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 1.5), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's right intersects small rect's left", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's left intersects big rect's right", + ); + }); + + test('small top rect adjacent to big bottom rect', function () { + // ┌─┐ + // ┌└─┘┐ + // │ 2 │ + // └───┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 0), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's top intersects small rect's bottom", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's bottom intersects big rect's top", + ); + }); + + test('small bottom rect adjacent to big top rect', function () { + // ┌───┐ + // │ 2 │ + // └┌─┐┘ + // └─┘ + const bigRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const smallRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1.5, 3), + 1, + 1, + ); + + const doIntersectOneWay = bigRect.intersects(smallRect); + const doIntersectOtherWay = smallRect.intersects(bigRect); + + assert.isTrue( + doIntersectOneWay, + "big rect's bottom intersects small rect's top", + ); + assert.isTrue( + doIntersectOtherWay, + "small rect's top intersects big rect's bottom", + ); + }); + + test('SW rect corner-adjacent to NE rect', function () { + // ┌───┐ + // │ 2 │ + // ┌───┐───┘ + // │ 2 │ + // └───┘ + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3), + 2, + 2, + ); + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 1), + 2, + 2, + ); + + const doIntersectOneWay = swRect.intersects(neRect); + const doIntersectOtherWay = neRect.intersects(swRect); + + assert.isTrue( + doIntersectOneWay, + 'SW rect intersects with SW corner of NE rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NE rect intersects with NE corner of SW rect', + ); + }); + + test('NW rect corner-adjacent to SE rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘───┐ + // │ 2 │ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3, 3), + 2, + 2, + ); + + const doIntersectOneWay = seRect.intersects(nwRect); + const doIntersectOtherWay = nwRect.intersects(seRect); + + assert.isTrue( + doIntersectOneWay, + 'SE rect intersects with SE corner of NW rect', + ); + assert.isTrue( + doIntersectOtherWay, + 'NW rect intersects with NW corner of SE rect', + ); + }); + }); + suite('does not intersect', function () { + test('Same-size rects nearly side-adjacent', function () { + // ┌───┐ ┌───┐ + // │ 2 │ │ 2 │ + // └───┘ └───┘ + const westRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const eastRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 2, + 2, + ); + + const doIntersectOneWay = westRect.intersects(eastRect); + const doIntersectOtherWay = eastRect.intersects(westRect); + + assert.isFalse( + doIntersectOneWay, + 'Western rect does not intersect with eastern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Eastern rect does not intersect with western rect', + ); + }); + + test('Same-size rects nearly side-adjacent', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const northRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const southRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 2, + 2, + ); + + const doIntersectOneWay = northRect.intersects(southRect); + const doIntersectOtherWay = southRect.intersects(northRect); + + assert.isFalse( + doIntersectOneWay, + 'Northern rect does not intersect with southern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Southern rect does not intersect with northern rect', + ); + }); + + test('Small rect left of big rect', function () { + // ┌───┐ + // ┌─┐│ 2 │ + // └─┘│ │ + // └───┘ + const westRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 1, + 1, + ); + const eastRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 1), + 2, + 2, + ); + + const doIntersectOneWay = westRect.intersects(eastRect); + const doIntersectOtherWay = eastRect.intersects(westRect); + + assert.isFalse( + doIntersectOneWay, + 'Western rect does not intersect with eastern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Eastern rect does not intersect with western rect', + ); + }); + + test('Small rect right of big rect', function () { + // ┌───┐ + // │ 2 │┌─┐ + // │ │└─┘ + // └───┘ + const westRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const eastRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 1, + 1, + ); + + const doIntersectOneWay = westRect.intersects(eastRect); + const doIntersectOtherWay = eastRect.intersects(westRect); + + assert.isFalse( + doIntersectOneWay, + 'Western rect does not intersect with eastern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Eastern rect does not intersect with western rect', + ); + }); + + test('Small rect above big rect', function () { + // ┌─┐ + // └─┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const northRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 1, + 1, + ); + const southRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2.5), + 2, + 2, + ); + + const doIntersectOneWay = northRect.intersects(southRect); + const doIntersectOtherWay = southRect.intersects(northRect); + + assert.isFalse( + doIntersectOneWay, + 'Northern rect does not intersect with southern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Southern rect does not intersect with northern rect', + ); + }); + + test('Small rect below big rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌─┐ + // └─┘ + const northRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const southRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 1, + 1, + ); + + const doIntersectOneWay = northRect.intersects(southRect); + const doIntersectOtherWay = southRect.intersects(northRect); + + assert.isFalse( + doIntersectOneWay, + 'Northern rect does not intersect with southern rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'Southern rect does not intersect with northern rect', + ); + }); + + test('Same-size rects diagonal (NE and SW) to each other', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 2, + 2, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 2, + 2, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isFalse( + doIntersectOneWay, + 'NE rect does not intersect with SW rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SW rect does not intersect with NE rect', + ); + }); + + test('Same-size rects diagonal (NW and SE) to each other', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 3.5), + 2, + 2, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isFalse( + doIntersectOneWay, + 'NW rect does not intersect with SE rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SE rect does not intersect with NW rect', + ); + }); + + test('Small rect NE of big rect', function () { + // ┌─┐ + // └─┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 1), + 1, + 1, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 2.5), + 2, + 2, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isFalse( + doIntersectOneWay, + 'NE rect does not intersect with SW rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SW rect does not intersect with NE rect', + ); + }); + + test('Small rect NW of big rect', function () { + // ┌─┐ + // └─┘ + // ┌───┐ + // │ 2 │ + // └───┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 1, + 1, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 2.5), + 2, + 2, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isFalse( + doIntersectOneWay, + 'NW rect does not intersect with SE rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SE rect does not intersect with NW rect', + ); + }); + + test('Small rect SW of big rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌─┐ + // └─┘ + const neRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(2.5, 1), + 2, + 2, + ); + const swRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 3.5), + 1, + 1, + ); + + const doIntersectOneWay = neRect.intersects(swRect); + const doIntersectOtherWay = swRect.intersects(neRect); + + assert.isFalse( + doIntersectOneWay, + 'NE rect does not intersect with SW rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SW rect does not intersect with NE rect', + ); + }); + + test('Small rect SE of big rect', function () { + // ┌───┐ + // │ 2 │ + // └───┘ + // ┌─┐ + // └─┘ + const nwRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(1, 1), + 2, + 2, + ); + const seRect = Blockly.utils.Rect.createFromPoint( + this.createCoord(3.5, 3.5), + 1, + 1, + ); + + const doIntersectOneWay = nwRect.intersects(seRect); + const doIntersectOtherWay = seRect.intersects(nwRect); + + assert.isFalse( + doIntersectOneWay, + 'NW rect does not intersect with SE rect', + ); + assert.isFalse( + doIntersectOtherWay, + 'SE rect does not intersect with NW rect', + ); + }); + }); + }); +}); diff --git a/tests/mocha/workspace_svg_test.js b/tests/mocha/workspace_svg_test.js index d5c80e2f327..75c0625fb15 100644 --- a/tests/mocha/workspace_svg_test.js +++ b/tests/mocha/workspace_svg_test.js @@ -406,6 +406,560 @@ suite('WorkspaceSvg', function () { }); }); }); + + suite('cleanUp', function () { + test('empty workspace does not change', function () { + this.workspace.cleanUp(); + + const blocks = this.workspace.getTopBlocks(true); + assert.equal(blocks.length, 0, 'workspace is empty'); + }); + + test('single block at (0, 0) does not change', function () { + const blockJson = { + 'type': 'math_number', + 'x': 0, + 'y': 0, + 'fields': { + 'NUM': 123, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const blocks = this.workspace.getTopBlocks(true); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(blocks.length, 1, 'workspace has one top-level block'); + assert.deepEqual( + blocks[0].getRelativeToSurfaceXY(), + origin, + 'block is at origin', + ); + }); + + test('single block at (10, 15) is moved to (0, 0)', function () { + const blockJson = { + 'type': 'math_number', + 'x': 10, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(topBlocks.length, 1, 'workspace has one top-level block'); + assert.equal(allBlocks.length, 1, 'workspace has one block overall'); + assert.deepEqual( + topBlocks[0].getRelativeToSurfaceXY(), + origin, + 'block is at origin', + ); + }); + + test('single block at (10, 15) with child is moved as unit to (0, 0)', function () { + const blockJson = { + 'type': 'logic_negate', + 'id': 'parent', + 'x': 10, + 'y': 15, + 'inputs': { + 'BOOL': { + 'block': { + 'type': 'logic_boolean', + 'id': 'child', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(topBlocks.length, 1, 'workspace has one top-level block'); + assert.equal(allBlocks.length, 2, 'workspace has two blocks overall'); + assert.deepEqual( + topBlocks[0].getRelativeToSurfaceXY(), + origin, + 'block is at origin', + ); + assert.notDeepEqual( + allBlocks[1].getRelativeToSurfaceXY(), + origin, + 'child is not at origin', + ); + }); + + test('two blocks first at (10, 15) second at (0, 0) do not switch places', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 10, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 0, 'y': 0}; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + // block1 and block2 do not switch places since blocks are pre-sorted by their position before + // being tidied up, so the order they were added to the workspace doesn't matter. + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock2 = new Blockly.utils.Coordinate(0, 50); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + origin, + 'block2 is at origin', + ); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + belowBlock2, + 'block1 is below block2', + ); + }); + + test('two overlapping blocks are moved to origin and below', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 25, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = { + ...blockJson1, + 'id': 'block2', + 'x': 15.25, + 'y': 20.25, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock1 = new Blockly.utils.Coordinate(0, 50); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + origin, + 'block1 is at origin', + ); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + belowBlock1, + 'block2 is below block1', + ); + }); + + test('two overlapping blocks with snapping are moved to grid-aligned positions', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 25, + 'y': 15, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = { + ...blockJson1, + 'id': 'block2', + 'x': 15.25, + 'y': 20.25, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + this.workspace.getGrid().setSpacing(20); + this.workspace.getGrid().setSnapToGrid(true); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const snappedOffOrigin = new Blockly.utils.Coordinate(10, 10); + const belowBlock1 = new Blockly.utils.Coordinate(10, 70); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + snappedOffOrigin, + 'block1 is near origin', + ); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + belowBlock1, + 'block2 is below block1', + ); + }); + + test('two overlapping blocks are moved to origin and below including children', function () { + const blockJson1 = { + 'type': 'logic_negate', + 'id': 'block1', + 'x': 10, + 'y': 15, + 'inputs': { + 'BOOL': { + 'block': { + 'type': 'logic_boolean', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + }, + }; + const blockJson2 = { + ...blockJson1, + 'id': 'block2', + 'x': 15.25, + 'y': 20.25, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock1 = new Blockly.utils.Coordinate(0, 50); + const block1Pos = block1.getRelativeToSurfaceXY(); + const block2Pos = block2.getRelativeToSurfaceXY(); + const block1ChildPos = block1.getChildren()[0].getRelativeToSurfaceXY(); + const block2ChildPos = block2.getChildren()[0].getRelativeToSurfaceXY(); + assert.equal(topBlocks.length, 2, 'workspace has two top-level block2'); + assert.equal(allBlocks.length, 4, 'workspace has four blocks overall'); + assert.deepEqual(block1Pos, origin, 'block1 is at origin'); + assert.deepEqual(block2Pos, belowBlock1, 'block2 is below block1'); + assert.isAbove( + block1ChildPos.x, + block1Pos.x, + "block1's child is right of it", + ); + assert.isBelow( + block1ChildPos.y, + block2Pos.y, + "block1's child is above block 2", + ); + assert.isAbove( + block2ChildPos.x, + block2Pos.x, + "block2's child is right of it", + ); + assert.isAbove( + block2ChildPos.y, + block1Pos.y, + "block2's child is below block 1", + ); + }); + + test('two large overlapping blocks are moved to origin and below', function () { + const blockJson1 = { + 'type': 'controls_repeat_ext', + 'id': 'block1', + 'x': 10, + 'y': 20, + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'controls_if', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 20, 'y': 30}; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1 = this.workspace.getBlockById('block1'); + const block2 = this.workspace.getBlockById('block2'); + const origin = new Blockly.utils.Coordinate(0, 0); + const belowBlock1 = new Blockly.utils.Coordinate(0, 144); + assert.equal(topBlocks.length, 2, 'workspace has two top-level blocks'); + assert.deepEqual( + block1.getRelativeToSurfaceXY(), + origin, + 'block1 is at origin', + ); + assert.deepEqual( + block2.getRelativeToSurfaceXY(), + belowBlock1, + 'block2 is below block1', + ); + }); + + test('five overlapping blocks are moved in-order as one column', function () { + const blockJson1 = { + 'type': 'math_number', + 'id': 'block1', + 'x': 1, + 'y': 2, + 'fields': { + 'NUM': 123, + }, + }; + const blockJson2 = {...blockJson1, 'id': 'block2', 'x': 3, 'y': 4}; + const blockJson3 = {...blockJson1, 'id': 'block3', 'x': 5, 'y': 6}; + const blockJson4 = {...blockJson1, 'id': 'block4', 'x': 7, 'y': 8}; + const blockJson5 = {...blockJson1, 'id': 'block5', 'x': 9, 'y': 10}; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + Blockly.serialization.blocks.append(blockJson3, this.workspace); + Blockly.serialization.blocks.append(blockJson4, this.workspace); + Blockly.serialization.blocks.append(blockJson5, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1Pos = this.workspace + .getBlockById('block1') + .getRelativeToSurfaceXY(); + const block2Pos = this.workspace + .getBlockById('block2') + .getRelativeToSurfaceXY(); + const block3Pos = this.workspace + .getBlockById('block3') + .getRelativeToSurfaceXY(); + const block4Pos = this.workspace + .getBlockById('block4') + .getRelativeToSurfaceXY(); + const block5Pos = this.workspace + .getBlockById('block5') + .getRelativeToSurfaceXY(); + const origin = new Blockly.utils.Coordinate(0, 0); + assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks'); + assert.deepEqual(block1Pos, origin, 'block1 is at origin'); + assert.equal(block2Pos.x, 0, 'block2.x is at 0'); + assert.equal(block3Pos.x, 0, 'block3.x is at 0'); + assert.equal(block4Pos.x, 0, 'block4.x is at 0'); + assert.equal(block5Pos.x, 0, 'block5.x is at 0'); + assert.isAbove(block2Pos.y, block1Pos.y, 'block2 is below block1'); + assert.isAbove(block3Pos.y, block2Pos.y, 'block3 is below block2'); + assert.isAbove(block4Pos.y, block3Pos.y, 'block4 is below block3'); + assert.isAbove(block5Pos.y, block4Pos.y, 'block5 is below block4'); + }); + + test('single immovable block at (10, 15) is not moved', function () { + const blockJson = { + 'type': 'math_number', + 'x': 10, + 'y': 15, + 'movable': false, + 'fields': { + 'NUM': 123, + }, + }; + Blockly.serialization.blocks.append(blockJson, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const allBlocks = this.workspace.getAllBlocks(false); + const origPos = new Blockly.utils.Coordinate(10, 15); + assert.equal(topBlocks.length, 1, 'workspace has one top-level block'); + assert.equal(allBlocks.length, 1, 'workspace has one block overall'); + assert.deepEqual( + topBlocks[0].getRelativeToSurfaceXY(), + origPos, + 'block is at (10, 15)', + ); + }); + + test('multiple block types immovable blocks are not moved', function () { + const smallBlockJson = { + 'type': 'math_number', + 'fields': { + 'NUM': 123, + }, + }; + const largeBlockJson = { + 'type': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'controls_if', + 'inputs': { + 'IF0': { + 'block': { + 'type': 'logic_boolean', + 'fields': { + 'BOOL': 'TRUE', + }, + }, + }, + 'DO0': { + 'block': { + 'type': 'text_print', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + // Block 1 overlaps block 2 (immovable) from above. + const blockJson1 = {...smallBlockJson, 'id': 'block1', 'x': 1, 'y': 2}; + const blockJson2 = { + ...smallBlockJson, + 'id': 'block2', + 'x': 10, + 'y': 20, + 'movable': false, + }; + // Block 3 overlaps block 2 (immovable) from below. + const blockJson3 = {...smallBlockJson, 'id': 'block3', 'x': 2, 'y': 30}; + const blockJson4 = {...largeBlockJson, 'id': 'block4', 'x': 3, 'y': 40}; + // Block 5 (immovable) will end up overlapping with block 4 since it's large and will be + // moved. + const blockJson5 = { + ...smallBlockJson, + 'id': 'block5', + 'x': 20, + 'y': 200, + 'movable': false, + }; + Blockly.serialization.blocks.append(blockJson1, this.workspace); + Blockly.serialization.blocks.append(blockJson2, this.workspace); + Blockly.serialization.blocks.append(blockJson3, this.workspace); + Blockly.serialization.blocks.append(blockJson4, this.workspace); + Blockly.serialization.blocks.append(blockJson5, this.workspace); + + this.workspace.cleanUp(); + + const topBlocks = this.workspace.getTopBlocks(true); + const block1Rect = this.workspace + .getBlockById('block1') + .getBoundingRectangle(); + const block2Rect = this.workspace + .getBlockById('block2') + .getBoundingRectangle(); + const block3Rect = this.workspace + .getBlockById('block3') + .getBoundingRectangle(); + const block4Rect = this.workspace + .getBlockById('block4') + .getBoundingRectangle(); + const block5Rect = this.workspace + .getBlockById('block5') + .getBoundingRectangle(); + assert.equal(topBlocks.length, 5, 'workspace has five top-level blocks'); + // Check that immovable blocks haven't moved. + assert.equal(block2Rect.left, 10, 'block2.x is at 10'); + assert.equal(block2Rect.top, 20, 'block2.y is at 20'); + assert.equal(block5Rect.left, 20, 'block5.x is at 20'); + assert.equal(block5Rect.top, 200, 'block5.y is at 200'); + // Check that movable positions have correctly been left-aligned. + assert.equal(block1Rect.left, 0, 'block1.x is at 0'); + assert.equal(block3Rect.left, 0, 'block3.x is at 0'); + assert.equal(block4Rect.left, 0, 'block4.x is at 0'); + // Block order should be: 2, 1, 3, 5, 4 since 2 and 5 are immovable. + assert.isAbove(block1Rect.top, block2Rect.top, 'block1 is below block2'); + assert.isAbove(block3Rect.top, block1Rect.top, 'block3 is below block1'); + assert.isAbove(block5Rect.top, block3Rect.top, 'block5 is below block3'); + assert.isAbove(block4Rect.top, block5Rect.top, 'block4 is below block5'); + // Ensure no blocks intersect (can check in order due to the position verification above). + assert.isFalse( + block2Rect.intersects(block1Rect), + 'block2/block1 do not intersect', + ); + assert.isFalse( + block1Rect.intersects(block3Rect), + 'block1/block3 do not intersect', + ); + assert.isFalse( + block3Rect.intersects(block5Rect), + 'block3/block5 do not intersect', + ); + assert.isFalse( + block5Rect.intersects(block4Rect), + 'block5/block4 do not intersect', + ); + }); + }); + suite('Workspace Base class', function () { testAWorkspace(); });