/**
 * @license
 * Copyright 2021 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Holds all methods necessary to use Blockly through the
 * keyboard.
 * @author aschmiedt@google.com (Abby Schmiedt)
 */

import * as Blockly from 'blockly/core';
import * as Constants from './constants';
import {
  registrationName as cursorRegistrationName,
  registrationType as cursorRegistrationType,
} from './flyout_cursor';

/**
 * Class that holds all methods necessary for keyboard navigation to work.
 */
export class Navigation {
  /**
   * Wrapper for method that deals with workspace changes.
   * Used for removing change listener.
   */
  protected wsChangeWrapper: (e: Blockly.Events.Abstract) => void;

  /**
   * Wrapper for method that deals with flyout changes.
   * Used for removing change listener.
   */
  protected flyoutChangeWrapper: (e: Blockly.Events.Abstract) => void;

  /**
   * The list of registered workspaces.
   * Used when removing change listeners in dispose.
   */
  protected workspaces: Blockly.WorkspaceSvg[] = [];

  /**
   * Constructor for keyboard navigation.
   */
  constructor() {
    this.wsChangeWrapper = this.workspaceChangeListener.bind(this);
    this.flyoutChangeWrapper = this.flyoutChangeListener.bind(this);
  }

  /**
   * Adds all necessary change listeners and markers to a workspace for keyboard
   * navigation to work. This must be called for keyboard navigation to work
   * on a workspace.
   *
   * @param workspace The workspace to add keyboard navigation to.
   */
  addWorkspace(workspace: Blockly.WorkspaceSvg) {
    this.workspaces.push(workspace);
    const flyout = workspace.getFlyout();
    workspace.addChangeListener(this.wsChangeWrapper);

    if (flyout) {
      this.addFlyout(flyout);
    }
  }

  /**
   * Removes all keyboard navigation change listeners and markers.
   *
   * @param workspace The workspace to remove keyboard navigation from.
   */
  removeWorkspace(workspace: Blockly.WorkspaceSvg) {
    const workspaceIdx = this.workspaces.indexOf(workspace);
    const flyout = workspace.getFlyout();

    if (workspace.getCursor()) {
      this.disableKeyboardAccessibility(workspace);
    }

    if (workspaceIdx > -1) {
      this.workspaces.splice(workspaceIdx, 1);
    }
    workspace.removeChangeListener(this.wsChangeWrapper);

    if (flyout) {
      this.removeFlyout(flyout);
    }
  }

  /**
   * Gets the navigation state of the current workspace.
   *
   * Note that this assumes a workspace with passive focus (including for its
   * toolbox or flyout) has a state of NOWHERE.
   *
   * @param workspace The workspace to get the state of.
   * @returns The state of the given workspace.
   */
  getState(workspace: Blockly.WorkspaceSvg): Constants.STATE {
    const focusedTree = Blockly.getFocusManager().getFocusedTree();
    if (focusedTree instanceof Blockly.WorkspaceSvg) {
      if (focusedTree.isFlyout) {
        return Constants.STATE.FLYOUT;
      } else {
        return Constants.STATE.WORKSPACE;
      }
    } else if (focusedTree instanceof Blockly.Toolbox) {
      if (workspace === focusedTree.getWorkspace()) {
        return Constants.STATE.TOOLBOX;
      }
    } else if (focusedTree instanceof Blockly.Flyout) {
      return Constants.STATE.FLYOUT;
    }
    // Either a non-Blockly element currently has DOM focus, or a different
    // workspace holds it.
    return Constants.STATE.NOWHERE;
  }

  /**
   * Adds all event listeners and cursors to the flyout that are needed for
   * keyboard navigation to work.
   *
   * @param flyout The flyout to add a cursor and change listeners to.
   */
  addFlyout(flyout: Blockly.IFlyout) {
    const flyoutWorkspace = flyout.getWorkspace();
    flyoutWorkspace.addChangeListener(this.flyoutChangeWrapper);
    const FlyoutCursorClass = Blockly.registry.getClass(
      cursorRegistrationType,
      cursorRegistrationName,
    );
    if (FlyoutCursorClass) {
      flyoutWorkspace
        .getMarkerManager()
        .setCursor(new FlyoutCursorClass(flyout));
    }
  }

  /**
   * Removes all change listeners from the flyout that are needed for
   * keyboard navigation to work.
   *
   * @param flyout The flyout to add a cursor and event listeners to.
   */
  removeFlyout(flyout: Blockly.IFlyout) {
    const flyoutWorkspace = flyout.getWorkspace();
    flyoutWorkspace.removeChangeListener(this.flyoutChangeWrapper);
  }

