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 {safeRun} from "../util";
|
||||||
import knex from "knex";
|
import knex from "knex";
|
||||||
import {
|
import {
|
||||||
ensureTable,
|
ensureTable,
|
||||||
storeReadSyscalls,
|
storeReadSyscalls,
|
||||||
storeWriteSyscalls,
|
storeWriteSyscalls,
|
||||||
} from "../syscalls/store.knex_node";
|
} from "../syscalls/store.knex_node";
|
||||||
import {fetchSyscalls} from "../syscalls/fetch.node";
|
import {fetchSyscalls} from "../syscalls/fetch.node";
|
||||||
import {EventHook, EventHookT} from "../hooks/event";
|
import {EventHook, EventHookT} from "../hooks/event";
|
||||||
@ -21,13 +21,13 @@ import {eventSyscalls} from "../syscalls/event";
|
|||||||
|
|
||||||
let args = yargs(hideBin(process.argv))
|
let args = yargs(hideBin(process.argv))
|
||||||
.option("port", {
|
.option("port", {
|
||||||
type: "number",
|
type: "number",
|
||||||
default: 1337,
|
default: 1337,
|
||||||
})
|
})
|
||||||
.parse();
|
.parse();
|
||||||
|
|
||||||
if (!args._.length) {
|
if (!args._.length) {
|
||||||
console.error("Usage: plugos-server <path-to-plugs>");
|
console.error("Usage: plugos-server <path-to-plugs>");
|
||||||
process.exit(1);
|
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,
|
receiveUpdates,
|
||||||
sendableUpdates,
|
sendableUpdates,
|
||||||
Update,
|
Update,
|
||||||
} from "@codemirror/collab";
|
} from "./cm_collab";
|
||||||
import { RangeSetBuilder } from "@codemirror/rangeset";
|
import { RangeSetBuilder } from "@codemirror/rangeset";
|
||||||
import { Text, Transaction } from "@codemirror/state";
|
import { Text, Transaction } from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
@ -138,10 +138,6 @@ export function collabExtension(
|
|||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
if (update.selectionSet) {
|
if (update.selectionSet) {
|
||||||
let pos = update.state.selection.main.head;
|
let pos = update.state.selection.main.head;
|
||||||
// if (pos === 0) {
|
|
||||||
// console.error("Warning: position reset? at 0");
|
|
||||||
// console.trace();
|
|
||||||
// }
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
update.view.dispatch({
|
update.view.dispatch({
|
||||||
effects: [
|
effects: [
|
||||||
@ -209,7 +205,6 @@ export function collabExtension(
|
|||||||
while (!this.done) {
|
while (!this.done) {
|
||||||
let version = getSyncedVersion(this.view.state);
|
let version = getSyncedVersion(this.view.state);
|
||||||
let updates = await callbacks.pullUpdates(pageName, version);
|
let updates = await callbacks.pullUpdates(pageName, version);
|
||||||
let d = receiveUpdates(this.view.state, updates);
|
|
||||||
// Pull out cursor updates and update local state
|
// Pull out cursor updates and update local state
|
||||||
for (let update of updates) {
|
for (let update of updates) {
|
||||||
if (update.effects) {
|
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 { CommandHook } from "./hooks/command";
|
||||||
import { SlashCommandHook } from "./hooks/slash_command";
|
import { SlashCommandHook } from "./hooks/slash_command";
|
||||||
import { CompleterHook } from "./hooks/completer";
|
import { CompleterHook } from "./hooks/completer";
|
||||||
|
import { pasteLinkExtension } from "./editor_paste";
|
||||||
|
|
||||||
class PageState {
|
class PageState {
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
@ -305,6 +306,7 @@ export class Editor implements AppEventDispatcher {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
click: (event: MouseEvent, view: EditorView) => {
|
click: (event: MouseEvent, view: EditorView) => {
|
||||||
safeRun(async () => {
|
safeRun(async () => {
|
||||||
@ -319,6 +321,7 @@ export class Editor implements AppEventDispatcher {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
pasteLinkExtension,
|
||||||
markdown({
|
markdown({
|
||||||
base: customMarkDown,
|
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 { styleTags, tags as t } from "@codemirror/highlight";
|
||||||
import {
|
import {
|
||||||
MarkdownConfig,
|
|
||||||
TaskList,
|
|
||||||
BlockContext,
|
BlockContext,
|
||||||
LeafBlock,
|
LeafBlock,
|
||||||
LeafBlockParser,
|
LeafBlockParser,
|
||||||
|
MarkdownConfig,
|
||||||
|
TaskList,
|
||||||
} from "@lezer/markdown";
|
} from "@lezer/markdown";
|
||||||
import { commonmark, mkLang } from "./markdown/markdown";
|
import { commonmark, mkLang } from "./markdown/markdown";
|
||||||
import * as ct from "./customtags";
|
import * as ct from "./customtags";
|
||||||
@ -60,8 +60,8 @@ const AtMention: MarkdownConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const urlRegexp =
|
export const urlRegexp =
|
||||||
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
|
/^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
|
||||||
|
|
||||||
const UnmarkedUrl: MarkdownConfig = {
|
const UnmarkedUrl: MarkdownConfig = {
|
||||||
defineNodes: ["URL"],
|
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> {
|
public wsCall(eventName: string, ...args: any[]): Promise<any> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.reqId++;
|
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) {
|
if (err) {
|
||||||
reject(new Error(err));
|
reject(new Error(err));
|
||||||
} else {
|
} else {
|
||||||
resolve(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.socket!.emit(eventName, this.reqId, ...args);
|
this.socket!.emit(eventName, reqId, ...args);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user