Moving everything into a single repo and build
parent
4e570191a6
commit
7352c6c612
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": "@parcel/config-default",
|
||||||
|
"validators": {
|
||||||
|
"*.{ts,tsx}": ["@parcel/validator-typescript"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
.PHONY: core
|
||||||
|
|
||||||
|
BUILD=../plugbox/bin/plugbox-bundle.mjs
|
||||||
|
|
||||||
|
core: core/*
|
||||||
|
${BUILD} --debug core/core.plug.json ../webapp/src/generated/core.plug.json
|
||||||
|
|
||||||
|
watch: *
|
||||||
|
ls -d core/* | entr make
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import esbuild from "esbuild";
|
||||||
|
import { readFile, unlink, writeFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import yargs from "yargs";
|
||||||
|
import { hideBin } from "yargs/helpers";
|
||||||
|
|
||||||
|
async function compile(filePath, functionName, debug) {
|
||||||
|
let outFile = "out.js";
|
||||||
|
|
||||||
|
let inFile = filePath;
|
||||||
|
|
||||||
|
if (functionName) {
|
||||||
|
// Generate a new file importing just this one function and exporting it
|
||||||
|
inFile = "in.js";
|
||||||
|
await writeFile(
|
||||||
|
inFile,
|
||||||
|
`import {${functionName}} from "./${filePath}";
|
||||||
|
export default ${functionName};`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Figure out how to make source maps work correctly with eval() code
|
||||||
|
let js = await esbuild.build({
|
||||||
|
entryPoints: [inFile],
|
||||||
|
bundle: true,
|
||||||
|
format: "iife",
|
||||||
|
globalName: "mod",
|
||||||
|
platform: "neutral",
|
||||||
|
sourcemap: false, //sourceMap ? "inline" : false,
|
||||||
|
minify: !debug,
|
||||||
|
outfile: outFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
let jsCode = (await readFile(outFile)).toString();
|
||||||
|
jsCode = jsCode.replace(/^var mod ?= ?/, "");
|
||||||
|
await unlink(outFile);
|
||||||
|
if (inFile !== filePath) {
|
||||||
|
await unlink(inFile);
|
||||||
|
}
|
||||||
|
// Strip final ';'
|
||||||
|
return jsCode.substring(0, jsCode.length - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bundle(manifestPath, sourceMaps) {
|
||||||
|
const rootPath = path.dirname(manifestPath);
|
||||||
|
const manifest = JSON.parse((await readFile(manifestPath)).toString());
|
||||||
|
|
||||||
|
for (let [name, def] of Object.entries(manifest.functions)) {
|
||||||
|
let jsFunctionName = def.functionName,
|
||||||
|
filePath = path.join(rootPath, def.path);
|
||||||
|
if (filePath.indexOf(":") !== -1) {
|
||||||
|
[filePath, jsFunctionName] = filePath.split(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
def.code = await compile(filePath, jsFunctionName, sourceMaps);
|
||||||
|
delete def.path;
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
async function run() {
|
||||||
|
let args = await yargs(hideBin(process.argv))
|
||||||
|
.option("debug", {
|
||||||
|
type: "boolean",
|
||||||
|
})
|
||||||
|
.parse();
|
||||||
|
|
||||||
|
let generatedManifest = await bundle(args._[0], !!args.debug);
|
||||||
|
await writeFile(args._[1], JSON.stringify(generatedManifest, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
|
@ -0,0 +1,12 @@
|
||||||
|
export default {
|
||||||
|
extensionsToTreatAsEsm: [".ts"],
|
||||||
|
preset: "ts-jest/presets/default-esm", // or other ESM presets
|
||||||
|
globals: {
|
||||||
|
"ts-jest": {
|
||||||
|
useESM: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,94 @@
|
||||||
|
{
|
||||||
|
"name": "silverbullet",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"resolutions": {
|
||||||
|
"@lezer/common": "https://github.com/zefhemel/common.git#046c880d1fcab713cadad327a5b7d8bb5de6522c"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"watch": "parcel watch",
|
||||||
|
"build": "parcel build",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"server": "nodemon dist/server/server.js pages"
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"webapp": {
|
||||||
|
"source": [
|
||||||
|
"webapp/index.html"
|
||||||
|
],
|
||||||
|
"context": "browser"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"source": [
|
||||||
|
"server/server.ts"
|
||||||
|
],
|
||||||
|
"outputFormat": "commonjs",
|
||||||
|
"isLibrary": true,
|
||||||
|
"context": "node"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/basic-setup": "^0.19.1",
|
||||||
|
"@codemirror/collab": "^0.19.0",
|
||||||
|
"@codemirror/commands": "^0.19.8",
|
||||||
|
"@codemirror/lang-markdown": "^0.19.6",
|
||||||
|
"@codemirror/state": "^0.19.7",
|
||||||
|
"@codemirror/view": "^0.19.42",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "6.0.0",
|
||||||
|
"@fortawesome/react-fontawesome": "0.1.17",
|
||||||
|
"@parcel/optimizer-data-url": "2.3.2",
|
||||||
|
"@parcel/service-worker": "^2.3.2",
|
||||||
|
"@parcel/transformer-inline-string": "2.3.2",
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/express": "^4.17.13",
|
||||||
|
"dexie": "^3.2.1",
|
||||||
|
"idb": "^7.0.0",
|
||||||
|
"jest": "^27.5.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"nodemon": "^2.0.15",
|
||||||
|
"parcel": "^2.3.2",
|
||||||
|
"socket.io-client": "^4.4.1",
|
||||||
|
"ts-jest": "^27.1.3",
|
||||||
|
"express": "^4.17.3",
|
||||||
|
"vm2": "^3.9.9",
|
||||||
|
"yargs": "^17.3.1",
|
||||||
|
"better-sqlite3": "^7.5.0",
|
||||||
|
"body-parser": "^1.19.2",
|
||||||
|
"browserify-zlib": "^0.2.0",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"knex": "^1.0.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"socket.io": "^4.4.1",
|
||||||
|
"socket.io-client": "^4.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@parcel/packager-raw-url": "2.3.2",
|
||||||
|
"@parcel/service-worker": "^2.3.2",
|
||||||
|
"@parcel/transformer-inline-string": "2.3.2",
|
||||||
|
"@parcel/transformer-sass": "2.3.2",
|
||||||
|
"@parcel/transformer-webmanifest": "2.3.2",
|
||||||
|
"@parcel/validator-typescript": "^2.3.2",
|
||||||
|
"@types/events": "^3.0.0",
|
||||||
|
"@types/jest": "^27.4.1",
|
||||||
|
"@types/node": "^17.0.21",
|
||||||
|
"@types/react": "^17.0.39",
|
||||||
|
"@types/react-dom": "^17.0.11",
|
||||||
|
"@vscode/sqlite3": "^5.0.7",
|
||||||
|
"assert": "^2.0.0",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"parcel": "^2.3.2",
|
||||||
|
"prettier": "^2.5.1",
|
||||||
|
"querystring-es3": "^0.2.1",
|
||||||
|
"stream-browserify": "^3.0.0",
|
||||||
|
"stream-http": "^3.2.0",
|
||||||
|
"timers-browserify": "^2.0.12",
|
||||||
|
"tty-browserify": "^0.0.1",
|
||||||
|
"typescript": "^4.6.2",
|
||||||
|
"uglify-js": "^3.15.1",
|
||||||
|
"url": "^0.11.0",
|
||||||
|
"util": "^0.12.4"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<script type="module">
|
||||||
|
// Sup yo!
|
||||||
|
import "./sandbox_worker";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { ControllerMessage, WorkerLike, WorkerMessage } from "./types";
|
||||||
|
import { Sandbox, System } from "./runtime";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import sandboxHtml from "bundle-text:./iframe_sandbox.html";
|
||||||
|
|
||||||
|
class IFrameWrapper implements WorkerLike {
|
||||||
|
private iframe: HTMLIFrameElement;
|
||||||
|
onMessage?: (message: any) => Promise<void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const iframe = document.createElement("iframe", {});
|
||||||
|
this.iframe = iframe;
|
||||||
|
iframe.style.display = "none";
|
||||||
|
// Let's lock this down significantly
|
||||||
|
iframe.setAttribute("sandbox", "allow-scripts");
|
||||||
|
iframe.srcdoc = sandboxHtml;
|
||||||
|
window.addEventListener("message", (evt: any) => {
|
||||||
|
if (evt.source !== iframe.contentWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = evt.data;
|
||||||
|
if (!data) return;
|
||||||
|
safeRun(async () => {
|
||||||
|
await this.onMessage!(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(message: any): void {
|
||||||
|
this.iframe.contentWindow!.postMessage(message, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
return this.iframe.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSandbox(system: System<any>) {
|
||||||
|
return new Sandbox(system, new IFrameWrapper());
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { ControllerMessage, WorkerLike, WorkerMessage } from "./types";
|
||||||
|
import { System, Sandbox } from "./runtime";
|
||||||
|
|
||||||
|
import { Worker } from "worker_threads";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import workerCode from "bundle-text:./node_worker.ts"
|
||||||
|
|
||||||
|
// ParcelJS will simply inline this into the bundle.
|
||||||
|
// const workerCode = fs.readFileSync(__dirname + "/node_worker.ts", "utf-8");
|
||||||
|
|
||||||
|
class NodeWorkerWrapper implements WorkerLike {
|
||||||
|
onMessage?: (message: any) => Promise<void>;
|
||||||
|
private worker: Worker;
|
||||||
|
|
||||||
|
constructor(worker: Worker) {
|
||||||
|
this.worker = worker;
|
||||||
|
worker.on("message", (message: any) => {
|
||||||
|
safeRun(async () => {
|
||||||
|
await this.onMessage!(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(message: any): void {
|
||||||
|
this.worker.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(): void {
|
||||||
|
this.worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSandbox(system: System<any>) {
|
||||||
|
return new Sandbox(
|
||||||
|
system,
|
||||||
|
new NodeWorkerWrapper(
|
||||||
|
new Worker(workerCode, {
|
||||||
|
eval: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
const { VM, VMScript } = require("vm2");
|
||||||
|
const { parentPort } = require("worker_threads");
|
||||||
|
|
||||||
|
let loadedFunctions = new Map<string, Function>();
|
||||||
|
let pendingRequests = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
resolve: (result: unknown) => void;
|
||||||
|
reject: (e: any) => void;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
let vm = new VM({
|
||||||
|
sandbox: {
|
||||||
|
console: console,
|
||||||
|
self: {
|
||||||
|
syscall: (reqId : number, name : string, args: any[]) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.set(reqId, { resolve, reject });
|
||||||
|
parentPort.postMessage({
|
||||||
|
type: "syscall",
|
||||||
|
id: reqId,
|
||||||
|
name,
|
||||||
|
// TODO: Figure out why this is necessary (to avoide a CloneError)
|
||||||
|
args: JSON.parse(JSON.stringify(args)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function wrapScript(code : string) {
|
||||||
|
return `(${code})["default"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRun(fn : any) {
|
||||||
|
fn().catch((e : any) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort.on("message", (data : any) => {
|
||||||
|
safeRun(async () => {
|
||||||
|
switch (data.type) {
|
||||||
|
case "load":
|
||||||
|
console.log("Booting", data.name);
|
||||||
|
loadedFunctions.set(data.name, new VMScript(wrapScript(data.code)));
|
||||||
|
parentPort.postMessage({
|
||||||
|
type: "inited",
|
||||||
|
name: data.name,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "invoke":
|
||||||
|
let fn = loadedFunctions.get(data.name);
|
||||||
|
if (!fn) {
|
||||||
|
throw new Error(`Function not loaded: ${data.name}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let r = vm.run(fn);
|
||||||
|
let result = await Promise.resolve(r(...data.args));
|
||||||
|
parentPort.postMessage({
|
||||||
|
type: "result",
|
||||||
|
id: data.id,
|
||||||
|
result: result,
|
||||||
|
});
|
||||||
|
} catch (e : any) {
|
||||||
|
console.log("ERROR", e);
|
||||||
|
parentPort.postMessage({
|
||||||
|
type: "result",
|
||||||
|
id: data.id,
|
||||||
|
error: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "syscall-response":
|
||||||
|
let syscallId = data.id;
|
||||||
|
const lookup = pendingRequests.get(syscallId);
|
||||||
|
if (!lookup) {
|
||||||
|
console.log(
|
||||||
|
"Current outstanding requests",
|
||||||
|
pendingRequests,
|
||||||
|
"looking up",
|
||||||
|
syscallId
|
||||||
|
);
|
||||||
|
throw Error("Invalid request id");
|
||||||
|
}
|
||||||
|
pendingRequests.delete(syscallId);
|
||||||
|
if (data.error) {
|
||||||
|
lookup.reject(new Error(data.error));
|
||||||
|
} else {
|
||||||
|
lookup.resolve(data.result);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { createSandbox } from "./node_sandbox";
|
||||||
|
import { System } from "./runtime";
|
||||||
|
import { test, expect } from "@jest/globals";
|
||||||
|
|
||||||
|
test("Run a Node sandbox", async () => {
|
||||||
|
let system = new System();
|
||||||
|
system.registerSyscalls({
|
||||||
|
addNumbers: (a, b) => {
|
||||||
|
return a + b;
|
||||||
|
},
|
||||||
|
failingSyscall: () => {
|
||||||
|
throw new Error("#fail");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let plug = await system.load(
|
||||||
|
"test",
|
||||||
|
{
|
||||||
|
functions: {
|
||||||
|
addTen: {
|
||||||
|
code: `(() => {
|
||||||
|
return {
|
||||||
|
default: (n) => {
|
||||||
|
return n + 10;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
},
|
||||||
|
addNumbersSyscall: {
|
||||||
|
code: `(() => {
|
||||||
|
return {
|
||||||
|
default: async (a, b) => {
|
||||||
|
return await self.syscall(1, "addNumbers", [a, b]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
},
|
||||||
|
errorOut: {
|
||||||
|
code: `(() => {
|
||||||
|
return {
|
||||||
|
default: () => {
|
||||||
|
throw Error("BOOM");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
},
|
||||||
|
errorOutSys: {
|
||||||
|
code: `(() => {
|
||||||
|
return {
|
||||||
|
default: async () => {
|
||||||
|
await self.syscall(2, "failingSyscall", []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
events: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createSandbox(system)
|
||||||
|
);
|
||||||
|
expect(await plug.invoke("addTen", [10])).toBe(20);
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
expect(await plug.invoke("addNumbersSyscall", [10, i])).toBe(10 + i);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await plug.invoke("errorOut", []);
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).toBe("BOOM");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await plug.invoke("errorOutSys", []);
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).toBe("#fail");
|
||||||
|
}
|
||||||
|
await system.stop();
|
||||||
|
});
|
|
@ -0,0 +1,193 @@
|
||||||
|
import {
|
||||||
|
ControllerMessage,
|
||||||
|
Manifest,
|
||||||
|
WorkerLike,
|
||||||
|
WorkerMessage,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface SysCallMapping {
|
||||||
|
[key: string]: (...args: any) => Promise<any> | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sandbox {
|
||||||
|
protected worker: WorkerLike;
|
||||||
|
protected reqId = 0;
|
||||||
|
protected outstandingInits = new Map<string, () => void>();
|
||||||
|
protected outstandingInvocations = new Map<
|
||||||
|
number,
|
||||||
|
{ resolve: (result: any) => void; reject: (e: any) => void }
|
||||||
|
>();
|
||||||
|
protected loadedFunctions = new Set<string>();
|
||||||
|
protected system: System<any>;
|
||||||
|
|
||||||
|
constructor(system: System<any>, worker: WorkerLike) {
|
||||||
|
worker.onMessage = this.onMessage.bind(this);
|
||||||
|
this.worker = worker;
|
||||||
|
this.system = system;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded(name: string) {
|
||||||
|
return this.loadedFunctions.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(name: string, code: string): Promise<void> {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "load",
|
||||||
|
name: name,
|
||||||
|
code: code,
|
||||||
|
} as WorkerMessage);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.loadedFunctions.add(name);
|
||||||
|
this.outstandingInits.set(name, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onMessage(data: ControllerMessage) {
|
||||||
|
switch (data.type) {
|
||||||
|
case "inited":
|
||||||
|
let initCb = this.outstandingInits.get(data.name!);
|
||||||
|
initCb && initCb();
|
||||||
|
this.outstandingInits.delete(data.name!);
|
||||||
|
break;
|
||||||
|
case "syscall":
|
||||||
|
try {
|
||||||
|
let result = await this.system.syscall(data.name!, data.args!);
|
||||||
|
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "syscall-response",
|
||||||
|
id: data.id,
|
||||||
|
result: result,
|
||||||
|
} as WorkerMessage);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "syscall-response",
|
||||||
|
id: data.id,
|
||||||
|
error: e.message,
|
||||||
|
} as WorkerMessage);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "result":
|
||||||
|
let resultCbs = this.outstandingInvocations.get(data.id!);
|
||||||
|
this.outstandingInvocations.delete(data.id!);
|
||||||
|
if (data.error) {
|
||||||
|
resultCbs && resultCbs.reject(new Error(data.error));
|
||||||
|
} else {
|
||||||
|
resultCbs && resultCbs.resolve(data.result);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("Unknown message type", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(name: string, args: any[]): Promise<any> {
|
||||||
|
this.reqId++;
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "invoke",
|
||||||
|
id: this.reqId,
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.outstandingInvocations.set(this.reqId, { resolve, reject });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Plug<HookT> {
|
||||||
|
system: System<HookT>;
|
||||||
|
sandbox: Sandbox;
|
||||||
|
public manifest?: Manifest<HookT>;
|
||||||
|
|
||||||
|
constructor(system: System<HookT>, name: string, sandbox: Sandbox) {
|
||||||
|
this.system = system;
|
||||||
|
this.sandbox = sandbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(manifest: Manifest<HookT>) {
|
||||||
|
this.manifest = manifest;
|
||||||
|
await this.dispatchEvent("load");
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(name: string, args: Array<any>): Promise<any> {
|
||||||
|
if (!this.sandbox.isLoaded(name)) {
|
||||||
|
const funDef = this.manifest!.functions[name];
|
||||||
|
if (!funDef) {
|
||||||
|
throw new Error(`Function ${name} not found in manifest`);
|
||||||
|
}
|
||||||
|
await this.sandbox.load(name, this.manifest!.functions[name].code!);
|
||||||
|
}
|
||||||
|
return await this.sandbox.invoke(name, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchEvent(name: string, data?: any): Promise<any[]> {
|
||||||
|
let functionsToSpawn = this.manifest!.hooks.events[name];
|
||||||
|
if (functionsToSpawn) {
|
||||||
|
return await Promise.all(
|
||||||
|
functionsToSpawn.map(
|
||||||
|
async (functionToSpawn: string) =>
|
||||||
|
await this.invoke(functionToSpawn, [data])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this.sandbox.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class System<HookT> {
|
||||||
|
protected plugs = new Map<string, Plug<HookT>>();
|
||||||
|
registeredSyscalls: SysCallMapping = {};
|
||||||
|
|
||||||
|
registerSyscalls(...registrationObjects: SysCallMapping[]) {
|
||||||
|
for (const registrationObject of registrationObjects) {
|
||||||
|
for (let p in registrationObject) {
|
||||||
|
this.registeredSyscalls[p] = registrationObject[p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syscall(name: string, args: Array<any>): Promise<any> {
|
||||||
|
const callback = this.registeredSyscalls[name];
|
||||||
|
if (!name) {
|
||||||
|
throw Error(`Unregistered syscall ${name}`);
|
||||||
|
}
|
||||||
|
if (!callback) {
|
||||||
|
throw Error(`Registered but not implemented syscall ${name}`);
|
||||||
|
}
|
||||||
|
return Promise.resolve(callback(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(
|
||||||
|
name: string,
|
||||||
|
manifest: Manifest<HookT>,
|
||||||
|
sandbox: Sandbox
|
||||||
|
): Promise<Plug<HookT>> {
|
||||||
|
const plug = new Plug(this, name, sandbox);
|
||||||
|
await plug.load(manifest);
|
||||||
|
this.plugs.set(name, plug);
|
||||||
|
return plug;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchEvent(name: string, data?: any): Promise<any[]> {
|
||||||
|
let promises = [];
|
||||||
|
for (let plug of this.plugs.values()) {
|
||||||
|
promises.push(plug.dispatchEvent(name, data));
|
||||||
|
}
|
||||||
|
return await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void[]> {
|
||||||
|
return Promise.all(
|
||||||
|
Array.from(this.plugs.values()).map((plug) => plug.stop())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { ControllerMessage, WorkerMessage, WorkerMessageType } from "./types";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
|
||||||
|
let loadedFunctions = new Map<string, Function>();
|
||||||
|
let pendingRequests = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
resolve: (result: unknown) => void;
|
||||||
|
reject: (e: any) => void;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
function syscall(id: number, name: string, args: any[]): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let postMessage = self.postMessage.bind(self);
|
||||||
|
|
||||||
|
if (window.parent !== window) {
|
||||||
|
console.log("running in an iframe");
|
||||||
|
postMessage = window.parent.postMessage.bind(window.parent);
|
||||||
|
// postMessage({ type: "test" }, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.syscall = async (id: number, name: string, args: any[]) => {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
pendingRequests.set(id, { resolve, reject });
|
||||||
|
postMessage(
|
||||||
|
{
|
||||||
|
type: "syscall",
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
},
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function wrapScript(code: string): string {
|
||||||
|
return `const fn = ${code};
|
||||||
|
return fn["default"].apply(null, arguments);`;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("message", (event: { data: WorkerMessage }) => {
|
||||||
|
// console.log("Got a message", event.data);
|
||||||
|
safeRun(async () => {
|
||||||
|
let messageEvent = event;
|
||||||
|
let data = messageEvent.data;
|
||||||
|
switch (data.type) {
|
||||||
|
case "load":
|
||||||
|
console.log("Booting", data.name);
|
||||||
|
loadedFunctions.set(data.name!, new Function(wrapScript(data.code!)));
|
||||||
|
postMessage(
|
||||||
|
{
|
||||||
|
type: "inited",
|
||||||
|
name: data.name,
|
||||||
|
} as ControllerMessage,
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "invoke":
|
||||||
|
let fn = loadedFunctions.get(data.name!);
|
||||||
|
if (!fn) {
|
||||||
|
throw new Error(`Function not loaded: ${data.name}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let result = await Promise.resolve(fn(...(data.args || [])));
|
||||||
|
postMessage(
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
id: data.id,
|
||||||
|
result: result,
|
||||||
|
} as ControllerMessage,
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
postMessage(
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
id: data.id,
|
||||||
|
error: e.message,
|
||||||
|
} as ControllerMessage,
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "syscall-response":
|
||||||
|
let syscallId = data.id!;
|
||||||
|
const lookup = pendingRequests.get(syscallId);
|
||||||
|
if (!lookup) {
|
||||||
|
console.log(
|
||||||
|
"Current outstanding requests",
|
||||||
|
pendingRequests,
|
||||||
|
"looking up",
|
||||||
|
syscallId
|
||||||
|
);
|
||||||
|
throw Error("Invalid request id");
|
||||||
|
}
|
||||||
|
pendingRequests.delete(syscallId);
|
||||||
|
if (data.error) {
|
||||||
|
lookup.reject(new Error(data.error));
|
||||||
|
} else {
|
||||||
|
lookup.resolve(data.result);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,44 @@
|
||||||
|
export type EventHook = {
|
||||||
|
events: { [key: string]: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerMessageType = "load" | "invoke" | "syscall-response";
|
||||||
|
|
||||||
|
export type WorkerMessage = {
|
||||||
|
type: WorkerMessageType;
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
args?: any[];
|
||||||
|
result?: any;
|
||||||
|
error?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ControllerMessageType = "inited" | "result" | "syscall";
|
||||||
|
|
||||||
|
export type ControllerMessage = {
|
||||||
|
type: ControllerMessageType;
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
args?: any[];
|
||||||
|
error?: string;
|
||||||
|
result?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Manifest<HookT> {
|
||||||
|
hooks: HookT & EventHook;
|
||||||
|
functions: {
|
||||||
|
[key: string]: FunctionDef;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionDef {
|
||||||
|
path?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerLike {
|
||||||
|
onMessage?: (message: any) => Promise<void>;
|
||||||
|
postMessage(message: any): void;
|
||||||
|
terminate(): void;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export function safeRun(fn: () => Promise<void>) {
|
||||||
|
fn().catch((e) => {
|
||||||
|
// console.error(e);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { ControllerMessage, WorkerLike, WorkerMessage } from "./types";
|
||||||
|
import { Sandbox, System } from "./runtime";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
|
||||||
|
class WebWorkerWrapper implements WorkerLike {
|
||||||
|
private worker: Worker;
|
||||||
|
onMessage?: (message: any) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(worker: Worker) {
|
||||||
|
this.worker = worker;
|
||||||
|
this.worker.addEventListener("message", (evt: any) => {
|
||||||
|
let data = evt.data;
|
||||||
|
if (!data) return;
|
||||||
|
safeRun(async () => {
|
||||||
|
await this.onMessage!(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
postMessage(message: any): void {
|
||||||
|
this.worker.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate() {
|
||||||
|
return this.worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSandbox(system: System<any>) {
|
||||||
|
// ParcelJS will build this file into a worker.
|
||||||
|
let worker = new Worker(new URL("sandbox_worker.ts", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
});
|
||||||
|
return new Sandbox(system, new WebWorkerWrapper(worker));
|
||||||
|
}
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { test, expect, beforeAll, afterAll, describe } from "@jest/globals";
|
||||||
|
|
||||||
|
import { createServer } from "http";
|
||||||
|
import { io as Client } from "socket.io-client";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { SocketServer } from "./api_server";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
describe("Server test", () => {
|
||||||
|
let io: Server,
|
||||||
|
socketServer: SocketServer,
|
||||||
|
clientSocket: any,
|
||||||
|
reqId = 0;
|
||||||
|
const tmpDir = path.join(__dirname, "test");
|
||||||
|
|
||||||
|
function wsCall(eventName: string, ...args: any[]): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reqId++;
|
||||||
|
clientSocket.once(`${eventName}Resp${reqId}`, (err: any, result: any) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clientSocket.emit(eventName, reqId, ...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
const httpServer = createServer();
|
||||||
|
io = new Server(httpServer);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(`${tmpDir}/test.md`, "This is a simple test");
|
||||||
|
httpServer.listen(async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
const port = httpServer.address().port;
|
||||||
|
// @ts-ignore
|
||||||
|
clientSocket = new Client(`http://localhost:${port}`);
|
||||||
|
socketServer = new SocketServer(tmpDir, io);
|
||||||
|
clientSocket.on("connect", done);
|
||||||
|
await socketServer.init();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
io.close();
|
||||||
|
clientSocket.close();
|
||||||
|
socketServer.close();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("List pages", async () => {
|
||||||
|
let pages = await wsCall("page.listPages");
|
||||||
|
expect(pages.length).toBe(1);
|
||||||
|
await wsCall("page.writePage", "test2.md", "This is another test");
|
||||||
|
let pages2 = await wsCall("page.listPages");
|
||||||
|
expect(pages2.length).toBe(2);
|
||||||
|
await wsCall("page.deletePage", "test2.md");
|
||||||
|
let pages3 = await wsCall("page.listPages");
|
||||||
|
expect(pages3.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Index operations", async () => {
|
||||||
|
await wsCall("index.clearPageIndexForPage", "test");
|
||||||
|
await wsCall("index.set", "test", "testkey", "value");
|
||||||
|
expect(await wsCall("index.get", "test", "testkey")).toBe("value");
|
||||||
|
await wsCall("index.delete", "test", "testkey");
|
||||||
|
expect(await wsCall("index.get", "test", "testkey")).toBe(null);
|
||||||
|
await wsCall("index.set", "test", "unrelated", 10);
|
||||||
|
await wsCall("index.set", "test", "unrelated", 12);
|
||||||
|
await wsCall("index.set", "test2", "complicated", {
|
||||||
|
name: "Bla",
|
||||||
|
age: 123123,
|
||||||
|
});
|
||||||
|
await wsCall("index.set", "test", "complicated", { name: "Bla", age: 100 });
|
||||||
|
await wsCall("index.set", "test", "complicated2", {
|
||||||
|
name: "Bla",
|
||||||
|
age: 101,
|
||||||
|
});
|
||||||
|
expect(await wsCall("index.get", "test", "complicated")).toStrictEqual({
|
||||||
|
name: "Bla",
|
||||||
|
age: 100,
|
||||||
|
});
|
||||||
|
let result = await wsCall("index.scanPrefixForPage", "test", "compli");
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
let result2 = await wsCall("index.scanPrefixGlobal", "compli");
|
||||||
|
expect(result2.length).toBe(3);
|
||||||
|
await wsCall("index.deletePrefixForPage", "test", "compli");
|
||||||
|
let result3 = await wsCall("index.scanPrefixForPage", "test", "compli");
|
||||||
|
expect(result3.length).toBe(0);
|
||||||
|
let result4 = await wsCall("index.scanPrefixGlobal", "compli");
|
||||||
|
expect(result4.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
import { Page } from "./types";
|
||||||
|
import * as path from "path";
|
||||||
|
import { IndexApi } from "./index_api";
|
||||||
|
import { PageApi } from "./page_api";
|
||||||
|
import { System } from "../plugbox/runtime";
|
||||||
|
import { createSandbox } from "../plugbox/node_sandbox";
|
||||||
|
import { NuggetHook } from "../webapp/types";
|
||||||
|
import corePlug from "../webapp/generated/core.plug.json";
|
||||||
|
import pageIndexSyscalls from "./syscalls/page_index";
|
||||||
|
|
||||||
|
export class ClientConnection {
|
||||||
|
openPages = new Set<string>();
|
||||||
|
constructor(readonly sock: Socket) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiProvider {
|
||||||
|
init(): Promise<void>;
|
||||||
|
api(): Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SocketServer {
|
||||||
|
private openPages = new Map<string, Page>();
|
||||||
|
private connectedSockets = new Set<Socket>();
|
||||||
|
private apis = new Map<string, ApiProvider>();
|
||||||
|
readonly rootPath: string;
|
||||||
|
private serverSocket: Server;
|
||||||
|
system: System<NuggetHook>;
|
||||||
|
|
||||||
|
constructor(rootPath: string, serverSocket: Server) {
|
||||||
|
this.rootPath = path.resolve(rootPath);
|
||||||
|
this.serverSocket = serverSocket;
|
||||||
|
this.system = new System<NuggetHook>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerApi(name: string, apiProvider: ApiProvider) {
|
||||||
|
await apiProvider.init();
|
||||||
|
this.apis.set(name, apiProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init() {
|
||||||
|
const indexApi = new IndexApi(this.rootPath);
|
||||||
|
await this.registerApi("index", indexApi);
|
||||||
|
this.system.registerSyscalls(pageIndexSyscalls(indexApi.db));
|
||||||
|
await this.registerApi(
|
||||||
|
"page",
|
||||||
|
new PageApi(
|
||||||
|
this.rootPath,
|
||||||
|
this.connectedSockets,
|
||||||
|
this.openPages,
|
||||||
|
this.system
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let plug = await this.system.load(
|
||||||
|
"core",
|
||||||
|
corePlug,
|
||||||
|
createSandbox(this.system)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.serverSocket.on("connection", (socket) => {
|
||||||
|
const clientConn = new ClientConnection(socket);
|
||||||
|
|
||||||
|
console.log("Connected", socket.id);
|
||||||
|
this.connectedSockets.add(socket);
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("Disconnected", socket.id);
|
||||||
|
clientConn.openPages.forEach(disconnectPageSocket);
|
||||||
|
this.connectedSockets.delete(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("page.closePage", (pageName: string) => {
|
||||||
|
console.log("Client closed page", pageName);
|
||||||
|
disconnectPageSocket(pageName);
|
||||||
|
clientConn.openPages.delete(pageName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCall = (
|
||||||
|
eventName: string,
|
||||||
|
cb: (...args: any[]) => Promise<any>
|
||||||
|
) => {
|
||||||
|
socket.on(eventName, (reqId: number, ...args) => {
|
||||||
|
cb(...args)
|
||||||
|
.then((result) => {
|
||||||
|
socket.emit(`${eventName}Resp${reqId}`, null, result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
socket.emit(`${eventName}Resp${reqId}`, err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectPageSocket = (pageName: string) => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
if (page) {
|
||||||
|
for (let client of page.clientStates) {
|
||||||
|
if (client.socket === socket) {
|
||||||
|
(this.apis.get("page")! as PageApi).disconnectClient(
|
||||||
|
client,
|
||||||
|
page
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (let [apiName, apiProvider] of this.apis) {
|
||||||
|
Object.entries(apiProvider.api()).forEach(([eventName, cb]) => {
|
||||||
|
onCall(`${apiName}.${eventName}`, (...args: any[]): any => {
|
||||||
|
// @ts-ignore
|
||||||
|
return cb(clientConn, ...args);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
(this.apis.get("index")! as IndexApi).db.destroy();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { readdir, readFile, stat, unlink, writeFile } from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
import { PageMeta } from "./types";
|
||||||
|
|
||||||
|
export class DiskStorage {
|
||||||
|
rootPath: string;
|
||||||
|
|
||||||
|
constructor(rootPath: string) {
|
||||||
|
this.rootPath = rootPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPages(): Promise<PageMeta[]> {
|
||||||
|
let fileNames: PageMeta[] = [];
|
||||||
|
|
||||||
|
const walkPath = async (dir: string) => {
|
||||||
|
let files = await readdir(dir);
|
||||||
|
for (let file of files) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
let s = await stat(fullPath);
|
||||||
|
if (s.isDirectory()) {
|
||||||
|
await walkPath(fullPath);
|
||||||
|
} else {
|
||||||
|
if (path.extname(file) === ".md") {
|
||||||
|
fileNames.push({
|
||||||
|
name: fullPath.substring(
|
||||||
|
this.rootPath.length + 1,
|
||||||
|
fullPath.length - 3
|
||||||
|
),
|
||||||
|
lastModified: s.mtime.getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walkPath(this.rootPath);
|
||||||
|
return fileNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPage(pageName: string): Promise<{ text: string; meta: PageMeta }> {
|
||||||
|
const localPath = path.join(this.rootPath, pageName + ".md");
|
||||||
|
try {
|
||||||
|
const s = await stat(localPath);
|
||||||
|
return {
|
||||||
|
text: await readFile(localPath, "utf8"),
|
||||||
|
meta: {
|
||||||
|
name: pageName,
|
||||||
|
lastModified: s.mtime.getTime(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// console.error("Error while writing page", pageName, e);
|
||||||
|
throw Error(`Could not read page ${pageName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writePage(pageName: string, text: string): Promise<PageMeta> {
|
||||||
|
let localPath = path.join(this.rootPath, pageName + ".md");
|
||||||
|
try {
|
||||||
|
await writeFile(localPath, text);
|
||||||
|
|
||||||
|
// console.log(`Wrote to ${localPath}`);
|
||||||
|
const s = await stat(localPath);
|
||||||
|
return {
|
||||||
|
name: pageName,
|
||||||
|
lastModified: s.mtime.getTime(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error while writing page", pageName, e);
|
||||||
|
throw Error(`Could not write ${pageName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageMeta(pageName: string): Promise<PageMeta> {
|
||||||
|
let localPath = path.join(this.rootPath, pageName + ".md");
|
||||||
|
try {
|
||||||
|
const s = await stat(localPath);
|
||||||
|
return {
|
||||||
|
name: pageName,
|
||||||
|
lastModified: s.mtime.getTime(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error while getting page meta", pageName, e);
|
||||||
|
throw Error(`Could not get meta for ${pageName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePage(pageName: string) {
|
||||||
|
let localPath = path.join(this.rootPath, pageName + ".md");
|
||||||
|
await unlink(localPath);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { ApiProvider, ClientConnection } from "./api_server";
|
||||||
|
import knex, { Knex } from "knex";
|
||||||
|
import path from "path";
|
||||||
|
import pageIndexSyscalls from "./syscalls/page_index";
|
||||||
|
|
||||||
|
type IndexItem = {
|
||||||
|
page: string;
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class IndexApi implements ApiProvider {
|
||||||
|
db: Knex;
|
||||||
|
|
||||||
|
constructor(rootPath: string) {
|
||||||
|
this.db = knex({
|
||||||
|
client: "better-sqlite3",
|
||||||
|
connection: {
|
||||||
|
filename: path.join(rootPath, "data.db"),
|
||||||
|
},
|
||||||
|
useNullAsDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!(await this.db.schema.hasTable("page_index"))) {
|
||||||
|
await this.db.schema.createTable("page_index", (table) => {
|
||||||
|
table.string("page");
|
||||||
|
table.string("key");
|
||||||
|
table.text("value");
|
||||||
|
table.primary(["page", "key"]);
|
||||||
|
});
|
||||||
|
console.log("Created table page_index");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api() {
|
||||||
|
const syscalls = pageIndexSyscalls(this.db);
|
||||||
|
return {
|
||||||
|
clearPageIndexForPage: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
page: string
|
||||||
|
) => {
|
||||||
|
return syscalls["indexer.clearPageIndexForPage"](page);
|
||||||
|
},
|
||||||
|
set: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
page: string,
|
||||||
|
key: string,
|
||||||
|
value: any
|
||||||
|
) => {
|
||||||
|
return syscalls["indexer.set"](page, key, value);
|
||||||
|
},
|
||||||
|
get: async (clientConn: ClientConnection, page: string, key: string) => {
|
||||||
|
return syscalls["indexer.get"](page, key);
|
||||||
|
},
|
||||||
|
delete: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
page: string,
|
||||||
|
key: string
|
||||||
|
) => {
|
||||||
|
return syscalls["indexer.delete"](page, key);
|
||||||
|
},
|
||||||
|
scanPrefixForPage: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
page: string,
|
||||||
|
prefix: string
|
||||||
|
) => {
|
||||||
|
return syscalls["indexer.scanPrefixForPage"](page, prefix);
|
||||||
|
},
|
||||||
|
scanPrefixGlobal: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
prefix: string
|
||||||
|
) => {
|
||||||
|
return syscalls["indexer.scanPrefixGlobal"](prefix);
|
||||||
|
},
|
||||||
|
deletePrefixForPage: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
page: string,
|
||||||
|
prefix: string
|
||||||
|
) => {
|
||||||
|
return syscalls["indexer.deletePrefixForPage"](page, prefix);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPageIndex: async (clientConn: ClientConnection) => {
|
||||||
|
return syscalls["indexer.clearPageIndex"]();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { ClientPageState, Page, PageMeta } from "./types";
|
||||||
|
import { ChangeSet } from "@codemirror/state";
|
||||||
|
import { Update } from "@codemirror/collab";
|
||||||
|
import { ApiProvider, ClientConnection } from "./api_server";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { DiskStorage } from "./disk_storage";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { stat } from "fs/promises";
|
||||||
|
import { Cursor, cursorEffect } from "../webapp/cursorEffect";
|
||||||
|
import { System } from "../plugbox/runtime";
|
||||||
|
import { NuggetHook } from "../webapp/types";
|
||||||
|
|
||||||
|
export class PageApi implements ApiProvider {
|
||||||
|
openPages: Map<string, Page>;
|
||||||
|
pageStore: DiskStorage;
|
||||||
|
rootPath: string;
|
||||||
|
connectedSockets: Set<Socket>;
|
||||||
|
private system: System<NuggetHook>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
rootPath: string,
|
||||||
|
connectedSockets: Set<Socket>,
|
||||||
|
openPages: Map<string, Page>,
|
||||||
|
system: System<NuggetHook>
|
||||||
|
) {
|
||||||
|
this.pageStore = new DiskStorage(rootPath);
|
||||||
|
this.rootPath = rootPath;
|
||||||
|
this.openPages = openPages;
|
||||||
|
this.connectedSockets = connectedSockets;
|
||||||
|
this.system = system;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
this.fileWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastCursors(page: Page) {
|
||||||
|
page.clientStates.forEach((client) => {
|
||||||
|
client.socket.emit(
|
||||||
|
"cursorSnapshot",
|
||||||
|
page.name,
|
||||||
|
Object.fromEntries(page.cursors.entries())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
flushPageToDisk(name: string, page: Page) {
|
||||||
|
safeRun(async () => {
|
||||||
|
let meta = await this.pageStore.writePage(name, page.text.sliceString(0));
|
||||||
|
console.log(`Wrote page ${name} to disk`);
|
||||||
|
page.meta = meta;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectClient(client: ClientPageState, page: Page) {
|
||||||
|
console.log("Disconnecting client");
|
||||||
|
page.clientStates.delete(client);
|
||||||
|
if (page.clientStates.size === 0) {
|
||||||
|
console.log("No more clients for", page.name, "flushing");
|
||||||
|
this.flushPageToDisk(page.name, page);
|
||||||
|
this.openPages.delete(page.name);
|
||||||
|
} else {
|
||||||
|
page.cursors.delete(client.socket.id);
|
||||||
|
this.broadcastCursors(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileWatcher() {
|
||||||
|
fs.watch(
|
||||||
|
this.rootPath,
|
||||||
|
{
|
||||||
|
recursive: true,
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
(eventType, filename) => {
|
||||||
|
safeRun(async () => {
|
||||||
|
if (!filename.endsWith(".md")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let localPath = path.join(this.rootPath, filename);
|
||||||
|
let pageName = filename.substring(0, filename.length - 3);
|
||||||
|
// console.log("Edit in", pageName, eventType);
|
||||||
|
let modifiedTime = 0;
|
||||||
|
try {
|
||||||
|
let s = await stat(localPath);
|
||||||
|
modifiedTime = s.mtime.getTime();
|
||||||
|
} catch (e) {
|
||||||
|
// File was deleted
|
||||||
|
console.log("Deleted", pageName);
|
||||||
|
for (let socket of this.connectedSockets) {
|
||||||
|
socket.emit("pageDeleted", pageName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openPage = this.openPages.get(pageName);
|
||||||
|
if (openPage) {
|
||||||
|
if (openPage.meta.lastModified < modifiedTime) {
|
||||||
|
console.log("Page changed on disk outside of editor, reloading");
|
||||||
|
this.openPages.delete(pageName);
|
||||||
|
const meta = {
|
||||||
|
name: pageName,
|
||||||
|
lastModified: modifiedTime,
|
||||||
|
} as PageMeta;
|
||||||
|
for (let client of openPage.clientStates) {
|
||||||
|
client.socket.emit("pageChanged", meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === "rename") {
|
||||||
|
// This most likely means a new file was created, let's push new file listings to all connected sockets
|
||||||
|
console.log(
|
||||||
|
"New file created, broadcasting to all connected sockets",
|
||||||
|
pageName
|
||||||
|
);
|
||||||
|
for (let socket of this.connectedSockets) {
|
||||||
|
socket.emit("pageCreated", {
|
||||||
|
name: pageName,
|
||||||
|
lastModified: modifiedTime,
|
||||||
|
} as PageMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
api() {
|
||||||
|
return {
|
||||||
|
openPage: async (clientConn: ClientConnection, pageName: string) => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
if (!page) {
|
||||||
|
try {
|
||||||
|
let { text, meta } = await this.pageStore.readPage(pageName);
|
||||||
|
page = new Page(pageName, text, meta);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Creating new page", pageName);
|
||||||
|
page = new Page(pageName, "", { name: pageName, lastModified: 0 });
|
||||||
|
}
|
||||||
|
this.openPages.set(pageName, page);
|
||||||
|
}
|
||||||
|
page.clientStates.add(
|
||||||
|
new ClientPageState(clientConn.sock, page.version)
|
||||||
|
);
|
||||||
|
clientConn.openPages.add(pageName);
|
||||||
|
console.log("Opened page", pageName);
|
||||||
|
this.broadcastCursors(page);
|
||||||
|
return page.toJSON();
|
||||||
|
},
|
||||||
|
pushUpdates: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
pageName: string,
|
||||||
|
version: number,
|
||||||
|
updates: any[]
|
||||||
|
): Promise<boolean> => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
console.error(
|
||||||
|
"Received updates for not open page",
|
||||||
|
pageName,
|
||||||
|
this.openPages.keys()
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (version !== page.version) {
|
||||||
|
console.error("Invalid version", version, page.version);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
console.log("Applying", updates.length, "updates to", pageName);
|
||||||
|
let transformedUpdates = [];
|
||||||
|
let textChanged = false;
|
||||||
|
for (let update of updates) {
|
||||||
|
let changes = ChangeSet.fromJSON(update.changes);
|
||||||
|
let transformedUpdate = {
|
||||||
|
changes,
|
||||||
|
clientID: update.clientID,
|
||||||
|
effects: update.cursors?.map((c: Cursor) => {
|
||||||
|
page!.cursors.set(c.userId, c);
|
||||||
|
return cursorEffect.of(c);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
page.updates.push(transformedUpdate);
|
||||||
|
transformedUpdates.push(transformedUpdate);
|
||||||
|
let oldText = page.text;
|
||||||
|
page.text = changes.apply(page.text);
|
||||||
|
if (oldText !== page.text) {
|
||||||
|
textChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"New version",
|
||||||
|
page.version,
|
||||||
|
"Updates buffered:",
|
||||||
|
page.updates.length
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textChanged) {
|
||||||
|
// Throttle
|
||||||
|
if (!page.saveTimer) {
|
||||||
|
page.saveTimer = setTimeout(() => {
|
||||||
|
if (page) {
|
||||||
|
console.log("Indexing", pageName);
|
||||||
|
|
||||||
|
this.system.dispatchEvent("page:index", {
|
||||||
|
name: pageName,
|
||||||
|
text: page.text.sliceString(0),
|
||||||
|
});
|
||||||
|
this.flushPageToDisk(pageName, page);
|
||||||
|
page.saveTimer = undefined;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (page.pending.length) {
|
||||||
|
page.pending.pop()!(transformedUpdates);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pullUpdates: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
pageName: string,
|
||||||
|
version: number
|
||||||
|
): Promise<Update[]> => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
// console.log("Pulling updates for", pageName);
|
||||||
|
if (!page) {
|
||||||
|
console.error("Fetching updates for not open page");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// TODO: Optimize this
|
||||||
|
let oldestVersion = Infinity;
|
||||||
|
page.clientStates.forEach((client) => {
|
||||||
|
oldestVersion = Math.min(client.version, oldestVersion);
|
||||||
|
if (client.socket === clientConn.sock) {
|
||||||
|
client.version = version;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.flushUpdates(oldestVersion);
|
||||||
|
if (version < page.version) {
|
||||||
|
return page.updatesSince(version);
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
page!.pending.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readPage: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
pageName: string
|
||||||
|
): Promise<{ text: string; meta: PageMeta }> => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
if (page) {
|
||||||
|
console.log("Serving page from memory", pageName);
|
||||||
|
return {
|
||||||
|
text: page.text.sliceString(0),
|
||||||
|
meta: page.meta,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return this.pageStore.readPage(pageName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
writePage: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
pageName: string,
|
||||||
|
text: string
|
||||||
|
) => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
if (page) {
|
||||||
|
for (let client of page.clientStates) {
|
||||||
|
client.socket.emit("reloadPage", pageName);
|
||||||
|
}
|
||||||
|
this.openPages.delete(pageName);
|
||||||
|
}
|
||||||
|
return this.pageStore.writePage(pageName, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePage: async (clientConn: ClientConnection, pageName: string) => {
|
||||||
|
this.openPages.delete(pageName);
|
||||||
|
clientConn.openPages.delete(pageName);
|
||||||
|
// Cascading of this to all connected clients will be handled by file watcher
|
||||||
|
return this.pageStore.deletePage(pageName);
|
||||||
|
},
|
||||||
|
|
||||||
|
listPages: async (clientConn: ClientConnection): Promise<PageMeta[]> => {
|
||||||
|
return this.pageStore.listPages();
|
||||||
|
},
|
||||||
|
|
||||||
|
getPageMeta: async (
|
||||||
|
clientConn: ClientConnection,
|
||||||
|
pageName: string
|
||||||
|
): Promise<PageMeta> => {
|
||||||
|
let page = this.openPages.get(pageName);
|
||||||
|
if (page) {
|
||||||
|
return page.meta;
|
||||||
|
}
|
||||||
|
return this.pageStore.getPageMeta(pageName);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import express from "express";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import http from "http";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import { SocketServer } from "./api_server";
|
||||||
|
import yargs from "yargs";
|
||||||
|
import { hideBin } from "yargs/helpers";
|
||||||
|
|
||||||
|
let args = yargs(hideBin(process.argv))
|
||||||
|
.option("debug", {
|
||||||
|
type: "boolean",
|
||||||
|
})
|
||||||
|
.option("port", {
|
||||||
|
type: "number",
|
||||||
|
default: 3000,
|
||||||
|
})
|
||||||
|
.parse();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
methods: "GET,HEAD,PUT,OPTIONS,POST,DELETE",
|
||||||
|
preflightContinue: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = args.port;
|
||||||
|
const distDir = `${__dirname}/../webapp`;
|
||||||
|
|
||||||
|
app.use("/", express.static(distDir));
|
||||||
|
let socketServer = new SocketServer(args._[0] as string, io);
|
||||||
|
socketServer.init();
|
||||||
|
|
||||||
|
// Fallback, serve index.html
|
||||||
|
let cachedIndex: string | undefined = undefined;
|
||||||
|
app.get("/*", async (req, res) => {
|
||||||
|
if (!cachedIndex) {
|
||||||
|
cachedIndex = await readFile(`${distDir}/index.html`, "utf8");
|
||||||
|
}
|
||||||
|
res.status(200).header("Content-Type", "text/html").send(cachedIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`Server listening on port ${port}`);
|
||||||
|
});
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
type IndexItem = {
|
||||||
|
page: string;
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KV = {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (db: Knex) {
|
||||||
|
const apiObj = {
|
||||||
|
"indexer.clearPageIndexForPage": async (page: string) => {
|
||||||
|
await db<IndexItem>("page_index").where({ page }).del();
|
||||||
|
},
|
||||||
|
"indexer.set": async (page: string, key: string, value: any) => {
|
||||||
|
let changed = await db<IndexItem>("page_index")
|
||||||
|
.where({ page, key })
|
||||||
|
.update("value", JSON.stringify(value));
|
||||||
|
if (changed === 0) {
|
||||||
|
await db<IndexItem>("page_index").insert({
|
||||||
|
page,
|
||||||
|
key,
|
||||||
|
value: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexer.batchSet": async (page: string, kvs: KV[]) => {
|
||||||
|
for (let { key, value } of kvs) {
|
||||||
|
await apiObj["indexer.set"](page, key, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexer.get": async (page: string, key: string) => {
|
||||||
|
let result = await db<IndexItem>("page_index")
|
||||||
|
.where({ page, key })
|
||||||
|
.select("value");
|
||||||
|
if (result.length) {
|
||||||
|
return JSON.parse(result[0].value);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexer.delete": async (page: string, key: string) => {
|
||||||
|
await db<IndexItem>("page_index").where({ page, key }).del();
|
||||||
|
},
|
||||||
|
"indexer.scanPrefixForPage": async (page: string, prefix: string) => {
|
||||||
|
return (
|
||||||
|
await db<IndexItem>("page_index")
|
||||||
|
.where({ page })
|
||||||
|
.andWhereLike("key", `${prefix}%`)
|
||||||
|
.select("page", "key", "value")
|
||||||
|
).map(({ page, key, value }) => ({
|
||||||
|
page,
|
||||||
|
key,
|
||||||
|
value: JSON.parse(value),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
"indexer.scanPrefixGlobal": async (prefix: string) => {
|
||||||
|
return (
|
||||||
|
await db<IndexItem>("page_index")
|
||||||
|
.andWhereLike("key", `${prefix}%`)
|
||||||
|
.select("page", "key", "value")
|
||||||
|
).map(({ page, key, value }) => ({
|
||||||
|
page,
|
||||||
|
key,
|
||||||
|
value: JSON.parse(value),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
"indexer.deletePrefixForPage": async (page: string, prefix: string) => {
|
||||||
|
return db<IndexItem>("page_index")
|
||||||
|
.where({ page })
|
||||||
|
.andWhereLike("key", `${prefix}%`)
|
||||||
|
.del();
|
||||||
|
},
|
||||||
|
"indexer.clearPageIndex": async () => {
|
||||||
|
return db<IndexItem>("page_index").del();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return apiObj;
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Update } from "@codemirror/collab";
|
||||||
|
import { Text } from "@codemirror/state";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { Cursor } from "../webapp/cursorEffect";
|
||||||
|
export class ClientPageState {
|
||||||
|
constructor(public socket: Socket, public version: number) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageMeta = {
|
||||||
|
name: string;
|
||||||
|
lastModified: number;
|
||||||
|
version?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Page {
|
||||||
|
versionOffset = 0;
|
||||||
|
updates: Update[] = [];
|
||||||
|
cursors = new Map<string, Cursor>();
|
||||||
|
clientStates = new Set<ClientPageState>();
|
||||||
|
|
||||||
|
pending: ((value: any) => void)[] = [];
|
||||||
|
|
||||||
|
text: Text;
|
||||||
|
meta: PageMeta;
|
||||||
|
|
||||||
|
saveTimer: NodeJS.Timeout | undefined;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
constructor(name: string, text: string, meta: PageMeta) {
|
||||||
|
this.name = name;
|
||||||
|
this.text = Text.of(text.split("\n"));
|
||||||
|
this.meta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatesSince(version: number): Update[] {
|
||||||
|
return this.updates.slice(version - this.versionOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
get version(): number {
|
||||||
|
return this.updates.length + this.versionOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushUpdates(version: number) {
|
||||||
|
if (this.versionOffset > version) {
|
||||||
|
throw Error("This should never happen");
|
||||||
|
}
|
||||||
|
if (this.versionOffset === version) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updates = this.updates.slice(version - this.versionOffset);
|
||||||
|
this.versionOffset = version;
|
||||||
|
// console.log("Flushed updates, now got", this.updates.length, "updates");
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
text: this.text,
|
||||||
|
version: this.version,
|
||||||
|
cursors: Object.fromEntries(this.cursors.entries()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function safeRun(fn: () => Promise<void>) {
|
||||||
|
fn().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
export type AppEvent =
|
||||||
|
| "app:ready"
|
||||||
|
| "page:save"
|
||||||
|
| "page:click"
|
||||||
|
| "page:index"
|
||||||
|
| "editor:complete";
|
||||||
|
|
||||||
|
export type ClickEvent = {
|
||||||
|
pos: number;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IndexEvent = {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AppEventDispatcher {
|
||||||
|
dispatchAppEvent(name: AppEvent, data?: any): Promise<any[]>;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Editor } from "./editor";
|
||||||
|
import { Space } from "./space";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
let socket = io(`http://${location.hostname}:3000`);
|
||||||
|
|
||||||
|
let editor = new Editor(new Space(socket), document.getElementById("root")!);
|
||||||
|
|
||||||
|
safeRun(async () => {
|
||||||
|
await editor.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
window.editor = editor;
|
||||||
|
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(new URL("service_worker.ts", import.meta.url), { type: "module" })
|
||||||
|
.then((r) => {
|
||||||
|
console.log("Service worker registered", r);
|
||||||
|
});
|
|
@ -0,0 +1,252 @@
|
||||||
|
import {
|
||||||
|
collab,
|
||||||
|
getSyncedVersion,
|
||||||
|
receiveUpdates,
|
||||||
|
sendableUpdates,
|
||||||
|
Update,
|
||||||
|
} from "@codemirror/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 "./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;
|
||||||
|
// if (pos === 0) {
|
||||||
|
// console.error("Warning: position reset? at 0");
|
||||||
|
// console.trace();
|
||||||
|
// }
|
||||||
|
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) {
|
||||||
|
// setTimeout(() => this.push(), 100);
|
||||||
|
this.throttledPush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pull() {
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.view.dispatch(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { EditorSelection, StateCommand, Transaction } from "@codemirror/state";
|
||||||
|
import { Text } from "@codemirror/text";
|
||||||
|
|
||||||
|
export function insertMarker(marker: string): StateCommand {
|
||||||
|
return ({ state, dispatch }) => {
|
||||||
|
const changes = state.changeByRange((range) => {
|
||||||
|
const isBoldBefore =
|
||||||
|
state.sliceDoc(range.from - marker.length, range.from) === marker;
|
||||||
|
const isBoldAfter =
|
||||||
|
state.sliceDoc(range.to, range.to + marker.length) === marker;
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
changes.push(
|
||||||
|
isBoldBefore
|
||||||
|
? {
|
||||||
|
from: range.from - marker.length,
|
||||||
|
to: range.from,
|
||||||
|
insert: Text.of([""]),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
from: range.from,
|
||||||
|
insert: Text.of([marker]),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
changes.push(
|
||||||
|
isBoldAfter
|
||||||
|
? {
|
||||||
|
from: range.to,
|
||||||
|
to: range.to + marker.length,
|
||||||
|
insert: Text.of([""]),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
from: range.to,
|
||||||
|
insert: Text.of([marker]),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const extendBefore = isBoldBefore ? -marker.length : marker.length;
|
||||||
|
const extendAfter = isBoldAfter ? -marker.length : marker.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes,
|
||||||
|
range: EditorSelection.range(
|
||||||
|
range.from + extendBefore,
|
||||||
|
range.to + extendAfter
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
state.update(changes, {
|
||||||
|
scrollIntoView: true,
|
||||||
|
annotations: Transaction.userEvent.of("input"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { AppCommand } from "../types";
|
||||||
|
import { isMacLike } from "../util";
|
||||||
|
import { FilterList, Option } from "./filter";
|
||||||
|
import { faPersonRunning } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
export function CommandPalette({
|
||||||
|
commands,
|
||||||
|
onTrigger,
|
||||||
|
}: {
|
||||||
|
commands: Map<string, AppCommand>;
|
||||||
|
onTrigger: (command: AppCommand | undefined) => void;
|
||||||
|
}) {
|
||||||
|
let options: Option[] = [];
|
||||||
|
const isMac = isMacLike();
|
||||||
|
for (let [name, def] of commands.entries()) {
|
||||||
|
options.push({
|
||||||
|
name: name,
|
||||||
|
hint: isMac && def.command.mac ? def.command.mac : def.command.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("Commands", options);
|
||||||
|
return (
|
||||||
|
<FilterList
|
||||||
|
label="Run"
|
||||||
|
placeholder="Command"
|
||||||
|
options={options}
|
||||||
|
allowNew={false}
|
||||||
|
icon={faPersonRunning}
|
||||||
|
helpText="Start typing the command name to filter results, press <code>Return</code> to run."
|
||||||
|
onSelect={(opt) => {
|
||||||
|
if (opt) {
|
||||||
|
onTrigger(commands.get(opt.name));
|
||||||
|
} else {
|
||||||
|
onTrigger(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,167 @@
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
name: string;
|
||||||
|
orderId?: number;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function magicSorter(a: Option, b: Option): number {
|
||||||
|
if (a.orderId && b.orderId) {
|
||||||
|
return a.orderId < b.orderId ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterList({
|
||||||
|
placeholder,
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
onSelect,
|
||||||
|
onKeyPress,
|
||||||
|
allowNew = false,
|
||||||
|
helpText = "",
|
||||||
|
icon,
|
||||||
|
newHint,
|
||||||
|
}: {
|
||||||
|
placeholder: string;
|
||||||
|
options: Option[];
|
||||||
|
label: string;
|
||||||
|
onKeyPress?: (key: string, currentText: string) => void;
|
||||||
|
onSelect: (option: Option | undefined) => void;
|
||||||
|
allowNew?: boolean;
|
||||||
|
helpText: string;
|
||||||
|
newHint?: string;
|
||||||
|
icon?: IconDefinition;
|
||||||
|
}) {
|
||||||
|
const searchBoxRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [matchingOptions, setMatchingOptions] = useState(
|
||||||
|
options.sort(magicSorter)
|
||||||
|
);
|
||||||
|
const [selectedOption, setSelectionOption] = useState(0);
|
||||||
|
|
||||||
|
let selectedElementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const filter = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const originalPhrase = e.target.value;
|
||||||
|
const searchPhrase = originalPhrase.toLowerCase();
|
||||||
|
|
||||||
|
if (searchPhrase) {
|
||||||
|
let foundExactMatch = false;
|
||||||
|
let results = options.filter((option) => {
|
||||||
|
if (option.name.toLowerCase() === searchPhrase) {
|
||||||
|
foundExactMatch = true;
|
||||||
|
}
|
||||||
|
return option.name.toLowerCase().indexOf(searchPhrase) !== -1;
|
||||||
|
});
|
||||||
|
results = results.sort(magicSorter);
|
||||||
|
if (allowNew && !foundExactMatch) {
|
||||||
|
results.push({
|
||||||
|
name: originalPhrase,
|
||||||
|
hint: newHint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setMatchingOptions(results);
|
||||||
|
} else {
|
||||||
|
let results = options.sort(magicSorter);
|
||||||
|
setMatchingOptions(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(originalPhrase);
|
||||||
|
setSelectionOption(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
searchBoxRef.current!.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function closer() {
|
||||||
|
onSelect(undefined);
|
||||||
|
}
|
||||||
|
document.addEventListener("click", closer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", closer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const returnEl = (
|
||||||
|
<div className="filter-box">
|
||||||
|
<div className="header">
|
||||||
|
<label>{label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={searchBoxRef}
|
||||||
|
onChange={filter}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) => {
|
||||||
|
// console.log("Key up", e.key);
|
||||||
|
if (onKeyPress) {
|
||||||
|
onKeyPress(e.key, text);
|
||||||
|
}
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
setSelectionOption(Math.max(0, selectedOption - 1));
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
setSelectionOption(
|
||||||
|
Math.min(matchingOptions.length - 1, selectedOption + 1)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
onSelect(matchingOptions[selectedOption]);
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
onSelect(undefined);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="help-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: helpText }}
|
||||||
|
></div>
|
||||||
|
<div className="result-list">
|
||||||
|
{matchingOptions && matchingOptions.length > 0
|
||||||
|
? matchingOptions.map((option, idx) => (
|
||||||
|
<div
|
||||||
|
key={"" + idx}
|
||||||
|
ref={selectedOption === idx ? selectedElementRef : undefined}
|
||||||
|
className={
|
||||||
|
selectedOption === idx ? "selected-option" : "option"
|
||||||
|
}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
setSelectionOption(idx);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="icon">
|
||||||
|
{icon && <FontAwesomeIcon icon={icon} />}
|
||||||
|
</span>
|
||||||
|
<span className="name">{option.name}</span>
|
||||||
|
{option.hint && <span className="hint">{option.hint}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedElementRef.current?.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return returnEl;
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { PageMeta } from "../types";
|
||||||
|
import { FilterList, Option } from "./filter";
|
||||||
|
import { faFileLines } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
export function PageNavigator({
|
||||||
|
allPages,
|
||||||
|
onNavigate,
|
||||||
|
currentPage,
|
||||||
|
}: {
|
||||||
|
allPages: Set<PageMeta>;
|
||||||
|
onNavigate: (page: string | undefined) => void;
|
||||||
|
currentPage?: string;
|
||||||
|
}) {
|
||||||
|
let options: Option[] = [];
|
||||||
|
for (let pageMeta of allPages) {
|
||||||
|
if (currentPage && currentPage === pageMeta.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Order by last modified date in descending order
|
||||||
|
let orderId = -pageMeta.lastModified;
|
||||||
|
// Unless it was opened and is still in memory
|
||||||
|
if (pageMeta.lastOpened) {
|
||||||
|
orderId = -pageMeta.lastOpened;
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
...pageMeta,
|
||||||
|
orderId: orderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FilterList
|
||||||
|
placeholder="Page"
|
||||||
|
label="Open"
|
||||||
|
options={options}
|
||||||
|
icon={faFileLines}
|
||||||
|
allowNew={true}
|
||||||
|
helpText="Start typing the page name to filter results, press <code>Return</code> to open."
|
||||||
|
newHint="Create page"
|
||||||
|
onSelect={(opt) => {
|
||||||
|
onNavigate(opt?.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import * as util from "../util";
|
||||||
|
|
||||||
|
export function StatusBar({ editorView }: { editorView?: EditorView }) {
|
||||||
|
let wordCount = 0,
|
||||||
|
readingTime = 0;
|
||||||
|
if (editorView) {
|
||||||
|
let text = editorView.state.sliceDoc();
|
||||||
|
wordCount = util.countWords(text);
|
||||||
|
readingTime = util.readingTime(wordCount);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div id="bottom">
|
||||||
|
{wordCount} words | {readingTime} min
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { AppViewState, PageMeta } from "../types";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faFileLines } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Notification } from "../types";
|
||||||
|
|
||||||
|
function prettyName(s: string | undefined): string {
|
||||||
|
if (!s) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return s.replaceAll("/", " / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
pageName,
|
||||||
|
status,
|
||||||
|
notifications,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
pageName?: string;
|
||||||
|
status?: string;
|
||||||
|
notifications: Notification[];
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div id="top" onClick={onClick}>
|
||||||
|
<div className="inner">
|
||||||
|
<span className="icon">
|
||||||
|
<FontAwesomeIcon icon={faFileLines} />
|
||||||
|
</span>
|
||||||
|
<span className="current-page">{prettyName(pageName)}</span>
|
||||||
|
<div className="status">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<div key={notification.id}>{notification.message}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export const pageLinkRegex = /\[\[([\w\s\/\:,\.\-]+)\]\]/;
|
|
@ -0,0 +1,12 @@
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Tag } from "@codemirror/highlight";
|
||||||
|
|
||||||
|
export const WikiLinkTag = Tag.define();
|
||||||
|
export const WikiLinkPageTag = Tag.define();
|
||||||
|
export const TagTag = Tag.define();
|
||||||
|
export const MentionTag = Tag.define();
|
||||||
|
export const TaskTag = Tag.define();
|
||||||
|
export const TaskMarkerTag = Tag.define();
|
||||||
|
export const CommentTag = Tag.define();
|
||||||
|
export const CommentMarkerTag = Tag.define();
|
||||||
|
export const BulletList = Tag.define();
|
||||||
|
export const OrderedList = Tag.define();
|
|
@ -0,0 +1,497 @@
|
||||||
|
import {
|
||||||
|
autocompletion,
|
||||||
|
Completion,
|
||||||
|
CompletionContext,
|
||||||
|
completionKeymap,
|
||||||
|
CompletionResult,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets";
|
||||||
|
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 {
|
||||||
|
drawSelection,
|
||||||
|
dropCursor,
|
||||||
|
EditorView,
|
||||||
|
highlightSpecialChars,
|
||||||
|
KeyBinding,
|
||||||
|
keymap,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
// import { debounce } from "lodash";
|
||||||
|
import React, { useEffect, useReducer } from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { Plug, System } from "../plugbox/runtime";
|
||||||
|
import { createSandbox as createIFrameSandbox } from "../plugbox/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 coreManifest from "./generated/core.plug.json";
|
||||||
|
import { lineWrapper } from "./line_wrapper";
|
||||||
|
import { markdown } from "./markdown";
|
||||||
|
import { IPageNavigator, PathPageNavigator } from "./navigator";
|
||||||
|
import customMarkDown from "./parser";
|
||||||
|
import reducer from "./reducer";
|
||||||
|
import { smartQuoteKeymap } from "./smart_quotes";
|
||||||
|
import { Space } from "./space";
|
||||||
|
import customMarkdownStyle from "./style";
|
||||||
|
import dbSyscalls from "./syscalls/db.localstorage";
|
||||||
|
import editorSyscalls from "./syscalls/editor.browser";
|
||||||
|
import indexerSyscalls from "./syscalls/indexer.native";
|
||||||
|
import spaceSyscalls from "./syscalls/space.native";
|
||||||
|
import {
|
||||||
|
Action,
|
||||||
|
AppCommand,
|
||||||
|
AppViewState,
|
||||||
|
initialViewState,
|
||||||
|
NuggetHook,
|
||||||
|
slashCommandRegexp,
|
||||||
|
} from "./types";
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
|
||||||
|
class PageState {
|
||||||
|
scrollTop: number;
|
||||||
|
selection: EditorSelection;
|
||||||
|
|
||||||
|
constructor(scrollTop: number, selection: EditorSelection) {
|
||||||
|
this.scrollTop = scrollTop;
|
||||||
|
this.selection = selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Editor implements AppEventDispatcher {
|
||||||
|
editorView?: EditorView;
|
||||||
|
viewState: AppViewState;
|
||||||
|
viewDispatch: React.Dispatch<Action>;
|
||||||
|
openPages: Map<string, PageState>;
|
||||||
|
space: Space;
|
||||||
|
editorCommands: Map<string, AppCommand>;
|
||||||
|
plugs: Plug<NuggetHook>[];
|
||||||
|
navigationResolve?: (val: undefined) => void;
|
||||||
|
pageNavigator: IPageNavigator;
|
||||||
|
|
||||||
|
constructor(space: Space, parent: Element) {
|
||||||
|
this.editorCommands = new Map();
|
||||||
|
this.openPages = new Map();
|
||||||
|
this.plugs = [];
|
||||||
|
this.space = space;
|
||||||
|
this.viewState = initialViewState;
|
||||||
|
this.viewDispatch = () => {};
|
||||||
|
this.render(parent);
|
||||||
|
this.editorView = new EditorView({
|
||||||
|
state: this.createEditorState(
|
||||||
|
"",
|
||||||
|
new CollabDocument(Text.of([""]), 0, new Map<string, Cursor>())
|
||||||
|
),
|
||||||
|
parent: document.getElementById("editor")!,
|
||||||
|
});
|
||||||
|
this.pageNavigator = new PathPageNavigator();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadPlugs();
|
||||||
|
this.focus();
|
||||||
|
|
||||||
|
this.pageNavigator.subscribe(async (pageName) => {
|
||||||
|
console.log("Now navigating to", pageName);
|
||||||
|
|
||||||
|
if (!this.editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadPage(pageName);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.space.on({
|
||||||
|
connect: () => {
|
||||||
|
if (this.currentPage) {
|
||||||
|
console.log("Connected to socket, fetch fresh?");
|
||||||
|
this.flashNotification("Reconnected, reloading page");
|
||||||
|
this.reloadPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pageChanged: (meta) => {
|
||||||
|
if (this.currentPage === meta.name) {
|
||||||
|
console.log("Page changed on disk, reloading");
|
||||||
|
this.flashNotification("Page changed on disk, reloading");
|
||||||
|
this.reloadPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pageListUpdated: (pages) => {
|
||||||
|
this.viewDispatch({
|
||||||
|
type: "pages-listed",
|
||||||
|
pages: pages,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.pageNavigator.getCurrentPage() === "") {
|
||||||
|
this.pageNavigator.navigate("start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flashNotification(message: string) {
|
||||||
|
let id = Math.floor(Math.random() * 1000000);
|
||||||
|
this.viewDispatch({
|
||||||
|
type: "show-notification",
|
||||||
|
notification: {
|
||||||
|
id: id,
|
||||||
|
message: message,
|
||||||
|
date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
this.viewDispatch({
|
||||||
|
type: "dismiss-notification",
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPlugs() {
|
||||||
|
const system = new System<NuggetHook>();
|
||||||
|
system.registerSyscalls(
|
||||||
|
dbSyscalls,
|
||||||
|
editorSyscalls(this),
|
||||||
|
spaceSyscalls(this),
|
||||||
|
indexerSyscalls(this.space)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Now loading core plug");
|
||||||
|
let mainPlug = await system.load(
|
||||||
|
"core",
|
||||||
|
coreManifest,
|
||||||
|
createIFrameSandbox(system)
|
||||||
|
);
|
||||||
|
this.plugs.push(mainPlug);
|
||||||
|
this.editorCommands = new Map<string, AppCommand>();
|
||||||
|
for (let plug of this.plugs) {
|
||||||
|
this.buildCommands(plug);
|
||||||
|
}
|
||||||
|
this.viewDispatch({
|
||||||
|
type: "update-commands",
|
||||||
|
commands: this.editorCommands,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCommands(plug: Plug<NuggetHook>) {
|
||||||
|
const cmds = plug.manifest!.hooks.commands;
|
||||||
|
for (let name in cmds) {
|
||||||
|
let cmd = cmds[name];
|
||||||
|
this.editorCommands.set(name, {
|
||||||
|
command: cmd,
|
||||||
|
run: async (arg): Promise<any> => {
|
||||||
|
return await plug.invoke(cmd.invoke, [arg]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Parallelize?
|
||||||
|
async dispatchAppEvent(name: AppEvent, data?: any): Promise<any[]> {
|
||||||
|
let results: any[] = [];
|
||||||
|
for (let plug of this.plugs) {
|
||||||
|
let plugResults = await plug.dispatchEvent(name, data);
|
||||||
|
if (plugResults) {
|
||||||
|
for (let result of plugResults) {
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentPage(): string | undefined {
|
||||||
|
return this.viewState.currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEditorState(pageName: string, doc: CollabDocument): EditorState {
|
||||||
|
let commandKeyBindings: KeyBinding[] = [];
|
||||||
|
for (let def of this.editorCommands.values()) {
|
||||||
|
if (def.command.key) {
|
||||||
|
commandKeyBindings.push({
|
||||||
|
key: def.command.key,
|
||||||
|
mac: def.command.mac,
|
||||||
|
run: (): boolean => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
await def.run(null);
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EditorState.create({
|
||||||
|
doc: doc.text,
|
||||||
|
extensions: [
|
||||||
|
highlightSpecialChars(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
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.plugCompleter.bind(this),
|
||||||
|
this.commandCompleter.bind(this),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
lineWrapper([
|
||||||
|
{ selector: "ATXHeading1", class: "line-h1" },
|
||||||
|
{ selector: "ATXHeading2", class: "line-h2" },
|
||||||
|
{ selector: "ATXHeading3", class: "line-h3" },
|
||||||
|
{ selector: "ListItem", class: "line-li", nesting: true },
|
||||||
|
{ selector: "Blockquote", class: "line-blockquote" },
|
||||||
|
{ selector: "Task", class: "line-task" },
|
||||||
|
{ selector: "CodeBlock", class: "line-code" },
|
||||||
|
{ selector: "FencedCode", class: "line-fenced-code" },
|
||||||
|
{ selector: "Comment", class: "line-comment" },
|
||||||
|
{ selector: "BulletList", class: "line-ul" },
|
||||||
|
{ selector: "OrderedList", class: "line-ol" },
|
||||||
|
]),
|
||||||
|
keymap.of([
|
||||||
|
...smartQuoteKeymap,
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
...standardKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...completionKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
...commandKeyBindings,
|
||||||
|
{
|
||||||
|
key: "Ctrl-b",
|
||||||
|
mac: "Cmd-b",
|
||||||
|
run: commands.insertMarker("**"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Ctrl-i",
|
||||||
|
mac: "Cmd-i",
|
||||||
|
run: commands.insertMarker("_"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Ctrl-p",
|
||||||
|
mac: "Cmd-p",
|
||||||
|
run: (): boolean => {
|
||||||
|
window.open(location.href, "_blank")!.focus();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Ctrl-k",
|
||||||
|
mac: "Cmd-k",
|
||||||
|
run: (): boolean => {
|
||||||
|
this.viewDispatch({ type: "start-navigate" });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Ctrl-.",
|
||||||
|
mac: "Cmd-.",
|
||||||
|
run: (): boolean => {
|
||||||
|
this.viewDispatch({
|
||||||
|
type: "show-palette",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
click: (event: MouseEvent, view: EditorView) => {
|
||||||
|
safeRun(async () => {
|
||||||
|
let clickEvent: ClickEvent = {
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
altKey: event.altKey,
|
||||||
|
pos: view.posAtCoords(event)!,
|
||||||
|
};
|
||||||
|
await this.dispatchAppEvent("page:click", clickEvent);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
markdown({
|
||||||
|
base: customMarkDown,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadPage() {
|
||||||
|
console.log("Reloading page");
|
||||||
|
safeRun(async () => {
|
||||||
|
await this.loadPage(this.currentPage!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async plugCompleter(): Promise<CompletionResult | null> {
|
||||||
|
let allCompletionResults = await this.dispatchAppEvent("editor:complete");
|
||||||
|
if (allCompletionResults.length === 1) {
|
||||||
|
return allCompletionResults[0];
|
||||||
|
} else if (allCompletionResults.length > 1) {
|
||||||
|
console.error(
|
||||||
|
"Got completion results from multiple sources, cannot deal with that",
|
||||||
|
allCompletionResults
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCompleter(ctx: CompletionContext): CompletionResult | null {
|
||||||
|
let prefix = ctx.matchBefore(slashCommandRegexp);
|
||||||
|
if (!prefix) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let options: Completion[] = [];
|
||||||
|
for (let [name, def] of this.viewState.commands) {
|
||||||
|
if (!def.command.slashCommand) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
label: def.command.slashCommand,
|
||||||
|
detail: name,
|
||||||
|
apply: () => {
|
||||||
|
this.editorView?.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: prefix!.from,
|
||||||
|
to: ctx.pos,
|
||||||
|
insert: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
safeRun(async () => {
|
||||||
|
await def.run(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: prefix.from + 1,
|
||||||
|
options: options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
this.editorView!.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(name: string) {
|
||||||
|
this.pageNavigator.navigate(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPage(pageName: string) {
|
||||||
|
const editorView = this.editorView;
|
||||||
|
if (!editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist current page state and nicely close page
|
||||||
|
if (this.currentPage) {
|
||||||
|
let pageState = this.openPages.get(this.currentPage)!;
|
||||||
|
if (pageState) {
|
||||||
|
pageState.selection = this.editorView!.state.selection;
|
||||||
|
pageState.scrollTop = this.editorView!.scrollDOM.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.space.closePage(this.currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch next page to open
|
||||||
|
let doc = await this.space.openPage(pageName);
|
||||||
|
let editorState = this.createEditorState(pageName, doc);
|
||||||
|
let pageState = this.openPages.get(pageName);
|
||||||
|
editorView.setState(editorState);
|
||||||
|
if (!pageState) {
|
||||||
|
pageState = new PageState(0, editorState.selection);
|
||||||
|
this.openPages.set(pageName, pageState!);
|
||||||
|
editorView.dispatch({
|
||||||
|
selection: { anchor: 0 },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Restore state
|
||||||
|
console.log("Restoring selection state", pageState.selection);
|
||||||
|
editorView.dispatch({
|
||||||
|
selection: pageState.selection,
|
||||||
|
});
|
||||||
|
editorView.scrollDOM.scrollTop = pageState!.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewDispatch({
|
||||||
|
type: "page-loaded",
|
||||||
|
name: pageName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewComponent(): React.ReactElement {
|
||||||
|
const [viewState, dispatch] = useReducer(reducer, initialViewState);
|
||||||
|
this.viewState = viewState;
|
||||||
|
this.viewDispatch = dispatch;
|
||||||
|
|
||||||
|
let editor = this;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewState.currentPage) {
|
||||||
|
document.title = viewState.currentPage;
|
||||||
|
}
|
||||||
|
}, [viewState.currentPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{viewState.showPageNavigator && (
|
||||||
|
<PageNavigator
|
||||||
|
allPages={viewState.allPages}
|
||||||
|
currentPage={this.currentPage}
|
||||||
|
onNavigate={(page) => {
|
||||||
|
dispatch({ type: "stop-navigate" });
|
||||||
|
editor.focus();
|
||||||
|
if (page) {
|
||||||
|
safeRun(async () => {
|
||||||
|
editor.navigate(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{viewState.showCommandPalette && (
|
||||||
|
<CommandPalette
|
||||||
|
onTrigger={(cmd) => {
|
||||||
|
dispatch({ type: "hide-palette" });
|
||||||
|
editor!.focus();
|
||||||
|
if (cmd) {
|
||||||
|
safeRun(async () => {
|
||||||
|
let result = await cmd.run(null);
|
||||||
|
console.log("Result of command", result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
commands={viewState.commands}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TopBar
|
||||||
|
pageName={viewState.currentPage}
|
||||||
|
notifications={viewState.notifications}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch({ type: "start-navigate" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div id="editor"></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(container: ReactDOM.Container) {
|
||||||
|
const ViewComponent = this.ViewComponent.bind(this);
|
||||||
|
ReactDOM.render(<ViewComponent />, container);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
export abstract class EventEmitter<HandlerT> {
|
||||||
|
private handlers: Partial<HandlerT>[] = [];
|
||||||
|
|
||||||
|
on(handlers: Partial<HandlerT>) {
|
||||||
|
this.handlers.push(handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(handlers: Partial<HandlerT>) {
|
||||||
|
this.handlers = this.handlers.filter((h) => h !== handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(eventName: keyof HandlerT, ...args: any[]) {
|
||||||
|
for (let handler of this.handlers) {
|
||||||
|
let fn: any = handler[eventName];
|
||||||
|
if (fn) {
|
||||||
|
fn(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Silver Bullet</title>
|
||||||
|
<link rel="stylesheet" href="styles/main.scss" />
|
||||||
|
<script type="module" src="boot.ts"></script>
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
|
||||||
|
import { Range } from "@codemirror/rangeset";
|
||||||
|
|
||||||
|
interface WrapElement {
|
||||||
|
selector: string;
|
||||||
|
class: string;
|
||||||
|
nesting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapLines(view: EditorView, wrapElements: WrapElement[]) {
|
||||||
|
let widgets: Range<Decoration>[] = [];
|
||||||
|
let elementStack: string[] = [];
|
||||||
|
for (let { from, to } of view.visibleRanges) {
|
||||||
|
const doc = view.state.doc;
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (type, from, to) => {
|
||||||
|
const bodyText = doc.sliceString(from, to);
|
||||||
|
for (let wrapElement of wrapElements) {
|
||||||
|
if (type.name == wrapElement.selector) {
|
||||||
|
if (wrapElement.nesting) {
|
||||||
|
elementStack.push(type.name);
|
||||||
|
}
|
||||||
|
const bodyText = doc.sliceString(from, to);
|
||||||
|
let idx = from;
|
||||||
|
for (let line of bodyText.split("\n")) {
|
||||||
|
let cls = wrapElement.class;
|
||||||
|
if (wrapElement.nesting) {
|
||||||
|
cls = `${cls} ${cls}-${elementStack.length}`;
|
||||||
|
}
|
||||||
|
widgets.push(
|
||||||
|
Decoration.line({
|
||||||
|
class: cls,
|
||||||
|
}).range(doc.lineAt(idx).from)
|
||||||
|
);
|
||||||
|
idx += line.length + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leave(type, from: number, to: number) {
|
||||||
|
for (let wrapElement of wrapElements) {
|
||||||
|
if (type.name == wrapElement.selector && wrapElement.nesting) {
|
||||||
|
elementStack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Widgets have to be sorted by `from` in ascending order
|
||||||
|
widgets = widgets.sort((a, b) => {
|
||||||
|
return a.from < b.from ? -1 : 1;
|
||||||
|
});
|
||||||
|
return Decoration.set(widgets);
|
||||||
|
}
|
||||||
|
export const lineWrapper = (wrapElements: WrapElement[]) =>
|
||||||
|
ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = wrapLines(view, wrapElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.docChanged || update.viewportChanged) {
|
||||||
|
this.decorations = wrapLines(update.view, wrapElements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
}
|
||||||
|
);
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"short_name": "Silver Bullet",
|
||||||
|
"name": "Silver Bullet",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./images/logo.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capture_links": "new-client",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "/",
|
||||||
|
"theme_color": "#000",
|
||||||
|
"description": "Note taking for winners"
|
||||||
|
}
|
|
@ -0,0 +1,371 @@
|
||||||
|
import {
|
||||||
|
StateCommand,
|
||||||
|
Text,
|
||||||
|
EditorSelection,
|
||||||
|
ChangeSpec,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import { SyntaxNode, Tree } from "@lezer/common";
|
||||||
|
import { markdownLanguage } from "./markdown";
|
||||||
|
|
||||||
|
function nodeStart(node: SyntaxNode, doc: Text) {
|
||||||
|
return doc.sliceString(node.from, node.from + 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Context {
|
||||||
|
constructor(
|
||||||
|
readonly node: SyntaxNode,
|
||||||
|
readonly from: number,
|
||||||
|
readonly to: number,
|
||||||
|
readonly spaceBefore: string,
|
||||||
|
readonly spaceAfter: string,
|
||||||
|
readonly type: string,
|
||||||
|
readonly item: SyntaxNode | null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
blank(trailing: boolean = true) {
|
||||||
|
let result = this.spaceBefore;
|
||||||
|
if (this.node.name == "Blockquote") {
|
||||||
|
result += ">";
|
||||||
|
} else if (this.node.name == "Comment") {
|
||||||
|
result += "%%";
|
||||||
|
} else
|
||||||
|
for (
|
||||||
|
let i = this.to - this.from - result.length - this.spaceAfter.length;
|
||||||
|
i > 0;
|
||||||
|
i--
|
||||||
|
)
|
||||||
|
result += " ";
|
||||||
|
return result + (trailing ? this.spaceAfter : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
marker(doc: Text, add: number) {
|
||||||
|
let number =
|
||||||
|
this.node.name == "OrderedList"
|
||||||
|
? String(+itemNumber(this.item!, doc)[2] + add)
|
||||||
|
: "";
|
||||||
|
return this.spaceBefore + number + this.type + this.spaceAfter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContext(node: SyntaxNode, line: string, doc: Text) {
|
||||||
|
let nodes = [];
|
||||||
|
for (
|
||||||
|
let cur: SyntaxNode | null = node;
|
||||||
|
cur && cur.name != "Document";
|
||||||
|
cur = cur.parent
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
cur.name == "ListItem" ||
|
||||||
|
cur.name == "Blockquote" ||
|
||||||
|
cur.name == "Comment"
|
||||||
|
)
|
||||||
|
nodes.push(cur);
|
||||||
|
}
|
||||||
|
let context = [],
|
||||||
|
pos = 0;
|
||||||
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||||
|
let node = nodes[i],
|
||||||
|
match,
|
||||||
|
start = pos;
|
||||||
|
if (
|
||||||
|
node.name == "Blockquote" &&
|
||||||
|
(match = /^[ \t]*>( ?)/.exec(line.slice(pos)))
|
||||||
|
) {
|
||||||
|
pos += match[0].length;
|
||||||
|
context.push(new Context(node, start, pos, "", match[1], ">", null));
|
||||||
|
} else if (
|
||||||
|
node.name == "Comment" &&
|
||||||
|
(match = /^[ \t]*%%( ?)/.exec(line.slice(pos)))
|
||||||
|
) {
|
||||||
|
pos += match[0].length;
|
||||||
|
context.push(new Context(node, start, pos, "", match[1], "%%", null));
|
||||||
|
} else if (
|
||||||
|
node.name == "ListItem" &&
|
||||||
|
node.parent!.name == "OrderedList" &&
|
||||||
|
(match = /^([ \t]*)\d+([.)])([ \t]*)/.exec(nodeStart(node, doc)))
|
||||||
|
) {
|
||||||
|
let after = match[3],
|
||||||
|
len = match[0].length;
|
||||||
|
if (after.length >= 4) {
|
||||||
|
after = after.slice(0, after.length - 4);
|
||||||
|
len -= 4;
|
||||||
|
}
|
||||||
|
pos += len;
|
||||||
|
context.push(
|
||||||
|
new Context(node.parent!, start, pos, match[1], after, match[2], node)
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
node.name == "ListItem" &&
|
||||||
|
node.parent!.name == "BulletList" &&
|
||||||
|
(match = /^([ \t]*)([-+*])([ \t]+)/.exec(nodeStart(node, doc)))
|
||||||
|
) {
|
||||||
|
let after = match[3],
|
||||||
|
len = match[0].length;
|
||||||
|
if (after.length > 4) {
|
||||||
|
after = after.slice(0, after.length - 4);
|
||||||
|
len -= 4;
|
||||||
|
}
|
||||||
|
pos += len;
|
||||||
|
context.push(
|
||||||
|
new Context(node.parent!, start, pos, match[1], after, match[2], node)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemNumber(item: SyntaxNode, doc: Text) {
|
||||||
|
return /^(\s*)(\d+)(?=[.)])/.exec(
|
||||||
|
doc.sliceString(item.from, item.from + 10)
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumberList(
|
||||||
|
after: SyntaxNode,
|
||||||
|
doc: Text,
|
||||||
|
changes: ChangeSpec[],
|
||||||
|
offset = 0
|
||||||
|
) {
|
||||||
|
for (let prev = -1, node = after; ; ) {
|
||||||
|
if (node.name == "ListItem") {
|
||||||
|
let m = itemNumber(node, doc);
|
||||||
|
let number = +m[2];
|
||||||
|
if (prev >= 0) {
|
||||||
|
if (number != prev + 1) return;
|
||||||
|
changes.push({
|
||||||
|
from: node.from + m[1].length,
|
||||||
|
to: node.from + m[0].length,
|
||||||
|
insert: String(prev + 2 + offset),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prev = number;
|
||||||
|
}
|
||||||
|
let next = node.nextSibling;
|
||||||
|
if (!next) break;
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This command, when invoked in Markdown context with cursor
|
||||||
|
/// selection(s), will create a new line with the markup for
|
||||||
|
/// blockquotes and lists that were active on the old line. If the
|
||||||
|
/// cursor was directly after the end of the markup for the old line,
|
||||||
|
/// trailing whitespace and list markers are removed from that line.
|
||||||
|
///
|
||||||
|
/// The command does nothing in non-Markdown context, so it should
|
||||||
|
/// not be used as the only binding for Enter (even in a Markdown
|
||||||
|
/// document, HTML and code regions might use a different language).
|
||||||
|
export const insertNewlineContinueMarkup: StateCommand = ({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
}) => {
|
||||||
|
let tree = syntaxTree(state),
|
||||||
|
{ doc } = state;
|
||||||
|
let dont = null,
|
||||||
|
changes = state.changeByRange((range) => {
|
||||||
|
if (!range.empty || !markdownLanguage.isActiveAt(state, range.from))
|
||||||
|
return (dont = { range });
|
||||||
|
let pos = range.from,
|
||||||
|
line = doc.lineAt(pos);
|
||||||
|
let context = getContext(tree.resolveInner(pos, -1), line.text, doc);
|
||||||
|
while (
|
||||||
|
context.length &&
|
||||||
|
context[context.length - 1].from > pos - line.from
|
||||||
|
)
|
||||||
|
context.pop();
|
||||||
|
if (!context.length) return (dont = { range });
|
||||||
|
let inner = context[context.length - 1];
|
||||||
|
if (inner.to - inner.spaceAfter.length > pos - line.from)
|
||||||
|
return (dont = { range });
|
||||||
|
|
||||||
|
let emptyLine =
|
||||||
|
pos >= inner.to - inner.spaceAfter.length &&
|
||||||
|
!/\S/.test(line.text.slice(inner.to));
|
||||||
|
// Empty line in list
|
||||||
|
if (inner.item && emptyLine) {
|
||||||
|
// First list item or blank line before: delete a level of markup
|
||||||
|
if (
|
||||||
|
inner.node.firstChild!.to >= pos ||
|
||||||
|
(line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text))
|
||||||
|
) {
|
||||||
|
let next = context.length > 1 ? context[context.length - 2] : null;
|
||||||
|
let delTo,
|
||||||
|
insert = "";
|
||||||
|
if (next && next.item) {
|
||||||
|
// Re-add marker for the list at the next level
|
||||||
|
delTo = line.from + next.from;
|
||||||
|
insert = next.marker(doc, 1);
|
||||||
|
} else {
|
||||||
|
delTo = line.from + (next ? next.to : 0);
|
||||||
|
}
|
||||||
|
let changes: ChangeSpec[] = [{ from: delTo, to: pos, insert }];
|
||||||
|
if (inner.node.name == "OrderedList")
|
||||||
|
renumberList(inner.item!, doc, changes, -2);
|
||||||
|
if (next && next.node.name == "OrderedList")
|
||||||
|
renumberList(next.item!, doc, changes);
|
||||||
|
return {
|
||||||
|
range: EditorSelection.cursor(delTo + insert.length),
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Move this line down
|
||||||
|
let insert = "";
|
||||||
|
for (let i = 0, e = context.length - 2; i <= e; i++)
|
||||||
|
insert += context[i].blank(i < e);
|
||||||
|
insert += state.lineBreak;
|
||||||
|
return {
|
||||||
|
range: EditorSelection.cursor(pos + insert.length),
|
||||||
|
changes: { from: line.from, insert },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inner.node.name == "Blockquote" && emptyLine && line.from) {
|
||||||
|
let prevLine = doc.lineAt(line.from - 1),
|
||||||
|
quoted = />\s*$/.exec(prevLine.text);
|
||||||
|
// Two aligned empty quoted lines in a row
|
||||||
|
if (quoted && quoted.index == inner.from) {
|
||||||
|
let changes = state.changes([
|
||||||
|
{ from: prevLine.from + quoted.index, to: prevLine.to },
|
||||||
|
{ from: line.from + inner.from, to: line.to },
|
||||||
|
]);
|
||||||
|
return { range: range.map(changes), changes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inner.node.name == "Comment" && emptyLine && line.from) {
|
||||||
|
let prevLine = doc.lineAt(line.from - 1),
|
||||||
|
commented = /%%\s*$/.exec(prevLine.text);
|
||||||
|
// Two aligned empty quoted lines in a row
|
||||||
|
if (commented && commented.index == inner.from) {
|
||||||
|
let changes = state.changes([
|
||||||
|
{ from: prevLine.from + commented.index, to: prevLine.to },
|
||||||
|
{ from: line.from + inner.from, to: line.to },
|
||||||
|
]);
|
||||||
|
return { range: range.map(changes), changes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changes: ChangeSpec[] = [];
|
||||||
|
if (inner.node.name == "OrderedList")
|
||||||
|
renumberList(inner.item!, doc, changes);
|
||||||
|
let insert = state.lineBreak;
|
||||||
|
let continued = inner.item && inner.item.from < line.from;
|
||||||
|
// If not dedented
|
||||||
|
if (
|
||||||
|
!continued ||
|
||||||
|
/^[\s\d.)\-+*>]*/.exec(line.text)![0].length >= inner.to
|
||||||
|
) {
|
||||||
|
for (let i = 0, e = context.length - 1; i <= e; i++)
|
||||||
|
insert +=
|
||||||
|
i == e && !continued
|
||||||
|
? context[i].marker(doc, 1)
|
||||||
|
: context[i].blank();
|
||||||
|
}
|
||||||
|
let from = pos;
|
||||||
|
while (
|
||||||
|
from > line.from &&
|
||||||
|
/\s/.test(line.text.charAt(from - line.from - 1))
|
||||||
|
)
|
||||||
|
from--;
|
||||||
|
changes.push({ from, to: pos, insert });
|
||||||
|
return { range: EditorSelection.cursor(from + insert.length), changes };
|
||||||
|
});
|
||||||
|
if (dont) return false;
|
||||||
|
dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isMark(node: SyntaxNode) {
|
||||||
|
return node.name == "QuoteMark" || node.name == "ListMark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextNodeForDelete(tree: Tree, pos: number) {
|
||||||
|
let node = tree.resolveInner(pos, -1),
|
||||||
|
scan = pos;
|
||||||
|
if (isMark(node)) {
|
||||||
|
scan = node.from;
|
||||||
|
node = node.parent!;
|
||||||
|
}
|
||||||
|
for (let prev; (prev = node.childBefore(scan)); ) {
|
||||||
|
if (isMark(prev)) {
|
||||||
|
scan = prev.from;
|
||||||
|
} else if (prev.name == "OrderedList" || prev.name == "BulletList") {
|
||||||
|
node = prev.lastChild!;
|
||||||
|
scan = node.to;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This command will, when invoked in a Markdown context with the
|
||||||
|
/// cursor directly after list or blockquote markup, delete one level
|
||||||
|
/// of markup. When the markup is for a list, it will be replaced by
|
||||||
|
/// spaces on the first invocation (a further invocation will delete
|
||||||
|
/// the spaces), to make it easy to continue a list.
|
||||||
|
///
|
||||||
|
/// When not after Markdown block markup, this command will return
|
||||||
|
/// false, so it is intended to be bound alongside other deletion
|
||||||
|
/// commands, with a higher precedence than the more generic commands.
|
||||||
|
export const deleteMarkupBackward: StateCommand = ({ state, dispatch }) => {
|
||||||
|
let tree = syntaxTree(state);
|
||||||
|
let dont = null,
|
||||||
|
changes = state.changeByRange((range) => {
|
||||||
|
let pos = range.from,
|
||||||
|
{ doc } = state;
|
||||||
|
if (range.empty && markdownLanguage.isActiveAt(state, range.from)) {
|
||||||
|
let line = doc.lineAt(pos);
|
||||||
|
let context = getContext(
|
||||||
|
contextNodeForDelete(tree, pos),
|
||||||
|
line.text,
|
||||||
|
doc
|
||||||
|
);
|
||||||
|
if (context.length) {
|
||||||
|
let inner = context[context.length - 1];
|
||||||
|
let spaceEnd =
|
||||||
|
inner.to - inner.spaceAfter.length + (inner.spaceAfter ? 1 : 0);
|
||||||
|
// Delete extra trailing space after markup
|
||||||
|
if (
|
||||||
|
pos - line.from > spaceEnd &&
|
||||||
|
!/\S/.test(line.text.slice(spaceEnd, pos - line.from))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
range: EditorSelection.cursor(line.from + spaceEnd),
|
||||||
|
changes: { from: line.from + spaceEnd, to: pos },
|
||||||
|
};
|
||||||
|
if (pos - line.from == spaceEnd) {
|
||||||
|
let start = line.from + inner.from;
|
||||||
|
// Replace a list item marker with blank space
|
||||||
|
if (
|
||||||
|
inner.item &&
|
||||||
|
inner.node.from < inner.item.from &&
|
||||||
|
/\S/.test(line.text.slice(inner.from, inner.to))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
range,
|
||||||
|
changes: {
|
||||||
|
from: start,
|
||||||
|
to: line.from + inner.to,
|
||||||
|
insert: inner.blank(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Delete one level of indentation
|
||||||
|
if (start < pos)
|
||||||
|
return {
|
||||||
|
range: EditorSelection.cursor(start),
|
||||||
|
changes: { from: start, to: pos },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (dont = { range });
|
||||||
|
});
|
||||||
|
if (dont) return false;
|
||||||
|
dispatch(
|
||||||
|
state.update(changes, { scrollIntoView: true, userEvent: "delete" })
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
|
@ -0,0 +1,56 @@
|
||||||
|
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}
|
||||||
|
|
||||||
|
/// 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}
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
if (defaultCodeLanguage instanceof LanguageSupport) {
|
||||||
|
support.push(defaultCodeLanguage.support)
|
||||||
|
defaultCode = defaultCodeLanguage.language
|
||||||
|
} else if (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)
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
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"
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
||||||
|
// "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")!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Language support for strict CommonMark.
|
||||||
|
export const commonmarkLanguage = mkLang(commonmark)
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}])
|
||||||
|
|
||||||
|
/// Language support for [GFM](https://github.github.com/gfm/) plus
|
||||||
|
/// subscript, superscript, and emoji syntax.
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { safeRun } from "./util";
|
||||||
|
|
||||||
|
export interface IPageNavigator {
|
||||||
|
subscribe(pageLoadCallback: (pageName: string) => Promise<void>): void;
|
||||||
|
navigate(page: string): void;
|
||||||
|
getCurrentPage(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePageUrl(name: string): string {
|
||||||
|
return name.replaceAll(" ", "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePageUrl(url: string): string {
|
||||||
|
return url.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PathPageNavigator implements IPageNavigator {
|
||||||
|
navigationResolve?: (value: undefined) => void;
|
||||||
|
async navigate(page: string) {
|
||||||
|
window.history.pushState({ page: page }, page, `/${encodePageUrl(page)}`);
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
await new Promise<undefined>((resolve) => {
|
||||||
|
this.navigationResolve = resolve;
|
||||||
|
});
|
||||||
|
this.navigationResolve = undefined;
|
||||||
|
}
|
||||||
|
subscribe(pageLoadCallback: (pageName: string) => Promise<void>): void {
|
||||||
|
const cb = () => {
|
||||||
|
const gotoPage = this.getCurrentPage();
|
||||||
|
if (!gotoPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeRun(async () => {
|
||||||
|
await pageLoadCallback(this.getCurrentPage());
|
||||||
|
if (this.navigationResolve) {
|
||||||
|
this.navigationResolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", cb);
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentPage(): string {
|
||||||
|
return decodePageUrl(location.pathname.substring(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HashPageNavigator implements IPageNavigator {
|
||||||
|
navigationResolve?: (value: undefined) => void;
|
||||||
|
async navigate(page: string) {
|
||||||
|
location.hash = encodePageUrl(page);
|
||||||
|
await new Promise<undefined>((resolve) => {
|
||||||
|
this.navigationResolve = resolve;
|
||||||
|
});
|
||||||
|
this.navigationResolve = undefined;
|
||||||
|
}
|
||||||
|
subscribe(pageLoadCallback: (pageName: string) => Promise<void>): void {
|
||||||
|
const cb = () => {
|
||||||
|
safeRun(async () => {
|
||||||
|
await pageLoadCallback(this.getCurrentPage());
|
||||||
|
if (this.navigationResolve) {
|
||||||
|
this.navigationResolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", cb);
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
getCurrentPage(): string {
|
||||||
|
return decodePageUrl(location.hash.substring(1));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { styleTags, tags as t } from "@codemirror/highlight";
|
||||||
|
import {
|
||||||
|
MarkdownConfig,
|
||||||
|
TaskList,
|
||||||
|
BlockContext,
|
||||||
|
LeafBlock,
|
||||||
|
LeafBlockParser,
|
||||||
|
} from "@lezer/markdown";
|
||||||
|
import { commonmark, mkLang } from "./markdown/markdown";
|
||||||
|
import * as ct from "./customtags";
|
||||||
|
import { pageLinkRegex } from "./constant";
|
||||||
|
|
||||||
|
const pageLinkRegexPrefix = new RegExp(
|
||||||
|
"^" + pageLinkRegex.toString().slice(1, -1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const WikiLink: MarkdownConfig = {
|
||||||
|
defineNodes: ["WikiLink", "WikiLinkPage"],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: "WikiLink",
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
let match: RegExpMatchArray | null;
|
||||||
|
if (
|
||||||
|
next != 91 /* '[' */ ||
|
||||||
|
!(match = pageLinkRegexPrefix.exec(cx.slice(pos, cx.end)))
|
||||||
|
) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return cx.addElement(
|
||||||
|
cx.elt("WikiLink", pos, pos + match[0].length + 1, [
|
||||||
|
cx.elt("WikiLinkPage", pos + 2, pos + match[0].length - 2),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
after: "Emphasis",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const AtMention: MarkdownConfig = {
|
||||||
|
defineNodes: ["AtMention"],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: "AtMention",
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
let match: RegExpMatchArray | null;
|
||||||
|
if (
|
||||||
|
next != 64 /* '@' */ ||
|
||||||
|
!(match = /^[A-Za-z\.]+/.exec(cx.slice(pos + 1, cx.end)))
|
||||||
|
) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return cx.addElement(
|
||||||
|
cx.elt("AtMention", pos, pos + 1 + match[0].length)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
after: "Emphasis",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlRegexp =
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
|
||||||
|
|
||||||
|
const UnmarkedUrl: MarkdownConfig = {
|
||||||
|
defineNodes: ["URL"],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: "URL",
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
let match: RegExpMatchArray | null;
|
||||||
|
if (
|
||||||
|
next != 104 /* 'h' */ ||
|
||||||
|
!(match = urlRegexp.exec(cx.slice(pos, cx.end)))
|
||||||
|
) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return cx.addElement(cx.elt("URL", pos, pos + match[0].length));
|
||||||
|
},
|
||||||
|
after: "Emphasis",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
class CommentParser implements LeafBlockParser {
|
||||||
|
nextLine() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(cx: BlockContext, leaf: LeafBlock) {
|
||||||
|
cx.addLeafElement(
|
||||||
|
leaf,
|
||||||
|
cx.elt("Comment", leaf.start, leaf.start + leaf.content.length, [
|
||||||
|
// cx.elt("CommentMarker", leaf.start, leaf.start + 3),
|
||||||
|
...cx.parser.parseInline(leaf.content.slice(3), leaf.start + 3),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const Comment: MarkdownConfig = {
|
||||||
|
defineNodes: [{ name: "Comment", block: true }],
|
||||||
|
parseBlock: [
|
||||||
|
{
|
||||||
|
name: "Comment",
|
||||||
|
leaf(cx, leaf) {
|
||||||
|
return /^%%\s/.test(leaf.content) ? new CommentParser() : null;
|
||||||
|
},
|
||||||
|
after: "SetextHeading",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagLink: MarkdownConfig = {
|
||||||
|
defineNodes: ["TagLink"],
|
||||||
|
parseInline: [
|
||||||
|
{
|
||||||
|
name: "TagLink",
|
||||||
|
parse(cx, next, pos) {
|
||||||
|
let match: RegExpMatchArray | null;
|
||||||
|
if (
|
||||||
|
next != 35 /* '#' */ ||
|
||||||
|
!(match = /^[A-Za-z\.]+/.exec(cx.slice(pos + 1, cx.end)))
|
||||||
|
) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return cx.addElement(cx.elt("TagLink", pos, pos + 1 + match[0].length));
|
||||||
|
},
|
||||||
|
after: "Emphasis",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const WikiMarkdown = commonmark.configure([
|
||||||
|
WikiLink,
|
||||||
|
AtMention,
|
||||||
|
// TagLink,
|
||||||
|
TaskList,
|
||||||
|
UnmarkedUrl,
|
||||||
|
Comment,
|
||||||
|
{
|
||||||
|
props: [
|
||||||
|
styleTags({
|
||||||
|
WikiLink: ct.WikiLinkTag,
|
||||||
|
WikiLinkPage: ct.WikiLinkPageTag,
|
||||||
|
AtMention: ct.MentionTag,
|
||||||
|
TagLink: ct.TagTag,
|
||||||
|
Task: ct.TaskTag,
|
||||||
|
TaskMarker: ct.TaskMarkerTag,
|
||||||
|
Url: t.url,
|
||||||
|
Comment: ct.CommentTag,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default mkLang(WikiMarkdown);
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { Action, AppViewState } from "./types";
|
||||||
|
|
||||||
|
export default function reducer(
|
||||||
|
state: AppViewState,
|
||||||
|
action: Action
|
||||||
|
): AppViewState {
|
||||||
|
// console.log("Got action", action);
|
||||||
|
switch (action.type) {
|
||||||
|
case "page-loaded":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
allPages: new Set(
|
||||||
|
[...state.allPages].map((pageMeta) =>
|
||||||
|
pageMeta.name === action.name
|
||||||
|
? { ...pageMeta, lastOpened: Date.now() }
|
||||||
|
: pageMeta
|
||||||
|
)
|
||||||
|
),
|
||||||
|
currentPage: action.name,
|
||||||
|
};
|
||||||
|
case "start-navigate":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
showPageNavigator: true,
|
||||||
|
};
|
||||||
|
case "stop-navigate":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
showPageNavigator: false,
|
||||||
|
};
|
||||||
|
case "pages-listed":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
allPages: action.pages,
|
||||||
|
};
|
||||||
|
case "show-palette":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
showCommandPalette: true,
|
||||||
|
};
|
||||||
|
case "hide-palette":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
showCommandPalette: false,
|
||||||
|
};
|
||||||
|
case "update-commands":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
commands: action.commands,
|
||||||
|
};
|
||||||
|
case "show-notification":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notifications: [action.notification, ...state.notifications],
|
||||||
|
};
|
||||||
|
case "dismiss-notification":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
notifications: state.notifications.filter((n) => n.id !== action.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { manifest, version } from "@parcel/service-worker";
|
||||||
|
|
||||||
|
async function install() {
|
||||||
|
const cache = await caches.open(version);
|
||||||
|
await cache.addAll(manifest);
|
||||||
|
}
|
||||||
|
//@ts-ignore
|
||||||
|
self.addEventListener("install", (e) => e.waitUntil(install()));
|
||||||
|
|
||||||
|
async function activate() {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map((key) => key !== version && caches.delete(key)));
|
||||||
|
}
|
||||||
|
//@ts-ignore
|
||||||
|
self.addEventListener("activate", (e) => e.waitUntil(activate()));
|
||||||
|
|
||||||
|
self.addEventListener("fetch", function (event) {});
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { KeyBinding } from "@codemirror/view";
|
||||||
|
|
||||||
|
// TODO: Add support for selection (put quotes around or create blockquote block?)
|
||||||
|
function keyBindingForQuote(
|
||||||
|
quote: string,
|
||||||
|
left: string,
|
||||||
|
right: string
|
||||||
|
): KeyBinding {
|
||||||
|
return {
|
||||||
|
key: quote,
|
||||||
|
run: (target): boolean => {
|
||||||
|
let cursorPos = target.state.selection.main.from;
|
||||||
|
let chBefore = target.state.sliceDoc(cursorPos - 1, cursorPos);
|
||||||
|
let quote = right;
|
||||||
|
if (/\W/.exec(chBefore) && !/[!\?,\.\-=“]/.exec(chBefore)) {
|
||||||
|
quote = left;
|
||||||
|
}
|
||||||
|
target.dispatch({
|
||||||
|
changes: {
|
||||||
|
insert: quote,
|
||||||
|
from: cursorPos,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
anchor: cursorPos + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const smartQuoteKeymap: KeyBinding[] = [
|
||||||
|
keyBindingForQuote('"', "“", "”"),
|
||||||
|
keyBindingForQuote("'", "‘", "’"),
|
||||||
|
];
|
|
@ -0,0 +1,191 @@
|
||||||
|
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 "./event";
|
||||||
|
|
||||||
|
export type SpaceEvents = {
|
||||||
|
connect: () => void;
|
||||||
|
pageCreated: (meta: PageMeta) => void;
|
||||||
|
pageChanged: (meta: PageMeta) => void;
|
||||||
|
pageDeleted: (name: string) => void;
|
||||||
|
pageListUpdated: (pages: Set<PageMeta>) => void;
|
||||||
|
} & CollabEvents;
|
||||||
|
|
||||||
|
export type KV = {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Space extends EventEmitter<SpaceEvents> {
|
||||||
|
socket: Socket;
|
||||||
|
reqId = 0;
|
||||||
|
allPages = new Set<PageMeta>();
|
||||||
|
|
||||||
|
constructor(socket: Socket) {
|
||||||
|
super();
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
[
|
||||||
|
"connect",
|
||||||
|
"cursorSnapshot",
|
||||||
|
"pageCreated",
|
||||||
|
"pageChanged",
|
||||||
|
"pageDeleted",
|
||||||
|
].forEach((eventName) => {
|
||||||
|
socket.on(eventName, (...args) => {
|
||||||
|
this.emit(eventName as keyof SpaceEvents, ...args);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private wsCall(eventName: string, ...args: any[]): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.reqId++;
|
||||||
|
this.socket!.once(`${eventName}Resp${this.reqId}`, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.socket!.emit(eventName, this.reqId, ...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPages(): Promise<PageMeta[]> {
|
||||||
|
return Array.from(this.allPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 closePage(name: string): Promise<void> {
|
||||||
|
this.socket.emit("page.closePage", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
|
||||||
|
return this.wsCall("page.readPage", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writePage(name: string, text: string): Promise<PageMeta> {
|
||||||
|
return this.wsCall("page.writePage", name, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePage(name: string): Promise<void> {
|
||||||
|
return this.wsCall("page.deletePage", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPageMeta(name: string): Promise<PageMeta> {
|
||||||
|
return this.wsCall("page.getPageMeta", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexSet(pageName: string, key: string, value: any) {
|
||||||
|
await this.wsCall("index.set", pageName, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexBatchSet(pageName: string, kvs: KV[]) {
|
||||||
|
// TODO: Optimize with batch call
|
||||||
|
for (let { key, value } of kvs) {
|
||||||
|
await this.indexSet(pageName, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexGet(pageName: string, key: string): Promise<any | null> {
|
||||||
|
return await this.wsCall("index.get", pageName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexScanPrefixForPage(
|
||||||
|
pageName: string,
|
||||||
|
keyPrefix: string
|
||||||
|
): Promise<{ key: string; value: any }[]> {
|
||||||
|
return await this.wsCall("index.scanPrefixForPage", pageName, keyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexScanPrefixGlobal(
|
||||||
|
keyPrefix: string
|
||||||
|
): Promise<{ key: string; value: any }[]> {
|
||||||
|
return await this.wsCall("index.scanPrefixGlobal", keyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexDeletePrefixForPage(pageName: string, keyPrefix: string) {
|
||||||
|
await this.wsCall("index.deletePrefixForPage", keyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
async indexDelete(pageName: string, key: string) {
|
||||||
|
await this.wsCall("index.delete", pageName, key);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { HighlightStyle, tags as t } from "@codemirror/highlight";
|
||||||
|
import * as ct from "./customtags";
|
||||||
|
|
||||||
|
export default HighlightStyle.define([
|
||||||
|
{ tag: t.heading1, class: "h1" },
|
||||||
|
{ tag: t.heading2, class: "h2" },
|
||||||
|
{ tag: t.heading3, class: "h3" },
|
||||||
|
{ tag: t.link, class: "link" },
|
||||||
|
{ tag: t.meta, class: "meta" },
|
||||||
|
{ tag: t.quote, class: "quote" },
|
||||||
|
{ tag: t.monospace, class: "code" },
|
||||||
|
{ tag: t.url, class: "url" },
|
||||||
|
{ tag: ct.WikiLinkTag, class: "wiki-link" },
|
||||||
|
{ tag: ct.WikiLinkPageTag, class: "wiki-link-page" },
|
||||||
|
{ tag: ct.TagTag, class: "tag" },
|
||||||
|
{ tag: ct.MentionTag, class: "mention" },
|
||||||
|
{ tag: ct.TaskTag, class: "task" },
|
||||||
|
{ tag: ct.TaskMarkerTag, class: "task-marker" },
|
||||||
|
{ tag: ct.CommentTag, class: "comment" },
|
||||||
|
{ tag: ct.CommentMarkerTag, class: "comment-marker" },
|
||||||
|
{ tag: t.emphasis, class: "emphasis" },
|
||||||
|
{ tag: t.strong, class: "strong" },
|
||||||
|
{ tag: t.atom, class: "atom" },
|
||||||
|
{ tag: t.bool, class: "bool" },
|
||||||
|
{ tag: t.url, class: "url" },
|
||||||
|
{ tag: t.inserted, class: "inserted" },
|
||||||
|
{ tag: t.deleted, class: "deleted" },
|
||||||
|
{ tag: t.literal, class: "literal" },
|
||||||
|
{ tag: t.list, class: "list" },
|
||||||
|
{ tag: t.definition, class: "li" },
|
||||||
|
{ tag: t.string, class: "string" },
|
||||||
|
{ tag: t.number, class: "number" },
|
||||||
|
{ tag: [t.regexp, t.escape, t.special(t.string)], class: "string2" },
|
||||||
|
{ tag: t.variableName, class: "variableName" },
|
||||||
|
{ tag: t.comment, class: "comment" },
|
||||||
|
{ tag: t.invalid, class: "invalid" },
|
||||||
|
{ tag: t.punctuation, class: "punctuation" },
|
||||||
|
]);
|
|
@ -0,0 +1,215 @@
|
||||||
|
.cm-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: var(--ident);
|
||||||
|
|
||||||
|
.cm-content {
|
||||||
|
font-family: var(--editor-font);
|
||||||
|
margin: auto;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
margin-right: -2px;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-label-container {
|
||||||
|
// display: none;
|
||||||
|
position: relative;
|
||||||
|
top: 2ch;
|
||||||
|
float: left;
|
||||||
|
width: 120px;
|
||||||
|
height: 2.2ch;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
border: gray 1px solid;
|
||||||
|
background-color: purple;
|
||||||
|
// font-size: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-label-container label {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-selectionBackground {
|
||||||
|
background-color: #d7e1f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-h1,
|
||||||
|
.line-h2,
|
||||||
|
.line-h3 {
|
||||||
|
background-color: rgba(0, 15, 52, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 2px 2px;
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-h3 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color list item this way */
|
||||||
|
.line-li .meta {
|
||||||
|
color: rgb(0, 123, 19);
|
||||||
|
}
|
||||||
|
/* Then undo other meta */
|
||||||
|
.line-li .meta ~ .meta {
|
||||||
|
color: #650007;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-code {
|
||||||
|
background-color: #efefef;
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-fenced-code {
|
||||||
|
background-color: #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: #650007;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-blockquote {
|
||||||
|
background-color: rgba(220, 220, 220, 0.5);
|
||||||
|
color: #676767;
|
||||||
|
text-indent: -2ch;
|
||||||
|
padding-left: 2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:not(.meta, .url) {
|
||||||
|
color: #0330cb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link.url {
|
||||||
|
color: #7e7d7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url:not(.link) {
|
||||||
|
color: #0330cb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-link-page {
|
||||||
|
color: #0330cb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.wiki-link {
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
color: #0330cb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
color: #8d8d8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background-color: #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indentation of follow-up lines
|
||||||
|
@mixin lineOverflow($baseIndent) {
|
||||||
|
text-indent: -1 * ($baseIndent + 2ch);
|
||||||
|
padding-left: $baseIndent + 2ch;
|
||||||
|
|
||||||
|
&.line-task {
|
||||||
|
text-indent: -1 * ($baseIndent + 6ch);
|
||||||
|
padding-left: $baseIndent + 6ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-blockquote {
|
||||||
|
text-indent: -1 * ($baseIndent + 4ch);
|
||||||
|
padding-left: $baseIndent + 4ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-ul {
|
||||||
|
&.line-li-1 {
|
||||||
|
@include lineOverflow(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2 {
|
||||||
|
@include lineOverflow(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2.line-li-3 {
|
||||||
|
@include lineOverflow(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2.line-li-3.line-li-4 {
|
||||||
|
@include lineOverflow(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2.line-li-3.line-li-4.line-li-5 {
|
||||||
|
@include lineOverflow(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-ol {
|
||||||
|
&.line-li-1 {
|
||||||
|
@include lineOverflow(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2 {
|
||||||
|
@include lineOverflow(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2.line-li-3 {
|
||||||
|
@include lineOverflow(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2.line-li-3.line-li-4 {
|
||||||
|
@include lineOverflow(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.line-li-1.line-li-2.line-li-3.line-li-4.line-li-5 {
|
||||||
|
@include lineOverflow(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-comment {
|
||||||
|
text-indent: -1 * 3ch;
|
||||||
|
padding-left: 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-marker {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-comment {
|
||||||
|
background-color: rgba(255, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
.filter-box {
|
||||||
|
position: absolute;
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
margin: auto;
|
||||||
|
max-width: 500px;
|
||||||
|
height: 600px;
|
||||||
|
background-color: #fff;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 290px;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 100;
|
||||||
|
border: rgb(103, 103, 103) 1px solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: 1px rgb(108, 108, 108) solid;
|
||||||
|
padding: 13px 10px 10px 10px;
|
||||||
|
display: flex;
|
||||||
|
label {
|
||||||
|
color: var(--highlight-color);
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: "Arial";
|
||||||
|
background: transparent;
|
||||||
|
color: #000;
|
||||||
|
border: 0;
|
||||||
|
padding: 3px;
|
||||||
|
outline: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
flex-grow: 100;
|
||||||
|
}
|
||||||
|
input::placeholder {
|
||||||
|
color: rgb(199, 199, 199);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
background-color: #eee;
|
||||||
|
border-bottom: 1px rgb(108, 108, 108) solid;
|
||||||
|
padding: 5px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
padding: 0 8px 0 5px;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
padding-top: -3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option,
|
||||||
|
.selected-option {
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-option {
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option .hint,
|
||||||
|
.selected-option .hint {
|
||||||
|
float: right;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: -4px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
color: #eee;
|
||||||
|
background-color: #212476;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
@use "editor.scss";
|
||||||
|
@use "filter_box.scss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ident: 18px;
|
||||||
|
/* --editor-font: "Avenir"; */
|
||||||
|
--editor-font: "Menlo";
|
||||||
|
--ui-font: "Arial";
|
||||||
|
--top-bar-bg: rgb(41, 41, 41);
|
||||||
|
--highlight-color: #464cfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#top {
|
||||||
|
height: 55px;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: rgb(213, 213, 213);
|
||||||
|
border-bottom: rgb(193, 193, 193) 1px solid;
|
||||||
|
color: rgb(55, 55, 55);
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
padding-top: 12px;
|
||||||
|
max-width: 800px;
|
||||||
|
font-size: 28px;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
float: right;
|
||||||
|
border: rgb(41, 41, 41) 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.current-page {
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #bottom {
|
||||||
|
// position: fixed;
|
||||||
|
// bottom: 0;
|
||||||
|
// left: 0;
|
||||||
|
// right: 0;
|
||||||
|
// height: 20px;
|
||||||
|
// background-color: rgb(232, 232, 232);
|
||||||
|
// color: rgb(79, 78, 78);
|
||||||
|
// border-top: rgb(186, 186, 186) 1px solid;
|
||||||
|
// margin: 0;
|
||||||
|
// padding: 5px 10px;
|
||||||
|
// font-family: var(--ui-font);
|
||||||
|
// font-size: 0.9em;
|
||||||
|
// text-align: right;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// body.keyboard #bottom {
|
||||||
|
// bottom: 250px;
|
||||||
|
// }
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
position: absolute;
|
||||||
|
top: 55px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 800px) {
|
||||||
|
.cm-editor .cm-content {
|
||||||
|
margin: 0 10px !important;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default {
|
||||||
|
"db.put": (key: string, value: any) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
"db.get": (key: string) => {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { Editor } from "../editor";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import { Transaction } from "@codemirror/state";
|
||||||
|
import { PageMeta } from "../types";
|
||||||
|
|
||||||
|
type SyntaxNode = {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureAnchor(expr: any, start: boolean) {
|
||||||
|
var _a;
|
||||||
|
let { source } = expr;
|
||||||
|
let addStart = start && source[0] != "^",
|
||||||
|
addEnd = source[source.length - 1] != "$";
|
||||||
|
if (!addStart && !addEnd) return expr;
|
||||||
|
return new RegExp(
|
||||||
|
`${addStart ? "^" : ""}(?:${source})${addEnd ? "$" : ""}`,
|
||||||
|
(_a = expr.flags) !== null && _a !== void 0
|
||||||
|
? _a
|
||||||
|
: expr.ignoreCase
|
||||||
|
? "i"
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (editor: Editor) => ({
|
||||||
|
"editor.getCurrentPage": (): string => {
|
||||||
|
return editor.currentPage!;
|
||||||
|
},
|
||||||
|
"editor.getText": () => {
|
||||||
|
return editor.editorView?.state.sliceDoc();
|
||||||
|
},
|
||||||
|
"editor.getCursor": (): number => {
|
||||||
|
return editor.editorView!.state.selection.main.from;
|
||||||
|
},
|
||||||
|
"editor.navigate": async (name: string) => {
|
||||||
|
await editor.navigate(name);
|
||||||
|
},
|
||||||
|
"editor.openUrl": async (url: string) => {
|
||||||
|
window.open(url, "_blank")!.focus();
|
||||||
|
},
|
||||||
|
"editor.insertAtPos": (text: string, pos: number) => {
|
||||||
|
editor.editorView!.dispatch({
|
||||||
|
changes: {
|
||||||
|
insert: text,
|
||||||
|
from: pos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"editor.replaceRange": (from: number, to: number, text: string) => {
|
||||||
|
editor.editorView!.dispatch({
|
||||||
|
changes: {
|
||||||
|
insert: text,
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"editor.moveCursor": (pos: number) => {
|
||||||
|
editor.editorView!.dispatch({
|
||||||
|
selection: {
|
||||||
|
anchor: pos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"editor.insertAtCursor": (text: string) => {
|
||||||
|
let editorView = editor.editorView!;
|
||||||
|
let from = editorView.state.selection.main.from;
|
||||||
|
editorView.dispatch({
|
||||||
|
changes: {
|
||||||
|
insert: text,
|
||||||
|
from: from,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
anchor: from + text.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"editor.getSyntaxNodeUnderCursor": (): SyntaxNode | undefined => {
|
||||||
|
const editorState = editor.editorView!.state;
|
||||||
|
let selection = editorState.selection.main;
|
||||||
|
if (selection.empty) {
|
||||||
|
let node = syntaxTree(editorState).resolveInner(selection.from);
|
||||||
|
if (node) {
|
||||||
|
return {
|
||||||
|
name: node.name,
|
||||||
|
text: editorState.sliceDoc(node.from, node.to),
|
||||||
|
from: node.from,
|
||||||
|
to: node.to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor.matchBefore": (
|
||||||
|
regexp: string
|
||||||
|
): { from: number; to: number; text: string } | null => {
|
||||||
|
const editorState = editor.editorView!.state;
|
||||||
|
let selection = editorState.selection.main;
|
||||||
|
let from = selection.from;
|
||||||
|
if (selection.empty) {
|
||||||
|
let line = editorState.doc.lineAt(from);
|
||||||
|
let start = Math.max(line.from, from - 250);
|
||||||
|
let str = line.text.slice(start - line.from, from - line.from);
|
||||||
|
let found = str.search(ensureAnchor(new RegExp(regexp), false));
|
||||||
|
// console.log("Line", line, start, str, new RegExp(regexp), found);
|
||||||
|
return found < 0
|
||||||
|
? null
|
||||||
|
: { from: start + found, to: from, text: str.slice(found) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
"editor.getSyntaxNodeAtPos": (pos: number): SyntaxNode | undefined => {
|
||||||
|
const editorState = editor.editorView!.state;
|
||||||
|
let node = syntaxTree(editorState).resolveInner(pos);
|
||||||
|
if (node) {
|
||||||
|
return {
|
||||||
|
name: node.name,
|
||||||
|
text: editorState.sliceDoc(node.from, node.to),
|
||||||
|
from: node.from,
|
||||||
|
to: node.to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor.dispatch": (change: Transaction) => {
|
||||||
|
editor.editorView!.dispatch(change);
|
||||||
|
},
|
||||||
|
"editor.prompt": (message: string, defaultValue = ""): string | null => {
|
||||||
|
return prompt(message, defaultValue);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Space, KV } from "../space";
|
||||||
|
|
||||||
|
export default (space: Space) => ({
|
||||||
|
"indexer.scanPrefixForPage": async (pageName: string, keyPrefix: string) => {
|
||||||
|
return await space.indexScanPrefixForPage(pageName, keyPrefix);
|
||||||
|
},
|
||||||
|
"indexer.scanPrefixGlobal": async (keyPrefix: string) => {
|
||||||
|
return await space.indexScanPrefixGlobal(keyPrefix);
|
||||||
|
},
|
||||||
|
"indexer.get": async (pageName: string, key: string): Promise<any> => {
|
||||||
|
return await space.indexGet(pageName, key);
|
||||||
|
},
|
||||||
|
"indexer.set": async (pageName: string, key: string, value: any) => {
|
||||||
|
await space.indexSet(pageName, key, value);
|
||||||
|
},
|
||||||
|
"indexer.batchSet": async (pageName: string, kvs: KV[]) => {
|
||||||
|
await space.indexBatchSet(pageName, kvs);
|
||||||
|
},
|
||||||
|
"indexer.delete": async (pageName: string, key: string) => {
|
||||||
|
await space.indexDelete(pageName, key);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Editor } from "../editor";
|
||||||
|
import { PageMeta } from "../types";
|
||||||
|
|
||||||
|
export default (editor: Editor) => ({
|
||||||
|
"space.listPages": (): PageMeta[] => {
|
||||||
|
return [...editor.viewState.allPages];
|
||||||
|
},
|
||||||
|
"space.readPage": async (
|
||||||
|
name: string
|
||||||
|
): Promise<{ text: string; meta: PageMeta }> => {
|
||||||
|
return await editor.space.readPage(name);
|
||||||
|
},
|
||||||
|
"space.writePage": async (name: string, text: string): Promise<PageMeta> => {
|
||||||
|
return await editor.space.writePage(name, text);
|
||||||
|
},
|
||||||
|
"space.deletePage": async (name: string) => {
|
||||||
|
console.log("Clearing page index", name);
|
||||||
|
await editor.space.indexDeletePrefixForPage(name, "");
|
||||||
|
// If we're deleting the current page, navigate to the start page
|
||||||
|
if (editor.currentPage === name) {
|
||||||
|
await editor.navigate("start");
|
||||||
|
}
|
||||||
|
// Remove page from open pages in editor
|
||||||
|
editor.openPages.delete(name);
|
||||||
|
console.log("Deleting page");
|
||||||
|
await editor.space.deletePage(name);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,20 @@
|
||||||
|
// @ts-ignore
|
||||||
|
let frameTest = document.getElementById("main-frame");
|
||||||
|
|
||||||
|
window.addEventListener("message", async (event) => {
|
||||||
|
let messageEvent = event as MessageEvent;
|
||||||
|
let data = messageEvent.data;
|
||||||
|
if (data.type === "iframe_event") {
|
||||||
|
// @ts-ignore
|
||||||
|
window.mainPlug.dispatchEvent(data.data.event, data.data.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
"ui.update": function (doc: any) {
|
||||||
|
// frameTest.contentWindow.postMessage({
|
||||||
|
// type: "loadContent",
|
||||||
|
// doc: doc,
|
||||||
|
// });
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,70 @@
|
||||||
|
import * as plugbox from "../plugbox/types";
|
||||||
|
|
||||||
|
export type NuggetHook = {
|
||||||
|
commands: {
|
||||||
|
[key: string]: CommandDef;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Manifest = plugbox.Manifest<NuggetHook>;
|
||||||
|
|
||||||
|
export type PageMeta = {
|
||||||
|
name: string;
|
||||||
|
lastModified: number;
|
||||||
|
version?: number;
|
||||||
|
lastOpened?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppCommand = {
|
||||||
|
command: CommandDef;
|
||||||
|
run: (arg: any) => Promise<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slashCommandRegexp = /\/[\w\-]*/;
|
||||||
|
|
||||||
|
export interface CommandDef {
|
||||||
|
// Function name to invoke
|
||||||
|
invoke: string;
|
||||||
|
|
||||||
|
// Bind to keyboard shortcut
|
||||||
|
key?: string;
|
||||||
|
mac?: string;
|
||||||
|
|
||||||
|
// If to show in slash invoked menu and if so, with what label
|
||||||
|
// should match slashCommandRegexp
|
||||||
|
slashCommand?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
date: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppViewState = {
|
||||||
|
currentPage?: string;
|
||||||
|
showPageNavigator: boolean;
|
||||||
|
showCommandPalette: boolean;
|
||||||
|
allPages: Set<PageMeta>;
|
||||||
|
commands: Map<string, AppCommand>;
|
||||||
|
notifications: Notification[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialViewState: AppViewState = {
|
||||||
|
showPageNavigator: false,
|
||||||
|
showCommandPalette: false,
|
||||||
|
allPages: new Set(),
|
||||||
|
commands: new Map(),
|
||||||
|
notifications: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| { type: "page-loaded"; name: string }
|
||||||
|
| { type: "pages-listed"; pages: Set<PageMeta> }
|
||||||
|
| { type: "start-navigate" }
|
||||||
|
| { type: "stop-navigate" }
|
||||||
|
| { type: "update-commands"; commands: Map<string, AppCommand> }
|
||||||
|
| { type: "show-palette" }
|
||||||
|
| { type: "hide-palette" }
|
||||||
|
| { type: "show-notification"; notification: Notification }
|
||||||
|
| { type: "dismiss-notification"; id: number };
|
|
@ -0,0 +1,31 @@
|
||||||
|
export function countWords(str: string): number {
|
||||||
|
var matches = str.match(/[\w\d\'\'-]+/gi);
|
||||||
|
return matches ? matches.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readingTime(wordCount: number): number {
|
||||||
|
// 225 is average word reading speed for adults
|
||||||
|
return Math.ceil(wordCount / 225);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeRun(fn: () => Promise<void>) {
|
||||||
|
fn().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMacLike() {
|
||||||
|
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throttle(func: () => void, limit: number) {
|
||||||
|
let timer: any = null;
|
||||||
|
return function () {
|
||||||
|
if (!timer) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
func();
|
||||||
|
timer = null;
|
||||||
|
}, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { Editor } from "./editor";
|
||||||
|
import { safeRun } from "./util";
|
Loading…
Reference in New Issue