diff --git a/package.json b/package.json index 653b2c27..1cb2bc25 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@codemirror/view": "^6.21.3", "@handsontable/react": "^14.1.0", "@lezer/highlight": "^1.1.6", + "@onsetsoftware/automerge-patcher": "^0.13.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", @@ -51,6 +52,7 @@ "cmdk": "^0.2.0", "codemirror": "^6.0.1", "d3": "^7.8.5", + "diff": "^5.2.0", "eventemitter3": "^5.0.1", "haikunator": "^2.1.2", "handsontable": "^14.1.0", @@ -78,6 +80,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@testing-library/react": "^15.0.2", + "@types/diff": "^5.2.1", "@types/lodash": "^4.14.199", "@types/node": "^20.8.2", "@types/react": "^18.2.15", diff --git a/src/datatypes/bot/datatype.ts b/src/datatypes/bot/datatype.ts index 1780fe9f..00700679 100644 --- a/src/datatypes/bot/datatype.ts +++ b/src/datatypes/bot/datatype.ts @@ -1,6 +1,6 @@ import { ContactDoc, RegisteredContactDoc } from "@/os/explorer/account"; -import { MarkdownDatatype } from "@/datatypes/markdown/datatype"; -import { MarkdownDoc } from "@/datatypes/markdown/schema"; +import { MarkdownDatatype } from "@/datatypes/essay/datatype"; +import { MarkdownDoc } from "@/datatypes/essay/schema"; import { type DataType } from "@/os/datatypes"; import { AutomergeUrl, Repo } from "@automerge/automerge-repo"; import { Bot } from "lucide-react"; diff --git a/src/datatypes/bot/essayEditingBot.ts b/src/datatypes/bot/essayEditingBot.ts index 6ba52a7a..41ac7376 100644 --- a/src/datatypes/bot/essayEditingBot.ts +++ b/src/datatypes/bot/essayEditingBot.ts @@ -1,7 +1,7 @@ import { RegisteredContactDoc } from "@/os/explorer/account"; import { DEFAULT_MODEL, openaiClient } from "@/os/lib/llm"; import { createBranch } from "@/os/versionControl/branches"; -import { MarkdownDoc } from "@/datatypes/markdown/schema"; +import { MarkdownDoc } from "@/datatypes/essay/schema"; import { AutomergeUrl, DocHandle, Repo } from "@automerge/automerge-repo"; import { splice } from "@automerge/automerge/next"; import { EssayEditingBotDoc } from "./schema"; diff --git a/src/datatypes/markdown/datatype.ts b/src/datatypes/essay/datatype.ts similarity index 65% rename from src/datatypes/markdown/datatype.ts rename to src/datatypes/essay/datatype.ts index a651187d..42701e89 100644 --- a/src/datatypes/markdown/datatype.ts +++ b/src/datatypes/essay/datatype.ts @@ -12,11 +12,12 @@ import { } from "@/os/versionControl/utils"; import { next as A } from "@automerge/automerge"; import { Repo } from "@automerge/automerge-repo"; -import { Doc, splice } from "@automerge/automerge/next"; +import { splice } from "@automerge/automerge/next"; import { pick } from "lodash"; import { Text } from "lucide-react"; import { AssetsDoc } from "../../tools/essay/assets"; import { MarkdownDoc, MarkdownDocAnchor } from "./schema"; +import { diffWords } from "diff"; import JSZip from "jszip"; @@ -148,13 +149,13 @@ export const patchesToAnnotations = ( switch (patch.action) { case "splice": { - const patchStart = patch.path[1] as number; - const patchEnd = Math.min( + let fromPos = patch.path[1] as number; + let toPos = Math.min( (patch.path[1] as number) + patch.value.length, doc.content.length - 1 ); - const fromCursor = getCursorSafely(doc, ["content"], patchStart); - const toCursor = getCursorSafely(doc, ["content"], patchEnd); + let fromCursor = A.getCursor(doc, ["content"], fromPos); + let toCursor = A.getCursor(doc, ["content"], toPos); if (!fromCursor || !toCursor) { console.warn("Failed to get cursor for patch", patch); @@ -165,25 +166,17 @@ export const patchesToAnnotations = ( if ( nextPatch && nextPatch.action === "del" && - nextPatch.path[1] === patchEnd + nextPatch.path[1] === toPos ) { - const before = docBefore.content.slice( - patchStart - offset, - patchStart - offset + nextPatch.length + let deleted = docBefore.content.slice( + fromPos - offset, + fromPos - offset + (nextPatch.length ?? 1) ); + let inserted = patch.value; - annotations.push({ - type: "changed", - before, - after: patch.value, - anchor: { - fromCursor: fromCursor, - toCursor: toCursor, - }, - }); + annotations.push(...diffText(deleted, inserted, doc, fromPos)); offset += patch.value.length - nextPatch.length; - i += 1; } else { annotations.push({ @@ -193,6 +186,14 @@ export const patchesToAnnotations = ( fromCursor: fromCursor, toCursor: toCursor, }, + inversePatches: [ + { + action: "del", + path: ["content"], + cursor: fromCursor, + length: patch.value.length, + }, + ], }); offset += patch.value.length; @@ -201,18 +202,17 @@ export const patchesToAnnotations = ( } case "del": { const patchStart = patch.path[1] as number; - const patchEnd = (patch.path[1] as number) + 1; - const fromCursor = getCursorSafely(doc, ["content"], patchStart); - const toCursor = getCursorSafely(doc, ["content"], patchEnd); + const cursor = getCursorSafely(doc, ["content"], patchStart); + const patchLength = patch.length ?? 1; // length is undefined if only one character is deleted const deleted = docBefore.content.slice( patchStart - offset, - patchStart - offset + patch.length + patchStart - offset + patchLength ); offset -= patch.length; - if (!fromCursor || !toCursor) { + if (!cursor) { console.warn("Failed to get cursor for patch", patch); break; } @@ -221,9 +221,17 @@ export const patchesToAnnotations = ( type: "deleted", deleted, anchor: { - fromCursor: fromCursor, - toCursor: toCursor, + fromCursor: cursor, + toCursor: cursor, }, + inversePatches: [ + { + action: "splice", + path: ["content"], + cursor, + value: deleted, + }, + ], }); break; } @@ -236,10 +244,130 @@ export const patchesToAnnotations = ( return annotations; }; +const diffText = ( + before: string, + after: string, + doc: MarkdownDoc, + offset: number +): Annotation[] => { + const annotations: Annotation[] = []; + const parts = diffWords(before, after); + + for (let i = 0; i < parts.length; i++) { + let deleted = ""; + let added = ""; + + for (; i < parts.length; i++) { + let part = parts[i]; + + if (part.added) { + added += part.value; + offset += part.value.length; + } else if (part.removed) { + deleted += part.value; + } else { + if (part.value.trim() === "") { + added += part.value; + deleted += part.value; + offset += part.value.length; + } else if (deleted === "" && added === "") { + offset += part.value.length; + } else { + i--; + break; + } + } + + const nextPart = parts[i + 1]; + if ( + nextPart && + !nextPart.added && + !nextPart.removed && + nextPart.value.trim() !== "" + ) { + break; + } + } + + if (deleted.length > 0 && added.length > 0) { + const anchor = { + fromCursor: A.getCursor(doc, ["content"], offset - added.length), + toCursor: A.getCursor(doc, ["content"], offset), + }; + + annotations.push({ + type: "changed", + anchor, + before: deleted, + after: added, + inversePatches: [ + { + action: "del", + path: ["content"], + cursor: anchor.fromCursor, + length: added.length, + }, + { + action: "splice", + path: ["content"], + cursor: anchor.fromCursor, + value: deleted, + }, + ], + }); + } else if (deleted.length > 0) { + const cursor = A.getCursor(doc, ["content"], offset); + + annotations.push({ + type: "deleted", + anchor: { + fromCursor: cursor, + toCursor: cursor, + }, + deleted, + inversePatches: [ + { + action: "splice", + path: ["content"], + cursor, + value: deleted, + }, + ], + }); + } else if (added.length > 0) { + const anchor = { + fromCursor: A.getCursor(doc, ["content"], offset - added.length), + toCursor: A.getCursor(doc, ["content"], offset), + }; + annotations.push({ + type: "added", + anchor, + added, + inversePatches: [ + { + action: "del", + path: ["content"], + cursor: anchor.fromCursor, + length: added.length, + }, + ], + }); + } + } + + return annotations; +}; + const valueOfAnchor = (doc: MarkdownDoc, anchor: MarkdownDocAnchor) => { const from = getCursorPositionSafely(doc, ["content"], anchor.fromCursor); const to = getCursorPositionSafely(doc, ["content"], anchor.toCursor); + // if the anchor points to an empty range return undefined + // so highlight comments that point to this will be filtered out + if (from === to) { + return undefined; + } + return doc.content.slice(from, to); }; @@ -308,4 +436,5 @@ export const MarkdownDatatype: DataType< doAnchorsOverlap, sortAnchorsBy, fileExportMethods, + supportsInlineComments: true, // todo: this should be part of the viewer }; diff --git a/src/datatypes/markdown/index.ts b/src/datatypes/essay/index.ts similarity index 100% rename from src/datatypes/markdown/index.ts rename to src/datatypes/essay/index.ts diff --git a/src/datatypes/markdown/schema.ts b/src/datatypes/essay/schema.ts similarity index 100% rename from src/datatypes/markdown/schema.ts rename to src/datatypes/essay/schema.ts diff --git a/src/datatypes/markdown/utils.ts b/src/datatypes/essay/utils.ts similarity index 67% rename from src/datatypes/markdown/utils.ts rename to src/datatypes/essay/utils.ts index 5a88836d..89fb8dfe 100644 --- a/src/datatypes/markdown/utils.ts +++ b/src/datatypes/essay/utils.ts @@ -1,5 +1,5 @@ import * as A from "@automerge/automerge/next"; -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement } from "react"; import ReactDOMServer from "react-dom/server"; import { MarkdownDoc } from "./schema"; @@ -8,24 +8,6 @@ export const isMarkdownDoc = (doc: A.Doc): doc is MarkdownDoc => { return typeof typedDoc.content === "string"; }; -export const useScrollPosition = (container: HTMLElement | null) => { - const [scrollPosition, setScrollPosition] = useState(0); - - useEffect(() => { - if (!container) { - return; - } - const updatePosition = () => { - setScrollPosition(container.scrollTop); - }; - container.addEventListener("scroll", () => updatePosition()); - updatePosition(); - return () => container.removeEventListener("scroll", updatePosition); - }, [container]); - - return scrollPosition; -}; - // Utils for converting back and forth between CodeMirror and Automerge ranges. // The end of a Codemirror range can be an index past the last character in the // document, but we can't get an Automerge cursor for that position. diff --git a/src/datatypes/tldraw/datatype.ts b/src/datatypes/tldraw/datatype.ts index 08b99f74..1e94ec2a 100644 --- a/src/datatypes/tldraw/datatype.ts +++ b/src/datatypes/tldraw/datatype.ts @@ -11,6 +11,7 @@ import { initVersionControlMetadata, } from "@/os/versionControl/schema"; import { defaultShapeUtils, Editor } from "@tldraw/tldraw"; +import { unpatch } from "@onsetsoftware/automerge-patcher"; // When a copy of the document has been made, // update the title so it's more clear which one is the copy vs original. @@ -119,6 +120,7 @@ export const patchesToAnnotations = ( type: "deleted", deleted: docBefore.store[shapeId], anchor: shapeId, + inversePatches: [unpatch(docBefore, patch)], } as Annotation, ]; @@ -128,6 +130,7 @@ export const patchesToAnnotations = ( type: "added", added: doc.store[shapeId], anchor: shapeId, + inversePatches: [unpatch(docBefore, patch)], } as Annotation, ]; diff --git a/src/os/datatypes.ts b/src/os/datatypes.ts index 63622ffe..3eec397b 100644 --- a/src/os/datatypes.ts +++ b/src/os/datatypes.ts @@ -16,9 +16,10 @@ import bot from "@/datatypes/bot"; import datagrid from "@/datatypes/datagrid"; import folder from "@/datatypes/folder"; import kanban from "@/datatypes/kanban"; -import markdown from "@/datatypes/markdown"; +import markdown from "@/datatypes/essay"; import tldraw from "@/datatypes/tldraw"; import { FileExportMethod } from "./fileExports"; +import { HasAssets } from "@/tools/essay/assets"; export type CoreDataType = { id: string; @@ -108,6 +109,10 @@ export type VersionedDataType = { * If this method is not implemented the anchors will not be sorted. */ sortAnchorsBy?: (doc: D, anchor: T) => any; + + // flag wether or not this data type has support for rendering comments inline or if it + // relies exclusively on the review sidebar to show comments + supportsInlineComments?: boolean; }; export type DataType = CoreDataType & VersionedDataType; diff --git a/src/os/explorer/components/Topbar.tsx b/src/os/explorer/components/Topbar.tsx index 4a1a3d5c..8fce5443 100644 --- a/src/os/explorer/components/Topbar.tsx +++ b/src/os/explorer/components/Topbar.tsx @@ -27,7 +27,7 @@ import { } from "@/components/ui/dropdown-menu"; import { getHeads, save } from "@automerge/automerge"; -import { MarkdownDoc } from "@/datatypes/markdown/schema"; +import { MarkdownDoc } from "@/datatypes/essay/schema"; import { DatatypeId, DATA_TYPES } from "../../datatypes"; import { runBot } from "@/datatypes/bot/essayEditingBot"; import { Button } from "@/components/ui/button"; @@ -151,11 +151,10 @@ export const Topbar: React.FC = ({