Removed all traces of sockets, real-time collab and other stuff.

This commit is contained in:
Zef Hemel
2022-03-31 14:28:07 +02:00
parent 4525d60964
commit 859657f8b8
41 changed files with 796 additions and 1691 deletions
+1 -3
View File
@@ -1,10 +1,8 @@
import { Editor } from "./editor";
import { Space } from "./space";
import { safeRun } from "./util";
import { io } from "socket.io-client";
let socket = io();
let editor = new Editor(new Space(socket), document.getElementById("root")!);
let editor = new Editor(new Space(""), document.getElementById("root")!);
safeRun(async () => {
await editor.init();
-196
View File
@@ -1,196 +0,0 @@
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;
}
-248
View File
@@ -1,248 +0,0 @@
import {
collab,
getSyncedVersion,
receiveUpdates,
sendableUpdates,
Update,
} from "./cm_collab";
import { RangeSetBuilder } from "@codemirror/rangeset";
import { Text, Transaction } from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { throttle } from "./util";
import { Cursor, cursorEffect } from "./cursorEffect";
import { EventEmitter } from "../common/event";
const throttleInterval = 250;
export class CollabDocument {
text: Text;
version: number;
cursors: Map<string, Cursor>;
constructor(text: Text, version: number, cursors: Map<string, Cursor>) {
this.text = text;
this.version = version;
this.cursors = cursors;
}
}
class CursorWidget extends WidgetType {
userId: string;
color: string;
constructor(userId: string, color: string) {
super();
this.userId = userId;
this.color = color;
}
eq(other: CursorWidget) {
return other.userId == this.userId;
}
toDOM() {
let el = document.createElement("span");
el.className = "other-cursor";
el.style.backgroundColor = this.color;
// let nameSpanContainer = document.createElement("span");
// nameSpanContainer.className = "cursor-label-container";
// let nameSpanLabel = document.createElement("label");
// nameSpanLabel.className = "cursor-label";
// nameSpanLabel.textContent = this.userId;
// nameSpanContainer.appendChild(nameSpanLabel);
// el.appendChild(nameSpanContainer);
return el;
}
}
export type CollabEvents = {
cursorSnapshot: (pageName: string, cursors: Map<string, Cursor>) => void;
};
export function collabExtension(
pageName: string,
clientID: string,
doc: CollabDocument,
collabEmitter: EventEmitter<CollabEvents>,
callbacks: {
pushUpdates: (
pageName: string,
version: number,
updates: readonly (Update & { origin: Transaction })[]
) => Promise<boolean>;
pullUpdates: (
pageName: string,
version: number
) => Promise<readonly Update[]>;
reload: () => void;
}
) {
let plugin = ViewPlugin.fromClass(
class {
private pushing = false;
private done = false;
private failedPushes = 0;
private cursorPositions: Map<string, Cursor> = doc.cursors;
decorations: DecorationSet;
throttledPush = throttle(() => this.push(), throttleInterval);
eventHandlers: Partial<CollabEvents> = {
cursorSnapshot: (pageName, cursors) => {
console.log("Received new cursor snapshot", cursors);
this.cursorPositions = new Map(Object.entries(cursors));
},
};
buildDecorations(view: EditorView) {
let builder = new RangeSetBuilder<Decoration>();
let list = [];
for (let [userId, def] of this.cursorPositions) {
if (userId == clientID) {
continue;
}
list.push({
pos: def.pos,
widget: Decoration.widget({
widget: new CursorWidget(userId, def.color),
side: 1,
}),
});
}
list
.sort((a, b) => a.pos - b.pos)
.forEach((r) => {
builder.add(r.pos, r.pos, r.widget);
});
return builder.finish();
}
constructor(private view: EditorView) {
if (pageName) {
this.pull();
}
this.decorations = this.buildDecorations(view);
collabEmitter.on(this.eventHandlers);
}
update(update: ViewUpdate) {
if (update.selectionSet) {
let pos = update.state.selection.main.head;
setTimeout(() => {
update.view.dispatch({
effects: [
cursorEffect.of({ pos: pos, userId: clientID, color: "red" }),
],
});
});
}
let foundCursorMoves = new Set<string>();
for (let tx of update.transactions) {
let cursorMove = tx.effects.find((e) => e.is(cursorEffect));
if (cursorMove) {
foundCursorMoves.add(cursorMove.value.userId);
}
}
// Update cursors
for (let cursor of this.cursorPositions.values()) {
if (foundCursorMoves.has(cursor.userId)) {
// Already got a cursor update for this one, no need to manually map
continue;
}
update.transactions.forEach((tx) => {
cursor.pos = tx.changes.mapPos(cursor.pos);
});
}
this.decorations = this.buildDecorations(update.view);
if (update.docChanged || foundCursorMoves.size > 0) {
this.throttledPush();
}
}
async push() {
let updates = sendableUpdates(this.view.state);
// TODO: compose multiple updates into one
if (this.pushing || !updates.length) return;
this.pushing = true;
let version = getSyncedVersion(this.view.state);
// console.log("Updates", updates, "to apply to version", version);
let success = await callbacks.pushUpdates(pageName, version, updates);
this.pushing = false;
if (!success && !this.done) {
this.failedPushes++;
if (this.failedPushes > 10) {
// Not sure if 10 is a good number, but YOLO
console.log("10 pushes failed, reloading");
callbacks.reload();
return this.destroy();
}
console.log(
`Push for page ${pageName} failed temporarily, but will try again`
);
} else {
this.failedPushes = 0;
}
// Regardless of whether the push failed or new updates came in
// while it was running, try again if there's updates remaining
if (!this.done && sendableUpdates(this.view.state).length) {
this.throttledPush();
}
}
async pull() {
while (!this.done) {
let version = getSyncedVersion(this.view.state);
let updates = await callbacks.pullUpdates(pageName, version);
// Pull out cursor updates and update local state
for (let update of updates) {
if (update.effects) {
for (let effect of update.effects) {
if (effect.is(cursorEffect)) {
this.cursorPositions.set(effect.value.userId, {
userId: effect.value.userId,
pos: effect.value.pos,
color: effect.value.color,
});
}
}
}
}
// Apply updates locally
this.view.dispatch(receiveUpdates(this.view.state, updates));
}
}
destroy() {
this.done = true;
collabEmitter.off(this.eventHandlers);
}
},
{
decorations: (v) => v.decorations,
}
);
return [
collab({
startVersion: doc.version,
clientID,
sharedEffects: (tr) => {
return tr.effects.filter((e) => e.is(cursorEffect));
},
}),
plugin,
];
}
+12 -4
View File
@@ -81,8 +81,11 @@ export function FilterList({
let selectedElementRef = useRef<HTMLDivElement>(null);
const filter = (e: React.ChangeEvent<HTMLInputElement>) => {
const originalPhrase = e.target.value;
function filterUpdate(e: React.ChangeEvent<HTMLInputElement>) {
updateFilter(e.target.value);
}
function updateFilter(originalPhrase: string) {
const searchPhrase = originalPhrase.toLowerCase();
if (searchPhrase) {
@@ -103,7 +106,11 @@ export function FilterList({
setText(originalPhrase);
setSelectionOption(0);
};
}
useEffect(() => {
updateFilter(text);
}, [options]);
useEffect(() => {
searchBoxRef.current!.focus();
@@ -113,6 +120,7 @@ export function FilterList({
function closer() {
onSelect(undefined);
}
document.addEventListener("click", closer);
return () => {
@@ -129,7 +137,7 @@ export function FilterList({
value={text}
placeholder={placeholder}
ref={searchBoxRef}
onChange={filter}
onChange={filterUpdate}
onKeyDown={(e: React.KeyboardEvent) => {
// console.log("Key up", e.key);
if (onKeyPress) {
+3 -3
View File
@@ -11,19 +11,19 @@ function prettyName(s: string | undefined): string {
export function TopBar({
pageName,
status,
unsavedChanges,
notifications,
onClick,
}: {
pageName?: string;
status?: string;
unsavedChanges: boolean;
notifications: Notification[];
onClick: () => void;
}) {
return (
<div id="top" onClick={onClick}>
<div className="inner">
<span className="icon">
<span className={`icon ${unsavedChanges ? "unsaved" : "saved"}`}>
<FontAwesomeIcon icon={faFileLines} />
</span>
<span className="current-page">{prettyName(pageName)}</span>
-12
View File
@@ -1,12 +0,0 @@
import { StateEffect } from "@codemirror/state";
export type Cursor = {
pos: number;
userId: string;
color: string;
};
export const cursorEffect = StateEffect.define<Cursor>({
map({ pos, userId, color }, changes) {
return { pos: changes.mapPos(pos), userId, color };
},
});
+69 -30
View File
@@ -4,7 +4,7 @@ import { indentWithTab, standardKeymap } from "@codemirror/commands";
import { history, historyKeymap } from "@codemirror/history";
import { bracketMatching } from "@codemirror/matchbrackets";
import { searchKeymap } from "@codemirror/search";
import { EditorSelection, EditorState, Text } from "@codemirror/state";
import { EditorSelection, EditorState } from "@codemirror/state";
import {
drawSelection,
dropCursor,
@@ -12,17 +12,17 @@ import {
highlightSpecialChars,
KeyBinding,
keymap,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
import React, { useEffect, useReducer } from "react";
import ReactDOM from "react-dom";
import { createSandbox as createIFrameSandbox } from "../plugos/environments/iframe_sandbox";
import { AppEvent, AppEventDispatcher, ClickEvent } from "./app_event";
import { CollabDocument, collabExtension } from "./collab";
import * as commands from "./commands";
import { CommandPalette } from "./components/command_palette";
import { PageNavigator } from "./components/page_navigator";
import { TopBar } from "./components/top_bar";
import { Cursor } from "./cursorEffect";
import { lineWrapper } from "./line_wrapper";
import { markdown } from "./markdown";
import { PathPageNavigator } from "./navigator";
@@ -56,6 +56,8 @@ class PageState {
}
}
const saveInterval = 2000;
export class Editor implements AppEventDispatcher {
private system = new System<SilverBulletHooks>("client");
readonly commandHook: CommandHook;
@@ -101,17 +103,14 @@ export class Editor implements AppEventDispatcher {
this.render(parent);
this.editorView = new EditorView({
state: this.createEditorState(
"",
new CollabDocument(Text.of([""]), 0, new Map<string, Cursor>())
),
state: this.createEditorState("", ""),
parent: document.getElementById("editor")!,
});
this.pageNavigator = new PathPageNavigator();
this.system.registerSyscalls("editor", [], editorSyscalls(this));
this.system.registerSyscalls("space", [], spaceSyscalls(this));
this.system.registerSyscalls("indexer", [], indexerSyscalls(this.space));
this.system.registerSyscalls("index", [], indexerSyscalls(this.space));
this.system.registerSyscalls("system", [], systemSyscalls(this.space));
}
@@ -134,12 +133,11 @@ export class Editor implements AppEventDispatcher {
});
this.space.on({
connect: () => {
if (this.currentPage) {
console.log("Connected to socket, fetch fresh?");
this.flashNotification("Reconnected, reloading page");
this.reloadPage();
}
pageCreated: (meta) => {
console.log("Page created", meta);
},
pageDeleted: (meta) => {
console.log("Page delete", meta);
},
pageChanged: (meta) => {
if (this.currentPage === meta.name) {
@@ -154,11 +152,6 @@ export class Editor implements AppEventDispatcher {
pages: pages,
});
},
loadSystem: (systemJSON) => {
safeRun(async () => {
await this.system.replaceAllFromJSON(systemJSON, createIFrameSandbox);
});
},
plugLoaded: (plugName, plug) => {
safeRun(async () => {
console.log("Plug load", plugName);
@@ -178,6 +171,40 @@ export class Editor implements AppEventDispatcher {
}
}
saveTimeout: any;
async save(immediate: boolean = false): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.viewState.unsavedChanges) {
return resolve();
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(
() => {
if (this.currentPage) {
console.log("Saving page", this.currentPage);
this.space
.writePage(
this.currentPage,
this.editorView!.state.sliceDoc(0),
true
)
.then(() => {
this.viewDispatch({ type: "page-saved" });
resolve();
})
.catch(reject);
} else {
resolve();
}
},
immediate ? 0 : saveInterval
);
});
}
flashNotification(message: string) {
let id = Math.floor(Math.random() * 1000000);
this.viewDispatch({
@@ -204,7 +231,7 @@ export class Editor implements AppEventDispatcher {
return this.viewState.currentPage;
}
createEditorState(pageName: string, doc: CollabDocument): EditorState {
createEditorState(pageName: string, text: string): EditorState {
let commandKeyBindings: KeyBinding[] = [];
for (let def of this.commandHook.editorCommands.values()) {
if (def.command.key) {
@@ -223,8 +250,9 @@ export class Editor implements AppEventDispatcher {
});
}
}
const editor = this;
return EditorState.create({
doc: doc.text,
doc: text,
extensions: [
highlightSpecialChars(),
history(),
@@ -233,11 +261,6 @@ export class Editor implements AppEventDispatcher {
customMarkdownStyle,
bracketMatching(),
closeBrackets(),
collabExtension(pageName, this.space.socket.id, doc, this.space, {
pushUpdates: this.space.pushUpdates.bind(this.space),
pullUpdates: this.space.pullUpdates.bind(this.space),
reload: this.reloadPage.bind(this),
}),
autocompletion({
override: [
this.completerHook.plugCompleter.bind(this.completerHook),
@@ -292,6 +315,8 @@ export class Editor implements AppEventDispatcher {
mac: "Cmd-k",
run: (): boolean => {
this.viewDispatch({ type: "start-navigate" });
// asynchornously will dispatch pageListUpdate event
this.space.updatePageListAsync();
return true;
},
},
@@ -321,6 +346,16 @@ export class Editor implements AppEventDispatcher {
});
},
}),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
if (update.docChanged) {
editor.viewDispatch({ type: "page-changed" });
editor.save();
}
}
}
),
pasteLinkExtension,
markdown({
base: customMarkDown,
@@ -332,6 +367,7 @@ export class Editor implements AppEventDispatcher {
reloadPage() {
console.log("Reloading page");
safeRun(async () => {
clearTimeout(this.saveTimeout);
await this.loadPage(this.currentPage!);
});
}
@@ -357,13 +393,13 @@ export class Editor implements AppEventDispatcher {
pageState.selection = this.editorView!.state.selection;
pageState.scrollTop = this.editorView!.scrollDOM.scrollTop;
}
await this.space.closePage(this.currentPage);
this.space.unwatchPage(this.currentPage);
await this.save(true);
}
// Fetch next page to open
let doc = await this.space.openPage(pageName);
let editorState = this.createEditorState(pageName, doc);
let doc = await this.space.readPage(pageName);
let editorState = this.createEditorState(pageName, doc.text);
let pageState = this.openPages.get(pageName);
editorView.setState(editorState);
if (!pageState) {
@@ -381,6 +417,8 @@ export class Editor implements AppEventDispatcher {
editorView.scrollDOM.scrollTop = pageState!.scrollTop;
}
this.space.watchPage(pageName);
this.viewDispatch({
type: "page-loaded",
name: pageName,
@@ -435,6 +473,7 @@ export class Editor implements AppEventDispatcher {
<TopBar
pageName={viewState.currentPage}
notifications={viewState.notifications}
unsavedChanges={viewState.unsavedChanges}
onClick={() => {
dispatch({ type: "start-navigate" });
}}
+2 -2
View File
@@ -1,8 +1,8 @@
import {
ChangeSpec,
EditorSelection,
StateCommand,
Text,
EditorSelection,
ChangeSpec,
} from "@codemirror/state";
import { syntaxTree } from "@codemirror/language";
import { SyntaxNode, Tree } from "@lezer/common";
+74 -43
View File
@@ -1,56 +1,87 @@
import {Prec} from "@codemirror/state"
import {KeyBinding, keymap} from "@codemirror/view"
import {Language, LanguageSupport, LanguageDescription} from "@codemirror/language"
import {MarkdownExtension, MarkdownParser, parseCode} from "@lezer/markdown"
import {html} from "@codemirror/lang-html"
import {commonmarkLanguage, markdownLanguage, mkLang, getCodeParser} from "./markdown"
import {insertNewlineContinueMarkup, deleteMarkupBackward} from "./commands"
export {commonmarkLanguage, markdownLanguage, insertNewlineContinueMarkup, deleteMarkupBackward}
import { Prec } from "@codemirror/state";
import { KeyBinding, keymap } from "@codemirror/view";
import {
Language,
LanguageDescription,
LanguageSupport,
} from "@codemirror/language";
import { MarkdownExtension, MarkdownParser, parseCode } from "@lezer/markdown";
import { html } from "@codemirror/lang-html";
import {
commonmarkLanguage,
getCodeParser,
markdownLanguage,
mkLang,
} from "./markdown";
import { deleteMarkupBackward, insertNewlineContinueMarkup } from "./commands";
export {
commonmarkLanguage,
markdownLanguage,
insertNewlineContinueMarkup,
deleteMarkupBackward,
};
/// A small keymap with Markdown-specific bindings. Binds Enter to
/// [`insertNewlineContinueMarkup`](#lang-markdown.insertNewlineContinueMarkup)
/// and Backspace to
/// [`deleteMarkupBackward`](#lang-markdown.deleteMarkupBackward).
export const markdownKeymap: readonly KeyBinding[] = [
{key: "Enter", run: insertNewlineContinueMarkup},
{key: "Backspace", run: deleteMarkupBackward}
]
{ key: "Enter", run: insertNewlineContinueMarkup },
{ key: "Backspace", run: deleteMarkupBackward },
];
const htmlNoMatch = html({matchClosingTags: false})
const htmlNoMatch = html({ matchClosingTags: false });
/// Markdown language support.
export function markdown(config: {
/// When given, this language will be used by default to parse code
/// blocks.
defaultCodeLanguage?: Language | LanguageSupport,
/// A collection of language descriptions to search through for a
/// matching language (with
/// [`LanguageDescription.matchLanguageName`](#language.LanguageDescription^matchLanguageName))
/// when a fenced code block has an info string.
codeLanguages?: readonly LanguageDescription[],
/// Set this to false to disable installation of the Markdown
/// [keymap](#lang-markdown.markdownKeymap).
addKeymap?: boolean,
/// Markdown parser
/// [extensions](https://github.com/lezer-parser/markdown#user-content-markdownextension)
/// to add to the parser.
extensions?: MarkdownExtension,
/// The base language to use. Defaults to
/// [`commonmarkLanguage`](#lang-markdown.commonmarkLanguage).
base?: Language
} = {}) {
let {codeLanguages, defaultCodeLanguage, addKeymap = true, base: {parser} = commonmarkLanguage} = config
if (!(parser instanceof MarkdownParser)) throw new RangeError("Base parser provided to `markdown` should be a Markdown parser")
let extensions = config.extensions ? [config.extensions] : []
let support = [htmlNoMatch.support], defaultCode
export function markdown(
config: {
/// When given, this language will be used by default to parse code
/// blocks.
defaultCodeLanguage?: Language | LanguageSupport;
/// A collection of language descriptions to search through for a
/// matching language (with
/// [`LanguageDescription.matchLanguageName`](#language.LanguageDescription^matchLanguageName))
/// when a fenced code block has an info string.
codeLanguages?: readonly LanguageDescription[];
/// Set this to false to disable installation of the Markdown
/// [keymap](#lang-markdown.markdownKeymap).
addKeymap?: boolean;
/// Markdown parser
/// [extensions](https://github.com/lezer-parser/markdown#user-content-markdownextension)
/// to add to the parser.
extensions?: MarkdownExtension;
/// The base language to use. Defaults to
/// [`commonmarkLanguage`](#lang-markdown.commonmarkLanguage).
base?: Language;
} = {}
) {
let {
codeLanguages,
defaultCodeLanguage,
addKeymap = true,
base: {parser} = commonmarkLanguage,
} = config;
if (!(parser instanceof MarkdownParser))
throw new RangeError(
"Base parser provided to `markdown` should be a Markdown parser"
);
let extensions = config.extensions ? [config.extensions] : [];
let support = [htmlNoMatch.support],
defaultCode;
if (defaultCodeLanguage instanceof LanguageSupport) {
support.push(defaultCodeLanguage.support)
defaultCode = defaultCodeLanguage.language
support.push(defaultCodeLanguage.support);
defaultCode = defaultCodeLanguage.language;
} else if (defaultCodeLanguage) {
defaultCode = defaultCodeLanguage
defaultCode = defaultCodeLanguage;
}
let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages || [], defaultCode) : undefined
extensions.push(parseCode({codeParser, htmlParser: htmlNoMatch.language.parser}))
if (addKeymap) support.push(Prec.high(keymap.of(markdownKeymap)))
return new LanguageSupport(mkLang(parser.configure(extensions)), support)
let codeParser =
codeLanguages || defaultCode
? getCodeParser(codeLanguages || [], defaultCode)
: undefined;
extensions.push(
parseCode({codeParser, htmlParser: htmlNoMatch.language.parser})
);
if (addKeymap) support.push(Prec.high(keymap.of(markdownKeymap)));
return new LanguageSupport(mkLang(parser.configure(extensions)), support);
}
+97 -67
View File
@@ -1,84 +1,114 @@
import {
Language, defineLanguageFacet, languageDataProp, foldNodeProp, indentNodeProp,
LanguageDescription, ParseContext
} from "@codemirror/language"
import {styleTags, tags as t} from "@codemirror/highlight"
import {parser as baseParser, MarkdownParser, GFM, Subscript, Superscript, Emoji, MarkdownConfig} from "@lezer/markdown"
defineLanguageFacet,
foldNodeProp,
indentNodeProp,
Language,
languageDataProp,
LanguageDescription,
ParseContext,
} from "@codemirror/language";
import { styleTags, tags as t } from "@codemirror/highlight";
import {
Emoji,
GFM,
MarkdownParser,
parser as baseParser,
Subscript,
Superscript,
} from "@lezer/markdown";
const data = defineLanguageFacet({block: {open: "<!--", close: "-->"}})
const data = defineLanguageFacet({ block: { open: "<!--", close: "-->" } });
export const commonmark = baseParser.configure({
props: [
styleTags({
"Blockquote/...": t.quote,
HorizontalRule: t.contentSeparator,
"ATXHeading1/... SetextHeading1/...": t.heading1,
"ATXHeading2/... SetextHeading2/...": t.heading2,
"ATXHeading3/...": t.heading3,
"ATXHeading4/...": t.heading4,
"ATXHeading5/...": t.heading5,
"ATXHeading6/...": t.heading6,
"Comment CommentBlock": t.comment,
Escape: t.escape,
Entity: t.character,
"Emphasis/...": t.emphasis,
"StrongEmphasis/...": t.strong,
"Link/... Image/...": t.link,
"OrderedList/... BulletList/...": t.list,
props: [
styleTags({
"Blockquote/...": t.quote,
HorizontalRule: t.contentSeparator,
"ATXHeading1/... SetextHeading1/...": t.heading1,
"ATXHeading2/... SetextHeading2/...": t.heading2,
"ATXHeading3/...": t.heading3,
"ATXHeading4/...": t.heading4,
"ATXHeading5/...": t.heading5,
"ATXHeading6/...": t.heading6,
"Comment CommentBlock": t.comment,
Escape: t.escape,
Entity: t.character,
"Emphasis/...": t.emphasis,
"StrongEmphasis/...": t.strong,
"Link/... Image/...": t.link,
"OrderedList/... BulletList/...": t.list,
// "CodeBlock/... FencedCode/...": t.blockComment,
"InlineCode CodeText": t.monospace,
URL: t.url,
"HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark": t.processingInstruction,
"CodeInfo LinkLabel": t.labelName,
LinkTitle: t.string,
Paragraph: t.content
}),
foldNodeProp.add(type => {
if (!type.is("Block") || type.is("Document")) return undefined
return (tree, state) => ({from: state.doc.lineAt(tree.from).to, to: tree.to})
}),
indentNodeProp.add({
Document: () => null
}),
languageDataProp.add({
Document: data
})
]
})
// "CodeBlock/... FencedCode/...": t.blockComment,
"InlineCode CodeText": t.monospace,
URL: t.url,
"HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark":
t.processingInstruction,
"CodeInfo LinkLabel": t.labelName,
LinkTitle: t.string,
Paragraph: t.content,
}),
foldNodeProp.add((type) => {
if (!type.is("Block") || type.is("Document")) return undefined;
return (tree, state) => ({
from: state.doc.lineAt(tree.from).to,
to: tree.to,
});
}),
indentNodeProp.add({
Document: () => null,
}),
languageDataProp.add({
Document: data,
}),
],
});
export function mkLang(parser: MarkdownParser) {
return new Language(data, parser, parser.nodeSet.types.find(t => t.name == "Document")!)
return new Language(
data,
parser,
parser.nodeSet.types.find((t) => t.name == "Document")!
);
}
/// Language support for strict CommonMark.
export const commonmarkLanguage = mkLang(commonmark)
export const commonmarkLanguage = mkLang(commonmark);
const extended = commonmark.configure([GFM, Subscript, Superscript, Emoji, {
const extended = commonmark.configure([
GFM,
Subscript,
Superscript,
Emoji,
{
props: [
styleTags({
"TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark": t.processingInstruction,
"TableHeader/...": t.heading,
"Strikethrough/...": t.strikethrough,
TaskMarker: t.atom,
Task: t.list,
Emoji: t.character,
"Subscript Superscript": t.special(t.content),
TableCell: t.content
})
]
}])
styleTags({
"TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark":
t.processingInstruction,
"TableHeader/...": t.heading,
"Strikethrough/...": t.strikethrough,
TaskMarker: t.atom,
Task: t.list,
Emoji: t.character,
"Subscript Superscript": t.special(t.content),
TableCell: t.content,
}),
],
},
]);
/// Language support for [GFM](https://github.github.com/gfm/) plus
/// subscript, superscript, and emoji syntax.
export const markdownLanguage = mkLang(extended)
export const markdownLanguage = mkLang(extended);
export function getCodeParser(languages: readonly LanguageDescription[],
defaultLanguage?: Language) {
return (info: string) => {
let found = info && LanguageDescription.matchLanguageName(languages, info, true)
if (!found) return defaultLanguage ? defaultLanguage.parser : null
if (found.support) return found.support.language.parser
return ParseContext.getSkippingParser(found.load())
}
export function getCodeParser(
languages: readonly LanguageDescription[],
defaultLanguage?: Language
) {
return (info: string) => {
let found =
info && LanguageDescription.matchLanguageName(languages, info, true);
if (!found) return defaultLanguage ? defaultLanguage.parser : null;
if (found.support) return found.support.language.parser;
return ParseContext.getSkippingParser(found.load());
};
}
+15 -5
View File
@@ -10,14 +10,24 @@ export default function reducer(
return {
...state,
allPages: new Set(
[...state.allPages].map((pageMeta) =>
pageMeta.name === action.name
? { ...pageMeta, lastOpened: Date.now() }
: pageMeta
)
[...state.allPages].map((pageMeta) =>
pageMeta.name === action.name
? {...pageMeta, lastOpened: Date.now()}
: pageMeta
)
),
currentPage: action.name,
};
case "page-changed":
return {
...state,
unsavedChanges: true,
};
case "page-saved":
return {
...state,
unsavedChanges: false,
};
case "start-navigate":
return {
...state,
+225 -126
View File
@@ -1,167 +1,266 @@
import { PageMeta } from "./types";
import { Socket } from "socket.io-client";
import { Update } from "@codemirror/collab";
import { ChangeSet, Text, Transaction } from "@codemirror/state";
import { CollabDocument, CollabEvents } from "./collab";
import { cursorEffect } from "./cursorEffect";
import { EventEmitter } from "../common/event";
import { Manifest } from "../common/manifest";
import { SystemJSON } from "../plugos/system";
import { safeRun } from "./util";
import { Plug } from "../plugos/plug";
export type SpaceEvents = {
connect: () => void;
pageCreated: (meta: PageMeta) => void;
pageChanged: (meta: PageMeta) => void;
pageDeleted: (name: string) => void;
pageListUpdated: (pages: Set<PageMeta>) => void;
loadSystem: (systemJSON: SystemJSON<any>) => void;
plugLoaded: (plugName: string, plug: Manifest) => void;
plugUnloaded: (plugName: string) => void;
} & CollabEvents;
export type KV = {
key: string;
value: any;
};
type PlugMeta = {
name: string;
version: number;
};
const pageWatchInterval = 2000;
const plugWatchInterval = 5000;
export class Space extends EventEmitter<SpaceEvents> {
socket: Socket;
reqId = 0;
allPages = new Set<PageMeta>();
pageUrl: string;
pageMetaCache = new Map<string, PageMeta>();
plugMetaCache = new Map<string, PlugMeta>();
watchedPages = new Set<string>();
saving = false;
private plugUrl: string;
private initialPageListLoad = true;
private initialPlugListLoad = true;
constructor(socket: Socket) {
constructor(url: string) {
super();
this.socket = socket;
this.pageUrl = url + "/fs";
this.plugUrl = url + "/plug";
this.watch();
this.pollPlugs();
this.updatePageListAsync();
}
[
"connect",
"cursorSnapshot",
"pageCreated",
"pageChanged",
"pageDeleted",
"loadSystem",
"plugLoaded",
"plugUnloaded",
].forEach((eventName) => {
socket.on(eventName, (...args) => {
this.emit(eventName as keyof SpaceEvents, ...args);
public watchPage(pageName: string) {
this.watchedPages.add(pageName);
}
public unwatchPage(pageName: string) {
this.watchedPages.delete(pageName);
}
watch() {
setInterval(() => {
safeRun(async () => {
if (this.saving) {
return;
}
for (const pageName of this.watchedPages) {
const oldMeta = this.pageMetaCache.get(pageName);
if (!oldMeta) {
// No longer in cache, meaning probably deleted let's unwatch
this.watchedPages.delete(pageName);
continue;
}
const newMeta = await this.getPageMeta(pageName);
if (oldMeta.lastModified !== newMeta.lastModified) {
console.log("Page", pageName, "changed on disk, emitting event");
this.emit("pageChanged", newMeta);
}
}
});
});
this.wsCall("page.listPages").then((pages) => {
this.allPages = new Set(pages);
this.emit("pageListUpdated", this.allPages);
});
this.on({
pageCreated: (meta) => {
// Cannot reply on equivalence in set, need to iterate over all pages
let found = false;
for (const page of this.allPages) {
if (page.name === meta.name) {
found = true;
break;
}
}, pageWatchInterval);
setInterval(() => {
safeRun(this.pollPlugs.bind(this));
}, plugWatchInterval);
}
public updatePageListAsync() {
safeRun(async () => {
let req = await fetch(this.pageUrl, {
method: "GET",
});
let deletedPages = new Set<string>(this.pageMetaCache.keys());
((await req.json()) as any[]).forEach((meta: any) => {
const pageName = meta.name;
const oldPageMeta = this.pageMetaCache.get(pageName);
const newPageMeta = {
name: pageName,
lastModified: meta.lastModified,
};
if (!oldPageMeta && !this.initialPageListLoad) {
this.emit("pageCreated", newPageMeta);
} else if (
oldPageMeta &&
oldPageMeta.lastModified !== newPageMeta.lastModified
) {
this.emit("pageChanged", newPageMeta);
}
if (!found) {
this.allPages.add(meta);
console.log("New page created", meta);
this.emit("pageListUpdated", this.allPages);
}
},
pageDeleted: (name) => {
console.log("Page delete", name);
this.allPages.forEach((meta) => {
if (name === meta.name) {
this.allPages.delete(meta);
}
});
this.emit("pageListUpdated", this.allPages);
},
// Page found, not deleted
deletedPages.delete(pageName);
// Update in cache
this.pageMetaCache.set(pageName, newPageMeta);
});
for (const deletedPage of deletedPages) {
this.pageMetaCache.delete(deletedPage);
this.emit("pageDeleted", deletedPage);
}
this.emit("pageListUpdated", new Set([...this.pageMetaCache.values()]));
this.initialPageListLoad = false;
});
}
openRequests = new Map<number, string>();
public wsCall(eventName: string, ...args: any[]): Promise<any> {
return new Promise((resolve, reject) => {
this.reqId++;
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, reqId, ...args);
});
public async listPages(): Promise<Set<PageMeta>> {
// this.updatePageListAsync();
return new Set([...this.pageMetaCache.values()]);
}
async pushUpdates(
pageName: string,
version: number,
fullUpdates: readonly (Update & { origin: Transaction })[]
): Promise<boolean> {
if (this.socket) {
let updates = fullUpdates.map((u) => ({
clientID: u.clientID,
changes: u.changes.toJSON(),
cursors: u.effects?.map((e) => e.value),
}));
return this.wsCall("page.pushUpdates", pageName, version, updates);
private responseToMetaCacher(name: string, res: Response): PageMeta {
const meta = {
name,
lastModified: +(res.headers.get("Last-Modified") || "0"),
};
this.pageMetaCache.set(name, meta);
return meta;
}
public async readPage(
name: string
): Promise<{ text: string; meta: PageMeta }> {
let res = await fetch(`${this.pageUrl}/${name}`, {
method: "GET",
});
return {
text: await res.text(),
meta: this.responseToMetaCacher(name, res),
};
}
public async writePage(
name: string,
text: string,
selfUpdate?: boolean
): Promise<PageMeta> {
try {
this.saving = true;
let res = await fetch(`${this.pageUrl}/${name}`, {
method: "PUT",
body: text,
});
const newMeta = this.responseToMetaCacher(name, res);
if (!selfUpdate) {
this.emit("pageChanged", newMeta);
}
return newMeta;
} finally {
this.saving = false;
}
return false;
}
async pullUpdates(
pageName: string,
version: number
): Promise<readonly Update[]> {
let updates: Update[] = await this.wsCall(
"page.pullUpdates",
pageName,
version
);
return updates.map((u) => ({
changes: ChangeSet.fromJSON(u.changes),
effects: u.effects?.map((e) => cursorEffect.of(e.value)),
clientID: u.clientID,
}));
public async deletePage(name: string): Promise<void> {
let req = await fetch(`${this.pageUrl}/${name}`, {
method: "DELETE",
});
if (req.status !== 200) {
throw Error(`Failed to delete page: ${req.statusText}`);
}
this.pageMetaCache.delete(name);
this.emit("pageDeleted", name);
this.emit("pageListUpdated", new Set([...this.pageMetaCache.values()]));
}
async listPages(): Promise<PageMeta[]> {
return Array.from(this.allPages);
private async getPageMeta(name: string): Promise<PageMeta> {
let res = await fetch(`${this.pageUrl}/${name}`, {
method: "OPTIONS",
});
return this.responseToMetaCacher(name, res);
}
async openPage(name: string): Promise<CollabDocument> {
this.reqId++;
let pageJSON = await this.wsCall("page.openPage", name);
return new CollabDocument(
Text.of(pageJSON.text),
pageJSON.version,
new Map(Object.entries(pageJSON.cursors))
);
async remoteSyscall(
plug: Plug<any>,
name: string,
args: any[]
): Promise<any> {
console.log("Making a remote syscall", name, args);
let req = await fetch(`${this.plugUrl}/${plug.name}/syscall/${name}`, {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify(args),
});
if (req.status !== 200) {
let error = await req.text();
throw Error(error);
}
if (req.headers.get("Content-length") === "0") {
return;
}
return await req.json();
}
async closePage(name: string): Promise<void> {
this.socket.emit("page.closePage", name);
async remoteInvoke(plug: Plug<any>, name: string, args: any[]): Promise<any> {
console.log("Making a remote syscall", name, JSON.stringify(args));
let req = await fetch(`${this.plugUrl}/${plug.name}/function/${name}`, {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify(args),
});
if (req.status !== 200) {
let error = await req.text();
throw Error(error);
}
if (req.headers.get("Content-length") === "0") {
return;
}
return await req.json();
}
async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
return this.wsCall("page.readPage", name);
private async pollPlugs(): Promise<void> {
const newPlugs = await this.loadPlugs();
let deletedPlugs = new Set<string>(this.plugMetaCache.keys());
for (const newPlugMeta of newPlugs) {
const oldPlugMeta = this.plugMetaCache.get(newPlugMeta.name);
if (
!oldPlugMeta ||
(oldPlugMeta && oldPlugMeta.version !== newPlugMeta.version)
) {
this.emit(
"plugLoaded",
newPlugMeta.name,
await this.loadPlug(newPlugMeta.name)
);
}
// Page found, not deleted
deletedPlugs.delete(newPlugMeta.name);
// Update in cache
this.plugMetaCache.set(newPlugMeta.name, newPlugMeta);
}
for (const deletedPlug of deletedPlugs) {
this.plugMetaCache.delete(deletedPlug);
this.emit("plugUnloaded", deletedPlug);
}
}
async writePage(name: string, text: string): Promise<PageMeta> {
return this.wsCall("page.writePage", name, text);
private async loadPlugs(): Promise<PlugMeta[]> {
let res = await fetch(`${this.plugUrl}`, {
method: "GET",
});
return (await res.json()) as PlugMeta[];
}
async deletePage(name: string): Promise<void> {
return this.wsCall("page.deletePage", name);
}
async getPageMeta(name: string): Promise<PageMeta> {
return this.wsCall("page.getPageMeta", name);
private async loadPlug(name: string): Promise<Manifest> {
let res = await fetch(`${this.plugUrl}/${name}`, {
method: "GET",
});
return (await res.json()) as Manifest;
}
}
+9
View File
@@ -68,7 +68,16 @@ body {
padding-left: 5px;
padding-right: 10px;
}
.icon.saved {
color: #015701;
}
.icon.unsaved {
color: #e19502;
}
}
}
#editor {
+1 -1
View File
@@ -12,6 +12,6 @@ export default function indexerSyscalls(space: Space): SysCallMapping {
"batchSet",
"delete",
],
(name, ...args) => space.wsCall(`index.${name}`, ...args)
(ctx, name, ...args) => space.remoteSyscall(ctx.plug, `index.${name}`, args)
);
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { PageMeta } from "../types";
import { SysCallMapping } from "../../plugos/system";
export default (editor: Editor): SysCallMapping => ({
listPages: (): PageMeta[] => {
return [...editor.viewState.allPages];
listPages: async (): Promise<PageMeta[]> => {
return [...(await editor.space.listPages())];
},
readPage: async (
ctx,
+1 -1
View File
@@ -7,7 +7,7 @@ export function systemSyscalls(space: Space): SysCallMapping {
if (!ctx.plug) {
throw Error("No plug associated with context");
}
return space.wsCall("invokeFunction", ctx.plug.name, name, ...args);
return space.remoteInvoke(ctx.plug, name, args);
},
};
}
+5
View File
@@ -5,6 +5,7 @@ export type PageMeta = {
lastModified: number;
version?: number;
lastOpened?: number;
created?: boolean;
};
export const slashCommandRegexp = /\/[\w\-]*/;
@@ -19,6 +20,7 @@ export type AppViewState = {
currentPage?: string;
showPageNavigator: boolean;
showCommandPalette: boolean;
unsavedChanges: boolean;
showRHS: boolean;
rhsHTML: string;
allPages: Set<PageMeta>;
@@ -29,6 +31,7 @@ export type AppViewState = {
export const initialViewState: AppViewState = {
showPageNavigator: false,
showCommandPalette: false,
unsavedChanges: false,
showRHS: false,
rhsHTML: "<h1>Loading...</h1>",
allPages: new Set(),
@@ -39,6 +42,8 @@ export const initialViewState: AppViewState = {
export type Action =
| { type: "page-loaded"; name: string }
| { type: "pages-listed"; pages: Set<PageMeta> }
| { type: "page-changed" }
| { type: "page-saved" }
| { type: "start-navigate" }
| { type: "stop-navigate" }
| { type: "update-commands"; commands: Map<string, AppCommand> }
-2
View File
@@ -1,2 +0,0 @@
import { Editor } from "./editor";
import { safeRun } from "./util";