Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions core/comments/comment_editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

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 * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';

export class CommentEditor implements IFocusableNode {
id?: string;
/** The foreignObject containing the HTML text area. */
foreignObject: SVGForeignObjectElement;

/** The text area where the user can type. */
textArea: HTMLTextAreaElement;

constructor(
public workspace: WorkspaceSvg,
commentSvgRoot: SVGGElement,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a bit like a dependency inversion to me. I'm wondering if we could follow the createDom pattern here and have the holding object for this new class create and add the editor as a child, rather than the editor self-registering itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, this doesn't need to be passed in, the parent can create it and append it to itself. This was my quick and dirty approach to make sure it works (it does!) and I'll definitely do another pass to clean this up and make sure it's thoroughly reviewed. The other thing I haven't given thought to yet is that we intend for RenderedWorkspaceComment to be subclassable so I need to make sure the solution is suitable for subclasses as well.

commentId?: string,
) {
this.foreignObject = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'class': 'blocklyCommentForeignObject',
},
commentSvgRoot,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
this.textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
body.appendChild(this.textArea);
this.foreignObject.appendChild(body);

if (commentId) {
this.id = commentId + '_textarea_';
this.textArea.setAttribute('id', this.id);
}

browserEvents.conditionalBind(
this.textArea,
'pointerdown',
this,
(e: PointerEvent) => {
// don't allow this event to bubble up
// and steal focus away from the editor/comment.
e.stopPropagation();
getFocusManager().focusNode(this);
},
);
}

getFocusableElement(): HTMLElement | SVGElement {
return this.textArea;
}
getFocusableTree(): IFocusableTree {
return this.workspace;
}
onNodeFocus(): void {}
onNodeBlur(): void {}
canBeFocused(): boolean {
return true;
}
}
30 changes: 11 additions & 19 deletions core/comments/comment_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as drag from '../utils/drag.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import {CommentEditor} from './comment_editor.js';

