Commit with collab on
This commit is contained in:
parent
c268fa9f27
commit
4525d60964
@ -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
196
webapp/cm_collab.ts
Normal 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;
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
41
webapp/editor_paste.ts
Normal 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})`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
@ -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"],
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user