UNPKG

@senx/discovery-code

Version:

Discovery Code Editor

694 lines (693 loc) 30.1 kB
import { Range } from "monaco-editor"; import { commentReducer, reduceComments, ReviewCommentRenderState, ReviewCommentStatus, } from "./events-comments-reducers"; import * as uuid from "uuid"; export { reduceComments }; const monacoWindow = window; var NavigationDirection; (function (NavigationDirection) { NavigationDirection[NavigationDirection["next"] = 1] = "next"; NavigationDirection[NavigationDirection["prev"] = 2] = "prev"; })(NavigationDirection || (NavigationDirection = {})); export var EditorMode; (function (EditorMode) { EditorMode[EditorMode["insertComment"] = 1] = "insertComment"; EditorMode[EditorMode["editComment"] = 2] = "editComment"; EditorMode[EditorMode["toolbar"] = 3] = "toolbar"; })(EditorMode || (EditorMode = {})); export function createReviewManager(editor, currentUser, actions, onChange, config, verbose) { //For Debug: (window as any).editor = editor; const rm = new ReviewManager(editor, currentUser, onChange, config, verbose); rm.load(actions || []); return rm; } const defaultReviewManagerConfig = { commentIndent: 20, commentIndentOffset: 20, editButtonEditText: 'Edit', editButtonEnableEdit: true, editButtonEnableRemove: true, editButtonOffset: '-10px', formatDate: null, readOnly: false, rulerMarkerColor: 'darkorange', rulerMarkerDarkColor: 'darkorange', showAddCommentGlyph: true, showInRuler: true, verticalOffset: 0, cancelButton: { label: 'Cancel', class: '' }, addButton: { label: 'Add comment', class: '' }, replyButton: { label: 'Reply', class: '' }, removeButton: { label: 'Remove', class: '' }, editButton: { label: 'Edit', class: '' } }; const CONTROL_ATTR_NAME = 'ReviewManagerControl'; // const POSITION_BELOW = 2; //above=1, below=2, exact=0 const POSITION_EXACT = 0; export class ReviewManager { constructor(editor, currentUser, onChange, config, verbose) { this.currentUser = currentUser; this.editor = editor; this.activeComment = null; //TODO - consider moving onto the store this.widgetInlineToolbar = null; this.widgetInlineCommentEditor = null; this.onChange = onChange; this.editorMode = EditorMode.toolbar; this.config = Object.assign(Object.assign({}, defaultReviewManagerConfig), (config || {})); this.currentLineDecorations = []; this.currentCommentDecorations = []; this.currentLineDecorationLineNumber = null; this.events = []; this.store = { comments: {} }; //, viewZoneIdsToDelete: [] }; this.renderStore = {}; this.verbose = verbose; this.editorConfig = this.editor.getRawOptions(); this.editor.onDidChangeConfiguration(() => (this.editorConfig = this.editor.getRawOptions())); this.editor.onMouseDown(this.handleMouseDown.bind(this)); this.canAddCondition = this.editor.createContextKey('add-context-key', !this.config.readOnly); this.inlineToolbarElements = this.createInlineToolbarWidget(); this.editorElements = this.createInlineEditorWidget(); this.addActions(); if (this.config.showAddCommentGlyph) { this.editor.onMouseMove(this.handleMouseMove.bind(this)); } } setReadOnlyMode(value) { this.config.readOnly = value; this.canAddCondition.set(!value); this.renderAddCommentLineDecoration(null); } load(events) { const store = reduceComments(events); this.loadFromStore(store, events); } loadFromStore(store, events) { this.editor.changeViewZones((changeAccessor) => { // Remove all the existing comments for (const viewState of Object.values(this.store.comments)) { const x = this.getRenderState(viewState.comment.id); if (x && x.viewZoneId !== null) { changeAccessor.removeZone(x.viewZoneId); } } this.events = events; this.store = store; this.store.deletedCommentIds = null; this.store.dirtyCommentIds = null; this.renderStore = {}; this.refreshComments(); this.verbose && console.debug('Events Loaded:', events.length, 'Review Comments:', Object.values(this.store.comments).length); }); } getThemedColor(name) { // editor.background: e {rgba: e} // editor.foreground: e {rgba: e} // editor.inactiveSelectionBackground: e {rgba: e} // editor.selectionHighlightBackground: e {rgba: e} // editorIndentGuide.activeBackground: e {rgba: e} // editorIndentGuide.background: e {rgba: e} const theme = this.editor._themeService._theme; let value = theme.getColor(name); // HACK - Buttons themes are not in monaco ... so just hack in theme for dark const missingThemes = { /*dark: { "button.background": "#0e639c", "button.foreground": "#ffffff", }, light: { "button.background": "#007acc", "button.foreground": "#ffffff", },*/ }; if (!value) { value = missingThemes[theme.themeName.indexOf('dark') > -1 ? 'dark' : 'light'][name]; } return value; } createInlineEditButtonsElement() { const root = document.createElement('div'); root.className = 'editButtonsContainer'; root.style.marginLeft = this.config.editButtonOffset; const add = document.createElement('span'); add.innerText = this.config.replyButton.label; add.className = this.config.replyButton.class || 'editButton add'; add.setAttribute(CONTROL_ATTR_NAME, ''); add.onclick = () => this.setEditorMode(EditorMode.insertComment, 'add-comment'); root.appendChild(add); let remove = null; let edit = null; let spacer = null; if (this.config.editButtonEnableRemove) { spacer = document.createElement('div'); spacer.innerText = ' '; root.appendChild(spacer); remove = document.createElement('span'); remove.setAttribute(CONTROL_ATTR_NAME, ''); remove.innerText = this.config.removeButton.label; remove.className = this.config.removeButton.class || 'editButton remove'; remove.onclick = () => this.activeComment && this.removeComment(this.activeComment.id); root.appendChild(remove); } if (this.config.editButtonEnableEdit) { spacer = document.createElement('div'); spacer.innerText = ' '; root.appendChild(spacer); edit = document.createElement('span'); edit.setAttribute(CONTROL_ATTR_NAME, ''); edit.innerText = this.config.editButton.label || 'Edit'; edit.className = this.config.editButton.class || 'editButton edit'; edit.onclick = () => this.setEditorMode(EditorMode.editComment, 'edit'); root.appendChild(edit); } return { root, add, remove, edit }; } handleCancel() { this.setEditorMode(EditorMode.toolbar, 'cancel'); this.editor.focus(); } handleAddComment() { const lineNumber = this.activeComment ? this.activeComment.lineNumber : this.editor.getSelection().endLineNumber; const text = this.editorElements.textarea.value; const selection = this.activeComment ? null : this.editor.getSelection(); this.addComment(lineNumber, text, selection); this.setEditorMode(EditorMode.toolbar, 'add-comment-1'); this.editor.focus(); } handleTextAreaKeyDown(e) { if (e.code === 'Escape') { this.handleCancel(); e.preventDefault(); console.info('preventDefault: Escape Key'); } else if (e.code === 'Enter' && e.ctrlKey) { this.handleAddComment(); e.preventDefault(); console.info('preventDefault: ctrl+Enter'); } } createInlineEditorElement() { const theme = this.editor._themeService._theme.themeName.indexOf('dark') > -1 ? 'dark' : 'light'; const root = document.createElement('div'); root.className = 'reviewCommentEditor ' + theme; const textarea = document.createElement('textarea'); textarea.setAttribute(CONTROL_ATTR_NAME, ''); textarea.className = 'reviewCommentEditor text'; textarea.innerText = ''; textarea.style.resize = 'none'; textarea.style.width = '100%'; textarea.name = 'text'; textarea.onkeydown = this.handleTextAreaKeyDown.bind(this); const confirm = document.createElement('button'); confirm.setAttribute(CONTROL_ATTR_NAME, ''); confirm.className = this.config.addButton.class || 'reviewCommentEditor save'; confirm.innerText = this.config.addButton.label || 'Add Comment'; confirm.onclick = this.handleAddComment.bind(this); const cancel = document.createElement('button'); cancel.setAttribute(CONTROL_ATTR_NAME, ''); cancel.className = this.config.cancelButton.class || 'reviewCommentEditor cancel'; cancel.innerText = this.config.cancelButton.label || 'Cancel'; cancel.onclick = this.handleCancel.bind(this); root.appendChild(textarea); root.appendChild(cancel); root.appendChild(confirm); return { root, confirm, cancel, textarea }; } createInlineToolbarWidget() { const buttonsElement = this.createInlineEditButtonsElement(); const this_ = this; this.widgetInlineToolbar = { allowEditorOverflow: true, getId: () => { return 'widgetInlineToolbar'; }, getDomNode: () => { return buttonsElement.root; }, getPosition: () => { if (this_.activeComment && this_.editorMode == EditorMode.toolbar && !this_.config.readOnly) { return { position: { lineNumber: this_.activeComment.lineNumber + 1, column: 1, }, preference: [POSITION_EXACT], }; } }, }; this.editor.addContentWidget(this.widgetInlineToolbar); return buttonsElement; } createInlineEditorWidget() { // doesn't re-theme when const editorElement = this.createInlineEditorElement(); this.widgetInlineCommentEditor = { allowEditorOverflow: true, getId: () => 'widgetInlineEditor', getDomNode: () => editorElement.root, getPosition: () => { if (this.editorMode == EditorMode.insertComment || this.editorMode == EditorMode.editComment) { const position = this.editor.getPosition(); return { position: { lineNumber: this.activeComment ? this.activeComment.lineNumber : position.lineNumber + 1, column: position.column, }, preference: [POSITION_EXACT], }; } }, }; this.editor.addContentWidget(this.widgetInlineCommentEditor); return editorElement; } setActiveComment(comment) { this.verbose && console.debug('setActiveComment', comment); const lineNumbersToMakeDirty = []; if (this.activeComment && (!comment || this.activeComment.lineNumber !== comment.lineNumber)) { lineNumbersToMakeDirty.push(this.activeComment.lineNumber); } if (comment) { lineNumbersToMakeDirty.push(comment.lineNumber); } this.activeComment = comment; if (lineNumbersToMakeDirty.length > 0) { this.filterAndMapComments(lineNumbersToMakeDirty, (comment) => { this.renderStore[comment.id].renderStatus = ReviewCommentRenderState.dirty; }); } } filterAndMapComments(lineNumbers, fn) { for (const cs of Object.values(this.store.comments)) { if (lineNumbers.indexOf(cs.comment.lineNumber) > -1) { fn(cs.comment); } } } handleMouseMove(ev) { if (ev.target && ev.target.position && ev.target.position.lineNumber) { this.currentLineDecorationLineNumber = ev.target.position.lineNumber; this.renderAddCommentLineDecoration(this.config.readOnly === true ? null : this.currentLineDecorationLineNumber); } } renderAddCommentLineDecoration(lineNumber) { const lines = lineNumber ? [ { range: new Range(lineNumber, 0, lineNumber, 0), options: { marginClassName: 'activeLineMarginClass', zIndex: 100, }, }, ] : []; this.currentLineDecorations = this.editor.deltaDecorations(this.currentLineDecorations, lines); } handleMouseDown(ev) { // Not ideal - but couldn't figure out a different way to identify the glyph event if (ev.target.element.className && ev.target.element.className.indexOf('activeLineMarginClass') > -1) { this.editor.setPosition({ lineNumber: this.currentLineDecorationLineNumber, column: 1, }); this.setEditorMode(EditorMode.insertComment, 'mouse-down-1'); } else if (!ev.target.element.hasAttribute(CONTROL_ATTR_NAME)) { let activeComment = null; if (ev.target.detail && ev.target.detail.viewZoneId !== null) { for (const comment of Object.values(this.store.comments).map((c) => c.comment)) { const x = this.getRenderState(comment.id); if (x.viewZoneId == ev.target.detail.viewZoneId) { activeComment = comment; break; } } } this.setActiveComment(activeComment); this.refreshComments(); this.setEditorMode(EditorMode.toolbar, 'mouse-down-2'); } } calculateMarginTopOffset(includeActiveCommentHeight) { let count = 0; let marginTop = 0; const lineHeight = this.editorConfig.lineHeight; if (this.activeComment) { for (let item of this.iterateComments()) { if (item.state.comment.lineNumber === this.activeComment.lineNumber && (item.state.comment != this.activeComment || includeActiveCommentHeight)) { count += this.calculateNumberOfLines(item.state.comment.text); } if (item.state.comment == this.activeComment) { break; } } marginTop = count * lineHeight; } return marginTop + this.config.verticalOffset; } layoutInlineToolbar() { this.inlineToolbarElements.root.style.backgroundColor = this.getThemedColor('editor.background'); this.inlineToolbarElements.root.style.marginTop = `${this.calculateMarginTopOffset(false)}px`; if (this.inlineToolbarElements.remove) { const hasChildren = this.activeComment && this.iterateComments((c) => c.comment.id === this.activeComment.id).length > 1; const isSameUser = this.activeComment && this.activeComment.author === this.currentUser; this.inlineToolbarElements.remove.style.display = hasChildren ? 'none' : ''; this.inlineToolbarElements.edit.style.display = hasChildren || !isSameUser ? 'none' : ''; } this.editor.layoutContentWidget(this.widgetInlineToolbar); } layoutInlineCommentEditor() { [this.editorElements.root, this.editorElements.textarea].forEach((e) => { e.style.backgroundColor = this.getThemedColor('editor.background'); e.style.color = this.getThemedColor('editor.foreground'); }); this.editorElements.confirm.innerText = this.editorMode === EditorMode.insertComment ? this.config.addButton.label || 'Add Comment' : 'Edit Comment'; // this.editorElements.root.style.marginTop = `${this.calculateMarginTopOffset( // true // )}px`; this.editor.layoutContentWidget(this.widgetInlineCommentEditor); } setEditorMode(mode, _why = null) { this.editorMode = this.config.readOnly ? EditorMode.toolbar : mode; this.layoutInlineToolbar(); this.layoutInlineCommentEditor(); if (mode === EditorMode.insertComment || mode === EditorMode.editComment) { if (mode === EditorMode.insertComment) { this.editorElements.textarea.value = ''; } else if (mode === EditorMode.editComment) { this.editorElements.textarea.value = this.activeComment ? this.activeComment.text : ''; } // HACK - because the event in monaco doesn't have preventdefault which means editor takes focus back... setTimeout(() => this.editorElements.textarea.focus(), 100); //TODO - make configurable } } getDateTimeNow() { return new Date(); } recurseComments(allComments, filterFn, depth, results) { const comments = Object.values(allComments).filter(filterFn); for (const cs of comments) { const comment = cs.comment; delete allComments[comment.id]; results.push({ depth, state: cs, }); this.recurseComments(allComments, (x) => x.comment.parentId === comment.id, depth + 1, results); } } iterateComments(filterFn) { if (!filterFn) { filterFn = (cs) => !cs.comment.parentId; } const copyCommentState = Object.assign({}, this.store.comments); const results = []; this.recurseComments(copyCommentState, filterFn, 0, results); return results; } removeComment(id) { return this.addEvent({ type: 'delete', targetId: id }); } addComment(lineNumber, text, selection) { const event = this.editorMode === EditorMode.editComment ? { type: 'edit', text, targetId: this.activeComment.id } : { type: 'create', text, lineNumber, selection, targetId: this.activeComment && this.activeComment.id, }; return this.addEvent(event); } addEvent(event) { event.createdBy = this.currentUser; event.createdAt = this.getDateTimeNow(); event.id = uuid.v4(); this.events.push(event); this.store = commentReducer(event, this.store); if (this.activeComment && !this.store.comments[this.activeComment.id]) { this.setActiveComment(null); } else if (this.activeComment && this.activeComment.status === ReviewCommentStatus.deleted) { this.setActiveComment(null); } this.refreshComments(); this.layoutInlineToolbar(); if (this.onChange) { this.onChange(this.events); } return event; } formatDate(dt) { if (this.config.formatDate) { return this.config.formatDate(dt); } else if (dt instanceof Date) { return dt.toISOString(); } else { return dt; } } static createElement(text, className, tagName = null) { const span = document.createElement(tagName || 'span'); span.className = className; span.innerText = text; return span; } getRenderState(commentId) { if (!this.renderStore[commentId]) { this.renderStore[commentId] = { viewZoneId: null, renderStatus: null }; } return this.renderStore[commentId]; } refreshComments() { this.editor.changeViewZones((changeAccessor) => { var _a; const lineNumbers = {}; for (const cid of Array.from(this.store.deletedCommentIds || [])) { const viewZoneId = (_a = this.renderStore[cid]) === null || _a === void 0 ? void 0 : _a.viewZoneId; changeAccessor.removeZone(viewZoneId); this.verbose && console.debug('Zone.Delete', viewZoneId); } this.store.deletedCommentIds = null; for (const cid of Array.from(this.store.dirtyCommentIds || [])) { this.getRenderState(cid).renderStatus = ReviewCommentRenderState.dirty; } this.store.dirtyCommentIds = null; for (const item of this.iterateComments()) { const rs = this.getRenderState(item.state.comment.id); if (rs.renderStatus === ReviewCommentRenderState.hidden) { this.verbose && console.debug('Zone.Hidden', item.state.comment.id); changeAccessor.removeZone(rs.viewZoneId); rs.viewZoneId = null; continue; } if (rs.renderStatus === ReviewCommentRenderState.dirty) { this.verbose && console.debug('Zone.Dirty', item.state.comment.id); changeAccessor.removeZone(rs.viewZoneId); rs.viewZoneId = null; rs.renderStatus = ReviewCommentRenderState.normal; } if (!lineNumbers[item.state.comment.lineNumber]) { lineNumbers[item.state.comment.lineNumber] = item.state.comment.selection; } if (rs.viewZoneId == null) { this.verbose && console.debug('Zone.Create', item.state.comment.id); const isActive = this.activeComment == item.state.comment; const domNode = ReviewManager.createElement('', `reviewComment ${isActive ? 'active' : ' inactive'}`); domNode.style.paddingLeft = this.config.commentIndent * (item.depth + 1) + this.config.commentIndentOffset + 'px'; domNode.style.backgroundColor = this.getThemedColor('editor.selectionHighlightBackground'); // For Debug - domNode.appendChild(this.createElement(`${item.state.comment.id}`, 'reviewComment id')) domNode.appendChild(ReviewManager.createElement(`${item.state.comment.author || ' '}`, 'reviewComment author')); domNode.appendChild(ReviewManager.createElement(' at ' + this.formatDate(item.state.comment.dt), 'reviewComment dt')); if (item.state.history.length > 1) { domNode.appendChild(ReviewManager.createElement(`(Edited ${item.state.history.length - 1} times)`, 'reviewComment history')); } domNode.appendChild(ReviewManager.createElement(`${item.state.comment.text}`, 'reviewComment text', 'div')); //todo jxb fixme // function getTextWidth() { // text = document.createElement("span"); // document.body.appendChild(text); // text.style.font = "times new roman"; // text.style.fontSize = 16 + "px"; // text.style.height = 'auto'; // text.style.width = 'auto'; // text.style.position = 'absolute'; // text.style.whiteSpace = 'no-wrap'; // text.innerHTML = 'Hello World'; // width = Math.ceil(text.clientWidth); // formattedWidth = width + "px"; // document.querySelector('.output').textContent // = formattedWidth; // document.body.removeChild(text); // } rs.viewZoneId = changeAccessor.addZone({ afterLineNumber: item.state.comment.lineNumber, heightInLines: this.calculateNumberOfLines(item.state.comment.text), domNode: domNode, suppressMouseDown: true, // This stops focus being lost the editor - meaning keyboard shortcuts keeps working }); } } if (this.config.showInRuler) { const decorators = []; for (const [ln, selection] of Object.entries(lineNumbers)) { decorators.push({ range: new Range(parseInt(ln), 0, parseInt(ln), 0), options: { isWholeLine: true, overviewRuler: { color: this.config.rulerMarkerColor, darkColor: this.config.rulerMarkerDarkColor, position: 2, }, }, }); if (selection) { decorators.push({ range: new Range(selection.startLineNumber, selection.startColumn, selection.endLineNumber, selection.endColumn), options: { className: 'reviewComment selection', }, }); } } this.currentCommentDecorations = this.editor.deltaDecorations(this.currentCommentDecorations, decorators); } }); } calculateNumberOfLines(text) { return text ? text.split(/\r*\n/).length + 1 : 1; } addActions() { var _a, _b, _c, _d, _e, _f; this.editor.addAction({ id: 'my-unique-id-add', label: this.config.addButton.label || 'Add Comment', keybindings: [ ((_a = monacoWindow.monaco) === null || _a === void 0 ? void 0 : _a.KeyMod.CtrlCmd) | ((_b = monacoWindow.monaco) === null || _b === void 0 ? void 0 : _b.KeyCode.F10), ], precondition: 'add-context-key', keybindingContext: null, contextMenuGroupId: 'navigation', contextMenuOrder: 0, run: () => { this.setEditorMode(EditorMode.insertComment, 'add-comment-x'); }, }); this.editor.addAction({ id: 'my-unique-id-next', label: 'Next Comment', keybindings: [ ((_c = monacoWindow.monaco) === null || _c === void 0 ? void 0 : _c.KeyMod.CtrlCmd) | ((_d = monacoWindow.monaco) === null || _d === void 0 ? void 0 : _d.KeyCode.F12), ], precondition: null, keybindingContext: null, contextMenuGroupId: 'navigation', contextMenuOrder: 0.101, run: () => { this.navigateToComment(NavigationDirection.next); }, }); this.editor.addAction({ id: 'my-unique-id-prev', label: 'Prev Comment', keybindings: [ ((_e = monacoWindow.monaco) === null || _e === void 0 ? void 0 : _e.KeyMod.CtrlCmd) | ((_f = monacoWindow.monaco) === null || _f === void 0 ? void 0 : _f.KeyCode.F11), ], precondition: null, keybindingContext: null, contextMenuGroupId: 'navigation', contextMenuOrder: 0.102, run: () => { this.navigateToComment(NavigationDirection.prev); }, }); } navigateToComment(direction) { let currentLine = 0; if (this.activeComment) { currentLine = this.activeComment.lineNumber; } else { currentLine = this.editor.getPosition().lineNumber; } const comments = Object.values(this.store.comments) .map((cs) => cs.comment) .filter((c) => { if (!c.parentId) { if (direction === NavigationDirection.next) { return c.lineNumber > currentLine; } else if (direction === NavigationDirection.prev) { return c.lineNumber < currentLine; } } }); if (comments.length) { comments.sort((a, b) => { if (direction === NavigationDirection.next) { return a.lineNumber - b.lineNumber; } else if (direction === NavigationDirection.prev) { return b.lineNumber - a.lineNumber; } }); const comment = comments[0]; this.setActiveComment(comment); this.refreshComments(); this.layoutInlineToolbar(); this.editor.revealLineInCenter(comment.lineNumber); } } updateConfig(config) { this.config = Object.assign(Object.assign({}, defaultReviewManagerConfig), (config || {})); } }