WIP: real-time collab support

pull/109/head
Zef Hemel 2022-10-25 18:50:07 +02:00
parent f04f429c29
commit 590440748b
11 changed files with 138 additions and 10 deletions

View File

@ -71,7 +71,7 @@ export {
Text, Text,
Transaction, Transaction,
} from "@codemirror/state"; } from "@codemirror/state";
export type { ChangeSpec, StateCommand } from "@codemirror/state"; export type { ChangeSpec, Extension, StateCommand } from "@codemirror/state";
export { export {
defaultHighlightStyle, defaultHighlightStyle,
defineLanguageFacet, defineLanguageFacet,

View File

@ -11,6 +11,7 @@
"@codemirror/autocomplete": "https://esm.sh/@codemirror/autocomplete@6.3.0?external=@codemirror/state,@lezer/common", "@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", "@codemirror/lint": "https://esm.sh/@codemirror/lint@6.0.0?external=@codemirror/state,@lezer/common",
"preact": "https://esm.sh/preact@10.11.1", "preact": "https://esm.sh/preact@10.11.1",
"yjs": "https://esm.sh/yjs@13.5.42",
"$sb/": "./plug-api/", "$sb/": "./plug-api/",
"handlebars": "https://esm.sh/handlebars", "handlebars": "https://esm.sh/handlebars",
"@lezer/lr": "https://esm.sh/@lezer/lr", "@lezer/lr": "https://esm.sh/@lezer/lr",

View File

@ -21,7 +21,7 @@ export async function readSettings<T extends object>(settings: T): Promise<T> {
const allSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {}; const allSettings = (await readYamlPage(SETTINGS_PAGE, ["yaml"])) || {};
// TODO: I'm sure there's a better way to type this than "any" // TODO: I'm sure there's a better way to type this than "any"
const collectedSettings: any = {}; const collectedSettings: any = {};
for (let [key, defaultVal] of Object.entries(settings)) { for (const [key, defaultVal] of Object.entries(settings)) {
if (key in allSettings) { if (key in allSettings) {
collectedSettings[key] = allSettings[key]; collectedSettings[key] = allSettings[key];
} else { } else {

View File

@ -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");
}

View File

@ -5,3 +5,4 @@ export * as markdown from "./markdown.ts";
export * as sandbox from "./sandbox.ts"; export * as sandbox from "./sandbox.ts";
export * as space from "./space.ts"; export * as space from "./space.ts";
export * as system from "./system.ts"; export * as system from "./system.ts";
export * as collab from "./collab.ts";

View File

@ -68,7 +68,6 @@ export async function readFileSearch(
searchPrefix.length, searchPrefix.length,
name.length - ".md".length, name.length - ".md".length,
); );
console.log("Here");
const results = await fulltext.fullTextSearch(phrase, { const results = await fulltext.fullTextSearch(phrase, {
highlightEllipsis: "...", highlightEllipsis: "...",
highlightPostfix: "==", highlightPostfix: "==",

View File

@ -40,9 +40,10 @@ safeRun(async () => {
"", "",
settings.indexPage || "index", settings.indexPage || "index",
); );
await editor.init();
// @ts-ignore: for convenience // @ts-ignore: for convenience
window.editor = editor; window.editor = editor;
await editor.init();
}); });
// if (localStorage.getItem("disable_sw") !== "true") { // if (localStorage.getItem("disable_sw") !== "true") {

57
web/collab.ts Normal file
View File

@ -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,
});
}
}

View File

@ -16,3 +16,12 @@ export {
export { FontAwesomeIcon } from "https://esm.sh/@aduh95/preact-fontawesome@0.1.5?external=@fortawesome/fontawesome-common-types"; 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 { 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"; 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";

View File

@ -1,4 +1,11 @@
import { preactRender, useEffect, useReducer } from "./deps.ts"; import {
preactRender,
useEffect,
useReducer,
Y,
yCollab,
yUndoManagerKeymap,
} from "./deps.ts";
import { import {
autocompletion, autocompletion,
@ -72,6 +79,8 @@ import { storeSyscalls } from "./syscalls/store.ts";
import { systemSyscalls } from "./syscalls/system.ts"; import { systemSyscalls } from "./syscalls/system.ts";
import { Action, AppViewState, initialViewState } from "./types.ts"; import { Action, AppViewState, initialViewState } from "./types.ts";
import assetSyscalls from "../plugos/syscalls/asset.ts"; import assetSyscalls from "../plugos/syscalls/asset.ts";
import { CollabState } from "./collab.ts";
import { collabSyscalls } from "./syscalls/collab.ts";
class PageState { class PageState {
constructor( constructor(
@ -102,6 +111,7 @@ export class Editor {
private mdExtensions: MDExt[] = []; private mdExtensions: MDExt[] = [];
urlPrefix: string; urlPrefix: string;
indexPage: string; indexPage: string;
collabState?: CollabState;
constructor( constructor(
space: Space, space: Space,
@ -136,6 +146,7 @@ export class Editor {
this.system.addHook(this.slashCommandHook); this.system.addHook(this.slashCommandHook);
this.render(parent); this.render(parent);
this.editorView = new EditorView({ this.editorView = new EditorView({
state: this.createEditorState("", ""), state: this.createEditorState("", ""),
parent: document.getElementById("sb-editor")!, parent: document.getElementById("sb-editor")!,
@ -155,15 +166,12 @@ export class Editor {
storeSyscalls(this.space), storeSyscalls(this.space),
sandboxSyscalls(this.system), sandboxSyscalls(this.system),
assetSyscalls(this.system), assetSyscalls(this.system),
collabSyscalls(this),
); );
// Make keyboard shortcuts work even when the editor is in read only mode or not focused // Make keyboard shortcuts work even when the editor is in read only mode or not focused
globalThis.addEventListener("keydown", (ev) => { globalThis.addEventListener("keydown", (ev) => {
if (!this.editorView?.hasFocus) { if (!this.editorView?.hasFocus) {
// console.log(
// "Window-level keyboard event",
// ev
// );
if ((ev.target as any).classList.contains("cm-textfield")) { if ((ev.target as any).classList.contains("cm-textfield")) {
// Search & replace feature, ignore this // Search & replace feature, ignore this
return; return;
@ -393,7 +401,7 @@ export class Editor {
// deno-lint-ignore no-this-alias // deno-lint-ignore no-this-alias
const editor = this; const editor = this;
return EditorState.create({ return EditorState.create({
doc: text, doc: this.collabState ? this.collabState.ytext.toString() : text,
extensions: [ extensions: [
markdown({ markdown({
base: buildMarkdown(this.mdExtensions), base: buildMarkdown(this.mdExtensions),
@ -454,6 +462,7 @@ export class Editor {
...searchKeymap, ...searchKeymap,
...historyKeymap, ...historyKeymap,
...completionKeymap, ...completionKeymap,
...(this.collabState ? yUndoManagerKeymap : []),
indentWithTab, indentWithTab,
...commandKeyBindings, ...commandKeyBindings,
{ {
@ -523,6 +532,7 @@ export class Editor {
pasteLinkExtension, pasteLinkExtension,
attachmentExtension(this), attachmentExtension(this),
closeBrackets(), closeBrackets(),
...[this.collabState ? this.collabState.collabExtension() : []],
], ],
}); });
} }
@ -618,6 +628,11 @@ export class Editor {
this.space.unwatchPage(previousPage); this.space.unwatchPage(previousPage);
if (previousPage !== pageName) { if (previousPage !== pageName) {
await this.save(true); 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; 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();
}
} }

20
web/syscalls/collab.ts Normal file
View File

@ -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();
},
};
}