From 4525d609647913eaa2806db5012e8fb1ed5dbc35 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Wed, 30 Mar 2022 15:16:22 +0200 Subject: [PATCH] Commit with collab on --- plugos/bin/plugos-server.ts | 12 +-- webapp/cm_collab.ts | 196 ++++++++++++++++++++++++++++++++++++ webapp/collab.ts | 11 +- webapp/editor.tsx | 3 + webapp/editor_paste.ts | 41 ++++++++ webapp/parser.ts | 8 +- webapp/space.ts | 8 +- 7 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 webapp/cm_collab.ts create mode 100644 webapp/editor_paste.ts diff --git a/plugos/bin/plugos-server.ts b/plugos/bin/plugos-server.ts index 28d1b65..93ef422 100755 --- a/plugos/bin/plugos-server.ts +++ b/plugos/bin/plugos-server.ts @@ -11,9 +11,9 @@ import {EndpointHook, EndpointHookT} from "../hooks/endpoint"; import {safeRun} from "../util"; import knex from "knex"; import { - ensureTable, - storeReadSyscalls, - storeWriteSyscalls, + ensureTable, + storeReadSyscalls, + storeWriteSyscalls, } from "../syscalls/store.knex_node"; import {fetchSyscalls} from "../syscalls/fetch.node"; import {EventHook, EventHookT} from "../hooks/event"; @@ -21,13 +21,13 @@ import {eventSyscalls} from "../syscalls/event"; let args = yargs(hideBin(process.argv)) .option("port", { - type: "number", - default: 1337, + type: "number", + default: 1337, }) .parse(); if (!args._.length) { - console.error("Usage: plugos-server "); + console.error("Usage: plugos-server "); process.exit(1); } diff --git a/webapp/cm_collab.ts b/webapp/cm_collab.ts new file mode 100644 index 0000000..2d54cce --- /dev/null +++ b/webapp/cm_collab.ts @@ -0,0 +1,196 @@ +import { + Annotation, + ChangeSet, + combineConfig, + EditorState, + Extension, + Facet, + StateEffect, + StateField, + Transaction, +} from "@codemirror/state"; + +/// An update is a set of changes and effects. +export interface Update { + /// The changes made by this update. + changes: ChangeSet; + /// The effects in this update. There'll only ever be effects here + /// when you configure your collab extension with a + /// [`sharedEffects`](#collab.collab^config.sharedEffects) option. + effects?: readonly StateEffect[]; + /// The [ID](#collab.CollabConfig.clientID) of the client who + /// created this update. + clientID: string; +} + +class LocalUpdate implements Update { + constructor( + readonly origin: Transaction, + readonly changes: ChangeSet, + readonly effects: readonly StateEffect[], + readonly clientID: string + ) {} +} + +class CollabState { + constructor( + // The version up to which changes have been confirmed. + readonly version: number, + // The local updates that havent been successfully sent to the + // server yet. + readonly unconfirmed: readonly LocalUpdate[] + ) {} +} + +type CollabConfig = { + /// The starting document version. Defaults to 0. + startVersion?: number; + /// This client's identifying [ID](#collab.getClientID). Will be a + /// randomly generated string if not provided. + clientID?: string; + /// It is possible to share information other than document changes + /// through this extension. If you provide this option, your + /// function will be called on each transaction, and the effects it + /// returns will be sent to the server, much like changes are. Such + /// effects are automatically remapped when conflicting remote + /// changes come in. + sharedEffects?: (tr: Transaction) => readonly StateEffect[]; +}; + +const collabConfig = Facet.define< + CollabConfig & { generatedID: string }, + Required +>({ + combine(configs) { + let combined = combineConfig(configs, { + startVersion: 0, + clientID: null as any, + sharedEffects: () => [], + }); + if (combined.clientID == null) + combined.clientID = (configs.length && configs[0].generatedID) || ""; + return combined; + }, +}); + +const collabReceive = Annotation.define(); + +const collabField = StateField.define({ + create(state) { + return new CollabState(state.facet(collabConfig).startVersion, []); + }, + + update(collab: CollabState, tr: Transaction) { + let isSync = tr.annotation(collabReceive); + if (isSync) return isSync; + let { sharedEffects, clientID } = tr.startState.facet(collabConfig); + let effects = sharedEffects(tr); + if (effects.length || !tr.changes.empty) + return new CollabState( + collab.version, + collab.unconfirmed.concat( + new LocalUpdate(tr, tr.changes, effects, clientID) + ) + ); + return collab; + }, +}); + +/// Create an instance of the collaborative editing plugin. +export function collab(config: CollabConfig = {}): Extension { + return [ + collabField, + collabConfig.of({ + generatedID: Math.floor(Math.random() * 1e9).toString(36), + ...config, + }), + ]; +} + +/// Create a transaction that represents a set of new updates received +/// from the authority. Applying this transaction moves the state +/// forward to adjust to the authority's view of the document. +export function receiveUpdates(state: EditorState, updates: readonly Update[]) { + let { version, unconfirmed } = state.field(collabField); + let { clientID } = state.facet(collabConfig); + + version += updates.length; + + let own = 0; + while (own < updates.length && updates[own].clientID == clientID) own++; + if (own) { + unconfirmed = unconfirmed.slice(own); + updates = updates.slice(own); + } + + // If all updates originated with us, we're done. + if (!updates.length) { + console.log("All updates are ours", unconfirmed.length); + return state.update({ + annotations: [collabReceive.of(new CollabState(version, unconfirmed))], + }); + } + + let changes = updates[0].changes, + effects = updates[0].effects || []; + for (let i = 1; i < updates.length; i++) { + let update = updates[i]; + effects = StateEffect.mapEffects(effects, update.changes); + if (update.effects) effects = effects.concat(update.effects); + changes = changes.compose(update.changes); + } + + if (unconfirmed.length) { + unconfirmed = unconfirmed.map((update) => { + let updateChanges = update.changes.map(changes); + changes = changes.map(update.changes, true); + return new LocalUpdate( + update.origin, + updateChanges, + StateEffect.mapEffects(update.effects, changes), + clientID + ); + }); + effects = StateEffect.mapEffects( + effects, + unconfirmed.reduce( + (ch, u) => ch.compose(u.changes), + ChangeSet.empty(unconfirmed[0].changes.length) + ) + ); + } + return state.update({ + changes, + effects, + annotations: [ + Transaction.addToHistory.of(false), + Transaction.remote.of(true), + collabReceive.of(new CollabState(version, unconfirmed)), + ], + filter: false, + }); +} + +/// Returns the set of locally made updates that still have to be sent +/// to the authority. The returned objects will also have an `origin` +/// property that points at the transaction that created them. This +/// may be useful if you want to send along metadata like timestamps. +/// (But note that the updates may have been mapped in the meantime, +/// whereas the transaction is just the original transaction that +/// created them.) +export function sendableUpdates( + state: EditorState +): readonly (Update & { origin: Transaction })[] { + return state.field(collabField).unconfirmed; +} + +/// Get the version up to which the collab plugin has synced with the +/// central authority. +export function getSyncedVersion(state: EditorState) { + return state.field(collabField).version; +} + +/// Get this editor's collaborative editing client ID. +export function getClientID(state: EditorState) { + return state.facet(collabConfig).clientID; +} diff --git a/webapp/collab.ts b/webapp/collab.ts index 344b33d..3761fe0 100644 --- a/webapp/collab.ts +++ b/webapp/collab.ts @@ -4,7 +4,7 @@ import { receiveUpdates, sendableUpdates, Update, -} from "@codemirror/collab"; +} from "./cm_collab"; import { RangeSetBuilder } from "@codemirror/rangeset"; import { Text, Transaction } from "@codemirror/state"; import { @@ -138,10 +138,6 @@ export function collabExtension( update(update: ViewUpdate) { if (update.selectionSet) { let pos = update.state.selection.main.head; - // if (pos === 0) { - // console.error("Warning: position reset? at 0"); - // console.trace(); - // } setTimeout(() => { update.view.dispatch({ effects: [ @@ -209,7 +205,6 @@ export function collabExtension( while (!this.done) { let version = getSyncedVersion(this.view.state); let updates = await callbacks.pullUpdates(pageName, version); - let d = receiveUpdates(this.view.state, updates); // Pull out cursor updates and update local state for (let update of updates) { if (update.effects) { @@ -224,7 +219,9 @@ export function collabExtension( } } } - this.view.dispatch(d); + + // Apply updates locally + this.view.dispatch(receiveUpdates(this.view.state, updates)); } } diff --git a/webapp/editor.tsx b/webapp/editor.tsx index 13ea006..9a0fc29 100644 --- a/webapp/editor.tsx +++ b/webapp/editor.tsx @@ -44,6 +44,7 @@ import { Panel } from "./components/panel"; import { CommandHook } from "./hooks/command"; import { SlashCommandHook } from "./hooks/slash_command"; import { CompleterHook } from "./hooks/completer"; +import { pasteLinkExtension } from "./editor_paste"; class PageState { scrollTop: number; @@ -305,6 +306,7 @@ export class Editor implements AppEventDispatcher { }, }, ]), + EditorView.domEventHandlers({ click: (event: MouseEvent, view: EditorView) => { safeRun(async () => { @@ -319,6 +321,7 @@ export class Editor implements AppEventDispatcher { }); }, }), + pasteLinkExtension, markdown({ base: customMarkDown, }), diff --git a/webapp/editor_paste.ts b/webapp/editor_paste.ts new file mode 100644 index 0000000..422cc0e --- /dev/null +++ b/webapp/editor_paste.ts @@ -0,0 +1,41 @@ +import { ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { urlRegexp } from "./parser"; + +export const pasteLinkExtension = ViewPlugin.fromClass( + class { + update(update: ViewUpdate): void { + update.transactions.forEach((tr) => { + if (tr.isUserEvent("input.paste")) { + let pastedText: string[] = []; + let from = 0; + let to = 0; + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + pastedText.push(inserted.sliceString(0)); + from = fromA; + to = toB; + }); + let pastedString = pastedText.join(""); + if (pastedString.match(urlRegexp)) { + let selection = update.startState.selection.main; + if (!selection.empty) { + setTimeout(() => { + update.view.dispatch({ + changes: [ + { + from: from, + to: to, + insert: `[${update.startState.sliceDoc( + selection.from, + selection.to + )}](${pastedString})`, + }, + ], + }); + }); + } + } + } + }); + } + } +); diff --git a/webapp/parser.ts b/webapp/parser.ts index ec30d9a..6c15506 100644 --- a/webapp/parser.ts +++ b/webapp/parser.ts @@ -1,10 +1,10 @@ import { styleTags, tags as t } from "@codemirror/highlight"; import { - MarkdownConfig, - TaskList, BlockContext, LeafBlock, LeafBlockParser, + MarkdownConfig, + TaskList, } from "@lezer/markdown"; import { commonmark, mkLang } from "./markdown/markdown"; import * as ct from "./customtags"; @@ -60,8 +60,8 @@ const AtMention: MarkdownConfig = { ], }; -const urlRegexp = - /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; +export const urlRegexp = + /^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; const UnmarkedUrl: MarkdownConfig = { defineNodes: ["URL"], diff --git a/webapp/space.ts b/webapp/space.ts index f475cb7..beef6cd 100644 --- a/webapp/space.ts +++ b/webapp/space.ts @@ -80,17 +80,21 @@ export class Space extends EventEmitter { }); } + openRequests = new Map(); public wsCall(eventName: string, ...args: any[]): Promise { return new Promise((resolve, reject) => { this.reqId++; - this.socket!.once(`${eventName}Resp${this.reqId}`, (err, result) => { + const reqId = this.reqId; + this.openRequests.set(reqId, eventName); + this.socket!.once(`${eventName}Resp${reqId}`, (err, result) => { + this.openRequests.delete(reqId); if (err) { reject(new Error(err)); } else { resolve(result); } }); - this.socket!.emit(eventName, this.reqId, ...args); + this.socket!.emit(eventName, reqId, ...args); }); }