  /**
   * Updates the state of keyboard navigation and the position of the cursor
   * based on workspace events.
   *
   * @param e The Blockly event to process.
   */
  workspaceChangeListener(e: Blockly.Events.Abstract) {
    if (!e.workspaceId) {
      return;
    }
    const workspace = Blockly.Workspace.getById(
      e.workspaceId,
    ) as Blockly.WorkspaceSvg;
    if (!workspace || !workspace.keyboardAccessibilityMode) {
      return;
    }
    if (e.type === Blockly.Events.BLOCK_CHANGE) {
      if ((e as Blockly.Events.BlockChange).element === 'mutation') {
        this.handleBlockMutation(workspace, e as Blockly.Events.BlockChange);
      }
    }
  }

  /**
   * Updates the state of keyboard navigation and the position of the cursor
   * based on events emitted from the flyout's workspace.
   *
   * @param e The Blockly event to process.
   */
  flyoutChangeListener(e: Blockly.Events.Abstract) {
    if (!e.workspaceId) {
      return;
    }
    const flyoutWorkspace = Blockly.Workspace.getById(
      e.workspaceId,
    ) as Blockly.WorkspaceSvg | null;
    const mainWorkspace = flyoutWorkspace?.targetWorkspace;
    if (!mainWorkspace) {
      return;
    }
    const flyout = mainWorkspace.getFlyout();
    if (!flyout) {
      return;
    }

    // This is called for simple toolboxes and for toolboxes that have a flyout
    // that does not close. Autoclosing flyouts close before we need to focus
    // the cursor on the block that was clicked.
    if (
      mainWorkspace &&
      mainWorkspace.keyboardAccessibilityMode &&
      !flyout.autoClose
    ) {
      if (
        e.type === Blockly.Events.CLICK &&
        (e as Blockly.Events.Click).targetType === 'block'
      ) {
        const {blockId} = e as Blockly.Events.Click;
        if (blockId) {
          const block = flyoutWorkspace.getBlockById(blockId);
          if (block) {
            this.handleBlockClickInFlyout(mainWorkspace, block);
          }
        }
      } else if (e.type === Blockly.Events.SELECTED) {
        const {newElementId} = e as Blockly.Events.Selected;
        if (newElementId) {
          const block = flyoutWorkspace.getBlockById(newElementId);
          if (block) {
            this.handleBlockClickInFlyout(mainWorkspace, block);
          }
        }
      }
    } else if (
      e.type === Blockly.Events.BLOCK_CREATE &&
      this.getState(mainWorkspace) === Constants.STATE.FLYOUT
    ) {
      // When variables are created, that recreates the flyout contents, leaving the
      // cursor in an invalid state.
      this.defaultFlyoutCursorIfNeeded(mainWorkspace);
    }
  }

  private isFlyoutItemDisposed(
    node: Blockly.IFocusableNode,
    sourceBlock: Blockly.BlockSvg | null,
  ) {
    if (sourceBlock?.disposed) {
      return true;
    }
    if (node instanceof Blockly.FlyoutButton) {
      return node.getSvgRoot().parentNode === null;
    }
    return false;
  }

  /**
   * Moves the cursor to the block level when the block the cursor is on
   * mutates.
   *
   * @param workspace The workspace the cursor belongs
   *     to.
   * @param e The Blockly event to process.
   */
  handleBlockMutation(
    workspace: Blockly.WorkspaceSvg,
    e: Blockly.Events.BlockChange,
  ) {
    const mutatedBlockId = e.blockId;
    const cursor = workspace.getCursor();
    const block = cursor?.getSourceBlock();
    if (block && block.id === mutatedBlockId) {
      cursor?.setCurNode(block);
    }
  }

  /**
   * Handles when a user clicks on a block in the flyout by moving the cursor
   * to that stack of blocks and setting the state of navigation to the flyout.
   *
   * @param mainWorkspace The workspace the user clicked on.
   * @param block The block the user clicked on.
   */
  handleBlockClickInFlyout(
    mainWorkspace: Blockly.WorkspaceSvg,
    block: Blockly.BlockSvg,
  ) {
    if (!block) {
      return;
    }
    const curNodeBlock = block.isShadow() ? block : block.getParent();
    if (curNodeBlock) {
      this.getFlyoutCursor(mainWorkspace)?.setCurNode(curNodeBlock);
    }
    const flyout = mainWorkspace.getFlyout();
    if (flyout) {
      Blockly.getFocusManager().focusTree(flyout.getWorkspace());
    }
  }

