Skip to content

Commit

Permalink
[#53027] Implement comments
Browse files Browse the repository at this point in the history
  • Loading branch information
MaciejWas authored and AdamOlech committed Feb 15, 2024
1 parent 3700d1e commit 79d94f9
Show file tree
Hide file tree
Showing 11 changed files with 627 additions and 5 deletions.
1 change: 1 addition & 0 deletions src/MystEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const MystWrapper = styled.div`
display: flex;
flex-flow: row wrap;
width: 100%;
position: relative;
background-color: white;
${props => props.fullscreen && 'box-sizing:border-box; height: calc(100vh - 60px); overflow-y: scroll;'}
`;
Expand Down
13 changes: 13 additions & 0 deletions src/comments/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { commentStateEffect } from "./textareaWidget";
import { commentMarker } from "../comments/sidebarWidget";
import { ycommentsFacet } from "./state";
import { EditorView } from "@codemirror/view";

const commentExtension = ycomments => [
ycommentsFacet.of(ycomments),
commentStateEffect,
commentMarker,
EditorView.updateListener.of(update => ycomments.syncComments(update))
]

export { commentExtension }
113 changes: 113 additions & 0 deletions src/comments/sidebarWidget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { gutter, GutterMarker, BlockInfo, EditorView } from "@codemirror/view"
import { YComments } from './ycomments';
import { updateShownComments, ycommentsFacet } from "./state";

class CommentMarker extends GutterMarker {
static MAIN_CLASS = "comment-gutter";
static ICON_CLASS = "comment-gutter-icon";
static HAS_COMMENTS_CLASS = "has-comments";

/**
* @param {BlockInfo} line
* @param {EditorView} view
*/
constructor(line, view) {
super();
this.line = line;
this.view = view;
if (view) {
this.ycomments = this.view.state.facet(ycommentsFacet);
this.lineNumber = this.view.state.doc.lineAt(this.line.to).number;
}
}

gutterCommentMarker() {
const commentMarker = document.createElement("div");
commentMarker.classList.add(CommentMarker.MAIN_CLASS)
if (this.lineNumber) {
commentMarker.style.width = (this.lineNumber.toString().length * 5) + "px";
}
return commentMarker
}

hasComments() {
if (!this.line) {
return false;
}

this.commentId = this.ycomments
.iterYComments()
.find(({lineNumber}) => lineNumber == this.lineNumber)
?.commentId;

return Boolean(this.commentId);
}

popupIcon() {
const icon = document.createElement("section");
icon.classList = CommentMarker.ICON_CLASS;
return icon;
}

markHasComments(marker) {
marker.classList.add(CommentMarker.HAS_COMMENTS_CLASS);
}

addPopupIcon(marker) {
marker.appendChild(this.popupIcon())
}

/** Main function. Used to render the actual gutter marker */
toDOM() {
const marker = this.gutterCommentMarker();
if (this.hasComments()) {
this.markHasComments(marker)
}
this.addPopupIcon(marker);
return marker
}
}

/**
* @param {YComments} ycomments
* @param {EditorView} view
*/
const getOrCreateComment = (view, line, ycomments) => {
const thisLineNumber = view.state.doc.lineAt(line.to).number;
const comment = ycomments.iterYComments()
.find(({lineNumber}) => lineNumber == thisLineNumber)
?.commentId

if (!comment) {
return ycomments.newComment(thisLineNumber);
}

return comment
}

/** @type {import("@codemirror/state").Extension} */
const commentMarker = gutter({
lineMarker(view, line) {
return new CommentMarker(line, view)
},
lineMarkerChange: (update) => update.transactions.some(t => t.effects.some(e => e.is(updateShownComments))),
initialSpacer: () => { return new CommentMarker(null, null) },
domEventHandlers: {
mousedown(view, line) {
let ycomments = view.state.facet(ycommentsFacet.reader);
let commentId = getOrCreateComment(view, line, ycomments);
let willBeVisible = ycomments.switchVisibility(commentId);

if (!willBeVisible && ycomments.isEmpty(commentId)) {
ycomments.deleteComment(commentId);
}

view.dispatch({ effects: updateShownComments.of(null) });
}
}
})


export { commentMarker }


16 changes: 16 additions & 0 deletions src/comments/state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { YComments } from './ycomments';
import { Facet, StateEffect } from "@codemirror/state";

/** @type {Facet<YComments, YComments>} */
const ycommentsFacet = Facet.define({
combine: values => values[values.length - 1],
static: true
})

/** @type {StateEffect<null>} */
const updateShownComments = StateEffect.define();

export {
ycommentsFacet,
updateShownComments
}
111 changes: 111 additions & 0 deletions src/comments/textareaWidget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { EditorView } from "@codemirror/view"
import { Decoration, WidgetType } from "@codemirror/view"
import { RangeSetBuilder, RangeSet, StateField, Transaction } from "@codemirror/state";
import { ycommentsFacet, updateShownComments, } from "./state";
import { YComments } from "./ycomments";

/** A placeholder. The role of this element is to reserve enough vertical and horizontal space for real comment.
* The real comment HTML element is rendered on the top of the textarea, outside the DOM tree of Code Mirror.
*/
class Comment extends WidgetType {
constructor(height, commentId) {
super();
this.height = height;
this.commentId = commentId;
}

toDOM() {
const btn = document.createElement("div");
btn.id = this.commentId;
btn.classList = "comment-box";
btn.style.height = (this.height) + "px";
return btn
}
}

const commentWidget = (height, commentId) => Decoration.widget({ widget: new Comment(height, commentId), side: 10000, inlineOrder: false, block: true })

const sortByLine = (commentA, commentB) => commentA.lineNumber - commentB.lineNumber;

/**
* @param {Transaction} transaction
* @returns {RangeSet<Decoration>}
*/
const shouldUpdateTextWidget = (transaction) =>
transaction.docChanged ||
transaction
.effects
.some(eff => eff.is(updateShownComments));

/** @param {Transaction} transaction */
const buildTextareaWidgets = (transaction) => [
(builder, { commentId, lineNumber, height }) => {
try {
const mountPoint = transaction.newDoc.line(lineNumber).to
builder.add(mountPoint, mountPoint, commentWidget(height, commentId));
} catch (e) {
console.warn(e)
console.warn(`An error occured when rendering comment ${commentId}. Comment will not be shown.`)
}
return builder;
},
new RangeSetBuilder()
];

/**
* @param {Transaction} transaction
* @param {YComments} ycomments
*/
const moveComments = (transaction, ycomments) => {
if (transaction.isUserEvent("input") || transaction.isUserEvent("delete")) {
const lineDiff = transaction.state.doc.lines - transaction.startState.doc.lines
if (lineDiff != 0) {
const docLines = transaction.state.doc.lines;
const cursorLine = transaction.state.doc.lineAt(transaction.selection.main.from).number - lineDiff;
ycomments.moveComments(cursorLine, lineDiff, docLines);
}
}
}

/** @type {StateField<RangeSet<Decoration>>} */
const commentStateEffect = StateField.define({

/**
* @returns {RangeSet<Decoration>}
*/
create() {
return new RangeSetBuilder().finish()
},

/**
* @param {RangeSet<Decoration>} oldState
* @param {Transaction} transaction
* @returns {RangeSet<Decoration>}
*/
update(oldState, transaction) {
if (shouldUpdateTextWidget(transaction)) {
const ycomments = transaction.state.facet(ycommentsFacet);
const isShown = ({isShown}) => isShown;

moveComments(transaction, ycomments)

return ycomments
.iterComments()
.filter(isShown)
.sort(sortByLine)
.reduce(
...buildTextareaWidgets(transaction)
)
.finish()
}

return oldState
},

provide(field) {
return EditorView.decorations.from(field);
}
});

export { commentStateEffect }

Loading

0 comments on commit 79d94f9

Please sign in to comment.