From 1ba91a4f1242af9ad3361f4955fe6fe7b8b6117a Mon Sep 17 00:00:00 2001 From: Maciej Wasilewski Date: Mon, 26 Feb 2024 09:20:11 +0100 Subject: [PATCH] Split YComments into separated services --- src/comments/sidebarWidget.js | 80 +++++----- src/comments/textareaWidget.js | 2 +- src/comments/ycomments.js | 261 ++++++++++++++++++++++----------- src/components/CodeMirror.js | 10 +- src/components/Comment.js | 36 ++--- 5 files changed, 227 insertions(+), 162 deletions(-) diff --git a/src/comments/sidebarWidget.js b/src/comments/sidebarWidget.js index ac61129..31ca764 100644 --- a/src/comments/sidebarWidget.js +++ b/src/comments/sidebarWidget.js @@ -5,7 +5,7 @@ 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"; + static COMMENT_IMAGE_CLASS = "comment-image"; /** * @param {BlockInfo} line @@ -13,58 +13,56 @@ class CommentMarker extends GutterMarker { */ 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; - } - } + this.gutterMarker = null; + this.icon = null; - gutterCommentMarker() { - const commentMarker = document.createElement("div"); - commentMarker.classList.add(CommentMarker.MAIN_CLASS) - if (this.lineNumber) { - commentMarker.style.width = (this.lineNumber.toString().length * 5) + "px"; + if (view && line) { + this.ycomments = view.state.facet(ycommentsFacet); + this.lineNumber = view.state.doc.lineAt(line.to).number; + this.commentId = this.ycomments.findCommentOn(this.lineNumber)?.commentId; } - 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; + createGutterMarker() { + this.gutterMarker = document.createElement("div"); + this.gutterMarker.classList.add(CommentMarker.MAIN_CLASS); + if (this.lineNumber) { + this.gutterMarker.style.width = (this.lineNumber.toString().length * 7) + "px"; + } + } + + addHoverEffects() { + this.icon.onmouseenter = () => this.icon.classList.add(CommentMarker.COMMENT_IMAGE_CLASS); + this.icon.onmouseleave = () => this.icon.classList.remove(CommentMarker.COMMENT_IMAGE_CLASS); } - markHasComments(marker) { - marker.classList.add(CommentMarker.HAS_COMMENTS_CLASS); + createPopupIcon() { + this.icon = document.createElement("section"); + this.icon.classList = CommentMarker.ICON_CLASS; + + if (!this.commentId) { + this.addHoverEffects(); + } } - addPopupIcon(marker) { - marker.appendChild(this.popupIcon()) + markHasComments() { + this.icon.classList.add(CommentMarker.COMMENT_IMAGE_CLASS) } /** Main function. Used to render the actual gutter marker */ toDOM() { - const marker = this.gutterCommentMarker(); + this.createGutterMarker(); + this.createPopupIcon(); if (this.hasComments()) { - this.markHasComments(marker) + this.markHasComments(); } - this.addPopupIcon(marker); - return marker + + this.gutterMarker.appendChild(this.icon); + return this.gutterMarker; } } @@ -73,13 +71,11 @@ class CommentMarker extends GutterMarker { * @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 - + const lineNumber = view.state.doc.lineAt(line.to).number; + const comment = ycomments.findCommentOn(lineNumber)?.commentId; + if (!comment) { - return ycomments.newComment(thisLineNumber); + return ycomments.newComment(lineNumber); } return comment @@ -96,7 +92,7 @@ const commentMarker = gutter({ mousedown(view, line) { let ycomments = view.state.facet(ycommentsFacet.reader); let commentId = getOrCreateComment(view, line, ycomments); - let willBeVisible = ycomments.switchVisibility(commentId); + let willBeVisible = ycomments.display().switchVisibility(commentId); if (!willBeVisible && ycomments.isEmpty(commentId)) { ycomments.deleteComment(commentId); diff --git a/src/comments/textareaWidget.js b/src/comments/textareaWidget.js index cde8338..037e0d0 100644 --- a/src/comments/textareaWidget.js +++ b/src/comments/textareaWidget.js @@ -62,7 +62,7 @@ const moveComments = (transaction, ycomments) => { 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); + ycomments.positions().shift(cursorLine, lineDiff, docLines); } } } diff --git a/src/comments/ycomments.js b/src/comments/ycomments.js index a2699ed..d683f92 100644 --- a/src/comments/ycomments.js +++ b/src/comments/ycomments.js @@ -2,36 +2,145 @@ import * as Y from "yjs"; import { updateShownComments } from "./state"; import { WebsocketProvider } from "y-websocket"; -const randomId = () => "comment-" + Math.random().toString().replace(".", "") - /** - * @typedef {{ height: number, isShown: boolean }} CommentInfo + * @typedef {{ height: number, isShown: boolean, top?: number }} CommentInfo * @typedef {{ [id: string]: CommentInfo }} AllCommentInfo */ + +const randomId = () => "comment-" + Math.random().toString().replace(".", "") + +export class CommentPositionManager { + /** @param {Y.Doc} ydoc */ + constructor(ydoc) { + /** @type {Y.Map} A map from line numbers to comment ids */ + this.commentPositions = ydoc.getMap(YComments.dataPath); + } + + iter() { + return [...this.commentPositions.entries()] + .map(([commentId, lineNumber]) => ({ commentId, lineNumber: parseInt(lineNumber) })) + } + + move(commentId, targetLine) { + if (targetLine > 0 && !this.isOccupied(targetLine)) { + this.commentPositions.set(commentId, targetLine); + } + } + + shift(startLine, diff, maxLine) { + if (diff < 0) { + this.iter() + .filter(c => startLine + diff < c.lineNumber && c.lineNumber <= startLine) + .forEach(c => this.del(c.commentId)); + } + + this.iter() + .filter(c => c.lineNumber >= startLine) + .filter(c => c.lineNumber + diff <= maxLine) + .forEach(c => this.move(c.commentId, c.lineNumber + diff)); + } + + isOccupied(lineNumber) { + return this.iter() + .some(c => c.lineNumber == lineNumber) + } + + get(commentId) { return this.commentPositions.get(commentId) } + + set(commentId, lineNumber) { return this.commentPositions.set(commentId, lineNumber) } + + del(commentId) { this.commentPositions.delete(commentId); } +} + +export class DisplayManager { + constructor() { + this.comments = {}; + this._onUpdate = () => { }; + } + + onUpdate(f) { this._onUpdate = f } + + switchVisibility(commentId) { + const state = this.isShown(commentId); + const newState = !state; + this.setVisibility(commentId, newState); + return newState; + } + + setVisibility(commentId, state) { + this.update(comments => { + if (!comments[commentId]) comments[commentId] = {} + comments[commentId].isShown = state; + return comments; + }) + } + + setHeight(commentId, height) { + this.update(ci => { + ci[commentId] ||= {}; + ci[commentId].height = height; + return ci; + }); + } + + offset(commentId) { + return this.comments[commentId].top + } + + isShown(commentId) { + if (this.comments[commentId]) { + return this.comments[commentId].isShown; + } + return true; + } + + del(commentId) { + this.update(comments => { + delete comments[commentId]; + return comments; + }); + } + + new(commentId) { + this.update(comments => { + comments[commentId] = { height: 18, isShown: false }; + return comments; + }); + } + + update(f) { + if (f) this.comments = f(this.comments); + this._onUpdate(); + } + + show(commentId) { this.setVisibility(commentId, true) } +} + + export class YComments { static commentsPrefix = "comments/"; /** * @param {Y.Doc} ydoc - * @param {(setter: (comments: AllCommentInfo) => AllCommentInfo) => void} setComments * @param {WebsocketProvider} provider - * @param {AllCommentInfo} comments */ - constructor(ydoc, provider, setComments, comments) { + constructor(ydoc, provider) { this.ydoc = ydoc; this.provider = provider; - this.setComments = setComments; - this.comments = comments; - - this.user = provider.awareness.getLocalState().user; - - /** @type {Y.Map} A map from line numbers to comment ids */ - this.commentPositions = ydoc.getMap(YComments.dataPath); /** @type {EditorView} The main codemirror instance */ this.mainCodeMirror = null; + + this.positionManager = new CommentPositionManager(ydoc); + this.displayManager = new DisplayManager(); + + this.positionManager.commentPositions.observeDeep(() => this.updateMainCodeMirror()) } + positions() { return this.positionManager } + + display() { return this.displayManager } + registerCodeMirror(cm) { this.mainCodeMirror = cm; } @@ -40,22 +149,9 @@ export class YComments { return this.ydoc.getText(YComments.commentsPrefix + commentId); } - getProvider() { - return this.provider; - } - - switchVisibility(commentId) { - const state = this.isShown(commentId); - const newState = !state; - - this.updateComments(comments => { - if (!comments[commentId]) comments[commentId] = {} - - comments[commentId].isShown = newState; - return comments; - }) - - return newState; + delText(commentId) { + let text = this.getTextForComment(commentId); + if (text?.parent) text.delete(); } isShown(commentId) { @@ -101,44 +197,43 @@ export class YComments { newComment(lineNumber) { const newCommentId = randomId(); - this.commentPositions.set(newCommentId, lineNumber.toString()); // Update YJS state - this.updateComments(comments => { // Update Preact state - comments[newCommentId] = { height: 18, isShown: false }; - return comments; - }); - - return newCommentId + this.positions().set(newCommentId, lineNumber.toString()); + this.display().new(newCommentId); + return newCommentId; } deleteComment(commentId) { - this.commentPositions.delete(commentId); // Update YJS state - this.updateComments(comments => { // Update Preact state - delete comments[commentId]; - return comments; - }); + this.positions().del(commentId); + this.display().del(commentId); + this.delText(commentId); } isEmpty(commentId) { return this.getTextForComment(commentId).length === 0; } - //////////////////////// SYNCHRONIZATION //////////////////////// + findCommentOn(lineNumber) { + return this.positions() + .iter() + .find(c => c.lineNumber == lineNumber); + } - /** Move Preact element to the `height` (relative to the main CodeMirror position) */ - syncHeight(commentId, height) { - this.updateComments(ci => { - ci[commentId].height = height; - return ci; - }); + //////////////////////// SYNCHRONIZATION //////////////////////// + updateHeight(commentId, height) { + this.display().setHeight(commentId, height); this.updateMainCodeMirror(); } /** Look for comment boxes in the main `CodeMirror` instance */ syncCommentLocations(update) { - this.updateComments( // sync comments locations + this.display().update( // sync comments locations (comments) => { - let boxes = update.view.dom.querySelectorAll(".comment-box"); - boxes.forEach(box => comments[box.id].top = box.offsetTop); + update.view.dom + .querySelectorAll(".comment-box") + .forEach(box => { + comments[box.id] ||= {}; + comments[box.id].top = box.offsetTop + }); return comments; } ) @@ -146,30 +241,34 @@ export class YComments { /** Fetch comments which are in Y.js state but not in Preact */ syncRemoteComments() { - this.updateComments(comments => { - this.iterYComments() - .filter(c => !comments[c.commentId]) - .forEach(c => { - comments[c.commentId] = { isShown: true, height: 17 }; - this.updateMainCodeMirror(); - }); - return comments; - }); - } + this.display() + .update(comments => { + this.positions() + .iter() + .filter(c => !comments[c.commentId]) + .forEach(c => { + comments[c.commentId] = { isShown: true, height: 17 }; + this.updateMainCodeMirror(); + }); + return comments; + }); + } /** Remove comments which are in Preact state but not in Y.js */ removeLocalComments() { - this.updateComments(comments => { - let remoteComments = this.iterYComments() - .map(c => c.commentId); - - for (let commentId in comments) { - if (!remoteComments.includes(commentId)) { - delete comments[commentId]; + let remoteComments = this.positions() + .iter() + .map(c => c.commentId); + + this.display() + .update(comments => { + for (let commentId in comments) { + if (!remoteComments.includes(commentId)) { + delete comments[commentId]; + } } - } - return comments; - }); + return comments; + }); } /** Full synchronization between Y.js and Preact state */ @@ -179,24 +278,10 @@ export class YComments { this.removeLocalComments(); } - //////////////////////// UTILITY //////////////////////// - - updateComments(f) { - this.setComments(ci => { - const newCommentState = { ...f(ci) }; - this.comments = newCommentState; - return newCommentState; - }); - } - - iterYComments() { - return [...this.commentPositions.entries()] - .map(([commentId, lineNumber]) => ({commentId, lineNumber: parseInt(lineNumber)})) - } - iterComments() { - const addPreactState = ({lineNumber, commentId}) => ({ ...this.comments[commentId], lineNumber, commentId }); - return this.iterYComments() + const addPreactState = ({ lineNumber, commentId }) => ({ ...this.displayManager.comments[commentId], lineNumber, commentId }); + return this.positions() + .iter() .map(addPreactState); } diff --git a/src/components/CodeMirror.js b/src/components/CodeMirror.js index 0b16114..c0b8762 100644 --- a/src/components/CodeMirror.js +++ b/src/components/CodeMirror.js @@ -35,21 +35,13 @@ const CodeEditor = styled.div` cursor: pointer; } - .comment-gutter.has-comments > .comment-gutter-icon { + .comment-image { position: absolute; display: inline; background-color: var(--gray-200); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:sketch='http://www.bohemiancoding.com/sketch/ns' width='17px' height='17px' viewBox='0 0 32 32' version='1.1'%3E%3Ctitle%3Ecomment-3%3C/title%3E%3Cdesc%3ECreated with Sketch Beta.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' sketch:type='MSPage'%3E%3Cg id='Icon-Set' sketch:type='MSLayerGroup' transform='translate(-204.000000, -255.000000)' fill='%23000000'%3E%3Cpath d='M228,267 C226.896,267 226,267.896 226,269 C226,270.104 226.896,271 228,271 C229.104,271 230,270.104 230,269 C230,267.896 229.104,267 228,267 L228,267 Z M220,281 C218.832,281 217.704,280.864 216.62,280.633 L211.912,283.463 L211.975,278.824 C208.366,276.654 206,273.066 206,269 C206,262.373 212.268,257 220,257 C227.732,257 234,262.373 234,269 C234,275.628 227.732,281 220,281 L220,281 Z M220,255 C211.164,255 204,261.269 204,269 C204,273.419 206.345,277.354 210,279.919 L210,287 L217.009,282.747 C217.979,282.907 218.977,283 220,283 C228.836,283 236,276.732 236,269 C236,261.269 228.836,255 220,255 L220,255 Z M212,267 C210.896,267 210,267.896 210,269 C210,270.104 210.896,271 212,271 C213.104,271 214,270.104 214,269 C214,267.896 213.104,267 212,267 L212,267 Z M220,267 C218.896,267 218,267.896 218,269 C218,270.104 218.896,271 220,271 C221.104,271 222,270.104 222,269 C222,267.896 221.104,267 220,267 L220,267 Z' id='comment-3' sketch:type='MSShapeGroup'%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); } - .comment-gutter > .comment-gutter-icon { - position: absolute; - &:hover { - background-color: var(--gray-200); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:sketch='http://www.bohemiancoding.com/sketch/ns' width='17px' height='17px' viewBox='0 0 32 32' version='1.1'%3E%3Ctitle%3Ecomment-3%3C/title%3E%3Cdesc%3ECreated with Sketch Beta.%3C/desc%3E%3Cdefs%3E%3C/defs%3E%3Cg id='Page-1' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' sketch:type='MSPage'%3E%3Cg id='Icon-Set' sketch:type='MSLayerGroup' transform='translate(-204.000000, -255.000000)' fill='%23000000'%3E%3Cpath d='M228,267 C226.896,267 226,267.896 226,269 C226,270.104 226.896,271 228,271 C229.104,271 230,270.104 230,269 C230,267.896 229.104,267 228,267 L228,267 Z M220,281 C218.832,281 217.704,280.864 216.62,280.633 L211.912,283.463 L211.975,278.824 C208.366,276.654 206,273.066 206,269 C206,262.373 212.268,257 220,257 C227.732,257 234,262.373 234,269 C234,275.628 227.732,281 220,281 L220,281 Z M220,255 C211.164,255 204,261.269 204,269 C204,273.419 206.345,277.354 210,279.919 L210,287 L217.009,282.747 C217.979,282.907 218.977,283 220,283 C228.836,283 236,276.732 236,269 C236,261.269 228.836,255 220,255 L220,255 Z M212,267 C210.896,267 210,267.896 210,269 C210,270.104 210.896,271 212,271 C213.104,271 214,270.104 214,269 C214,267.896 213.104,267 212,267 L212,267 Z M220,267 C218.896,267 218,267.896 218,269 C218,270.104 218.896,271 220,271 C221.104,271 222,270.104 222,269 C222,267.896 221.104,267 220,267 L220,267 Z' id='comment-3' sketch:type='MSShapeGroup'%3E%3C/path%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); - } - } - .comment-box { width: 95%; display: flex; diff --git a/src/components/Comment.js b/src/components/Comment.js index 8dc7330..5e507af 100644 --- a/src/components/Comment.js +++ b/src/components/Comment.js @@ -4,6 +4,7 @@ import { styled } from 'styled-components'; import { EditorView } from "codemirror"; import { EditorState } from "@codemirror/state"; import { ExtensionBuilder } from "../extensions"; +import { YComments } from "../comments/ycomments"; const YCommentWrapper = styled.div` position:absolute; @@ -25,11 +26,12 @@ const YCommentWrapper = styled.div` background-color: var(--gray-500); } ` -const YComment = ({ ycomments, shownComments, commentId }) => { +/** @param {{ ycomments: YComments }} */ +const YComment = ({ ycomments, commentId }) => { let cmref = useRef(null); const updateHeight = useCallback( - (update) => update.heightChanged && ycomments.syncHeight(commentId, cmref.current.clientHeight), + (update) => update.heightChanged && ycomments.updateHeight(commentId, cmref.current.clientHeight), [commentId] ) @@ -42,7 +44,7 @@ const YComment = ({ ycomments, shownComments, commentId }) => { state: EditorState.create({ doc: ytext.toString(), extensions: ExtensionBuilder.minimalSetup() - .useCollaboration({ ytext, provider: ycomments.getProvider() }) + .useCollaboration({ ytext, provider: ycomments.provider }) .useDefaultHistory() .addUpdateListener(updateHeight) .create() @@ -57,31 +59,21 @@ const YComment = ({ ycomments, shownComments, commentId }) => { }, [cmref]) return html` - <${YCommentWrapper} top=${shownComments[commentId].top}> -
+ <${YCommentWrapper} top=${ycomments.display().offset(commentId)}> +
` } +/** @param {{ ycomments: YComments }} */ export const YCommentsParent = ({ ycomments }) => { - let createCommentPopup = useCallback( - (commentId) => html` - <${YComment} - key=${commentId} - commentId=${commentId} - ycomments=${ycomments} - shownComments=${ycomments.comments} - />`, - [ycomments, ycomments.comments] - ); + let createWidget = ({ commentId }) => html`<${YComment} ...${{key: commentId, commentId, ycomments}}/>` + let createWidgets = () => ycomments.iterComments().map(createWidget) + let [widgets, setWidgets] = useState(createWidgets()); - let [elems, setElems] = useState(Object.keys(ycomments.comments).map(createCommentPopup)); + ycomments.display() + .onUpdate(() => setWidgets(createWidgets())); - useEffect( - () => setElems(Object.keys(ycomments.comments).map(createCommentPopup)), - [ycomments.comments] - ) - - return html`${elems}` + return html`${widgets}` } \ No newline at end of file