Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts
5221 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../../../base/browser/dom.js';6import * as nls from '../../../../nls.js';7import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';8import * as languages from '../../../../editor/common/languages.js';9import { Emitter } from '../../../../base/common/event.js';10import { ICommentService } from './commentService.js';11import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';12import { KeyCode } from '../../../../base/common/keyCodes.js';13import { CommentNode } from './commentNode.js';14import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';15import { URI } from '../../../../base/common/uri.js';16import { ICommentThreadWidget } from '../common/commentThreadWidget.js';17import { IMarkdownRendererExtraOptions } from '../../../../platform/markdown/browser/markdownRenderer.js';18import { ICellRange } from '../../notebook/common/notebookRange.js';19import { IRange } from '../../../../editor/common/core/range.js';20import { LayoutableEditor } from './simpleCommentEditor.js';2122export class CommentThreadBody<T extends IRange | ICellRange = IRange> extends Disposable {23private _commentsElement!: HTMLElement;24private _commentElements: CommentNode<T>[] = [];25private _resizeObserver: MutationObserver | null = null;26private _focusedComment: number | undefined = undefined;27private _onDidResize = new Emitter<dom.Dimension>();28onDidResize = this._onDidResize.event;2930private _commentDisposable = new DisposableMap<CommentNode<T>, DisposableStore>();3132get length() {33return this._commentThread.comments ? this._commentThread.comments.length : 0;34}3536get activeComment() {37return this._commentElements.filter(node => node.isEditing)[0];38}3940constructor(41private readonly _parentEditor: LayoutableEditor,42readonly owner: string,43readonly parentResourceUri: URI,44readonly container: HTMLElement,45private _markdownRendererOptions: IMarkdownRendererExtraOptions,46private _commentThread: languages.CommentThread<T>,47private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,48private _scopedInstatiationService: IInstantiationService,49private _parentCommentThreadWidget: ICommentThreadWidget,50@ICommentService private readonly commentService: ICommentService,51) {52super();5354this._register(dom.addDisposableListener(container, dom.EventType.FOCUS_IN, e => {55// TODO @rebornix, limit T to IRange | ICellRange56this.commentService.setActiveEditingCommentThread(this._commentThread);57}));58}5960focus(commentUniqueId?: number) {61if (commentUniqueId !== undefined) {62const comment = this._commentElements.find(commentNode => commentNode.comment.uniqueIdInThread === commentUniqueId);63if (comment) {64comment.focus();65return;66}67}68this._commentsElement.focus();69}7071hasCommentsInEditMode() {72return this._commentElements.some(commentNode => commentNode.isEditing);73}7475ensureFocusIntoNewEditingComment() {76if (this._commentElements.length === 1 && this._commentElements[0].isEditing) {77this._commentElements[0].setFocus(true);78}79}8081async display() {82this._commentsElement = dom.append(this.container, dom.$('div.comments-container'));83this._commentsElement.setAttribute('role', 'presentation');84this._commentsElement.tabIndex = 0;85this._updateAriaLabel();8687this._register(dom.addDisposableListener(this._commentsElement, dom.EventType.KEY_DOWN, (e) => {88const event = new StandardKeyboardEvent(e as KeyboardEvent);89if ((event.equals(KeyCode.UpArrow) || event.equals(KeyCode.DownArrow)) && (!this._focusedComment || !this._commentElements[this._focusedComment].isEditing)) {90const moveFocusWithinBounds = (change: number): number => {91if (this._focusedComment === undefined && change >= 0) { return 0; }92if (this._focusedComment === undefined && change < 0) { return this._commentElements.length - 1; }93const newIndex = this._focusedComment! + change;94return Math.min(Math.max(0, newIndex), this._commentElements.length - 1);95};9697this._setFocusedComment(event.equals(KeyCode.UpArrow) ? moveFocusWithinBounds(-1) : moveFocusWithinBounds(1));98}99}));100101this._commentDisposable.clearAndDisposeAll();102this._commentElements = [];103if (this._commentThread.comments) {104for (const comment of this._commentThread.comments) {105const newCommentNode = this.createNewCommentNode(comment);106107this._commentElements.push(newCommentNode);108this._commentsElement.appendChild(newCommentNode.domNode);109if (comment.mode === languages.CommentMode.Editing) {110await newCommentNode.switchToEditMode();111}112}113}114115this._resizeObserver = new MutationObserver(this._refresh.bind(this));116117this._resizeObserver.observe(this.container, {118attributes: true,119childList: true,120characterData: true,121subtree: true122});123}124125private _containerClientArea: dom.Dimension | undefined = undefined;126private _refresh() {127const dimensions = dom.getClientArea(this.container);128if ((dimensions.height === 0 && dimensions.width === 0) || (dom.Dimension.equals(this._containerClientArea, dimensions))) {129return;130}131this._containerClientArea = dimensions;132this._onDidResize.fire(dimensions);133}134135getDimensions() {136return dom.getClientArea(this.container);137}138139layout(widthInPixel?: number) {140this._commentElements.forEach(element => {141element.layout(widthInPixel);142});143}144145getPendingEdits(): { [key: number]: languages.PendingComment } {146const pendingEdits: { [key: number]: languages.PendingComment } = {};147this._commentElements.forEach(element => {148if (element.isEditing) {149const pendingEdit = element.getPendingEdit();150if (pendingEdit) {151pendingEdits[element.comment.uniqueIdInThread] = pendingEdit;152}153}154});155156return pendingEdits;157}158159getCommentCoords(commentUniqueId: number): { thread: dom.IDomNodePagePosition; comment: dom.IDomNodePagePosition } | undefined {160const matchedNode = this._commentElements.filter(commentNode => commentNode.comment.uniqueIdInThread === commentUniqueId);161if (matchedNode && matchedNode.length) {162const commentThreadCoords = dom.getDomNodePagePosition(this._commentElements[0].domNode);163const commentCoords = dom.getDomNodePagePosition(matchedNode[0].domNode);164return {165thread: commentThreadCoords,166comment: commentCoords167};168}169170return;171}172173async updateCommentThread(commentThread: languages.CommentThread<T>, preserveFocus: boolean) {174const oldCommentsLen = this._commentElements.length;175const newCommentsLen = commentThread.comments ? commentThread.comments.length : 0;176177const commentElementsToDel: CommentNode<T>[] = [];178const commentElementsToDelIndex: number[] = [];179for (let i = 0; i < oldCommentsLen; i++) {180const comment = this._commentElements[i].comment;181const newComment = commentThread.comments ? commentThread.comments.filter(c => c.uniqueIdInThread === comment.uniqueIdInThread) : [];182183if (newComment.length) {184this._commentElements[i].update(newComment[0]);185} else {186commentElementsToDelIndex.push(i);187commentElementsToDel.push(this._commentElements[i]);188}189}190191// del removed elements192for (let i = commentElementsToDel.length - 1; i >= 0; i--) {193const commentToDelete = commentElementsToDel[i];194this._commentDisposable.deleteAndDispose(commentToDelete);195196this._commentElements.splice(commentElementsToDelIndex[i], 1);197commentToDelete.domNode.remove();198}199200201let lastCommentElement: HTMLElement | null = null;202const newCommentNodeList: CommentNode<T>[] = [];203const newCommentsInEditMode: CommentNode<T>[] = [];204const startEditing: Promise<void>[] = [];205206for (let i = newCommentsLen - 1; i >= 0; i--) {207const currentComment = commentThread.comments![i];208const oldCommentNode = this._commentElements.filter(commentNode => commentNode.comment.uniqueIdInThread === currentComment.uniqueIdInThread);209if (oldCommentNode.length) {210lastCommentElement = oldCommentNode[0].domNode;211newCommentNodeList.unshift(oldCommentNode[0]);212} else {213const newElement = this.createNewCommentNode(currentComment);214215newCommentNodeList.unshift(newElement);216if (lastCommentElement) {217this._commentsElement.insertBefore(newElement.domNode, lastCommentElement);218lastCommentElement = newElement.domNode;219} else {220this._commentsElement.appendChild(newElement.domNode);221lastCommentElement = newElement.domNode;222}223224if (currentComment.mode === languages.CommentMode.Editing) {225startEditing.push(newElement.switchToEditMode());226newCommentsInEditMode.push(newElement);227}228}229}230231this._commentThread = commentThread;232this._commentElements = newCommentNodeList;233// Start editing *after* updating the thread and elements to avoid a sequencing issue https://github.com/microsoft/vscode/issues/239191234await Promise.all(startEditing);235236if (newCommentsInEditMode.length) {237const lastIndex = this._commentElements.indexOf(newCommentsInEditMode[newCommentsInEditMode.length - 1]);238this._focusedComment = lastIndex;239}240241this._updateAriaLabel();242if (!preserveFocus) {243this._setFocusedComment(this._focusedComment);244}245}246247private _updateAriaLabel() {248if (this._commentThread.isDocumentCommentThread()) {249if (this._commentThread.range) {250this._commentsElement.ariaLabel = nls.localize('commentThreadAria.withRange', "Comment thread with {0} comments on lines {1} through {2}. {3}.",251this._commentThread.comments?.length, this._commentThread.range.startLineNumber, this._commentThread.range.endLineNumber,252this._commentThread.label);253} else {254this._commentsElement.ariaLabel = nls.localize('commentThreadAria.document', "Comment thread with {0} comments on the entire document. {1}.",255this._commentThread.comments?.length, this._commentThread.label);256}257} else {258this._commentsElement.ariaLabel = nls.localize('commentThreadAria', "Comment thread with {0} comments. {1}.",259this._commentThread.comments?.length, this._commentThread.label);260}261}262263private _setFocusedComment(value: number | undefined) {264if (this._focusedComment !== undefined) {265this._commentElements[this._focusedComment]?.setFocus(false);266}267268if (this._commentElements.length === 0 || value === undefined) {269this._focusedComment = undefined;270} else {271this._focusedComment = Math.min(value, this._commentElements.length - 1);272this._commentElements[this._focusedComment].setFocus(true);273}274}275276private createNewCommentNode(comment: languages.Comment): CommentNode<T> {277const newCommentNode = this._scopedInstatiationService.createInstance(CommentNode,278this._parentEditor,279this._commentThread,280comment,281this._pendingEdits ? this._pendingEdits[comment.uniqueIdInThread] : undefined,282this.owner,283this.parentResourceUri,284this._parentCommentThreadWidget,285this._markdownRendererOptions) as unknown as CommentNode<T>;286287const disposables: DisposableStore = new DisposableStore();288disposables.add(newCommentNode.onDidClick(clickedNode =>289this._setFocusedComment(this._commentElements.findIndex(commentNode => commentNode.comment.uniqueIdInThread === clickedNode.comment.uniqueIdInThread))290));291disposables.add(newCommentNode);292this._commentDisposable.set(newCommentNode, disposables);293294return newCommentNode;295}296297public override dispose(): void {298super.dispose();299300if (this._resizeObserver) {301this._resizeObserver.disconnect();302this._resizeObserver = null;303}304305this._commentDisposable.dispose();306}307}308309310