WIP: real-time collab support
parent
f04f429c29
commit
590440748b
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
|
@ -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: "==",
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue