diff --git a/common/deps.ts b/common/deps.ts index 5a749435..c0c943ee 100644 --- a/common/deps.ts +++ b/common/deps.ts @@ -71,7 +71,7 @@ export { Text, Transaction, } from "@codemirror/state"; -export type { ChangeSpec, StateCommand } from "@codemirror/state"; +export type { ChangeSpec, Extension, StateCommand } from "@codemirror/state"; export { defaultHighlightStyle, defineLanguageFacet, diff --git a/import_map.json b/import_map.json index b1a19ef2..5d518e3d 100644 --- a/import_map.json +++ b/import_map.json @@ -11,6 +11,7 @@ "@codemirror/autocomplete": "https://esm.sh/@codemirror/autocomplete@6.3.0?external=@codemirror/state,@lezer/common", "@codemirror/lint": "https://esm.sh/@codemirror/lint@6.0.0?external=@codemirror/state,@lezer/common", "preact": "https://esm.sh/preact@10.11.1", + "yjs": "https://esm.sh/yjs@13.5.42", "$sb/": "./plug-api/", "handlebars": "https://esm.sh/handlebars", "@lezer/lr": "https://esm.sh/@lezer/lr", diff --git a/plug-api/lib/settings_page.ts b/plug-api/lib/settings_page.ts index 9bb5dbfc..0181af63 100644 --- a/plug-api/lib/settings_page.ts +++ b/plug-api/lib/settings_page.ts @@ -21,7 +21,7 @@ export async function readSettings(settings: T): Promise { const allSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {}; // TODO: I'm sure there's a better way to type this than "any" const collectedSettings: any = {}; - for (let [key, defaultVal] of Object.entries(settings)) { + for (const [key, defaultVal] of Object.entries(settings)) { if (key in allSettings) { collectedSettings[key] = allSettings[key]; } else { diff --git a/plug-api/silverbullet-syscall/collab.ts b/plug-api/silverbullet-syscall/collab.ts new file mode 100644 index 00000000..8cfe85de --- /dev/null +++ b/plug-api/silverbullet-syscall/collab.ts @@ -0,0 +1,9 @@ +import { syscall } from "./syscall.ts"; + +export function start(serverUrl: string, token: string, username: string) { + return syscall("collab.start", serverUrl, token, username); +} + +export function stop() { + return syscall("collab.stop"); +} diff --git a/plug-api/silverbullet-syscall/mod.ts b/plug-api/silverbullet-syscall/mod.ts index e54f5e9b..6dca8a4c 100644 --- a/plug-api/silverbullet-syscall/mod.ts +++ b/plug-api/silverbullet-syscall/mod.ts @@ -5,3 +5,4 @@ export * as markdown from "./markdown.ts"; export * as sandbox from "./sandbox.ts"; export * as space from "./space.ts"; export * as system from "./system.ts"; +export * as collab from "./collab.ts"; diff --git a/plugs/core/search.ts b/plugs/core/search.ts index b4cd7ffe..6c11d4a1 100644 --- a/plugs/core/search.ts +++ b/plugs/core/search.ts @@ -68,7 +68,6 @@ export async function readFileSearch( searchPrefix.length, name.length - ".md".length, ); - console.log("Here"); const results = await fulltext.fullTextSearch(phrase, { highlightEllipsis: "...", highlightPostfix: "==", diff --git a/web/boot.ts b/web/boot.ts index 3464458b..b1512321 100644 --- a/web/boot.ts +++ b/web/boot.ts @@ -40,9 +40,10 @@ safeRun(async () => { "", settings.indexPage || "index", ); - await editor.init(); // @ts-ignore: for convenience window.editor = editor; + + await editor.init(); }); // if (localStorage.getItem("disable_sw") !== "true") { diff --git a/web/collab.ts b/web/collab.ts new file mode 100644 index 00000000..3ae65932 --- /dev/null +++ b/web/collab.ts @@ -0,0 +1,57 @@ +import { Extension, WebsocketProvider, Y, yCollab } from "./deps.ts"; + +const userColors = [ + { color: "#30bced", light: "#30bced33" }, + { color: "#6eeb83", light: "#6eeb8333" }, + { color: "#ffbc42", light: "#ffbc4233" }, + { color: "#ecd444", light: "#ecd44433" }, + { color: "#ee6352", light: "#ee635233" }, + { color: "#9ac2c9", light: "#9ac2c933" }, + { color: "#8acb88", light: "#8acb8833" }, + { color: "#1be7ff", light: "#1be7ff33" }, +]; + +export class CollabState { + ydoc: Y.Doc; + collabProvider: WebsocketProvider; + ytext: Y.Text; + yundoManager: Y.UndoManager; + + constructor(serverUrl: string, token: string, username: string) { + this.ydoc = new Y.Doc(); + this.collabProvider = new WebsocketProvider( + serverUrl, + token, + this.ydoc, + ); + + this.collabProvider.on("status", (e: any) => { + console.log("Collab status change", e); + }); + this.collabProvider.on("sync", (e: any) => { + console.log("Sync status", e); + }); + + this.ytext = this.ydoc.getText("codemirror"); + this.yundoManager = new Y.UndoManager(this.ytext); + + const randomColor = + userColors[Math.floor(Math.random() * userColors.length)]; + + this.collabProvider.awareness.setLocalStateField("user", { + name: username, + color: randomColor.color, + colorLight: randomColor.light, + }); + } + + stop() { + this.collabProvider.destroy(); + } + + collabExtension(): Extension { + return yCollab(this.ytext, this.collabProvider.awareness, { + undoManager: this.yundoManager, + }); + } +} diff --git a/web/deps.ts b/web/deps.ts index 44bc9eaf..4d9b188b 100644 --- a/web/deps.ts +++ b/web/deps.ts @@ -16,3 +16,12 @@ export { export { FontAwesomeIcon } from "https://esm.sh/@aduh95/preact-fontawesome@0.1.5?external=@fortawesome/fontawesome-common-types"; export { faPersonRunning } from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0"; export type { IconDefinition } from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0"; + +// Y collab +export * as Y from "yjs"; +export { + yCollab, + yUndoManagerKeymap, +} from "https://esm.sh/y-codemirror.next@0.3.2?external=yjs,@codemirror/state,@codemirror/commands,@codemirror/history,@codemirror/view"; +export { WebrtcProvider } from "https://esm.sh/y-webrtc@10.2.3"; +export { WebsocketProvider } from "https://esm.sh/y-websocket@1.4.5"; diff --git a/web/editor.tsx b/web/editor.tsx index 092a0917..b325803b 100644 --- a/web/editor.tsx +++ b/web/editor.tsx @@ -1,4 +1,11 @@ -import { preactRender, useEffect, useReducer } from "./deps.ts"; +import { + preactRender, + useEffect, + useReducer, + Y, + yCollab, + yUndoManagerKeymap, +} from "./deps.ts"; import { autocompletion, @@ -72,6 +79,8 @@ import { storeSyscalls } from "./syscalls/store.ts"; import { systemSyscalls } from "./syscalls/system.ts"; import { Action, AppViewState, initialViewState } from "./types.ts"; import assetSyscalls from "../plugos/syscalls/asset.ts"; +import { CollabState } from "./collab.ts"; +import { collabSyscalls } from "./syscalls/collab.ts"; class PageState { constructor( @@ -102,6 +111,7 @@ export class Editor { private mdExtensions: MDExt[] = []; urlPrefix: string; indexPage: string; + collabState?: CollabState; constructor( space: Space, @@ -136,6 +146,7 @@ export class Editor { this.system.addHook(this.slashCommandHook); this.render(parent); + this.editorView = new EditorView({ state: this.createEditorState("", ""), parent: document.getElementById("sb-editor")!, @@ -155,15 +166,12 @@ export class Editor { storeSyscalls(this.space), sandboxSyscalls(this.system), assetSyscalls(this.system), + collabSyscalls(this), ); // Make keyboard shortcuts work even when the editor is in read only mode or not focused globalThis.addEventListener("keydown", (ev) => { if (!this.editorView?.hasFocus) { - // console.log( - // "Window-level keyboard event", - // ev - // ); if ((ev.target as any).classList.contains("cm-textfield")) { // Search & replace feature, ignore this return; @@ -393,7 +401,7 @@ export class Editor { // deno-lint-ignore no-this-alias const editor = this; return EditorState.create({ - doc: text, + doc: this.collabState ? this.collabState.ytext.toString() : text, extensions: [ markdown({ base: buildMarkdown(this.mdExtensions), @@ -454,6 +462,7 @@ export class Editor { ...searchKeymap, ...historyKeymap, ...completionKeymap, + ...(this.collabState ? yUndoManagerKeymap : []), indentWithTab, ...commandKeyBindings, { @@ -523,6 +532,7 @@ export class Editor { pasteLinkExtension, attachmentExtension(this), closeBrackets(), + ...[this.collabState ? this.collabState.collabExtension() : []], ], }); } @@ -618,6 +628,11 @@ export class Editor { this.space.unwatchPage(previousPage); if (previousPage !== pageName) { await this.save(true); + // And stop the collab session + if (this.collabState) { + this.collabState.stop(); + this.collabState = undefined; + } } } @@ -870,4 +885,20 @@ export class Editor { } return; } + + startCollab(serverUrl: string, token: string, username: string) { + if (this.collabState) { + // Clean up old collab state + this.collabState.stop(); + } + const initialText = this.editorView!.state.sliceDoc(); + this.collabState = new CollabState(serverUrl, token, username); + this.collabState.collabProvider.once("sync", (synced: boolean) => { + if (this.collabState?.ytext.toString() === "") { + console.log("Synced value is empty, putting back original text"); + this.collabState?.ytext.insert(0, initialText); + } + }); + this.rebuildEditorState(); + } } diff --git a/web/syscalls/collab.ts b/web/syscalls/collab.ts new file mode 100644 index 00000000..da395b00 --- /dev/null +++ b/web/syscalls/collab.ts @@ -0,0 +1,20 @@ +import { SysCallMapping } from "../../plugos/system.ts"; +import type { Editor } from "../editor.tsx"; + +export function collabSyscalls(editor: Editor): SysCallMapping { + return { + "collab.start": ( + _ctx, + serverUrl: string, + token: string, + username: string, + ) => { + editor.startCollab(serverUrl, token, username); + }, + "collab.stop": ( + _ctx, + ) => { + editor.collabState?.stop(); + }, + }; +}