Skip to content

Commit eaf5eea

Browse files
authored
feat: make comment editor separately focusable from comment itself (#9154)
* feat: make comment editor separately focusable from comment itself * feat: improve design and add styling * chore: fix lint * fix: add event listeners to focus parent comment * fix: export CommentEditor * fix: export CommentEditor * fix: extract comment identifier to constant
1 parent 5427c3d commit eaf5eea

5 files changed

Lines changed: 284 additions & 102 deletions

File tree

core/comments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
export {CommentEditor} from './comments/comment_editor.js';
78
export {CommentView} from './comments/comment_view.js';
89
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
910
export {WorkspaceComment} from './comments/workspace_comment.js';

core/comments/comment_editor.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* @license
3+
* Copyright 2024 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as browserEvents from '../browser_events.js';
8+
import {getFocusManager} from '../focus_manager.js';
9+
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
10+
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
11+
import * as dom from '../utils/dom.js';
12+
import {Size} from '../utils/size.js';
13+
import {Svg} from '../utils/svg.js';
14+
import {WorkspaceSvg} from '../workspace_svg.js';
15+
16+
/**
17+
* String added to the ID of a workspace comment to identify
18+
* the focusable node for the comment editor.
19+
*/
20+
export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_';
21+
22+
/** The part of a comment that can be typed into. */
23+
export class CommentEditor implements IFocusableNode {
24+
id?: string;
25+
/** The foreignObject containing the HTML text area. */
26+
private foreignObject: SVGForeignObjectElement;
27+
28+
/** The text area where the user can type. */
29+
private textArea: HTMLTextAreaElement;
30+
31+
/** Listeners for changes to text. */
32+
private textChangeListeners: Array<
33+
(oldText: string, newText: string) => void
34+
> = [];
35+
36+
/** The current text of the comment. Updates on text area change. */
37+
private text: string = '';
38+
39+
constructor(
40+
public workspace: WorkspaceSvg,
41+
commentId?: string,
42+
private onFinishEditing?: () => void,
43+
) {
44+
this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, {
45+
'class': 'blocklyCommentForeignObject',
46+
});
47+
const body = document.createElementNS(dom.HTML_NS, 'body');
48+
body.setAttribute('xmlns', dom.HTML_NS);
49+
body.className = 'blocklyMinimalBody';
50+
this.textArea = document.createElementNS(
51+
dom.HTML_NS,
52+
'textarea',
53+
) as HTMLTextAreaElement;
54+
dom.addClass(this.textArea, 'blocklyCommentText');
55+
dom.addClass(this.textArea, 'blocklyTextarea');
56+
dom.addClass(this.textArea, 'blocklyText');
57+
body.appendChild(this.textArea);
58+
this.foreignObject.appendChild(body);
59+
60+
if (commentId) {
61+
this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER;
62+
this.textArea.setAttribute('id', this.id);
63+
}
64+
65+
// Register browser event listeners for the user typing in the textarea.
66+
browserEvents.conditionalBind(
67+
this.textArea,
68+
'change',
69+
this,
70+
this.onTextChange,
71+
);
72+
73+
// Register listener for pointerdown to focus the textarea.
74+
browserEvents.conditionalBind(
75+
this.textArea,
76+
'pointerdown',
77+
this,
78+
(e: PointerEvent) => {
79+
// don't allow this event to bubble up
80+
// and steal focus away from the editor/comment.
81+
e.stopPropagation();
82+
getFocusManager().focusNode(this);
83+
},
84+
);
85+
86+
// Register listener for keydown events that would finish editing.
87+
browserEvents.conditionalBind(
88+
this.textArea,
89+
'keydown',
90+
this,
91+
this.handleKeyDown,
92+
);
93+
}
94+
95+
/** Gets the dom structure for this comment editor. */
96+
getDom(): SVGForeignObjectElement {
97+
return this.foreignObject;
98+
}
99+
100+
/** Gets the current text of the comment. */
101+
getText(): string {
102+
return this.text;
103+
}
104+
105+
/** Sets the current text of the comment and fires change listeners. */
106+
setText(text: string) {
107+
this.textArea.value = text;
108+
this.onTextChange();
109+
}
110+
111+
/**
112+
* Triggers listeners when the text of the comment changes, either
113+
* programmatically or manually by the user.
114+
*/
115+
private onTextChange() {
116+
const oldText = this.text;
117+
this.text = this.textArea.value;
118+
// Loop through listeners backwards in case they remove themselves.
119+
for (let i = this.textChangeListeners.length - 1; i >= 0; i--) {
120+
this.textChangeListeners[i](oldText, this.text);
121+
}
122+
}
123+
124+
/**
125+
* Do something when the user indicates they've finished editing.
126+
*
127+
* @param e Keyboard event.
128+
*/
129+
private handleKeyDown(e: KeyboardEvent) {
130+
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
131+
if (this.onFinishEditing) this.onFinishEditing();
132+
e.stopPropagation();
133+
}
134+
}
135+
136+
/** Registers a callback that listens for text changes. */
137+
addTextChangeListener(listener: (oldText: string, newText: string) => void) {
138+
this.textChangeListeners.push(listener);
139+
}
140+
141+
/** Removes the given listener from the list of text change listeners. */
142+
removeTextChangeListener(listener: () => void) {
143+
this.textChangeListeners.splice(
144+
this.textChangeListeners.indexOf(listener),
145+
1,
146+
);
147+
}
148+
149+
/** Sets the placeholder text displayed for an empty comment. */
150+
setPlaceholderText(text: string) {
151+
this.textArea.placeholder = text;
152+
}
153+
154+
/** Sets whether the textarea is editable. If not, the textarea will be readonly. */
155+
setEditable(isEditable: boolean) {
156+
if (isEditable) {
157+
this.textArea.removeAttribute('readonly');
158+
} else {
159+
this.textArea.setAttribute('readonly', 'true');
160+
}
161+
}
162+
163+
/** Update the size of the comment editor element. */
164+
updateSize(size: Size, topBarSize: Size) {
165+
this.foreignObject.setAttribute(
166+
'height',
167+
`${size.height - topBarSize.height}`,
168+
);
169+
this.foreignObject.setAttribute('width', `${size.width}`);
170+
this.foreignObject.setAttribute('y', `${topBarSize.height}`);
171+
if (this.workspace.RTL) {
172+
this.foreignObject.setAttribute('x', `${-size.width}`);
173+
}
174+
}
175+
176+
getFocusableElement(): HTMLElement | SVGElement {
177+
return this.textArea;
178+
}
179+
getFocusableTree(): IFocusableTree {
180+
return this.workspace;
181+
}
182+
onNodeFocus(): void {}
183+
onNodeBlur(): void {}
184+
canBeFocused(): boolean {
185+
if (this.id) return true;
186+
return false;
187+
}
188+
}

0 commit comments

Comments
 (0)