export class CommentView implements IRenderedElement {
/** The root group element of the comment view. */
Expand Down Expand Up @@ -106,7 +107,12 @@ export class CommentView implements IRenderedElement {
/** The default size of newly created comments. */
static defaultCommentSize = new Size(120, 100);

constructor(readonly workspace: WorkspaceSvg) {
public commentEditor: CommentEditor | undefined;

constructor(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative approach might be to make CommentView the IFocusableNode. That would presumably work properly for both read-only and editable versions of the view, right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I just realized the oddity here. CommentView already holds the root that RenderedWorkspaceComment uses. That's actually rather confusing now that I think about it.

I suspect there's some way to clean this whole thing up a bit more, but I disagree with my previous comment. It does seem like the new class is needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CommentView is an effort to separate the view logic from the business logic for the workspace comments, as opposed to everywhere else in blockly where we just mix the two willy nilly. So I think it does make sense that CommentView is the focusable node for the entire comment, but there are probably some other oddities here just from the fact that we don't use this pattern much in blockly.

readonly workspace: WorkspaceSvg,
private commentId?: string,
) {
this.svgRoot = dom.createSvgElement(Svg.G, {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
});
Expand Down Expand Up @@ -240,26 +246,12 @@ export class CommentView implements IRenderedElement {
foreignObject: SVGForeignObjectElement;
textArea: HTMLTextAreaElement;
} {
const foreignObject = dom.createSvgElement(
Svg.FOREIGNOBJECT,
{
'class': 'blocklyCommentForeignObject',
},
this.commentEditor = new CommentEditor(
this.workspace,
svgRoot,
this.commentId,
);
const body = document.createElementNS(dom.HTML_NS, 'body');
body.setAttribute('xmlns', dom.HTML_NS);
body.className = 'blocklyMinimalBody';
const textArea = document.createElementNS(
dom.HTML_NS,
'textarea',
) as HTMLTextAreaElement;
dom.addClass(textArea, 'blocklyCommentText');
dom.addClass(textArea, 'blocklyTextarea');
dom.addClass(textArea, 'blocklyText');
body.appendChild(textArea);
foreignObject.appendChild(body);

const {foreignObject, textArea} = this.commentEditor;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this is temporary, but if not we probably don't want to destructure a class like this since the class could change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't this the same as doing

const foreignObject = this.commentEditor.foreignObject;
const textArea = this.commentEditor.textArea;

? if the class properties change, either one is going to have to change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Yes you're correct. I thought that foreignObject and textArea here were local names only and not directly being associated to the property names in the object. Because it's actually the latter, this should always work as expected. TIL. :)

browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange);

return {foreignObject, textArea};
Expand Down
12 changes: 3 additions & 9 deletions core/comments/rendered_workspace_comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class RenderedWorkspaceComment
IFocusableNode
{
/** The class encompassing the svg elements making up the workspace comment. */
private view: CommentView;
view: CommentView;

public readonly workspace: WorkspaceSvg;

Expand All @@ -59,7 +59,7 @@ export class RenderedWorkspaceComment

this.workspace = workspace;

this.view = new CommentView(workspace);
this.view = new CommentView(workspace, this.id);
// Set the size to the default size as defined in the superclass.
this.view.setSize(this.getSize());
this.view.setEditable(this.isEditable());
Expand Down Expand Up @@ -224,13 +224,7 @@ export class RenderedWorkspaceComment
private startGesture(e: PointerEvent) {
const gesture = this.workspace.getGesture(e);
if (gesture) {
if (browserEvents.isTargetInput(e)) {
// If the text area was the focus, don't allow this event to bubble up
// and steal focus away from the editor/comment.
e.stopPropagation();
} else {
gesture.handleCommentStart(e, this);
}
gesture.handleCommentStart(e, this);
getFocusManager().focusNode(this);
}
}
Expand Down
29 changes: 20 additions & 9 deletions core/workspace_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2715,6 +2715,7 @@ export class WorkspaceSvg

/** See IFocusableTree.lookUpFocusableNode. */
lookUpFocusableNode(id: string): IFocusableNode | null {
if (!id) return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed when logging there's a bunch of times that this function is triggered with an empty string for the id, which will never match anything so there's just a bunch of wasted work getting all the blocks and comments and connections and whatnot.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only place that we call lookUpFocusableNode: https://github.com/google/blockly/blob/97ffea73becea41465e7c4e757fab44a2ded10df/core/utils/focusable_tree_traverser.ts#L89

I suspect this is happening for all the cases document.body gets focus (which is a lot, including in these editable textarea cases for reasons I'm not quite sure of). This is actually another argument in favor of moving toward typed IDs since it would be more explicit why an ID is missing, versus just being empty.

We should patch it in the traverser, I think, rather than here. In fact, we can probably short-circuit that entire function and just return null if the ID is empty for the provided element (since IFocusableNode requires a valid, unique ID to be present which means it can't be an empty string/left undefined).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I filed #9155 to clean that up separately and I'll remove it from this PR.

// Check against flyout items if this workspace is part of a flyout. Note
// that blocks may match against this pass before reaching getBlockById()
// below (but only for a flyout workspace).
Expand Down Expand Up @@ -2757,21 +2758,31 @@ export class WorkspaceSvg
return null;
}

// Search for a specific workspace comment editor
// (only if id seems like it is one).
const commentEditorIndicator = id.indexOf('_textarea_');
if (commentEditorIndicator !== -1) {
const commentId = id.substring(0, commentEditorIndicator);
const comment = this.getCommentById(commentId);
if (
comment instanceof RenderedWorkspaceComment &&
comment.canBeFocused() &&
comment.view.commentEditor
) {
return comment.view.commentEditor;
}
}

// Search for a specific block.
const block = this.getAllBlocks(false).find(
(block) => block.getFocusableElement().id === id,
);
if (block) return block;

// Search for a workspace comment (semi-expensive).
for (const comment of this.getTopComments()) {
if (
comment instanceof RenderedWorkspaceComment &&
comment.canBeFocused() &&
comment.getFocusableElement().id === id
) {
return comment;
}
// Search for a workspace comment.
const comment = this.getCommentById(id);
if (comment instanceof RenderedWorkspaceComment && comment.canBeFocused()) {
return comment;
}

// Search for icons and bubbles (which requires an expensive getAllBlocks).
Expand Down