1
0

Commit with collab on

This commit is contained in:
Zef Hemel 2022-03-30 15:16:22 +02:00
parent c268fa9f27
commit 4525d60964
7 changed files with 260 additions and 19 deletions

View File

@ -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 <path-to-plugs>");
console.error("Usage: plugos-server <path-to-plugs>");
process.exit(1);
}

196
webapp/cm_collab.ts Normal file
View File

@ -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<any>[];
/// 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<any>[],
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<any>[];
};
const collabConfig = Facet.define<
CollabConfig & { generatedID: string },
Required<CollabConfig>
>({
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<CollabState>();
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;
}

View File

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

View File

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

41
webapp/editor_paste.ts Normal file
View File

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

View File

@ -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"],

View File

@ -80,17 +80,21 @@ export class Space extends EventEmitter<SpaceEvents> {
});
}
openRequests = new Map<number, string>();
public wsCall(eventName: string, ...args: any[]): Promise<any> {
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);
});
}