Path: blob/main/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts
3296 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 { IMarkdownRendererOptions, MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';18import { IOpenerService } from '../../../../platform/opener/common/opener.js';19import { ILanguageService } from '../../../../editor/common/languages/language.js';20import { ICellRange } from '../../notebook/common/notebookRange.js';21import { IRange } from '../../../../editor/common/core/range.js';22import { LayoutableEditor } from './simpleCommentEditor.js';2324export class CommentThreadBody<T extends IRange | ICellRange = IRange> extends Disposable {25private _commentsElement!: HTMLElement;26private _commentElements: CommentNode<T>[] = [];27private _resizeObserver: any;28private _focusedComment: number | undefined = undefined;29private _onDidResize = new Emitter<dom.Dimension>();30onDidResize = this._onDidResize.event;3132private _commentDisposable = new DisposableMap<CommentNode<T>, DisposableStore>();33private _markdownRenderer: MarkdownRenderer;3435get length() {36return this._commentThread.comments ? this._commentThread.comments.length : 0;37}3839get activeComment() {40return this._commentElements.filter(node => node.isEditing)[0];41}4243constructor(44private readonly _parentEditor: LayoutableEditor,45readonly owner: string,46readonly parentResourceUri: URI,47readonly container: HTMLElement,48private _options: IMarkdownRendererOptions,49private _commentThread: languages.CommentThread<T>,50private _pendingEdits: { [key: number]: languages.PendingComment } | undefined,51private _scopedInstatiationService: IInstantiationService,52private _parentCommentThreadWidget: ICommentThreadWidget,53@ICommentService private commentService: ICommentService,54@IOpenerService private openerService: IOpenerService,55@ILanguageService private languageService: ILanguageService,56) {57super();5859this._register(dom.addDisposableListener(container, dom.EventType.FOCUS_IN, e => {60// TODO @rebornix, limit T to IRange | ICellRange61this.commentService.setActiveEditingCommentThread(this._commentThread);62}));6364this._markdownRenderer = new MarkdownRenderer(this._options, this.languageService, this.openerService);65}6667focus(commentUniqueId?: number) {68if (commentUniqueId !== undefined) {69const comment = this._commentElements.find(commentNode => commentNode.comment.uniqueIdInThread === commentUniqueId);70if (comment) {71comment.focus();72return;73}74}75this._commentsElement.focus();76}7778hasCommentsInEditMode() {79return this._commentElements.some(commentNode => commentNode.isEditing);80}8182ensureFocusIntoNewEditingComment() {83if (this._commentElements.length === 1 && this._commentElements[0].isEditing) {84this._commentElements[0].setFocus(true);85}86}8788async display() {89this._commentsElement = dom.append(this.container, dom.$('div.comments-container'));90this._commentsElement.setAttribute('role', 'presentation');91this._commentsElement.tabIndex = 0;92this._updateAriaLabel();9394this._register(dom.addDisposableListener(this._commentsElement, dom.EventType.KEY_DOWN, (e) => {95const event = new StandardKeyboardEvent(e as KeyboardEvent);96if ((event.equals(KeyCode.UpArrow) || event.equals(KeyCode.DownArrow)) && (!this._focusedComment || !this._commentElements[this._focusedComment].isEditing)) {97const moveFocusWithinBounds = (change: number): number => {98if (this._focusedComment === undefined && change >= 0) { return 0; }99if (this._focusedComment === undefined && change < 0) { return this._commentElements.length - 1; }100const newIndex = this._focusedComment! + change;101return Math.min(Math.max(0, newIndex), this._commentElements.length - 1);102};103104this._setFocusedComment(event.equals(KeyCode.UpArrow) ? moveFocusWithinBounds(-1) : moveFocusWithinBounds(1));105}106}));107108this._commentDisposable.clearAndDisposeAll();109this._commentElements = [];110if (this._commentThread.comments) {111for (const comment of this._commentThread.comments) {112const newCommentNode = this.createNewCommentNode(comment);113114this._commentElements.push(newCommentNode);115this._commentsElement.appendChild(newCommentNode.domNode);116if (comment.mode === languages.CommentMode.Editing) {117await newCommentNode.switchToEditMode();118}119}120}121122this._resizeObserver = new MutationObserver(this._refresh.bind(this));123124this._resizeObserver.observe(this.container, {125attributes: true,126childList: true,127characterData: true,128subtree: true129});130}131132private _refresh() {133const dimensions = dom.getClientArea(this.container);134this._onDidResize.fire(dimensions);135}136137getDimensions() {138return dom.getClientArea(this.container);139}140141layout(widthInPixel?: number) {142this._commentElements.forEach(element => {143element.layout(widthInPixel);144});145}146147getPendingEdits(): { [key: number]: languages.PendingComment } {148const pendingEdits: { [key: number]: languages.PendingComment } = {};149this._commentElements.forEach(element => {150if (element.isEditing) {151const pendingEdit = element.getPendingEdit();152if (pendingEdit) {153pendingEdits[element.comment.uniqueIdInThread] = pendingEdit;154}155}156});157158return pendingEdits;159}160161getCommentCoords(commentUniqueId: number): { thread: dom.IDomNodePagePosition; comment: dom.IDomNodePagePosition } | undefined {162const matchedNode = this._commentElements.filter(commentNode => commentNode.comment.uniqueIdInThread === commentUniqueId);163if (matchedNode && matchedNode.length) {164const commentThreadCoords = dom.getDomNodePagePosition(this._commentElements[0].domNode);165const commentCoords = dom.getDomNodePagePosition(matchedNode[0].domNode);166return {167thread: commentThreadCoords,168comment: commentCoords169};170}171172return;173}174175async updateCommentThread(commentThread: languages.CommentThread<T>, preserveFocus: boolean) {176const oldCommentsLen = this._commentElements.length;177const newCommentsLen = commentThread.comments ? commentThread.comments.length : 0;178179const commentElementsToDel: CommentNode<T>[] = [];180const commentElementsToDelIndex: number[] = [];181for (let i = 0; i < oldCommentsLen; i++) {182const comment = this._commentElements[i].comment;183const newComment = commentThread.comments ? commentThread.comments.filter(c => c.uniqueIdInThread === comment.uniqueIdInThread) : [];184185if (newComment.length) {186this._commentElements[i].update(newComment[0]);187} else {188commentElementsToDelIndex.push(i);189commentElementsToDel.push(this._commentElements[i]);190}191}192193// del removed elements194for (let i = commentElementsToDel.length - 1; i >= 0; i--) {195const commentToDelete = commentElementsToDel[i];196this._commentDisposable.deleteAndDispose(commentToDelete);197198this._commentElements.splice(commentElementsToDelIndex[i], 1);199commentToDelete.domNode.remove();200}201202203let lastCommentElement: HTMLElement | null = null;204const newCommentNodeList: CommentNode<T>[] = [];205const newCommentsInEditMode: CommentNode<T>[] = [];206const startEditing: Promise<void>[] = [];207208for (let i = newCommentsLen - 1; i >= 0; i--) {209const currentComment = commentThread.comments![i];210const oldCommentNode = this._commentElements.filter(commentNode => commentNode.comment.uniqueIdInThread === currentComment.uniqueIdInThread);211if (oldCommentNode.length) {212lastCommentElement = oldCommentNode[0].domNode;213newCommentNodeList.unshift(oldCommentNode[0]);214} else {215const newElement = this.createNewCommentNode(currentComment);216217newCommentNodeList.unshift(newElement);218if (lastCommentElement) {219this._commentsElement.insertBefore(newElement.domNode, lastCommentElement);220lastCommentElement = newElement.domNode;221} else {222this._commentsElement.appendChild(newElement.domNode);223lastCommentElement = newElement.domNode;224}225226if (currentComment.mode === languages.CommentMode.Editing) {227startEditing.push(newElement.switchToEditMode());228newCommentsInEditMode.push(newElement);229}230}231}232233this._commentThread = commentThread;234this._commentElements = newCommentNodeList;235// Start editing *after* updating the thread and elements to avoid a sequencing issue https://github.com/microsoft/vscode/issues/239191236await Promise.all(startEditing);237238if (newCommentsInEditMode.length) {239const lastIndex = this._commentElements.indexOf(newCommentsInEditMode[newCommentsInEditMode.length - 1]);240this._focusedComment = lastIndex;241}242243this._updateAriaLabel();244if (!preserveFocus) {245this._setFocusedComment(this._focusedComment);246}247}248249private _updateAriaLabel() {250if (this._commentThread.isDocumentCommentThread()) {251if (this._commentThread.range) {252this._commentsElement.ariaLabel = nls.localize('commentThreadAria.withRange', "Comment thread with {0} comments on lines {1} through {2}. {3}.",253this._commentThread.comments?.length, this._commentThread.range.startLineNumber, this._commentThread.range.endLineNumber,254this._commentThread.label);255} else {256this._commentsElement.ariaLabel = nls.localize('commentThreadAria.document', "Comment thread with {0} comments on the entire document. {1}.",257this._commentThread.comments?.length, this._commentThread.label);258}259} else {260this._commentsElement.ariaLabel = nls.localize('commentThreadAria', "Comment thread with {0} comments. {1}.",261this._commentThread.comments?.length, this._commentThread.label);262}263}264265private _setFocusedComment(value: number | undefined) {266if (this._focusedComment !== undefined) {267this._commentElements[this._focusedComment]?.setFocus(false);268}269270if (this._commentElements.length === 0 || value === undefined) {271this._focusedComment = undefined;272} else {273this._focusedComment = Math.min(value, this._commentElements.length - 1);274this._commentElements[this._focusedComment].setFocus(true);275}276}277278private createNewCommentNode(comment: languages.Comment): CommentNode<T> {279const newCommentNode = this._scopedInstatiationService.createInstance(CommentNode,280this._parentEditor,281this._commentThread,282comment,283this._pendingEdits ? this._pendingEdits[comment.uniqueIdInThread] : undefined,284this.owner,285this.parentResourceUri,286this._parentCommentThreadWidget,287this._markdownRenderer) as unknown as CommentNode<T>;288289const disposables: DisposableStore = new DisposableStore();290disposables.add(newCommentNode.onDidClick(clickedNode =>291this._setFocusedComment(this._commentElements.findIndex(commentNode => commentNode.comment.uniqueIdInThread === clickedNode.comment.uniqueIdInThread))292));293disposables.add(newCommentNode);294this._commentDisposable.set(newCommentNode, disposables);295296return newCommentNode;297}298299public override dispose(): void {300super.dispose();301302if (this._resizeObserver) {303this._resizeObserver.disconnect();304this._resizeObserver = null;305}306307this._commentDisposable.dispose();308}309}310311312