diff --git a/package.json b/package.json index 1aacc08b..92d2b207 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "@codemirror/language": "^6.9.1", "@codemirror/language-data": "^6.3.1", "@codemirror/view": "^6.21.3", + "@effect/schema": "^0.57.0", "@lezer/highlight": "^1.1.6", + "@microlink/react-json-view": "^1.23.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", @@ -46,14 +48,18 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "codemirror": "^6.0.1", + "effect": "^2.0.0", "eventemitter3": "^5.0.1", + "fast-check": "^3.15.0", "lodash": "^4.17.21", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.284.0", + "mime-types": "^2.1.35", "openai": "^4.11.0", "react": "^18.2.0", "react-arborist": "^3.3.1", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.12", "react-usestateref": "^1.0.8", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/DocExplorer/components/DocExplorer.tsx b/src/DocExplorer/components/DocExplorer.tsx index 2fae4f3a..229cc601 100644 --- a/src/DocExplorer/components/DocExplorer.tsx +++ b/src/DocExplorer/components/DocExplorer.tsx @@ -1,13 +1,21 @@ -import { AutomergeUrl, isValidAutomergeUrl } from "@automerge/automerge-repo"; +import { + AutomergeUrl, + Repo, + isValidAutomergeUrl, +} from "@automerge/automerge-repo"; import React, { useCallback, useEffect, useState } from "react"; import { TinyEssayEditor } from "../../tee/components/TinyEssayEditor"; -import { useDocument, useRepo } from "@automerge/automerge-repo-react-hooks"; -import { init } from "../../tee/datatype"; +import { + useDocument, + useHandle, + useRepo, +} from "@automerge/automerge-repo-react-hooks"; +import { Essay, EssayDoc } from "@/tee/schemas/Essay"; import { Button } from "@/components/ui/button"; -import { MarkdownDoc } from "@/tee/schema"; -import { getTitle } from "@/tee/datatype"; + import { DocType, + FolderDoc, useCurrentAccount, useCurrentAccountDoc, useCurrentRootFolderDoc, @@ -15,7 +23,10 @@ import { import { Sidebar } from "./Sidebar"; import { Topbar } from "./Topbar"; -import { LoadingScreen } from "./LoadingScreen"; +import { LoadingScreen } from "../../automerge-repo-schema-utils/LoadingScreen"; +import { ChangeFn } from "@automerge/automerge"; +import { getTitle } from "@/tee/schemas/Essay"; +import { withDocument } from "@/automerge-repo-schema-utils/LoadDocument"; export const DocExplorer: React.FC = () => { const repo = useRepo(); @@ -25,8 +36,13 @@ export const DocExplorer: React.FC = () => { const [showSidebar, setShowSidebar] = useState(true); - const { selectedDoc, selectDoc, selectedDocUrl, openDocFromUrl } = - useSelectedDoc({ rootFolderDoc, changeRootFolderDoc }); + const { + selectedDoc, + selectedEssay, + selectedDocUrl, + selectDoc, + openDocFromUrl, + } = useSelectedDoc({ rootFolderDoc, changeRootFolderDoc, repo }); const selectedDocName = rootFolderDoc?.docs.find( (doc) => doc.url === selectedDocUrl @@ -38,8 +54,7 @@ export const DocExplorer: React.FC = () => { throw new Error("Only essays are supported right now"); } - const newDocHandle = repo.create(); - newDocHandle.change(init); + const newEssay = Essay.create(repo); if (!rootFolderDoc) { return; @@ -49,11 +64,11 @@ export const DocExplorer: React.FC = () => { doc.docs.unshift({ type: "essay", name: "Untitled document", - url: newDocHandle.url, + url: newEssay.handle.url, }) ); - selectDoc(newDocHandle.url); + selectDoc(newEssay.handle.url); }, [changeRootFolderDoc, repo, rootFolderDoc, selectDoc] ); @@ -64,14 +79,16 @@ export const DocExplorer: React.FC = () => { return; } - const title = getTitle(selectedDoc.content); - changeRootFolderDoc((doc) => { const existingDocLink = doc.docs.find( (link) => link.url === selectedDocUrl ); - if (existingDocLink && existingDocLink.name !== title) { - existingDocLink.name = title; + if ( + existingDocLink && + selectedEssay && + existingDocLink.name !== selectedEssay.title + ) { + existingDocLink.name = selectedEssay.title; } }); }, [ @@ -80,6 +97,7 @@ export const DocExplorer: React.FC = () => { changeAccountDoc, rootFolderDoc, changeRootFolderDoc, + selectedEssay, ]); // update tab title to be the selected doc @@ -180,11 +198,8 @@ export const DocExplorer: React.FC = () => { )} - {/* NOTE: we set the URL as the component key, to force re-mount on URL change. - If we want more continuity we could not do this. */} - {selectedDocUrl && selectedDoc && ( - - )} + {selectedDocUrl && + withDocument(TinyEssayEditor, selectedDocUrl, Essay)} @@ -195,9 +210,20 @@ export const DocExplorer: React.FC = () => { // Drive the currently selected doc using the URL hash // (We encapsulate the selection state in a hook so that the only // API for changing the selection is properly thru the URL) -const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { +const useSelectedDoc = ({ + rootFolderDoc, + changeRootFolderDoc, + repo, +}: { + rootFolderDoc: FolderDoc; + changeRootFolderDoc: ChangeFn<(doc: FolderDoc) => void>; + repo: Repo; +}) => { const [selectedDocUrl, setSelectedDocUrl] = useState(null); - const [selectedDoc] = useDocument(selectedDocUrl); + const selectedDocHandle = useHandle(selectedDocUrl); + const [selectedDoc] = useDocument(selectedDocUrl); + + const selectedEssay = selectedDoc ? new Essay(selectedDocHandle, repo) : null; const selectDoc = (docUrl: AutomergeUrl | null) => { if (docUrl) { @@ -226,7 +252,7 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { setSelectedDocUrl(docUrl); }, - [rootFolderDoc, changeRootFolderDoc, selectDoc] + [rootFolderDoc, changeRootFolderDoc] ); // observe the URL hash to change the selected document @@ -240,6 +266,9 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { return; } openDocFromUrl(docUrl); + + // @ts-expect-error - adding property to window + window.handle = repo.find(docUrl); } }; @@ -257,6 +286,8 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { return { selectedDocUrl, selectedDoc, + selectedEssay, + selectedDocHandle, selectDoc, openDocFromUrl, }; diff --git a/src/DocExplorer/components/Topbar.tsx b/src/DocExplorer/components/Topbar.tsx index 6eb25785..6bd15630 100644 --- a/src/DocExplorer/components/Topbar.tsx +++ b/src/DocExplorer/components/Topbar.tsx @@ -14,11 +14,8 @@ import { useHandle, useRepo, } from "@automerge/automerge-repo-react-hooks"; -import { asMarkdownFile, markCopy } from "../../tee/datatype"; import { SyncIndicatorWrapper } from "./SyncIndicator"; import { AccountPicker } from "./AccountPicker"; -import { MarkdownDoc } from "@/tee/schema"; -import { getTitle } from "@/tee/datatype"; import { saveFile } from "../utils"; import { DocLink, useCurrentRootFolderDoc } from "../account"; @@ -31,6 +28,11 @@ import { } from "@/components/ui/dropdown-menu"; import { save } from "@automerge/automerge"; +import { parseSync } from "@effect/schema/Parser"; +import { EssayV1ToHasTitleV1 } from "@/tee/schemas/transforms"; + +import { extension } from "mime-types"; +import { Essay, EssayDoc } from "@/tee/schemas/Essay"; type TopbarProps = { showSidebar: boolean; @@ -52,21 +54,11 @@ export const Topbar: React.FC = ({ const selectedDocName = rootFolderDoc?.docs.find( (doc) => doc.url === selectedDocUrl )?.name; - const selectedDocHandle = useHandle(selectedDocUrl); + const selectedDocHandle = useHandle(selectedDocUrl); + const [selectedDoc] = useDocument(selectedDocUrl); - // GL 12/13: here we assume this is a TEE Markdown doc, but in future should be more generic. - const [selectedDoc] = useDocument(selectedDocUrl); - - const exportAsMarkdown = useCallback(() => { - const file = asMarkdownFile(selectedDoc); - saveFile(file, "index.md", [ - { - accept: { - "text/markdown": [".md"], - }, - }, - ]); - }, [selectedDoc]); + // todo: do this creation in the hook itself, one time only + const essay = new Essay(selectedDocHandle, repo); const downloadAsAutomerge = useCallback(() => { const file = new Blob([save(selectedDoc)], { @@ -124,13 +116,11 @@ export const Topbar: React.FC = ({ { - const newHandle = repo.clone(selectedDocHandle); - newHandle.change((doc) => { - markCopy(doc); - }); + const newEssay = essay.clone(); const newDocLink: DocLink = { - url: newHandle.url, - name: getTitle(newHandle.docSync().content), + url: newEssay.handle.url, + // TODO: generalize this to other doc types besides essays + name: newEssay.title, type: "essay", }; @@ -151,10 +141,41 @@ export const Topbar: React.FC = ({ Make a copy - exportAsMarkdown()}> - {" "} - Export as Markdown - + {Object.entries(essay.fileExports).map(([fileType, getFile]) => ( + { + const file = getFile(); + const title = essay.title; + const safeTitle = title + .replace(/[^a-z0-9]/gi, "_") + .toLowerCase(); + const fileExtension = extension(file.type); + + if (!fileExtension) { + throw new Error( + `No file extension found for file type ${file.type}` + ); + } + + // TODO: generalize this logic more from markdown to others + saveFile(file, `${safeTitle}.${fileExtension}`, [ + { + accept: { + "text/markdown": [`.${fileExtension}`], + }, + }, + ]); + }} + > + {" "} + Export as {fileType} + + ))} + downloadAsAutomerge()}> {" "} Download Automerge file diff --git a/src/automerge-repo-schema-utils/LoadDocument.tsx b/src/automerge-repo-schema-utils/LoadDocument.tsx new file mode 100644 index 00000000..1806b994 --- /dev/null +++ b/src/automerge-repo-schema-utils/LoadDocument.tsx @@ -0,0 +1,83 @@ +// A wrapper component that loads data, used like this: + +import { AutomergeUrl } from "@automerge/automerge-repo"; +import { Schema as S } from "@effect/schema"; +import { useTypedDocument } from "./useTypedDocument"; +import { LoadingScreen } from "./LoadingScreen"; +import { LoadDocumentChildProps } from "./utils"; +import { formatErrors } from "@effect/schema/TreeFormatter"; +import { RawView } from "./RawView"; +import { Essay } from "@/tee/schemas/Essay"; + +// +// {({doc, changeDoc, handle}) => +//
...
} +//
+ +export const LoadDocument: React.FC<{ + docUrl: AutomergeUrl; + schema: S.Schema; + children: (props: { + doc: any; + changeDoc: any; + handle: any; + }) => React.ReactNode; +}> = ({ docUrl, schema, children }) => { + const result = useTypedDocument(docUrl, schema); // used to trigger re-rendering when the doc loads + + if (result._tag === "loading") { + return ; + } + + if (result._tag === "error") { + return ( +
+
+
+ Error: The loaded document does not conform to the expected schema. +
+
+            {formatErrors(result.error.errors)}
+          
+
+
+ You can try to repair the error manually: +
+
+ +
+
+ ); + } + + return ( +
+ {children({ + doc: result.doc, + changeDoc: result.changeDoc, + handle: result.handle, + })} +
+ ); +}; + +// A higher-order component that makes it more concise to use the wrapper. Use like this: +//
+// {withDocument(MyChildComponent, docUrl, schema)} +//
+ +export const withDocument = ( + Component: React.FC>, + docUrl: AutomergeUrl, + model: T +) => { + return ( + // It's important to set the key to the doc URL here because that forces + // the component to remount when the URL changes, which is what we want. + + {({ doc, changeDoc, handle }) => ( + + )} + + ); +}; diff --git a/src/DocExplorer/components/LoadingScreen.tsx b/src/automerge-repo-schema-utils/LoadingScreen.tsx similarity index 89% rename from src/DocExplorer/components/LoadingScreen.tsx rename to src/automerge-repo-schema-utils/LoadingScreen.tsx index b9eff8f8..5533e0ca 100644 --- a/src/DocExplorer/components/LoadingScreen.tsx +++ b/src/automerge-repo-schema-utils/LoadingScreen.tsx @@ -4,10 +4,14 @@ import { Progress } from "@/components/ui/progress"; // A very rough and naive loading screen. // It just assumes things load in ~1 second and shows a progress bar. + // Typically Automerge blocks the UI thread while it's loading though, // so we don't get smooth progress or anything, the bar just // shows empty and then the doc loads. +// TODO: use a GPU transform to get smooth loading while process blocked +// TODO: get more actual progress metadata out of automerge to drive the bar + export const LoadingScreen = ({ docUrl, handle, diff --git a/src/automerge-repo-schema-utils/RawView.tsx b/src/automerge-repo-schema-utils/RawView.tsx new file mode 100644 index 00000000..befb6e67 --- /dev/null +++ b/src/automerge-repo-schema-utils/RawView.tsx @@ -0,0 +1,66 @@ +import React, { useCallback } from "react"; +import ReactJson, { InteractionProps } from "@microlink/react-json-view"; +import { useDocument } from "@automerge/automerge-repo-react-hooks"; +import "react-error-boundary"; + +import { AutomergeUrl } from "@automerge/automerge-repo"; + +export function RawView({ documentUrl }: { documentUrl: AutomergeUrl }) { + const [doc, changeDoc] = useDocument(documentUrl); + + const onEdit = useCallback( + ({ namespace, new_value, name }: InteractionProps) => { + changeDoc(function (doc) { + let current = doc; + for ( + let _i = 0, namespace_1 = namespace; + _i < namespace_1.length; + _i++ + ) { + const key = namespace_1[_i]; + current = current[key]; + } + current[name] = new_value; + }); + }, + [changeDoc] + ); + + const onAdd = useCallback(function () { + return true; + }, []); + + const onDelete = useCallback( + function ({ namespace, name }) { + changeDoc(function (doc) { + let current = doc; + for ( + let _i = 0, namespace_2 = namespace; + _i < namespace_2.length; + _i++ + ) { + const key = namespace_2[_i]; + current = current[key]; + } + delete current[name]; + }); + }, + [changeDoc] + ); + + if (!doc) { + return
Loading {documentUrl}...
; + } + + return ( +
+ +
+ ); +} diff --git a/src/automerge-repo-schema-utils/useTypedDocument.ts b/src/automerge-repo-schema-utils/useTypedDocument.ts new file mode 100644 index 00000000..8d7138fd --- /dev/null +++ b/src/automerge-repo-schema-utils/useTypedDocument.ts @@ -0,0 +1,80 @@ +import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge/next"; +import { + AutomergeUrl, + DocHandle, + DocHandleChangePayload, +} from "@automerge/automerge-repo"; +import { useEffect, useState, useCallback } from "react"; +import { useRepo } from "@automerge/automerge-repo-react-hooks"; +import { Schema as S } from "@effect/schema"; +import { isLeft, Either } from "effect/Either"; +import { Either as E } from "effect"; +import { SchemaToType } from "./utils"; +import { ParseError } from "@effect/schema/ParseResult"; + +// An experimental version of the automerge-repo useDocument hook +// which has stronger schema validation powered by @effect/schema + +type HookResult> = + | { _tag: "loading"; handle: DocHandle> } + | { + _tag: "error"; + error: ParseError; + } + | { + _tag: "ok"; + doc: Doc>; + changeDoc: (changeFn: ChangeFn>) => void; + handle: DocHandle>; + }; + +export function useTypedDocument>( + documentUrl: AutomergeUrl | null, + schema: T +): HookResult { + const repo = useRepo(); + const handle = documentUrl ? repo.find>(documentUrl) : null; + const [result, setResult] = useState>({ + _tag: "loading", + handle: handle, + }); + + const processNewDoc = useCallback( + (doc: Doc>) => { + const parseResult = S.parseEither(schema)(doc); + if (isLeft(parseResult)) { + setResult({ _tag: "error", error: parseResult.left }); + } else { + setResult(() => ({ + _tag: "ok", + doc: doc, + changeDoc: (changeFn) => { + handle.change(changeFn); + }, + handle, + })); + } + }, + [schema, handle] + ); + + useEffect(() => { + if (!handle) return; + + handle.doc().then((v) => { + processNewDoc(v); + }); + + const onChange = (h: DocHandleChangePayload>) => { + processNewDoc(h.doc); + }; + handle.on("change", onChange); + const cleanup = () => { + handle.removeListener("change", onChange); + }; + + return cleanup; + }, [handle, processNewDoc]); + + return result; +} diff --git a/src/automerge-repo-schema-utils/utils.ts b/src/automerge-repo-schema-utils/utils.ts new file mode 100644 index 00000000..1190dbe4 --- /dev/null +++ b/src/automerge-repo-schema-utils/utils.ts @@ -0,0 +1,97 @@ +// A utility type to recursively make an object mutable. +// This helps us take the immutable objects returned by +// the schema library and make them mutable for editing +// within an automerge document. + +import { Doc } from "@automerge/automerge"; +import { DocHandle, Repo } from "@automerge/automerge-repo"; +import { Schema as S } from "@effect/schema"; +import { isLeft } from "effect/Either"; + +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 SchemaToType> = DeepMutable>; + +export type AutomergeClassToDocType> = + SchemaToType; + +// somehow we need to get the original schema +// export const openAs(doc, newSchema) => ... + +// we want to say: +// const { title } = convert(Essay, HasTitle)(doc) + +export type LoadDocumentChildProps = { + doc: Doc; + changeDoc: (changeFn: (doc: T) => void) => void; + handle: DocHandle; +}; + +export type ActionSpec = { + name: string; + description: string; + run: (doc: T, params: any) => void; + // TODO: redo this + parameters: any; + // parameters: { + // type: "object"; + // properties: { + // // TODO: this should support arbitrary JSON schema as input parameters. + // // The only reason I haven't done that yet is that my UI form logic is dumb and simple. + // // We can switch to a generic JSON schema form builder maybe, + // // and also use the typescript types from that here. + // // or, better -- switch to effect schema here! + // [key: string]: { + // type: "string" | "number" | "boolean"; + // description: string; + // }; + // }; + // }; +}; + +export abstract class AutomergeModel> { + constructor(public handle: DocHandle>, public repo: Repo) {} + + getTitle: (doc: SchemaToType) => string = () => "Untitled"; + actions: { [key: string]: ActionSpec> } = {}; + fileExports: { [key: string]: (doc: SchemaToType) => Blob } = {}; + markAsCopy: (doc: SchemaToType) => void = () => {}; +} + +// todo: turn this into a Javascript class? +// Essay could inherit from it? +export type AutomergeClass> = { + schema: S; + + /** Populate a blank doc with initial default data fitting a schema */ + init: (doc: SchemaToType) => void; + + /** Get the title of the document */ + getTitle: (doc: SchemaToType) => string; + + /** List out actions which can be performed on this document. */ + actions: { [key: string]: ActionSpec> }; + + /** Provide ways to convert the document to various file formats. */ + fileExports: { [key: string]: (doc: SchemaToType) => Blob }; + + /** Mark the document as a copy of another document. */ + markAsCopy: (doc: SchemaToType) => void; +}; + +export function createDocument>( + repo: Repo, + schema: T +): DocHandle> { + const handle = repo.create>(); + handle.change(schema.init); + return handle; +} diff --git a/src/index.ts b/src/index.ts index 4c57c0ea..56407005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,10 @@ import css from "./index.css"; document.adoptedStyleSheets.push(css); export { mount } from "./tee/mount.js"; -export { init } from "./tee/datatype.js"; + +// TODO: this needs to be reworked to account for the new schema system, +// where init is a function available on a schema object. +// export { init } from "./tee/datatype.js"; // @ts-expect-error - set a window global for the logo image using browser standards window.logoImageUrl = new URL( diff --git a/src/tee/codemirrorPlugins/commentThreads.ts b/src/tee/codemirrorPlugins/commentThreads.ts index 2665d3f1..18052dbb 100644 --- a/src/tee/codemirrorPlugins/commentThreads.ts +++ b/src/tee/codemirrorPlugins/commentThreads.ts @@ -1,8 +1,9 @@ import { EditorView, Decoration } from "@codemirror/view"; import { StateEffect, StateField } from "@codemirror/state"; -import { CommentThreadForUI } from "../schema"; + import { amRangeToCMRange } from "../utils"; import { sortBy } from "lodash"; +import { CommentThreadForUI } from "../types"; export const setThreadsEffect = StateEffect.define(); export const threadsField = StateField.define({ diff --git a/src/tee/components/CommentsSidebar.tsx b/src/tee/components/CommentsSidebar.tsx index 8ab8a757..3d99e2eb 100644 --- a/src/tee/components/CommentsSidebar.tsx +++ b/src/tee/components/CommentsSidebar.tsx @@ -1,10 +1,4 @@ import { Button } from "@/components/ui/button"; -import { - Comment, - CommentThread, - CommentThreadWithPosition, - MarkdownDoc, -} from "../schema"; import { Check, MessageSquarePlus, Reply } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; @@ -21,6 +15,9 @@ import { useEffect, useState } from "react"; import { getRelativeTimeString, cmRangeToAMRange } from "../utils"; import { useCurrentAccount } from "@/DocExplorer/account"; import { ContactAvatar } from "@/DocExplorer/components/ContactAvatar"; +import { CommentThread, Comment, EssayDoc } from "../schemas/Essay"; +import { CommentThreadWithPosition } from "../types"; +import { AutomergeUrl } from "@automerge/automerge-repo"; export const CommentsSidebar = ({ doc, @@ -30,8 +27,8 @@ export const CommentsSidebar = ({ activeThreadId, setActiveThreadId, }: { - doc: MarkdownDoc; - changeDoc: (changeFn: ChangeFn) => void; + doc: EssayDoc; + changeDoc: (changeFn: ChangeFn) => void; selection: TextSelection; threadsWithPositions: CommentThreadWithPosition[]; activeThreadId: string | null; @@ -132,7 +129,8 @@ export const CommentsSidebar = ({
{comment.contactUrl ? ( diff --git a/src/tee/components/MarkdownEditor.tsx b/src/tee/components/MarkdownEditor.tsx index 4cedfbcf..b2d54c37 100644 --- a/src/tee/components/MarkdownEditor.tsx +++ b/src/tee/components/MarkdownEditor.tsx @@ -16,7 +16,7 @@ import { } from "@automerge/automerge-codemirror"; import { indentWithTab } from "@codemirror/commands"; import { type DocHandle } from "@automerge/automerge-repo"; -import { CommentThreadForUI, MarkdownDoc } from "../schema"; + import { syntaxHighlighting, indentOnInput, @@ -39,6 +39,8 @@ import { threadsField, } from "../codemirrorPlugins/commentThreads"; import { lineWrappingPlugin } from "../codemirrorPlugins/lineWrapping"; +import { CommentThreadForUI } from "../types"; +import { EssayDoc } from "../schemas/Essay"; export type TextSelection = { from: number; @@ -47,7 +49,7 @@ export type TextSelection = { }; export type EditorProps = { - handle: DocHandle; + handle: DocHandle; path: Prop[]; setSelection: (selection: TextSelection) => void; setView: (view: EditorView) => void; diff --git a/src/tee/components/TinyEssayEditor.tsx b/src/tee/components/TinyEssayEditor.tsx index 84ad954c..b90c9297 100644 --- a/src/tee/components/TinyEssayEditor.tsx +++ b/src/tee/components/TinyEssayEditor.tsx @@ -1,9 +1,4 @@ -import { AutomergeUrl } from "@automerge/automerge-repo"; -import { useDocument, useHandle } from "@automerge/automerge-repo-react-hooks"; import { MarkdownEditor, TextSelection } from "./MarkdownEditor"; - -import { MarkdownDoc } from "../schema"; -import { LoadingScreen } from "../../DocExplorer/components/LoadingScreen"; import { useRef, useState } from "react"; import { EditorView } from "@codemirror/view"; @@ -13,10 +8,14 @@ import { useThreadsWithPositions } from "../utils"; // TODO: audit the CSS being imported here; // it should be all 1) specific to TEE, 2) not dependent on viewport / media queries import "../../tee/index.css"; +import { LoadDocumentChildProps } from "@/automerge-repo-schema-utils/utils"; +import { EssayDoc } from "../schemas/Essay"; -export const TinyEssayEditor = ({ docUrl }: { docUrl: AutomergeUrl }) => { - const [doc, changeDoc] = useDocument(docUrl); // used to trigger re-rendering when the doc loads - const handle = useHandle(docUrl); +export const TinyEssayEditor = ({ + doc, + changeDoc, + handle, +}: LoadDocumentChildProps) => { const [selection, setSelection] = useState(); const [activeThreadId, setActiveThreadId] = useState(); const [view, setView] = useState(); @@ -29,11 +28,6 @@ export const TinyEssayEditor = ({ docUrl }: { docUrl: AutomergeUrl }) => { editorRef, }); - // todo: remove from this component and move up to DocExplorer? - if (!doc) { - return ; - } - return (
diff --git a/src/tee/datatype.ts b/src/tee/datatype.ts deleted file mode 100644 index 6e769e82..00000000 --- a/src/tee/datatype.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Currently this is a loose collection of operations related to the -// MarkdownDoc datatype. -// It will become more structured in future work on schemas / datatypes. - -import { MarkdownDoc } from "./schema"; -import { splice } from "@automerge/automerge/next"; - -export const init = (doc: any) => { - doc.content = "# Untitled\n\n"; - doc.commentThreads = {}; -}; - -// When a copy of the document has been made, -// update the title so it's more clear which one is the copy vs original. -// (this mechanism needs to be thought out more...) -export const markCopy = (doc: MarkdownDoc) => { - const firstHeadingIndex = doc.content.search(/^#\s.*$/m); - if (firstHeadingIndex !== -1) { - splice(doc, ["content"], firstHeadingIndex + 2, 0, "Copy of "); - } -}; - -export const asMarkdownFile = (doc: MarkdownDoc): Blob => { - return new Blob([doc.content], { type: "text/markdown" }); -}; // Helper to get the title of one of our markdown docs. -// looks first for yaml frontmatter from the i&s essay format; -// then looks for the first H1. - -export const getTitle = (content: string) => { - const frontmatterRegex = /---\n([\s\S]+?)\n---/; - const frontmatterMatch = content.match(frontmatterRegex); - const frontmatter = frontmatterMatch ? frontmatterMatch[1] : ""; - - const titleRegex = /title:\s"(.+?)"/; - const subtitleRegex = /subtitle:\s"(.+?)"/; - - const titleMatch = frontmatter.match(titleRegex); - const subtitleMatch = frontmatter.match(subtitleRegex); - - let title = titleMatch ? titleMatch[1] : null; - const subtitle = subtitleMatch ? subtitleMatch[1] : ""; - - // If title not found in frontmatter, find first markdown heading - if (!title) { - const titleFallbackRegex = /(^|\n)#\s(.+)/; - const titleFallbackMatch = content.match(titleFallbackRegex); - title = titleFallbackMatch ? titleFallbackMatch[2] : "Untitled"; - } - - return `${title} ${subtitle && `: ${subtitle}`}`; -}; diff --git a/src/tee/main.tsx b/src/tee/main.tsx index a6771fa2..56cc98c5 100644 --- a/src/tee/main.tsx +++ b/src/tee/main.tsx @@ -7,7 +7,7 @@ import { next as Automerge } from "@automerge/automerge"; import { mount } from "./mount.js"; import "./index.css"; -import { MarkdownDoc } from "./schema.js"; +import { EssayV1 } from "./schemas/Essay.js"; const SYNC_SERVER_URL = import.meta.env?.VITE_SYNC_SERVER_URL ?? "wss://sync.automerge.org"; @@ -25,9 +25,9 @@ let handle; if (isValidAutomergeUrl(rootDocUrl)) { handle = repo.find(rootDocUrl); } else { - handle = repo.create(); - const { init } = await import("./datatype.js"); - handle.change(init); + handle = repo.create(); + const { Essay } = await import("./schemas/Essay.js"); + handle.change(Essay.init); } // eslint-disable-next-line diff --git a/src/tee/schema.ts b/src/tee/schema.ts deleted file mode 100644 index 96678fc5..00000000 --- a/src/tee/schema.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AutomergeUrl } from "@automerge/automerge-repo"; - -export type Comment = { - id: string; - content: string; - contactUrl?: AutomergeUrl; - timestamp: number; - - // A legacy field for backwards compatibility. - // Was used to point to user objects in the doc itself. - // Now superceded by contactUrl. - userId?: string | null; -}; - -export type CommentThread = { - id: string; - comments: Comment[]; - resolved: boolean; - fromCursor: string; // Automerge cursor - toCursor: string; // Automerge cursor -}; - -export type CommentThreadForUI = CommentThread & { - from: number; - to: number; - active: boolean; -}; - -export type CommentThreadWithPosition = CommentThreadForUI & { yCoord: number }; - -export type User = { - id: string; - name: string; -}; - -export type MarkdownDoc = { - content: string; - commentThreads: { [key: string]: CommentThread }; - users: User[]; -}; diff --git a/src/tee/schemas/Essay.ts b/src/tee/schemas/Essay.ts new file mode 100644 index 00000000..e7b80868 --- /dev/null +++ b/src/tee/schemas/Essay.ts @@ -0,0 +1,199 @@ +import { + AutomergeClass, + AutomergeModel, +} from "@/automerge-repo-schema-utils/utils"; +import { SchemaToType } from "@/automerge-repo-schema-utils/utils"; +import { uuid } from "@automerge/automerge"; +import { DocHandle, Repo } from "@automerge/automerge-repo"; +import { getCursor, splice } from "@automerge/automerge/next"; +import { Schema as S } from "@effect/schema"; +import { isLeft } from "effect/Either"; + +const CommentV1 = S.struct({ + id: S.string, + content: S.string, + contactUrl: S.optional(S.string), + timestamp: S.number, + userId: S.optional(S.string), +}); + +type CommentV1 = SchemaToType; +export type Comment = CommentV1; + +const CommentThreadV1 = S.struct({ + id: S.string, + comments: S.array(CommentV1), + resolved: S.boolean, + fromCursor: S.string, + toCursor: S.string, +}); + +type CommentThreadV1 = SchemaToType; +export type CommentThread = CommentThreadV1; + +const UserV1 = S.struct({ + id: S.string, + name: S.string, +}); + +type UserV1 = SchemaToType; +export type User = UserV1; + +export const EssayV1 = S.struct({ + content: S.string, + commentThreads: S.record(S.string, CommentThreadV1), + users: S.array(UserV1), +}); + +export type EssayV1 = SchemaToType; +export type EssayDoc = EssayV1; +export type EssaySchema = EssayV1; + +export class Essay extends AutomergeModel { + static schema = EssayV1; + + static create = (repo: Repo): Essay => { + const handle = repo.create>(); + handle.change(Essay.init); + return this.fromHandle(handle); + }; + + // Expect that loading happens earlier, and by this point we already have a handle loaded with the correct schema. + static fromHandle = (handle: DocHandle): Essay => { + const doc = handle.docSync(); + const parseResult = S.parseEither(this.schema)(doc); + if (isLeft(parseResult)) { + throw new Error(`Failed to parse document: ${parseResult.left}`); + } + // @ts-expect-error why does this not work? i think the DeepMutable thing. + return new Essay(handle); + }; + + static init = (doc: EssayV1) => { + doc.content = "# Untitled\n\n"; + doc.commentThreads = {}; + doc.users = []; + }; + + clone = () => { + const newHandle = this.repo.clone(this.handle); + const newEssay = Essay.fromHandle(newHandle); + newEssay.markAsCopy(); + return newEssay; + }; + + get doc() { + return this.handle.docSync(); + } + + get title() { + return getTitle(this.doc.content); + } + + actions = { + resolveAllComments: { + name: "resolve all comments", + description: "resolve all comments on the doc", + parameters: { + type: "object", + properties: {}, + }, + run: (doc) => { + for (const threadId in doc.commentThreads) { + const thread = doc.commentThreads[threadId]; + thread.resolved = true; + } + }, + }, + startThread: { + name: "start thread", + description: "Start a new comment thread", + parameters: { + type: "object", + properties: { + text: { + type: "string", + description: "Quoted text which is the target of the comment", + }, + comment: { + type: "string", + description: "The content of the comment", + }, + }, + }, + run: (doc, params) => { + const textStartIndex = doc.content.indexOf(params.text); + if (textStartIndex < 0) { + throw new Error(`text not found: ${params.text}`); + } + const textEndIndex = textStartIndex + params.text.length - 1; + + const fromCursor = getCursor(doc, ["content"], textStartIndex); + const toCursor = getCursor(doc, ["content"], textEndIndex); + + const comment: Comment = { + id: uuid(), + content: params.comment, + userId: null, + timestamp: Date.now(), + }; + + const thread: CommentThread = { + id: uuid(), + comments: [comment], + resolved: false, + fromCursor, + toCursor, + }; + + doc.commentThreads[thread.id] = thread; + }, + }, + }; + + // todo: factor out more exporting stuff into an abstract class + fileExports = { + Markdown: (): Blob => { + return new Blob([this.doc.content], { type: "text/markdown" }); + }, + Plaintext: (): Blob => { + return new Blob([this.doc.content], { type: "text/plain" }); + }, + }; + + // Dubious whether this deserves its own API..? + // It could be an action that is idiomatically provided..? + /* Mark a document as a copy of another document */ + markAsCopy = () => { + this.handle.change((doc) => { + const firstHeadingIndex = doc.content.search(/^#\s.*$/m); + if (firstHeadingIndex !== -1) { + splice(this.doc, ["content"], firstHeadingIndex + 2, 0, "Copy of "); + } + }); + }; +} + +export const getTitle = (content: string) => { + const frontmatterRegex = /---\n([\s\S]+?)\n---/; + const frontmatterMatch = content.match(frontmatterRegex); + const frontmatter = frontmatterMatch ? frontmatterMatch[1] : ""; + + const titleRegex = /title:\s"(.+?)"/; + const subtitleRegex = /subtitle:\s"(.+?)"/; + + const titleMatch = frontmatter.match(titleRegex); + const subtitleMatch = frontmatter.match(subtitleRegex); + + let title = titleMatch ? titleMatch[1] : null; + const subtitle = subtitleMatch ? subtitleMatch[1] : ""; + + // If title not found in frontmatter, find first markdown heading + if (!title) { + const titleFallbackRegex = /(^|\n)#\s(.+)/; + const titleFallbackMatch = content.match(titleFallbackRegex); + title = titleFallbackMatch ? titleFallbackMatch[2] : "Untitled"; + } + + return `${title}${subtitle && `: ${subtitle}`}`; +}; diff --git a/src/tee/schemas/HasTitle.ts b/src/tee/schemas/HasTitle.ts new file mode 100644 index 00000000..f22001e7 --- /dev/null +++ b/src/tee/schemas/HasTitle.ts @@ -0,0 +1,11 @@ +import { SchemaToType } from "@/automerge-repo-schema-utils/utils"; +import { Schema as S } from "@effect/schema"; + +export const HasTitleV1 = S.struct({ + title: S.string, +}); + +export type HasTitleV1 = SchemaToType; + +export const HasTitle = HasTitleV1; +export type HasTitle = HasTitleV1; diff --git a/src/tee/schemas/transforms.ts b/src/tee/schemas/transforms.ts new file mode 100644 index 00000000..7a2ee88c --- /dev/null +++ b/src/tee/schemas/transforms.ts @@ -0,0 +1,29 @@ +import { Schema as S } from "@effect/schema"; +import { EssayV1 } from "./Essay"; +import { HasTitleV1 } from "./HasTitle"; +import { getTitle } from "./Essay"; + +// TODO: fix this type error, it's coming from the DeepMutable thing? +// @ts-expect-error DeepMutable issue +export const EssayV1ToHasTitleV1: S.Schema = S.transform( + EssayV1, + HasTitleV1, + (essay) => { + return { title: getTitle(essay.content) }; + }, + + // this reverse conversion is bogus... + (title) => ({ content: title.title, commentThreads: {}, users: [] }) +); + +export const transforms = [EssayV1ToHasTitleV1]; + +// todo: some kind of general transform function that does auto lookup? +// for now we just statically specify the transforms. +// export const transform = (from: S.Schema, to: S.Schema, doc: SchemaToType) => { +// const transform = transforms.find((t) => (t.ast.from === from.ast && t.ast.to === to.ast); +// if (!transform) { +// throw new Error(`No transform found from ${from} to ${to}`); +// } +// return parseSync(transform)(doc) +// }; diff --git a/src/tee/types.ts b/src/tee/types.ts new file mode 100644 index 00000000..f647db5d --- /dev/null +++ b/src/tee/types.ts @@ -0,0 +1,8 @@ +import { CommentThread } from "./schemas/Essay"; + +export type CommentThreadForUI = CommentThread & { + from: number; + to: number; + active: boolean; +}; +export type CommentThreadWithPosition = CommentThreadForUI & { yCoord: number }; diff --git a/src/tee/utils.ts b/src/tee/utils.ts index 93cae515..60520908 100644 --- a/src/tee/utils.ts +++ b/src/tee/utils.ts @@ -1,12 +1,9 @@ -import { - CommentThreadForUI, - CommentThreadWithPosition, - MarkdownDoc, -} from "./schema"; import { EditorView } from "@codemirror/view"; import { next as A } from "@automerge/automerge"; import { ReactElement, useEffect, useMemo, useState } from "react"; import ReactDOMServer from "react-dom/server"; +import { CommentThreadForUI, CommentThreadWithPosition } from "./types"; +import { EssayDoc } from "./schemas/Essay"; // taken from https://www.builder.io/blog/relative-time /** @@ -73,7 +70,7 @@ const estimatedHeightOfThread = (thread: CommentThreadForUI) => { // Resolve comment thread cursors to integer positions in the document export const getThreadsForUI = ( - doc: MarkdownDoc, + doc: EssayDoc, activeThreadId: string | null ): CommentThreadForUI[] => { return Object.values(doc.commentThreads ?? {}) @@ -120,7 +117,7 @@ export const getVisibleTheadsWithPos = ({ activeThreadId, }: { threads: CommentThreadForUI[]; - doc: MarkdownDoc; + doc: EssayDoc; view: EditorView; activeThreadId: string | null; }): CommentThreadWithPosition[] => { @@ -255,7 +252,7 @@ export const useThreadsWithPositions = ({ activeThreadId, editorRef, }: { - doc: MarkdownDoc; + doc: EssayDoc; view: EditorView; activeThreadId: string; editorRef: React.MutableRefObject; diff --git a/yarn.lock b/yarn.lock index 6a6c61e0..d29c8096 100644 --- a/yarn.lock +++ b/yarn.lock @@ -280,6 +280,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5": + version "7.23.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.7.tgz#dd7c88deeb218a0f8bd34d5db1aa242e0f203193" + integrity sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.13.10": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" @@ -634,6 +641,11 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@effect/schema@^0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@effect/schema/-/schema-0.57.0.tgz#e6bca1460225beb95c574dbdee82647896563f94" + integrity sha512-o4dKS1qhtdIcj4wIS6uejRwXJYcouLO5klAe62vV4QfzQGGItgvD4f0HD6tdlh2E2jlo+iRSPibZadv2J3yEVQ== + "@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" @@ -1100,6 +1112,16 @@ "@lezer/highlight" "^1.0.0" "@lezer/lr" "^1.0.0" +"@microlink/react-json-view@^1.23.0": + version "1.23.0" + resolved "https://registry.yarnpkg.com/@microlink/react-json-view/-/react-json-view-1.23.0.tgz#641c2483b1a0014818303d4e9cce634d5dacc7e9" + integrity sha512-HYJ1nsfO4/qn8afnAMhuk7+5a1vcjEaS8Gm5Vpr1SqdHDY0yLBJGpA+9DvKyxyVKaUkXzKXt3Mif9RcmFSdtYg== + dependencies: + flux "~4.0.1" + react-base16-styling "~0.6.0" + react-lifecycles-compat "~3.0.4" + react-textarea-autosize "~8.3.2" + "@noble/hashes@^1.2.0": version "1.3.2" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" @@ -2208,6 +2230,11 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -2245,6 +2272,11 @@ base-x@^4.0.0: resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a" integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw== +base16@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" + integrity sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2487,6 +2519,13 @@ crelt@^1.0.5: resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== +cross-fetch@^3.1.5: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -2619,6 +2658,11 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" +effect@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/effect/-/effect-2.0.0.tgz#e4d0c06e024b81504f2aa7e29b07128bddab4694" + integrity sha512-yYYNf91dlk7dEl+U1QzeSiMHIM9zz1/NqSJ5hNRZ6GrFwctaCQcdxmAhll6mQRCgXFIAMhMEawmATlGLWtU5qQ== + 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" @@ -2809,6 +2853,13 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +fast-check@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-3.15.0.tgz#3ee501aa82c836efb96d7bc8c68aa7bbc1d79f8e" + integrity sha512-iBz6c+EXL6+nI931x/sbZs1JYTZtLG6Cko0ouS8LRTikhDR7+wZk4TYzdRavlnByBs2G6+nuuJ7NYL9QplNt8Q== + dependencies: + pure-rand "^6.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -2847,6 +2898,31 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +fbemitter@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fbemitter/-/fbemitter-3.0.0.tgz#00b2a1af5411254aab416cd75f9e6289bee4bff3" + integrity sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw== + dependencies: + fbjs "^3.0.0" + +fbjs-css-vars@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" + integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== + +fbjs@^3.0.0, fbjs@^3.0.1: + version "3.0.5" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.5.tgz#aa0edb7d5caa6340011790bd9249dbef8a81128d" + integrity sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg== + dependencies: + cross-fetch "^3.1.5" + fbjs-css-vars "^1.0.0" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^1.0.35" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -2883,6 +2959,14 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== +flux@~4.0.1: + version "4.0.4" + resolved "https://registry.yarnpkg.com/flux/-/flux-4.0.4.tgz#9661182ea81d161ee1a6a6af10d20485ef2ac572" + integrity sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw== + dependencies: + fbemitter "^3.0.0" + fbjs "^3.0.1" + form-data-encoder@1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" @@ -3269,6 +3353,16 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.curry@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + integrity sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA== + +lodash.flow@^3.3.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a" + integrity sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -3363,7 +3457,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12: +mime-types@^2.1.12, mime-types@^2.1.35: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -3426,7 +3520,7 @@ node-domexception@1.0.0: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-fetch@^2.6.7: +node-fetch@^2.6.12, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -3458,7 +3552,7 @@ nwsapi@^2.2.4: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== -object-assign@^4.0.1: +object-assign@^4.0.1, object-assign@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -3675,6 +3769,13 @@ pretty-format@^29.5.0: ansi-styles "^5.0.0" react-is "^18.0.0" +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" @@ -3690,6 +3791,16 @@ punycode@^2.1.1, punycode@^2.3.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +pure-color@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" + integrity sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA== + +pure-rand@^6.0.0: + version "6.0.4" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" + integrity sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA== + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -3716,6 +3827,16 @@ react-arborist@^3.3.1: redux "^5.0.0" use-sync-external-store "^1.2.0" +react-base16-styling@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c" + integrity sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ== + dependencies: + base16 "^1.0.0" + lodash.curry "^4.0.1" + lodash.flow "^3.3.0" + pure-color "^1.2.0" + react-dnd-html5-backend@^14.0.3: version "14.1.0" resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz#b35a3a0c16dd3a2bfb5eb7ec62cf0c2cace8b62f" @@ -3742,6 +3863,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-error-boundary@^4.0.12: + version "4.0.12" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.12.tgz#59f8f1dbc53bbbb34fc384c8db7cf4082cb63e2c" + integrity sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA== + dependencies: + "@babel/runtime" "^7.12.5" + react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -3752,6 +3880,11 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-lifecycles-compat@~3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" @@ -3796,6 +3929,15 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" +react-textarea-autosize@~8.3.2: + version "8.3.4" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524" + integrity sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ== + dependencies: + "@babel/runtime" "^7.10.2" + use-composed-ref "^1.3.0" + use-latest "^1.2.1" + react-usestateref@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/react-usestateref/-/react-usestateref-1.0.8.tgz#b40519af0d6f3b3822c70eb5db80f7d47f1b1ff5" @@ -3947,6 +4089,11 @@ semver@^7.5.4: dependencies: lru-cache "^6.0.0" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -4224,6 +4371,11 @@ typescript@^5.0.2: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +ua-parser-js@^1.0.35: + version "1.0.37" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" + integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== + ufo@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.3.2.tgz#c7d719d0628a1c80c006d2240e0d169f6e3c0496" @@ -4269,11 +4421,23 @@ use-callback-ref@^1.3.0: dependencies: tslib "^2.0.0" -use-isomorphic-layout-effect@^1.1.2: +use-composed-ref@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" + integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== + +use-isomorphic-layout-effect@^1.1.1, use-isomorphic-layout-effect@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== +use-latest@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2" + integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + use-resize-observer@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"