  /**
   * Move the flyout cursor to the preferred end if unset (as it is initially despite
   * the types) or on a disposed item.
   *
   * @param workspace The workspace.
   * @param prefer The preferred default position.
   * @return true if the cursor location was defaulted.
   */
  defaultFlyoutCursorIfNeeded(
    workspace: Blockly.WorkspaceSvg,
    prefer: 'first' | 'last' = 'first',
  ) {
    const flyout = workspace.getFlyout();
    if (!flyout) return false;
    const flyoutCursor = this.getFlyoutCursor(workspace);
    if (!flyoutCursor) return false;

    const curNode = flyoutCursor.getCurNode();
    const sourceBlock = flyoutCursor.getSourceBlock();
    if (curNode && !this.isFlyoutItemDisposed(curNode, sourceBlock))
      return false;

    const flyoutContents = flyout.getContents();
    const defaultFlyoutItem =
      prefer === 'first'
        ? flyoutContents[0]
        : flyoutContents[flyoutContents.length - 1];
    if (!defaultFlyoutItem) return false;
    const defaultFlyoutItemElement = defaultFlyoutItem.getElement();
    flyoutCursor.setCurNode(defaultFlyoutItemElement);
    return true;
  }

  /**
   * Sets the cursor location when focusing the workspace.
   * Tries the following, in order, stopping after the first success:
   *  - Resume editing by returning the cursor to its previous location, if valid.
   *  - Move the cursor to the top connection point on on the first top block.
   *  - Move the cursor to the default location on the workspace.
   *
   * @param workspace The main Blockly workspace.
   * @param prefer The preferred default position.
   * @return true if the cursor location was defaulted.
   */
  defaultWorkspaceCursorPositionIfNeeded(
    workspace: Blockly.WorkspaceSvg,
    prefer: 'first' | 'last' = 'first',
  ) {
    const topBlocks = workspace.getTopBlocks(true);
    const cursor = workspace.getCursor();
    if (!cursor) {
      return;
    }
    const disposed = cursor.getSourceBlock()?.disposed;
    if (cursor.getCurNode() && !disposed) {
      // Retain the cursor's previous position since it's set, but only if not
      // disposed (which can happen when blocks are reloaded).
      return false;
    }
    if (topBlocks.length > 0) {
      cursor.setCurNode(
        topBlocks[prefer === 'first' ? 0 : topBlocks.length - 1],
      );
    } else {
      cursor.setCurNode(workspace);
    }
    return true;
  }

  /**
   * Gets the cursor on the flyout's workspace.
   *
   * @param workspace The main workspace the flyout is on.
   * @returns The flyout's cursor or null if no flyout exists.
   */
  getFlyoutCursor(workspace: Blockly.WorkspaceSvg): Blockly.LineCursor | null {
    const flyout = workspace.getFlyout();
    const cursor = flyout ? flyout.getWorkspace().getCursor() : null;

    return cursor;
  }

