mirror of
https://github.com/google/blockly.git
synced 2026-06-17 00:25:14 +02:00
release: merge v13 into main
release: merge v13 into main Merge pull request #9979 from RaspberryPiFoundation/v13
This commit is contained in:
+3
-1
@@ -20,9 +20,11 @@ changelog:
|
||||
- title: Bug fixes 🐛
|
||||
labels:
|
||||
- 'PR: fix'
|
||||
- title: Cleanup ♻️
|
||||
- title: Documentation updates 📄
|
||||
labels:
|
||||
- 'PR: docs'
|
||||
- title: Cleanup ♻️
|
||||
labels:
|
||||
- 'PR: refactor'
|
||||
- title: Reverted changes ⎌
|
||||
labels:
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
# TODO (#2114): re-enable osx build.
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [macos-latest]
|
||||
node-version: [18.x, 20.x]
|
||||
node-version: [22.x, 24.x]
|
||||
# See supported Node.js release schedule at
|
||||
# https://nodejs.org/en/about/releases/
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./packages/blockly
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
# TODO (#2114): re-enable osx build.
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
node-version: [18.x, 20.x, 22.x, 24.x]
|
||||
node-version: [22.x, 24.x]
|
||||
# See supported Node.js release schedule at
|
||||
# https://nodejs.org/en/about/releases/
|
||||
|
||||
@@ -43,6 +43,10 @@ jobs:
|
||||
- name: Npm Clean Install
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Chrome
|
||||
if: runner.os == 'Linux'
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
|
||||
- name: Linux Test Setup
|
||||
if: runner.os == 'Linux'
|
||||
run: source ./tests/scripts/setup_linux_env.sh
|
||||
@@ -62,10 +66,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
|
||||
- name: Npm Install
|
||||
run: npm install
|
||||
@@ -81,10 +85,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
|
||||
- name: Npm Install
|
||||
run: npm install
|
||||
|
||||
@@ -154,6 +154,9 @@ jobs:
|
||||
git add packages/blockly/package.json package-lock.json
|
||||
git commit -m "release: v${{ needs.version.outputs.version }}"
|
||||
git push
|
||||
TAG="blockly-v${{ needs.version.outputs.version }}"
|
||||
git tag "$TAG"
|
||||
git push origin "refs/tags/$TAG"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
Generated
+5805
-5325
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -20,12 +20,12 @@
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0"
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint": "9.36.0",
|
||||
"prettier": "3.6.2"
|
||||
"eslint": "10.3.0",
|
||||
"prettier": "3.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "npm run test --ws --if-present",
|
||||
@@ -34,4 +34,4 @@
|
||||
"format:check": "npm run format:check --ws --if-present",
|
||||
"postinstall": "patch-package"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'ITEM',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_REPEAT_ITEM}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'NUM',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_REPEAT_NUM}',
|
||||
},
|
||||
],
|
||||
'output': 'Array',
|
||||
@@ -69,6 +71,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'LIST',
|
||||
'check': 'Array',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_TO_CHANGE}',
|
||||
},
|
||||
],
|
||||
'output': 'Array',
|
||||
@@ -86,6 +89,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': ['String', 'Array'],
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_TO_CHECK}',
|
||||
},
|
||||
],
|
||||
'output': 'Boolean',
|
||||
@@ -102,6 +106,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': ['String', 'Array'],
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LISTS_TO_CHECK}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -274,7 +279,14 @@ const LISTS_CREATE_WITH = {
|
||||
// Add new inputs.
|
||||
for (let i = 0; i < this.itemCount_; i++) {
|
||||
if (!this.getInput('ADD' + i)) {
|
||||
const input = this.appendValueInput('ADD' + i).setAlign(Align.RIGHT);
|
||||
const input = this.appendValueInput('ADD' + i)
|
||||
.setAlign(Align.RIGHT)
|
||||
.setAriaLabelProvider(
|
||||
Msg['INPUT_LABEL_LISTS_CREATE_WITH_ITEM'].replace(
|
||||
'%1',
|
||||
String(i + 1),
|
||||
),
|
||||
);
|
||||
if (i === 0) {
|
||||
input.appendField(Msg['LISTS_CREATE_WITH_INPUT_WITH']);
|
||||
}
|
||||
@@ -350,7 +362,8 @@ const LISTS_INDEXOF = {
|
||||
this.setOutput(true, 'Number');
|
||||
this.appendValueInput('VALUE')
|
||||
.setCheck('Array')
|
||||
.appendField(Msg['LISTS_INDEX_OF_INPUT_IN_LIST']);
|
||||
.appendField(Msg['LISTS_INDEX_OF_INPUT_IN_LIST'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
|
||||
const operatorsDropdown = fieldRegistry.fromJson({
|
||||
type: 'field_dropdown',
|
||||
options: OPERATORS,
|
||||
@@ -408,7 +421,8 @@ const LISTS_GETINDEX = {
|
||||
);
|
||||
this.appendValueInput('VALUE')
|
||||
.setCheck('Array')
|
||||
.appendField(Msg['LISTS_GET_INDEX_INPUT_IN_LIST']);
|
||||
.appendField(Msg['LISTS_GET_INDEX_INPUT_IN_LIST'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
|
||||
this.appendDummyInput()
|
||||
.appendField(modeMenu, 'MODE')
|
||||
.appendField('', 'SPACE');
|
||||
@@ -586,7 +600,9 @@ const LISTS_GETINDEX = {
|
||||
this.removeInput('ORDINAL', true);
|
||||
// Create either a value 'AT' input or a dummy input.
|
||||
if (isAt) {
|
||||
this.appendValueInput('AT').setCheck('Number');
|
||||
this.appendValueInput('AT')
|
||||
.setCheck('Number')
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_POSITION']);
|
||||
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
|
||||
this.appendDummyInput('ORDINAL').appendField(
|
||||
Msg['ORDINAL_NUMBER_SUFFIX'],
|
||||
@@ -629,7 +645,8 @@ const LISTS_SETINDEX = {
|
||||
this.setStyle('list_blocks');
|
||||
this.appendValueInput('LIST')
|
||||
.setCheck('Array')
|
||||
.appendField(Msg['LISTS_SET_INDEX_INPUT_IN_LIST']);
|
||||
.appendField(Msg['LISTS_SET_INDEX_INPUT_IN_LIST'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
|
||||
const operationDropdown = fieldRegistry.fromJson({
|
||||
type: 'field_dropdown',
|
||||
options: MODE,
|
||||
@@ -656,7 +673,9 @@ const LISTS_SETINDEX = {
|
||||
);
|
||||
this.appendDummyInput().appendField(menu, 'WHERE');
|
||||
this.appendDummyInput('AT');
|
||||
this.appendValueInput('TO').appendField(Msg['LISTS_SET_INDEX_INPUT_TO']);
|
||||
this.appendValueInput('TO')
|
||||
.appendField(Msg['LISTS_SET_INDEX_INPUT_TO'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_VALUE_TO_SET']);
|
||||
this.setInputsInline(true);
|
||||
this.setPreviousStatement(true);
|
||||
this.setNextStatement(true);
|
||||
@@ -758,7 +777,9 @@ const LISTS_SETINDEX = {
|
||||
this.removeInput('ORDINAL', true);
|
||||
// Create either a value 'AT' input or a dummy input.
|
||||
if (isAt) {
|
||||
this.appendValueInput('AT').setCheck('Number');
|
||||
this.appendValueInput('AT')
|
||||
.setCheck('Number')
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_POSITION']);
|
||||
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
|
||||
this.appendDummyInput('ORDINAL').appendField(
|
||||
Msg['ORDINAL_NUMBER_SUFFIX'],
|
||||
@@ -802,7 +823,8 @@ const LISTS_GETSUBLIST = {
|
||||
this.setStyle('list_blocks');
|
||||
this.appendValueInput('LIST')
|
||||
.setCheck('Array')
|
||||
.appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST']);
|
||||
.appendField(Msg['LISTS_GET_SUBLIST_INPUT_IN_LIST'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_TO_CHECK']);
|
||||
const createMenu = (n: 1 | 2): FieldDropdown => {
|
||||
const menu = fieldRegistry.fromJson({
|
||||
type: 'field_dropdown',
|
||||
@@ -895,7 +917,13 @@ const LISTS_GETSUBLIST = {
|
||||
this.removeInput('ORDINAL' + n, true);
|
||||
// Create either a value 'AT' input or a dummy input.
|
||||
if (isAt) {
|
||||
this.appendValueInput('AT' + n).setCheck('Number');
|
||||
this.appendValueInput('AT' + n)
|
||||
.setCheck('Number')
|
||||
.setAriaLabelProvider(
|
||||
n === 1
|
||||
? Msg['INPUT_LABEL_LISTS_START_POSITION']
|
||||
: Msg['INPUT_LABEL_LISTS_END_POSITION'],
|
||||
);
|
||||
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
|
||||
this.appendDummyInput('ORDINAL' + n).appendField(
|
||||
Msg['ORDINAL_NUMBER_SUFFIX'],
|
||||
@@ -948,6 +976,7 @@ blocks['lists_sort'] = {
|
||||
'type': 'input_value',
|
||||
'name': 'LIST',
|
||||
'check': 'Array',
|
||||
'ariaLabelText': Msg['INPUT_LABEL_LISTS_TO_CHANGE'],
|
||||
},
|
||||
],
|
||||
'output': 'Array',
|
||||
@@ -972,6 +1001,14 @@ blocks['lists_split'] = {
|
||||
[Msg['LISTS_SPLIT_TEXT_FROM_LIST'], 'JOIN'],
|
||||
],
|
||||
});
|
||||
const inputAriaLabelProvider = () => {
|
||||
const mode = this.getFieldValue('MODE');
|
||||
if (mode === 'SPLIT') {
|
||||
return Msg['INPUT_LABEL_LISTS_LIST_FROM_TEXT'];
|
||||
} else if (mode === 'JOIN') {
|
||||
return Msg['INPUT_LABEL_LISTS_TEXT_FROM_LIST'];
|
||||
}
|
||||
};
|
||||
if (!dropdown) throw new Error('field_dropdown not found');
|
||||
dropdown.setValidator((newMode) => {
|
||||
this.updateType_(newMode);
|
||||
@@ -980,10 +1017,12 @@ blocks['lists_split'] = {
|
||||
this.setStyle('list_blocks');
|
||||
this.appendValueInput('INPUT')
|
||||
.setCheck('String')
|
||||
.appendField(dropdown, 'MODE');
|
||||
.appendField(dropdown, 'MODE')
|
||||
.setAriaLabelProvider(inputAriaLabelProvider);
|
||||
this.appendValueInput('DELIM')
|
||||
.setCheck('String')
|
||||
.appendField(Msg['LISTS_SPLIT_WITH_DELIMITER']);
|
||||
.appendField(Msg['INPUT_LABEL_LISTS_DELIMITER'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_LISTS_SPLIT_WITH_DELIMITER']);
|
||||
this.setInputsInline(true);
|
||||
this.setOutput(true, 'Array');
|
||||
this.setTooltip(() => {
|
||||
|
||||
@@ -100,10 +100,9 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'previousStatement': null,
|
||||
'nextStatement': null,
|
||||
'style': 'logic_blocks',
|
||||
'tooltip': '%{BKYCONTROLS_IF_TOOLTIP_2}',
|
||||
'tooltip': '%{BKY_CONTROLS_IF_TOOLTIP_2}',
|
||||
'helpUrl': '%{BKY_CONTROLS_IF_HELPURL}',
|
||||
'suppressPrefixSuffix': true,
|
||||
'extensions': ['controls_if_tooltip'],
|
||||
},
|
||||
// Block for comparison operator.
|
||||
{
|
||||
@@ -113,22 +112,24 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'A',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_VALUE_A}',
|
||||
},
|
||||
{
|
||||
'type': 'field_dropdown',
|
||||
'name': 'OP',
|
||||
'options': [
|
||||
['=', 'EQ'],
|
||||
['\u2260', 'NEQ'],
|
||||
['\u200F<', 'LT'],
|
||||
['\u200F\u2264', 'LTE'],
|
||||
['\u200F>', 'GT'],
|
||||
['\u200F\u2265', 'GTE'],
|
||||
['=', 'EQ', '%{BKY_LOGIC_COMPARE_EQ_ARIA}'],
|
||||
['\u2260', 'NEQ', '%{BKY_LOGIC_COMPARE_NEQ_ARIA}'],
|
||||
['\u200F<', 'LT', '%{BKY_LOGIC_COMPARE_LT_ARIA}'],
|
||||
['\u200F\u2264', 'LTE', '%{BKY_LOGIC_COMPARE_LTE_ARIA}'],
|
||||
['\u200F>', 'GT', '%{BKY_LOGIC_COMPARE_GT_ARIA}'],
|
||||
['\u200F\u2265', 'GTE', '%{BKY_LOGIC_COMPARE_GTE_ARIA}'],
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'B',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_VALUE_B}',
|
||||
},
|
||||
],
|
||||
'inputsInline': true,
|
||||
@@ -146,6 +147,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'A',
|
||||
'check': 'Boolean',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_CONDITION_A}',
|
||||
},
|
||||
{
|
||||
'type': 'field_dropdown',
|
||||
@@ -159,6 +161,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'B',
|
||||
'check': 'Boolean',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_CONDITION_B}',
|
||||
},
|
||||
],
|
||||
'inputsInline': true,
|
||||
|
||||
@@ -42,6 +42,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'TIMES',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_TIMES}',
|
||||
},
|
||||
],
|
||||
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
|
||||
@@ -69,6 +70,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'value': 10,
|
||||
'min': 0,
|
||||
'precision': 1,
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_TIMES}',
|
||||
},
|
||||
],
|
||||
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
|
||||
@@ -101,6 +103,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'BOOL',
|
||||
'check': 'Boolean',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_CONDITION}',
|
||||
},
|
||||
],
|
||||
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
|
||||
@@ -131,18 +134,21 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'name': 'FROM',
|
||||
'check': 'Number',
|
||||
'align': 'RIGHT',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_FROM}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'TO',
|
||||
'check': 'Number',
|
||||
'align': 'RIGHT',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_TO}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'BY',
|
||||
'check': 'Number',
|
||||
'align': 'RIGHT',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_BY}',
|
||||
},
|
||||
],
|
||||
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
|
||||
@@ -173,6 +179,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'LIST',
|
||||
'check': 'Array',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_LOOP_LIST}',
|
||||
},
|
||||
],
|
||||
'message1': '%{BKY_CONTROLS_REPEAT_INPUT_DO} %1',
|
||||
|
||||
@@ -50,22 +50,44 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'A',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_A}',
|
||||
},
|
||||
{
|
||||
'type': 'field_dropdown',
|
||||
'name': 'OP',
|
||||
'options': [
|
||||
['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD'],
|
||||
['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS'],
|
||||
['%{BKY_MATH_MULTIPLICATION_SYMBOL}', 'MULTIPLY'],
|
||||
['%{BKY_MATH_DIVISION_SYMBOL}', 'DIVIDE'],
|
||||
['%{BKY_MATH_POWER_SYMBOL}', 'POWER'],
|
||||
[
|
||||
'%{BKY_MATH_ADDITION_SYMBOL}',
|
||||
'ADD',
|
||||
'%{BKY_MATH_ADDITION_SYMBOL_ARIA}',
|
||||
],
|
||||
[
|
||||
'%{BKY_MATH_SUBTRACTION_SYMBOL}',
|
||||
'MINUS',
|
||||
'%{BKY_MATH_SUBTRACTION_SYMBOL_ARIA}',
|
||||
],
|
||||
[
|
||||
'%{BKY_MATH_MULTIPLICATION_SYMBOL}',
|
||||
'MULTIPLY',
|
||||
'%{BKY_MATH_MULTIPLICATION_SYMBOL_ARIA}',
|
||||
],
|
||||
[
|
||||
'%{BKY_MATH_DIVISION_SYMBOL}',
|
||||
'DIVIDE',
|
||||
'%{BKY_MATH_DIVISION_SYMBOL_ARIA}',
|
||||
],
|
||||
[
|
||||
'%{BKY_MATH_POWER_SYMBOL}',
|
||||
'POWER',
|
||||
'%{BKY_MATH_POWER_SYMBOL_ARIA}',
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'B',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_B}',
|
||||
},
|
||||
],
|
||||
'inputsInline': true,
|
||||
@@ -85,18 +107,23 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'name': 'OP',
|
||||
'options': [
|
||||
['%{BKY_MATH_SINGLE_OP_ROOT}', 'ROOT'],
|
||||
['%{BKY_MATH_SINGLE_OP_ABSOLUTE}', 'ABS'],
|
||||
['-', 'NEG'],
|
||||
['ln', 'LN'],
|
||||
['log10', 'LOG10'],
|
||||
['e^', 'EXP'],
|
||||
['10^', 'POW10'],
|
||||
[
|
||||
'%{BKY_MATH_SINGLE_OP_ABSOLUTE}',
|
||||
'ABS',
|
||||
'%{BKY_MATH_SINGLE_OP_ABSOLUTE_ARIA}',
|
||||
],
|
||||
['-', 'NEG', '%{BKY_MATH_SINGLE_OP_NEG_ARIA}'],
|
||||
['ln', 'LN', '%{BKY_MATH_SINGLE_OP_LN_ARIA}'],
|
||||
['log10', 'LOG10', '%{BKY_MATH_SINGLE_OP_LOG10_ARIA}'],
|
||||
['e^', 'EXP', '%{BKY_MATH_SINGLE_OP_EXP_ARIA}'],
|
||||
['10^', 'POW10', '%{BKY_MATH_SINGLE_OP_POW10_ARIA}'],
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'NUM',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -114,18 +141,19 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'field_dropdown',
|
||||
'name': 'OP',
|
||||
'options': [
|
||||
['%{BKY_MATH_TRIG_SIN}', 'SIN'],
|
||||
['%{BKY_MATH_TRIG_COS}', 'COS'],
|
||||
['%{BKY_MATH_TRIG_TAN}', 'TAN'],
|
||||
['%{BKY_MATH_TRIG_ASIN}', 'ASIN'],
|
||||
['%{BKY_MATH_TRIG_ACOS}', 'ACOS'],
|
||||
['%{BKY_MATH_TRIG_ATAN}', 'ATAN'],
|
||||
['%{BKY_MATH_TRIG_SIN}', 'SIN', '%{BKY_MATH_TRIG_SIN_ARIA}'],
|
||||
['%{BKY_MATH_TRIG_COS}', 'COS', '%{BKY_MATH_TRIG_COS_ARIA}'],
|
||||
['%{BKY_MATH_TRIG_TAN}', 'TAN', '%{BKY_MATH_TRIG_TAN_ARIA}'],
|
||||
['%{BKY_MATH_TRIG_ASIN}', 'ASIN', '%{BKY_MATH_TRIG_ASIN_ARIA}'],
|
||||
['%{BKY_MATH_TRIG_ACOS}', 'ACOS', '%{BKY_MATH_TRIG_ACOS_ARIA}'],
|
||||
['%{BKY_MATH_TRIG_ATAN}', 'ATAN', '%{BKY_MATH_TRIG_ATAN_ARIA}'],
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'NUM',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -143,12 +171,12 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'field_dropdown',
|
||||
'name': 'CONSTANT',
|
||||
'options': [
|
||||
['\u03c0', 'PI'],
|
||||
['e', 'E'],
|
||||
['\u03c6', 'GOLDEN_RATIO'],
|
||||
['sqrt(2)', 'SQRT2'],
|
||||
['sqrt(\u00bd)', 'SQRT1_2'],
|
||||
['\u221e', 'INFINITY'],
|
||||
['\u03c0', 'PI', '%{BKY_MATH_CONSTANT_PI_ARIA}'],
|
||||
['e', 'E', '%{BKY_MATH_CONSTANT_E_ARIA}'],
|
||||
['\u03c6', 'GOLDEN_RATIO', '%{BKY_MATH_CONSTANT_GOLDEN_RATIO_ARIA}'],
|
||||
['sqrt(2)', 'SQRT2', '%{BKY_MATH_CONSTANT_SQRT2_ARIA}'],
|
||||
['sqrt(\u00bd)', 'SQRT1_2', '%{BKY_MATH_CONSTANT_SQRT1_2_ARIA}'],
|
||||
['\u221e', 'INFINITY', '%{BKY_MATH_CONSTANT_INFINITY_ARIA}'],
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -168,6 +196,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'NUMBER_TO_CHECK',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_TO_CHECK}',
|
||||
},
|
||||
{
|
||||
'type': 'field_dropdown',
|
||||
@@ -204,6 +233,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'DELTA',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_CHANGE_BY}',
|
||||
},
|
||||
],
|
||||
'previousStatement': null,
|
||||
@@ -231,6 +261,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'NUM',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -250,8 +281,16 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'name': 'OP',
|
||||
'options': [
|
||||
['%{BKY_MATH_ONLIST_OPERATOR_SUM}', 'SUM'],
|
||||
['%{BKY_MATH_ONLIST_OPERATOR_MIN}', 'MIN'],
|
||||
['%{BKY_MATH_ONLIST_OPERATOR_MAX}', 'MAX'],
|
||||
[
|
||||
'%{BKY_MATH_ONLIST_OPERATOR_MIN}',
|
||||
'MIN',
|
||||
'%{BKY_MATH_ONLIST_OPERATOR_MIN_ARIA}',
|
||||
],
|
||||
[
|
||||
'%{BKY_MATH_ONLIST_OPERATOR_MAX}',
|
||||
'MAX',
|
||||
'%{BKY_MATH_ONLIST_OPERATOR_MAX_ARIA}',
|
||||
],
|
||||
['%{BKY_MATH_ONLIST_OPERATOR_AVERAGE}', 'AVERAGE'],
|
||||
['%{BKY_MATH_ONLIST_OPERATOR_MEDIAN}', 'MEDIAN'],
|
||||
['%{BKY_MATH_ONLIST_OPERATOR_MODE}', 'MODE'],
|
||||
@@ -263,6 +302,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'LIST',
|
||||
'check': 'Array',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_LIST}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -281,11 +321,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'DIVIDEND',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_DIVIDEND}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'DIVISOR',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_DIVISOR}',
|
||||
},
|
||||
],
|
||||
'inputsInline': true,
|
||||
@@ -304,6 +346,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_MATH_CONSTRAIN_VALUE}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
@@ -332,11 +375,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'FROM',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_MIN}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'TO',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_MAX}',
|
||||
},
|
||||
],
|
||||
'inputsInline': true,
|
||||
@@ -365,11 +410,13 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'X',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_ATAN2_X}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'Y',
|
||||
'check': 'Number',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_NUMBER_ATAN2_Y}',
|
||||
},
|
||||
],
|
||||
'inputsInline': true,
|
||||
|
||||
@@ -344,14 +344,6 @@ const PROCEDURE_DEF_COMMON = {
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Return all variables referenced by this block.
|
||||
*
|
||||
* @returns List of variable names.
|
||||
*/
|
||||
getVars: function (this: ProcedureBlock): string[] {
|
||||
return this.arguments_;
|
||||
},
|
||||
/**
|
||||
* Return all variables referenced by this block.
|
||||
*
|
||||
@@ -507,6 +499,7 @@ blocks['procedures_defnoreturn'] = {
|
||||
const nameField = fieldRegistry.fromJson({
|
||||
type: 'field_input',
|
||||
text: initName,
|
||||
ariaTypeName: Msg['ARIA_TYPE_FIELD_TEXT_INPUT_PROCEDURE'],
|
||||
}) as FieldTextInput;
|
||||
nameField!.setValidator(Procedures.rename);
|
||||
nameField.setSpellcheck(false);
|
||||
@@ -555,6 +548,7 @@ blocks['procedures_defreturn'] = {
|
||||
const nameField = fieldRegistry.fromJson({
|
||||
type: 'field_input',
|
||||
text: initName,
|
||||
ariaTypeName: Msg['ARIA_TYPE_FIELD_TEXT_INPUT_PROCEDURE'],
|
||||
}) as FieldTextInput;
|
||||
nameField.setValidator(Procedures.rename);
|
||||
nameField.setSpellcheck(false);
|
||||
@@ -673,6 +667,9 @@ const PROCEDURES_MUTATORARGUMENT = {
|
||||
const field = new ProcedureArgumentField(
|
||||
Procedures.DEFAULT_ARG,
|
||||
this.validator_,
|
||||
{
|
||||
ariaTypeName: Msg['ARIA_TYPE_FIELD_TEXT_INPUT_ARGUMENT'],
|
||||
},
|
||||
);
|
||||
|
||||
this.appendDummyInput()
|
||||
@@ -1019,14 +1016,6 @@ const PROCEDURE_CALL_COMMON = {
|
||||
this.setProcedureParameters_(params, ids);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Return all variables referenced by this block.
|
||||
*
|
||||
* @returns List of variable names.
|
||||
*/
|
||||
getVars: function (this: CallBlock): string[] {
|
||||
return this.arguments_;
|
||||
},
|
||||
/**
|
||||
* Return all variables referenced by this block.
|
||||
*
|
||||
@@ -1062,7 +1051,8 @@ const PROCEDURE_CALL_COMMON = {
|
||||
if (
|
||||
def &&
|
||||
(def.type !== this.defType_ ||
|
||||
JSON.stringify(def.getVars()) !== JSON.stringify(this.arguments_))
|
||||
JSON.stringify(def.getVarModels().map((model) => model.getName())) !==
|
||||
JSON.stringify(this.arguments_))
|
||||
) {
|
||||
// The signatures don't match.
|
||||
def = null;
|
||||
|
||||
@@ -93,6 +93,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'TEXT',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_APPEND}',
|
||||
},
|
||||
],
|
||||
'previousStatement': null,
|
||||
@@ -108,6 +109,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': ['String', 'Array'],
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -123,6 +125,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': ['String', 'Array'],
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
|
||||
},
|
||||
],
|
||||
'output': 'Boolean',
|
||||
@@ -138,6 +141,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
|
||||
},
|
||||
{
|
||||
'type': 'field_dropdown',
|
||||
@@ -151,6 +155,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'FIND',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_FIND}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -167,13 +172,22 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
|
||||
},
|
||||
{
|
||||
'type': 'field_dropdown',
|
||||
'name': 'WHERE',
|
||||
'options': [
|
||||
['%{BKY_TEXT_CHARAT_FROM_START}', 'FROM_START'],
|
||||
['%{BKY_TEXT_CHARAT_FROM_END}', 'FROM_END'],
|
||||
[
|
||||
'%{BKY_TEXT_CHARAT_FROM_START}',
|
||||
'FROM_START',
|
||||
'%{BKY_TEXT_FROM_START_ARIA}',
|
||||
],
|
||||
[
|
||||
'%{BKY_TEXT_CHARAT_FROM_END}',
|
||||
'FROM_END',
|
||||
'%{BKY_TEXT_FROM_END_ARIA}',
|
||||
],
|
||||
['%{BKY_TEXT_CHARAT_FIRST}', 'FIRST'],
|
||||
['%{BKY_TEXT_CHARAT_LAST}', 'LAST'],
|
||||
['%{BKY_TEXT_CHARAT_RANDOM}', 'RANDOM'],
|
||||
@@ -191,8 +205,8 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
/** Type of a 'text_get_substring' block. */
|
||||
type GetSubstringBlock = Block & GetSubstringMixin;
|
||||
interface GetSubstringMixin extends GetSubstringType {
|
||||
WHERE_OPTIONS_1: Array<[string, string]>;
|
||||
WHERE_OPTIONS_2: Array<[string, string]>;
|
||||
WHERE_OPTIONS_1: Array<[string, string, string?]>;
|
||||
WHERE_OPTIONS_2: Array<[string, string, string?]>;
|
||||
}
|
||||
type GetSubstringType = typeof GET_SUBSTRING_BLOCK;
|
||||
|
||||
@@ -202,20 +216,37 @@ const GET_SUBSTRING_BLOCK = {
|
||||
*/
|
||||
init: function (this: GetSubstringBlock) {
|
||||
this['WHERE_OPTIONS_1'] = [
|
||||
[Msg['TEXT_GET_SUBSTRING_START_FROM_START'], 'FROM_START'],
|
||||
[Msg['TEXT_GET_SUBSTRING_START_FROM_END'], 'FROM_END'],
|
||||
[
|
||||
Msg['TEXT_GET_SUBSTRING_START_FROM_START'],
|
||||
'FROM_START',
|
||||
Msg['TEXT_FROM_START_ARIA'],
|
||||
],
|
||||
[
|
||||
Msg['TEXT_GET_SUBSTRING_START_FROM_END'],
|
||||
'FROM_END',
|
||||
Msg['TEXT_FROM_END_ARIA'],
|
||||
],
|
||||
[Msg['TEXT_GET_SUBSTRING_START_FIRST'], 'FIRST'],
|
||||
];
|
||||
this['WHERE_OPTIONS_2'] = [
|
||||
[Msg['TEXT_GET_SUBSTRING_END_FROM_START'], 'FROM_START'],
|
||||
[Msg['TEXT_GET_SUBSTRING_END_FROM_END'], 'FROM_END'],
|
||||
[
|
||||
Msg['TEXT_GET_SUBSTRING_END_FROM_START'],
|
||||
'FROM_START',
|
||||
Msg['TEXT_FROM_START_ARIA'],
|
||||
],
|
||||
[
|
||||
Msg['TEXT_GET_SUBSTRING_END_FROM_END'],
|
||||
'FROM_END',
|
||||
Msg['TEXT_FROM_END_ARIA'],
|
||||
],
|
||||
[Msg['TEXT_GET_SUBSTRING_END_LAST'], 'LAST'],
|
||||
];
|
||||
this.setHelpUrl(Msg['TEXT_GET_SUBSTRING_HELPURL']);
|
||||
this.setStyle('text_blocks');
|
||||
this.appendValueInput('STRING')
|
||||
.setCheck('String')
|
||||
.appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT']);
|
||||
.appendField(Msg['TEXT_GET_SUBSTRING_INPUT_IN_TEXT'])
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_TO_CHECK']);
|
||||
const createMenu = (n: 1 | 2): FieldDropdown => {
|
||||
const menu = fieldRegistry.fromJson({
|
||||
type: 'field_dropdown',
|
||||
@@ -297,7 +328,13 @@ const GET_SUBSTRING_BLOCK = {
|
||||
this.removeInput('ORDINAL' + n, true);
|
||||
// Create either a value 'AT' input or a dummy input.
|
||||
if (isAt) {
|
||||
this.appendValueInput('AT' + n).setCheck('Number');
|
||||
this.appendValueInput('AT' + n)
|
||||
.setCheck('Number')
|
||||
.setAriaLabelProvider(
|
||||
n === 1
|
||||
? Msg['INPUT_LABEL_TEXT_START_POSITION']
|
||||
: Msg['INPUT_LABEL_TEXT_END_POSITION'],
|
||||
);
|
||||
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
|
||||
this.appendDummyInput('ORDINAL' + n).appendField(
|
||||
Msg['ORDINAL_NUMBER_SUFFIX'],
|
||||
@@ -342,7 +379,8 @@ blocks['text_changeCase'] = {
|
||||
options: OPERATORS,
|
||||
}) as FieldDropdown,
|
||||
'CASE',
|
||||
);
|
||||
)
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_TO_CHANGE']);
|
||||
this.setOutput(true, 'String');
|
||||
this.setTooltip(Msg['TEXT_CHANGECASE_TOOLTIP']);
|
||||
},
|
||||
@@ -368,7 +406,8 @@ blocks['text_trim'] = {
|
||||
options: OPERATORS,
|
||||
}) as FieldDropdown,
|
||||
'MODE',
|
||||
);
|
||||
)
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_TO_CHANGE']);
|
||||
this.setOutput(true, 'String');
|
||||
this.setTooltip(Msg['TEXT_TRIM_TOOLTIP']);
|
||||
},
|
||||
@@ -461,7 +500,9 @@ blocks['text_prompt_ext'] = {
|
||||
this.updateType_(newOp);
|
||||
return undefined; // FieldValidators can't be void. Use option as-is.
|
||||
});
|
||||
this.appendValueInput('TEXT').appendField(dropdown, 'TYPE');
|
||||
this.appendValueInput('TEXT')
|
||||
.appendField(dropdown, 'TYPE')
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_PROMPT_MESSAGE']);
|
||||
this.setOutput(true, 'String');
|
||||
this.setTooltip(() => {
|
||||
return this.getFieldValue('TYPE') === 'TEXT'
|
||||
@@ -528,11 +569,13 @@ blocks['text_count'] = {
|
||||
'type': 'input_value',
|
||||
'name': 'SUB',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_FIND}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'TEXT',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
|
||||
},
|
||||
],
|
||||
'output': 'Number',
|
||||
@@ -556,16 +599,19 @@ blocks['text_replace'] = {
|
||||
'type': 'input_value',
|
||||
'name': 'FROM',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_FIND}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'TO',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_REPLACE}',
|
||||
},
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'TEXT',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHECK}',
|
||||
},
|
||||
],
|
||||
'output': 'String',
|
||||
@@ -589,6 +635,7 @@ blocks['text_reverse'] = {
|
||||
'type': 'input_value',
|
||||
'name': 'TEXT',
|
||||
'check': 'String',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_TEXT_TO_CHANGE}',
|
||||
},
|
||||
],
|
||||
'output': 'String',
|
||||
@@ -757,13 +804,15 @@ const JOIN_MUTATOR_MIXIN = {
|
||||
'text_create_join_container',
|
||||
) as BlockSvg;
|
||||
containerBlock.initSvg();
|
||||
let connection = containerBlock.getInput('STACK')!.connection!;
|
||||
let connection = containerBlock.getInput('STACK')?.connection;
|
||||
for (let i = 0; i < this.itemCount_; i++) {
|
||||
const itemBlock = workspace.newBlock(
|
||||
'text_create_join_item',
|
||||
) as JoinItemBlock;
|
||||
itemBlock.initSvg();
|
||||
connection.connect(itemBlock.previousConnection);
|
||||
if (itemBlock.previousConnection) {
|
||||
connection?.connect(itemBlock.previousConnection);
|
||||
}
|
||||
connection = itemBlock.nextConnection;
|
||||
}
|
||||
return containerBlock;
|
||||
@@ -836,7 +885,11 @@ const JOIN_MUTATOR_MIXIN = {
|
||||
// Add new inputs.
|
||||
for (let i = 0; i < this.itemCount_; i++) {
|
||||
if (!this.getInput('ADD' + i)) {
|
||||
const input = this.appendValueInput('ADD' + i).setAlign(Align.RIGHT);
|
||||
const input = this.appendValueInput('ADD' + i)
|
||||
.setAlign(Align.RIGHT)
|
||||
.setAriaLabelProvider(
|
||||
Msg['INPUT_LABEL_TEXT_JOIN_ITEM'].replace('%1', (i + 1).toString()),
|
||||
);
|
||||
if (i === 0) {
|
||||
input.appendField(Msg['TEXT_JOIN_TITLE_CREATEWITH']);
|
||||
}
|
||||
@@ -931,7 +984,9 @@ const CHARAT_MUTATOR_MIXIN = {
|
||||
this.removeInput('ORDINAL', true);
|
||||
// Create either a value 'AT' input or a dummy input.
|
||||
if (isAt) {
|
||||
this.appendValueInput('AT').setCheck('Number');
|
||||
this.appendValueInput('AT')
|
||||
.setCheck('Number')
|
||||
.setAriaLabelProvider(Msg['INPUT_LABEL_TEXT_POSITION']);
|
||||
if (Msg['ORDINAL_NUMBER_SUFFIX']) {
|
||||
this.appendDummyInput('ORDINAL').appendField(
|
||||
Msg['ORDINAL_NUMBER_SUFFIX'],
|
||||
|
||||
@@ -56,6 +56,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
|
||||
{
|
||||
'type': 'input_value',
|
||||
'name': 'VALUE',
|
||||
'ariaLabelText': '%{BKY_INPUT_LABEL_VARIABLES_SET}',
|
||||
},
|
||||
],
|
||||
'previousStatement': null,
|
||||
@@ -117,12 +118,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
|
||||
this.type === 'variables_get' ||
|
||||
this.type === 'variables_get_reporter'
|
||||
) {
|
||||
const name = this.getField('VAR')!.getText();
|
||||
const renameOption = {
|
||||
text: Msg['RENAME_VARIABLE'],
|
||||
text: Msg['RENAME_VARIABLE'].replace('%1', name),
|
||||
enabled: true,
|
||||
callback: renameOptionCallbackFactory(this),
|
||||
};
|
||||
const name = this.getField('VAR')!.getText();
|
||||
const deleteOption = {
|
||||
text: Msg['DELETE_VARIABLE'].replace('%1', name),
|
||||
enabled: true,
|
||||
|
||||
@@ -117,12 +117,12 @@ const CUSTOM_CONTEXT_MENU_VARIABLE_GETTER_SETTER_MIXIN = {
|
||||
this.type === 'variables_get_dynamic' ||
|
||||
this.type === 'variables_get_reporter_dynamic'
|
||||
) {
|
||||
const name = this.getField('VAR')!.getText();
|
||||
const renameOption = {
|
||||
text: Msg['RENAME_VARIABLE'],
|
||||
text: Msg['RENAME_VARIABLE'].replace('%1', name),
|
||||
enabled: true,
|
||||
callback: renameOptionCallbackFactory(this),
|
||||
};
|
||||
const name = this.getField('VAR')!.getText();
|
||||
const deleteOption = {
|
||||
text: Msg['DELETE_VARIABLE'].replace('%1', name),
|
||||
enabled: true,
|
||||
|
||||
@@ -46,12 +46,14 @@ import type {
|
||||
IVariableModel,
|
||||
IVariableState,
|
||||
} from './interfaces/i_variable_model.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as registry from './registry.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import * as arrayUtils from './utils/array.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
import {replaceMessageReferences} from './utils/parsing.js';
|
||||
import {Size} from './utils/size.js';
|
||||
import type {Workspace} from './workspace.js';
|
||||
|
||||
@@ -242,6 +244,9 @@ export class Block {
|
||||
inputsInlineDefault?: boolean;
|
||||
workspace: Workspace;
|
||||
|
||||
/** A custom provider for generating the aria role description for this block. */
|
||||
private ariaRoleDescriptionProvider: string | (() => string) | undefined;
|
||||
|
||||
/**
|
||||
* @param workspace The block's workspace.
|
||||
* @param prototypeName Name of the language object containing type-specific
|
||||
@@ -962,7 +967,30 @@ export class Block {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns True if this block is a value block with a single editable field.
|
||||
* Determines and returns the full-block field for this block, or null if there isn't one
|
||||
* and this block can't be considered a singleton field block.
|
||||
*
|
||||
* Note that this method is unreliable if a block contains a single field that
|
||||
* hasn't been initialized/rendered yet.
|
||||
*
|
||||
* @returns The full-block field this block contains, or null if it doesn't contain one.
|
||||
* @internal
|
||||
*/
|
||||
getFullBlockField(): Field<any> | null {
|
||||
if (!this.isSimpleReporter()) return null;
|
||||
const field = this.inputList[0]?.fieldRow[0];
|
||||
return field?.isFullBlockField() ? field : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block is a simple reporter if it has an output connection and exactly one field.
|
||||
* In some renderers, simple reporters are rendered differently from other blocks.
|
||||
* Being a simple reporter block is a prerequisite to the single field rendering itself
|
||||
* as a "full-block field", but it is not sufficient, as not all fields or renderers use
|
||||
* this special rendering. Use `getFullBlockField` to determine if the block is rendered
|
||||
* as a "full-block field block".
|
||||
*
|
||||
* @returns True if this block is a value block with a single field.
|
||||
* @internal
|
||||
*/
|
||||
isSimpleReporter(): boolean {
|
||||
@@ -1136,26 +1164,10 @@ export class Block {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all variables referenced by this block.
|
||||
*
|
||||
* @returns List of variable ids.
|
||||
*/
|
||||
getVars(): string[] {
|
||||
const vars: string[] = [];
|
||||
for (const field of this.getFields()) {
|
||||
if (field.referencesVariables()) {
|
||||
vars.push(field.getValue());
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all variables referenced by this block.
|
||||
*
|
||||
* @returns List of variable models.
|
||||
* @internal
|
||||
*/
|
||||
getVarModels(): IVariableModel<IVariableState>[] {
|
||||
const vars = [];
|
||||
@@ -1541,6 +1553,44 @@ export class Block {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom aria role description provider for this block. If not set,
|
||||
* uses a default provider based on the block's properties (e.g. whether it has
|
||||
* inputs, outputs, etc.).
|
||||
*
|
||||
* @param description The description or function to provide the description.
|
||||
* If a string, we'll replace message references in the string, e.g.
|
||||
* `%{BKY_CUSTOM_MESSAGE}` will be replaced with the value of
|
||||
* `Blockly.Msg['CUSTOM_MESSAGE']`.}'
|
||||
*/
|
||||
setAriaRoleDescriptionProvider(description: string | (() => string)) {
|
||||
this.ariaRoleDescriptionProvider = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The string to use as the role description for this block. If a
|
||||
* custom provider has been set, use that. Otherwise, return a default
|
||||
* description based on the block's properties.
|
||||
*/
|
||||
getAriaRoleDescription(): string {
|
||||
if (this.ariaRoleDescriptionProvider) {
|
||||
if (typeof this.ariaRoleDescriptionProvider === 'function') {
|
||||
return this.ariaRoleDescriptionProvider();
|
||||
}
|
||||
return replaceMessageReferences(this.ariaRoleDescriptionProvider);
|
||||
}
|
||||
|
||||
let roleDescription: string;
|
||||
if (this.statementInputCount) {
|
||||
roleDescription = Msg['BLOCK_LABEL_CONTAINER'];
|
||||
} else if (this.outputConnection) {
|
||||
roleDescription = Msg['BLOCK_LABEL_VALUE'];
|
||||
} else {
|
||||
roleDescription = Msg['BLOCK_LABEL_STATEMENT'];
|
||||
}
|
||||
return roleDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a human-readable text representation of this block and any children.
|
||||
*
|
||||
@@ -1795,6 +1845,11 @@ export class Block {
|
||||
const localizedValue = parsing.replaceMessageReferences(rawValue);
|
||||
this.setHelpUrl(localizedValue);
|
||||
}
|
||||
|
||||
if (json['ariaRoleDescription'] !== undefined) {
|
||||
this.setAriaRoleDescriptionProvider(json['ariaRoleDescription']);
|
||||
}
|
||||
|
||||
if (typeof json['extensions'] === 'string') {
|
||||
console.warn(
|
||||
warningPrefix +
|
||||
@@ -2123,6 +2178,9 @@ export class Block {
|
||||
input.setAlign(alignment);
|
||||
}
|
||||
}
|
||||
if (element['ariaLabelText']) {
|
||||
input.setAriaLabelProvider(element['ariaLabelText']);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Raspberry Pi Foundation
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {BlockSvg} from './block_svg.js';
|
||||
import {ConnectionType} from './connection_type.js';
|
||||
import {FieldLabel} from './field_label.js';
|
||||
import type {Input} from './inputs/input.js';
|
||||
import {inputTypes} from './inputs/input_types.js';
|
||||
import {
|
||||
ISelectableToolboxItem,
|
||||
isSelectableToolboxItem,
|
||||
} from './interfaces/i_selectable_toolbox_item.js';
|
||||
import {Msg} from './msg.js';
|
||||
import {RenderedConnection} from './rendered_connection.js';
|
||||
import {Role, setRole, setState, State, Verbosity} from './utils/aria.js';
|
||||
|
||||
/**
|
||||
* Prepositions to use when describing the relationship between two blocks based
|
||||
* on their connection types.
|
||||
*/
|
||||
export enum ConnectionPreposition {
|
||||
UNKNOWN,
|
||||
BEFORE,
|
||||
AFTER,
|
||||
AROUND,
|
||||
INSIDE,
|
||||
TO,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ARIA representation of the specified block.
|
||||
*
|
||||
* The returned label will contain a complete context of the block, including:
|
||||
* - Whether it begins a block stack or statement input stack.
|
||||
* - Its constituent editable and non-editable fields.
|
||||
* - Properties, including: disabled, collapsed, replaceable (a shadow), etc.
|
||||
* - Its parent toolbox category.
|
||||
* - Whether it has inputs.
|
||||
*
|
||||
* Beyond this, the returned label is specifically assembled with commas in
|
||||
* select locations with the intention of better 'prosody' in the screen reader
|
||||
* readouts since there's a lot of information being shared with the user. The
|
||||
* returned label also places more important information earlier in the label so
|
||||
* that the user gets the most important context as soon as possible in case
|
||||
* they wish to stop readout early.
|
||||
*
|
||||
* The returned label will be specialized based on whether the block is part of a
|
||||
* flyout.
|
||||
*
|
||||
* Custom input labels (from {@link Input.setAriaLabelProvider}) are not included
|
||||
* here; they are used only in move-mode disambiguation and parent-input context
|
||||
* via {@link Input.getAriaLabelText}.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block for which an ARIA representation should be created.
|
||||
* @param verbosity How much detail to include in the description.
|
||||
* @returns The ARIA representation for the specified block.
|
||||
*/
|
||||
export function computeAriaLabel(
|
||||
block: BlockSvg,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
) {
|
||||
if (block.isSimpleReporter()) {
|
||||
// special case for full-block field blocks.
|
||||
const field = block.getFullBlockField();
|
||||
if (field) {
|
||||
return field.computeAriaLabel(verbosity >= Verbosity.STANDARD);
|
||||
}
|
||||
}
|
||||
return [
|
||||
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
|
||||
getParentInputLabel(block),
|
||||
...getInputLabels(block, verbosity),
|
||||
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
|
||||
verbosity >= Verbosity.LOQUACIOUS && getShadowBlockLabel(block),
|
||||
verbosity >= Verbosity.STANDARD && getInputCountLabel(block),
|
||||
verbosity >= Verbosity.LOQUACIOUS && block.getAriaRoleDescription(),
|
||||
]
|
||||
.filter((label) => !!label)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ARIA role and role description for the specified block, accounting
|
||||
* for whether the block is part of a flyout.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to set ARIA role and roledescription attributes on.
|
||||
*/
|
||||
export function configureAriaRole(block: BlockSvg) {
|
||||
setRole(block.getSvgRoot(), Role.PRESENTATION);
|
||||
const focusableElement = block.getFocusableElement();
|
||||
if (!block.isInFlyout) {
|
||||
// blocks in the flyout have their role set by the Flyout's block inflater
|
||||
// don't overwrite it here
|
||||
setRole(focusableElement, Role.FIGURE);
|
||||
}
|
||||
|
||||
setState(
|
||||
focusableElement,
|
||||
State.ROLEDESCRIPTION,
|
||||
block.getAriaRoleDescription(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of ARIA labels for the 'field row' for the specified Input.
|
||||
*
|
||||
* 'Field row' essentially means the horizontal run of readable fields that
|
||||
* precede the Input. Together, these provide the domain context for the input,
|
||||
* particularly in the context of connections. In some cases, there may not be
|
||||
* any readable fields immediately prior to the Input. In that case, if the
|
||||
* `lookback` attribute is specified, all of the fields on the row immediately
|
||||
* above the Input will be used instead.
|
||||
*
|
||||
* If the input contains multiple adjacent FieldLabel fields, they will be
|
||||
* combined together into a singular label string so that screenreaders can
|
||||
* know to read them together as one piece of text.
|
||||
*
|
||||
* Empty field labels are excluded because they don't provide useful context.
|
||||
* Fields should generally have a helpful label, but there are exceptions, such
|
||||
* as when empty label fields are used to control the layout of a block.
|
||||
*
|
||||
* @internal
|
||||
* @param input The Input to compute a description/context label for.
|
||||
* @param lookback If true, will use labels for fields on the previous row if
|
||||
* the given input's row has no fields itself.
|
||||
* @returns A list of labels for fields on the same row (or previous row, if
|
||||
* lookback is specified) as the given input.
|
||||
*/
|
||||
export function computeFieldRowLabel(
|
||||
input: Input,
|
||||
lookback: boolean,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
): string[] {
|
||||
const includeTypeInfo = verbosity >= Verbosity.LOQUACIOUS;
|
||||
let adjacentFieldLabels: Array<string> = [];
|
||||
const fieldRowLabel = input.fieldRow
|
||||
.filter((field) => field.isVisible())
|
||||
.flatMap((field, index, visibleFields) => {
|
||||
const isFieldLabel = field instanceof FieldLabel;
|
||||
if (isFieldLabel) {
|
||||
if (
|
||||
index < visibleFields.length - 1 &&
|
||||
visibleFields[index + 1] instanceof FieldLabel
|
||||
) {
|
||||
// Both this item and the next item are FieldLabels. We want to
|
||||
// combine these, so we add this one to the list for later handling.
|
||||
adjacentFieldLabels.push(field.computeAriaLabel(includeTypeInfo));
|
||||
return [];
|
||||
} else if (adjacentFieldLabels.length >= 1) {
|
||||
// There is at least one adjacent FieldLabel before this one but none
|
||||
// after. Combine the FieldLabels into one string.
|
||||
adjacentFieldLabels.push(field.computeAriaLabel(includeTypeInfo));
|
||||
const label = adjacentFieldLabels.join(' ');
|
||||
adjacentFieldLabels = [];
|
||||
return label;
|
||||
}
|
||||
}
|
||||
return field.computeAriaLabel(includeTypeInfo);
|
||||
});
|
||||
|
||||
if (!fieldRowLabel.length && lookback) {
|
||||
const inputs = input.getSourceBlock().inputList;
|
||||
const index = inputs.indexOf(input);
|
||||
if (index > 0) {
|
||||
return computeFieldRowLabel(inputs[index - 1], lookback, verbosity);
|
||||
}
|
||||
}
|
||||
return fieldRowLabel.filter((label) => !!label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a description of the parent input a block is attached to.
|
||||
* When a block is connected to an input, the input's label will sometimes
|
||||
* be prepended to the block's description.
|
||||
*
|
||||
* If an input has a custom label, the custom label will be prepended
|
||||
* to the first child block connected to that input.
|
||||
*
|
||||
* If an input does not have a custom label, the input's fallback
|
||||
* label determined from the field row will be prepended to the
|
||||
* child block's label only if the following are true:
|
||||
* - the parent block has at least one statement input
|
||||
* - the child block in question is not attached to the first
|
||||
* statement input of the parent block (in this case, the label
|
||||
* would be redundant with the parent block's label)
|
||||
*
|
||||
* For statement inputs without their own field labels, labels from other
|
||||
* inputs in the same statement section are included (via
|
||||
* {@link getInputLabelsSubset}), consistent with move-target disambiguation.
|
||||
*
|
||||
* For statement inputs, the resolved label (whether custom or fallback) is
|
||||
* wrapped in the "Begin %1" prefix so the readout indicates that the child
|
||||
* block starts the body of the statement input.
|
||||
*
|
||||
* Labels for child blocks of inputs are excluded because they are included
|
||||
* with {@link getInputLabels} already.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a parent input label for.
|
||||
* @returns A description of the block's parent statement input, or undefined
|
||||
* for blocks that do not have one.
|
||||
*/
|
||||
function getParentInputLabel(block: BlockSvg) {
|
||||
const parentInput = (
|
||||
block.outputConnection ?? block.previousConnection
|
||||
)?.targetConnection?.getParentInput();
|
||||
if (!parentInput) return undefined;
|
||||
|
||||
const parentBlock = parentInput.getSourceBlock();
|
||||
if (parentBlock.isInsertionMarker()) return undefined;
|
||||
|
||||
// parentInput is only non-null when this block is directly attached to the
|
||||
// input (i.e. it is the first child block in that input). A custom label
|
||||
// is always prepended for the first child; a fallback label from the field
|
||||
// row is only used in select circumstances.
|
||||
let inputLabel: string | string[];
|
||||
const customLabel = parentInput.getAriaLabelText();
|
||||
if (customLabel) {
|
||||
inputLabel = customLabel;
|
||||
} else {
|
||||
if (!parentBlock.statementInputCount) return undefined;
|
||||
|
||||
const firstStatementInput = parentBlock.inputList.find(
|
||||
(i) => i.type === inputTypes.STATEMENT,
|
||||
);
|
||||
// The first statement input in a block has no field row label as it would
|
||||
// be duplicative of the block's label.
|
||||
if (parentInput === firstStatementInput) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sectionLabels = getInputLabelsSubset(
|
||||
parentBlock as BlockSvg,
|
||||
parentInput,
|
||||
false, // Exclude labels from child blocks.
|
||||
);
|
||||
if (!sectionLabels.length) {
|
||||
return undefined;
|
||||
}
|
||||
inputLabel = sectionLabels.join(', ');
|
||||
}
|
||||
|
||||
if (parentInput.type === inputTypes.STATEMENT) {
|
||||
const labelText = Array.isArray(inputLabel)
|
||||
? inputLabel.join(' ')
|
||||
: inputLabel;
|
||||
return Msg['BLOCK_LABEL_BEGIN_PREFIX'].replace('%1', labelText);
|
||||
}
|
||||
return inputLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text indicating that a block is the root block of a stack.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a label for.
|
||||
* @returns Text indicating that the block begins a stack, or undefined if it
|
||||
* does not.
|
||||
*/
|
||||
function getBeginStackLabel(block: BlockSvg) {
|
||||
// Don't include the "begin stack" label for blocks that are moving
|
||||
// or blocks in the flyout
|
||||
if (block.isInFlyout || block.isDragging()) return undefined;
|
||||
return block.getRootBlock() === block
|
||||
? Msg['BLOCK_LABEL_BEGIN_STACK']
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of accessibility labels for fields and inputs on a block.
|
||||
* Each entry in the returned array corresponds to one of: (a) a label for a
|
||||
* continuous run of non-interactable fields, (b) a label for an editable field,
|
||||
* (c) a label for an input. When an input contains nested blocks/fields/inputs,
|
||||
* their contents are returned as a single item in the array per top-level
|
||||
* input.
|
||||
*
|
||||
* Uses derived labels only (field row text and connected block content via
|
||||
* {@link Input.getLabel}). Custom input labels are not included; see
|
||||
* {@link Input.getAriaLabelText} for move-mode and parent-input usage.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a list of field/input labels for.
|
||||
* @param verbosity How much detail to include in each input label.
|
||||
* @returns A list of field/input labels for the given block.
|
||||
*/
|
||||
export function getInputLabels(
|
||||
block: BlockSvg,
|
||||
verbosity = Verbosity.STANDARD,
|
||||
): string[] {
|
||||
const visibleInputs = block.inputList.filter((input) => input.isVisible());
|
||||
let inputsToLabel = visibleInputs;
|
||||
|
||||
// For terse and standard verbosity levels, if there are multiple statement inputs,
|
||||
// only include labels up to the first one.
|
||||
if (verbosity <= Verbosity.STANDARD) {
|
||||
const statementInputs = visibleInputs.filter(
|
||||
(input) => input.type === inputTypes.STATEMENT,
|
||||
);
|
||||
|
||||
if (statementInputs.length > 1) {
|
||||
inputsToLabel = visibleInputs.slice(
|
||||
0,
|
||||
visibleInputs.indexOf(statementInputs[0]) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return inputsToLabel.map((input) => input.getLabel(verbosity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a subset of derived labels for inputs on the given block, ending at
|
||||
* the specified input. Used to disambiguate move targets and connection
|
||||
* highlights when no custom label is set.
|
||||
*
|
||||
* The subset is determined based on the input type:
|
||||
* - For non-statement inputs, only the label for the given input is returned.
|
||||
* - For statement inputs, labels are collected from the start of the current
|
||||
* statement section up to and including the given input. A statement section
|
||||
* begins immediately after the previous statement input, or at the start of
|
||||
* the block if none exists.
|
||||
*
|
||||
* Label resolution (see also {@link computeMoveConnectionLabel}):
|
||||
* 1. Custom labels ({@link Input.getAriaLabelText}) are handled by callers, not here.
|
||||
* 2. Derived labels from {@link Input.getLabel} (field row + child blocks).
|
||||
* 3. Numbered fallback ({@link Msg.INPUT_LABEL_INDEX}) when tier 2 is empty.
|
||||
* For the statement target input, the fallback is omitted if any earlier
|
||||
* input in the subset already produced a label.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a list of field/input labels for.
|
||||
* @param endInput The input that defines the end of the subset.
|
||||
* @param includeEndInputChildren Whether to include labels for child blocks
|
||||
* connected to the end input.
|
||||
* @returns A list of field/input labels for the given block.
|
||||
*/
|
||||
export function getInputLabelsSubset(
|
||||
block: BlockSvg,
|
||||
endInput: Input,
|
||||
includeEndInputChildren: boolean,
|
||||
): string[] {
|
||||
const inputIndex = block.inputList.indexOf(endInput);
|
||||
if (inputIndex === -1) {
|
||||
throw new Error(
|
||||
`Input with name "${endInput.name}" not found on block with id "${block.id}".`,
|
||||
);
|
||||
}
|
||||
const isStatementTarget = endInput.type === inputTypes.STATEMENT;
|
||||
|
||||
const startIndex = isStatementTarget
|
||||
? findStartOfStatementSection(block.inputList, inputIndex)
|
||||
: inputIndex;
|
||||
|
||||
// For statement inputs, we include all visible inputs from the start
|
||||
// of the current statement section up to and including the target input.
|
||||
// For non-statement inputs, this will just be the target input itself.
|
||||
const inputsInSubset = block.inputList
|
||||
.slice(startIndex, inputIndex + 1)
|
||||
.filter((subsetInput) => subsetInput.isVisible());
|
||||
|
||||
// The derived labels are based on the field row and any connected child
|
||||
// blocks. Labels for child blocks are potentially skipped if they would be
|
||||
// redundant within the overall block label.
|
||||
const derivedLabels = inputsInSubset.map((subsetInput) =>
|
||||
subsetInput.getLabel(
|
||||
Verbosity.TERSE,
|
||||
subsetInput !== endInput || includeEndInputChildren,
|
||||
),
|
||||
);
|
||||
|
||||
// For statement inputs, we only include the fallback label ("input %1")
|
||||
// for the target input if no preceding input in the subset has a label.
|
||||
// This prevents, e.g., "else" statement inputs from being read as "else, input 2".
|
||||
const precedingLabelsProvideContext =
|
||||
isStatementTarget && derivedLabels.slice(0, -1).some((label) => !!label);
|
||||
|
||||
return derivedLabels
|
||||
.map((label, index) => {
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
const subsetInput = inputsInSubset[index];
|
||||
// Dummy and end-row inputs are not connection inputs; getIndex() is -1
|
||||
// and would produce a misleading "input 0" fallback label.
|
||||
if (
|
||||
subsetInput.type === inputTypes.DUMMY ||
|
||||
subsetInput.type === inputTypes.END_ROW
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const isStatementTargetInput =
|
||||
isStatementTarget && index === derivedLabels.length - 1;
|
||||
if (isStatementTargetInput && precedingLabelsProvideContext) {
|
||||
return undefined;
|
||||
}
|
||||
return Msg['INPUT_LABEL_INDEX'].replace(
|
||||
'%1',
|
||||
(subsetInput.getIndex() + 1).toString(),
|
||||
);
|
||||
})
|
||||
.filter((label) => label !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the starting index of the current statement section within a list of inputs.
|
||||
*
|
||||
* A statement section is defined as the group of inputs that follow the most
|
||||
* recent preceding statement input. If no prior statement input exists, the
|
||||
* section starts at index 0.
|
||||
*
|
||||
* @param inputs The list of inputs to search.
|
||||
* @param fromIndex The index of the current statement input.
|
||||
* @returns The index of the first input in the current statement section.
|
||||
*/
|
||||
function findStartOfStatementSection(
|
||||
inputs: Input[],
|
||||
fromIndex: number,
|
||||
): number {
|
||||
// Find the first input after the previous statement input.
|
||||
for (let i = fromIndex - 1; i >= 0; i--) {
|
||||
if (inputs[i].type === inputTypes.STATEMENT) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the toolbox category that the given block is part of.
|
||||
* This is heuristic-based; each toolbox category's contents are enumerated, and
|
||||
* if a block with the given block's type is encountered, that category is
|
||||
* deemed to be its parent. As a fallback, a toolbox category with the same
|
||||
* colour as the block may be returned. This is not comprehensive; blocks may
|
||||
* exist on the workspace which are not part of any category, or a given block
|
||||
* type may be part of multiple categories or belong to a dynamically-generated
|
||||
* category, or there may not even be a toolbox at all. In these cases, either
|
||||
* the first matching category or undefined will be returned.
|
||||
*
|
||||
* This method exists to attempt to provide similar context as block colour
|
||||
* provides to sighted users, e.g. where a red block comes from a red category.
|
||||
* It is inherently best-effort due to the above-mentioned constraints.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to retrieve a category name for.
|
||||
* @returns A description of the given block's parent toolbox category if any,
|
||||
* otherwise undefined.
|
||||
*/
|
||||
function getParentToolboxCategoryLabel(block: BlockSvg) {
|
||||
const toolbox = block.workspace.getToolbox();
|
||||
if (!toolbox) return undefined;
|
||||
|
||||
let parentCategory: ISelectableToolboxItem | undefined = undefined;
|
||||
for (const category of toolbox.getToolboxItems()) {
|
||||
if (!isSelectableToolboxItem(category)) continue;
|
||||
|
||||
const contents = category.getContents();
|
||||
if (
|
||||
Array.isArray(contents) &&
|
||||
contents.some(
|
||||
(item) =>
|
||||
item.kind.toLowerCase() === 'block' &&
|
||||
'type' in item &&
|
||||
item.type === block.type,
|
||||
)
|
||||
) {
|
||||
parentCategory = category;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
'getColour' in category &&
|
||||
typeof category.getColour === 'function' &&
|
||||
category.getColour() === block.getColour()
|
||||
) {
|
||||
parentCategory = category;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentCategory) {
|
||||
return Msg['BLOCK_LABEL_TOOLBOX_CATEGORY'].replace(
|
||||
'%1',
|
||||
parentCategory.getName(),
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate translated announcement template based on the connection type.
|
||||
*
|
||||
* @param preposition The relationship between the local and neighbour connections.
|
||||
* @returns A translated string template to use for announcing a block move.
|
||||
*/
|
||||
function getAnnouncementTemplate(preposition: ConnectionPreposition): string {
|
||||
switch (preposition) {
|
||||
case ConnectionPreposition.BEFORE:
|
||||
return Msg['ANNOUNCE_MOVE_BEFORE'];
|
||||
case ConnectionPreposition.AFTER:
|
||||
return Msg['ANNOUNCE_MOVE_AFTER'];
|
||||
case ConnectionPreposition.INSIDE:
|
||||
return Msg['ANNOUNCE_MOVE_INSIDE'];
|
||||
case ConnectionPreposition.AROUND:
|
||||
return Msg['ANNOUNCE_MOVE_AROUND'];
|
||||
default:
|
||||
return Msg['ANNOUNCE_MOVE_TO'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label for a connection that includes either a block label, input
|
||||
* label, or both.
|
||||
*
|
||||
* Input label resolution:
|
||||
* 1. Custom label from {@link Input.getAriaLabelText} when set.
|
||||
* 2. Otherwise derived labels from {@link getInputLabelsSubset} (field row,
|
||||
* child blocks, and numbered fallbacks as needed).
|
||||
*
|
||||
* @param conn The connection to generate a label for.
|
||||
* @param baseLabel An optional block label to include in the returned string.
|
||||
* @returns A label describing the given connection
|
||||
*/
|
||||
function computeMoveConnectionLabel(
|
||||
conn: RenderedConnection,
|
||||
baseLabel: string,
|
||||
): string {
|
||||
const input = conn.getParentInput();
|
||||
if (!input) return baseLabel;
|
||||
|
||||
let inputLabel = input.getAriaLabelText();
|
||||
|
||||
if (!inputLabel) {
|
||||
const labels = getInputLabelsSubset(conn.getSourceBlock(), input, true);
|
||||
if (!labels.length) return baseLabel;
|
||||
|
||||
inputLabel = labels.join(', ');
|
||||
}
|
||||
|
||||
return baseLabel
|
||||
? Msg['ANNOUNCE_MOVE_OF'].replace('%1', inputLabel).replace('%2', baseLabel)
|
||||
: inputLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a translated string describing an in-progress move of a block to a new
|
||||
* connection, suitable for announcement on the ARIA live region. The returned string
|
||||
* will be assembled based on the types of the local and neighbour connections and
|
||||
* the presence of any readable fields on the block's inputs. If multiple potential
|
||||
* candidate connections are present, additional context will be included in the
|
||||
* returned string to help disambiguate between them.
|
||||
*
|
||||
* @param local The moving side of the candidate connection pair
|
||||
* @param neighbour The target side of the candidate connection pair
|
||||
* @param disambiguationPolicy A function that determines whether it's useful to
|
||||
* include parent input labels for disambiguation.
|
||||
* @param isMoveStart Whether this announcement is for the start of a move. If false,
|
||||
* skip announcing the block label since it should have already been announced.
|
||||
*/
|
||||
export function computeMoveLabel(
|
||||
local: RenderedConnection,
|
||||
neighbour: RenderedConnection,
|
||||
disambiguationPolicy: (forLocal: boolean) => boolean,
|
||||
isMoveStart = false,
|
||||
): string {
|
||||
const preposition = getConnectionPreposition(local, neighbour);
|
||||
const template = getAnnouncementTemplate(preposition);
|
||||
|
||||
const needsDisambiguation = ![
|
||||
ConnectionPreposition.BEFORE,
|
||||
ConnectionPreposition.AFTER,
|
||||
].includes(preposition);
|
||||
|
||||
const includeLocalContext = needsDisambiguation && disambiguationPolicy(true);
|
||||
const includeNeighbourContext =
|
||||
needsDisambiguation && disambiguationPolicy(false);
|
||||
|
||||
let blockLabel = isMoveStart
|
||||
? local.getSourceBlock().getStackBlocksCountLabel()
|
||||
: '';
|
||||
let neighbourLabel = neighbour.getSourceBlock().getAriaLabel(Verbosity.TERSE);
|
||||
|
||||
if (includeLocalContext) {
|
||||
blockLabel = computeMoveConnectionLabel(local, blockLabel);
|
||||
}
|
||||
if (includeNeighbourContext) {
|
||||
neighbourLabel = computeMoveConnectionLabel(neighbour, neighbourLabel);
|
||||
}
|
||||
|
||||
return template.replace('%1', blockLabel).replace('%2', neighbourLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate preposition to use in the move announcement based on the
|
||||
* relationship between the local and neighbour connections.
|
||||
*/
|
||||
function getConnectionPreposition(
|
||||
local: RenderedConnection,
|
||||
neighbour: RenderedConnection,
|
||||
): ConnectionPreposition {
|
||||
switch (local.type) {
|
||||
case ConnectionType.INPUT_VALUE:
|
||||
case ConnectionType.OUTPUT_VALUE:
|
||||
return ConnectionPreposition.TO;
|
||||
case ConnectionType.NEXT_STATEMENT:
|
||||
if (local === local.getSourceBlock().nextConnection) {
|
||||
return ConnectionPreposition.BEFORE;
|
||||
} else {
|
||||
return ConnectionPreposition.AROUND;
|
||||
}
|
||||
case ConnectionType.PREVIOUS_STATEMENT:
|
||||
if (neighbour === neighbour.getSourceBlock().nextConnection) {
|
||||
return ConnectionPreposition.AFTER;
|
||||
} else {
|
||||
return ConnectionPreposition.INSIDE;
|
||||
}
|
||||
}
|
||||
// Not normally reachable since all valid connection types are covered.
|
||||
// Satisfies the return type.
|
||||
return ConnectionPreposition.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating that the block is disabled.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block is disabled (if it is), otherwise
|
||||
* undefined.
|
||||
*/
|
||||
function getDisabledLabel(block: BlockSvg) {
|
||||
return block.isEnabled() ? undefined : Msg['BLOCK_LABEL_DISABLED'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating that the block is collapsed.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block is collapsed (if it is), otherwise
|
||||
* undefined.
|
||||
*/
|
||||
function getCollapsedLabel(block: BlockSvg) {
|
||||
return block.isCollapsed() ? Msg['BLOCK_LABEL_COLLAPSED'] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating that the block is a shadow block.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block is a shadow (if it is), otherwise
|
||||
* undefined.
|
||||
*/
|
||||
function getShadowBlockLabel(block: BlockSvg) {
|
||||
return block.isShadow() ? Msg['BLOCK_LABEL_REPLACEABLE'] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a label indicating whether the block has one or multiple inputs.
|
||||
*
|
||||
* @internal
|
||||
* @param block The block to generate a label for.
|
||||
* @returns A label indicating that the block has one or multiple inputs,
|
||||
* otherwise undefined.
|
||||
*/
|
||||
function getInputCountLabel(block: BlockSvg) {
|
||||
const branchCount = block.inputList.filter(
|
||||
(input) => input.type === inputTypes.STATEMENT,
|
||||
).length;
|
||||
|
||||
if (branchCount > 1) {
|
||||
return Msg['BLOCK_LABEL_HAS_BRANCHES'].replace(
|
||||
'%1',
|
||||
branchCount.toString(),
|
||||
);
|
||||
}
|
||||
const valueInputCount = block.inputList.reduce((totalSum, input) => {
|
||||
return (
|
||||
input.fieldRow.reduce((fieldCount, field) => {
|
||||
return field.EDITABLE && !field.isFullBlockField()
|
||||
? ++fieldCount
|
||||
: fieldCount;
|
||||
}, totalSum) +
|
||||
(input.connection?.type === ConnectionType.INPUT_VALUE ? 1 : 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
switch (valueInputCount) {
|
||||
case 0:
|
||||
return undefined;
|
||||
case 1:
|
||||
return Msg['BLOCK_LABEL_HAS_INPUT'];
|
||||
default:
|
||||
return Msg['BLOCK_LABEL_HAS_INPUTS'];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {IFlyout} from './interfaces/i_flyout.js';
|
||||
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
|
||||
import * as registry from './registry.js';
|
||||
import * as blocks from './serialization/blocks.js';
|
||||
import {aria} from './utils.js';
|
||||
import type {BlockInfo} from './utils/toolbox.js';
|
||||
import * as utilsXml from './utils/xml.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
@@ -67,7 +68,19 @@ export class BlockFlyoutInflater implements IFlyoutInflater {
|
||||
// Mark blocks as being inside a flyout. This is used to detect and
|
||||
// prevent the closure of the flyout if the user right-clicks on such
|
||||
// a block.
|
||||
block.getDescendants(false).forEach((b) => (b.isInFlyout = true));
|
||||
block.getDescendants(false).forEach((b) => {
|
||||
b.isInFlyout = true;
|
||||
const focusableElement = b.getFocusableElement();
|
||||
// blocks can't be focused if they're in a flyout and not top-level
|
||||
// nonfocusable blocks should be hidden from the aria tree
|
||||
aria.setState(focusableElement, aria.State.HIDDEN, true);
|
||||
aria.setRole(focusableElement, aria.Role.PRESENTATION);
|
||||
});
|
||||
// Since getDescencdants includes the root block, we need
|
||||
// to correct the role and hidden state for it.
|
||||
const focusableElement = block.getFocusableElement();
|
||||
aria.clearState(focusableElement, aria.State.HIDDEN);
|
||||
aria.setRole(focusableElement, aria.Role.LISTITEM);
|
||||
this.addBlockListeners(block);
|
||||
|
||||
return new FlyoutItem(block, BLOCK_TYPE);
|
||||
|
||||
@@ -16,6 +16,7 @@ import './events/events_selected.js';
|
||||
|
||||
import {Block} from './block.js';
|
||||
import * as blockAnimations from './block_animations.js';
|
||||
import {computeAriaLabel, configureAriaRole} from './block_aria_composer.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
|
||||
import * as common from './common.js';
|
||||
@@ -35,6 +36,7 @@ import {EventType} from './events/type.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import {FieldLabel} from './field_label.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import * as hints from './hints.js';
|
||||
import {IconType} from './icons/icon_types.js';
|
||||
import {MutatorIcon} from './icons/mutator_icon.js';
|
||||
import {WarningIcon} from './icons/warning_icon.js';
|
||||
@@ -43,11 +45,16 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import {IContextMenu} from './interfaces/i_contextmenu.js';
|
||||
import type {ICopyable} from './interfaces/i_copyable.js';
|
||||
import {IDeletable} from './interfaces/i_deletable.js';
|
||||
import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js';
|
||||
import type {
|
||||
DragDisposition,
|
||||
IDragStrategy,
|
||||
IDraggable,
|
||||
} from './interfaces/i_draggable.js';
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import {IIcon} from './interfaces/i_icon.js';
|
||||
import * as internalConstants from './internal_constants.js';
|
||||
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as renderManagement from './render_management.js';
|
||||
import {RenderedConnection} from './rendered_connection.js';
|
||||
@@ -56,6 +63,7 @@ import * as blocks from './serialization/blocks.js';
|
||||
import type {BlockStyle} from './theme.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import {idGenerator} from './utils.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
@@ -156,12 +164,9 @@ export class BlockSvg
|
||||
private visuallyDisabled = false;
|
||||
|
||||
override workspace: WorkspaceSvg;
|
||||
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
||||
override outputConnection!: RenderedConnection;
|
||||
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
||||
override nextConnection!: RenderedConnection;
|
||||
// TODO(b/109816955): remove '!', see go/strict-prop-init-fix.
|
||||
override previousConnection!: RenderedConnection;
|
||||
override outputConnection: RenderedConnection | null = null;
|
||||
override nextConnection: RenderedConnection | null = null;
|
||||
override previousConnection: RenderedConnection | null = null;
|
||||
|
||||
private translation = '';
|
||||
|
||||
@@ -241,6 +246,7 @@ export class BlockSvg
|
||||
if (!svg.parentNode) {
|
||||
this.workspace.getCanvas().appendChild(svg);
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -603,6 +609,7 @@ export class BlockSvg
|
||||
this.getInput(collapsedInputName) ||
|
||||
this.appendDummyInput(collapsedInputName);
|
||||
input.appendField(new FieldLabel(text), collapsedFieldName);
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,6 +798,13 @@ export class BlockSvg
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not this block is currently being dragged.
|
||||
*/
|
||||
isDragging() {
|
||||
return this.dragging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether this block is movable or not.
|
||||
*
|
||||
@@ -832,6 +846,7 @@ export class BlockSvg
|
||||
override setShadow(shadow: boolean) {
|
||||
super.setShadow(shadow);
|
||||
this.applyColour();
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -903,14 +918,21 @@ export class BlockSvg
|
||||
Tooltip.dispose();
|
||||
ContextMenu.hide();
|
||||
|
||||
if (animate) {
|
||||
this.unplug(healStack);
|
||||
blockAnimations.disposeUiEffect(this);
|
||||
}
|
||||
|
||||
const focusManager = getFocusManager();
|
||||
const focusedElement =
|
||||
focusManager.getFocusedNode()?.getFocusableElement() ?? null;
|
||||
|
||||
super.dispose(!!healStack);
|
||||
dom.removeNode(this.svgGroup);
|
||||
|
||||
// If this block (or a descendant) was focused, focus its parent or
|
||||
// workspace instead.
|
||||
const focusManager = getFocusManager();
|
||||
if (
|
||||
this.getSvgRoot().contains(
|
||||
focusManager.getFocusedNode()?.getFocusableElement() ?? null,
|
||||
)
|
||||
) {
|
||||
if (this.getSvgRoot().contains(focusedElement)) {
|
||||
let parent: BlockSvg | undefined | null = this.getParent();
|
||||
if (!parent) {
|
||||
// In some cases, blocks are disconnected from their parents before
|
||||
@@ -927,28 +949,21 @@ export class BlockSvg
|
||||
parent = targetConnection?.getSourceBlock();
|
||||
}
|
||||
}
|
||||
if (parent) {
|
||||
focusManager.focusNode(parent);
|
||||
} else {
|
||||
const nearestNeighbour = this.getNearestNeighbour();
|
||||
if (nearestNeighbour) {
|
||||
focusManager.focusNode(nearestNeighbour);
|
||||
setTimeout(() => {
|
||||
if (!this.workspace.rendered) return;
|
||||
if (parent) {
|
||||
focusManager.focusNode(parent);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!this.workspace.rendered) return;
|
||||
const nearestNeighbour = this.getNearestNeighbour();
|
||||
|
||||
if (nearestNeighbour) {
|
||||
focusManager.focusNode(nearestNeighbour);
|
||||
} else {
|
||||
focusManager.focusTree(this.workspace);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
this.unplug(healStack);
|
||||
blockAnimations.disposeUiEffect(this);
|
||||
}
|
||||
|
||||
super.dispose(!!healStack);
|
||||
dom.removeNode(this.svgGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1052,6 +1067,7 @@ export class BlockSvg
|
||||
for (const child of this.getChildren(false)) {
|
||||
child.updateDisabled();
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1599,6 +1615,7 @@ export class BlockSvg
|
||||
if (
|
||||
this.isDeadOrDying() ||
|
||||
this.workspace.isDragging() ||
|
||||
this.isDragging() ||
|
||||
root.isInFlyout
|
||||
) {
|
||||
return;
|
||||
@@ -1620,7 +1637,7 @@ export class BlockSvg
|
||||
|
||||
if (conn.isSuperior()) {
|
||||
neighbour.bumpAwayFrom(conn, /* initiatedByThis = */ false);
|
||||
} else {
|
||||
} else if (!neighbour.getSourceBlock().isDragging()) {
|
||||
conn.bumpAwayFrom(neighbour, /* initiatedByThis = */ true);
|
||||
}
|
||||
}
|
||||
@@ -1774,24 +1791,7 @@ export class BlockSvg
|
||||
* @internal
|
||||
*/
|
||||
fadeForReplacement(add: boolean) {
|
||||
// TODO (7204): Remove these internal methods.
|
||||
(this.pathObject as AnyDuringMigration).updateReplacementFade(add);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual effect to show that if the dragging block is dropped it will connect
|
||||
* to this input.
|
||||
*
|
||||
* @param conn The connection on the input to highlight.
|
||||
* @param add True if highlighting should be added.
|
||||
* @internal
|
||||
*/
|
||||
highlightShapeForInput(conn: RenderedConnection, add: boolean) {
|
||||
// TODO (7204): Remove these internal methods.
|
||||
(this.pathObject as AnyDuringMigration).updateShapeForInputHighlight(
|
||||
conn,
|
||||
add,
|
||||
);
|
||||
this.pathObject.updateReplacing?.(add);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1820,18 +1820,21 @@ export class BlockSvg
|
||||
}
|
||||
|
||||
/** Starts a drag on the block. */
|
||||
startDrag(e?: PointerEvent): void {
|
||||
this.dragStrategy.startDrag(e);
|
||||
startDrag(e?: PointerEvent | KeyboardEvent) {
|
||||
return this.dragStrategy.startDrag(e);
|
||||
}
|
||||
|
||||
/** Drags the block to the given location. */
|
||||
drag(newLoc: Coordinate, e?: PointerEvent): void {
|
||||
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void {
|
||||
this.dragStrategy.drag(newLoc, e);
|
||||
}
|
||||
|
||||
/** Ends the drag on the block. */
|
||||
endDrag(e?: PointerEvent): void {
|
||||
this.dragStrategy.endDrag(e);
|
||||
endDrag(
|
||||
e: PointerEvent | KeyboardEvent | undefined,
|
||||
disposition: DragDisposition,
|
||||
): void {
|
||||
this.dragStrategy.endDrag(e, disposition);
|
||||
}
|
||||
|
||||
/** Moves the block back to where it was at the start of a drag. */
|
||||
@@ -1877,8 +1880,23 @@ export class BlockSvg
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of blocks that this block is nested inside of.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getNestingLevel(): number {
|
||||
const surroundParent = this.getSurroundParent();
|
||||
return surroundParent ? surroundParent.getNestingLevel() + 1 : 0;
|
||||
}
|
||||
|
||||
/** See IFocusableNode.getFocusableElement. */
|
||||
getFocusableElement(): HTMLElement | SVGElement {
|
||||
// For full-block fields, we focus the field itself
|
||||
const fullBlockField = this.getFullBlockField();
|
||||
if (fullBlockField) {
|
||||
return fullBlockField.getFocusableElement();
|
||||
}
|
||||
return this.pathObject.svgPath;
|
||||
}
|
||||
|
||||
@@ -1889,10 +1907,16 @@ export class BlockSvg
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {
|
||||
this.recomputeAriaContext();
|
||||
this.select();
|
||||
this.workspace.scrollBoundsIntoView(
|
||||
this.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
const focusedNode = getFocusManager().getFocusedNode();
|
||||
if (focusedNode && focusedNode !== this) {
|
||||
renderManagement.finishQueuedRenders().then(() => {
|
||||
this.workspace.scrollBoundsIntoView(
|
||||
this.getBoundingRectangleWithoutChildren(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
@@ -1904,4 +1928,134 @@ export class BlockSvg
|
||||
canBeFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this block via keyboard navigation.
|
||||
* If this block is in the flyout, a new copy is spawned in move mode on the
|
||||
* main workspace. If this block has a single full-block field, that field
|
||||
* will be focused. Otherwise, this is a no-op.
|
||||
*/
|
||||
performAction(e?: KeyboardEvent) {
|
||||
if (this.workspace.isFlyout) {
|
||||
KeyboardMover.mover.startMove(this, e);
|
||||
return;
|
||||
} else if (this.isSimpleReporter()) {
|
||||
for (const input of this.inputList) {
|
||||
for (const field of input.fieldRow) {
|
||||
if (field.isClickable() && field.isFullBlockField()) {
|
||||
field.showEditor();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.workspace.getNavigator().getInNode(this)) {
|
||||
hints.showBlockNavigationHint(this.workspace);
|
||||
} else {
|
||||
hints.showHelpHint(this.workspace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of all of the parent blocks of the given block.
|
||||
*
|
||||
* @internal
|
||||
* @returns A set of the parents of the given block.
|
||||
*/
|
||||
getParents(): Set<BlockSvg> {
|
||||
const parents = new Set<BlockSvg>();
|
||||
let parent = this.getParent();
|
||||
while (parent) {
|
||||
parents.add(parent);
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of all of the parent blocks connected to an output of the
|
||||
* given block or one of its parents. Also includes the given block.
|
||||
*
|
||||
* @internal
|
||||
* @returns A set of the output-connected parents of the given block.
|
||||
*/
|
||||
getOutputParents(): Set<BlockSvg> {
|
||||
const parents = new Set<BlockSvg>();
|
||||
parents.add(this);
|
||||
let parent = this.outputConnection?.targetBlock();
|
||||
while (parent) {
|
||||
parents.add(parent);
|
||||
parent = parent.outputConnection?.targetBlock();
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ID for the logical "row" this block is part of. A "row" is
|
||||
* bounded by a previous/next connection, a statement input, or a block stack
|
||||
* boundary; all blocks/inputs nested inside of one of those are conceptually
|
||||
* part of its same row.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getRowId(): string {
|
||||
const connectedInput =
|
||||
this.outputConnection?.targetConnection?.getParentInput();
|
||||
// Blocks with an output value have the same ID as the input they're
|
||||
// connected to.
|
||||
if (connectedInput) {
|
||||
return connectedInput.getRowId();
|
||||
}
|
||||
|
||||
// All other blocks are their own row.
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ARIA label, role and roledescription for this block.
|
||||
*/
|
||||
private recomputeAriaContext() {
|
||||
if (this.getFullBlockField()) return;
|
||||
aria.setState(
|
||||
this.getFocusableElement(),
|
||||
aria.State.LABEL,
|
||||
this.getAriaLabel(aria.Verbosity.STANDARD),
|
||||
);
|
||||
configureAriaRole(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a description of this block suitable for screenreaders or use in
|
||||
* ARIA attributes.
|
||||
*
|
||||
* @param verbosity How much detail to include in the description.
|
||||
* @returns An accessibility description of this block.
|
||||
*/
|
||||
getAriaLabel(verbosity: aria.Verbosity) {
|
||||
return computeAriaLabel(this, verbosity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of blocks in this stack (connected by next connections)
|
||||
* and return a label to describe it. Uses the standard label if there is only one block.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getStackBlocksCountLabel(): string {
|
||||
let count = 1;
|
||||
let block = this.getNextBlock();
|
||||
while (block) {
|
||||
count++;
|
||||
block = block.getNextBlock();
|
||||
}
|
||||
if (count <= 1) {
|
||||
return computeAriaLabel(this, aria.Verbosity.TERSE);
|
||||
}
|
||||
|
||||
const labelTemplate = Msg['BLOCK_LABEL_STACK_BLOCKS'];
|
||||
return labelTemplate.replace('%1', count.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ import * as icons from './icons.js';
|
||||
import {inject} from './inject.js';
|
||||
import * as inputs from './inputs.js';
|
||||
import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
|
||||
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
|
||||
import {MoveIndicator} from './keyboard_nav/move_indicator.js';
|
||||
import {LabelFlyoutInflater} from './label_flyout_inflater.js';
|
||||
import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js';
|
||||
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
|
||||
@@ -125,7 +127,10 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
|
||||
import {Input} from './inputs/input.js';
|
||||
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
|
||||
import {IAutoHideable} from './interfaces/i_autohideable.js';
|
||||
import {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import {
|
||||
IBoundedElement,
|
||||
isBoundedElement,
|
||||
} from './interfaces/i_bounded_element.js';
|
||||
import {IBubble} from './interfaces/i_bubble.js';
|
||||
import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js';
|
||||
import {IComponent} from './interfaces/i_component.js';
|
||||
@@ -137,6 +142,7 @@ import {IDeletable, isDeletable} from './interfaces/i_deletable.js';
|
||||
import {IDeleteArea} from './interfaces/i_delete_area.js';
|
||||
import {IDragTarget} from './interfaces/i_drag_target.js';
|
||||
import {
|
||||
DragDisposition,
|
||||
IDragStrategy,
|
||||
IDraggable,
|
||||
isDraggable,
|
||||
@@ -171,15 +177,13 @@ import {
|
||||
import {IVariableMap} from './interfaces/i_variable_map.js';
|
||||
import {IVariableModel, IVariableState} from './interfaces/i_variable_model.js';
|
||||
import * as internalConstants from './internal_constants.js';
|
||||
import {LineCursor} from './keyboard_nav/line_cursor.js';
|
||||
import {Marker} from './keyboard_nav/marker.js';
|
||||
import {ToolboxNavigator} from './keyboard_nav/navigators/toolbox_navigator.js';
|
||||
import {
|
||||
KeyboardNavigationController,
|
||||
keyboardNavigationController,
|
||||
} from './keyboard_navigation_controller.js';
|
||||
import type {LayerManager} from './layer_manager.js';
|
||||
import * as layers from './layers.js';
|
||||
import {MarkerManager} from './marker_manager.js';
|
||||
import {Menu} from './menu.js';
|
||||
import {MenuItem} from './menuitem.js';
|
||||
import {MetricsManager} from './metrics_manager.js';
|
||||
@@ -230,8 +234,6 @@ import {ZoomControls} from './zoom_controls.js';
|
||||
* This constant is overridden by the build script (npm run build) to the value
|
||||
* of the version in package.json. This is done by the Closure Compiler in the
|
||||
* buildCompressed gulp task.
|
||||
* For local builds, you can pass --define='Blockly.VERSION=X.Y.Z' to the
|
||||
* compiler to override this constant.
|
||||
*
|
||||
* @define {string}
|
||||
*/
|
||||
@@ -433,16 +435,21 @@ Names.prototype.populateProcedures = function (
|
||||
};
|
||||
// clang-format on
|
||||
|
||||
export * from './flyout_navigator.js';
|
||||
export * from './interfaces/i_navigation_policy.js';
|
||||
export * from './keyboard_nav/block_navigation_policy.js';
|
||||
export * from './keyboard_nav/connection_navigation_policy.js';
|
||||
export * from './keyboard_nav/field_navigation_policy.js';
|
||||
export * from './keyboard_nav/flyout_button_navigation_policy.js';
|
||||
export * from './keyboard_nav/flyout_navigation_policy.js';
|
||||
export * from './keyboard_nav/flyout_separator_navigation_policy.js';
|
||||
export * from './keyboard_nav/workspace_navigation_policy.js';
|
||||
export * from './navigator.js';
|
||||
export * from './keyboard_nav/navigation_policies/block_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/bubble_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/comment_bar_button_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/comment_editor_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/connection_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/field_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/flyout_button_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/flyout_separator_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/icon_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/toolbox_item_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/workspace_comment_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigation_policies/workspace_navigation_policy.js';
|
||||
export * from './keyboard_nav/navigators/flyout_navigator.js';
|
||||
export * from './keyboard_nav/navigators/navigator.js';
|
||||
export * from './toast.js';
|
||||
|
||||
// Re-export submodules that no longer declareLegacyNamespace.
|
||||
@@ -465,7 +472,6 @@ export {
|
||||
DragTarget,
|
||||
Events,
|
||||
Extensions,
|
||||
LineCursor,
|
||||
Procedures,
|
||||
ShortcutItems,
|
||||
Themes,
|
||||
@@ -500,6 +506,8 @@ export {
|
||||
BlockFlyoutInflater,
|
||||
ButtonFlyoutInflater,
|
||||
CodeGenerator,
|
||||
Direction,
|
||||
DragDisposition,
|
||||
Field,
|
||||
FieldCheckbox,
|
||||
FieldCheckboxConfig,
|
||||
@@ -584,17 +592,17 @@ export {
|
||||
ImageProperties,
|
||||
Input,
|
||||
InsertionMarkerPreviewer,
|
||||
KeyboardMover,
|
||||
KeyboardNavigationController,
|
||||
LabelFlyoutInflater,
|
||||
LayerManager,
|
||||
Marker,
|
||||
MarkerManager,
|
||||
Menu,
|
||||
MenuGenerator,
|
||||
MenuGeneratorFunction,
|
||||
MenuItem,
|
||||
MenuOption,
|
||||
MetricsManager,
|
||||
MoveIndicator,
|
||||
Msg,
|
||||
Names,
|
||||
Options,
|
||||
@@ -609,6 +617,7 @@ export {
|
||||
Toolbox,
|
||||
ToolboxCategory,
|
||||
ToolboxItem,
|
||||
ToolboxNavigator,
|
||||
ToolboxSeparator,
|
||||
Trashcan,
|
||||
UnattachedFieldError,
|
||||
@@ -626,6 +635,7 @@ export {
|
||||
icons,
|
||||
inject,
|
||||
inputs,
|
||||
isBoundedElement,
|
||||
isCopyable,
|
||||
isDeletable,
|
||||
isDraggable,
|
||||
|
||||
@@ -8,13 +8,16 @@ import * as browserEvents from '../browser_events.js';
|
||||
import * as common from '../common.js';
|
||||
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {IBoundedElement} from '../interfaces/i_bounded_element.js';
|
||||
import {IBubble} from '../interfaces/i_bubble.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import {ISelectable} from '../interfaces/i_selectable.js';
|
||||
import {ContainerRegion} from '../metrics_manager.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import {Scrollbar} from '../scrollbar.js';
|
||||
import {aria} from '../utils.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import * as idGenerator from '../utils/idgenerator.js';
|
||||
@@ -24,12 +27,21 @@ import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
/**
|
||||
* Represents a either a string or a function that, when called, can provide a
|
||||
* custom ARIA string to represent a bubble, or null if the default fallback
|
||||
* should be used. See setAriaLabelProvider for more context.
|
||||
*/
|
||||
export type AriaLabelProvider = string | ((bubble: Bubble) => string | null);
|
||||
|
||||
/**
|
||||
* The abstract pop-up bubble class. This creates a UI that looks like a speech
|
||||
* bubble, where it has a "tail" that points to the block, and a "head" that
|
||||
* displays arbitrary svg elements.
|
||||
*/
|
||||
export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
export abstract class Bubble
|
||||
implements IBubble, ISelectable, IFocusableNode, IBoundedElement
|
||||
{
|
||||
/** The width of the border around the bubble. */
|
||||
static readonly BORDER_WIDTH = 6;
|
||||
|
||||
@@ -88,10 +100,15 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
/** The position of the left of the bubble realtive to its anchor. */
|
||||
private relativeLeft = 0;
|
||||
|
||||
private dragStrategy = new BubbleDragStrategy(this, this.workspace);
|
||||
private dragStrategy: BubbleDragStrategy = new BubbleDragStrategy(
|
||||
this,
|
||||
this.workspace,
|
||||
);
|
||||
|
||||
private focusableElement: SVGElement | HTMLElement;
|
||||
|
||||
private ariaLabelProvider: AriaLabelProvider | null = null;
|
||||
|
||||
/**
|
||||
* @param workspace The workspace this bubble belongs to.
|
||||
* @param anchor The anchor location of the thing this bubble is attached to.
|
||||
@@ -156,6 +173,7 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
this,
|
||||
this.onKeyDown,
|
||||
);
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/** Dispose of this bubble. */
|
||||
@@ -274,6 +292,18 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the bubble by the given amounts in the x and y directions.
|
||||
*
|
||||
* @param dx The distance to move along the x axis.
|
||||
* @param dy The distance to move along the y axis.
|
||||
* @param _reason A description of why this move is happening.
|
||||
*/
|
||||
moveBy(dx: number, dy: number, _reason?: string[]) {
|
||||
const origin = this.getRelativeToSurfaceXY();
|
||||
this.moveTo(origin.x + dx, origin.y + dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions the bubble "optimally" so that the most of it is visible and
|
||||
* it does not overlap the rect (if provided).
|
||||
@@ -617,6 +647,21 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounds of this bubble.
|
||||
*
|
||||
* @returns A bounding box for this bubble.
|
||||
*/
|
||||
getBoundingRectangle(): Rect {
|
||||
const origin = this.getRelativeToSurfaceXY();
|
||||
return new Rect(
|
||||
origin.y,
|
||||
origin.y + this.size.height,
|
||||
origin.x,
|
||||
origin.x + this.size.width,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
getSvgRoot(): SVGElement {
|
||||
return this.svgRoot;
|
||||
@@ -664,8 +709,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
}
|
||||
|
||||
/** Starts a drag on the bubble. */
|
||||
startDrag(): void {
|
||||
this.dragStrategy.startDrag();
|
||||
startDrag() {
|
||||
return this.dragStrategy.startDrag();
|
||||
}
|
||||
|
||||
/** Drags the bubble to the given location. */
|
||||
@@ -729,4 +774,64 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
|
||||
getOwner(): (IHasBubble & IFocusableNode) | undefined {
|
||||
return this.owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the ARIA label and role for this bubble. This is automatically called
|
||||
* during initialization, but implementations may find it useful to call this if
|
||||
* the bubble's label should be changed.
|
||||
*
|
||||
* Bubbles use a default non-specific label unless they're customized otherwise
|
||||
* which is the responsibility of the bubble's owner rather than bubble
|
||||
* implementations. Customization can be done via setAriaLabelProvider.
|
||||
*/
|
||||
protected recomputeAriaContext(): void {
|
||||
const element = this.getFocusableElement();
|
||||
if (!element) return;
|
||||
|
||||
aria.setRole(element, aria.Role.GROUP);
|
||||
|
||||
const label = this.getAriaLabel()?.trim();
|
||||
|
||||
aria.setState(
|
||||
element,
|
||||
aria.State.LABEL,
|
||||
label ? label : Msg['BUBBLE_LABEL_DEFAULT'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom ARIA label provider for this bubble, or null if it should be reset
|
||||
* to use the default method.
|
||||
*
|
||||
* Bubbles do not compute ARIA labels specifically to their implementation since
|
||||
* they can be rather general-purpose. Instead, owners of the specific bubble
|
||||
* instance (such as an icon) are responsible for defining custom label providers
|
||||
* for their bubbles.
|
||||
*
|
||||
* Note that calling this isn't sufficient for it to actually be used.
|
||||
* recomputeAriaContext will likely also need to be called to actually apply the
|
||||
* custom label to the bubble's focusable element.
|
||||
*/
|
||||
setAriaLabelProvider(provider: AriaLabelProvider | null): void {
|
||||
this.ariaLabelProvider = provider;
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this bubble based on the provider set via
|
||||
* setAriaLabelProvider. This will return null if the provider is absent or
|
||||
* returns null.
|
||||
*
|
||||
* @returns The ARIA label to use for this bubble, or null if one is not provided.
|
||||
*/
|
||||
getAriaLabel(): string | null {
|
||||
if (this.ariaLabelProvider) {
|
||||
if (typeof this.ariaLabelProvider === 'string') {
|
||||
return this.ariaLabelProvider;
|
||||
} else if (typeof this.ariaLabelProvider === 'function') {
|
||||
return this.ariaLabelProvider(this);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
import type {BlocklyOptions} from '../blockly_options.js';
|
||||
import {Abstract as AbstractEvent} from '../events/events_abstract.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js';
|
||||
import {Options} from '../options.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
@@ -50,8 +54,9 @@ export class MiniWorkspaceBubble extends Bubble {
|
||||
public readonly workspace: WorkspaceSvg,
|
||||
protected anchor: Coordinate,
|
||||
protected ownerRect?: Rect,
|
||||
protected owner?: IHasBubble & IFocusableNode,
|
||||
) {
|
||||
super(workspace, anchor, ownerRect);
|
||||
super(workspace, anchor, ownerRect, undefined, owner);
|
||||
const options = new Options(workspaceOptions);
|
||||
this.validateWorkspaceOptions(options);
|
||||
|
||||
@@ -153,11 +158,10 @@ export class MiniWorkspaceBubble extends Bubble {
|
||||
* are dealt with by resizing the workspace to show them.
|
||||
*/
|
||||
private bumpBlocksIntoBounds() {
|
||||
if (
|
||||
this.miniWorkspace.isDragging() &&
|
||||
!this.miniWorkspace.keyboardMoveInProgress
|
||||
)
|
||||
// Only bump for mouse-driven drags.
|
||||
if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const MARGIN = 20;
|
||||
|
||||
@@ -189,15 +193,13 @@ export class MiniWorkspaceBubble extends Bubble {
|
||||
* mini workspace.
|
||||
*/
|
||||
private updateBubbleSize() {
|
||||
if (
|
||||
this.miniWorkspace.isDragging() &&
|
||||
!this.miniWorkspace.keyboardMoveInProgress
|
||||
)
|
||||
if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable autolayout if a keyboard move is in progress to prevent the
|
||||
// mutator bubble from jumping around.
|
||||
this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress;
|
||||
this.autoLayout &&= !KeyboardMover.mover.isMoving();
|
||||
|
||||
const currSize = this.getSize();
|
||||
const newSize = this.calculateWorkspaceSize();
|
||||
@@ -289,4 +291,12 @@ export class MiniWorkspaceBubble extends Bubble {
|
||||
'monkey-patched in by blockly.ts',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this bubble via keyboard navigation by focusing
|
||||
* the mutator workspace.
|
||||
*/
|
||||
performAction() {
|
||||
getFocusManager().focusTree(this.getWorkspace());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
@@ -23,8 +25,9 @@ export class TextBubble extends Bubble {
|
||||
public readonly workspace: WorkspaceSvg,
|
||||
protected anchor: Coordinate,
|
||||
protected ownerRect?: Rect,
|
||||
protected owner?: IHasBubble & IFocusableNode,
|
||||
) {
|
||||
super(workspace, anchor, ownerRect);
|
||||
super(workspace, anchor, ownerRect, undefined, owner);
|
||||
this.paragraph = this.stringToSvg(text, this.contentContainer);
|
||||
this.updateBubbleSize();
|
||||
dom.addClass(this.svgRoot, 'blocklyTextBubble');
|
||||
|
||||
@@ -63,6 +63,9 @@ export class TextInputBubble extends Bubble {
|
||||
/** View responsible for supporting text editing. */
|
||||
private editor: CommentEditor;
|
||||
|
||||
private readonly textChangeListener = () => {
|
||||
this.recomputeAriaContext();
|
||||
};
|
||||
/**
|
||||
* @param workspace The workspace this bubble belongs to.
|
||||
* @param anchor The anchor location of the thing this bubble is attached to.
|
||||
@@ -85,6 +88,7 @@ export class TextInputBubble extends Bubble {
|
||||
this.contentContainer.appendChild(this.editor.getDom());
|
||||
this.resizeGroup = this.createResizeHandle(this.svgRoot, workspace);
|
||||
this.setSize(this.DEFAULT_SIZE, true);
|
||||
this.addTextChangeListener(this.textChangeListener);
|
||||
}
|
||||
|
||||
/** @returns the text of this bubble. */
|
||||
@@ -279,6 +283,22 @@ export class TextInputBubble extends Bubble {
|
||||
getEditor() {
|
||||
return this.editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this bubble via keyboard navigation by focusing
|
||||
* the comment editor.
|
||||
*/
|
||||
performAction() {
|
||||
getFocusManager().focusNode(this.getEditor());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of this bubble.
|
||||
*/
|
||||
dispose() {
|
||||
super.dispose();
|
||||
this.editor.removeTextChangeListener(this.textChangeListener);
|
||||
}
|
||||
}
|
||||
|
||||
Css.register(`
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {IFocusableNode} from '../blockly.js';
|
||||
import {config} from '../config.js';
|
||||
import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {ICopyData} from '../interfaces/i_copyable.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {IPaster} from '../interfaces/i_paster.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import {State, append} from '../serialization/blocks.js';
|
||||
|
||||
@@ -15,9 +15,10 @@ import {Coordinate} from '../utils/coordinate.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import * as registry from './registry.js';
|
||||
|
||||
export class WorkspaceCommentPaster
|
||||
implements IPaster<WorkspaceCommentCopyData, RenderedWorkspaceComment>
|
||||
{
|
||||
export class WorkspaceCommentPaster implements IPaster<
|
||||
WorkspaceCommentCopyData,
|
||||
RenderedWorkspaceComment
|
||||
> {
|
||||
static TYPE = 'workspace-comment';
|
||||
|
||||
paste(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
@@ -56,6 +57,7 @@ export class CollapseCommentBarButton extends CommentBarButton {
|
||||
},
|
||||
this.container,
|
||||
);
|
||||
this.recomputeAriaContext();
|
||||
this.bindId = browserEvents.conditionalBind(
|
||||
this.icon,
|
||||
'pointerdown',
|
||||
@@ -95,8 +97,22 @@ export class CollapseCommentBarButton extends CommentBarButton {
|
||||
}
|
||||
|
||||
this.getCommentView().setCollapsed(!this.getCommentView().isCollapsed());
|
||||
this.recomputeAriaContext();
|
||||
this.workspace.hideChaff();
|
||||
|
||||
e?.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this button (defaults to null). Note that this
|
||||
* method will only be called and apply when recomputeAriaContext is called.
|
||||
*
|
||||
* @returns The ARIA label to use for this button, or null to use a default.
|
||||
*/
|
||||
protected getAriaLabel(): string {
|
||||
const isCollapsed = this.getCommentView().isCollapsed();
|
||||
return isCollapsed
|
||||
? Msg['ARIA_LABEL_COMMENT_EXPAND']
|
||||
: Msg['ARIA_LABEL_COMMENT_COLLAPSE'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as aria from '../utils/aria.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import type {CommentView} from './comment_view.js';
|
||||
@@ -102,4 +104,33 @@ export abstract class CommentBarButton implements IFocusableNode {
|
||||
canBeFocused() {
|
||||
return this.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the ARIA label and role for this button. Note that this is not
|
||||
* automatically called during initialization and must be called once a button's
|
||||
* focusable element (icon) is initialized. Implementations may also find it useful
|
||||
* to call this if the button's label should be changed.
|
||||
*/
|
||||
protected recomputeAriaContext(): void {
|
||||
if (!this.icon) return;
|
||||
|
||||
aria.setRole(this.icon, aria.Role.BUTTON);
|
||||
|
||||
const label = this.getAriaLabel();
|
||||
aria.setState(
|
||||
this.icon,
|
||||
aria.State.LABEL,
|
||||
label || Msg['ARIA_LABEL_BUTTON'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this button (defaults to null). Note that this
|
||||
* method will only be called and apply when recomputeAriaContext is called.
|
||||
*
|
||||
* @returns The ARIA label to use for this button, or null to use a default.
|
||||
*/
|
||||
protected getAriaLabel(): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as aria from '../utils/aria.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Rect} from '../utils/rect.js';
|
||||
import {Size} from '../utils/size.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import * as svgMath from '../utils/svg_math.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {RenderedWorkspaceComment} from './rendered_workspace_comment.js';
|
||||
|
||||
/**
|
||||
* String added to the ID of a workspace comment to identify
|
||||
@@ -25,7 +28,7 @@ export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_';
|
||||
|
||||
/** The part of a comment that can be typed into. */
|
||||
export class CommentEditor implements IFocusableNode {
|
||||
id?: string;
|
||||
id: string;
|
||||
/** The foreignObject containing the HTML text area. */
|
||||
private foreignObject: SVGForeignObjectElement;
|
||||
|
||||
@@ -40,9 +43,12 @@ export class CommentEditor implements IFocusableNode {
|
||||
/** The current text of the comment. Updates on text area change. */
|
||||
private text: string = '';
|
||||
|
||||
/** The parent object that owns this comment editor. */
|
||||
private parent?: BlockSvg | RenderedWorkspaceComment;
|
||||
|
||||
constructor(
|
||||
public workspace: WorkspaceSvg,
|
||||
commentId?: string,
|
||||
commentId: string,
|
||||
private onFinishEditing?: () => void,
|
||||
) {
|
||||
this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, {
|
||||
@@ -57,6 +63,7 @@ export class CommentEditor implements IFocusableNode {
|
||||
) as HTMLTextAreaElement;
|
||||
this.textArea.setAttribute('tabindex', '-1');
|
||||
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
|
||||
aria.setRole(this.textArea, aria.Role.TEXTBOX);
|
||||
this.textArea.setAttribute(
|
||||
'placeholder',
|
||||
Msg['WORKSPACE_COMMENT_DEFAULT_TEXT'],
|
||||
@@ -67,10 +74,8 @@ export class CommentEditor implements IFocusableNode {
|
||||
body.appendChild(this.textArea);
|
||||
this.foreignObject.appendChild(body);
|
||||
|
||||
if (commentId) {
|
||||
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
|
||||
this.textArea.setAttribute('id', this.id);
|
||||
}
|
||||
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
|
||||
this.textArea.setAttribute('id', this.id);
|
||||
|
||||
// Register browser event listeners for the user typing in the textarea.
|
||||
browserEvents.conditionalBind(
|
||||
@@ -131,6 +136,23 @@ export class CommentEditor implements IFocusableNode {
|
||||
this.onTextChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the parent object that owns this comment editor.
|
||||
*
|
||||
* @param newParent The parent of this comment editor.
|
||||
* @internal
|
||||
*/
|
||||
setParent(newParent: BlockSvg | RenderedWorkspaceComment): void {
|
||||
this.parent = newParent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent object that owns this comment editor, if any.
|
||||
*/
|
||||
getParent(): BlockSvg | RenderedWorkspaceComment | undefined {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers listeners when the text of the comment changes, either
|
||||
* programmatically or manually by the user.
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import * as css from '../css.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node';
|
||||
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
|
||||
import * as layers from '../layers.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as aria from '../utils/aria.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import * as drag from '../utils/drag.js';
|
||||
@@ -107,6 +108,12 @@ export class CommentView implements IRenderedElement {
|
||||
this.svgRoot = dom.createSvgElement(Svg.G, {
|
||||
'class': 'blocklyComment blocklyEditable blocklyDraggable',
|
||||
});
|
||||
aria.setRole(this.svgRoot, aria.Role.BUTTON);
|
||||
aria.setState(
|
||||
this.svgRoot,
|
||||
aria.State.ROLEDESCRIPTION,
|
||||
Msg['ARIA_LABEL_COMMENT'],
|
||||
);
|
||||
|
||||
this.highlightRect = this.createHighlightRect(this.svgRoot);
|
||||
|
||||
@@ -120,6 +127,11 @@ export class CommentView implements IRenderedElement {
|
||||
} = this.createTopBar(this.svgRoot));
|
||||
|
||||
this.commentEditor = this.createTextArea();
|
||||
aria.setState(
|
||||
this.svgRoot,
|
||||
aria.State.LABELLEDBY,
|
||||
this.commentEditor.getFocusableElement().id,
|
||||
);
|
||||
|
||||
this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace);
|
||||
|
||||
@@ -235,7 +247,7 @@ export class CommentView implements IRenderedElement {
|
||||
*
|
||||
* @returns The FocusableNode representing the editor portion of this comment.
|
||||
*/
|
||||
getEditorFocusableNode(): IFocusableNode {
|
||||
getEditorFocusableNode(): CommentEditor {
|
||||
return this.commentEditor;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as touch from '../touch.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
@@ -56,6 +57,7 @@ export class DeleteCommentBarButton extends CommentBarButton {
|
||||
},
|
||||
container,
|
||||
);
|
||||
this.recomputeAriaContext();
|
||||
this.bindId = browserEvents.conditionalBind(
|
||||
this.icon,
|
||||
'pointerdown',
|
||||
@@ -102,5 +104,16 @@ export class DeleteCommentBarButton extends CommentBarButton {
|
||||
this.getCommentView().dispose();
|
||||
e?.stopPropagation();
|
||||
getFocusManager().focusNode(this.workspace);
|
||||
this.workspace.getAudioManager().play('delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this button (defaults to null). Note that this
|
||||
* method will only be called and apply when recomputeAriaContext is called.
|
||||
*
|
||||
* @returns The ARIA label to use for this button, or null to use a default.
|
||||
*/
|
||||
protected getAriaLabel(): string {
|
||||
return Msg['REMOVE_COMMENT'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export class RenderedWorkspaceComment
|
||||
this.workspace = workspace;
|
||||
|
||||
this.view = new CommentView(workspace, this.id);
|
||||
this.view.getEditorFocusableNode().setParent(this);
|
||||
// Set the size to the default size as defined in the superclass.
|
||||
this.view.setSize(this.getSize());
|
||||
this.view.setEditable(this.isEditable());
|
||||
@@ -239,8 +240,8 @@ export class RenderedWorkspaceComment
|
||||
}
|
||||
|
||||
/** Starts a drag on the comment. */
|
||||
startDrag(): void {
|
||||
this.dragStrategy.startDrag();
|
||||
startDrag() {
|
||||
return this.dragStrategy.startDrag();
|
||||
}
|
||||
|
||||
/** Drags the comment to the given location. */
|
||||
@@ -358,4 +359,13 @@ export class RenderedWorkspaceComment
|
||||
canBeFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this comment via keyboard navigation.
|
||||
* Expands the comment and focuses its editor.
|
||||
*/
|
||||
performAction() {
|
||||
this.setCollapsed(false);
|
||||
getFocusManager().focusNode(this.getEditorFocusableNode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ export class WorkspaceComment {
|
||||
|
||||
/** Returns the position of the comment in workspace coordinates. */
|
||||
getRelativeToSurfaceXY(): Coordinate {
|
||||
return this.location;
|
||||
return this.location.clone();
|
||||
}
|
||||
|
||||
/** Disposes of this comment. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as eventUtils from './events/utils.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {ISelectable, isSelectable} from './interfaces/i_selectable.js';
|
||||
import {ShortcutRegistry} from './shortcut_registry.js';
|
||||
import * as deprecation from './utils/deprecation.js';
|
||||
import type {Workspace} from './workspace.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
@@ -58,10 +59,27 @@ export function registerWorkspace(workspace: Workspace) {
|
||||
*
|
||||
* @param workspace
|
||||
*/
|
||||
export function unregisterWorkpace(workspace: Workspace) {
|
||||
export function unregisterWorkspace(workspace: Workspace) {
|
||||
delete WorkspaceDB_[workspace.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a workspace from the workspace db.
|
||||
*
|
||||
* @deprecated v13: use Blockly.common.unregisterWorkspace
|
||||
* @param workspace
|
||||
*/
|
||||
export function unregisterWorkpace(workspace: Workspace) {
|
||||
deprecation.warn(
|
||||
'Blockly.common.unregisterWorkpace',
|
||||
'v13',
|
||||
'v14',
|
||||
'Blockly.common.unregisterWorkspace',
|
||||
);
|
||||
|
||||
unregisterWorkspace(workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* The main workspace most recently used.
|
||||
* Set by Blockly.WorkspaceSvg.prototype.markFocused
|
||||
@@ -86,6 +104,11 @@ export function getMainWorkspace(): Workspace {
|
||||
*/
|
||||
export function setMainWorkspace(workspace: Workspace) {
|
||||
mainWorkspace = workspace;
|
||||
if (workspace.rendered) {
|
||||
getFocusManager().setPopoverFocusRoot(
|
||||
(workspace as WorkspaceSvg).getInjectionDiv(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,8 +164,15 @@ let parentContainer: Element | null;
|
||||
*
|
||||
* @returns The parent container.
|
||||
*/
|
||||
export function getParentContainer(): Element | null {
|
||||
return parentContainer;
|
||||
export function getParentContainer(
|
||||
workspace = getMainWorkspace(),
|
||||
): Element | null {
|
||||
if (parentContainer) return parentContainer;
|
||||
if (workspace && workspace.rendered) {
|
||||
return (workspace as WorkspaceSvg).getInjectionDiv();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {BlockSvg} from './block_svg.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {config} from './config.js';
|
||||
import type {
|
||||
ActionContextMenuOption,
|
||||
ContextMenuOption,
|
||||
LegacyContextMenuOption,
|
||||
} from './contextmenu_registry.js';
|
||||
@@ -25,6 +26,7 @@ import * as aria from './utils/aria.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
import {getShortcutKeysShort} from './utils/shortcut_formatting.js';
|
||||
import * as svgMath from './utils/svg_math.js';
|
||||
import * as WidgetDiv from './widgetdiv.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
@@ -134,7 +136,7 @@ function populate_(
|
||||
continue;
|
||||
}
|
||||
|
||||
const menuItem = new MenuItem(option.text);
|
||||
const menuItem = new MenuItem(makeMenuitem(option));
|
||||
menuItem.setRightToLeft(rtl);
|
||||
menuItem.setRole(aria.Role.MENUITEM);
|
||||
menu.addChild(menuItem);
|
||||
@@ -302,3 +304,48 @@ export function callbackFactory(
|
||||
export function getMenu(): Menu | null {
|
||||
return menu_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a menu item to represent the given context menu option.
|
||||
* For text-based menu options, this wraps the text in a container with its
|
||||
* corresponding keyboard shortcut, if any. HTML-based menu options are displayed
|
||||
* as-is.
|
||||
*
|
||||
* @param option The context menu option to generate a menu item for.
|
||||
* @returns A `MenuItem` representing the given context menu option.
|
||||
*/
|
||||
function makeMenuitem(
|
||||
option: ActionContextMenuOption | LegacyContextMenuOption,
|
||||
) {
|
||||
const text = option.text;
|
||||
if (text && !(text instanceof HTMLElement)) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'blocklyShortcutContainer';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = text;
|
||||
const shortcut = document.createElement('span');
|
||||
shortcut.className = 'blocklyShortcut';
|
||||
shortcut.textContent = ` ${getKeyboardShortcut(option)}`;
|
||||
container.appendChild(label);
|
||||
container.appendChild(shortcut);
|
||||
return container;
|
||||
}
|
||||
|
||||
return option.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a textual representation of the keyboard shortcut for the given
|
||||
* context menu item, if any.
|
||||
*
|
||||
* @param option The context menu item to retrieve a keyboard shortcut for.
|
||||
* @returns A textual representation of the keyboard shortcut registered under
|
||||
* the name stored in the menu option's `associatedKeyboardShortcut` field,
|
||||
* if any.
|
||||
*/
|
||||
function getKeyboardShortcut(
|
||||
option: ContextMenuOption | LegacyContextMenuOption,
|
||||
): string {
|
||||
if (!('id' in option) || !option.associatedKeyboardShortcut) return '';
|
||||
return getShortcutKeysShort(option.associatedKeyboardShortcut);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function registerUndo() {
|
||||
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
|
||||
id: 'undoWorkspace',
|
||||
weight: 1,
|
||||
associatedKeyboardShortcut: 'undo',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(undoOption);
|
||||
}
|
||||
@@ -76,6 +77,7 @@ export function registerRedo() {
|
||||
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
|
||||
id: 'redoWorkspace',
|
||||
weight: 2,
|
||||
associatedKeyboardShortcut: 'redo',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(redoOption);
|
||||
}
|
||||
@@ -103,6 +105,7 @@ export function registerCleanup() {
|
||||
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
|
||||
id: 'cleanWorkspace',
|
||||
weight: 3,
|
||||
associatedKeyboardShortcut: 'cleanup',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(cleanOption);
|
||||
}
|
||||
@@ -349,6 +352,7 @@ export function registerDuplicate() {
|
||||
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
|
||||
id: 'blockDuplicate',
|
||||
weight: 1,
|
||||
associatedKeyboardShortcut: 'duplicate',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(duplicateOption);
|
||||
}
|
||||
@@ -541,12 +545,14 @@ export function registerDelete() {
|
||||
},
|
||||
callback(scope: Scope) {
|
||||
if (scope.block) {
|
||||
getFocusManager().focusNode(scope.block);
|
||||
scope.block.checkAndDelete();
|
||||
}
|
||||
},
|
||||
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
|
||||
id: 'blockDelete',
|
||||
weight: 6,
|
||||
associatedKeyboardShortcut: 'delete',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(deleteOption);
|
||||
}
|
||||
@@ -591,10 +597,12 @@ export function registerCommentDelete() {
|
||||
eventUtils.setGroup(true);
|
||||
scope.comment?.dispose();
|
||||
eventUtils.setGroup(false);
|
||||
scope.comment?.workspace.getAudioManager().play('delete');
|
||||
},
|
||||
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
|
||||
id: 'commentDelete',
|
||||
weight: 6,
|
||||
associatedKeyboardShortcut: 'delete',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(deleteOption);
|
||||
}
|
||||
@@ -615,6 +623,7 @@ export function registerCommentDuplicate() {
|
||||
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
|
||||
id: 'commentDuplicate',
|
||||
weight: 1,
|
||||
associatedKeyboardShortcut: 'duplicate',
|
||||
};
|
||||
ContextMenuRegistry.registry.register(duplicateOption);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export class ContextMenuRegistry {
|
||||
| ContextMenuRegistry.SeparatorContextMenuOption
|
||||
| ContextMenuRegistry.ActionContextMenuOption;
|
||||
menuOption = {
|
||||
id: item.id,
|
||||
scope,
|
||||
weight: item.weight,
|
||||
};
|
||||
@@ -122,6 +123,7 @@ export class ContextMenuRegistry {
|
||||
text: displayText,
|
||||
callback: item.callback,
|
||||
enabled: precondition === 'enabled',
|
||||
associatedKeyboardShortcut: item.associatedKeyboardShortcut,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,6 +190,13 @@ export namespace ContextMenuRegistry {
|
||||
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
|
||||
separator?: never;
|
||||
preconditionFn: (p1: Scope, menuOpenEvent: Event) => string;
|
||||
/**
|
||||
* Identifier used to associate this context menu item with a keyboard
|
||||
* shortcut which will be displayed in the menu as a hint. Should
|
||||
* correspond to the name under which a keyboard shortcut that performs the
|
||||
* same action as this menu item is registered.
|
||||
*/
|
||||
associatedKeyboardShortcut?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,8 +217,10 @@ export namespace ContextMenuRegistry {
|
||||
* Fields common to all context menu items as used by contextmenu.ts.
|
||||
*/
|
||||
export interface CoreContextMenuOption {
|
||||
id: string;
|
||||
scope: Scope;
|
||||
weight: number;
|
||||
associatedKeyboardShortcut?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,3 +287,5 @@ export type RegistryItem = ContextMenuRegistry.RegistryItem;
|
||||
export type ContextMenuOption = ContextMenuRegistry.ContextMenuOption;
|
||||
export type LegacyContextMenuOption =
|
||||
ContextMenuRegistry.LegacyContextMenuOption;
|
||||
export type ActionContextMenuOption =
|
||||
ContextMenuRegistry.ActionContextMenuOption;
|
||||
|
||||
+209
-28
@@ -5,8 +5,9 @@
|
||||
*/
|
||||
|
||||
// Former goog.module ID: Blockly.Css
|
||||
/** Has CSS already been injected? */
|
||||
let injected = false;
|
||||
const injectionSites = new WeakSet<Document | ShadowRoot>();
|
||||
const registeredCss: Array<string> = [];
|
||||
import * as userAgent from './utils/useragent.js';
|
||||
|
||||
/**
|
||||
* Add some CSS to the blob that will be injected later. Allows optional
|
||||
@@ -15,10 +16,7 @@ let injected = false;
|
||||
* @param cssContent Multiline CSS string or an array of single lines of CSS.
|
||||
*/
|
||||
export function register(cssContent: string) {
|
||||
if (injected) {
|
||||
throw Error('CSS already injected');
|
||||
}
|
||||
content += '\n' + cssContent;
|
||||
registeredCss.push(cssContent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,37 +26,51 @@ export function register(cssContent: string) {
|
||||
* b) It speeds up loading by not blocking on a separate HTTP transfer.
|
||||
* c) The CSS content may be made dynamic depending on init options.
|
||||
*
|
||||
* @param container The div or other HTML element into which Blockly was injected.
|
||||
* @param hasCss If false, don't inject CSS (providing CSS becomes the
|
||||
* document's responsibility).
|
||||
* @param pathToMedia Path from page to the Blockly media directory.
|
||||
*/
|
||||
export function inject(hasCss: boolean, pathToMedia: string) {
|
||||
// Only inject the CSS once.
|
||||
if (injected) {
|
||||
return;
|
||||
}
|
||||
injected = true;
|
||||
if (!hasCss) {
|
||||
return;
|
||||
}
|
||||
export function inject(
|
||||
container: HTMLElement,
|
||||
hasCss: boolean,
|
||||
pathToMedia: string,
|
||||
) {
|
||||
if (!hasCss || typeof window === 'undefined') return;
|
||||
|
||||
const root = container.getRootNode() as Document | ShadowRoot;
|
||||
if (injectionSites.has(root)) return;
|
||||
injectionSites.add(root);
|
||||
|
||||
// Strip off any trailing slash (either Unix or Windows).
|
||||
const mediaPath = pathToMedia.replace(/[\\/]$/, '');
|
||||
const cssContent = content.replace(/<<<PATH>>>/g, mediaPath);
|
||||
// Cleanup the collected css content after injecting it to the DOM.
|
||||
content = '';
|
||||
const cssText = [content, ...registeredCss]
|
||||
.join('\n')
|
||||
.replace(/<<<PATH>>>/g, mediaPath);
|
||||
|
||||
// Inject CSS tag at start of head.
|
||||
const cssNode = document.createElement('style');
|
||||
cssNode.id = 'blockly-common-style';
|
||||
const cssTextNode = document.createTextNode(cssContent);
|
||||
cssNode.appendChild(cssTextNode);
|
||||
document.head.insertBefore(cssNode, document.head.firstChild);
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'blockly-common-style';
|
||||
styleEl.textContent = cssText;
|
||||
// Prepend so Blockly's rules sit at the start of the cascade; any user
|
||||
// stylesheet declared later wins by document order. Style elements appended
|
||||
// to the light DOM don't apply inside shadow roots, so for the shadow DOM
|
||||
// case we prepend the style element to the shadow root itself.
|
||||
(root instanceof ShadowRoot ? root : document.head).prepend(styleEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CSS content for Blockly.
|
||||
*/
|
||||
let content = `
|
||||
const content = `
|
||||
:is(
|
||||
.injectionDiv,
|
||||
.blocklyWidgetDiv,
|
||||
.blocklyDropdownDiv,
|
||||
.blocklyTooltipDiv,
|
||||
) * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.blocklySvg {
|
||||
background-color: #fff;
|
||||
outline: none;
|
||||
@@ -109,7 +121,7 @@ let content = `
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
border: 1px solid;
|
||||
border-color: #dadce0;
|
||||
background-color: #fff;
|
||||
@@ -365,6 +377,7 @@ input[type=number] {
|
||||
.blocklyContextMenu {
|
||||
border-radius: 4px;
|
||||
max-height: 100%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.blocklyDropdownMenu {
|
||||
@@ -446,7 +459,7 @@ input[type=number] {
|
||||
|
||||
/* State: selected/checked. */
|
||||
.blocklyMenuItemSelected .blocklyMenuItemCheckbox {
|
||||
background: url(<<<PATH>>>/sprites.png) no-repeat -48px -16px;
|
||||
background: url(<<<PATH>>>/sprites.svg) no-repeat -48px -16px;
|
||||
float: left;
|
||||
margin-left: -24px;
|
||||
width: 16px;
|
||||
@@ -468,6 +481,19 @@ input[type=number] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.blocklyRTL .blocklyMenuItemContent .blocklyShortcutContainer {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.blocklyMenuItemContent .blocklyShortcutContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.blocklyMenuItemContent .blocklyShortcutContainer .blocklyShortcut {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.blocklyBlockDragSurface, .blocklyAnimationLayer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -501,8 +527,163 @@ input[type=number] {
|
||||
.blocklyComment,
|
||||
.blocklyBubble,
|
||||
.blocklyIconGroup,
|
||||
.blocklyTextarea
|
||||
.blocklyTextarea,
|
||||
.blocklyZoom,
|
||||
.blocklyTrash,
|
||||
) {
|
||||
outline: none;
|
||||
}
|
||||
.hiddenForAria {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.injectionDiv {
|
||||
--blockly-active-node-color: #fff200;
|
||||
--blockly-active-tree-color: #1379f6;
|
||||
--blockly-selection-width: 3px;
|
||||
}
|
||||
|
||||
/* Active focus cases: */
|
||||
/* Blocks with active focus. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyActiveFocus:is(.blocklyPath, .blocklyHighlightedConnectionPath),
|
||||
/* Fields with active focus, */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyActiveFocus.blocklyField
|
||||
> .blocklyFieldRect,
|
||||
/* Icons with active focus. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyActiveFocus.blocklyIconGroup
|
||||
> .blocklyIconShape:first-child,
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyActiveFocus
|
||||
> .blocklyFocusRing {
|
||||
stroke: var(--blockly-active-node-color);
|
||||
stroke-width: var(--blockly-selection-width);
|
||||
}
|
||||
|
||||
/* Passive focus cases: */
|
||||
/* Blocks with passive focus except when widget/dropdown div in use. */
|
||||
.blocklyPassiveFocus:is(
|
||||
.blocklyPath:not(.blocklyFlyout .blocklyPath),
|
||||
.blocklyHighlightedConnectionPath
|
||||
),
|
||||
/* Fields with passive focus except when widget/dropdown div in use. */
|
||||
.blocklyKeyboardNavigation:not(
|
||||
:has(
|
||||
.blocklyDropDownDiv:focus-within,
|
||||
.blocklyWidgetDiv:focus-within
|
||||
)
|
||||
)
|
||||
.blocklyPassiveFocus.blocklyField
|
||||
> .blocklyFieldRect,
|
||||
/* Icons with passive focus except when widget/dropdown div in use. */
|
||||
.blocklyKeyboardNavigation:not(
|
||||
:has(
|
||||
.blocklyDropDownDiv:focus-within,
|
||||
.blocklyWidgetDiv:focus-within
|
||||
)
|
||||
)
|
||||
.blocklyPassiveFocus.blocklyIconGroup
|
||||
> .blocklyIconShape:first-child {
|
||||
stroke: var(--blockly-active-node-color);
|
||||
stroke-dasharray: 5px 3px;
|
||||
stroke-width: var(--blockly-selection-width);
|
||||
}
|
||||
|
||||
/* Workaround for unexpectedly hidden connection path due to core style. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyPassiveFocus.blocklyHighlightedConnectionPath {
|
||||
display: unset !important;
|
||||
}
|
||||
|
||||
/* Different ways for toolbox/flyout to be the active tree: */
|
||||
/* Active focus in the flyout. */
|
||||
.blocklyKeyboardNavigation .blocklyFlyout:has(.blocklyActiveFocus),
|
||||
/* Active focus in the toolbox. */
|
||||
.blocklyKeyboardNavigation .blocklyToolbox:has(.blocklyActiveFocus),
|
||||
/* Active focus on the toolbox/flyout. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyActiveFocus:is(.blocklyFlyout, .blocklyToolbox) {
|
||||
outline-offset: calc(var(--blockly-selection-width) * -1);
|
||||
outline: var(--blockly-selection-width) solid
|
||||
var(--blockly-active-tree-color);
|
||||
}
|
||||
|
||||
/* Suppress default outline. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyToolboxCategoryContainer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Different ways for the workspace to be the active tree: */
|
||||
/* Active focus within workspace. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyWorkspace:has(.blocklyActiveFocus)
|
||||
.blocklyWorkspaceFocusRing,
|
||||
/* Active focus within drag layer. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklySvg:has(~ .blocklyBlockDragSurface .blocklyActiveFocus)
|
||||
.blocklyWorkspaceFocusRing,
|
||||
/* Active focus on workspace. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyWorkspace.blocklyActiveFocus
|
||||
.blocklyWorkspaceFocusRing,
|
||||
/* Focus in widget/dropdown div considered to be in workspace. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyWorkspace.blocklyShowingDropDownDiv
|
||||
.blocklyWorkspaceFocusRing,
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyWorkspace.blocklyShowingWidgetDiv
|
||||
.blocklyWorkspaceFocusRing {
|
||||
stroke: var(--blockly-active-tree-color);
|
||||
stroke-width: calc(var(--blockly-selection-width) * 2);
|
||||
}
|
||||
|
||||
/* The workspace itself is the active node. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyWorkspace.blocklyActiveFocus
|
||||
.blocklyWorkspaceSelectionRing {
|
||||
stroke: var(--blockly-active-node-color);
|
||||
stroke-width: var(--blockly-selection-width);
|
||||
}
|
||||
|
||||
/* The workspace itself is the active node. */
|
||||
.blocklyKeyboardNavigation
|
||||
.blocklyBubble.blocklyActiveFocus
|
||||
.blocklyEmboss .blocklyDraggable {
|
||||
stroke: var(--blockly-active-node-color);
|
||||
stroke-width: var(--blockly-selection-width);
|
||||
}
|
||||
/* Flyout buttons and labels */
|
||||
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus,
|
||||
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus {
|
||||
outline: none;
|
||||
}
|
||||
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutLabel.blocklyActiveFocus > .blocklyFlyoutLabelText,
|
||||
.blocklyKeyboardNavigation .blocklyFlyout .blocklyFlyoutButton.blocklyActiveFocus > .blocklyFlyoutButtonBackground {
|
||||
outline-offset: 2px;
|
||||
outline: var(--blockly-selection-width) solid var(--blockly-active-node-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.blocklyDialog {
|
||||
min-width: 300px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 8px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #999;
|
||||
}
|
||||
.blocklyDialogForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 8px;
|
||||
}
|
||||
.blocklyDialogButtonRow {
|
||||
display: flex;
|
||||
flex-direction: ${userAgent.MOBILE || userAgent.APPLE ? 'row-reverse' : 'row'};
|
||||
column-gap: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {DragTarget} from './drag_target.js';
|
||||
import {isDeletable} from './interfaces/i_deletable.js';
|
||||
import type {IDeleteArea} from './interfaces/i_delete_area.js';
|
||||
import type {IDraggable} from './interfaces/i_draggable.js';
|
||||
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
|
||||
|
||||
/**
|
||||
* Abstract class for a component that can delete a block or bubble that is
|
||||
@@ -56,7 +57,10 @@ export class DeleteArea extends DragTarget implements IDeleteArea {
|
||||
* area.
|
||||
*/
|
||||
wouldDelete(element: IDraggable): boolean {
|
||||
if (element instanceof BlockSvg) {
|
||||
// don't delete things if we're doing a keyboard move
|
||||
if (KeyboardMover.mover.isMoving()) {
|
||||
this.updateWouldDelete_(false);
|
||||
} else if (element instanceof BlockSvg) {
|
||||
const block = element;
|
||||
const couldDeleteBlock = !block.getParent() && block.isDeletable();
|
||||
this.updateWouldDelete_(couldDeleteBlock);
|
||||
|
||||
@@ -6,15 +6,24 @@
|
||||
|
||||
// Former goog.module ID: Blockly.dialog
|
||||
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {Msg} from './msg.js';
|
||||
import type {ToastOptions} from './toast.js';
|
||||
import {Toast} from './toast.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
/** Supported types of dialogs. */
|
||||
enum DialogType {
|
||||
ALERT = 1,
|
||||
CONFIRM,
|
||||
PROMPT,
|
||||
}
|
||||
|
||||
/** Tally of the number of dialogs currently on screen. */
|
||||
let activeDialogCount = 0;
|
||||
|
||||
const defaultAlert = function (message: string, opt_callback?: () => void) {
|
||||
window.alert(message);
|
||||
if (opt_callback) {
|
||||
opt_callback();
|
||||
}
|
||||
displayDialog(DialogType.ALERT, message, opt_callback, undefined);
|
||||
};
|
||||
|
||||
let alertImplementation = defaultAlert;
|
||||
@@ -23,7 +32,7 @@ const defaultConfirm = function (
|
||||
message: string,
|
||||
callback: (result: boolean) => void,
|
||||
) {
|
||||
callback(window.confirm(message));
|
||||
displayDialog(DialogType.CONFIRM, message, callback, undefined);
|
||||
};
|
||||
|
||||
let confirmImplementation = defaultConfirm;
|
||||
@@ -33,9 +42,7 @@ const defaultPrompt = function (
|
||||
defaultValue: string,
|
||||
callback: (result: string | null) => void,
|
||||
) {
|
||||
// NOTE TO DEVELOPER: Ephemeral focus doesn't need to be taken for the native
|
||||
// window prompt since it prevents focus from changing while open.
|
||||
callback(window.prompt(message, defaultValue));
|
||||
displayDialog(DialogType.PROMPT, message, callback, defaultValue);
|
||||
};
|
||||
|
||||
let promptImplementation = defaultPrompt;
|
||||
@@ -165,3 +172,108 @@ export function setToast(
|
||||
) {
|
||||
toastImplementation = toastFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog, potentially prompting for user input, and invokes the
|
||||
* provided callback with the response.
|
||||
*/
|
||||
function displayDialog(
|
||||
...[type, message, callback, defaultValue]:
|
||||
| [
|
||||
type: DialogType.PROMPT,
|
||||
message: string,
|
||||
callback: (result: string | null) => void,
|
||||
defaultValue: string | undefined,
|
||||
]
|
||||
| [
|
||||
type: DialogType.CONFIRM,
|
||||
message: string,
|
||||
callback: (result: boolean) => void,
|
||||
defaultValue: undefined,
|
||||
]
|
||||
| [
|
||||
type: DialogType.ALERT,
|
||||
message: string,
|
||||
callback: (() => void) | undefined,
|
||||
defaultValue: undefined,
|
||||
]
|
||||
): void {
|
||||
const OK = 'ok';
|
||||
const CANCEL = 'cancel';
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
const form = document.createElement('form');
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
const buttonRow = document.createElement('div');
|
||||
const ok = document.createElement('button');
|
||||
|
||||
dialog.className = 'blocklyDialog';
|
||||
form.className = 'blocklyDialogForm';
|
||||
label.className = 'blocklyDialogPrompt';
|
||||
buttonRow.className = 'blocklyDialogButtonRow';
|
||||
ok.className = 'blocklyDialogConfirmButton';
|
||||
|
||||
form.setAttribute('method', 'dialog');
|
||||
|
||||
label.textContent = message;
|
||||
label.setAttribute('for', 'blockly-form-input');
|
||||
|
||||
ok.textContent = Msg['DIALOG_OK'];
|
||||
ok.value = OK;
|
||||
|
||||
dialog.appendChild(form);
|
||||
form.appendChild(label);
|
||||
|
||||
if (type === DialogType.PROMPT) {
|
||||
input.id = 'blockly-form-input';
|
||||
input.className = 'blocklyDialogInput';
|
||||
input.type = 'text';
|
||||
input.name = 'input';
|
||||
input.autofocus = true;
|
||||
if (defaultValue) {
|
||||
input.value = defaultValue;
|
||||
}
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
buttonRow.appendChild(ok);
|
||||
|
||||
if (type === DialogType.CONFIRM || type === DialogType.PROMPT) {
|
||||
const cancel = document.createElement('button');
|
||||
cancel.className = 'blocklyDialogCancelButton';
|
||||
cancel.textContent = Msg['DIALOG_CANCEL'];
|
||||
cancel.value = CANCEL;
|
||||
buttonRow.appendChild(cancel);
|
||||
}
|
||||
|
||||
form.appendChild(buttonRow);
|
||||
|
||||
let restoreFocus: (() => void) | undefined;
|
||||
if (activeDialogCount === 0) {
|
||||
restoreFocus = getFocusManager().takeEphemeralFocus(dialog);
|
||||
}
|
||||
|
||||
activeDialogCount++;
|
||||
|
||||
dialog.addEventListener('close', () => {
|
||||
activeDialogCount--;
|
||||
if (!activeDialogCount) {
|
||||
restoreFocus?.();
|
||||
}
|
||||
dialog.remove();
|
||||
switch (type) {
|
||||
case DialogType.CONFIRM:
|
||||
callback(dialog.returnValue === OK);
|
||||
break;
|
||||
case DialogType.PROMPT:
|
||||
callback(dialog.returnValue === OK ? input.value : null);
|
||||
break;
|
||||
case DialogType.ALERT:
|
||||
callback?.();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {IBubble, WorkspaceSvg} from '../blockly.js';
|
||||
import {IDragStrategy} from '../interfaces/i_draggable.js';
|
||||
import type {IBubble} from '../interfaces/i_bubble.js';
|
||||
import type {IDragStrategy} from '../interfaces/i_draggable.js';
|
||||
import * as layers from '../layers.js';
|
||||
import {Coordinate} from '../utils.js';
|
||||
import type {Coordinate} from '../utils.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
export class BubbleDragStrategy implements IDragStrategy {
|
||||
private startLoc: Coordinate | null = null;
|
||||
@@ -21,13 +22,15 @@ export class BubbleDragStrategy implements IDragStrategy {
|
||||
return true;
|
||||
}
|
||||
|
||||
startDrag(): void {
|
||||
startDrag() {
|
||||
this.startLoc = this.bubble.getRelativeToSurfaceXY();
|
||||
this.workspace.setResizesEnabled(false);
|
||||
this.workspace.getLayerManager()?.moveToDragLayer(this.bubble);
|
||||
if (this.bubble.setDragging) {
|
||||
this.bubble.setDragging(true);
|
||||
}
|
||||
|
||||
return this.bubble;
|
||||
}
|
||||
|
||||
drag(newLoc: Coordinate): void {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {RenderedWorkspaceComment} from '../comments.js';
|
||||
import {CommentMove} from '../events/events_comment_move.js';
|
||||
import type {RenderedWorkspaceComment} from '../comments.js';
|
||||
import type {CommentMove} from '../events/events_comment_move.js';
|
||||
import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import {IDragStrategy} from '../interfaces/i_draggable.js';
|
||||
import type {IDragStrategy} from '../interfaces/i_draggable.js';
|
||||
import * as layers from '../layers.js';
|
||||
import {Coordinate} from '../utils.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import type {Coordinate} from '../utils.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
export class CommentDragStrategy implements IDragStrategy {
|
||||
private startLoc: Coordinate | null = null;
|
||||
@@ -30,12 +30,13 @@ export class CommentDragStrategy implements IDragStrategy {
|
||||
);
|
||||
}
|
||||
|
||||
startDrag(): void {
|
||||
startDrag() {
|
||||
this.fireDragStartEvent();
|
||||
this.startLoc = this.comment.getRelativeToSurfaceXY();
|
||||
this.workspace.setResizesEnabled(false);
|
||||
this.workspace.getLayerManager()?.moveToDragLayer(this.comment);
|
||||
this.comment.setDragging(true);
|
||||
return this.comment;
|
||||
}
|
||||
|
||||
drag(newLoc: Coordinate): void {
|
||||
@@ -49,6 +50,7 @@ export class CommentDragStrategy implements IDragStrategy {
|
||||
this.workspace
|
||||
.getLayerManager()
|
||||
?.moveOffDragLayer(this.comment, layers.BLOCK);
|
||||
this.workspace.getAudioManager().play('drop');
|
||||
this.comment.setDragging(false);
|
||||
|
||||
this.comment.snapToGrid();
|
||||
|
||||
@@ -4,39 +4,37 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as blockAnimations from '../block_animations.js';
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {ComponentManager} from '../component_manager.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import {IDeletable, isDeletable} from '../interfaces/i_deletable.js';
|
||||
import {IDeleteArea} from '../interfaces/i_delete_area.js';
|
||||
import {IDragTarget} from '../interfaces/i_drag_target.js';
|
||||
import {IDraggable} from '../interfaces/i_draggable.js';
|
||||
import {IDragger} from '../interfaces/i_dragger.js';
|
||||
import type {IDeletable} from '../interfaces/i_deletable.js';
|
||||
import {isDeletable} from '../interfaces/i_deletable.js';
|
||||
import type {IDeleteArea} from '../interfaces/i_delete_area.js';
|
||||
import type {IDragTarget} from '../interfaces/i_drag_target.js';
|
||||
import {DragDisposition, type IDraggable} from '../interfaces/i_draggable.js';
|
||||
import type {IDragger} from '../interfaces/i_dragger.js';
|
||||
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import * as registry from '../registry.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
export class Dragger implements IDragger {
|
||||
protected startLoc: Coordinate;
|
||||
|
||||
protected dragTarget: IDragTarget | null = null;
|
||||
|
||||
constructor(
|
||||
protected draggable: IDraggable,
|
||||
protected workspace: WorkspaceSvg,
|
||||
) {
|
||||
constructor(protected draggable: IDraggable) {
|
||||
this.startLoc = draggable.getRelativeToSurfaceXY();
|
||||
}
|
||||
|
||||
/** Handles any drag startup. */
|
||||
onDragStart(e: PointerEvent) {
|
||||
onDragStart(e?: PointerEvent | KeyboardEvent) {
|
||||
if (!eventUtils.getGroup()) {
|
||||
eventUtils.setGroup(true);
|
||||
}
|
||||
this.draggable.startDrag(e);
|
||||
this.draggable = this.draggable.startDrag(e);
|
||||
this.startLoc = this.draggable.getRelativeToSurfaceXY();
|
||||
|
||||
return this.draggable;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,27 +43,30 @@ export class Dragger implements IDragger {
|
||||
* @param totalDelta The total amount in pixel coordinates the mouse has moved
|
||||
* since the start of the drag.
|
||||
*/
|
||||
onDrag(e: PointerEvent, totalDelta: Coordinate) {
|
||||
onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) {
|
||||
this.moveDraggable(e, totalDelta);
|
||||
const root = this.getRoot(this.draggable);
|
||||
|
||||
// Must check `wouldDelete` before calling other hooks on drag targets
|
||||
// since we have documented that we would do so.
|
||||
if (isDeletable(root)) {
|
||||
root.setDeleteStyle(this.wouldDeleteDraggable(e, root));
|
||||
if (isDeletable(this.draggable)) {
|
||||
this.draggable.setDeleteStyle(
|
||||
this.wouldDeleteDraggable(
|
||||
this.draggable.getRelativeToSurfaceXY(),
|
||||
this.draggable,
|
||||
),
|
||||
);
|
||||
}
|
||||
this.updateDragTarget(e);
|
||||
this.updateDragTarget(this.draggable.getRelativeToSurfaceXY());
|
||||
}
|
||||
|
||||
/** Updates the drag target under the pointer (if there is one). */
|
||||
protected updateDragTarget(e: PointerEvent) {
|
||||
const newDragTarget = this.workspace.getDragTarget(e);
|
||||
const root = this.getRoot(this.draggable);
|
||||
protected updateDragTarget(coordinate: Coordinate) {
|
||||
const newDragTarget = this.draggable.workspace.getDragTarget(coordinate);
|
||||
if (this.dragTarget !== newDragTarget) {
|
||||
this.dragTarget?.onDragExit(root);
|
||||
newDragTarget?.onDragEnter(root);
|
||||
this.dragTarget?.onDragExit(this.draggable);
|
||||
newDragTarget?.onDragEnter(this.draggable);
|
||||
}
|
||||
newDragTarget?.onDragOver(root);
|
||||
newDragTarget?.onDragOver(this.draggable);
|
||||
this.dragTarget = newDragTarget;
|
||||
}
|
||||
|
||||
@@ -73,7 +74,10 @@ export class Dragger implements IDragger {
|
||||
* Calculates the correct workspace coordinate for the movable and tells
|
||||
* the draggable to go to that location.
|
||||
*/
|
||||
private moveDraggable(e: PointerEvent, totalDelta: Coordinate) {
|
||||
private moveDraggable(
|
||||
e: PointerEvent | KeyboardEvent | undefined,
|
||||
totalDelta: Coordinate,
|
||||
) {
|
||||
const delta = this.pixelsToWorkspaceUnits(totalDelta);
|
||||
const newLoc = Coordinate.sum(this.startLoc, delta);
|
||||
this.draggable.drag(newLoc, e);
|
||||
@@ -84,13 +88,13 @@ export class Dragger implements IDragger {
|
||||
* at the current location.
|
||||
*/
|
||||
protected wouldDeleteDraggable(
|
||||
e: PointerEvent,
|
||||
coordinate: Coordinate,
|
||||
rootDraggable: IDraggable & IDeletable,
|
||||
) {
|
||||
const dragTarget = this.workspace.getDragTarget(e);
|
||||
const dragTarget = this.draggable.workspace.getDragTarget(coordinate);
|
||||
if (!dragTarget) return false;
|
||||
|
||||
const componentManager = this.workspace.getComponentManager();
|
||||
const componentManager = this.draggable.workspace.getComponentManager();
|
||||
const isDeleteArea = componentManager.hasCapability(
|
||||
dragTarget.id,
|
||||
ComponentManager.Capability.DELETE_AREA,
|
||||
@@ -101,34 +105,46 @@ export class Dragger implements IDragger {
|
||||
}
|
||||
|
||||
/** Handles any drag cleanup. */
|
||||
onDragEnd(e: PointerEvent) {
|
||||
onDragEnd(e?: PointerEvent | KeyboardEvent) {
|
||||
const origGroup = eventUtils.getGroup();
|
||||
const dragTarget = this.workspace.getDragTarget(e);
|
||||
const root = this.getRoot(this.draggable);
|
||||
const dragTarget = this.draggable.workspace.getDragTarget(
|
||||
this.draggable.getRelativeToSurfaceXY(),
|
||||
);
|
||||
|
||||
if (dragTarget) {
|
||||
this.dragTarget?.onDrop(root);
|
||||
this.dragTarget?.onDrop(this.draggable);
|
||||
}
|
||||
|
||||
if (this.shouldReturnToStart(e, root)) {
|
||||
let reverted = false;
|
||||
if (
|
||||
this.shouldReturnToStart(
|
||||
this.draggable.getRelativeToSurfaceXY(),
|
||||
this.draggable,
|
||||
)
|
||||
) {
|
||||
reverted = true;
|
||||
this.draggable.revertDrag();
|
||||
}
|
||||
|
||||
const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root);
|
||||
const wouldDelete =
|
||||
isDeletable(this.draggable) &&
|
||||
this.wouldDeleteDraggable(
|
||||
this.draggable.getRelativeToSurfaceXY(),
|
||||
this.draggable,
|
||||
);
|
||||
|
||||
// TODO(#8148): use a generalized API instead of an instanceof check.
|
||||
if (wouldDelete && this.draggable instanceof BlockSvg) {
|
||||
blockAnimations.disposeUiEffect(this.draggable.getRootBlock());
|
||||
}
|
||||
|
||||
this.draggable.endDrag(e);
|
||||
|
||||
if (wouldDelete && isDeletable(root)) {
|
||||
if (wouldDelete && isDeletable(this.draggable)) {
|
||||
this.draggable.endDrag(e, DragDisposition.DELETE);
|
||||
// We want to make sure the delete gets grouped with any possible move
|
||||
// event. In core Blockly this shouldn't happen, but due to a change
|
||||
// in behavior older custom draggables might still clear the group.
|
||||
eventUtils.setGroup(origGroup);
|
||||
root.dispose();
|
||||
this.draggable.dispose();
|
||||
} else {
|
||||
this.draggable.endDrag(
|
||||
e,
|
||||
reverted ? DragDisposition.REVERT : DragDisposition.COMMIT,
|
||||
);
|
||||
}
|
||||
eventUtils.setGroup(false);
|
||||
|
||||
@@ -139,32 +155,37 @@ export class Dragger implements IDragger {
|
||||
}
|
||||
}
|
||||
|
||||
// We need to special case blocks for now so that we look at the root block
|
||||
// instead of the one actually being dragged in most cases.
|
||||
private getRoot(draggable: IDraggable): IDraggable {
|
||||
return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable;
|
||||
/** Handles a drag being reverted. */
|
||||
onDragRevert() {
|
||||
this.draggable.revertDrag();
|
||||
if (isFocusableNode(this.draggable)) {
|
||||
getFocusManager().focusNode(this.draggable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if we should return the draggable to its original location
|
||||
* at the end of the drag.
|
||||
*/
|
||||
protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) {
|
||||
const dragTarget = this.workspace.getDragTarget(e);
|
||||
protected shouldReturnToStart(
|
||||
coordinate: Coordinate,
|
||||
rootDraggable: IDraggable,
|
||||
) {
|
||||
const dragTarget = this.draggable.workspace.getDragTarget(coordinate);
|
||||
if (!dragTarget) return false;
|
||||
return dragTarget.shouldPreventMove(rootDraggable);
|
||||
}
|
||||
|
||||
protected pixelsToWorkspaceUnits(pixelCoord: Coordinate): Coordinate {
|
||||
const result = new Coordinate(
|
||||
pixelCoord.x / this.workspace.scale,
|
||||
pixelCoord.y / this.workspace.scale,
|
||||
pixelCoord.x / this.draggable.workspace.scale,
|
||||
pixelCoord.y / this.draggable.workspace.scale,
|
||||
);
|
||||
if (this.workspace.isMutator) {
|
||||
if (this.draggable.workspace.isMutator) {
|
||||
// If we're in a mutator, its scale is always 1, purely because of some
|
||||
// oddities in our rendering optimizations. The actual scale is the same
|
||||
// as the scale on the parent workspace. Fix that for dragging.
|
||||
const mainScale = this.workspace.options.parentWorkspace!.scale;
|
||||
const mainScale = this.draggable.workspace.options.parentWorkspace!.scale;
|
||||
result.scale(1 / mainScale);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -17,7 +17,9 @@ import * as browserEvents from './browser_events.js';
|
||||
import * as common from './common.js';
|
||||
import type {Field} from './field.js';
|
||||
import {ReturnEphemeralFocus, getFocusManager} from './focus_manager.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
import * as math from './utils/math.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
import type {Size} from './utils/size.js';
|
||||
@@ -48,6 +50,12 @@ export const PADDING_Y = 16;
|
||||
/** Length of animations in seconds. */
|
||||
export const ANIMATION_TIME = 0.25;
|
||||
|
||||
/**
|
||||
* Class applied to the element that is displaying the DropDownDiv, used to
|
||||
* apply focus styles.
|
||||
*/
|
||||
const SHOWING_DROPDOWNDIV_SELECTOR = 'blocklyShowingDropDownDiv';
|
||||
|
||||
/**
|
||||
* Timer for animation out, to be cleared if we need to immediately hide
|
||||
* without disrupting new shows.
|
||||
@@ -127,6 +135,7 @@ export function createDom() {
|
||||
div = document.createElement('div');
|
||||
div.className = 'blocklyDropDownDiv';
|
||||
div.tabIndex = -1;
|
||||
div.id = idGenerator.getNextUniqueId();
|
||||
const parentDiv = common.getParentContainer() || document.body;
|
||||
parentDiv.appendChild(div);
|
||||
|
||||
@@ -151,6 +160,19 @@ export function createDom() {
|
||||
'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's';
|
||||
}
|
||||
|
||||
/**
|
||||
* Deals with the root element that contains this and other popovers losing
|
||||
* focus by returning ephemeral focus if we hold it and hiding the DropDownDiv.
|
||||
*/
|
||||
function handleFocusLoss() {
|
||||
if (returnEphemeralFocus) {
|
||||
returnEphemeralFocus(false);
|
||||
returnEphemeralFocus = null;
|
||||
}
|
||||
|
||||
hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an element to maintain bounds within. Drop-downs will appear
|
||||
* within the box of this element if possible.
|
||||
@@ -370,6 +392,11 @@ export function show<T>(
|
||||
manageEphemeralFocus: boolean,
|
||||
opt_onHide?: () => void,
|
||||
): boolean {
|
||||
getFocusManager().registerPopoverFocusLossHandler(handleFocusLoss);
|
||||
|
||||
const parentDiv = common.getParentContainer();
|
||||
parentDiv?.appendChild(div);
|
||||
|
||||
owner = newOwner as Field;
|
||||
onHide = opt_onHide || null;
|
||||
// Set direction.
|
||||
@@ -381,6 +408,19 @@ export function show<T>(
|
||||
dom.addClass(div, renderedClassName);
|
||||
dom.addClass(div, themeClassName);
|
||||
|
||||
const existingOwnership = aria.getState(
|
||||
mainWorkspace.getFocusableElement(),
|
||||
aria.State.OWNS,
|
||||
);
|
||||
aria.setState(
|
||||
mainWorkspace.getFocusableElement(),
|
||||
aria.State.OWNS,
|
||||
existingOwnership ? [existingOwnership, div.id] : div.id,
|
||||
);
|
||||
mainWorkspace
|
||||
.getFocusableElement()
|
||||
.classList.add(SHOWING_DROPDOWNDIV_SELECTOR);
|
||||
|
||||
// When we change `translate` multiple times in close succession,
|
||||
// Chrome may choose to wait and apply them all at once.
|
||||
// Since we want the translation to initial X, Y to be immediate,
|
||||
@@ -666,6 +706,7 @@ export function hideIfOwner<T>(
|
||||
|
||||
/** Hide the menu, triggering animation. */
|
||||
export function hide() {
|
||||
getFocusManager().unregisterPopoverFocusLossHandler(handleFocusLoss);
|
||||
// Start the animation by setting the translation and fading out.
|
||||
// Reset to (initialX, initialY) - i.e., no translation.
|
||||
div.style.transform = 'translate(0, 0)';
|
||||
@@ -693,15 +734,28 @@ export function hideWithoutAnimation() {
|
||||
onHide();
|
||||
onHide = null;
|
||||
}
|
||||
clearContent();
|
||||
owner = null;
|
||||
|
||||
(common.getMainWorkspace() as WorkspaceSvg).markFocused();
|
||||
const workspace = common.getMainWorkspace() as WorkspaceSvg;
|
||||
const existingOwnership =
|
||||
aria.getState(workspace.getFocusableElement(), aria.State.OWNS) ?? '';
|
||||
aria.setState(
|
||||
workspace.getFocusableElement(),
|
||||
aria.State.OWNS,
|
||||
existingOwnership.replace(div.id, ''),
|
||||
);
|
||||
workspace
|
||||
.getFocusableElement()
|
||||
.classList.remove(SHOWING_DROPDOWNDIV_SELECTOR);
|
||||
|
||||
workspace.markFocused();
|
||||
|
||||
if (returnEphemeralFocus) {
|
||||
returnEphemeralFocus();
|
||||
returnEphemeralFocus = null;
|
||||
}
|
||||
|
||||
clearContent();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -738,17 +792,26 @@ function positionInternal(
|
||||
arrow.style.display = 'none';
|
||||
}
|
||||
|
||||
const initialX = Math.floor(metrics.initialX);
|
||||
const initialY = Math.floor(metrics.initialY);
|
||||
const finalX = Math.floor(metrics.finalX);
|
||||
const finalY = Math.floor(metrics.finalY);
|
||||
let initialX = Math.floor(metrics.initialX);
|
||||
let initialY = Math.floor(metrics.initialY);
|
||||
let finalX = Math.floor(metrics.finalX);
|
||||
let finalY = Math.floor(metrics.finalY);
|
||||
|
||||
const parentElement = div.parentElement;
|
||||
if (parentElement) {
|
||||
const bounds = parentElement.getBoundingClientRect();
|
||||
initialX -= bounds.left + window.scrollX;
|
||||
finalX -= bounds.left + window.scrollX;
|
||||
initialY -= bounds.top + window.scrollY;
|
||||
finalY -= bounds.top + window.scrollY;
|
||||
}
|
||||
|
||||
// First apply initial translation.
|
||||
div.style.left = initialX + 'px';
|
||||
div.style.top = initialY + 'px';
|
||||
|
||||
// Show the div.
|
||||
div.style.display = 'block';
|
||||
div.style.visibility = 'visible';
|
||||
div.style.opacity = '1';
|
||||
// Add final translate, animated through `transition`.
|
||||
// Coordinates are relative to (initialX, initialY),
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
import type {Block} from '../block.js';
|
||||
import * as registry from '../registry.js';
|
||||
import * as blocks from '../serialization/blocks.js';
|
||||
import * as utilsXml from '../utils/xml.js';
|
||||
import {Workspace} from '../workspace.js';
|
||||
import * as Xml from '../xml.js';
|
||||
import {BlockBase, BlockBaseJson} from './events_block_base.js';
|
||||
import {EventType} from './type.js';
|
||||
import * as eventUtils from './utils.js';
|
||||
@@ -28,9 +26,6 @@ import * as eventUtils from './utils.js';
|
||||
export class BlockCreate extends BlockBase {
|
||||
override type = EventType.BLOCK_CREATE;
|
||||
|
||||
/** The XML representation of the created block(s). */
|
||||
xml?: Element | DocumentFragment;
|
||||
|
||||
/** The JSON respresentation of the created block(s). */
|
||||
json?: blocks.State;
|
||||
|
||||
@@ -50,7 +45,6 @@ export class BlockCreate extends BlockBase {
|
||||
this.recordUndo = false;
|
||||
}
|
||||
|
||||
this.xml = Xml.blockToDomWithXY(opt_block);
|
||||
this.ids = eventUtils.getDescendantIds(opt_block);
|
||||
|
||||
this.json = blocks.save(opt_block, {addCoordinates: true}) as blocks.State;
|
||||
@@ -63,12 +57,6 @@ export class BlockCreate extends BlockBase {
|
||||
*/
|
||||
override toJson(): BlockCreateJson {
|
||||
const json = super.toJson() as BlockCreateJson;
|
||||
if (!this.xml) {
|
||||
throw new Error(
|
||||
'The block XML is undefined. Either pass a block to ' +
|
||||
'the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
if (!this.ids) {
|
||||
throw new Error(
|
||||
'The block IDs are undefined. Either pass a block to ' +
|
||||
@@ -81,7 +69,6 @@ export class BlockCreate extends BlockBase {
|
||||
'the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
json['xml'] = Xml.domToText(this.xml);
|
||||
json['ids'] = this.ids;
|
||||
json['json'] = this.json;
|
||||
if (!this.recordUndo) {
|
||||
@@ -109,7 +96,6 @@ export class BlockCreate extends BlockBase {
|
||||
workspace,
|
||||
event ?? new BlockCreate(),
|
||||
) as BlockCreate;
|
||||
newEvent.xml = utilsXml.textToDom(json['xml']);
|
||||
newEvent.ids = json['ids'];
|
||||
newEvent.json = json['json'] as blocks.State;
|
||||
if (json['recordUndo'] !== undefined) {
|
||||
@@ -176,7 +162,6 @@ const allShadowBlocks = function (
|
||||
};
|
||||
|
||||
export interface BlockCreateJson extends BlockBaseJson {
|
||||
xml: string;
|
||||
ids: string[];
|
||||
json: object;
|
||||
recordUndo?: boolean;
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
import type {Block} from '../block.js';
|
||||
import * as registry from '../registry.js';
|
||||
import * as blocks from '../serialization/blocks.js';
|
||||
import * as utilsXml from '../utils/xml.js';
|
||||
import {Workspace} from '../workspace.js';
|
||||
import * as Xml from '../xml.js';
|
||||
import {BlockBase, BlockBaseJson} from './events_block_base.js';
|
||||
import {EventType} from './type.js';
|
||||
import * as eventUtils from './utils.js';
|
||||
@@ -26,9 +24,6 @@ import * as eventUtils from './utils.js';
|
||||
* deleted.
|
||||
*/
|
||||
export class BlockDelete extends BlockBase {
|
||||
/** The XML representation of the deleted block(s). */
|
||||
oldXml?: Element | DocumentFragment;
|
||||
|
||||
/** The JSON respresentation of the deleted block(s). */
|
||||
oldJson?: blocks.State;
|
||||
|
||||
@@ -56,7 +51,6 @@ export class BlockDelete extends BlockBase {
|
||||
this.recordUndo = false;
|
||||
}
|
||||
|
||||
this.oldXml = Xml.blockToDomWithXY(opt_block);
|
||||
this.ids = eventUtils.getDescendantIds(opt_block);
|
||||
this.wasShadow = opt_block.isShadow();
|
||||
this.oldJson = blocks.save(opt_block, {
|
||||
@@ -71,12 +65,6 @@ export class BlockDelete extends BlockBase {
|
||||
*/
|
||||
override toJson(): BlockDeleteJson {
|
||||
const json = super.toJson() as BlockDeleteJson;
|
||||
if (!this.oldXml) {
|
||||
throw new Error(
|
||||
'The old block XML is undefined. Either pass a block ' +
|
||||
'to the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
if (!this.ids) {
|
||||
throw new Error(
|
||||
'The block IDs are undefined. Either pass a block to ' +
|
||||
@@ -95,7 +83,6 @@ export class BlockDelete extends BlockBase {
|
||||
'to the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
json['oldXml'] = Xml.domToText(this.oldXml);
|
||||
json['ids'] = this.ids;
|
||||
json['wasShadow'] = this.wasShadow;
|
||||
json['oldJson'] = this.oldJson;
|
||||
@@ -124,10 +111,8 @@ export class BlockDelete extends BlockBase {
|
||||
workspace,
|
||||
event ?? new BlockDelete(),
|
||||
) as BlockDelete;
|
||||
newEvent.oldXml = utilsXml.textToDom(json['oldXml']);
|
||||
newEvent.ids = json['ids'];
|
||||
newEvent.wasShadow =
|
||||
json['wasShadow'] || newEvent.oldXml.tagName.toLowerCase() === 'shadow';
|
||||
newEvent.wasShadow = json['wasShadow'];
|
||||
newEvent.oldJson = json['oldJson'];
|
||||
if (json['recordUndo'] !== undefined) {
|
||||
newEvent.recordUndo = json['recordUndo'];
|
||||
@@ -172,7 +157,6 @@ export class BlockDelete extends BlockBase {
|
||||
}
|
||||
|
||||
export interface BlockDeleteJson extends BlockBaseJson {
|
||||
oldXml: string;
|
||||
ids: string[];
|
||||
wasShadow: boolean;
|
||||
oldJson: blocks.State;
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
import type {WorkspaceComment} from '../comments/workspace_comment.js';
|
||||
import * as registry from '../registry.js';
|
||||
import * as comments from '../serialization/workspace_comments.js';
|
||||
import * as utilsXml from '../utils/xml.js';
|
||||
import type {Workspace} from '../workspace.js';
|
||||
import * as Xml from '../xml.js';
|
||||
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
|
||||
import {EventType} from './type.js';
|
||||
|
||||
@@ -26,9 +24,6 @@ import {EventType} from './type.js';
|
||||
export class CommentCreate extends CommentBase {
|
||||
override type = EventType.COMMENT_CREATE;
|
||||
|
||||
/** The XML representation of the created workspace comment. */
|
||||
xml?: Element | DocumentFragment;
|
||||
|
||||
/** The JSON representation of the created workspace comment. */
|
||||
json?: comments.State;
|
||||
|
||||
@@ -43,7 +38,6 @@ export class CommentCreate extends CommentBase {
|
||||
return; // Blank event to be populated by fromJson.
|
||||
}
|
||||
|
||||
this.xml = Xml.saveWorkspaceComment(opt_comment);
|
||||
this.json = comments.save(opt_comment, {addCoordinates: true});
|
||||
}
|
||||
|
||||
@@ -55,19 +49,12 @@ export class CommentCreate extends CommentBase {
|
||||
*/
|
||||
override toJson(): CommentCreateJson {
|
||||
const json = super.toJson() as CommentCreateJson;
|
||||
if (!this.xml) {
|
||||
throw new Error(
|
||||
'The comment XML is undefined. Either pass a comment to ' +
|
||||
'the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
if (!this.json) {
|
||||
throw new Error(
|
||||
'The comment JSON is undefined. Either pass a block to ' +
|
||||
'the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
json['xml'] = Xml.domToText(this.xml);
|
||||
json['json'] = this.json;
|
||||
return json;
|
||||
}
|
||||
@@ -91,7 +78,6 @@ export class CommentCreate extends CommentBase {
|
||||
workspace,
|
||||
event ?? new CommentCreate(),
|
||||
) as CommentCreate;
|
||||
newEvent.xml = utilsXml.textToDom(json['xml']);
|
||||
newEvent.json = json['json'];
|
||||
return newEvent;
|
||||
}
|
||||
@@ -107,7 +93,6 @@ export class CommentCreate extends CommentBase {
|
||||
}
|
||||
|
||||
export interface CommentCreateJson extends CommentBaseJson {
|
||||
xml: string;
|
||||
json: object;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
import type {WorkspaceComment} from '../comments/workspace_comment.js';
|
||||
import * as registry from '../registry.js';
|
||||
import * as comments from '../serialization/workspace_comments.js';
|
||||
import * as utilsXml from '../utils/xml.js';
|
||||
import type {Workspace} from '../workspace.js';
|
||||
import * as Xml from '../xml.js';
|
||||
import {CommentBase, CommentBaseJson} from './events_comment_base.js';
|
||||
import {EventType} from './type.js';
|
||||
|
||||
@@ -26,9 +24,6 @@ import {EventType} from './type.js';
|
||||
export class CommentDelete extends CommentBase {
|
||||
override type = EventType.COMMENT_DELETE;
|
||||
|
||||
/** The XML representation of the deleted workspace comment. */
|
||||
xml?: Element;
|
||||
|
||||
/** The JSON representation of the created workspace comment. */
|
||||
json?: comments.State;
|
||||
|
||||
@@ -43,7 +38,6 @@ export class CommentDelete extends CommentBase {
|
||||
return; // Blank event to be populated by fromJson.
|
||||
}
|
||||
|
||||
this.xml = Xml.saveWorkspaceComment(opt_comment);
|
||||
this.json = comments.save(opt_comment, {addCoordinates: true});
|
||||
}
|
||||
|
||||
@@ -63,19 +57,12 @@ export class CommentDelete extends CommentBase {
|
||||
*/
|
||||
override toJson(): CommentDeleteJson {
|
||||
const json = super.toJson() as CommentDeleteJson;
|
||||
if (!this.xml) {
|
||||
throw new Error(
|
||||
'The comment XML is undefined. Either pass a comment to ' +
|
||||
'the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
if (!this.json) {
|
||||
throw new Error(
|
||||
'The comment JSON is undefined. Either pass a block to ' +
|
||||
'the constructor, or call fromJson',
|
||||
);
|
||||
}
|
||||
json['xml'] = Xml.domToText(this.xml);
|
||||
json['json'] = this.json;
|
||||
return json;
|
||||
}
|
||||
@@ -99,14 +86,12 @@ export class CommentDelete extends CommentBase {
|
||||
workspace,
|
||||
event ?? new CommentDelete(),
|
||||
) as CommentDelete;
|
||||
newEvent.xml = utilsXml.textToDom(json['xml']);
|
||||
newEvent.json = json['json'];
|
||||
return newEvent;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentDeleteJson extends CommentBaseJson {
|
||||
xml: string;
|
||||
json: object;
|
||||
}
|
||||
|
||||
|
||||
+190
-19
@@ -28,9 +28,11 @@ import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import type {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js';
|
||||
import type {IRegistrable} from './interfaces/i_registrable.js';
|
||||
import {ISerializable} from './interfaces/i_serializable.js';
|
||||
import {Msg} from './msg.js';
|
||||
import type {ConstantProvider} from './renderers/common/constants.js';
|
||||
import type {KeyboardShortcut} from './shortcut_registry.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import type {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
@@ -64,7 +66,7 @@ export type FieldValidator<T = any> = (newValue: T) => T | null | undefined;
|
||||
/**
|
||||
* Abstract class for an editable field.
|
||||
*
|
||||
* @typeParam T - The value stored on the field.
|
||||
* @template T - The value stored on the field.
|
||||
*/
|
||||
export abstract class Field<T = any>
|
||||
implements IKeyboardAccessible, IRegistrable, ISerializable, IFocusableNode
|
||||
@@ -98,6 +100,9 @@ export abstract class Field<T = any>
|
||||
/** Validation function called when user edits an editable field. */
|
||||
protected validator_: FieldValidator<T> | null = null;
|
||||
|
||||
/** The ARIA-friendly label representation of this field's type. */
|
||||
protected ariaTypeName: string | null = null;
|
||||
|
||||
/**
|
||||
* Used to cache the field's tooltip value if setTooltip is called when the
|
||||
* field is not yet initialized. Is *not* guaranteed to be accurate.
|
||||
@@ -250,6 +255,9 @@ export abstract class Field<T = any>
|
||||
if (config.tooltip) {
|
||||
this.setTooltip(parsing.replaceMessageReferences(config.tooltip));
|
||||
}
|
||||
if (config.ariaTypeName) {
|
||||
this.ariaTypeName = config.ariaTypeName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,7 +276,6 @@ export abstract class Field<T = any>
|
||||
`problems with focus: ${block.id}.`,
|
||||
);
|
||||
}
|
||||
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,6 +307,87 @@ export abstract class Field<T = any>
|
||||
return this.sourceBlock_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or null if it is
|
||||
* unspecified.
|
||||
*/
|
||||
getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Note that implementations should generally always override this value to
|
||||
* ensure a non-null value is returned. The default implementation relies on
|
||||
* 'getText' which may return an empty string. A null return value from this
|
||||
* function will prompt ARIA label generation to skip the field's value
|
||||
* entirely when there may be a better contextual placeholder to use isstead.
|
||||
*
|
||||
* For example, to avoid hiding an empty text input field from screen reader,
|
||||
* implementations should ensure that if the text is an empty string, this
|
||||
* function would return an appropriate, localized value such as "empty text".
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text, or null if no text is
|
||||
* currently defined or known for the field.
|
||||
*/
|
||||
getAriaValue(): string | null {
|
||||
if (this.getValue() == null) {
|
||||
return null;
|
||||
} else {
|
||||
return this.getText();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a descriptive ARIA label to represent this field with configurable
|
||||
* verbosity.
|
||||
*
|
||||
* A 'verbose' label includes type information, if available, whereas a
|
||||
* non-verbose label only contains the field's value.
|
||||
*
|
||||
* Note that this will always return the latest representation of the field's
|
||||
* label which may differ from any previously set ARIA label for the field
|
||||
* itself. Implementations are largely responsible for ensuring that the
|
||||
* field's ARIA label is set correctly at relevant moments in the field's
|
||||
* lifecycle (such as when its value changes).
|
||||
*
|
||||
* Finally, it is never guaranteed that implementations use the label returned
|
||||
* by this method for their actual ARIA label. Some implementations may rely
|
||||
* on other contexts to convey information like the field's value. Example:
|
||||
* checkboxes represent their checked/non-checked status (i.e. value) through
|
||||
* a separate ARIA property.
|
||||
*
|
||||
* If the field's value is empty then it will return a localized placeholder
|
||||
* indicating that its value is empty. If this method returns an empty string,
|
||||
* the output will be ignored when composing the block-level ARIA label. Make
|
||||
* sure you want your label hidden from screenreaders before returning an
|
||||
* empty string.
|
||||
*
|
||||
* @param includeTypeInfo Whether to include the field's type information in
|
||||
* the returned label, if available.
|
||||
*/
|
||||
computeAriaLabel(includeTypeInfo: boolean = true): string {
|
||||
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
|
||||
let ariaValue = this.getAriaValue();
|
||||
if (ariaValue === null || ariaValue === '') {
|
||||
ariaValue = Msg['FIELD_LABEL_EMPTY'];
|
||||
}
|
||||
|
||||
if (ariaTypeName) {
|
||||
return `${ariaTypeName}: ${ariaValue}`;
|
||||
}
|
||||
return ariaValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize everything to render this field. Override
|
||||
* methods initModel and initView rather than this method.
|
||||
@@ -312,11 +400,7 @@ export abstract class Field<T = any>
|
||||
// Field has already been initialized once.
|
||||
return;
|
||||
}
|
||||
const id = this.id_;
|
||||
if (!id) throw new Error('Expected ID to be defined prior to init.');
|
||||
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
|
||||
'id': id,
|
||||
});
|
||||
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
|
||||
if (!this.isVisible()) {
|
||||
this.fieldGroup_.style.display = 'none';
|
||||
}
|
||||
@@ -328,6 +412,15 @@ export abstract class Field<T = any>
|
||||
this.bindEvents_();
|
||||
this.initModel();
|
||||
this.applyColour();
|
||||
|
||||
// Since full-block fields can be focused from the workspace's tree,
|
||||
// they need IDs in the format that the workspace is expecting.
|
||||
if (this.isFullBlockField()) {
|
||||
this.id_ = idGenerator.getNextUniqueId();
|
||||
} else {
|
||||
this.id_ = `${sourceBlockSvg.id}_field_${idGenerator.getNextUniqueId()}`;
|
||||
}
|
||||
this.fieldGroup_.setAttribute('id', this.id_);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,15 +443,19 @@ export abstract class Field<T = any>
|
||||
/**
|
||||
* Defines whether this field should take up the full block or not.
|
||||
*
|
||||
* Be cautious when overriding this function. It may not work as you expect /
|
||||
* intend because the behavior was kind of hacked in. If you are thinking
|
||||
* about overriding this function, post on the forum with your intended
|
||||
* behavior to see if there's another approach.
|
||||
* This is typically only done for certain kinds of fields and in certain
|
||||
* renderers. You should only override this if you're sure your field will
|
||||
* render correctly in zelos and other renderers that support full-block
|
||||
* fields.
|
||||
*
|
||||
* @internal
|
||||
* Blocks that contain only a single field that is a full-block-field
|
||||
* have a special appearance in some renderers and their behavior is
|
||||
* unique, because we pretend that the field is a whole block in some cases.
|
||||
* This is hacky and you should use caution when attempting to do anything
|
||||
* with this method.
|
||||
*/
|
||||
isFullBlockField(): boolean {
|
||||
return !this.borderRect_;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,15 +480,18 @@ export abstract class Field<T = any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a field text element. Not to be overridden by subclasses. Instead
|
||||
* Create a field text element. Not to be overridden by subclasses. Instead,
|
||||
* modify the result of the function inside initView, or create a separate
|
||||
* function to call.
|
||||
* function to call. Aria state is hidden; use the aria label for the field
|
||||
* and/or containing block to expose content to screen readers. Text content
|
||||
* for custom blocks can be set after creation.
|
||||
*/
|
||||
protected createTextElement_() {
|
||||
this.textElement_ = dom.createSvgElement(
|
||||
Svg.TEXT,
|
||||
{
|
||||
'class': 'blocklyText blocklyFieldText',
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
this.fieldGroup_,
|
||||
);
|
||||
@@ -834,7 +934,7 @@ export abstract class Field<T = any>
|
||||
const xOffset =
|
||||
margin !== undefined
|
||||
? margin
|
||||
: !this.isFullBlockField()
|
||||
: this.borderRect_
|
||||
? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING
|
||||
: 0;
|
||||
let totalWidth = xOffset * 2;
|
||||
@@ -845,7 +945,7 @@ export abstract class Field<T = any>
|
||||
contentWidth = dom.getTextWidth(this.textElement_);
|
||||
totalWidth += contentWidth;
|
||||
}
|
||||
if (!this.isFullBlockField()) {
|
||||
if (this.borderRect_) {
|
||||
totalHeight = Math.max(totalHeight, constants!.FIELD_BORDER_RECT_HEIGHT);
|
||||
}
|
||||
|
||||
@@ -942,7 +1042,7 @@ export abstract class Field<T = any>
|
||||
throw new UnattachedFieldError();
|
||||
}
|
||||
|
||||
if (this.isFullBlockField()) {
|
||||
if (!this.borderRect_) {
|
||||
// Browsers are inconsistent in what they return for a bounding box.
|
||||
// - Webkit / Blink: fill-box / object bounding box
|
||||
// - Gecko: stroke-box
|
||||
@@ -960,7 +1060,7 @@ export abstract class Field<T = any>
|
||||
xy.y -= 0.5 * scale;
|
||||
}
|
||||
} else {
|
||||
const bBox = this.borderRect_!.getBoundingClientRect();
|
||||
const bBox = this.borderRect_.getBoundingClientRect();
|
||||
xy = style.getPageOffset(this.borderRect_!);
|
||||
scaledWidth = bBox.width;
|
||||
scaledHeight = bBox.height;
|
||||
@@ -1395,6 +1495,76 @@ export abstract class Field<T = any>
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this field via keyboard navigation.
|
||||
* Shows and focuses the field editor.
|
||||
*/
|
||||
performAction() {
|
||||
this.showEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the aria state and label for this field. Fields are generally hidden
|
||||
* when in blocks in the flyout (except for top-level full-block fields), and
|
||||
* otherwise set to a role of button (indicating they can be clicked to edit)
|
||||
* and given the label returned from their `computeAriaLabel` method.
|
||||
*
|
||||
* Subclasses can override this in order to change the role or label, but they must
|
||||
* ensure they keep the correct behavior for fields in flyout blocks.
|
||||
*
|
||||
* This method will return a boolean indicating if the element is displayed in the
|
||||
* aria tree or not. This can be used by subclasses to determine whether or not
|
||||
* to continue customizing the role and label (hidden elements should not have labels).
|
||||
*
|
||||
* @returns true if the element is in the accessibility tree, false if the aria state is hidden
|
||||
*/
|
||||
protected recomputeAriaContext(): boolean {
|
||||
let focusableElement;
|
||||
try {
|
||||
focusableElement = this.getFocusableElement();
|
||||
} catch {
|
||||
// Just return because the field hasn't been initialized yet.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!focusableElement) return false;
|
||||
|
||||
if (this.getSourceBlock()?.isInFlyout) {
|
||||
const isTopLevelFullBlockField =
|
||||
this.getSourceBlock()?.getFullBlockField() &&
|
||||
!this.getSourceBlock()?.getParent();
|
||||
if (!isTopLevelFullBlockField) {
|
||||
// Fields in the flyout are not generally focusable, so they should
|
||||
// be hidden. An exception is full-block field blocks that don't have
|
||||
// parents, since the block itself defers to the field's focusable element.
|
||||
aria.setState(focusableElement, aria.State.HIDDEN, true);
|
||||
return false;
|
||||
} else {
|
||||
// Top-level full-block fields in the flyout need to have their
|
||||
// roledescription set. This can't happen in the flyout code because
|
||||
// the field hasn't been initialized yet then.
|
||||
// These blocks should also have the rest of the state in this method set.
|
||||
const roleDescription =
|
||||
this.getSourceBlock()?.getAriaRoleDescription() ||
|
||||
Msg['BLOCK_LABEL_VALUE'];
|
||||
aria.setState(
|
||||
focusableElement,
|
||||
aria.State.ROLEDESCRIPTION,
|
||||
roleDescription,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
aria.clearState(focusableElement, aria.State.HIDDEN);
|
||||
// The button role is intended to indicate to users that the field has an
|
||||
// editing mode that can be activated.
|
||||
aria.setRole(focusableElement, aria.Role.BUTTON);
|
||||
|
||||
const label = this.computeAriaLabel(true);
|
||||
aria.setState(focusableElement, aria.State.LABEL, label);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses should reimplement this method to construct their Field
|
||||
* subclass from a JSON arg object.
|
||||
@@ -1417,6 +1587,7 @@ export abstract class Field<T = any>
|
||||
*/
|
||||
export interface FieldConfig {
|
||||
tooltip?: string;
|
||||
ariaTypeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,8 @@ import './events/events_block_change.js';
|
||||
|
||||
import {Field, FieldConfig, FieldValidator} from './field.js';
|
||||
import * as fieldRegistry from './field_registry.js';
|
||||
import {Msg} from './msg.js';
|
||||
import {aria} from './utils.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
|
||||
type BoolString = 'TRUE' | 'FALSE';
|
||||
@@ -111,6 +113,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
|
||||
const textElement = this.getTextElement();
|
||||
dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField');
|
||||
textElement.style.display = this.value_ ? 'block' : 'none';
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
override render_() {
|
||||
@@ -170,6 +173,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
|
||||
if (this.textElement_) {
|
||||
this.textElement_.style.display = this.value_ ? 'block' : 'none';
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,6 +217,39 @@ export class FieldCheckbox extends Field<CheckboxBool> {
|
||||
return !!value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_CHECKBOX'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* The FieldCheckbox implementation is not used for the actual ARIA label of
|
||||
* the field, since the checked state is already included in the ARIA checked
|
||||
* state, but it is used for the ARIA label of its source block.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text.
|
||||
*/
|
||||
override getAriaValue(): string | null {
|
||||
// return null;
|
||||
const checked = this.convertValueToBool(this.value_);
|
||||
return checked
|
||||
? Msg['FIELD_LABEL_CHECKBOX_CHECKED']
|
||||
: Msg['FIELD_LABEL_CHECKBOX_UNCHECKED'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a FieldCheckbox from a JSON arg object.
|
||||
*
|
||||
@@ -228,6 +265,29 @@ export class FieldCheckbox extends Field<CheckboxBool> {
|
||||
// 'override' the static fromJson method.
|
||||
return new this(options.checked, undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes the label and sets additional aria state.
|
||||
*/
|
||||
override recomputeAriaContext(): boolean {
|
||||
const shouldCustomize = super.recomputeAriaContext();
|
||||
if (!shouldCustomize) return false;
|
||||
|
||||
const focusableElement = this.getFocusableElement();
|
||||
|
||||
aria.setState(focusableElement, aria.State.HIDDEN, false);
|
||||
aria.setRole(focusableElement, aria.Role.CHECKBOX);
|
||||
const checked = this.convertValueToBool(this.value_);
|
||||
aria.setState(focusableElement, aria.State.CHECKED, checked);
|
||||
|
||||
// Checkbox fields do not use this.computeAriaLabel(), because the
|
||||
// included 'checked' or 'not checked' state in the ARIA label would
|
||||
// be redundant with the ARIA checked state.
|
||||
const label = this.getAriaTypeName();
|
||||
|
||||
aria.setState(focusableElement, aria.State.LABEL, label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
fieldRegistry.register('field_checkbox', FieldCheckbox);
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as fieldRegistry from './field_registry.js';
|
||||
import {Menu} from './menu.js';
|
||||
import {MenuSeparator} from './menu_separator.js';
|
||||
import {MenuItem} from './menuitem.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
@@ -98,6 +99,12 @@ export class FieldDropdown extends Field<string> {
|
||||
/** The total vertical padding above and below an image. */
|
||||
protected static IMAGE_Y_PADDING = FieldDropdown.IMAGE_Y_OFFSET * 2;
|
||||
|
||||
/**
|
||||
* True once the field’s DOM has been created and it is safe to run ARIA
|
||||
* updates in response to value changes.
|
||||
*/
|
||||
isInitialized: boolean = false;
|
||||
|
||||
/**
|
||||
* @param menuGenerator A non-empty array of options for a dropdown list, or a
|
||||
* function which generates these options. Also accepts Field.SKIP_SETUP
|
||||
@@ -197,6 +204,23 @@ export class FieldDropdown extends Field<string> {
|
||||
dom.addClass(this.fieldGroup_, 'blocklyField');
|
||||
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is hacky way of determining if a dropdown field is a full-block field or not.
|
||||
* The constants that control the border rect are the same ones that determine how we
|
||||
* render full-block dropdown fields. It's a full-block field if it doesn't have the
|
||||
* border rect (and it's a simple reporter block).
|
||||
*
|
||||
* @returns true if this field should be treated as a full-block field
|
||||
*/
|
||||
override isFullBlockField(): boolean {
|
||||
return (
|
||||
!this.shouldAddBorderRect_() &&
|
||||
!!this.getSourceBlock()?.isSimpleReporter()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,6 +319,7 @@ export class FieldDropdown extends Field<string> {
|
||||
}
|
||||
|
||||
this.applyColour();
|
||||
aria.setState(this.getFocusableElement(), aria.State.EXPANDED, true);
|
||||
}
|
||||
|
||||
/** Create the dropdown editor. */
|
||||
@@ -306,6 +331,11 @@ export class FieldDropdown extends Field<string> {
|
||||
const menu = new Menu();
|
||||
menu.setRole(aria.Role.LISTBOX);
|
||||
this.menu_ = menu;
|
||||
aria.setState(
|
||||
this.getFocusableElement(),
|
||||
aria.State.CONTROLS,
|
||||
this.menu_.getId(),
|
||||
);
|
||||
|
||||
const options = this.getOptions(false);
|
||||
this.selectedMenuItem = null;
|
||||
@@ -317,6 +347,7 @@ export class FieldDropdown extends Field<string> {
|
||||
}
|
||||
|
||||
const [label, value] = option;
|
||||
const ariaLabel = this.computeOptionAriaLabel(option, i);
|
||||
const content = (() => {
|
||||
if (isImageProperties(label)) {
|
||||
// Convert ImageProperties to an HTMLImageElement.
|
||||
@@ -327,7 +358,7 @@ export class FieldDropdown extends Field<string> {
|
||||
}
|
||||
return label;
|
||||
})();
|
||||
const menuItem = new MenuItem(content, value);
|
||||
const menuItem = new MenuItem(content, value, ariaLabel);
|
||||
menuItem.setRole(aria.Role.OPTION);
|
||||
menuItem.setRightToLeft(block.RTL);
|
||||
menuItem.setCheckable(true);
|
||||
@@ -350,6 +381,7 @@ export class FieldDropdown extends Field<string> {
|
||||
this.menu_ = null;
|
||||
this.selectedMenuItem = null;
|
||||
this.applyColour();
|
||||
aria.setState(this.getFocusableElement(), aria.State.EXPANDED, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,6 +504,9 @@ export class FieldDropdown extends Field<string> {
|
||||
this.selectedOption = option;
|
||||
}
|
||||
}
|
||||
if (this.isInitialized) {
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -653,7 +688,7 @@ export class FieldDropdown extends Field<string> {
|
||||
typeof HTMLElement !== 'undefined' &&
|
||||
option instanceof HTMLElement
|
||||
) {
|
||||
return option.title ?? option.ariaLabel ?? option.innerText;
|
||||
return option.title || (option.ariaLabel ?? option.innerText);
|
||||
} else if (typeof option === 'string') {
|
||||
return option;
|
||||
}
|
||||
@@ -705,9 +740,16 @@ export class FieldDropdown extends Field<string> {
|
||||
return option;
|
||||
}
|
||||
|
||||
const [label, value] = option;
|
||||
const [label, value, ariaLabel] = option;
|
||||
if (typeof label === 'string') {
|
||||
return [parsing.replaceMessageReferences(label), value];
|
||||
const trimmedLabelOption: MenuOption = [
|
||||
parsing.replaceMessageReferences(label),
|
||||
value,
|
||||
];
|
||||
if (ariaLabel) {
|
||||
trimmedLabelOption.push(parsing.replaceMessageReferences(ariaLabel));
|
||||
}
|
||||
return trimmedLabelOption;
|
||||
}
|
||||
|
||||
hasNonTextContent = true;
|
||||
@@ -716,14 +758,18 @@ export class FieldDropdown extends Field<string> {
|
||||
const imageLabel = isImageProperties(label)
|
||||
? {...label, alt: parsing.replaceMessageReferences(label.alt)}
|
||||
: label;
|
||||
return [imageLabel, value];
|
||||
const imageOptions: MenuOption = [imageLabel, value];
|
||||
if (ariaLabel) {
|
||||
imageOptions.push(parsing.replaceMessageReferences(ariaLabel));
|
||||
}
|
||||
return imageOptions;
|
||||
});
|
||||
|
||||
if (hasNonTextContent || options.length < 2) {
|
||||
return {options: trimmedOptions};
|
||||
}
|
||||
|
||||
const stringOptions = trimmedOptions as [string, string][];
|
||||
const stringOptions = trimmedOptions as [string, string, string][];
|
||||
const stringLabels = stringOptions.map(([label]) => label);
|
||||
|
||||
const shortest = utilsString.shortestStringLength(stringLabels);
|
||||
@@ -762,14 +808,20 @@ export class FieldDropdown extends Field<string> {
|
||||
* @returns A new array with all of the option text trimmed.
|
||||
*/
|
||||
private applyTrim(
|
||||
options: [string, string][],
|
||||
options: [string, string, string?][],
|
||||
prefixLength: number,
|
||||
suffixLength: number,
|
||||
): MenuOption[] {
|
||||
return options.map(([text, value]) => [
|
||||
text.substring(prefixLength, text.length - suffixLength),
|
||||
value,
|
||||
]);
|
||||
return options.map(([text, value, ariaLabel]) => {
|
||||
const trimmedText = text.substring(
|
||||
prefixLength,
|
||||
text.length - suffixLength,
|
||||
);
|
||||
|
||||
return ariaLabel !== undefined
|
||||
? [trimmedText, value, ariaLabel]
|
||||
: [trimmedText, value];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -813,12 +865,132 @@ export class FieldDropdown extends Field<string> {
|
||||
`Invalid option[${i}]: Each FieldDropdown option must have a string
|
||||
label, image description, or HTML element. Found ${option[0]} in: ${option}`,
|
||||
);
|
||||
} else if (option[2] && typeof option[2] !== 'string') {
|
||||
foundError = true;
|
||||
console.error(
|
||||
`Invalid option[${i}]: Each FieldDropdown option ARIA label must be a string.
|
||||
Found ${option[2]} in: ${option}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (foundError) {
|
||||
throw TypeError('Found invalid FieldDropdown options.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_DROPDOWN'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text.
|
||||
*/
|
||||
override getAriaValue(): string {
|
||||
// Note: This fallback is effectively unreachable since computeOptionAriaLabel
|
||||
// always returns a non-empty string for non-separator options. It exists as a
|
||||
// defensive safeguard.
|
||||
return (
|
||||
this.getSelectedAriaLabel() || this.getText() || Msg['FIELD_LABEL_EMPTY']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label for the currently selected dropdown option.
|
||||
*
|
||||
* @returns The computed ARIA label for the selected option, or `null` if no
|
||||
* option is selected.
|
||||
*/
|
||||
private getSelectedAriaLabel(): string | null {
|
||||
if (!this.selectedOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const option = this.selectedOption;
|
||||
const ariaLabel = this.computeOptionAriaLabel(
|
||||
option,
|
||||
this.getOptions(false).indexOf(option),
|
||||
);
|
||||
|
||||
if (typeof ariaLabel === 'string') {
|
||||
return ariaLabel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the default label and sets additional aria state.
|
||||
*/
|
||||
override recomputeAriaContext(): boolean {
|
||||
const shouldCustomize = super.recomputeAriaContext();
|
||||
if (!shouldCustomize) return false;
|
||||
|
||||
const focusableElement = this.getFocusableElement();
|
||||
const label = this.computeAriaLabel(true);
|
||||
|
||||
aria.setState(focusableElement, aria.State.LABEL, label);
|
||||
aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox');
|
||||
aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes an ARIA-friendly label for a dropdown option.
|
||||
*
|
||||
* The label is derived using a prioritized set of sources.
|
||||
*
|
||||
* Returned values are guaranteed to be non-empty strings for all non-separator
|
||||
* options. Whitespace-only values are ignored when determining a usable label.
|
||||
*
|
||||
* @param option The dropdown option for which to compute the ARIA label.
|
||||
* @param index The index of the option within the dropdown (0-based).
|
||||
* @returns A string suitable for use as an ARIA label. Returns an empty string
|
||||
* only if the option is a separator.
|
||||
*/
|
||||
private computeOptionAriaLabel(option: MenuOption, index: number): string {
|
||||
if (option === FieldDropdown.SEPARATOR) return '';
|
||||
|
||||
const [label, , explicitAriaLabel] = option;
|
||||
|
||||
if (typeof explicitAriaLabel === 'string' && explicitAriaLabel.trim()) {
|
||||
return explicitAriaLabel;
|
||||
}
|
||||
|
||||
let text: string | null = null;
|
||||
|
||||
if (isImageProperties(label)) {
|
||||
text = label.ariaLabel ?? label.alt;
|
||||
} else if (
|
||||
typeof HTMLElement !== 'undefined' &&
|
||||
label instanceof HTMLElement
|
||||
) {
|
||||
// This chain is similar to getText_, but prioritizes ariaLabel over title.
|
||||
text = label.ariaLabel ?? (label.title || label.innerText);
|
||||
} else if (typeof label === 'string') {
|
||||
text = label;
|
||||
}
|
||||
|
||||
if (text && text.trim()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// If we can't find any text to use for the ARIA label, use the option index.
|
||||
return Msg['FIELD_LABEL_OPTION_INDEX'].replace('%1', String(index + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -850,6 +1022,7 @@ export interface ImageProperties {
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -860,7 +1033,7 @@ export interface ImageProperties {
|
||||
* the language-neutral value.
|
||||
*/
|
||||
export type MenuOption =
|
||||
| [string | ImageProperties | HTMLElement, string]
|
||||
| [string | ImageProperties | HTMLElement, string, string?]
|
||||
| 'separator';
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
import {Field, FieldConfig} from './field.js';
|
||||
import * as fieldRegistry from './field_registry.js';
|
||||
import {Msg} from './msg.js';
|
||||
import {aria} from './utils.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
import {Size} from './utils/size.js';
|
||||
@@ -111,9 +113,18 @@ export class FieldImage extends Field<string> {
|
||||
|
||||
if (config) {
|
||||
this.configure_(config);
|
||||
} else if (isFieldImageConfig(alt)) {
|
||||
// Block Factory and some hand-written blocks pass a config object as the
|
||||
// fourth argument instead of using the seventh `config` parameter.
|
||||
// This is wrong, and typescript will complain about it, but handle it
|
||||
// for backwards compatibility.
|
||||
this.configure_(alt);
|
||||
} else {
|
||||
this.flipRtl = !!flipRtl;
|
||||
this.altText = parsing.replaceMessageReferences(alt) || '';
|
||||
this.altText =
|
||||
typeof alt === 'string'
|
||||
? parsing.replaceMessageReferences(alt) || ''
|
||||
: '';
|
||||
}
|
||||
this.setValue(parsing.replaceMessageReferences(src));
|
||||
}
|
||||
@@ -157,6 +168,8 @@ export class FieldImage extends Field<string> {
|
||||
if (this.clickHandler) {
|
||||
this.imageElement.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
override updateSize_() {}
|
||||
@@ -186,6 +199,7 @@ export class FieldImage extends Field<string> {
|
||||
if (this.imageElement) {
|
||||
this.imageElement.setAttributeNS(dom.XLINK_NS, 'xlink:href', this.value_);
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,6 +297,104 @@ export class FieldImage extends Field<string> {
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_IMAGE'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text, or null if no text is
|
||||
* currently defined or known for the field.
|
||||
*/
|
||||
override getAriaValue(): string | null {
|
||||
return this.altText || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a descriptive ARIA label to represent this field with configurable
|
||||
* verbosity.
|
||||
*
|
||||
* A 'verbose' label includes type information, if available, whereas a
|
||||
* non-verbose label only contains the field's value.
|
||||
*
|
||||
* Note that this will always return the latest representation of the field's
|
||||
* label which may differ from any previously set ARIA label for the field
|
||||
* itself. Implementations are largely responsible for ensuring that the
|
||||
* field's ARIA label is set correctly at relevant moments in the field's
|
||||
* lifecycle (such as when its value changes).
|
||||
*
|
||||
* Finally, it is never guaranteed that implementations use the label returned
|
||||
* by this method for their actual ARIA label. Some implementations may rely
|
||||
* on other contexts to convey information like the field's value. Example:
|
||||
* checkboxes represent their checked/non-checked status (i.e. value) through
|
||||
* a separate ARIA property.
|
||||
*
|
||||
* Returns an empty string on clickable images (buttons), as we do not want to
|
||||
* include image buttons on the block-level ARIA label. When the button is
|
||||
* focused the label is set in recomputeAriaContext below.
|
||||
*
|
||||
* @param includeTypeInfo Whether to include the field's type information in
|
||||
* the returned label, if available.
|
||||
*/
|
||||
override computeAriaLabel(includeTypeInfo: boolean): string {
|
||||
return this.isClickable() ? '' : super.computeAriaLabel(includeTypeInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes label and sets additional aria state.
|
||||
*/
|
||||
override recomputeAriaContext(): boolean {
|
||||
const shouldCustomize = super.recomputeAriaContext();
|
||||
if (!shouldCustomize) return false;
|
||||
|
||||
const focusableElement = this.getFocusableElement();
|
||||
|
||||
// The button role is intended to indicate to users that the field has an
|
||||
// editing mode that can be activated. The presentation role is used to
|
||||
// prevent screen readers from reading the content or its descendants.
|
||||
// Only clickable image fields are navigable.
|
||||
if (!this.isClickable()) {
|
||||
aria.setRole(focusableElement, aria.Role.PRESENTATION);
|
||||
aria.clearState(focusableElement, aria.State.LABEL);
|
||||
return false;
|
||||
}
|
||||
// For clickable images we need to set the label to the alt text here as
|
||||
// we have overridden the computeAriaLabel to return an empty string. This
|
||||
// will set it at the element level.
|
||||
const label = this.getAriaValue() || '';
|
||||
aria.setState(focusableElement, aria.State.LABEL, label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a value is a FieldImage config object passed in place of alt
|
||||
* text (e.g. `{alt: '*', flipRtl: false}`). You shouldn't do this on purpose,
|
||||
* but the block factory generates block definitions in this format.
|
||||
*
|
||||
* @param value The value to test.
|
||||
*/
|
||||
function isFieldImageConfig(value: unknown): value is FieldImageConfig {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
('alt' in value || 'flipRtl' in value)
|
||||
);
|
||||
}
|
||||
|
||||
fieldRegistry.register('field_image', FieldImage);
|
||||
|
||||
@@ -53,7 +53,7 @@ const MINIMUM_WIDTH = 14;
|
||||
/**
|
||||
* Abstract class for an editable input field.
|
||||
*
|
||||
* @typeParam T - The value stored on the field.
|
||||
* @template T - The value stored on the field.
|
||||
* @internal
|
||||
*/
|
||||
export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
@@ -166,7 +166,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
override initView() {
|
||||
const block = this.getSourceBlock();
|
||||
if (!block) throw new UnattachedFieldError();
|
||||
super.initView();
|
||||
|
||||
if (!this.isFullBlockField()) {
|
||||
// Full-block fields don't get the border-rect element.
|
||||
this.createBorderRect_();
|
||||
}
|
||||
this.createTextElement_();
|
||||
if (this.fieldGroup_) {
|
||||
dom.addClass(this.fieldGroup_, 'blocklyField');
|
||||
}
|
||||
|
||||
if (this.isFullBlockField()) {
|
||||
this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();
|
||||
@@ -175,6 +183,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
if (this.fieldGroup_) {
|
||||
dom.addClass(this.fieldGroup_, 'blocklyInputField');
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
override isFullBlockField(): boolean {
|
||||
@@ -224,6 +233,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
);
|
||||
}
|
||||
}
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,6 +248,7 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
this.isDirty_ = true;
|
||||
this.isTextValid_ = true;
|
||||
this.value_ = newValue;
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,10 +262,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
if (!this.fieldGroup_) return;
|
||||
|
||||
if (!this.isFullBlockField() && this.borderRect_) {
|
||||
this.borderRect_!.style.display = 'block';
|
||||
this.borderRect_.style.display = 'block';
|
||||
this.borderRect_.setAttribute('stroke', block.getColourTertiary());
|
||||
} else {
|
||||
this.borderRect_!.style.display = 'none';
|
||||
// In general, do *not* let fields control the color of blocks. Having the
|
||||
// field control the color is unexpected, and could have performance
|
||||
// impacts.
|
||||
@@ -600,16 +610,20 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
dropDownDiv.hideWithoutAnimation();
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const cursor = this.workspace_?.getCursor();
|
||||
const navigator = this.workspace_?.getNavigator();
|
||||
|
||||
const isValidDestination = (node: IFocusableNode | null) =>
|
||||
(node instanceof FieldInput ||
|
||||
(node instanceof BlockSvg && node.isSimpleReporter())) &&
|
||||
node !== this.getSourceBlock();
|
||||
|
||||
let target = e.shiftKey
|
||||
? cursor?.getPreviousNode(this, isValidDestination, false)
|
||||
: cursor?.getNextNode(this, isValidDestination, false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
let target: IFocusableNode | null | undefined = this;
|
||||
do {
|
||||
target = e.shiftKey
|
||||
? navigator?.getOutNode(target)
|
||||
: navigator?.getInNode(target);
|
||||
} while (target && !isValidDestination(target));
|
||||
target =
|
||||
target instanceof BlockSvg && target.isSimpleReporter()
|
||||
? target.getFields().next().value
|
||||
@@ -625,7 +639,9 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
targetSourceBlock instanceof BlockSvg
|
||||
) {
|
||||
getFocusManager().focusNode(targetSourceBlock);
|
||||
} else getFocusManager().focusNode(target);
|
||||
} else {
|
||||
getFocusManager().focusNode(target);
|
||||
}
|
||||
target.showEditor();
|
||||
}
|
||||
}
|
||||
@@ -702,8 +718,15 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
|
||||
// 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;
|
||||
let x = block.RTL ? bBox.right - div!.offsetWidth : bBox.left;
|
||||
let y = bBox.top;
|
||||
|
||||
const parentElement = div?.parentElement;
|
||||
if (parentElement) {
|
||||
const bounds = parentElement.getBoundingClientRect();
|
||||
x -= bounds.left + window.scrollX;
|
||||
y -= bounds.top + window.scrollY;
|
||||
}
|
||||
|
||||
div!.style.left = `${x}px`;
|
||||
div!.style.top = `${y}px`;
|
||||
@@ -794,6 +817,47 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
|
||||
protected getValueFromEditorText_(text: string): AnyDuringMigration {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_INPUT'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text.
|
||||
*/
|
||||
override getAriaValue(): string | null {
|
||||
return this.getText() || Msg['FIELD_LABEL_EMPTY'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes the label for this field to include "editable" if it applies.
|
||||
*/
|
||||
override recomputeAriaContext(): boolean {
|
||||
const shouldCustomize = super.recomputeAriaContext();
|
||||
if (!shouldCustomize) return false;
|
||||
const focusableElement = this.getFocusableElement();
|
||||
|
||||
let label = this.computeAriaLabel(true);
|
||||
if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) {
|
||||
label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
|
||||
}
|
||||
aria.setState(focusableElement, aria.State.LABEL, label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import {Field, FieldConfig} from './field.js';
|
||||
import * as fieldRegistry from './field_registry.js';
|
||||
import {aria} from './utils.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
|
||||
@@ -76,9 +77,46 @@ export class FieldLabel extends Field<string> {
|
||||
}
|
||||
if (this.fieldGroup_) {
|
||||
dom.addClass(this.fieldGroup_, 'blocklyLabelField');
|
||||
aria.setState(this.fieldGroup_, aria.State.HIDDEN, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a descriptive ARIA label to represent this field with configurable
|
||||
* verbosity.
|
||||
*
|
||||
* A 'verbose' label includes type information, if available, whereas a
|
||||
* non-verbose label only contains the field's value.
|
||||
*
|
||||
* Note that this will always return the latest representation of the field's
|
||||
* label which may differ from any previously set ARIA label for the field
|
||||
* itself. Implementations are largely responsible for ensuring that the
|
||||
* field's ARIA label is set correctly at relevant moments in the field's
|
||||
* lifecycle (such as when its value changes).
|
||||
*
|
||||
* Finally, it is never guaranteed that implementations use the label returned
|
||||
* by this method for their actual ARIA label. Some implementations may rely
|
||||
* on other contexts to convey information like the field's value. Example:
|
||||
* checkboxes represent their checked/non-checked status (i.e. value) through
|
||||
* a separate ARIA property.
|
||||
*
|
||||
* Unlike other built-in fields, FieldLabel does return an empty string when its
|
||||
* value is empty. This is because empty labels are sometimes used for layout
|
||||
* purposes.
|
||||
*
|
||||
* @param includeTypeInfo Whether to include the field's type information in
|
||||
* the returned label, if available.
|
||||
*/
|
||||
override computeAriaLabel(includeTypeInfo: boolean = true): string {
|
||||
const ariaTypeName = includeTypeInfo ? this.getAriaTypeName() : null;
|
||||
const ariaValue = this.getAriaValue() ?? '';
|
||||
|
||||
if (ariaTypeName) {
|
||||
return `${ariaTypeName}: ${ariaValue}`;
|
||||
}
|
||||
return ariaValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the input value casts to a valid string.
|
||||
*
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
FieldInputValidator,
|
||||
} from './field_input.js';
|
||||
import * as fieldRegistry from './field_registry.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
|
||||
/**
|
||||
@@ -299,11 +299,9 @@ export class FieldNumber extends FieldInput<number> {
|
||||
// Set the accessibility state
|
||||
if (this.min_ > -Infinity) {
|
||||
htmlInput.min = `${this.min_}`;
|
||||
aria.setState(htmlInput, aria.State.VALUEMIN, this.min_);
|
||||
}
|
||||
if (this.max_ < Infinity) {
|
||||
htmlInput.max = `${this.max_}`;
|
||||
aria.setState(htmlInput, aria.State.VALUEMAX, this.max_);
|
||||
}
|
||||
return htmlInput;
|
||||
}
|
||||
@@ -341,6 +339,19 @@ export class FieldNumber extends FieldInput<number> {
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_NUMBER'];
|
||||
}
|
||||
}
|
||||
|
||||
fieldRegistry.register('field_number', FieldNumber);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FieldInputValidator,
|
||||
} from './field_input.js';
|
||||
import * as fieldRegistry from './field_registry.js';
|
||||
import {Msg} from './msg.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
|
||||
@@ -89,6 +90,19 @@ export class FieldTextInput extends FieldInput<string> {
|
||||
// override the static fromJson method.
|
||||
return new this(text, undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's type.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's type.
|
||||
*
|
||||
* @returns An ARIA representation of the field's type or a default if it is
|
||||
* unspecified.
|
||||
*/
|
||||
override getAriaTypeName(): string | null {
|
||||
return this.ariaTypeName || Msg['ARIA_TYPE_FIELD_TEXT_INPUT'];
|
||||
}
|
||||
}
|
||||
|
||||
fieldRegistry.register('field_input', FieldTextInput);
|
||||
|
||||
@@ -605,7 +605,7 @@ export class FieldVariable extends FieldDropdown {
|
||||
];
|
||||
}
|
||||
options.push([
|
||||
Msg['RENAME_VARIABLE'],
|
||||
Msg['RENAME_VARIABLE'].replace('%1', name),
|
||||
internalConstants.RENAME_VARIABLE_ID,
|
||||
]);
|
||||
if (Msg['DELETE_VARIABLE']) {
|
||||
@@ -617,6 +617,18 @@ export class FieldVariable extends FieldDropdown {
|
||||
|
||||
return options;
|
||||
}
|
||||
/**
|
||||
* Gets an ARIA-friendly label representation of this field's value.
|
||||
*
|
||||
* Implementations are responsible for, and encouraged to, return a localized
|
||||
* version of the ARIA representation of the field's value.
|
||||
*
|
||||
* @returns An ARIA representation of the field's text.
|
||||
*/
|
||||
override getAriaValue(): string {
|
||||
// Example: 'Variable "i"'
|
||||
return Msg['FIELD_LABEL_VARIABLE'].replace('%1', super.getAriaValue());
|
||||
}
|
||||
}
|
||||
|
||||
fieldRegistry.register('field_variable', FieldVariable);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
// Former goog.module ID: Blockly.Flyout
|
||||
|
||||
import {BlockSvg} from './block_svg.js';
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import {ComponentManager} from './component_manager.js';
|
||||
import {DeleteArea} from './delete_area.js';
|
||||
@@ -20,20 +19,20 @@ import {EventType} from './events/type.js';
|
||||
import * as eventUtils from './events/utils.js';
|
||||
import {FlyoutItem} from './flyout_item.js';
|
||||
import {FlyoutMetricsManager} from './flyout_metrics_manager.js';
|
||||
import {FlyoutNavigator} from './flyout_navigator.js';
|
||||
import {FlyoutSeparator, SeparatorAxis} from './flyout_separator.js';
|
||||
import {getFocusManager} from './focus_manager.js';
|
||||
import {IAutoHideable} from './interfaces/i_autohideable.js';
|
||||
import type {IFlyout} from './interfaces/i_flyout.js';
|
||||
import type {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
|
||||
import {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import {isSelectableToolboxItem} from './interfaces/i_selectable_toolbox_item.js';
|
||||
import {FlyoutNavigator} from './keyboard_nav/navigators/flyout_navigator.js';
|
||||
import {Msg} from './msg.js';
|
||||
import type {Options} from './options.js';
|
||||
import * as registry from './registry.js';
|
||||
import * as renderManagement from './render_management.js';
|
||||
import {ScrollbarPair} from './scrollbar_pair.js';
|
||||
import {SEPARATOR_TYPE} from './separator_flyout_inflater.js';
|
||||
import * as blocks from './serialization/blocks.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as idGenerator from './utils/idgenerator.js';
|
||||
import {Svg} from './utils/svg.js';
|
||||
@@ -45,24 +44,13 @@ import {WorkspaceSvg} from './workspace_svg.js';
|
||||
*/
|
||||
export abstract class Flyout
|
||||
extends DeleteArea
|
||||
implements IAutoHideable, IFlyout, IFocusableNode
|
||||
implements IAutoHideable, IFlyout
|
||||
{
|
||||
/**
|
||||
* Position the flyout.
|
||||
*/
|
||||
abstract position(): void;
|
||||
|
||||
/**
|
||||
* Determine if a drag delta is toward the workspace, based on the position
|
||||
* and orientation of the flyout. This is used in determineDragIntention_ to
|
||||
* determine if a new block should be created or if the flyout should scroll.
|
||||
*
|
||||
* @param currentDragDeltaXY How far the pointer has
|
||||
* moved from the position at mouse down, in pixel units.
|
||||
* @returns True if the drag is toward the workspace.
|
||||
*/
|
||||
abstract isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean;
|
||||
|
||||
/**
|
||||
* Sets the translation of the flyout to match the scrollbars.
|
||||
*
|
||||
@@ -191,29 +179,6 @@ export abstract class Flyout
|
||||
* Height of flyout.
|
||||
*/
|
||||
protected height_ = 0;
|
||||
// clang-format off
|
||||
/**
|
||||
* Range of a drag angle from a flyout considered "dragging toward
|
||||
* workspace". Drags that are within the bounds of this many degrees from
|
||||
* the orthogonal line to the flyout edge are considered to be "drags toward
|
||||
* the workspace".
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```
|
||||
* Flyout Edge Workspace
|
||||
* [block] / <-within this angle, drags "toward workspace" |
|
||||
* [block] ---- orthogonal to flyout boundary ---- |
|
||||
* [block] \ |
|
||||
* ```
|
||||
*
|
||||
* The angle is given in degrees from the orthogonal.
|
||||
*
|
||||
* This is used to know when to create a new block and when to scroll the
|
||||
* flyout. Setting it to 360 means that all drags create a new block.
|
||||
*/
|
||||
// clang-format on
|
||||
protected dragAngleRange_ = 70;
|
||||
|
||||
/**
|
||||
* The path around the background of the flyout, which will be filled with a
|
||||
@@ -327,6 +292,17 @@ export abstract class Flyout
|
||||
.getThemeManager()
|
||||
.subscribe(this.svgBackground_, 'flyoutOpacity', 'fill-opacity');
|
||||
|
||||
this.svgGroup_.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
getFocusManager().focusTree(this.targetWorkspace);
|
||||
if (!this.targetWorkspace.isMutator) {
|
||||
this.autoHide(false);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
return this.svgGroup_;
|
||||
}
|
||||
|
||||
@@ -339,6 +315,7 @@ export abstract class Flyout
|
||||
init(targetWorkspace: WorkspaceSvg) {
|
||||
this.targetWorkspace = targetWorkspace;
|
||||
this.workspace_.targetWorkspace = targetWorkspace;
|
||||
this.workspace_.setInitialAriaContext();
|
||||
|
||||
this.workspace_.scrollbar = new ScrollbarPair(
|
||||
this.workspace_,
|
||||
@@ -622,6 +599,16 @@ export abstract class Flyout
|
||||
* toolbox definition, or a string with the name of the dynamic category.
|
||||
*/
|
||||
show(flyoutDef: toolbox.FlyoutDefinition | string) {
|
||||
// As part of showing, the existing contents of the flyout will be cleared.
|
||||
// If the focused element is a flyout item, i.e. a child of the workspace
|
||||
// and not the workspace itself, move focus to the workspace to prevent
|
||||
// focus from being lost when the currently focused element is destroyed.
|
||||
if (
|
||||
getFocusManager().getFocusedTree() === this.workspace_ &&
|
||||
getFocusManager().getFocusedNode() !== this.workspace_
|
||||
) {
|
||||
getFocusManager().focusNode(this.workspace_);
|
||||
}
|
||||
this.workspace_.setResizesEnabled(false);
|
||||
eventUtils.setRecordUndo(false);
|
||||
this.hide();
|
||||
@@ -649,6 +636,7 @@ export abstract class Flyout
|
||||
this.width_ = 0;
|
||||
}
|
||||
this.reflow();
|
||||
this.updateAriaContext();
|
||||
eventUtils.setRecordUndo(true);
|
||||
this.workspace_.setResizesEnabled(true);
|
||||
|
||||
@@ -667,6 +655,53 @@ export abstract class Flyout
|
||||
this.workspace_.addChangeListener(this.reflowWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the aria attributes for the entire flyout dom.
|
||||
* This needs to do two things:
|
||||
* 1. Set aria-owns on the flyout's workspace canvas to include the ids of all
|
||||
* focusable elements in the flyout.
|
||||
* 2. Update the aria attributes on the flyout's workspace. This can't be done at workspace
|
||||
* creation because the workspace may not have all required information until the flyout
|
||||
* is fully shown.
|
||||
*/
|
||||
protected updateAriaContext() {
|
||||
// Set aria-owns on the flyout's workspace canvas to include the ids of all focusable elements in the flyout.
|
||||
// This is probably not necessary if the listitems are all direct descendants of the canvas, but
|
||||
// we can't know the dom structure of the flyout contents, so it's best to be explicit.
|
||||
const focusableIds = this.getContents()
|
||||
.map((item) => item.getElement())
|
||||
.filter((item) => item.canBeFocused())
|
||||
.map((item) => item.getFocusableElement().id);
|
||||
aria.setState(
|
||||
this.getWorkspace().getCanvas(),
|
||||
aria.State.OWNS,
|
||||
focusableIds.join(' '),
|
||||
);
|
||||
|
||||
// Update aria attributes on the flyout's workspace.
|
||||
// Only call a flyout's workspace a region if it's not auto-closing and not a mutator
|
||||
if (!this.targetWorkspace.isMutator && !this.autoClose) {
|
||||
aria.setRole(this.getWorkspace().svgGroup_, aria.Role.REGION);
|
||||
} else {
|
||||
aria.setRole(this.getWorkspace().svgGroup_, aria.Role.PRESENTATION);
|
||||
}
|
||||
|
||||
// the label for a flyout includes the category name if it's available
|
||||
const selectedItem = this.targetWorkspace.getToolbox()?.getSelectedItem();
|
||||
const selectedItemName =
|
||||
selectedItem && isSelectableToolboxItem(selectedItem)
|
||||
? selectedItem.getName()
|
||||
: '';
|
||||
const ariaLabel = Msg['WORKSPACE_LABEL_FLYOUT_WORKSPACE']
|
||||
.replace('%1', selectedItemName)
|
||||
.trim();
|
||||
aria.setState(this.getWorkspace().getCanvas(), aria.State.LABEL, ariaLabel);
|
||||
|
||||
// The block canvas is a list. The list items must be direct descendants of the list,
|
||||
// and the flyout may or may not be a region, so we set the role on the block canvas rather than the svgGroup_.
|
||||
aria.setRole(this.getWorkspace().getCanvas(), aria.Role.LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the contents array and gaps array necessary to create the layout for
|
||||
* the flyout.
|
||||
@@ -790,47 +825,6 @@ export abstract class Flyout
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this flyout allow you to create a new instance of the given block?
|
||||
* Used for deciding if a block can be "dragged out of" the flyout.
|
||||
*
|
||||
* @param block The block to copy from the flyout.
|
||||
* @returns True if you can create a new instance of the block, false
|
||||
* otherwise.
|
||||
* @internal
|
||||
*/
|
||||
isBlockCreatable(block: BlockSvg): boolean {
|
||||
return block.isEnabled() && !this.getTargetWorkspace().isReadOnly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy of this block on the workspace.
|
||||
*
|
||||
* @param originalBlock The block to copy from the flyout.
|
||||
* @returns The newly created block.
|
||||
* @throws {Error} if something went wrong with deserialization.
|
||||
* @internal
|
||||
*/
|
||||
createBlock(originalBlock: BlockSvg): BlockSvg {
|
||||
const targetWorkspace = this.targetWorkspace;
|
||||
const svgRootOld = originalBlock.getSvgRoot();
|
||||
if (!svgRootOld) {
|
||||
throw Error('oldBlock is not rendered');
|
||||
}
|
||||
|
||||
// Clone the block.
|
||||
const json = this.serializeBlock(originalBlock);
|
||||
// Normally this resizes leading to weird jumps. Save it for terminateDrag.
|
||||
targetWorkspace.setResizesEnabled(false);
|
||||
const block = blocks.appendInternal(json, targetWorkspace, {
|
||||
recordUndo: true,
|
||||
}) as BlockSvg;
|
||||
|
||||
this.positionNewBlock(originalBlock, block);
|
||||
targetWorkspace.hideChaff();
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflow flyout contents.
|
||||
*/
|
||||
@@ -851,59 +845,6 @@ export abstract class Flyout
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a block to JSON.
|
||||
*
|
||||
* @param block The block to serialize.
|
||||
* @returns A serialized representation of the block.
|
||||
*/
|
||||
protected serializeBlock(block: BlockSvg): blocks.State {
|
||||
return blocks.save(block) as blocks.State;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions a block on the target workspace.
|
||||
*
|
||||
* @param oldBlock The flyout block being copied.
|
||||
* @param block The block to posiiton.
|
||||
*/
|
||||
private positionNewBlock(oldBlock: BlockSvg, block: BlockSvg) {
|
||||
const targetWorkspace = this.targetWorkspace;
|
||||
|
||||
// The offset in pixels between the main workspace's origin and the upper
|
||||
// left corner of the injection div.
|
||||
const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels();
|
||||
|
||||
// The offset in pixels between the flyout workspace's origin and the upper
|
||||
// left corner of the injection div.
|
||||
const flyoutOffsetPixels = this.workspace_.getOriginOffsetInPixels();
|
||||
|
||||
// The position of the old block in flyout workspace coordinates.
|
||||
const oldBlockPos = oldBlock.getRelativeToSurfaceXY();
|
||||
// The position of the old block in pixels relative to the flyout
|
||||
// workspace's origin.
|
||||
oldBlockPos.scale(this.workspace_.scale);
|
||||
|
||||
// The position of the old block in pixels relative to the upper left corner
|
||||
// of the injection div.
|
||||
const oldBlockOffsetPixels = Coordinate.sum(
|
||||
flyoutOffsetPixels,
|
||||
oldBlockPos,
|
||||
);
|
||||
|
||||
// The position of the old block in pixels relative to the origin of the
|
||||
// main workspace.
|
||||
const finalOffset = Coordinate.difference(
|
||||
oldBlockOffsetPixels,
|
||||
mainOffsetPixels,
|
||||
);
|
||||
// The position of the old block in main workspace coordinates.
|
||||
finalOffset.scale(1 / targetWorkspace.scale);
|
||||
|
||||
// No 'reason' provided since events are disabled.
|
||||
block.moveTo(new Coordinate(finalOffset.x, finalOffset.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the inflater responsible for constructing items of the given type.
|
||||
*
|
||||
@@ -928,86 +869,4 @@ export abstract class Flyout
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.getFocusableElement.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
getFocusableElement(): HTMLElement | SVGElement {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.getFocusableTree.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
getFocusableTree(): IFocusableTree {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
|
||||
/** See IFocusableNode.onNodeFocus. */
|
||||
onNodeFocus(): void {}
|
||||
|
||||
/** See IFocusableNode.onNodeBlur. */
|
||||
onNodeBlur(): void {}
|
||||
|
||||
/** See IFocusableNode.canBeFocused. */
|
||||
canBeFocused(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.getRootFocusableNode.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
getRootFocusableNode(): IFocusableNode {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.getRestoredFocusableNode.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
getRestoredFocusableNode(
|
||||
_previousNode: IFocusableNode | null,
|
||||
): IFocusableNode | null {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.getNestedTrees.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
getNestedTrees(): Array<IFocusableTree> {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.lookUpFocusableNode.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
lookUpFocusableNode(_id: string): IFocusableNode | null {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
|
||||
/** See IFocusableTree.onTreeFocus. */
|
||||
onTreeFocus(
|
||||
_node: IFocusableNode,
|
||||
_previousTree: IFocusableTree | null,
|
||||
): void {}
|
||||
|
||||
/**
|
||||
* See IFocusableNode.onTreeBlur.
|
||||
*
|
||||
* @deprecated v12: Use the Flyout's workspace for focus operations, instead.
|
||||
*/
|
||||
onTreeBlur(_nextTree: IFocusableTree | null): void {
|
||||
throw new Error('Flyouts are not directly focusable.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
|
||||
import * as browserEvents from './browser_events.js';
|
||||
import * as Css from './css.js';
|
||||
import * as hints from './hints.js';
|
||||
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
||||
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
||||
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
||||
import type {IRenderedElement} from './interfaces/i_rendered_element.js';
|
||||
import {idGenerator} from './utils.js';
|
||||
import {Msg} from './msg.js';
|
||||
import {aria, idGenerator} from './utils.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import * as parsing from './utils/parsing.js';
|
||||
@@ -134,6 +136,7 @@ export class FlyoutButton
|
||||
},
|
||||
this.svgGroup!,
|
||||
);
|
||||
aria.setRole(shadow, aria.Role.PRESENTATION);
|
||||
}
|
||||
// Background rectangle.
|
||||
const rect = dom.createSvgElement(
|
||||
@@ -147,6 +150,7 @@ export class FlyoutButton
|
||||
},
|
||||
this.svgGroup!,
|
||||
);
|
||||
aria.setRole(rect, aria.Role.PRESENTATION);
|
||||
|
||||
const svgText = dom.createSvgElement(
|
||||
Svg.TEXT,
|
||||
@@ -170,6 +174,13 @@ export class FlyoutButton
|
||||
.getThemeManager()
|
||||
.subscribe(this.svgText, 'flyoutForegroundColour', 'fill');
|
||||
}
|
||||
aria.setRole(svgText, aria.Role.PRESENTATION);
|
||||
|
||||
// We add the word "heading" or "button" to the label so that they give appropriate hints
|
||||
// we can't use the corresponding roles because that overwrites the context of it being a list item.
|
||||
const ariaLabel = `${text}, ${this.isFlyoutLabel ? Msg['ARIA_LABEL_HEADING'] : Msg['ARIA_LABEL_BUTTON']}`;
|
||||
aria.setState(this.getFocusableElement(), aria.State.LABEL, ariaLabel);
|
||||
aria.setRole(this.getFocusableElement(), aria.Role.LISTITEM);
|
||||
|
||||
const fontSize = style.getComputedStyle(svgText, 'fontSize');
|
||||
const fontWeight = style.getComputedStyle(svgText, 'fontWeight');
|
||||
@@ -414,6 +425,30 @@ export class FlyoutButton
|
||||
canBeFocused(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of this FlyoutButton.
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this button via keyboard navigation.
|
||||
* Invokes the click handler callback for buttons. For labels, which are not
|
||||
* interactive, shows a toast directing the user to navigate using the arrow
|
||||
* keys or the next-heading shortcut.
|
||||
*/
|
||||
performAction(): void {
|
||||
if (this.isFlyoutLabel) {
|
||||
hints.showFlyoutLabelActionHint(this.targetWorkspace);
|
||||
return;
|
||||
}
|
||||
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
|
||||
if (callback) {
|
||||
callback(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CSS for buttons and labels. See css.js for use. */
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {FlyoutItem} from './flyout_item.js';
|
||||
import type {Options} from './options.js';
|
||||
import * as registry from './registry.js';
|
||||
import {Scrollbar} from './scrollbar.js';
|
||||
import type {Coordinate} from './utils/coordinate.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
import * as toolbox from './utils/toolbox.js';
|
||||
import * as WidgetDiv from './widgetdiv.js';
|
||||
@@ -271,32 +270,6 @@ export class HorizontalFlyout extends Flyout {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a drag delta is toward the workspace, based on the position
|
||||
* and orientation of the flyout. This is used in determineDragIntention_ to
|
||||
* determine if a new block should be created or if the flyout should scroll.
|
||||
*
|
||||
* @param currentDragDeltaXY How far the pointer has moved from the position
|
||||
* at mouse down, in pixel units.
|
||||
* @returns True if the drag is toward the workspace.
|
||||
*/
|
||||
override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean {
|
||||
const dx = currentDragDeltaXY.x;
|
||||
const dy = currentDragDeltaXY.y;
|
||||
// Direction goes from -180 to 180, with 0 toward the right and 90 on top.
|
||||
const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180;
|
||||
|
||||
const range = this.dragAngleRange_;
|
||||
// Check for up or down dragging.
|
||||
if (
|
||||
(dragDirection < 90 + range && dragDirection > 90 - range) ||
|
||||
(dragDirection > -90 - range && dragDirection < -90 + range)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounding rectangle of the drag target area in pixel units
|
||||
* relative to viewport.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFlyout} from './interfaces/i_flyout.js';
|
||||
import {FlyoutButtonNavigationPolicy} from './keyboard_nav/flyout_button_navigation_policy.js';
|
||||
import {FlyoutNavigationPolicy} from './keyboard_nav/flyout_navigation_policy.js';
|
||||
import {FlyoutSeparatorNavigationPolicy} from './keyboard_nav/flyout_separator_navigation_policy.js';
|
||||
import {Navigator} from './navigator.js';
|
||||
|
||||
export class FlyoutNavigator extends Navigator {
|
||||
constructor(flyout: IFlyout) {
|
||||
super();
|
||||
this.rules.push(
|
||||
new FlyoutButtonNavigationPolicy(),
|
||||
new FlyoutSeparatorNavigationPolicy(),
|
||||
);
|
||||
this.rules = this.rules.map(
|
||||
(rule) => new FlyoutNavigationPolicy(rule, flyout),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import type {FlyoutItem} from './flyout_item.js';
|
||||
import type {Options} from './options.js';
|
||||
import * as registry from './registry.js';
|
||||
import {Scrollbar} from './scrollbar.js';
|
||||
import type {Coordinate} from './utils/coordinate.js';
|
||||
import {Rect} from './utils/rect.js';
|
||||
import * as toolbox from './utils/toolbox.js';
|
||||
import * as WidgetDiv from './widgetdiv.js';
|
||||
@@ -235,33 +234,6 @@ export class VerticalFlyout extends Flyout {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a drag delta is toward the workspace, based on the position
|
||||
* and orientation of the flyout. This is used in determineDragIntention_ to
|
||||
* determine if a new block should be created or if the flyout should scroll.
|
||||
*
|
||||
* @param currentDragDeltaXY How far the pointer has moved from the position
|
||||
* at mouse down, in pixel units.
|
||||
* @returns True if the drag is toward the workspace.
|
||||
*/
|
||||
override isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean {
|
||||
const dx = currentDragDeltaXY.x;
|
||||
const dy = currentDragDeltaXY.y;
|
||||
// Direction goes from -180 to 180, with 0 toward the right and 90 on top.
|
||||
const dragDirection = (Math.atan2(dy, dx) / Math.PI) * 180;
|
||||
|
||||
const range = this.dragAngleRange_;
|
||||
// Check for left or right dragging.
|
||||
if (
|
||||
(dragDirection < range && dragDirection > -range) ||
|
||||
dragDirection < -180 + range ||
|
||||
dragDirection > 180 - range
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounding rectangle of the drag target area in pixel units
|
||||
* relative to viewport.
|
||||
|
||||
@@ -11,11 +11,15 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
|
||||
|
||||
/**
|
||||
* Type declaration for returning focus to FocusManager upon completing an
|
||||
* ephemeral UI flow (such as a dialog).
|
||||
* ephemeral UI flow (such as a dialog). Normally, the FocusManager will refocus
|
||||
* the previously-focused element. If callers do not wish for the FocusManager
|
||||
* to do so, they may call this method with `restoreFocus` set to false to
|
||||
* prevent automatic refocusing and leave focus where it is.
|
||||
*
|
||||
*
|
||||
* See FocusManager.takeEphemeralFocus for more details.
|
||||
*/
|
||||
export type ReturnEphemeralFocus = () => void;
|
||||
export type ReturnEphemeralFocus = (restoreFocus?: boolean) => void;
|
||||
|
||||
/**
|
||||
* Represents an IFocusableTree that has been registered for focus management in
|
||||
@@ -83,6 +87,33 @@ export class FocusManager {
|
||||
private recentlyLostAllFocus: boolean = false;
|
||||
private isUpdatingFocusedNode: boolean = false;
|
||||
|
||||
/**
|
||||
* Root element in which popovers (WidgetDiv, DropDownDiv) currently live.
|
||||
*/
|
||||
private popoverFocusRoot?: HTMLElement;
|
||||
|
||||
/**
|
||||
* Set of callbacks to invoke if the popover focus root loses focus.
|
||||
*/
|
||||
private popoverFocusLossHandlers: Set<() => void> = new Set();
|
||||
|
||||
/**
|
||||
* Handler for focusout in the popover focus root that selectively
|
||||
* invokes the popover focus loss handlers if focus has truly transitioned
|
||||
* outside of the focus root, and not e.g. to a different popover.
|
||||
*/
|
||||
private popoverFocusOutHandler = (e: FocusEvent) => {
|
||||
const target = e.relatedTarget;
|
||||
if (
|
||||
target === null ||
|
||||
(target instanceof Node && !this.popoverFocusRoot?.contains(target))
|
||||
) {
|
||||
for (const handler of this.popoverFocusLossHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
addGlobalEventListener: (type: string, listener: EventListener) => void,
|
||||
) {
|
||||
@@ -446,7 +477,7 @@ export class FocusManager {
|
||||
focusableElement.focus({preventScroll: true});
|
||||
|
||||
let hasFinishedEphemeralFocus = false;
|
||||
return () => {
|
||||
return (restoreFocus = true) => {
|
||||
if (hasFinishedEphemeralFocus) {
|
||||
throw Error(
|
||||
`Attempted to finish ephemeral focus twice for element: ` +
|
||||
@@ -455,8 +486,7 @@ export class FocusManager {
|
||||
}
|
||||
hasFinishedEphemeralFocus = true;
|
||||
this.currentlyHoldsEphemeralFocus = false;
|
||||
|
||||
if (this.focusedNode) {
|
||||
if (this.focusedNode && restoreFocus) {
|
||||
this.activelyFocusNode(this.focusedNode, null);
|
||||
|
||||
// Even though focus was restored, check if it's lost again. It's
|
||||
@@ -667,6 +697,50 @@ export class FocusManager {
|
||||
}
|
||||
return FocusManager.focusManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current popover focus root. Generally this is active
|
||||
* workspace's injection div or the explicitly specified parent container for
|
||||
* the WidgetDiv, DropDownDiv, etc.
|
||||
*
|
||||
* @internal
|
||||
* @param newRoot The new element that contains all popovers.
|
||||
*/
|
||||
setPopoverFocusRoot(newRoot: HTMLElement) {
|
||||
this.popoverFocusRoot?.removeEventListener(
|
||||
'focusout',
|
||||
this.popoverFocusOutHandler,
|
||||
);
|
||||
this.popoverFocusRoot = newRoot;
|
||||
this.popoverFocusRoot.addEventListener(
|
||||
'focusout',
|
||||
this.popoverFocusOutHandler,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback to be invoked if the popover focus root loses
|
||||
* focus. This should only be called by popovers that need to react to
|
||||
* focus changes by e.g. hiding themselves and resigning ephemeral focus.
|
||||
*
|
||||
* @internal
|
||||
* @param handler A callback function.
|
||||
*/
|
||||
registerPopoverFocusLossHandler(handler: () => void) {
|
||||
this.popoverFocusLossHandlers.add(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a previously-registered popover focus loss handler. This
|
||||
* should only be invoked by popovers when they no longer need to be
|
||||
* notified of focus loss, typically when they are hidden.
|
||||
*
|
||||
* @internal
|
||||
* @param handler A previously-registered callback function.
|
||||
*/
|
||||
unregisterPopoverFocusLossHandler(handler: () => void) {
|
||||
this.popoverFocusLossHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience function for FocusManager.getFocusManager. */
|
||||
|
||||
@@ -34,9 +34,11 @@ import type {IFlyout} from './interfaces/i_flyout.js';
|
||||
import type {IIcon} from './interfaces/i_icon.js';
|
||||
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
|
||||
import * as registry from './registry.js';
|
||||
import * as blocks from './serialization/blocks.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import * as Touch from './touch.js';
|
||||
import {Coordinate} from './utils/coordinate.js';
|
||||
import * as svgMath from './utils/svg_math.js';
|
||||
import {WorkspaceDragger} from './workspace_dragger.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
@@ -255,40 +257,6 @@ export class Gesture {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this gesture to record whether a block is being dragged from the
|
||||
* flyout.
|
||||
* This function should be called on a pointermove event the first time
|
||||
* the drag radius is exceeded. It should be called no more than once per
|
||||
* gesture. If a block should be dragged from the flyout this function creates
|
||||
* the new block on the main workspace and updates targetBlock_ and
|
||||
* startWorkspace_.
|
||||
*
|
||||
* @returns True if a block is being dragged from the flyout.
|
||||
*/
|
||||
private updateIsDraggingFromFlyout(): boolean {
|
||||
if (!this.targetBlock || !this.flyout?.isBlockCreatable(this.targetBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.flyout.targetWorkspace) {
|
||||
throw new Error(`Cannot update dragging from the flyout because the ' +
|
||||
'flyout's target workspace is undefined`);
|
||||
}
|
||||
|
||||
this.startWorkspace_ = this.flyout.targetWorkspace;
|
||||
this.startWorkspace_.updateScreenCalculationsIfScrolled();
|
||||
// Start the event group now, so that the same event group is used for
|
||||
// block creation and block dragging.
|
||||
if (!eventUtils.getGroup()) {
|
||||
eventUtils.setGroup(true);
|
||||
}
|
||||
// The start block is no longer relevant, because this is a drag.
|
||||
this.startBlock = null;
|
||||
this.targetBlock = this.flyout.createBlock(this.targetBlock);
|
||||
getFocusManager().focusNode(this.targetBlock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether to start a workspace drag. If a workspace is being dragged,
|
||||
* create the necessary WorkspaceDragger and start the drag.
|
||||
@@ -335,14 +303,10 @@ export class Gesture {
|
||||
}
|
||||
this.calledUpdateIsDragging = true;
|
||||
|
||||
// If we drag a block out of the flyout, it updates `common.getSelected`
|
||||
// to return the new block.
|
||||
if (this.flyout) this.updateIsDraggingFromFlyout();
|
||||
|
||||
const selected = common.getSelected();
|
||||
if (selected && isDraggable(selected) && selected.isMovable()) {
|
||||
this.dragging = true;
|
||||
this.dragger = this.createDragger(selected, this.startWorkspace_);
|
||||
this.dragger = this.createDragger(selected);
|
||||
this.dragger.onDragStart(e);
|
||||
this.dragger.onDrag(e, this.currentDragDeltaXY);
|
||||
} else {
|
||||
@@ -350,16 +314,13 @@ export class Gesture {
|
||||
}
|
||||
}
|
||||
|
||||
private createDragger(
|
||||
draggable: IDraggable,
|
||||
workspace: WorkspaceSvg,
|
||||
): IDragger {
|
||||
private createDragger(draggable: IDraggable): IDragger {
|
||||
const DraggerClass = registry.getClassFromOptions(
|
||||
registry.Type.BLOCK_DRAGGER,
|
||||
this.creatorWorkspace.options,
|
||||
true,
|
||||
);
|
||||
return new DraggerClass!(draggable, workspace);
|
||||
return new DraggerClass!(draggable);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -896,16 +857,24 @@ export class Gesture {
|
||||
'Cannot do a block click because the target block is ' + 'undefined',
|
||||
);
|
||||
}
|
||||
if (this.flyout.isBlockCreatable(this.targetBlock)) {
|
||||
if (!eventUtils.getGroup()) {
|
||||
eventUtils.setGroup(true);
|
||||
}
|
||||
const newBlock = this.flyout.createBlock(this.targetBlock);
|
||||
newBlock.snapToGrid();
|
||||
newBlock.bumpNeighbours();
|
||||
|
||||
// If a new block was added, make sure that it's correctly focused.
|
||||
getFocusManager().focusNode(newBlock);
|
||||
const json = blocks.save(this.targetBlock);
|
||||
const targetWorkspace = this.flyout.targetWorkspace;
|
||||
if (json && targetWorkspace) {
|
||||
const screenCoordinate = svgMath.wsToScreenCoordinates(
|
||||
this.flyout.getWorkspace(),
|
||||
this.targetBlock.getRelativeToSurfaceXY(),
|
||||
);
|
||||
const workspaceCoordinates = svgMath.screenToWsCoordinates(
|
||||
targetWorkspace,
|
||||
screenCoordinate,
|
||||
);
|
||||
json.x = workspaceCoordinates.x;
|
||||
json.y = workspaceCoordinates.y;
|
||||
blocks.appendInternal(json, targetWorkspace, {
|
||||
recordUndo: true,
|
||||
});
|
||||
targetWorkspace.hideChaff(false);
|
||||
}
|
||||
} else {
|
||||
if (!this.startWorkspace_) {
|
||||
@@ -1035,8 +1004,8 @@ export class Gesture {
|
||||
this.setTargetBlock(block.getParent()!);
|
||||
} else {
|
||||
this.targetBlock = block;
|
||||
getFocusManager().focusNode(this.targetBlock);
|
||||
this.targetBlock.bringToFront();
|
||||
getFocusManager().focusNode(this.targetBlock);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Raspberry Pi Foundation
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {Msg} from './msg.js';
|
||||
import {names} from './shortcut_items.js';
|
||||
import {Toast} from './toast.js';
|
||||
import {getShortcutKeysShort} from './utils/shortcut_formatting.js';
|
||||
import * as userAgent from './utils/useragent.js';
|
||||
import type {WorkspaceSvg} from './workspace_svg.js';
|
||||
|
||||
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
|
||||
const constrainedMoveHintId = 'constrainedMoveHint';
|
||||
const helpHintId = 'helpHint';
|
||||
const blockNavigationHintId = 'blockNavigationHint';
|
||||
const workspaceNavigationHintId = 'workspaceNavigationHint';
|
||||
const flyoutLabelHintId = 'flyoutLabelHint';
|
||||
const copiedHintId = 'copiedHint';
|
||||
const cutHintId = 'cutHint';
|
||||
const screenreaderHintId = 'screenreaderHint';
|
||||
|
||||
/**
|
||||
* Nudge the user to use unconstrained movement.
|
||||
*
|
||||
* @param workspace Workspace.
|
||||
* @param force Set to show it even if previously shown.
|
||||
*/
|
||||
export function showUnconstrainedMoveHint(
|
||||
workspace: WorkspaceSvg,
|
||||
force = false,
|
||||
) {
|
||||
const modifier =
|
||||
userAgent.MAC || userAgent.IPAD || userAgent.IPHONE
|
||||
? Msg['COMMAND_KEY']
|
||||
: Msg['CONTROL_KEY'];
|
||||
const message = Msg['KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT']
|
||||
.replace('%1', modifier)
|
||||
.replace('%2', Msg['ENTER_KEY']);
|
||||
Toast.show(workspace, {
|
||||
message,
|
||||
id: unconstrainedMoveHintId,
|
||||
oncePerSession: !force,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the user to move a block that's in move mode.
|
||||
*
|
||||
* @param workspace Workspace.
|
||||
*/
|
||||
export function showConstrainedMovementHint(workspace: WorkspaceSvg) {
|
||||
const message = Msg['KEYBOARD_NAV_CONSTRAINED_MOVE_HINT'].replace(
|
||||
'%1',
|
||||
Msg['ENTER_KEY'],
|
||||
);
|
||||
Toast.show(workspace, {
|
||||
message,
|
||||
id: constrainedMoveHintId,
|
||||
oncePerSession: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear active move-related hints, if any.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function clearMoveHints(workspace: WorkspaceSvg) {
|
||||
Toast.hide(workspace, constrainedMoveHintId);
|
||||
Toast.hide(workspace, unconstrainedMoveHintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the user to open the help.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showHelpHint(workspace: WorkspaceSvg) {
|
||||
const shortcut = getShortcutKeysShort('list_shortcuts');
|
||||
if (!shortcut) return;
|
||||
|
||||
const message = Msg['HELP_PROMPT'].replace('%1', shortcut);
|
||||
const id = helpHintId;
|
||||
Toast.show(workspace, {message, id});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user how to navigate inside blocks.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showBlockNavigationHint(workspace: WorkspaceSvg) {
|
||||
const shortcut = getShortcutKeysShort(
|
||||
workspace.RTL ? names.NAVIGATE_LEFT : names.NAVIGATE_RIGHT,
|
||||
);
|
||||
const message = Msg['KEYBOARD_NAV_BLOCK_NAVIGATION_HINT'].replace(
|
||||
'%1',
|
||||
shortcut,
|
||||
);
|
||||
const id = blockNavigationHintId;
|
||||
Toast.show(workspace, {message, id});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user how to navigate inside the workspace.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showWorkspaceNavigationHint(workspace: WorkspaceSvg) {
|
||||
const message = Msg['KEYBOARD_NAV_WORKSPACE_NAVIGATION_HINT'];
|
||||
const id = workspaceNavigationHintId;
|
||||
Toast.show(workspace, {message, id});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user how to navigate away from a flyout label (heading) when they
|
||||
* try to act on it. Labels are not interactive, so direct them to use the
|
||||
* arrow keys to reach a block or the next-heading shortcut to skip ahead.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function showFlyoutLabelActionHint(workspace: WorkspaceSvg) {
|
||||
const message = Msg['KEYBOARD_NAV_FLYOUT_LABEL_HINT'].replace(
|
||||
'%1',
|
||||
getShortcutKeysShort(names.NEXT_HEADING),
|
||||
);
|
||||
Toast.show(workspace, {message, id: flyoutLabelHintId});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the user to paste after a copy.
|
||||
*
|
||||
* @param workspace Workspace.
|
||||
*/
|
||||
export function showCopiedHint(workspace: WorkspaceSvg) {
|
||||
Toast.show(workspace, {
|
||||
message: Msg['KEYBOARD_NAV_COPIED_HINT'].replace(
|
||||
'%1',
|
||||
getShortcutKeysShort(names.PASTE),
|
||||
),
|
||||
duration: 7,
|
||||
id: copiedHintId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the user to paste after a cut.
|
||||
*
|
||||
* @param workspace Workspace.
|
||||
*/
|
||||
export function showCutHint(workspace: WorkspaceSvg) {
|
||||
Toast.show(workspace, {
|
||||
message: Msg['KEYBOARD_NAV_CUT_HINT'].replace(
|
||||
'%1',
|
||||
getShortcutKeysShort(names.PASTE),
|
||||
),
|
||||
duration: 7,
|
||||
id: cutHintId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear active paste-related hints, if any.
|
||||
*
|
||||
* @param workspace The workspace.
|
||||
*/
|
||||
export function clearPasteHints(workspace: WorkspaceSvg) {
|
||||
Toast.hide(workspace, cutHintId);
|
||||
Toast.hide(workspace, copiedHintId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the user about screenreader optimization mode being toggled, and how
|
||||
* to undo it.
|
||||
*
|
||||
* @param workspace The workspace where screenreader mode was toggled.
|
||||
* @param enabled True if screenreader mode is now enabled, otherwise false.
|
||||
*/
|
||||
export function showScreenreaderModeHint(
|
||||
workspace: WorkspaceSvg,
|
||||
enabled: boolean,
|
||||
) {
|
||||
Toast.show(workspace, {
|
||||
message: (enabled
|
||||
? Msg['SCREENREADER_MODE_ENABLED']
|
||||
: Msg['SCREENREADER_MODE_DISABLED']
|
||||
).replace('%1', getShortcutKeysShort(names.TOGGLE_SCREENREADER)),
|
||||
duration: 7,
|
||||
id: screenreaderHintId,
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import type {ISerializable} from '../interfaces/i_serializable.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import {Coordinate} from '../utils.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
@@ -336,6 +337,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
'comment',
|
||||
),
|
||||
);
|
||||
if (this.svgRoot) {
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
}
|
||||
|
||||
/** See IHasBubble.getBubble. */
|
||||
@@ -366,6 +370,7 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
this.getBubbleOwnerRect(),
|
||||
this,
|
||||
);
|
||||
this.textInputBubble.getEditor().setParent(this.sourceBlock as BlockSvg);
|
||||
this.textInputBubble.setText(this.getText());
|
||||
this.textInputBubble.setSize(this.bubbleSize, true);
|
||||
if (this.bubbleLocation) {
|
||||
@@ -376,6 +381,9 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
this.textInputBubble.addLocationChangeListener(() =>
|
||||
this.onBubbleLocationChange(),
|
||||
);
|
||||
this.textInputBubble.setAriaLabelProvider(() =>
|
||||
Msg['BUBBLE_LABEL_COMMENT'].replace('%1', this.getText()),
|
||||
);
|
||||
}
|
||||
|
||||
/** Hides any open bubbles owned by this comment. */
|
||||
@@ -403,6 +411,19 @@ export class CommentIcon extends Icon implements IHasBubble, ISerializable {
|
||||
private getBubbleOwnerRect(): Rect {
|
||||
return (this.sourceBlock as BlockSvg).getBoundingRectangleWithoutChildren();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this icon (defaults to null). Note that this
|
||||
* method will only be called during initialization by default, so dynamic changes
|
||||
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
|
||||
*
|
||||
* @returns The ARIA label to use for this icon, or null to use a default.
|
||||
*/
|
||||
protected override getAriaLabel(): string | null {
|
||||
return this.bubbleIsVisible()
|
||||
? Msg['ICON_LABEL_COMMENT_OPEN']
|
||||
: Msg['ICON_LABEL_COMMENT_CLOSED'];
|
||||
}
|
||||
}
|
||||
|
||||
/** The save state format for a comment icon. */
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
import type {Block} from '../block.js';
|
||||
import type {BlockSvg} from '../block_svg.js';
|
||||
import * as browserEvents from '../browser_events.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
|
||||
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
|
||||
import {hasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import type {IIcon} from '../interfaces/i_icon.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import * as tooltip from '../tooltip.js';
|
||||
import {aria} from '../utils.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
import * as idGenerator from '../utils/idgenerator.js';
|
||||
@@ -73,6 +77,7 @@ export abstract class Icon implements IIcon, IContextMenu {
|
||||
);
|
||||
(this.svgRoot as any).tooltip = this;
|
||||
tooltip.bindMouseEvents(this.svgRoot);
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -189,6 +194,22 @@ export abstract class Icon implements IIcon, IContextMenu {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user acting on this icon via keyboard navigation.
|
||||
* Performs the same action as a click would, and focuses this icon's bubble
|
||||
* if it has one.
|
||||
*/
|
||||
performAction() {
|
||||
this.onClick();
|
||||
renderManagement.finishQueuedRenders().then(() => {
|
||||
if (hasBubble(this) && this.bubbleIsVisible()) {
|
||||
const bubble = this.getBubble();
|
||||
if (!bubble) return;
|
||||
getFocusManager().focusNode(bubble);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block that this icon is attached to.
|
||||
*
|
||||
@@ -201,4 +222,28 @@ export abstract class Icon implements IIcon, IContextMenu {
|
||||
showContextMenu(e: PointerEvent) {
|
||||
(this.getSourceBlock() as BlockSvg).showContextMenu(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the ARIA label and role for this icon. This is automatically called
|
||||
* during initialization, but implementations may find it useful to call this if
|
||||
* the icon's label should be changed.
|
||||
*/
|
||||
protected recomputeAriaContext(): void {
|
||||
const element = this.getFocusableElement();
|
||||
if (!element) return;
|
||||
aria.setRole(element, aria.Role.BUTTON);
|
||||
const label = this.getAriaLabel() ?? Msg['ICON_LABEL_DEFAULT'];
|
||||
aria.setState(element, aria.State.LABEL, label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this icon (defaults to null). Note that this
|
||||
* method will only be called during initialization by default, so dynamic changes
|
||||
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
|
||||
*
|
||||
* @returns The ARIA label to use for this icon, or null to use a default.
|
||||
*/
|
||||
protected getAriaLabel(): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {isBlockChange, isBlockCreate} from '../events/predicates.js';
|
||||
import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import * as dom from '../utils/dom.js';
|
||||
@@ -176,6 +177,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
|
||||
this.sourceBlock.workspace,
|
||||
this.getAnchorLocation(),
|
||||
this.getBubbleOwnerRect(),
|
||||
this,
|
||||
);
|
||||
this.applyColour();
|
||||
this.createRootBlock();
|
||||
@@ -183,6 +185,9 @@ export class MutatorIcon extends Icon implements IHasBubble {
|
||||
this.miniWorkspaceBubble?.addWorkspaceChangeListener(
|
||||
this.createMiniWorkspaceChangeListener(),
|
||||
);
|
||||
this.miniWorkspaceBubble.setAriaLabelProvider(
|
||||
Msg['WORKSPACE_LABEL_MUTATOR_WORKSPACE'],
|
||||
);
|
||||
} else {
|
||||
this.miniWorkspaceBubble?.dispose();
|
||||
this.miniWorkspaceBubble = null;
|
||||
@@ -201,6 +206,7 @@ export class MutatorIcon extends Icon implements IHasBubble {
|
||||
'mutator',
|
||||
),
|
||||
);
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/** See IHasBubble.getBubble. */
|
||||
@@ -357,4 +363,17 @@ export class MutatorIcon extends Icon implements IHasBubble {
|
||||
getWorkspace(): WorkspaceSvg | undefined {
|
||||
return this.miniWorkspaceBubble?.getWorkspace();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this icon (defaults to null). Note that this
|
||||
* method will only be called during initialization by default, so dynamic changes
|
||||
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
|
||||
*
|
||||
* @returns The ARIA label to use for this icon, or null to use a default.
|
||||
*/
|
||||
protected override getAriaLabel(): string | null {
|
||||
return this.bubbleIsVisible()
|
||||
? Msg['ICON_LABEL_MUTATOR_OPEN']
|
||||
: Msg['ICON_LABEL_MUTATOR_CLOSED'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {EventType} from '../events/type.js';
|
||||
import * as eventUtils from '../events/utils.js';
|
||||
import type {IBubble} from '../interfaces/i_bubble.js';
|
||||
import type {IHasBubble} from '../interfaces/i_has_bubble.js';
|
||||
import {Msg} from '../msg.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import {Size} from '../utils.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
@@ -182,8 +183,12 @@ export class WarningIcon extends Icon implements IHasBubble {
|
||||
this.sourceBlock.workspace,
|
||||
this.getAnchorLocation(),
|
||||
this.getBubbleOwnerRect(),
|
||||
this,
|
||||
);
|
||||
this.applyColour();
|
||||
this.textBubble.setAriaLabelProvider(() =>
|
||||
Msg['BUBBLE_LABEL_WARNING'].replace('%1', this.getText()),
|
||||
);
|
||||
} else {
|
||||
this.textBubble?.dispose();
|
||||
this.textBubble = null;
|
||||
@@ -196,6 +201,7 @@ export class WarningIcon extends Icon implements IHasBubble {
|
||||
'warning',
|
||||
),
|
||||
);
|
||||
this.recomputeAriaContext();
|
||||
}
|
||||
|
||||
/** See IHasBubble.getBubble. */
|
||||
@@ -223,4 +229,17 @@ export class WarningIcon extends Icon implements IHasBubble {
|
||||
const bbox = this.sourceBlock.getSvgRoot().getBBox();
|
||||
return new Rect(bbox.y, bbox.y + bbox.height, bbox.x, bbox.x + bbox.width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ARIA label to use for this icon (defaults to null). Note that this
|
||||
* method will only be called during initialization by default, so dynamic changes
|
||||
* to the icon's ARIA label need to be applied by calling recomputeAriaContext.
|
||||
*
|
||||
* @returns The ARIA label to use for this icon, or null to use a default.
|
||||
*/
|
||||
protected override getAriaLabel(): string | null {
|
||||
return this.bubbleIsVisible()
|
||||
? Msg['ICON_LABEL_WARNING_OPEN']
|
||||
: Msg['ICON_LABEL_WARNING_CLOSED'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import * as common from './common.js';
|
||||
import * as Css from './css.js';
|
||||
import * as dropDownDiv from './dropdowndiv.js';
|
||||
import {Grid} from './grid.js';
|
||||
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
|
||||
import {Options} from './options.js';
|
||||
import {ScrollbarPair} from './scrollbar_pair.js';
|
||||
import * as Tooltip from './tooltip.js';
|
||||
import * as Touch from './touch.js';
|
||||
import * as aria from './utils/aria.js';
|
||||
import * as dom from './utils/dom.js';
|
||||
import {Svg} from './utils/svg.js';
|
||||
import * as WidgetDiv from './widgetdiv.js';
|
||||
@@ -53,6 +55,7 @@ export function inject(
|
||||
if (opt_options?.rtl) {
|
||||
dom.addClass(subContainer, 'blocklyRTL');
|
||||
}
|
||||
aria.setRole(subContainer, aria.Role.APPLICATION);
|
||||
|
||||
containerElement!.appendChild(subContainer);
|
||||
const svg = createDom(subContainer, options);
|
||||
@@ -78,6 +81,8 @@ export function inject(
|
||||
common.globalShortcutHandler,
|
||||
);
|
||||
|
||||
aria.initializeGlobalAriaLiveRegion(subContainer);
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@@ -95,7 +100,7 @@ function createDom(container: HTMLElement, options: Options): SVGElement {
|
||||
container.setAttribute('dir', 'LTR');
|
||||
|
||||
// Load CSS.
|
||||
Css.inject(options.hasCss, options.pathToMedia);
|
||||
Css.inject(container, options.hasCss, options.pathToMedia);
|
||||
|
||||
// Build the SVG DOM.
|
||||
/*
|
||||
@@ -172,7 +177,7 @@ function createMainWorkspace(
|
||||
if (!wsOptions.hasCategories && wsOptions.languageTree) {
|
||||
// Add flyout as an <svg> that is a sibling of the workspace SVG.
|
||||
const flyout = mainWorkspace.addFlyout(Svg.SVG);
|
||||
dom.insertAfter(flyout, svg);
|
||||
injectionDiv.insertBefore(flyout, svg);
|
||||
}
|
||||
if (wsOptions.hasTrashcan) {
|
||||
mainWorkspace.addTrashcan();
|
||||
@@ -314,6 +319,11 @@ function bindDocumentEvents() {
|
||||
// should run regardless of what other touch event handlers have run.
|
||||
browserEvents.bind(document, 'touchend', null, Touch.longStop);
|
||||
browserEvents.bind(document, 'touchcancel', null, Touch.longStop);
|
||||
browserEvents.bind(document, 'keydown', null, function (e: KeyboardEvent) {
|
||||
if (e.key === 'Tab') {
|
||||
keyboardNavigationController.setIsActive(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
documentEventsBound = true;
|
||||
}
|
||||
@@ -329,4 +339,5 @@ function loadSounds(pathToMedia: string, workspace: WorkspaceSvg) {
|
||||
audioMgr.load([`${pathToMedia}click.mp3`], 'click');
|
||||
audioMgr.load([`${pathToMedia}disconnect.mp3`], 'disconnect');
|
||||
audioMgr.load([`${pathToMedia}delete.mp3`], 'delete');
|
||||
audioMgr.load([`${pathToMedia}drop.mp3`], 'drop');
|
||||
}
|
||||
|
||||
@@ -15,15 +15,25 @@
|
||||
import '../field_label.js';
|
||||
|
||||
import type {Block} from '../block.js';
|
||||
import {computeFieldRowLabel, getInputLabels} from '../block_aria_composer.js';
|
||||
import type {BlockSvg} from '../block_svg.js';
|
||||
import type {Connection} from '../connection.js';
|
||||
import type {ConnectionType} from '../connection_type.js';
|
||||
import {ConnectionType} from '../connection_type.js';
|
||||
import type {Field} from '../field.js';
|
||||
import * as fieldRegistry from '../field_registry.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import {Verbosity} from '../utils/aria.js';
|
||||
import * as parsing from '../utils/parsing.js';
|
||||
import {Align} from './align.js';
|
||||
import {inputTypes} from './input_types.js';
|
||||
|
||||
/**
|
||||
* Represents a string or a function that returns a string which can be used as a
|
||||
* custom ARIA string to represent an Input, or null if the default fallback should
|
||||
* be used. See setAriaLabelProvider for more context.
|
||||
*/
|
||||
export type AriaLabelProvider = ((input: Input) => string | null) | string;
|
||||
|
||||
/** Class for an input with optional fields. */
|
||||
export class Input {
|
||||
fieldRow: Field[] = [];
|
||||
@@ -33,6 +43,9 @@ export class Input {
|
||||
/** Is the input visible? */
|
||||
private visible = true;
|
||||
|
||||
/** The AriaLabelProvider */
|
||||
private ariaLabelProvider: AriaLabelProvider | null = null;
|
||||
|
||||
public readonly type: inputTypes = inputTypes.CUSTOM;
|
||||
|
||||
public connection: Connection | null = null;
|
||||
@@ -271,6 +284,42 @@ export class Input {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom ARIA label provider for this input, or null if it should be reset
|
||||
* to use the default method.
|
||||
*
|
||||
* Inputs do not compute ARIA contexts directly, so the set provider will be used
|
||||
* in select cases when the Input needs to be represented (such as for parts of a
|
||||
* block label or for connections). Note that overriding this provider will not
|
||||
* recompute any already constructed ARIA labels, and it cannot be assumed that the
|
||||
* provider will be called any particular number of times during label
|
||||
* recomputation. As such, implementations should make sure to provide a
|
||||
* deterministic and idempotent ARIA representation each time the provider is
|
||||
* called for a given input. It's also fine to reuse providers across multiple
|
||||
* Input implementations.
|
||||
*
|
||||
* @param provider The string or function to use to set the ARIA label for the input
|
||||
* @returns The input being modified (to allow chaining).
|
||||
*/
|
||||
setAriaLabelProvider(provider: AriaLabelProvider | null): Input {
|
||||
this.ariaLabelProvider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string from the custom ARIA label provider set, or null if the default label (from the field row) should
|
||||
* be used. See setAriaLabelProvider for more context.
|
||||
*/
|
||||
getAriaLabelText(): string | null {
|
||||
if (!this.ariaLabelProvider) {
|
||||
return null;
|
||||
} else if (typeof this.ariaLabelProvider === 'string') {
|
||||
return parsing.replaceMessageReferences(this.ariaLabelProvider);
|
||||
} else {
|
||||
return this.ariaLabelProvider(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the fields on this input for a headless block.
|
||||
*
|
||||
@@ -314,4 +363,86 @@ export class Input {
|
||||
protected makeConnection(type: ConnectionType): Connection {
|
||||
return this.sourceBlock.makeConnection_(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ID for the logical "row" this input is part of. A "row" is
|
||||
* bounded by a previous/next connection, a statement input, or a block stack
|
||||
* boundary; all blocks/inputs nested inside of one of those are conceptually
|
||||
* part of its same row.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getRowId(): string {
|
||||
const inputs = this.getSourceBlock().inputList;
|
||||
|
||||
// The first visible input shares the block's row id; this also covers
|
||||
// the collapsed-input placeholder, since every other input is hidden.
|
||||
if (this === inputs.find((i) => i.isVisible())) {
|
||||
return (this.getSourceBlock() as BlockSvg).getRowId();
|
||||
}
|
||||
|
||||
// Fallback when inputs[0] itself is hidden.
|
||||
if (this === inputs[0]) {
|
||||
return (this.getSourceBlock() as BlockSvg).getRowId();
|
||||
}
|
||||
|
||||
const inputIndex = inputs.indexOf(this);
|
||||
const precedingStatementInput =
|
||||
inputs[inputIndex - 1].connection?.type === ConnectionType.NEXT_STATEMENT;
|
||||
|
||||
// Each subsequent statement input or value input following a statement
|
||||
// input is on its own row and has its own row ID.
|
||||
if (
|
||||
this.connection?.type === ConnectionType.NEXT_STATEMENT ||
|
||||
precedingStatementInput
|
||||
) {
|
||||
return `${this.getSourceBlock().id}-input${inputIndex}`;
|
||||
}
|
||||
|
||||
// Value inputs have the same row ID as their preceding input, since
|
||||
// they're all on one row.
|
||||
return inputs[inputIndex - 1].getRowId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a derived accessibility label for this input: field row text plus
|
||||
* labels of any connected child blocks (unless excluded). Does not include
|
||||
* custom labels from {@link getAriaLabelText}; those are used in move-mode
|
||||
* and parent-input context only.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getLabel(verbosity = Verbosity.STANDARD, includeChildren = true): string {
|
||||
if (!this.isVisible()) return '';
|
||||
|
||||
const labels = computeFieldRowLabel(this, false, verbosity);
|
||||
|
||||
if (
|
||||
includeChildren &&
|
||||
this.connection?.type === ConnectionType.INPUT_VALUE
|
||||
) {
|
||||
const childBlock = this.connection.targetBlock();
|
||||
if (childBlock && !childBlock.isInsertionMarker()) {
|
||||
labels.push(
|
||||
getInputLabels(childBlock as BlockSvg, verbosity).join(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
return labels.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of this input, excluding inputs without connections, on its
|
||||
* source block.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
getIndex(): number {
|
||||
const noConnectionInputTypes = [inputTypes.DUMMY, inputTypes.END_ROW];
|
||||
const allInputs = this.getSourceBlock().inputList;
|
||||
const allConnectionInputs = allInputs.filter(
|
||||
(input) => !noConnectionInputTypes.includes(input.type),
|
||||
);
|
||||
return allConnectionInputs.indexOf(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
|
||||
staticConn.highlight();
|
||||
}
|
||||
|
||||
if (this.workspace.getRenderer().shouldHighlightConnection(draggedConn)) {
|
||||
draggedConn.highlight();
|
||||
}
|
||||
|
||||
this.draggedConn = draggedConn;
|
||||
this.staticConn = staticConn;
|
||||
} finally {
|
||||
@@ -224,6 +228,10 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
|
||||
this.staticConn.unhighlight();
|
||||
this.staticConn = null;
|
||||
}
|
||||
if (this.draggedConn) {
|
||||
this.draggedConn.unhighlight();
|
||||
this.draggedConn = null;
|
||||
}
|
||||
if (this.fadedBlock) {
|
||||
this.fadedBlock.fadeForReplacement(false);
|
||||
this.fadedBlock = null;
|
||||
|
||||
@@ -29,3 +29,16 @@ export interface IBoundedElement {
|
||||
*/
|
||||
moveBy(dx: number, dy: number, reason?: string[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given object conforms to IBoundedElement.
|
||||
*
|
||||
* @param object The object to test for conformance.
|
||||
* @returns True if the object conforms to IBoundedElement, otherwise false.
|
||||
*/
|
||||
export function isBoundedElement(object: any): object is IBoundedElement {
|
||||
return (
|
||||
typeof (object as IBoundedElement).getBoundingRectangle === 'function' &&
|
||||
typeof (object as IBoundedElement).moveBy === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
// Former goog.module ID: Blockly.IBubble
|
||||
|
||||
import type {Coordinate} from '../utils/coordinate.js';
|
||||
import {IBoundedElement} from './i_bounded_element.js';
|
||||
import type {IContextMenu} from './i_contextmenu.js';
|
||||
import type {IDraggable} from './i_draggable.js';
|
||||
import {IFocusableNode} from './i_focusable_node.js';
|
||||
import {ISelectable} from './i_selectable.js';
|
||||
|
||||
/**
|
||||
* A bubble interface.
|
||||
*/
|
||||
export interface IBubble extends IDraggable, IContextMenu, IFocusableNode {
|
||||
export interface IBubble
|
||||
extends IDraggable, IContextMenu, ISelectable, IBoundedElement {
|
||||
/**
|
||||
* Return the coordinates of the top-left corner of this bubble's body
|
||||
* relative to the drawing surface's origin (0,0), in workspace units.
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
// Former goog.module ID: Blockly.ICollapsibleToolboxItem
|
||||
|
||||
import type {ISelectableToolboxItem} from './i_selectable_toolbox_item.js';
|
||||
import {
|
||||
type ISelectableToolboxItem,
|
||||
isSelectableToolboxItem,
|
||||
} from './i_selectable_toolbox_item.js';
|
||||
import type {IToolboxItem} from './i_toolbox_item.js';
|
||||
|
||||
/**
|
||||
@@ -31,3 +34,19 @@ export interface ICollapsibleToolboxItem extends ISelectableToolboxItem {
|
||||
/** Toggles whether or not the toolbox item is expanded. */
|
||||
toggleExpanded(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard that checks whether an object is an ICollapsibleToolboxItem.
|
||||
*/
|
||||
export function isCollapsibleToolboxItem(
|
||||
obj: any,
|
||||
): obj is ICollapsibleToolboxItem {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj.getChildToolboxItems === 'function' &&
|
||||
typeof obj.isExpanded === 'function' &&
|
||||
typeof obj.toggleExpanded === 'function' &&
|
||||
isSelectableToolboxItem(obj) &&
|
||||
obj.isCollapsible()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,23 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {Coordinate} from '../utils/coordinate';
|
||||
import type {Coordinate} from '../utils/coordinate.js';
|
||||
import type {IBoundedElement} from './i_bounded_element.js';
|
||||
import type {ISelectable} from './i_selectable.js';
|
||||
|
||||
export enum DragDisposition {
|
||||
COMMIT = 1,
|
||||
DELETE = 2,
|
||||
REVERT = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an object that can be dragged.
|
||||
*/
|
||||
export interface IDraggable extends IDragStrategy {
|
||||
export interface IDraggable
|
||||
extends IDragStrategy, IBoundedElement, ISelectable {
|
||||
/**
|
||||
* Returns the current location of the draggable in workspace
|
||||
* coordinates.
|
||||
* Returns the current location of the draggable in workspace coordinates.
|
||||
*
|
||||
* @returns Coordinate of current location on workspace.
|
||||
*/
|
||||
@@ -27,11 +35,11 @@ export interface IDragStrategy {
|
||||
* Handles any drag startup (e.g moving elements to the front of the
|
||||
* workspace).
|
||||
*
|
||||
* @param e PointerEvent that started the drag; can be used to
|
||||
* check modifier keys, etc. May be missing when dragging is
|
||||
* triggered programatically rather than by user.
|
||||
* @param e Event that started the drag; can be used to check modifier keys,
|
||||
* etc. May be missing when dragging is triggered programmatically rather
|
||||
* than by user.
|
||||
*/
|
||||
startDrag(e?: PointerEvent): void;
|
||||
startDrag(e?: PointerEvent | KeyboardEvent): IDraggable;
|
||||
|
||||
/**
|
||||
* Handles moving elements to the new location, and updating any
|
||||
@@ -39,21 +47,23 @@ export interface IDragStrategy {
|
||||
*
|
||||
* @param newLoc Workspace coordinate to which the draggable has
|
||||
* been dragged.
|
||||
* @param e PointerEvent that continued the drag. Can be
|
||||
* used to check modifier keys, etc.
|
||||
* @param e Event that continued the drag. Can be used to check modifier
|
||||
* keys, etc.
|
||||
*/
|
||||
drag(newLoc: Coordinate, e?: PointerEvent): void;
|
||||
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void;
|
||||
|
||||
/**
|
||||
* Handles any drag cleanup, including e.g. connecting or deleting
|
||||
* blocks.
|
||||
* Handles any drag cleanup, including e.g. connecting or deleting blocks.
|
||||
*
|
||||
* @param newLoc Workspace coordinate at which the drag finished.
|
||||
* been dragged.
|
||||
* @param e PointerEvent that finished the drag. Can be
|
||||
* used to check modifier keys, etc.
|
||||
* @param e Event that finished the drag. Can be used to check modifier keys,
|
||||
* etc.
|
||||
* @param disposition The end result of the drag.
|
||||
*/
|
||||
endDrag(e?: PointerEvent): void;
|
||||
endDrag(
|
||||
e: PointerEvent | KeyboardEvent | undefined,
|
||||
disposition: DragDisposition,
|
||||
): void;
|
||||
|
||||
/** Moves the draggable back to where it was at the start of the drag. */
|
||||
revertDrag(): void;
|
||||
|
||||
@@ -4,32 +4,51 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {Coordinate} from '../utils/coordinate';
|
||||
import type {Coordinate} from '../utils/coordinate';
|
||||
import type {IDraggable} from './i_draggable';
|
||||
|
||||
export interface IDragger {
|
||||
/**
|
||||
* Handles any drag startup.
|
||||
*
|
||||
* @param e PointerEvent that started the drag.
|
||||
* @param e Event that started the drag.
|
||||
*/
|
||||
onDragStart(e: PointerEvent): void;
|
||||
onDragStart(e?: PointerEvent | KeyboardEvent): IDraggable;
|
||||
|
||||
/**
|
||||
* Handles dragging, including calculating where the element should
|
||||
* actually be moved to.
|
||||
*
|
||||
* @param e PointerEvent that continued the drag.
|
||||
* @param totalDelta The total distance, in pixels, that the mouse
|
||||
* @param e Event that continued the drag.
|
||||
* @param totalDelta The total distance, in pixels, that the draggable
|
||||
* has moved since the start of the drag.
|
||||
*/
|
||||
onDrag(e: PointerEvent, totalDelta: Coordinate): void;
|
||||
onDrag(
|
||||
e: PointerEvent | KeyboardEvent | undefined,
|
||||
totalDelta: Coordinate,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Handles any drag cleanup.
|
||||
* Handles any drag cleanup when a drag finishes normally.
|
||||
*
|
||||
* @param e PointerEvent that finished the drag.
|
||||
* @param totalDelta The total distance, in pixels, that the mouse
|
||||
* @param e Event that finished the drag.
|
||||
* @param totalDelta The total distance, in pixels, that the draggable
|
||||
* has moved since the start of the drag.
|
||||
*/
|
||||
onDragEnd(e: PointerEvent, totalDelta: Coordinate): void;
|
||||
onDragEnd(
|
||||
e: PointerEvent | KeyboardEvent | undefined,
|
||||
totalDelta: Coordinate,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Handles any drag cleanup when a drag is reverted.
|
||||
*
|
||||
* @param e Event that finished the drag.
|
||||
* @param totalDelta The total distance, in pixels, that the draggable
|
||||
* has moved since the start of the drag.
|
||||
*/
|
||||
onDragRevert(
|
||||
e: PointerEvent | KeyboardEvent | undefined,
|
||||
totalDelta: Coordinate,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -6,19 +6,16 @@
|
||||
|
||||
// Former goog.module ID: Blockly.IFlyout
|
||||
|
||||
import type {BlockSvg} from '../block_svg.js';
|
||||
import type {FlyoutItem} from '../flyout_item.js';
|
||||
import type {Coordinate} from '../utils/coordinate.js';
|
||||
import type {Svg} from '../utils/svg.js';
|
||||
import type {FlyoutDefinition} from '../utils/toolbox.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {IFocusableTree} from './i_focusable_tree.js';
|
||||
import type {IRegistrable} from './i_registrable.js';
|
||||
|
||||
/**
|
||||
* Interface for a flyout.
|
||||
*/
|
||||
export interface IFlyout extends IRegistrable, IFocusableTree {
|
||||
export interface IFlyout extends IRegistrable {
|
||||
/** Whether the flyout is laid out horizontally or not. */
|
||||
horizontalLayout: boolean;
|
||||
|
||||
@@ -129,15 +126,6 @@ export interface IFlyout extends IRegistrable, IFocusableTree {
|
||||
*/
|
||||
getContents(): FlyoutItem[];
|
||||
|
||||
/**
|
||||
* Create a copy of this block on the workspace.
|
||||
*
|
||||
* @param originalBlock The block to copy from the flyout.
|
||||
* @returns The newly created block.
|
||||
* @throws {Error} if something went wrong with deserialization.
|
||||
*/
|
||||
createBlock(originalBlock: BlockSvg): BlockSvg;
|
||||
|
||||
/** Reflow blocks and their mats. */
|
||||
reflow(): void;
|
||||
|
||||
@@ -164,27 +152,6 @@ export interface IFlyout extends IRegistrable, IFocusableTree {
|
||||
/** Position the flyout. */
|
||||
position(): void;
|
||||
|
||||
/**
|
||||
* Determine if a drag delta is toward the workspace, based on the position
|
||||
* and orientation of the flyout. This is used in determineDragIntention_ to
|
||||
* determine if a new block should be created or if the flyout should scroll.
|
||||
*
|
||||
* @param currentDragDeltaXY How far the pointer has moved from the position
|
||||
* at mouse down, in pixel units.
|
||||
* @returns True if the drag is toward the workspace.
|
||||
*/
|
||||
isDragTowardWorkspace(currentDragDeltaXY: Coordinate): boolean;
|
||||
|
||||
/**
|
||||
* Does this flyout allow you to create a new instance of the given block?
|
||||
* Used for deciding if a block can be "dragged out of" the flyout.
|
||||
*
|
||||
* @param block The block to copy from the flyout.
|
||||
* @returns True if you can create a new instance of the block, false
|
||||
* otherwise.
|
||||
*/
|
||||
isBlockCreatable(block: BlockSvg): boolean;
|
||||
|
||||
/** Scroll the flyout to the beginning of its contents. */
|
||||
scrollToStart(): void;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ export interface IFlyoutInflater {
|
||||
* Note that this method's interface is identical to that in ISerializer, to
|
||||
* allow for code reuse.
|
||||
*
|
||||
* You must ensure that any item created by this method has the appropriate
|
||||
* ARIA markup:
|
||||
* - The role of the element's focusable element should be set to `listitem`.
|
||||
* - The focusable element must have an `id` attribute.
|
||||
* - Any DOM parents of the focusable element should set their role to
|
||||
* `presentation` to avoid interfering with flyout list navigation.
|
||||
* - If the element is not focusable, it must be hidden from the ARIA tree.
|
||||
* Only do this if the content should be inaccessible to screenreaders.
|
||||
*
|
||||
* @param state A JSON representation of an element to inflate on the flyout.
|
||||
* @param flyout The flyout on whose workspace the inflated element
|
||||
* should be created. If the inflated element is an `IRenderedElement` it
|
||||
|
||||
@@ -99,6 +99,15 @@ export interface IFocusableNode {
|
||||
* @returns Whether this node can be focused by FocusManager.
|
||||
*/
|
||||
canBeFocused(): boolean;
|
||||
|
||||
/**
|
||||
* Optional method invoked when this node has focus and the user acts on it by
|
||||
* pressing Enter or Space. Behavior should generally be similar to the node
|
||||
* being clicked on.
|
||||
*
|
||||
* @param e The event that triggered this action, if any.
|
||||
*/
|
||||
performAction?(e?: Event): void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {Navigator} from '../keyboard_nav/navigators/navigator';
|
||||
import type {IFocusableNode} from './i_focusable_node.js';
|
||||
|
||||
/**
|
||||
@@ -122,6 +123,14 @@ export interface IFocusableTree {
|
||||
* as in the case that Blockly is entirely losing DOM focus).
|
||||
*/
|
||||
onTreeBlur(nextTree: IFocusableTree | null): void;
|
||||
|
||||
/**
|
||||
* Returns a Navigator instance to be used to determine the navigation order
|
||||
* between IFocusableNodes contained within this IFocusableTree. Generally
|
||||
* this can just be an instance of Navigator, but trees may choose to return a
|
||||
* subclass to customize navigation behavior within their context.
|
||||
*/
|
||||
getNavigator(): Navigator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface JsonBlockDefinition {
|
||||
inputsInline?: boolean;
|
||||
tooltip?: string;
|
||||
helpUrl?: string;
|
||||
ariaRoleDescription?: string;
|
||||
extensions?: string[];
|
||||
mutator?: string;
|
||||
enableContextMenu?: boolean;
|
||||
|
||||
@@ -44,6 +44,19 @@ export interface INavigationPolicy<T> {
|
||||
*/
|
||||
getPreviousSibling(current: T): IFocusableNode | null;
|
||||
|
||||
/**
|
||||
* Returns an ID corresponding to the visual "row" the given element is part
|
||||
* of. All elements on the same visual row should share the same ID. For
|
||||
* example, icons share their parent block's row ID, as do inline connected
|
||||
* blocks or value inputs. Statement inputs, external inputs, or blocks
|
||||
* connected to one another's previous or next connections form distinct
|
||||
* visual rows and should have distinct row IDs.
|
||||
*
|
||||
* @param current The element to return the row ID of.
|
||||
* @returns The row ID of the given element.
|
||||
*/
|
||||
getRowId(current: T): string;
|
||||
|
||||
/**
|
||||
* Returns whether or not the given instance should be reachable via keyboard
|
||||
* navigation.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
// Former goog.module ID: Blockly.ISelectable
|
||||
|
||||
import type {Workspace} from '../workspace.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {IFocusableNode, isFocusableNode} from './i_focusable_node.js';
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ import {IFocusableNode, isFocusableNode} from './i_focusable_node.js';
|
||||
export interface ISelectable extends IFocusableNode {
|
||||
id: string;
|
||||
|
||||
workspace: Workspace;
|
||||
workspace: WorkspaceSvg;
|
||||
|
||||
/** Select this. Highlight it visually. */
|
||||
select(): void;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Former goog.module ID: Blockly.ISelectableToolboxItem
|
||||
|
||||
import type {FlyoutItemInfoArray} from '../utils/toolbox';
|
||||
import type {IToolboxItem} from './i_toolbox_item.js';
|
||||
import {isToolboxItem, type IToolboxItem} from './i_toolbox_item.js';
|
||||
|
||||
/**
|
||||
* Interface for an item in the toolbox that can be selected.
|
||||
@@ -54,10 +54,17 @@ export interface ISelectableToolboxItem extends IToolboxItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard that checks whether an IToolboxItem is an ISelectableToolboxItem.
|
||||
* Type guard that checks whether an object is an ISelectableToolboxItem.
|
||||
*/
|
||||
export function isSelectableToolboxItem(
|
||||
toolboxItem: IToolboxItem,
|
||||
): toolboxItem is ISelectableToolboxItem {
|
||||
return toolboxItem.isSelectable();
|
||||
obj: any,
|
||||
): obj is ISelectableToolboxItem {
|
||||
return (
|
||||
typeof obj.getName === 'function' &&
|
||||
typeof obj.getContents === 'function' &&
|
||||
typeof obj.setSelected === 'function' &&
|
||||
typeof obj.onClick === 'function' &&
|
||||
isToolboxItem(obj) &&
|
||||
obj.isSelectable()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,4 +118,9 @@ export interface IToolbox extends IRegistrable, IFocusableTree {
|
||||
|
||||
/** Disposes of this toolbox. */
|
||||
dispose(): void;
|
||||
|
||||
/**
|
||||
* Returns a list of items in this toolbox.
|
||||
*/
|
||||
getToolboxItems(): IToolboxItem[];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// Former goog.module ID: Blockly.IToolboxItem
|
||||
|
||||
import type {IFocusableNode} from './i_focusable_node.js';
|
||||
import type {IToolbox} from './i_toolbox.js';
|
||||
|
||||
/**
|
||||
* Interface for an item in the toolbox.
|
||||
@@ -80,4 +81,26 @@ export interface IToolboxItem extends IFocusableNode {
|
||||
* @param isVisible True if category should be visible.
|
||||
*/
|
||||
setVisible_(isVisible: boolean): void;
|
||||
|
||||
getParentToolbox(): IToolbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard that checks whether an object is an IToolboxItem.
|
||||
*/
|
||||
export function isToolboxItem(obj: any): obj is IToolboxItem {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj.init === 'function' &&
|
||||
typeof obj.getDiv === 'function' &&
|
||||
typeof obj.getId === 'function' &&
|
||||
typeof obj.getParent === 'function' &&
|
||||
typeof obj.getLevel === 'function' &&
|
||||
typeof obj.isSelectable === 'function' &&
|
||||
typeof obj.isCollapsible === 'function' &&
|
||||
typeof obj.dispose === 'function' &&
|
||||
typeof obj.getClickTarget === 'function' &&
|
||||
typeof obj.setVisible_ === 'function' &&
|
||||
typeof obj.getParentToolbox === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {TextInputBubble} from '../bubbles/textinput_bubble.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from an TextInputBubble.
|
||||
*/
|
||||
export class BlockCommentNavigationPolicy
|
||||
implements INavigationPolicy<TextInputBubble>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given block comment.
|
||||
*
|
||||
* @param current The block comment to return the first child of.
|
||||
* @returns The text editor of the given block comment bubble.
|
||||
*/
|
||||
getFirstChild(current: TextInputBubble): IFocusableNode | null {
|
||||
return current.getEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given block comment.
|
||||
*
|
||||
* @param current The block comment to return the parent of.
|
||||
* @returns The parent block of the given block comment.
|
||||
*/
|
||||
getParent(current: TextInputBubble): IFocusableNode | null {
|
||||
return current.getOwner() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given block comment.
|
||||
*
|
||||
* @param _current The block comment to find the following element of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getNextSibling(_current: TextInputBubble): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given block comment.
|
||||
*
|
||||
* @param _current The block comment to find the preceding element of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getPreviousSibling(_current: TextInputBubble): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given block comment can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given block comment can be focused.
|
||||
*/
|
||||
isNavigable(current: TextInputBubble): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an TextInputBubble.
|
||||
*/
|
||||
isApplicable(current: any): current is TextInputBubble {
|
||||
return current instanceof TextInputBubble;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {ConnectionType} from '../connection_type.js';
|
||||
import type {Field} from '../field.js';
|
||||
import type {Icon} from '../icons/icon.js';
|
||||
import type {IBoundedElement} from '../interfaces/i_bounded_element.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import type {ISelectable} from '../interfaces/i_selectable.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a block.
|
||||
*/
|
||||
export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
|
||||
/**
|
||||
* Returns the first child of the given block.
|
||||
*
|
||||
* @param current The block to return the first child of.
|
||||
* @returns The first field or input of the given block, if any.
|
||||
*/
|
||||
getFirstChild(current: BlockSvg): IFocusableNode | null {
|
||||
const candidates = getBlockNavigationCandidates(current, true);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given block.
|
||||
*
|
||||
* @param current The block to return the parent of.
|
||||
* @returns The top block of the given block's stack, or the connection to
|
||||
* which it is attached.
|
||||
*/
|
||||
getParent(current: BlockSvg): IFocusableNode | null {
|
||||
if (current.previousConnection?.targetBlock()) {
|
||||
const surroundParent = current.getSurroundParent();
|
||||
if (surroundParent) return surroundParent;
|
||||
} else if (current.outputConnection?.targetBlock()) {
|
||||
return current.outputConnection.targetBlock();
|
||||
}
|
||||
|
||||
return current.workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given block.
|
||||
*
|
||||
* @param current The block to find the following element of.
|
||||
* @returns The first node of the next input/stack if the given block is a terminal
|
||||
* block, or its next connection.
|
||||
*/
|
||||
getNextSibling(current: BlockSvg): IFocusableNode | null {
|
||||
if (current.nextConnection?.targetBlock()) {
|
||||
return current.nextConnection?.targetBlock();
|
||||
} else if (current.outputConnection?.targetBlock()) {
|
||||
return navigateBlock(current, 1);
|
||||
} else if (current.getSurroundParent()) {
|
||||
return navigateBlock(current.getTopStackBlock(), 1);
|
||||
} else if (this.getParent(current) instanceof WorkspaceSvg) {
|
||||
return navigateStacks(current, 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given block.
|
||||
*
|
||||
* @param current The block to find the preceding element of.
|
||||
* @returns The block's previous/output connection, or the last
|
||||
* connection/block of the previous block stack if it is a root block.
|
||||
*/
|
||||
getPreviousSibling(current: BlockSvg): IFocusableNode | null {
|
||||
if (current.previousConnection?.targetBlock()) {
|
||||
return current.previousConnection?.targetBlock();
|
||||
} else if (current.outputConnection?.targetBlock()) {
|
||||
return navigateBlock(current, -1);
|
||||
} else if (this.getParent(current) instanceof WorkspaceSvg) {
|
||||
return navigateStacks(current, -1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given block can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given block can be focused.
|
||||
*/
|
||||
isNavigable(current: BlockSvg): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is a BlockSvg.
|
||||
*/
|
||||
isApplicable(current: any): current is BlockSvg {
|
||||
return current instanceof BlockSvg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of the navigable children of the given block.
|
||||
*
|
||||
* @param block The block to retrieve the navigable children of.
|
||||
* @returns A list of navigable/focusable children of the given block.
|
||||
*/
|
||||
function getBlockNavigationCandidates(
|
||||
block: BlockSvg,
|
||||
forward: boolean,
|
||||
): IFocusableNode[] {
|
||||
const candidates: IFocusableNode[] = block.getIcons();
|
||||
|
||||
for (const input of block.inputList) {
|
||||
if (!input.isVisible()) continue;
|
||||
candidates.push(...input.fieldRow);
|
||||
if (input.connection?.targetBlock()) {
|
||||
const connectedBlock = input.connection.targetBlock() as BlockSvg;
|
||||
if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) {
|
||||
const lastStackBlock = connectedBlock
|
||||
.lastConnectionInStack(false)
|
||||
?.getSourceBlock();
|
||||
if (lastStackBlock) {
|
||||
candidates.push(lastStackBlock);
|
||||
}
|
||||
} else {
|
||||
candidates.push(connectedBlock);
|
||||
}
|
||||
} else if (input.connection?.type === ConnectionType.INPUT_VALUE) {
|
||||
candidates.push(input.connection as RenderedConnection);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next/previous stack relative to the given element's stack.
|
||||
*
|
||||
* @param current The element whose stack will be navigated relative to.
|
||||
* @param delta The difference in index to navigate; positive values navigate
|
||||
* to the nth next stack, while negative values navigate to the nth previous
|
||||
* stack.
|
||||
* @returns The first element in the stack offset by `delta` relative to the
|
||||
* current element's stack, or the last element in the stack offset by
|
||||
* `delta` relative to the current element's stack when navigating backwards.
|
||||
*/
|
||||
export function navigateStacks(current: ISelectable, delta: number) {
|
||||
const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg)
|
||||
.getTopBoundedElements(true)
|
||||
.filter((element: IBoundedElement) => isFocusableNode(element));
|
||||
const currentIndex = stacks.indexOf(
|
||||
current instanceof BlockSvg ? current.getRootBlock() : current,
|
||||
);
|
||||
const targetIndex = currentIndex + delta;
|
||||
let result: IFocusableNode | null = null;
|
||||
if (targetIndex >= 0 && targetIndex < stacks.length) {
|
||||
result = stacks[targetIndex];
|
||||
} else if (targetIndex < 0) {
|
||||
result = stacks[stacks.length - 1];
|
||||
} else if (targetIndex >= stacks.length) {
|
||||
result = stacks[0];
|
||||
}
|
||||
|
||||
// When navigating to a previous block stack, our previous sibling is the last
|
||||
// block in it.
|
||||
if (delta < 0 && result instanceof BlockSvg) {
|
||||
return result.lastConnectionInStack(false)?.getSourceBlock() ?? result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next navigable item relative to the provided block child.
|
||||
*
|
||||
* @param current The navigable block child item to navigate relative to.
|
||||
* @param delta The difference in index to navigate; positive values navigate
|
||||
* forward by n, while negative values navigate backwards by n.
|
||||
* @returns The navigable block child offset by `delta` relative to `current`.
|
||||
*/
|
||||
export function navigateBlock(
|
||||
current: Icon | Field | RenderedConnection | BlockSvg,
|
||||
delta: number,
|
||||
): IFocusableNode | null {
|
||||
const block =
|
||||
current instanceof BlockSvg
|
||||
? (current.outputConnection?.targetBlock() ?? current.getSurroundParent())
|
||||
: current.getSourceBlock();
|
||||
if (!(block instanceof BlockSvg)) return null;
|
||||
|
||||
const candidates = getBlockNavigationCandidates(block, delta > 0);
|
||||
const currentIndex = candidates.indexOf(current);
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const targetIndex = currentIndex + delta;
|
||||
if (targetIndex >= 0 && targetIndex < candidates.length) {
|
||||
return candidates[targetIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {BlockSvg} from '../block_svg.js';
|
||||
import {ConnectionType} from '../connection_type.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
import {navigateBlock} from './block_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a connection.
|
||||
*/
|
||||
export class ConnectionNavigationPolicy
|
||||
implements INavigationPolicy<RenderedConnection>
|
||||
{
|
||||
/**
|
||||
* Returns the first child of the given connection.
|
||||
*
|
||||
* @param current The connection to return the first child of.
|
||||
* @returns The connection's first child element, or null if not none.
|
||||
*/
|
||||
getFirstChild(current: RenderedConnection): IFocusableNode | null {
|
||||
if (current.getParentInput()) {
|
||||
return current.targetConnection;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given connection.
|
||||
*
|
||||
* @param current The connection to return the parent of.
|
||||
* @returns The given connection's parent connection or block.
|
||||
*/
|
||||
getParent(current: RenderedConnection): IFocusableNode | null {
|
||||
return current.getSourceBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next element following the given connection.
|
||||
*
|
||||
* @param current The connection to navigate from.
|
||||
* @returns The field, input connection or block following this connection.
|
||||
*/
|
||||
getNextSibling(current: RenderedConnection): IFocusableNode | null {
|
||||
if (current.getParentInput()) {
|
||||
return navigateBlock(current, 1);
|
||||
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
|
||||
const nextBlock = current.targetConnection;
|
||||
// If this connection is the last one in the stack, our next sibling is
|
||||
// the next block stack.
|
||||
const sourceBlock = current.getSourceBlock();
|
||||
if (
|
||||
!nextBlock &&
|
||||
sourceBlock.getRootBlock().lastConnectionInStack(false) === current
|
||||
) {
|
||||
const topBlocks = sourceBlock.workspace.getTopBlocks(true);
|
||||
let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) + 1;
|
||||
if (targetIndex >= topBlocks.length) {
|
||||
targetIndex = 0;
|
||||
}
|
||||
const nextBlock = topBlocks[targetIndex];
|
||||
return this.getParentConnection(nextBlock) ?? nextBlock;
|
||||
}
|
||||
|
||||
return nextBlock;
|
||||
}
|
||||
|
||||
return current.getSourceBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the element preceding the given connection.
|
||||
*
|
||||
* @param current The connection to navigate from.
|
||||
* @returns The field, input connection or block preceding this connection.
|
||||
*/
|
||||
getPreviousSibling(current: RenderedConnection): IFocusableNode | null {
|
||||
if (current.getParentInput()) {
|
||||
return navigateBlock(current, -1);
|
||||
} else if (
|
||||
current.type === ConnectionType.PREVIOUS_STATEMENT ||
|
||||
current.type === ConnectionType.OUTPUT_VALUE
|
||||
) {
|
||||
const previousConnection =
|
||||
current.targetConnection && !current.targetConnection.getParentInput()
|
||||
? current.targetConnection
|
||||
: null;
|
||||
|
||||
// If this connection is a disconnected previous/output connection, our
|
||||
// previous sibling is the previous block stack's last connection/block.
|
||||
const sourceBlock = current.getSourceBlock();
|
||||
if (
|
||||
!previousConnection &&
|
||||
this.getParentConnection(sourceBlock.getRootBlock()) === current
|
||||
) {
|
||||
const topBlocks = sourceBlock.workspace.getTopBlocks(true);
|
||||
let targetIndex = topBlocks.indexOf(sourceBlock.getRootBlock()) - 1;
|
||||
if (targetIndex < 0) {
|
||||
targetIndex = topBlocks.length - 1;
|
||||
}
|
||||
const previousRootBlock = topBlocks[targetIndex];
|
||||
return (
|
||||
previousRootBlock.lastConnectionInStack(false) ?? previousRootBlock
|
||||
);
|
||||
}
|
||||
|
||||
return previousConnection;
|
||||
} else if (current.type === ConnectionType.NEXT_STATEMENT) {
|
||||
return current.getSourceBlock();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent connection on a block.
|
||||
* This is either an output connection, previous connection or undefined.
|
||||
* If both connections exist return the one that is actually connected
|
||||
* to another block.
|
||||
*
|
||||
* @param block The block to find the parent connection on.
|
||||
* @returns The connection connecting to the parent of the block.
|
||||
*/
|
||||
protected getParentConnection(block: BlockSvg) {
|
||||
if (!block.outputConnection || block.previousConnection?.isConnected()) {
|
||||
return block.previousConnection;
|
||||
}
|
||||
return block.outputConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given connection can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given connection can be focused.
|
||||
*/
|
||||
isNavigable(current: RenderedConnection): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is a RenderedConnection.
|
||||
*/
|
||||
isApplicable(current: any): current is RenderedConnection {
|
||||
return current instanceof RenderedConnection;
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {IFlyout} from '../interfaces/i_flyout.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Generic navigation policy that navigates between items in the flyout.
|
||||
*/
|
||||
export class FlyoutNavigationPolicy<T> implements INavigationPolicy<T> {
|
||||
/**
|
||||
* Creates a new FlyoutNavigationPolicy instance.
|
||||
*
|
||||
* @param policy The policy to defer to for parents/children.
|
||||
* @param flyout The flyout this policy will control navigation in.
|
||||
*/
|
||||
constructor(
|
||||
private policy: INavigationPolicy<T>,
|
||||
private flyout: IFlyout,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns null to prevent navigating into flyout items.
|
||||
*
|
||||
* @param _current The flyout item to navigate from.
|
||||
* @returns Null to prevent navigating into flyout items.
|
||||
*/
|
||||
getFirstChild(_current: T): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given flyout item.
|
||||
*
|
||||
* @param current The flyout item to navigate from.
|
||||
* @returns The parent of the given flyout item.
|
||||
*/
|
||||
getParent(current: T): IFocusableNode | null {
|
||||
return this.policy.getParent(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next item in the flyout relative to the given item.
|
||||
*
|
||||
* @param current The flyout item to navigate from.
|
||||
* @returns The flyout item following the given one.
|
||||
*/
|
||||
getNextSibling(current: T): IFocusableNode | null {
|
||||
const flyoutContents = this.flyout.getContents();
|
||||
if (!flyoutContents) return null;
|
||||
|
||||
let index = flyoutContents.findIndex(
|
||||
(flyoutItem) => flyoutItem.getElement() === current,
|
||||
);
|
||||
|
||||
if (index === -1) return null;
|
||||
index++;
|
||||
if (index >= flyoutContents.length) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
return flyoutContents[index].getElement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous item in the flyout relative to the given item.
|
||||
*
|
||||
* @param current The flyout item to navigate from.
|
||||
* @returns The flyout item preceding the given one.
|
||||
*/
|
||||
getPreviousSibling(current: T): IFocusableNode | null {
|
||||
const flyoutContents = this.flyout.getContents();
|
||||
if (!flyoutContents) return null;
|
||||
|
||||
let index = flyoutContents.findIndex(
|
||||
(flyoutItem) => flyoutItem.getElement() === current,
|
||||
);
|
||||
|
||||
if (index === -1) return null;
|
||||
index--;
|
||||
if (index < 0) {
|
||||
index = flyoutContents.length - 1;
|
||||
}
|
||||
|
||||
return flyoutContents[index].getElement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given flyout item can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given flyout item can be focused.
|
||||
*/
|
||||
isNavigable(current: T): boolean {
|
||||
return this.policy.isNavigable(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is a BlockSvg.
|
||||
*/
|
||||
isApplicable(current: any): current is T {
|
||||
return this.policy.isApplicable(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Raspberry Pi Foundation
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type {BlockSvg} from '../block_svg.js';
|
||||
import type {IDraggable} from '../interfaces/i_draggable.js';
|
||||
import type {IDragger} from '../interfaces/i_dragger.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import * as registry from '../registry.js';
|
||||
import * as renderManagement from '../render_management.js';
|
||||
import {ShortcutRegistry} from '../shortcut_registry.js';
|
||||
import {Coordinate} from '../utils/coordinate.js';
|
||||
import {KeyCodes} from '../utils/keycodes.js';
|
||||
import {MoveIndicator} from './move_indicator.js';
|
||||
/**
|
||||
* Cardinal directions in which a move can proceed.
|
||||
*/
|
||||
export enum Direction {
|
||||
NONE,
|
||||
UP,
|
||||
DOWN,
|
||||
LEFT,
|
||||
RIGHT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier for a keyboard shortcut that commits the in-progress move.
|
||||
*/
|
||||
const COMMIT_MOVE_SHORTCUT = 'commitMove';
|
||||
|
||||
/**
|
||||
* Class responsible for coordinating keyboard-driven moves with the workspace
|
||||
* and dragging system.
|
||||
*/
|
||||
export class KeyboardMover {
|
||||
/**
|
||||
* Object responsible for dragging workspace elements in response to move
|
||||
* commands.
|
||||
*/
|
||||
private dragger?: IDragger;
|
||||
|
||||
/**
|
||||
* The object that is currently being moved.
|
||||
*/
|
||||
private draggable?: IDraggable;
|
||||
|
||||
/**
|
||||
* Workspace coordinate that the current move started from.
|
||||
*/
|
||||
private startLocation?: Coordinate;
|
||||
|
||||
/**
|
||||
* The total distance, in workspace coordinates, that the element being moved
|
||||
* has been moved since the movement process started.
|
||||
*/
|
||||
private totalDelta = new Coordinate(0, 0);
|
||||
|
||||
/**
|
||||
* The distance to move an item in workspace coordinates.
|
||||
*/
|
||||
private stepDistance = 20;
|
||||
|
||||
/**
|
||||
* Symbol attached to the item being moved to indicate it is in move mode.
|
||||
*/
|
||||
private moveIndicator?: MoveIndicator;
|
||||
|
||||
private allowedShortcuts: string[] = [];
|
||||
|
||||
// Set up a blur listener to end the move if the user clicks away
|
||||
private readonly blurListener = () => {
|
||||
this.abortMove();
|
||||
};
|
||||
|
||||
static mover = new KeyboardMover();
|
||||
|
||||
// Constructor is private to keep this class a singleton.
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Returns true iff the given draggable is allowed to be moved.
|
||||
*
|
||||
* @param draggable The draggable element to try to move.
|
||||
* @returns True iff movement is allowed.
|
||||
*/
|
||||
canMove(draggable: IDraggable) {
|
||||
return !draggable.workspace.isReadOnly() && draggable.isMovable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff this Mover is currently moving an element.
|
||||
*
|
||||
* @returns True iff a workspace element is being moved.
|
||||
*/
|
||||
isMoving() {
|
||||
return !!this.draggable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start moving the currently-focused item on workspace, if possible.
|
||||
*
|
||||
* @param draggable The element to start moving.
|
||||
* @param event The keyboard event that triggered this move.
|
||||
* @returns True iff a move has successfully begun.
|
||||
*/
|
||||
startMove(draggable: IDraggable, event?: KeyboardEvent) {
|
||||
if (!this.canMove(draggable) || this.isMoving()) return false;
|
||||
|
||||
const DraggerClass = registry.getClassFromOptions(
|
||||
registry.Type.BLOCK_DRAGGER,
|
||||
draggable.workspace.options,
|
||||
true,
|
||||
);
|
||||
if (!DraggerClass) throw new Error('no Dragger registered');
|
||||
this.dragger = new DraggerClass(draggable, draggable.workspace);
|
||||
// Record that a move is in progress and start dragging.
|
||||
this.draggable = this.dragger.onDragStart(event);
|
||||
this.startLocation = this.draggable.getRelativeToSurfaceXY();
|
||||
|
||||
this.updateTotalDelta();
|
||||
|
||||
this.draggable
|
||||
.getFocusableElement()
|
||||
.addEventListener('blur', this.blurListener);
|
||||
|
||||
// Register a keyboard shortcut under the key combos of all existing
|
||||
// keyboard shortcuts that commits the move before allowing the real
|
||||
// shortcut to proceed. This avoids all kinds of fun brokenness when
|
||||
// deleting/copying/otherwise acting on a element in move mode.
|
||||
const shortcutKeys = Object.values(ShortcutRegistry.registry.getRegistry())
|
||||
.flatMap((shortcut) => shortcut.keyCodes)
|
||||
.filter((keyCode) => {
|
||||
return (
|
||||
keyCode &&
|
||||
![
|
||||
KeyCodes.RIGHT,
|
||||
KeyCodes.LEFT,
|
||||
KeyCodes.UP,
|
||||
KeyCodes.DOWN,
|
||||
KeyCodes.ENTER,
|
||||
KeyCodes.SPACE,
|
||||
KeyCodes.ESC,
|
||||
KeyCodes.M,
|
||||
].includes(
|
||||
typeof keyCode === 'number'
|
||||
? keyCode
|
||||
: parseInt(`${keyCode.split('+').pop()}`),
|
||||
)
|
||||
);
|
||||
})
|
||||
// Convince TS there aren't undefined values.
|
||||
.filter((keyCode): keyCode is string | number => !!keyCode);
|
||||
|
||||
const commitMoveShortcut = {
|
||||
name: COMMIT_MOVE_SHORTCUT,
|
||||
preconditionFn: () => {
|
||||
return this.isMoving();
|
||||
},
|
||||
callback: () => {
|
||||
this.finishMove();
|
||||
return false;
|
||||
},
|
||||
keyCodes: shortcutKeys,
|
||||
allowCollision: true,
|
||||
};
|
||||
|
||||
ShortcutRegistry.registry.register(commitMoveShortcut, true);
|
||||
|
||||
this.scrollCurrentElementIntoView();
|
||||
this.moveIndicator = new MoveIndicator(this.draggable.workspace);
|
||||
this.repositionMoveIndicator();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the current element in the given direction.
|
||||
*
|
||||
* @param direction The direction to move the currently-moving element.
|
||||
* @param event The event that triggered this move, if any.
|
||||
* @returns True iff this action applies and has been performed.
|
||||
*/
|
||||
move(direction: Direction, event?: KeyboardEvent | PointerEvent) {
|
||||
switch (direction) {
|
||||
case Direction.UP:
|
||||
this.totalDelta.y -= this.stepDistance;
|
||||
break;
|
||||
case Direction.DOWN:
|
||||
this.totalDelta.y += this.stepDistance;
|
||||
break;
|
||||
case Direction.LEFT:
|
||||
this.totalDelta.x -= this.stepDistance;
|
||||
break;
|
||||
case Direction.RIGHT:
|
||||
this.totalDelta.x += this.stepDistance;
|
||||
break;
|
||||
}
|
||||
|
||||
this.dragger?.onDrag(event, this.totalPixelDelta());
|
||||
|
||||
this.updateTotalDelta();
|
||||
this.scrollCurrentElementIntoView();
|
||||
this.repositionMoveIndicator();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish moving the item that is currently being moved.
|
||||
*
|
||||
* @param event The event that triggered the end of the move, if any.
|
||||
* @returns True iff move successfully finished.
|
||||
*/
|
||||
finishMove(event?: KeyboardEvent | PointerEvent) {
|
||||
this.preDragEndCleanup();
|
||||
|
||||
this.dragger?.onDragEnd(event, this.totalPixelDelta());
|
||||
|
||||
this.postDragEndCleanup();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort moving the currently-focused item on workspace.
|
||||
*
|
||||
* @param event The event that triggered the end of the move, if any.
|
||||
* @returns True iff move successfully aborted.
|
||||
*/
|
||||
abortMove(event?: KeyboardEvent | PointerEvent) {
|
||||
this.preDragEndCleanup();
|
||||
|
||||
this.dragger?.onDragRevert(event, this.totalPixelDelta());
|
||||
|
||||
this.postDragEndCleanup();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the distance by which an object will be moved.
|
||||
*
|
||||
* @param stepDistance The distance in workspace coordinates that each move
|
||||
* should move elements on the workspace by.
|
||||
*/
|
||||
setMoveDistance(stepDistance: number) {
|
||||
this.stepDistance = stepDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of the names of shortcuts that are allowed to be run while
|
||||
* a keyboard-driven move is in progress.
|
||||
*/
|
||||
getAllowedShortcuts() {
|
||||
return this.allowedShortcuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds shortcuts with the given names to the list of shortcuts that are
|
||||
* allowed to be run while a keyboard-driven move is in progress.
|
||||
*/
|
||||
setAllowedShortcuts(shortcutNames: string[]) {
|
||||
this.allowedShortcuts = shortcutNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositions the move indicator to the corner of the item being moved.
|
||||
*/
|
||||
private repositionMoveIndicator() {
|
||||
renderManagement.finishQueuedRenders().then(() => {
|
||||
let bounds = this.draggable?.getBoundingRectangle();
|
||||
if (
|
||||
this.draggable &&
|
||||
'getBoundingRectangleWithoutChildren' in this.draggable
|
||||
) {
|
||||
bounds = this.positionForBlockMoveIndicator(this.draggable as BlockSvg);
|
||||
}
|
||||
|
||||
if (!bounds) return;
|
||||
|
||||
this.moveIndicator?.moveTo(
|
||||
this.draggable?.workspace.RTL ? bounds.left : bounds.right,
|
||||
bounds.top,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a bounding box used for positioning the move indicator on a block.
|
||||
* Blocks require special treatment because `BlockSvg.getBoundingRectangle()`
|
||||
* includes the bounds of nested and subsequent blocks. Since the move
|
||||
* indicator is positioned at the top right corner of the bounds, this can
|
||||
* result in it appearing to float in empty space when e.g. a small block has
|
||||
* a much wider block nested inside a statement input. BlockSvg also provides
|
||||
* `getBoundingRectangleWithoutChildren()`, which addresses that case, but is
|
||||
* insufficient because in the case of nested *value* blocks in a row, the
|
||||
* child blocks' bounds should contribute to the bounding box.
|
||||
*
|
||||
* @param block The block to retrieve an adjusted bounding box for.
|
||||
* @returns A bounding box for the given block whose top-right corner
|
||||
* corresponds to the maximum visual extent of the given block's row.
|
||||
*/
|
||||
private positionForBlockMoveIndicator(block: BlockSvg) {
|
||||
const navigator = block.workspace.getNavigator();
|
||||
let rightmost: IFocusableNode = block;
|
||||
let nextCandidate = null;
|
||||
// Find the rightmost element on the same visual row as the starting block.
|
||||
while ((nextCandidate = navigator.getInNode(rightmost))) {
|
||||
rightmost = nextCandidate;
|
||||
}
|
||||
|
||||
// Get the parent block of the rightmost element in the case where it is
|
||||
// e.g. a field.
|
||||
let targetBlock = navigator.getSourceBlockFromNode(rightmost);
|
||||
|
||||
// Work backwards from the rightmost block; deeply nested value blocks do
|
||||
// not have the same y position as their parent because they are visually
|
||||
// depicted as being inside of it. Keep working up the parent block
|
||||
// hierarchy until one is found with the same y position as the starting
|
||||
// block, meaning is is the rightmost top-level value block in the same row
|
||||
// as the starting block.
|
||||
const topline = block.getBoundingRectangleWithoutChildren().getOrigin().y;
|
||||
while (
|
||||
targetBlock?.getBoundingRectangleWithoutChildren().getOrigin().y !==
|
||||
topline
|
||||
) {
|
||||
targetBlock = targetBlock?.getParent() ?? null;
|
||||
}
|
||||
|
||||
return (
|
||||
targetBlock?.getBoundingRectangleWithoutChildren() ??
|
||||
block.getBoundingRectangleWithoutChildren()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common clean-up for finish/abort run before terminating the move.
|
||||
*/
|
||||
private preDragEndCleanup() {
|
||||
ShortcutRegistry.registry.unregister(COMMIT_MOVE_SHORTCUT);
|
||||
|
||||
// Remove the blur listener before ending the drag
|
||||
this.draggable
|
||||
?.getFocusableElement()
|
||||
.removeEventListener('blur', this.blurListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common clean-up for finish/abort run after terminating the move.
|
||||
*/
|
||||
private postDragEndCleanup() {
|
||||
this.moveIndicator?.dispose();
|
||||
this.moveIndicator = undefined;
|
||||
this.draggable = undefined;
|
||||
this.dragger = undefined;
|
||||
this.startLocation = undefined;
|
||||
this.totalDelta = new Coordinate(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total distance current element has moved in pixels.
|
||||
*/
|
||||
private totalPixelDelta() {
|
||||
const scale = this.draggable?.workspace.scale ?? 1;
|
||||
return new Coordinate(this.totalDelta.x * scale, this.totalDelta.y * scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the current element into view.
|
||||
*/
|
||||
private scrollCurrentElementIntoView() {
|
||||
if (!this.draggable) return;
|
||||
const bounds = this.draggable.getBoundingRectangle();
|
||||
this.draggable.workspace.scrollBoundsIntoView(bounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates the total movement delta from the starting location and the
|
||||
* current position of the item being moved.
|
||||
*/
|
||||
private updateTotalDelta() {
|
||||
if (!this.draggable || !this.startLocation) return;
|
||||
|
||||
this.totalDelta = new Coordinate(
|
||||
this.draggable.getRelativeToSurfaceXY().x - this.startLocation.x,
|
||||
this.draggable.getRelativeToSurfaceXY().y - this.startLocation.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2020 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview The class representing a line cursor.
|
||||
* A line cursor tries to traverse the blocks and connections on a block as if
|
||||
* they were lines of code in a text editor. Previous and next traverse previous
|
||||
* connections, next connections and blocks, while in and out traverse input
|
||||
* connections and fields.
|
||||
* @author aschmiedt@google.com (Abby Schmiedt)
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js';
|
||||
import {getFocusManager} from '../focus_manager.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import * as registry from '../registry.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
import {Marker} from './marker.js';
|
||||
|
||||
/**
|
||||
* Class for a line cursor.
|
||||
*/
|
||||
export class LineCursor extends Marker {
|
||||
override type = 'cursor';
|
||||
|
||||
/** Locations to try moving the cursor to after a deletion. */
|
||||
private potentialNodes: IFocusableNode[] | null = null;
|
||||
|
||||
/**
|
||||
* @param workspace The workspace this cursor belongs to.
|
||||
*/
|
||||
constructor(protected readonly workspace: WorkspaceSvg) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the next block or workspace comment in the pre-order
|
||||
* traversal.
|
||||
*
|
||||
* @returns The next node, or null if the current node is not set or there is
|
||||
* no next value.
|
||||
*/
|
||||
next(): IFocusableNode | null {
|
||||
const curNode = this.getCurNode();
|
||||
if (!curNode) {
|
||||
return null;
|
||||
}
|
||||
const newNode = this.getNextNode(
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (newNode) {
|
||||
this.setCurNode(newNode);
|
||||
}
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the next input connection or field
|
||||
* in the pre order traversal.
|
||||
*
|
||||
* @returns The next node, or null if the current node is
|
||||
* not set or there is no next value.
|
||||
*/
|
||||
in(): IFocusableNode | null {
|
||||
const curNode = this.getCurNode();
|
||||
if (!curNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newNode = this.getNextNode(curNode, () => true, true);
|
||||
|
||||
if (newNode) {
|
||||
this.setCurNode(newNode);
|
||||
}
|
||||
return newNode;
|
||||
}
|
||||
/**
|
||||
* Moves the cursor to the previous block or workspace comment in the
|
||||
* pre-order traversal.
|
||||
*
|
||||
* @returns The previous node, or null if the current node is not set or there
|
||||
* is no previous value.
|
||||
*/
|
||||
prev(): IFocusableNode | null {
|
||||
const curNode = this.getCurNode();
|
||||
if (!curNode) {
|
||||
return null;
|
||||
}
|
||||
const newNode = this.getPreviousNode(
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
(candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()) ||
|
||||
candidate instanceof RenderedWorkspaceComment
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (newNode) {
|
||||
this.setCurNode(newNode);
|
||||
}
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the previous input connection or field in the pre order
|
||||
* traversal.
|
||||
*
|
||||
* @returns The previous node, or null if the current node
|
||||
* is not set or there is no previous value.
|
||||
*/
|
||||
out(): IFocusableNode | null {
|
||||
const curNode = this.getCurNode();
|
||||
if (!curNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newNode = this.getPreviousNode(curNode, () => true, true);
|
||||
|
||||
if (newNode) {
|
||||
this.setCurNode(newNode);
|
||||
}
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true iff the node to which we would navigate if in() were
|
||||
* called is the same as the node to which we would navigate if next() were
|
||||
* called - in effect, if the LineCursor is at the end of the 'current
|
||||
* line' of the program.
|
||||
*/
|
||||
atEndOfLine(): boolean {
|
||||
const curNode = this.getCurNode();
|
||||
if (!curNode) return false;
|
||||
const inNode = this.getNextNode(curNode, () => true, true);
|
||||
const nextNode = this.getNextNode(
|
||||
curNode,
|
||||
(candidate: IFocusableNode | null) => {
|
||||
return (
|
||||
candidate instanceof BlockSvg &&
|
||||
!candidate.outputConnection?.targetBlock()
|
||||
);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
return inNode === nextNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses pre order traversal to navigate the Blockly AST. This will allow
|
||||
* a user to easily navigate the entire Blockly AST without having to go in
|
||||
* and out levels on the tree.
|
||||
*
|
||||
* @param node The current position in the AST.
|
||||
* @param isValid A function true/false depending on whether the given node
|
||||
* should be traversed.
|
||||
* @param visitedNodes A set of previously visited nodes used to avoid cycles.
|
||||
* @returns The next node in the traversal.
|
||||
*/
|
||||
private getNextNodeImpl(
|
||||
node: IFocusableNode | null,
|
||||
isValid: (p1: IFocusableNode | null) => boolean,
|
||||
visitedNodes: Set<IFocusableNode> = new Set<IFocusableNode>(),
|
||||
): IFocusableNode | null {
|
||||
if (!node || visitedNodes.has(node)) return null;
|
||||
let newNode =
|
||||
this.workspace.getNavigator().getFirstChild(node) ||
|
||||
this.workspace.getNavigator().getNextSibling(node);
|
||||
|
||||
let target = node;
|
||||
while (target && !newNode) {
|
||||
const parent = this.workspace.getNavigator().getParent(target);
|
||||
if (!parent) break;
|
||||
newNode = this.workspace.getNavigator().getNextSibling(parent);
|
||||
target = parent;
|
||||
}
|
||||
|
||||
if (isValid(newNode)) return newNode;
|
||||
if (newNode) {
|
||||
visitedNodes.add(node);
|
||||
return this.getNextNodeImpl(newNode, isValid, visitedNodes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next node in the AST, optionally allowing for loopback.
|
||||
*
|
||||
* @param node The current position in the AST.
|
||||
* @param isValid A function true/false depending on whether the given node
|
||||
* should be traversed.
|
||||
* @param loop Whether to loop around to the beginning of the workspace if no
|
||||
* valid node was found.
|
||||
* @returns The next node in the traversal.
|
||||
*/
|
||||
getNextNode(
|
||||
node: IFocusableNode | null,
|
||||
isValid: (p1: IFocusableNode | null) => boolean,
|
||||
loop: boolean,
|
||||
): IFocusableNode | null {
|
||||
if (!node || (!loop && this.getLastNode() === node)) return null;
|
||||
|
||||
return this.getNextNodeImpl(node, isValid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverses the pre order traversal in order to find the previous node. This
|
||||
* will allow a user to easily navigate the entire Blockly AST without having
|
||||
* to go in and out levels on the tree.
|
||||
*
|
||||
* @param node The current position in the AST.
|
||||
* @param isValid A function true/false depending on whether the given node
|
||||
* should be traversed.
|
||||
* @param visitedNodes A set of previously visited nodes used to avoid cycles.
|
||||
* @returns The previous node in the traversal or null if no previous node
|
||||
* exists.
|
||||
*/
|
||||
private getPreviousNodeImpl(
|
||||
node: IFocusableNode | null,
|
||||
isValid: (p1: IFocusableNode | null) => boolean,
|
||||
visitedNodes: Set<IFocusableNode> = new Set<IFocusableNode>(),
|
||||
): IFocusableNode | null {
|
||||
if (!node || visitedNodes.has(node)) return null;
|
||||
|
||||
const newNode =
|
||||
this.getRightMostChild(
|
||||
this.workspace.getNavigator().getPreviousSibling(node),
|
||||
node,
|
||||
) || this.workspace.getNavigator().getParent(node);
|
||||
|
||||
if (isValid(newNode)) return newNode;
|
||||
if (newNode) {
|
||||
visitedNodes.add(node);
|
||||
return this.getPreviousNodeImpl(newNode, isValid, visitedNodes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous node in the AST, optionally allowing for loopback.
|
||||
*
|
||||
* @param node The current position in the AST.
|
||||
* @param isValid A function true/false depending on whether the given node
|
||||
* should be traversed.
|
||||
* @param loop Whether to loop around to the end of the workspace if no valid
|
||||
* node was found.
|
||||
* @returns The previous node in the traversal or null if no previous node
|
||||
* exists.
|
||||
*/
|
||||
getPreviousNode(
|
||||
node: IFocusableNode | null,
|
||||
isValid: (p1: IFocusableNode | null) => boolean,
|
||||
loop: boolean,
|
||||
): IFocusableNode | null {
|
||||
if (!node || (!loop && this.getFirstNode() === node)) return null;
|
||||
|
||||
return this.getPreviousNodeImpl(node, isValid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the right most child of a node.
|
||||
*
|
||||
* @param node The node to find the right most child of.
|
||||
* @returns The right most child of the given node, or the node if no child
|
||||
* exists.
|
||||
*/
|
||||
private getRightMostChild(
|
||||
node: IFocusableNode | null,
|
||||
stopIfFound: IFocusableNode,
|
||||
): IFocusableNode | null {
|
||||
if (!node) return node;
|
||||
let newNode = this.workspace.getNavigator().getFirstChild(node);
|
||||
if (!newNode || newNode === stopIfFound) return node;
|
||||
for (
|
||||
let nextNode: IFocusableNode | null = newNode;
|
||||
nextNode;
|
||||
nextNode = this.workspace.getNavigator().getNextSibling(newNode)
|
||||
) {
|
||||
if (nextNode === stopIfFound) break;
|
||||
newNode = nextNode;
|
||||
}
|
||||
return this.getRightMostChild(newNode, stopIfFound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare for the deletion of a block by making a list of nodes we
|
||||
* could move the cursor to afterwards and save it to
|
||||
* this.potentialNodes.
|
||||
*
|
||||
* After the deletion has occurred, call postDelete to move it to
|
||||
* the first valid node on that list.
|
||||
*
|
||||
* The locations to try (in order of preference) are:
|
||||
*
|
||||
* - The current location.
|
||||
* - The connection to which the deleted block is attached.
|
||||
* - The block connected to the next connection of the deleted block.
|
||||
* - The parent block of the deleted block.
|
||||
* - A location on the workspace beneath the deleted block.
|
||||
*
|
||||
* N.B.: When block is deleted, all of the blocks conneccted to that
|
||||
* block's inputs are also deleted, but not blocks connected to its
|
||||
* next connection.
|
||||
*
|
||||
* @param deletedBlock The block that is being deleted.
|
||||
*/
|
||||
preDelete(deletedBlock: BlockSvg) {
|
||||
const curNode = this.getCurNode();
|
||||
|
||||
const nodes: IFocusableNode[] = curNode ? [curNode] : [];
|
||||
// The connection to which the deleted block is attached.
|
||||
const parentConnection =
|
||||
deletedBlock.previousConnection?.targetConnection ??
|
||||
deletedBlock.outputConnection?.targetConnection;
|
||||
if (parentConnection) {
|
||||
nodes.push(parentConnection);
|
||||
}
|
||||
// The block connected to the next connection of the deleted block.
|
||||
const nextBlock = deletedBlock.getNextBlock();
|
||||
if (nextBlock) {
|
||||
nodes.push(nextBlock);
|
||||
}
|
||||
// The parent block of the deleted block.
|
||||
const parentBlock = deletedBlock.getParent();
|
||||
if (parentBlock) {
|
||||
nodes.push(parentBlock);
|
||||
}
|
||||
// A location on the workspace beneath the deleted block.
|
||||
// Move to the workspace.
|
||||
nodes.push(this.workspace);
|
||||
this.potentialNodes = nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor to the first valid location in
|
||||
* this.potentialNodes, following a block deletion.
|
||||
*/
|
||||
postDelete() {
|
||||
const nodes = this.potentialNodes;
|
||||
this.potentialNodes = null;
|
||||
if (!nodes) throw new Error('must call preDelete first');
|
||||
for (const node of nodes) {
|
||||
if (!this.getSourceBlockFromNode(node)?.disposed) {
|
||||
this.setCurNode(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('no valid nodes in this.potentialNodes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current location of the cursor.
|
||||
*
|
||||
* Overrides normal Marker getCurNode to update the current node from the
|
||||
* selected block. This typically happens via the selection listener but that
|
||||
* is not called immediately when `Gesture` calls
|
||||
* `Blockly.common.setSelected`. In particular the listener runs after showing
|
||||
* the context menu.
|
||||
*
|
||||
* @returns The current field, connection, or block the cursor is on.
|
||||
*/
|
||||
getCurNode(): IFocusableNode | null {
|
||||
return getFocusManager().getFocusedNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the location of the cursor and draw it.
|
||||
*
|
||||
* Overrides normal Marker setCurNode logic to call
|
||||
* this.drawMarker() instead of this.drawer.draw() directly.
|
||||
*
|
||||
* @param newNode The new location of the cursor.
|
||||
*/
|
||||
setCurNode(newNode: IFocusableNode) {
|
||||
getFocusManager().focusNode(newNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first navigable node on the workspace, or null if none exist.
|
||||
*
|
||||
* @returns The first navigable node on the workspace, or null.
|
||||
*/
|
||||
getFirstNode(): IFocusableNode | null {
|
||||
return this.workspace.getNavigator().getFirstChild(this.workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last navigable node on the workspace, or null if none exist.
|
||||
*
|
||||
* @returns The last navigable node on the workspace, or null.
|
||||
*/
|
||||
getLastNode(): IFocusableNode | null {
|
||||
const first = this.getFirstNode();
|
||||
return this.getPreviousNode(first, () => true, true);
|
||||
}
|
||||
}
|
||||
|
||||
registry.register(registry.Type.CURSOR, registry.DEFAULT, LineCursor);
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The class representing a marker.
|
||||
* Used primarily for keyboard navigation to show a marked location.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
// Former goog.module ID: Blockly.Marker
|
||||
|
||||
import {BlockSvg} from '../block_svg.js';
|
||||
import {Field} from '../field.js';
|
||||
import {Icon} from '../icons/icon.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import {RenderedConnection} from '../rendered_connection.js';
|
||||
|
||||
/**
|
||||
* Class for a marker.
|
||||
* This is used in keyboard navigation to save a location in the Blockly AST.
|
||||
*/
|
||||
export class Marker {
|
||||
/** The colour of the marker. */
|
||||
colour: string | null = null;
|
||||
|
||||
/** The current location of the marker. */
|
||||
protected curNode: IFocusableNode | null = null;
|
||||
|
||||
/** The type of the marker. */
|
||||
type = 'marker';
|
||||
|
||||
/**
|
||||
* Gets the current location of the marker.
|
||||
*
|
||||
* @returns The current field, connection, or block the marker is on.
|
||||
*/
|
||||
getCurNode(): IFocusableNode | null {
|
||||
return this.curNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the location of the marker and call the update method.
|
||||
*
|
||||
* @param newNode The new location of the marker, or null to remove it.
|
||||
*/
|
||||
setCurNode(newNode: IFocusableNode | null) {
|
||||
this.curNode = newNode;
|
||||
}
|
||||
|
||||
/** Dispose of this marker. */
|
||||
dispose() {
|
||||
this.curNode = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block that the given node is a child of.
|
||||
*
|
||||
* @returns The parent block of the node if any, otherwise null.
|
||||
*/
|
||||
getSourceBlockFromNode(node: IFocusableNode | null): BlockSvg | null {
|
||||
if (node instanceof BlockSvg) {
|
||||
return node;
|
||||
} else if (node instanceof Field) {
|
||||
return node.getSourceBlock() as BlockSvg;
|
||||
} else if (node instanceof RenderedConnection) {
|
||||
return node.getSourceBlock();
|
||||
} else if (node instanceof Icon) {
|
||||
return node.getSourceBlock() as BlockSvg;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block that this marker's current node is a child of.
|
||||
*
|
||||
* @returns The parent block of the marker's current node if any, otherwise
|
||||
* null.
|
||||
*/
|
||||
getSourceBlock(): BlockSvg | null {
|
||||
return this.getSourceBlockFromNode(this.getCurNode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Raspberry Pi Foundation
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as dom from '../utils/dom.js';
|
||||
import {Svg} from '../utils/svg.js';
|
||||
import type {WorkspaceSvg} from '../workspace_svg.js';
|
||||
|
||||
/**
|
||||
* Four-way arrow indicator attached to a workspace element to indicate that it
|
||||
* is being moved.
|
||||
*/
|
||||
export class MoveIndicator {
|
||||
/**
|
||||
* Root SVG element for the indicator.
|
||||
*/
|
||||
svgRoot: SVGGElement;
|
||||
|
||||
/**
|
||||
* Creates a new move indicator.
|
||||
*
|
||||
* @param workspace The workspace the indicator should be displayed on.
|
||||
*/
|
||||
constructor(private workspace: WorkspaceSvg) {
|
||||
this.svgRoot = dom.createSvgElement(
|
||||
Svg.G,
|
||||
{},
|
||||
// Mutator workspaces don't have a drag layer, so fall back to the block
|
||||
// layer.
|
||||
workspace.getLayerManager()?.getDragLayer() ??
|
||||
workspace.getLayerManager()?.getBlockLayer(),
|
||||
);
|
||||
this.svgRoot.classList.add('blocklyMoveIndicator');
|
||||
const rtl = workspace.RTL;
|
||||
dom.createSvgElement(
|
||||
Svg.CIRCLE,
|
||||
{
|
||||
'fill': 'white',
|
||||
'fill-opacity': '0.8',
|
||||
'stroke': 'grey',
|
||||
'stroke-width': '1',
|
||||
'r': 20,
|
||||
'cx': 20 * (rtl ? -1 : 1),
|
||||
'cy': 20,
|
||||
},
|
||||
this.svgRoot,
|
||||
);
|
||||
dom.createSvgElement(
|
||||
Svg.PATH,
|
||||
{
|
||||
'fill': 'none',
|
||||
'stroke': 'black',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': '2',
|
||||
'd': 'm18 9l3 3l-3 3m-3-3h6M6 9l-3 3l3 3m-3-3h6m0 6l3 3l3-3m-3-3v6m3-15l-3-3l-3 3m3-3v6',
|
||||
'transform': `translate(${(rtl ? -4 : 1) * 8} 8)`,
|
||||
},
|
||||
this.svgRoot,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves this indicator to the specified location.
|
||||
*
|
||||
* @param x The location on the X axis to move to.
|
||||
* @param y The location on the Y axis to move to.
|
||||
*/
|
||||
moveTo(x: number, y: number) {
|
||||
this.svgRoot.setAttribute(
|
||||
'transform',
|
||||
`translate(${x + (this.workspace.RTL ? 20 : -20)}, ${y - 20})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of this move indicator.
|
||||
*/
|
||||
dispose() {
|
||||
dom.removeNode(this.svgRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {BlockSvg} from '../../block_svg.js';
|
||||
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
|
||||
import {hasBubble} from '../../interfaces/i_has_bubble.js';
|
||||
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
|
||||
import {RenderedConnection} from '../../rendered_connection.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a block.
|
||||
*/
|
||||
export class BlockNavigationPolicy implements INavigationPolicy<BlockSvg> {
|
||||
/**
|
||||
* Returns the first child of the given block.
|
||||
*
|
||||
* @param current The block to return the first child of.
|
||||
* @returns The first icon, field, or input of the given block, if any.
|
||||
*/
|
||||
getFirstChild(current: BlockSvg): IFocusableNode | null {
|
||||
return getBlockNavigationCandidates(current)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given block.
|
||||
*
|
||||
* @param current The block to return the parent of.
|
||||
* @returns The top block of the given block's stack, or the connection to
|
||||
* which it is attached.
|
||||
*/
|
||||
getParent(current: BlockSvg): IFocusableNode | null {
|
||||
if (current.previousConnection?.targetBlock()) {
|
||||
const surroundParent = current.getSurroundParent();
|
||||
if (surroundParent) return surroundParent;
|
||||
} else if (current.outputConnection?.targetBlock()) {
|
||||
return current.outputConnection.targetBlock();
|
||||
}
|
||||
|
||||
return current.workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given block.
|
||||
*
|
||||
* @param current The block to find the following element of.
|
||||
* @returns The block's next connection, or the next peer on its parent block,
|
||||
* otherwise null.
|
||||
*/
|
||||
getNextSibling(current: BlockSvg): IFocusableNode | null {
|
||||
if (current.nextConnection) {
|
||||
return current.nextConnection.targetBlock() ?? current.nextConnection;
|
||||
} else if (current.outputConnection?.targetConnection) {
|
||||
const parent = this.getParent(current) as BlockSvg;
|
||||
return navigateBlock(parent, current, 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given block.
|
||||
*
|
||||
* @param current The block to find the preceding element of.
|
||||
* @returns The block's previous connection, or the previous peer on its
|
||||
* parent block, otherwise null.
|
||||
*/
|
||||
getPreviousSibling(current: BlockSvg): IFocusableNode | null {
|
||||
if (current.previousConnection?.targetBlock()) {
|
||||
return current.previousConnection;
|
||||
} else if (current.outputConnection?.targetBlock()) {
|
||||
return navigateBlock(
|
||||
current.outputConnection.targetBlock()!,
|
||||
current,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visual row ID of the given block.
|
||||
*
|
||||
* @param current The block to retrieve the row ID of.
|
||||
* @returns The row ID of the given block.
|
||||
*/
|
||||
getRowId(current: BlockSvg) {
|
||||
return current.getRowId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given block can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given block can be focused.
|
||||
*/
|
||||
isNavigable(current: BlockSvg): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is a BlockSvg.
|
||||
*/
|
||||
isApplicable(current: any): current is BlockSvg {
|
||||
return current instanceof BlockSvg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of the navigable children of the given block.
|
||||
*
|
||||
* @param block The block to retrieve the navigable children of.
|
||||
* @returns A list of navigable/focusable children of the given block.
|
||||
*/
|
||||
function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] {
|
||||
const candidates: IFocusableNode[] = [];
|
||||
|
||||
// Icons and open bubbles are navigable.
|
||||
for (const icon of block.getIcons()) {
|
||||
// Icons hidden when the block is collapsed shouldn't be navigable.
|
||||
if (block.isCollapsed() && !icon.isShownWhenCollapsed()) continue;
|
||||
candidates.push(icon);
|
||||
let bubble;
|
||||
if (
|
||||
hasBubble(icon) &&
|
||||
icon.bubbleIsVisible() &&
|
||||
(bubble = icon.getBubble())
|
||||
) {
|
||||
candidates.push(bubble);
|
||||
}
|
||||
}
|
||||
|
||||
for (const input of block.inputList) {
|
||||
// Invisible inputs are not valid navigation candidates.
|
||||
if (!input.isVisible()) continue;
|
||||
|
||||
// Fields are navigable.
|
||||
candidates.push(...input.fieldRow);
|
||||
|
||||
// Connections on inputs are navigable.
|
||||
const connection = input.connection;
|
||||
if (!connection) continue;
|
||||
candidates.push(connection as RenderedConnection);
|
||||
|
||||
// Child blocks attached to inputs are navigable.
|
||||
const attachedBlock = connection.targetBlock();
|
||||
if (!attachedBlock) continue;
|
||||
candidates.push(attachedBlock as BlockSvg);
|
||||
|
||||
// The last (empty) next connection in a child statement block stack is
|
||||
// navigable.
|
||||
const lastConnection = attachedBlock.lastConnectionInStack(false);
|
||||
if (!lastConnection) continue;
|
||||
candidates.push(lastConnection as RenderedConnection);
|
||||
}
|
||||
|
||||
// The block's next connection is navigable.
|
||||
if (block.nextConnection && !block.isCollapsed()) {
|
||||
candidates.push(block.nextConnection);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next navigable item relative to the provided block child.
|
||||
*
|
||||
* @param block The block whose children should be navigated.
|
||||
* @param current The navigable block child item to navigate relative to.
|
||||
* @param delta The difference in index to navigate; positive values navigate
|
||||
* forward by n, while negative values navigate backwards by n.
|
||||
* @returns The navigable block child offset by `delta` relative to `current`.
|
||||
*/
|
||||
export function navigateBlock(
|
||||
block: BlockSvg,
|
||||
current: IFocusableNode,
|
||||
delta: number,
|
||||
): IFocusableNode | null {
|
||||
const candidates = getBlockNavigationCandidates(block);
|
||||
const currentIndex = candidates.indexOf(current);
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const targetIndex = currentIndex + delta;
|
||||
if (targetIndex >= 0 && targetIndex < candidates.length) {
|
||||
return candidates[targetIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {BlockSvg} from '../../block_svg.js';
|
||||
import {Bubble} from '../../bubbles/bubble.js';
|
||||
import type {Icon} from '../../icons/icon.js';
|
||||
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
|
||||
import {navigateBlock} from './block_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a Bubble.
|
||||
*/
|
||||
export class BubbleNavigationPolicy implements INavigationPolicy<Bubble> {
|
||||
/**
|
||||
* Returns the first child of the given bubble.
|
||||
*
|
||||
* @param _current The bubble to return the first child of.
|
||||
* @returns Null.
|
||||
*/
|
||||
getFirstChild(_current: Bubble): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given bubble.
|
||||
*
|
||||
* @param current The bubble to return the parent of.
|
||||
* @returns The parent block of the given bubble.
|
||||
*/
|
||||
getParent(current: Bubble): IFocusableNode | null {
|
||||
return current.getOwner() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next peer node of the given bubble.
|
||||
*
|
||||
* @param current The bubble to find the following element of.
|
||||
* @returns The next navigable item on the bubble's icon's parent block.
|
||||
*/
|
||||
getNextSibling(current: Bubble): IFocusableNode | null {
|
||||
return navigateBlock(
|
||||
(current.getOwner() as Icon | undefined)?.getSourceBlock() as BlockSvg,
|
||||
current,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous peer node of the given bubble.
|
||||
*
|
||||
* @param current The bubble to find the preceding element of.
|
||||
* @returns The previous navigable item on the bubble's icon's parent block.
|
||||
*/
|
||||
getPreviousSibling(current: Bubble): IFocusableNode | null {
|
||||
return navigateBlock(
|
||||
(current.getOwner() as Icon | undefined)?.getSourceBlock() as BlockSvg,
|
||||
current,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the row ID of the given bubble.
|
||||
*
|
||||
* @param current The bubble to retrieve the row ID of.
|
||||
* @returns The row ID of the given bubble.
|
||||
*/
|
||||
getRowId(current: Bubble) {
|
||||
return (
|
||||
(
|
||||
(current.getOwner() as Icon | undefined)?.getSourceBlock() as
|
||||
| BlockSvg
|
||||
| undefined
|
||||
)?.getRowId() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given bubble can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given bubble can be focused.
|
||||
*/
|
||||
isNavigable(current: Bubble): boolean {
|
||||
return current.canBeFocused();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is an Bubble.
|
||||
*/
|
||||
isApplicable(current: any): current is Bubble {
|
||||
return current instanceof Bubble;
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -4,16 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {CommentBarButton} from '../comments/comment_bar_button.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import {CommentBarButton} from '../../comments/comment_bar_button.js';
|
||||
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a CommentBarButton.
|
||||
*/
|
||||
export class CommentBarButtonNavigationPolicy
|
||||
implements INavigationPolicy<CommentBarButton>
|
||||
{
|
||||
export class CommentBarButtonNavigationPolicy implements INavigationPolicy<CommentBarButton> {
|
||||
/**
|
||||
* Returns the first child of the given CommentBarButton.
|
||||
*
|
||||
@@ -66,6 +64,16 @@ export class CommentBarButtonNavigationPolicy
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the row ID of the given CommentBarButton.
|
||||
*
|
||||
* @param current The CommentBarButton to retrieve the row ID of.
|
||||
* @returns The row ID of the given CommentBarButton.
|
||||
*/
|
||||
getRowId(current: CommentBarButton) {
|
||||
return current.getCommentView().commentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given CommentBarButton can be navigated to.
|
||||
*
|
||||
+14
-6
@@ -4,18 +4,16 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {CommentEditor} from '../comments/comment_editor.js';
|
||||
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js';
|
||||
import {CommentEditor} from '../../comments/comment_editor.js';
|
||||
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a comment editor.
|
||||
* This is a no-op placeholder (other than isNavigable/isApplicable) since
|
||||
* comment editors handle their own navigation when editing ends.
|
||||
*/
|
||||
export class CommentEditorNavigationPolicy
|
||||
implements INavigationPolicy<CommentEditor>
|
||||
{
|
||||
export class CommentEditorNavigationPolicy implements INavigationPolicy<CommentEditor> {
|
||||
getFirstChild(_current: CommentEditor): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
@@ -32,6 +30,16 @@ export class CommentEditorNavigationPolicy
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the row ID of the given comment editor.
|
||||
*
|
||||
* @param current The comment editor to retrieve the row ID of.
|
||||
* @returns The row ID of the given comment editor.
|
||||
*/
|
||||
getRowId(current: CommentEditor) {
|
||||
return current.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given comment editor can be navigated to.
|
||||
*
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {BlockSvg} from '../../block_svg.js';
|
||||
import {ConnectionType} from '../../connection_type.js';
|
||||
import type {IFocusableNode} from '../../interfaces/i_focusable_node.js';
|
||||
import type {INavigationPolicy} from '../../interfaces/i_navigation_policy.js';
|
||||
import {RenderedConnection} from '../../rendered_connection.js';
|
||||
import {navigateBlock} from './block_navigation_policy.js';
|
||||
|
||||
/**
|
||||
* Set of rules controlling keyboard navigation from a connection.
|
||||
*/
|
||||
export class ConnectionNavigationPolicy implements INavigationPolicy<RenderedConnection> {
|
||||
/**
|
||||
* Returns the first child of a connection.
|
||||
*
|
||||
* @returns Null, as connections do not have children.
|
||||
*/
|
||||
getFirstChild(): IFocusableNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent of the given connection.
|
||||
*
|
||||
* @param current The connection to return the parent of.
|
||||
* @returns The given connection's parent block.
|
||||
*/
|
||||
getParent(current: RenderedConnection): IFocusableNode | null {
|
||||
return current.getSourceBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next element following the given connection.
|
||||
*
|
||||
* @param current The connection to navigate from.
|
||||
* @returns The field, input connection or block following this connection.
|
||||
*/
|
||||
getNextSibling(current: RenderedConnection): IFocusableNode | null {
|
||||
if (current.getParentInput()) {
|
||||
return navigateBlock(current.getSourceBlock(), current, 1);
|
||||
} else if (
|
||||
current.type === ConnectionType.NEXT_STATEMENT &&
|
||||
current.getSourceBlock().getSurroundParent() &&
|
||||
!current.targetConnection
|
||||
) {
|
||||
return navigateBlock(
|
||||
current.getSourceBlock().getSurroundParent()!,
|
||||
current,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
switch (current.type) {
|
||||
case ConnectionType.NEXT_STATEMENT:
|
||||
return current.targetConnection;
|
||||
case ConnectionType.PREVIOUS_STATEMENT:
|
||||
case ConnectionType.OUTPUT_VALUE:
|
||||
return current.getSourceBlock();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the element preceding the given connection.
|
||||
*
|
||||
* @param current The connection to navigate from.
|
||||
* @returns The field, input connection or block preceding this connection.
|
||||
*/
|
||||
getPreviousSibling(current: RenderedConnection): IFocusableNode | null {
|
||||
if (current.getParentInput()) {
|
||||
return navigateBlock(
|
||||
current.getParentInput()!.getSourceBlock() as BlockSvg,
|
||||
current,
|
||||
-1,
|
||||
);
|
||||
}
|
||||
switch (current.type) {
|
||||
case ConnectionType.NEXT_STATEMENT:
|
||||
return current.getSourceBlock();
|
||||
case ConnectionType.PREVIOUS_STATEMENT:
|
||||
case ConnectionType.OUTPUT_VALUE:
|
||||
return current.targetConnection;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the row ID of the given connection.
|
||||
*
|
||||
* @param current The connection to retrieve the row ID of.
|
||||
* @returns The row ID of the given connection.
|
||||
*/
|
||||
getRowId(current: RenderedConnection) {
|
||||
switch (current.type) {
|
||||
case ConnectionType.NEXT_STATEMENT:
|
||||
case ConnectionType.PREVIOUS_STATEMENT:
|
||||
return current.id;
|
||||
case ConnectionType.INPUT_VALUE:
|
||||
return current.getParentInput()!.getRowId();
|
||||
case ConnectionType.OUTPUT_VALUE:
|
||||
default:
|
||||
return current.getSourceBlock().getRowId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the given connection can be navigated to.
|
||||
*
|
||||
* @param current The instance to check for navigability.
|
||||
* @returns True if the given connection can be focused.
|
||||
*/
|
||||
isNavigable(current: RenderedConnection): boolean {
|
||||
if (!current.canBeFocused()) return false;
|
||||
|
||||
// Empty next connections on block stacks inside of a C shaped block are
|
||||
// navigable.
|
||||
if (current.type === ConnectionType.NEXT_STATEMENT) {
|
||||
if (current.targetBlock()) return false;
|
||||
|
||||
const rootBlock =
|
||||
current.getSourceBlock().getRootBlock() ?? current.getSourceBlock();
|
||||
if (current === rootBlock.lastConnectionInStack(false)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empty input connections are navigable.
|
||||
return (
|
||||
current.type === ConnectionType.INPUT_VALUE && !current.targetBlock()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given object can be navigated from by this policy.
|
||||
*
|
||||
* @param current The object to check if this policy applies to.
|
||||
* @returns True if the object is a RenderedConnection.
|
||||
*/
|
||||
isApplicable(current: any): current is RenderedConnection {
|
||||
return current instanceof RenderedConnection;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user