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">
+
+ />`}
/>
<${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 @@
+