  /**
   * Tries to intelligently connect the blocks or connections
   * represented by the given nodes, based on node types and locations.
   *
   * @param stationaryNode The first node to connect.
   * @param movingBlock The block we're moving.
   * @returns True if the key was handled; false if something went
   *     wrong.
   */
  findInsertStartPoint(
    stationaryNode: Blockly.IFocusableNode,
    movingBlock: Blockly.BlockSvg,
  ): Blockly.RenderedConnection | null {
    const movingHasOutput = !!movingBlock.outputConnection;

    if (stationaryNode instanceof Blockly.Field) {
      // Can't connect a block to a field, so try going up to the source block.
      const sourceBlock = stationaryNode.getSourceBlock() as Blockly.BlockSvg;
      if (!sourceBlock) return null;
      return this.findInsertStartPoint(sourceBlock, movingBlock);
    } else if (stationaryNode instanceof Blockly.RenderedConnection) {
      // Move to the block if we're trying to insert a statement block into
      // a value connection.
      if (
        !movingHasOutput &&
        stationaryNode.type === Blockly.ConnectionType.INPUT_VALUE
      ) {
        const sourceBlock = stationaryNode.getSourceBlock();
        if (!sourceBlock) return null;
        return this.findInsertStartPoint(sourceBlock, movingBlock);
      }

      // Connect the moving block to the stationary connection using
      // the most plausible connection on the moving block.
      return stationaryNode;
    } else if (stationaryNode instanceof Blockly.WorkspaceSvg) {
      return null;
    } else if (stationaryNode instanceof Blockly.BlockSvg) {
      // 1. Connect blocks to first compatible input
      const inputType = movingHasOutput
        ? Blockly.inputs.inputTypes.VALUE
        : Blockly.inputs.inputTypes.STATEMENT;
      const compatibleInputs = stationaryNode.inputList.filter(
        (input) => input.type === inputType,
      );
      const input = compatibleInputs.length > 0 ? compatibleInputs[0] : null;
      let connection = input?.connection;
      if (connection) {
        if (inputType === Blockly.inputs.inputTypes.STATEMENT) {
          while (connection.targetBlock()?.nextConnection) {
            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
            connection = connection.targetBlock()!.nextConnection!;
          }
        }
        return connection as Blockly.RenderedConnection;
      }

      // 2. Connect statement blocks to next connection.
      if (stationaryNode.nextConnection && !movingHasOutput) {
        return stationaryNode.nextConnection;
      }

      // 3. Output connection. This will wrap around or displace.
      if (stationaryNode.outputConnection) {
        // Try to wrap.
        const target = stationaryNode.outputConnection.targetConnection;
        if (movingHasOutput && target) {
          return this.findInsertStartPoint(target, movingBlock);
        } else if (!movingHasOutput) {
          // Move to parent if we're trying to insert a statement block.
          const parent = stationaryNode.getParent();
          if (!parent) return null;
          return this.findInsertStartPoint(parent, movingBlock);
        }
        return stationaryNode.outputConnection;
      }
    }
    this.warn(`Unexpected case in findInsertStartPoint ${stationaryNode}.`);
    return null;
  }

  /**
   * Tries to intelligently connect the blocks or connections
   * represented by the given nodes, based on node types and locations.
   *
   * @param stationaryNode The first node to connect.
   * @param movingBlock The block we're moving.
   * @returns True if the connection was successful, false otherwise.
   */
  tryToConnectBlock(
    stationaryNode: Blockly.IFocusableNode,
    movingBlock: Blockly.BlockSvg,
  ): boolean {
    const destConnection = this.findInsertStartPoint(
      stationaryNode,
      movingBlock,
    );
    if (!destConnection) return false;
    return this.insertBlock(movingBlock, destConnection);
  }

  /**
   * Disconnects the child block from its parent block. No-op if the two given
   * connections are unrelated.
   *
   * @param movingConnection The connection that is being moved.
   * @param destConnection The connection to be moved to.
   */
  disconnectChild(
    movingConnection: Blockly.RenderedConnection,
    destConnection: Blockly.RenderedConnection,
  ) {
    const movingBlock = movingConnection.getSourceBlock();
    const destBlock = destConnection.getSourceBlock();
    let inferiorConnection;

    if (movingBlock.getRootBlock() === destBlock.getRootBlock()) {
      if (movingBlock.getDescendants(false).includes(destBlock)) {
        inferiorConnection = this.getInferiorConnection(destConnection);
        if (inferiorConnection) {
          inferiorConnection.disconnect();
        }
      } else {
        inferiorConnection = this.getInferiorConnection(movingConnection);
        if (inferiorConnection) {
          inferiorConnection.disconnect();
        }
      }
    }
  }

  /**
   * Tries to connect the  given connections.
   *
   * If the given connections are not compatible try finding compatible
   * connections on the source blocks of the given connections.
   *
   * @param movingConnection The connection that is being moved.
   * @param destConnection The connection to be moved to.
   * @returns True if the two connections or their target connections
   *     were connected, false otherwise.
   */
  connect(
    movingConnection: Blockly.RenderedConnection | null,
    destConnection: Blockly.RenderedConnection | null,
  ): boolean {
    if (!movingConnection || !destConnection) {
      return false;
    }

    const movingInferior = this.getInferiorConnection(movingConnection);
    const destSuperior = this.getSuperiorConnection(destConnection);

    const movingSuperior = this.getSuperiorConnection(movingConnection);
    const destInferior = this.getInferiorConnection(destConnection);

    if (
      movingInferior &&
      destSuperior &&
      this.moveAndConnect(movingInferior, destSuperior)
    ) {
      return true;
      // Try swapping the inferior and superior connections on the blocks.
    } else if (
      movingSuperior &&
      destInferior &&
      this.moveAndConnect(movingSuperior, destInferior)
    ) {
      return true;
    } else if (this.moveAndConnect(movingConnection, destConnection)) {
      return true;
    } else {
      const checker = movingConnection.getConnectionChecker();
      const reason = checker.canConnectWithReason(
        movingConnection,
        destConnection,
        false,
      );
      this.warn(
        'Connection failed with error: ' +
          checker.getErrorMessage(reason, movingConnection, destConnection),
      );
      return false;
    }
  }

