diff --git a/package.json b/package.json index e062f6fa..c9305a48 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,12 @@ "@codemirror/language": "^6.9.1", "@codemirror/language-data": "^6.3.1", "@codemirror/view": "^6.21.3", + "@effect/schema": "^0.52.0", "@lezer/highlight": "^1.1.6", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", @@ -39,6 +41,7 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "codemirror": "^6.0.1", + "effect": "^2.0.0-next.59", "lodash": "^4.17.21", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.284.0", diff --git a/src/components/App.tsx b/src/components/App.tsx index 7a4ce083..e0ef512e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,23 +1,26 @@ import { AutomergeUrl } from "@automerge/automerge-repo"; -import { useDocument, useHandle } from "@automerge/automerge-repo-react-hooks"; +import { useHandle } from "@automerge/automerge-repo-react-hooks"; import { MarkdownEditor, TextSelection } from "./MarkdownEditor"; import { LocalSession, MarkdownDoc } from "../schema"; import { Navbar } from "./Navbar"; import { LoadingScreen } from "./LoadingScreen"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { EditorView } from "@codemirror/view"; import { CommentsSidebar } from "./CommentsSidebar"; import { useThreadsWithPositions } from "../utils"; +import { useTypedDocument } from "@/useTypedDocument"; +import { Heads, getHeads } from "@automerge/automerge/next"; function App({ docUrl }: { docUrl: AutomergeUrl }) { - const [doc, changeDoc] = useDocument(docUrl); // used to trigger re-rendering when the doc loads + const [doc, changeDoc] = useTypedDocument(docUrl, MarkdownDoc); // used to trigger re-rendering when the doc loads const handle = useHandle(docUrl); const [session, setSessionInMemory] = useState(); const [selection, setSelection] = useState(); const [activeThreadId, setActiveThreadId] = useState(); const [view, setView] = useState(); + const [showDiff, setShowDiff] = useState(false); const localStorageKey = `LocalSession-${docUrl}`; @@ -41,6 +44,15 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { activeThreadId, }); + const diffHeads: Heads | null = useMemo(() => { + if (!doc) return []; + if (doc?.forkMetadata?.parent && showDiff) { + return [...doc.forkMetadata.parent.forkedAtHeads]; + } else { + return null; + } + }, [doc, showDiff]); + if (!doc || !session) { return ; } @@ -54,13 +66,16 @@ function App({ docUrl }: { docUrl: AutomergeUrl }) { changeDoc={changeDoc} session={session} setSession={setSession} + showDiff={showDiff} + setShowDiff={setShowDiff} /> -
+
; + diffHeads: Heads | null; path: Prop[]; setSelection: (selection: TextSelection) => void; setView: (view: EditorView) => void; @@ -101,6 +101,92 @@ const threadDecorations = EditorView.decorations.compute( } ) ?? []; + if (decorations.length === 0) { + return Decoration.none; + } + + return Decoration.set(decorations); + } +); + +// Stuff for patches decoration + +const setPatchesEffect = StateEffect.define(); +const patchesField = StateField.define({ + create() { + return []; + }, + update(patches, tr) { + for (const e of tr.effects) { + if (e.is(setPatchesEffect)) { + return e.value; + } + } + return patches; + }, +}); + +class DeletionMarker extends WidgetType { + constructor() { + super(); + } + + toDOM(): HTMLElement { + const box = document.createElement("div"); + box.style.display = "inline-block"; + box.style.color = "#ff5353"; + box.style.fontSize = "0.8em"; + box.style.marginTop = "0.3em"; + box.style.verticalAlign = "top"; + box.innerText = "⌫"; + return box; + } + + eq() { + // todo: i think this is right for now until we show hover of del text etc + return true; + } + + ignoreEvent() { + return true; + } +} + +const spliceDecoration = Decoration.mark({ class: "cm-patch-splice" }); +const deleteDecoration = Decoration.widget({ + widget: new DeletionMarker(), + side: 1, +}); + +const patchDecorations = EditorView.decorations.compute( + [patchesField], + (state) => { + const patches = state + .field(patchesField) + .filter((patch) => patch.path[0] === "content"); + + const decorations = patches.flatMap((patch) => { + switch (patch.action) { + case "splice": { + const from = patch.path[1]; + const length = patch.value.length; + if (length === 0) { + return []; + } + return [spliceDecoration.range(from, from + length)]; + } + case "del": { + const from = patch.path[1]; + return [deleteDecoration.range(from)]; + } + } + return []; + }); + + if (decorations.length === 0) { + return Decoration.none; + } + return Decoration.set(decorations); } ); @@ -136,6 +222,9 @@ const theme = EditorView.theme({ ".cm-comment-thread": { backgroundColor: "rgb(255 249 194)", }, + ".cm-patch-splice": { + backgroundColor: "rgb(0 255 0 / 20%)", + }, ".cm-comment-thread.active": { backgroundColor: "rgb(255 227 135)", }, @@ -256,6 +345,7 @@ export function MarkdownEditor({ setView, setActiveThreadId, threadsWithPositions, + diffHeads, }: EditorProps) { const containerRef = useRef(null); const editorRoot = useRef(null); @@ -268,6 +358,21 @@ export function MarkdownEditor({ }); }, [threadsWithPositions]); + const doc = handle.docSync(); + const view = editorRoot.current; + + // // Propagate patches into the codemirror + useEffect(() => { + const doc = handle.docSync(); + if (!diffHeads) { + return; + } + const patches = diff(doc, diffHeads, getHeads(doc)); + editorRoot.current?.dispatch({ + effects: setPatchesEffect.of(patches), + }); + }, [handle, doc, diffHeads, view]); + useEffect(() => { const doc = handle.docSync(); const source = doc.content; // this should use path @@ -308,6 +413,8 @@ export function MarkdownEditor({ frontmatterPlugin, threadsField, threadDecorations, + patchesField, + patchDecorations, previewFiguresPlugin, highlightKeywordsPlugin, tableOfContentsPreviewPlugin, diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 4d0059cd..e91bc497 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,14 +1,8 @@ import { Button } from "@/components/ui/button"; import { LocalSession, MarkdownDoc, User } from "../schema"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { ChangeFn, save } from "@automerge/automerge/next"; -import { - Check, - ChevronsUpDown, - Download, - Plus, - User as UserIcon, -} from "lucide-react"; +import { ChangeFn, getHeads, save } from "@automerge/automerge/next"; +import { Check, ChevronsUpDown, User as UserIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Command, @@ -33,12 +27,27 @@ import { DialogTrigger, DialogFooter, } from "@/components/ui/dialog"; +import { + Menubar, + MenubarCheckboxItem, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +} from "@/components/ui/menubar"; + import { Label } from "@/components/ui/label"; -import { useCallback, useEffect, useState } from "react"; +import { SetStateAction, useCallback, useEffect, useState } from "react"; import { getTitle, saveFile } from "../utils"; import { uuid } from "@automerge/automerge"; -import { DocHandle } from "@automerge/automerge-repo"; +import { DocHandle, isValidAutomergeUrl } from "@automerge/automerge-repo"; import { SyncIndicator } from "./SyncIndicator"; +import { useRepo } from "@automerge/automerge-repo-react-hooks"; const initials = (name: string) => { return name @@ -66,13 +75,18 @@ export const Navbar = ({ changeDoc, session, setSession, + showDiff, + setShowDiff, }: { handle: DocHandle; doc: MarkdownDoc; changeDoc: (changeFn: ChangeFn) => void; session: LocalSession; setSession: (session: LocalSession) => void; + showDiff: boolean; + setShowDiff: React.Dispatch>; }) => { + const repo = useRepo(); const [namePickerOpen, setNamePickerOpen] = useState(false); const [tentativeUser, setTentativeUser] = useState({ _type: "unknown", @@ -134,156 +148,348 @@ export const Navbar = ({ document.title = title; }, [title]); + // fork and open clone in new tab + const forkDoc = useCallback(() => { + const clone = repo.clone(handle); + clone.change((doc) => { + // @ts-expect-error need to figure out how to make the type mutable in change blocks + doc.forkMetadata.parent = { + url: handle.url, + forkedAtHeads: getHeads(doc), + }; + }); + + // @ts-expect-error window global + window.openDocumentInNewTab(clone.url); + }, [repo, handle]); + + const goToParent = () => { + if (!doc?.forkMetadata?.parent) return; + + // @ts-expect-error using window global + window.openDocumentInNewTab(doc.forkMetadata.parent.url); + }; + + const shareToParent = () => { + const parentUrl = doc?.forkMetadata?.parent?.url; + if (!parentUrl || !isValidAutomergeUrl(parentUrl)) { + return; + } + const parent = repo.find(parentUrl); + parent.change((doc) => { + if (!doc.forkMetadata.knownForks.includes(handle.url)) { + // @ts-expect-error need to figure out how to make the type mutable in change blocks + doc.forkMetadata.knownForks.push(handle.url); + } + }); + }; + + const mergeToParent = () => { + const parentUrl = doc?.forkMetadata?.parent?.url; + if (!parentUrl || !isValidAutomergeUrl(parentUrl)) { + return; + } + const parent = repo.find(parentUrl); + parent.merge(handle); + }; + + const knownForks = doc?.forkMetadata?.knownForks ?? []; + if (!doc) { return <>; } return ( -
- - -
- {title} -
- -
- - - - - - - {/* */} - - {sessionUser ? initials(sessionUser.name) : } - - - - - - Edit profile - - Log in as existing user, or sign up with your name - - -
-
- - - - - - - - - No user found. - - {users.map((user) => ( - { - if (user.id !== sessionUser?.id) { - setTentativeUser({ - _type: "existing", - id: user.id, - }); - } - setNamePickerOpen(false); - }} - > - - {user.name} - - ))} - - - - -
-
or
-
- - +
+ + + + {" "} + + + alert("not implemented yet")}> + About this OS + + + alert("not implemented yet")}> + System Settings + + + alert("not implemented yet")}> + Log out + + + + + + Tiny Essay Editor + + + alert("not implemented yet")}> + About Tiny Essay Editor + + + alert("not implemented yet")}> + Settings + + + + + File + + {/* TODO: this approach to making a new doc doesn't work for TEE/TR */} + window.open("/", "_blank")}> + New + + { + const automergeURLToOpen = prompt("Automerge URL:"); + if ( + automergeURLToOpen === null || + automergeURLToOpen === "" + ) { + return; } - onFocus={() => { - setTentativeUser({ _type: "new", name: "" }); - setNamePickerOpen(false); - }} - onChange={(e) => { - setTentativeUser({ - _type: "new", - name: e.target.value, - }); - }} - > -
-
- - - + + + + + No user found. + + {users.map((user) => ( + { + if (user.id !== sessionUser?.id) { + setTentativeUser({ + _type: "existing", + id: user.id, + }); + } + setNamePickerOpen(false); + }} + > + + {user.name} + + ))} + + + + +
+
or
+
+ + - Save changes - - - - -
+ onFocus={() => { + setTentativeUser({ _type: "new", name: "" }); + setNamePickerOpen(false); + }} + onChange={(e) => { + setTentativeUser({ + _type: "new", + name: e.target.value, + }); + }} + > +
+
+ + + + + + + +
+ {/*
+ + +
+ {title} +
+
*/}
); }; diff --git a/src/components/SyncIndicator.tsx b/src/components/SyncIndicator.tsx index 2689a78c..8a07d793 100644 --- a/src/components/SyncIndicator.tsx +++ b/src/components/SyncIndicator.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/popover"; import { getRelativeTimeString } from "@/utils"; import { useRef } from "react"; +import { MenubarContent, MenubarMenu, MenubarTrigger } from "./ui/menubar"; // if we are connected to the sync server and have outstanding changes we should switch to the error mode if we haven't received a sync message in a while // this variable specifies this timeout duration @@ -35,135 +36,64 @@ export const SyncIndicator = ({ handle }: { handle: DocHandle }) => { timestamp: onlineState.timestamp, isActive: onlineState.isOnline, }); - const [isHovered, setIsHovered] = useState(false); - const handleMouseEnter = () => { - setIsHovered(true); - }; - - const handleMouseLeave = () => { - setIsHovered(false); - }; + let icon =
; + let details =
; if (onlineState.isOnline) { if ( (isConnectedToServer || !hasInitialConnectionWaitTimedOut) && (isSynced || !hasSyncTimedOut) ) { - return ( - - -
- -
-
- -
-
-
Connection:
-
Connected to server
-
-
-
Last synced:
-
- {getRelativeTimeString(lastSyncUpdate)} -
-
-
-
Sync status:
-
- {isSynced ? "Up to date" : "Syncing..."} -
-
-
-
-
+ icon = ( +
+ +
); - } else { - return ( - - -
- - {!isSynced &&
*
} -
Sync Error
+ details = ( +
+
+
+
Connection:
+
Connected to server
- - -
- There was an unexpected error connecting to the sync server. Don't - worry, your changes are saved locally. Please try reloading and - see if that fixes the issue. +
+
Last synced:
+
+ {getRelativeTimeString(lastSyncUpdate)} +
+
+
+
Sync status:
+
+ {isSynced ? "Up to date" : "Syncing..."} +
-
-
-
Connection:
-
Connection error
-
-
-
Last synced:
-
- {getRelativeTimeString(lastSyncUpdate)} -
-
-
-
Sync status:
-
- {isSynced ? ( - "No unsynced changes" - ) : ( - Unsynced changes (*) - )} -
-
-
- - +
+
); - } - } else { - return ( - - -
- - {!isSynced && ( -
*
- )} + } else { + icon = ( +
+ + {!isSynced &&
*
} +
Sync Error
+
+ ); + details = ( +
+
+ There was an unexpected error connecting to the sync server. Don't + worry, your changes are saved locally. Please try reloading and see + if that fixes the issue.
- -
Connection:
-
Offline
+
Connection error
Last synced:
@@ -177,18 +107,63 @@ export const SyncIndicator = ({ handle }: { handle: DocHandle }) => { {isSynced ? ( "No unsynced changes" ) : ( - - You have unsynced changes. They are saved locally and will - sync next time you have internet and you open the app. - + Unsynced changes (*) )}
-
- +
+ ); + } + } else { + icon = ( +
+ + {!isSynced && ( +
*
+ )} +
+ ); + details = ( +
+
+
+
Connection:
+
Offline
+
+
+
Last synced:
+
{getRelativeTimeString(lastSyncUpdate)}
+
+
+
Sync status:
+
+ {isSynced ? ( + "No unsynced changes" + ) : ( + + You have unsynced changes. They are saved locally and will + sync next time you have internet and you open the app. + + )} +
+
+
+
); } + + return ( + + {icon} + +
{details}
+
+
+ ); }; const SYNC_SERVER_PREFIX = "storage-server-"; diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx new file mode 100644 index 00000000..d11c2993 --- /dev/null +++ b/src/components/ui/menubar.tsx @@ -0,0 +1,234 @@ +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const MenubarMenu = MenubarPrimitive.Menu + +const MenubarGroup = MenubarPrimitive.Group + +const MenubarPortal = MenubarPrimitive.Portal + +const MenubarSub = MenubarPrimitive.Sub + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup + +const Menubar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Menubar.displayName = MenubarPrimitive.Root.displayName + +const MenubarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName + +const MenubarSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName + +const MenubarContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref + ) => ( + + + + ) +) +MenubarContent.displayName = MenubarPrimitive.Content.displayName + +const MenubarItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +MenubarItem.displayName = MenubarPrimitive.Item.displayName + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName + +const MenubarRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName + +const MenubarLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +MenubarLabel.displayName = MenubarPrimitive.Label.displayName + +const MenubarSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName + +const MenubarShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +MenubarShortcut.displayname = "MenubarShortcut" + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +} diff --git a/src/index.ts b/src/index.ts index 337e8911..13274663 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,8 @@ window.logoImageUrl = new URL( "./assets/logo-favicon-310x310-transparent.png", import.meta.url ).href; + +// @ts-expect-error - set a window global +window.openDocumentInNewTab = (docUrl) => { + alert("Not implemented yet"); +}; diff --git a/src/init.ts b/src/init.ts index 007df9b5..de11af61 100644 --- a/src/init.ts +++ b/src/init.ts @@ -11,7 +11,7 @@ const LAB_USERS = [ "Alex Good", "Orion Henry", "Mary Rose Cook", -].sort() +].sort(); export function init(doc: any) { doc.content = "# Untitled\n\n"; @@ -22,4 +22,8 @@ export function init(doc: any) { const user = { id: idStr, name }; doc.users.push(user); } + doc.forkMetadata = { + parent: null, + knownForks: [], + }; } diff --git a/src/main.tsx b/src/main.tsx index a60482e6..2801571b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -40,4 +40,19 @@ window.handle = handle; // we'll use this later for experimentation // @ts-expect-error - adding property to window window.logoImageUrl = "/assets/logo-favicon-310x310-transparent.png"; +// GL 12/6/23: Sometimes we need to wait a bit before navigating to a new doc, +// e.g. when making a fork the repo seems to not be ready if we go immediately. +// investigate this and remove the timeout. +// @ts-expect-error - set a window global +window.openDocumentInNewTab = (docUrl) => { + setTimeout( + () => + window.open( + `${document.location.origin}${document.location.pathname}#${docUrl}`, + "_blank" + ), + 500 + ); +}; + mount(document.getElementById("root"), { docUrl }); diff --git a/src/schema.ts b/src/schema.ts index 1d0266a5..f7704f63 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,17 +1,24 @@ -export type Comment = { - id: string; - content: string; - userId: string | null; - timestamp: number; -}; +import { Doc, Heads, Patch } from "@automerge/automerge"; +import { Schema as S } from "@effect/schema"; -export type CommentThread = { - id: string; - comments: Comment[]; - resolved: boolean; - fromCursor: string; // Automerge cursor - toCursor: string; // Automerge cursor -}; +const Comment = S.struct({ + id: S.string, + content: S.string, + userId: S.nullable(S.string), + timestamp: S.number, +}); + +export type Comment = S.Schema.To; + +const CommentThread = S.struct({ + id: S.string, + comments: S.array(Comment), + resolved: S.boolean, + fromCursor: S.string, // Automerge cursor + toCursor: S.string, // Automerge cursor +}); + +export type CommentThread = S.Schema.To; export type CommentThreadForUI = CommentThread & { from: number; @@ -21,17 +28,59 @@ export type CommentThreadForUI = CommentThread & { export type CommentThreadWithPosition = CommentThreadForUI & { yCoord: number }; -export type User = { - id: string; - name: string; -}; +const User = S.struct({ + id: S.string, + name: S.string, +}); -export type MarkdownDoc = { - content: string; - commentThreads: { [key: string]: CommentThread }; - users: User[]; -}; +export type User = S.Schema.To; + +export const parseUser = S.parseSync(User); export type LocalSession = { userId: string | null; }; + +export const MarkdownDoc = S.struct({ + content: S.string, + + // GL 12/6/23: For some reason the schema parser doesn't like it + // when I use this correctly narrower type, + // for now I'm working around by using a too-wide type. + // commentThreads: S.readonlyMap(S.string, CommentThread), + commentThreads: S.object, + + users: S.array(User), + + // GL 12/6/23: We should move this out to a generic forkable trait... + forkMetadata: S.struct({ + parent: S.nullable( + S.struct({ + url: S.string, + forkedAtHeads: S.array(S.string), + }) + ), + knownForks: S.array(S.string), + }), +}); + +export type MarkdownDoc = S.Schema.To; + +export type DeepMutable = { + -readonly [K in keyof T]: T[K] extends (infer R)[] + ? DeepMutable[] + : T[K] extends ReadonlyArray + ? DeepMutable[] + : T[K] extends object + ? DeepMutable + : T[K]; +}; + +export type MutableMarkdownDoc = DeepMutable; + +export type Snapshot = { + heads: Heads; + doc: Doc; + previous: Snapshot | null; + diffFromPrevious: Patch[]; +}; diff --git a/src/useTypedDocument.ts b/src/useTypedDocument.ts new file mode 100644 index 00000000..5ebb3b15 --- /dev/null +++ b/src/useTypedDocument.ts @@ -0,0 +1,67 @@ +import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge/next"; +import { + AutomergeUrl, + DocHandleChangePayload, +} from "@automerge/automerge-repo"; +import { useEffect, useState } from "react"; +import { useRepo } from "@automerge/automerge-repo-react-hooks"; +import { Schema as S } from "@effect/schema"; +import { isLeft, isRight } from "effect/Either"; + +// An experimental version of the automerge-repo useDocument hook +// which has stronger schema validation powered by @effect/schema + +export function useTypedDocument>( + documentUrl: AutomergeUrl | null, + schema: T +): [ + Doc> | undefined, + (changeFn: ChangeFn>) => void +] { + type ResultType = S.Schema.To; + + const [doc, setDoc] = useState>(); + const repo = useRepo(); + + const handle = documentUrl ? repo.find(documentUrl) : null; + + const parseSchema = S.parseEither(schema); + + useEffect(() => { + if (!handle) return; + + handle.doc().then((v) => { + const parseResult = parseSchema(v); + // GL 12/6/23: + // TODO: Need to think a lot more about what to do with errors here. + // Should we crash the app and prevent it from loading? + // Could use effect schema transforms to do a basic cambria thing. + if (isLeft(parseResult)) { + console.error( + "WARNING: document loaded from repo does not match schema" + ); + console.error(v); + console.error(String(parseResult.left)); + } + setDoc(v); + }); + + const onChange = (h: DocHandleChangePayload) => setDoc(h.doc); + handle.on("change", onChange); + const cleanup = () => { + handle.removeListener("change", onChange); + }; + + return cleanup; + }, [handle, parseSchema]); + + const changeDoc = ( + changeFn: ChangeFn, + options?: ChangeOptions | undefined + ) => { + if (!handle) return; + handle.change(changeFn, options); + }; + + return [doc, changeDoc]; +} diff --git a/src/utils.ts b/src/utils.ts index 553142c0..a8c92d81 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,9 +4,15 @@ import { MarkdownDoc, } from "./schema"; import { EditorView } from "@codemirror/view"; -import { next as A } from "@automerge/automerge"; +import { + diff, + getHeads, + getCursorPosition, + Patch, +} from "@automerge/automerge/next"; import { ReactElement, useEffect, useMemo, useState } from "react"; import ReactDOMServer from "react-dom/server"; +import { isValidAutomergeUrl } from "@automerge/automerge-repo"; // taken from https://www.builder.io/blog/relative-time /** @@ -82,8 +88,8 @@ export const getThreadsForUI = ( let from = 0; let to = 0; try { - from = A.getCursorPosition(doc, ["content"], thread.fromCursor); - to = A.getCursorPosition(doc, ["content"], thread.toCursor); + from = getCursorPosition(doc, ["content"], thread.fromCursor); + to = getCursorPosition(doc, ["content"], thread.toCursor); } catch (e) { if (e instanceof RangeError) { // If the cursor isn't found in the content string, hide the comment. diff --git a/yarn.lock b/yarn.lock index 5f28805f..0922dde5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -627,6 +627,11 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@effect/schema@^0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@effect/schema/-/schema-0.52.0.tgz#5dc612e895c9f691f379a15679b6dffc0c1d087c" + integrity sha512-x6SmSdoL6PeZVAaK895NoRkKF8D/w+XyO8i17cUsQYFJBHNyUTi9Y1H2wrO8TkxhTmC93ejburpM+35/OKCi2Q== + "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" @@ -1030,6 +1035,17 @@ "@radix-ui/react-use-callback-ref" "1.0.1" "@radix-ui/react-use-layout-effect" "1.0.1" +"@radix-ui/react-collection@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159" + integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-compose-refs@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae" @@ -1100,6 +1116,13 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.5" +"@radix-ui/react-direction@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b" + integrity sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-dismissable-layer@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.0.tgz#35b7826fa262fd84370faef310e627161dffa76b" @@ -1182,6 +1205,48 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-menu@2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e" + integrity sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-callback-ref" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + +"@radix-ui/react-menubar@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-menubar/-/react-menubar-1.0.4.tgz#7d46ababfec63db3868d9ed79366686634c1201a" + integrity sha512-bHgUo9gayKZfaQcWSSLr++LyS0rgh+MvD89DE4fJ6TkGHvjHgPaBZf44hdka7ogOxIOdj9163J+5xL2Dn4qzzg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-menu" "2.0.6" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-roving-focus" "1.0.4" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-popover@^1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c" @@ -1280,6 +1345,22 @@ "@radix-ui/react-context" "1.0.1" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-roving-focus@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974" + integrity sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-direction" "1.0.1" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-slot@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.0.tgz#7fa805b99891dea1e862d8f8fbe07f4d6d0fd698" @@ -2103,6 +2184,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +effect@^2.0.0-next.59: + version "2.0.0-next.59" + resolved "https://registry.yarnpkg.com/effect/-/effect-2.0.0-next.59.tgz#312bb6c70a8adf29b857414e42af118faf26fcab" + integrity sha512-EE87vFl0/zIN5lKDtFccU3YCnbPqjxg9rY72obNN65/GE4JOJsXciyX8XC4pIDr3lE6KeJ0le8IXf+A7d92ntQ== + electron-to-chromium@^1.4.535: version "1.4.557" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.557.tgz#f3941b569c82b7bb909411855c6ff9bfe1507829"