diff --git a/src/comments/lineAuthors.js b/src/comments/lineAuthors.js index 4f8ec19..948fcc7 100644 --- a/src/comments/lineAuthors.js +++ b/src/comments/lineAuthors.js @@ -14,6 +14,8 @@ const lineAuthorsFacet = Facet.define({ ////////////////////////////// LINE-COLORING ////////////////////////////// +export const lineAuthorsEffect = StateEffect.define(); + /** * A CodeMirror extension. Colors line of the editor respective to their authors. * @@ -104,7 +106,7 @@ const commentLineHighlighter = ViewPlugin.fromClass( /** @param {ViewUpdate} update */ update(update) { - if (update.docChanged || update.viewportChanged) { + if (update.docChanged || update.viewportChanged || update.transactions.some((t) => t.effects.some((e) => e.is(lineAuthorsEffect)))) { update.transactions.filter(isUserEvent).forEach((t) => this.markLinesEditedInTransaction(t)); this.decorations = this.colorEditorLines(update.view); diff --git a/src/comments/ycomments.js b/src/comments/ycomments.js index a9f0494..2d56dff 100644 --- a/src/comments/ycomments.js +++ b/src/comments/ycomments.js @@ -283,6 +283,11 @@ export class YComments { this.draggedComment = null; this.commentWithPopup = null; + /** commentId -> (EditorView) => void - here a listener can be added to wait for an editor for a comment to become available */ + this.commentEditorsListeners = new Map(); + /** commentId -> CodeMirror View */ + this.commentEditors = new Map(); + this.suggestions = ydoc.getMap("suggestions"); this.suggestions.observe(() => { if (!this.mainCodeMirror) return; @@ -555,4 +560,23 @@ export class YComments { this.display().setVisibility(id, true); } } + + registerCommentEditor(commentId, view) { + this.commentEditors.set(commentId, view); + if (this.commentEditorsListeners.has(commentId)) { + this.commentEditorsListeners.get(commentId)(view); + this.commentEditorsListeners.delete(commentId); + } + } + + getEditorForComment(commentId) { + return new Promise((resolve) => { + if (this.commentEditors.has(commentId)) { + resolve(this.commentEditors.get(commentId)); + } else { + // Wait for the editor to be available, then resolve. + this.commentEditorsListeners.set(commentId, resolve); + } + }); + } } diff --git a/src/components/CodeMirror.js b/src/components/CodeMirror.js index a565057..c3e35c3 100644 --- a/src/components/CodeMirror.js +++ b/src/components/CodeMirror.js @@ -7,7 +7,8 @@ import { ExtensionBuilder } from "../extensions"; import { YCommentsParent } from "../components/Comment"; import commentIcon from "../icons/comment.svg?raw"; import { customHighlighter } from "../extensions/customHighlights"; -import { suggestionCompartment } from "../extensions/suggestions"; +import { AddSuggestionBtn, suggestionCompartment } from "../extensions/suggestions"; +import editIcon from "../icons/edit.svg"; const CodeEditor = styled.div` border-radius: var(--border-radius); @@ -203,6 +204,7 @@ const CodeMirror = ({ text, id, name, mode, spellcheckOpts, highlights, collabor editorRef, }) .useComments({ enabled: collaboration.opts.commentsEnabled, ycomments: collaboration.ycomments }) + .useSuggestionPopup({ enabled: collaboration.opts.commentsEnabled, ycomments: collaboration.ycomments }) .addUpdateListener((update) => update.docChanged && text.set(view.state.doc.toString())) .useRemoveSelectionOnBlur() .create(), @@ -233,6 +235,10 @@ const CodeMirror = ({ text, id, name, mode, spellcheckOpts, highlights, collabor ${collaboration.opts.commentsEnabled && !collaboration.error && html`<${YCommentsParent} ycomments=${collaboration.ycomments} collaboration=${collaboration.opts} />`} + ${collaboration.opts.commentsEnabled && + html`<${AddSuggestionBtn} style="display: none" className="myst-add-suggestion" title="Suggest Changes"> + edit + `} <${HiddenTextArea} value=${text.get()} name=${name} id=${id}> `; diff --git a/src/components/Comment.js b/src/components/Comment.js index 834183d..e856c52 100644 --- a/src/components/Comment.js +++ b/src/components/Comment.js @@ -104,6 +104,7 @@ const YComment = ({ ycomments, commentId, collaboration }) => { }); ycomments.syncSuggestions(commentId); + ycomments.registerCommentEditor(commentId, view); ytext.observe((_, tr) => { if (!tr.local) return; ycomments.syncSuggestions(commentId); diff --git a/src/extensions/index.js b/src/extensions/index.js index 2129418..2f9883e 100644 --- a/src/extensions/index.js +++ b/src/extensions/index.js @@ -8,6 +8,7 @@ import spellcheck from "./spellchecker"; import { customHighlighter } from "./customHighlights"; import { commentExtension } from "../comments"; import { commentAuthoring } from "../comments/lineAuthors"; +import { suggestionPopup } from "./suggestions"; const basicSetupWithoutHistory = basicSetup.filter((_, i) => i != 3); const minimalSetupWithoutHistory = minimalSetup.filter((_, i) => i != 1); @@ -136,6 +137,13 @@ export class ExtensionBuilder { return this; } + useSuggestionPopup({ enabled, ycomments }) { + if (enabled) { + this.base.push(EditorView.updateListener.of((update) => suggestionPopup(update, ycomments))); + } + return this; + } + create() { return [...this.important, ...this.base, ...this.extensions]; } diff --git a/src/extensions/suggestions.js b/src/extensions/suggestions.js index d05f0db..1e2a256 100644 --- a/src/extensions/suggestions.js +++ b/src/extensions/suggestions.js @@ -1,5 +1,9 @@ -import { Compartment, StateEffect } from "@codemirror/state"; -import { Decoration, WidgetType } from "@codemirror/view"; +import { Compartment, EditorSelection, StateEffect } from "@codemirror/state"; +import { Decoration, EditorView, ViewUpdate, WidgetType } from "@codemirror/view"; +import { YComments } from "../comments/ycomments"; +import { lineAuthorsEffect } from "../comments/lineAuthors"; +import styled from "styled-components"; +import DefaultButton from "../components/Buttons"; export const suggestionEffect = StateEffect.define(); @@ -27,7 +31,7 @@ export function parseCommentLine({ commentId, text, color }) { if (targetStr.length !== 0) { suggestions.push({ - targetRegexSrc: `(?<=^|[ \\t\\r\\.]|\\b)${escapeRegExp(targetStr)}(?=$|[\\s\\.]|\\b)`, + targetRegexSrc: `(?<=^|[ \\t\\r\\.]|\\W)${escapeRegExp(targetStr)}(?=$|[\\s\\.]|\\W)`, targetRegexFlags: "gm", id: commentId, cssClass: "cm-suggestion", @@ -97,3 +101,102 @@ class Replacement extends WidgetType { return replacementText; } } + +export function suggestionPopup(/** @type {ViewUpdate} */ update, /** @type {YComments} */ ycomments) { + const addSuggestionBtn = document.querySelector(".myst-add-suggestion"); + const mainSel = update.state.selection.main; + const noSelection = mainSel.head === mainSel.anchor; + const multilineSelection = update.state.doc.lineAt(mainSel.head).number !== update.state.doc.lineAt(mainSel.anchor).number; + if (!update.selectionSet || noSelection || multilineSelection) { + addSuggestionBtn.style.display = "none"; + return; + } + + const startPos = update.view.coordsAtPos(mainSel.from); + const endPos = update.view.coordsAtPos(mainSel.to); + const middle = (startPos.left + endPos.left) / 2; + addSuggestionBtn.style.top = `${startPos.top - 130 + window.scrollY}px`; + addSuggestionBtn.style.left = `${middle - 20}px`; + addSuggestionBtn.style.display = "block"; + + addSuggestionBtn.onmousedown = async (ev) => { + // This is to ensure the button does not take focus from CodeMirror + ev.preventDefault(); + const line = update.state.doc.lineAt(mainSel.from); + + let suggestionFrom = mainSel.from; + const wordBoundaryRegex = /[ \t\r\W]/; + let pos; + for (pos = suggestionFrom - 1; pos >= line.from; pos--) { + if (wordBoundaryRegex.test(update.state.doc.sliceString(pos, pos + 1))) { + break; + } + } + suggestionFrom = pos + 1; + + let suggestionTo = mainSel.to; + for (pos = suggestionTo; pos <= line.to; pos++) { + if (wordBoundaryRegex.test(update.state.doc.sliceString(pos, pos + 1))) { + break; + } + } + suggestionTo = pos; + + let suggestionText = `|${update.state.doc.sliceString(suggestionFrom, suggestionTo)} -> |`; + let id = ycomments.findCommentOn(line.number)?.commentId; + if (id) { + suggestionText = "\n" + suggestionText; + } else { + id = ycomments.newComment(line.number); + } + + ycomments.ydoc.transact(() => { + const text = ycomments.getTextForComment(id); + text.insert(text.length, suggestionText); + const authors = ycomments.lineAuthors(id); + authors.mark(authors.lineAuthors.length); + }, ycomments.provider.awareness.clientID); + + ycomments.display().setVisibility(id, true); + ycomments.updateMainCodeMirror(); + + /** @type {EditorView} */ + const commentView = await ycomments.getEditorForComment(id); + commentView.focus(); + commentView.dispatch({ + selection: EditorSelection.create([EditorSelection.range(commentView.state.doc.length - 1, commentView.state.doc.length - 1)]), + effects: lineAuthorsEffect.of(null), + }); + }; +} + +export const AddSuggestionBtn = styled(DefaultButton)` + position: absolute; + z-index: 10; + display: none; + transform: translateX(-50%); + margin: 0 !important; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 !important; + + &:hover { + background-color: var(--gray-400); + } + + img { + filter: invert(100%); + } + + &::after { + content: ""; + position: absolute; + display: block; + transform: translate(10px, 8px); + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid var(--icon-border); + } +`; diff --git a/src/icons/edit.svg b/src/icons/edit.svg new file mode 100644 index 0000000..0af9739 --- /dev/null +++ b/src/icons/edit.svg @@ -0,0 +1,3 @@ + + +