  /**
   * Finds the inferior connection on the source block if the given connection
   * is superior.
   *
   * @param connection The connection trying to be connected.
   * @returns The inferior connection or null if none exists.
   */
  getInferiorConnection(
    connection: Blockly.RenderedConnection | null,
  ): Blockly.RenderedConnection | null {
    if (!connection) {
      return null;
    }
    const block = connection.getSourceBlock() as Blockly.BlockSvg;
    if (!connection.isSuperior()) {
      return connection;
    } else if (block.previousConnection) {
      return block.previousConnection;
    } else if (block.outputConnection) {
      return block.outputConnection;
    } else {
      return null;
    }
  }

  /**
   * Finds a superior connection on the source block if the given connection is
   * inferior.
   *
   * @param connection The connection trying to be connected.
   * @returns The superior connection or null if none exists.
   */
  getSuperiorConnection(
    connection: Blockly.RenderedConnection | null,
  ): Blockly.RenderedConnection | null {
    if (!connection) {
      return null;
    }
    if (connection.isSuperior()) {
      return connection;
    } else if (connection.targetConnection) {
      return connection.targetConnection;
    }
    return null;
  }

  /**
   * Moves the moving connection to the target connection and connects them.
   *
   * @param movingConnection The connection that is being moved.
   * @param destConnection The connection to be moved to.
   * @returns True if the connections were connected, false otherwise.
   */
  moveAndConnect(
    movingConnection: Blockly.RenderedConnection | null,
    destConnection: Blockly.RenderedConnection | null,
  ): boolean {
    if (!movingConnection || !destConnection) {
      return false;
    }
    const movingBlock = movingConnection.getSourceBlock();
    const checker = movingConnection.getConnectionChecker();

    if (
      checker.canConnect(movingConnection, destConnection, false) &&
      !destConnection.getSourceBlock().isShadow()
    ) {
      this.disconnectChild(movingConnection, destConnection);

      // Position the root block near the connection so it does not move the
      // other block when they are connected.
      if (!destConnection.isSuperior()) {
        const rootBlock = movingBlock.getRootBlock();

        const originalOffsetToTarget = {
          x: destConnection.x - movingConnection.x,
          y: destConnection.y - movingConnection.y,
        };
        const originalOffsetInBlock = movingConnection
          .getOffsetInBlock()
          .clone();
        rootBlock.positionNearConnection(
          movingConnection,
          originalOffsetToTarget,
          originalOffsetInBlock,
        );
      }
      destConnection.connect(movingConnection);
      return true;
    }
    return false;
  }

  /**
   * Tries to connect the given block to the destination connection, making an
   * intelligent guess about which connection to use on the moving block.
   *
   * @param block The block to move.
   * @param destConnection The connection to
   *     connect to.
   * @returns Whether the connection was successful.
   */
  insertBlock(
    block: Blockly.BlockSvg,
    destConnection: Blockly.RenderedConnection,
  ): boolean {
    switch (destConnection.type) {
      case Blockly.PREVIOUS_STATEMENT:
        if (this.connect(block.nextConnection, destConnection)) {
          return true;
        }
        break;
      case Blockly.NEXT_STATEMENT:
        if (this.connect(block.previousConnection, destConnection)) {
          return true;
        }
        break;
      case Blockly.INPUT_VALUE:
        if (this.connect(block.outputConnection, destConnection)) {
          return true;
        }
        break;
      case Blockly.OUTPUT_VALUE:
        for (let i = 0; i < block.inputList.length; i++) {
          const inputConnection = block.inputList[i].connection;
          if (
            inputConnection &&
            inputConnection.type === Blockly.INPUT_VALUE &&
            this.connect(
              inputConnection as Blockly.RenderedConnection,
              destConnection,
            )
          ) {
            return true;
          }
        }
        // If there are no input values pass the output and destination
        // connections to connect_ to find a way to connect the two.
        if (
          block.outputConnection &&
          this.connect(block.outputConnection, destConnection)
        ) {
          return true;
        }
        break;
    }
    this.warn('This block can not be inserted at the marked location.');
    return false;
  }

  /**
   * Enables accessibility mode.
   *
   * @param workspace The workspace to enable keyboard
   *     accessibility mode on.
   */
  enableKeyboardAccessibility(workspace: Blockly.WorkspaceSvg) {
    if (
      this.workspaces.includes(workspace) &&
      !workspace.keyboardAccessibilityMode
    ) {
      workspace.keyboardAccessibilityMode = true;
    }
  }

  /**
   * Disables accessibility mode.
   *
   * @param workspace The workspace to disable keyboard
   *     accessibility mode on.
   */
  disableKeyboardAccessibility(workspace: Blockly.WorkspaceSvg) {
    if (
      this.workspaces.includes(workspace) &&
      workspace.keyboardAccessibilityMode
    ) {
      workspace.keyboardAccessibilityMode = false;
    }
  }

  /**
   * Navigation log handler. If loggingCallback is defined, use it.
   * Otherwise just log to the console.log.
   *
   * @param msg The message to log.
   */
  log(msg: string) {
    console.log(msg);
  }

  /**
   * Navigation warning handler. If loggingCallback is defined, use it.
   * Otherwise call console.warn.
   *
   * @param msg The warning message.
   */
  warn(msg: string) {
    console.warn(msg);
  }

  /**
   * Navigation error handler. If loggingCallback is defined, use it.
   * Otherwise call console.error.
   *
   * @param msg The error message.
   */
  error(msg: string) {
    console.error(msg);
  }

  /**
   * Save the current cursor location and open the toolbox or flyout
   * to select and insert a block.
   *
   * @param workspace The active workspace.
   */
  openToolboxOrFlyout(workspace: Blockly.WorkspaceSvg) {
    const toolbox = workspace.getToolbox();
    const flyout = workspace.getFlyout();
    if (toolbox) {
      Blockly.getFocusManager().focusTree(toolbox);
    } else if (flyout) {
      Blockly.getFocusManager().focusTree(flyout.getWorkspace());
    }
  }

  /**
   * Pastes the copied block to the marked location if possible or
   * onto the workspace otherwise.
   *
   * @param copyData The data to paste into the workspace.
   * @param workspace The workspace to paste the data into.
   * @returns True if the paste was sucessful, false otherwise.
   */
  paste(copyData: Blockly.ICopyData, workspace: Blockly.WorkspaceSvg): boolean {
    // Do this before clipoard.paste due to cursor/focus workaround in getCurNode.
    const targetNode = workspace.getCursor()?.getCurNode();

    Blockly.Events.setGroup(true);
    const block = Blockly.clipboard.paste(
      copyData,
      workspace,
    ) as Blockly.BlockSvg;
    if (block) {
      if (targetNode) {
        this.tryToConnectBlock(targetNode, block);
      }
      return true;
    }
    Blockly.Events.setGroup(false);
    return false;
  }

  /**
   * Determines whether keyboard navigation should be allowed based on the
   * current state of the workspace.
   *
   * A return value of 'true' generally indicates that either the workspace,
   * toolbox or flyout has enabled keyboard navigation and is currently in a
   * state (e.g. focus) that can support keyboard navigation.
   *
   * @param workspace the workspace in which keyboard navigation may be allowed.
   * @returns whether keyboard navigation is currently allowed.
   */
  canCurrentlyNavigate(workspace: Blockly.WorkspaceSvg) {
    const accessibilityMode = workspace.isFlyout
      ? workspace.targetWorkspace?.keyboardAccessibilityMode
      : workspace.keyboardAccessibilityMode;
    return (
      !!accessibilityMode &&
      this.getState(workspace) !== Constants.STATE.NOWHERE &&
      !Blockly.getFocusManager().ephemeralFocusTaken()
    );
  }

  /**
   * Determines whether the provided workspace is currently keyboard navigable
   * and editable.
   *
   * For the navigability criteria, see canCurrentlyKeyboardNavigate.
   *
   * @param workspace the workspace in which keyboard editing may be allowed.
   * @returns whether keyboard navigation and editing is currently allowed.
   */
  canCurrentlyEdit(workspace: Blockly.WorkspaceSvg) {
    return this.canCurrentlyNavigate(workspace) && !workspace.options.readOnly;
  }

  /**
   * Removes the change listeners on all registered workspaces.
   */
  dispose() {
    for (const workspace of this.workspaces) {
      this.removeWorkspace(workspace);
    }
  }
}
