Refactor and renaming

This commit is contained in:
Zef Hemel
2022-04-25 10:33:38 +02:00
parent 6a10cbd9b7
commit 44cf4c6a72
114 changed files with 20723 additions and 183 deletions
+21
View File
@@ -0,0 +1,21 @@
import type { ParseTree } from "@silverbulletmd/common/tree";
export type AppEvent = "page:click" | "page:complete";
export type ClickEvent = {
page: string;
pos: number;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
};
export type IndexEvent = {
name: string;
text: string;
};
export type IndexTreeEvent = {
name: string;
tree: ParseTree;
};
+48
View File
@@ -0,0 +1,48 @@
import { Editor } from "./editor";
import { safeRun } from "../common/util";
import { Space } from "@silverbulletmd/common/spaces/space";
import { HttpSpacePrimitives } from "@silverbulletmd/common/spaces/http_space_primitives";
// let localSpace = new Space(new IndexedDBSpacePrimitives("pages"), true);
// localSpace.watch();
// @ts-ignore
let isDesktop = typeof window.desktop !== "undefined";
let serverSpace = new Space(new HttpSpacePrimitives(""), true);
serverSpace.watch();
// // @ts-ignore
// window.syncer = async () => {
// let lastLocalSync = +(localStorage.getItem("lastLocalSync") || "0"),
// lastRemoteSync = +(localStorage.getItem("lastRemoteSync") || "0");
// let syncer = new SpaceSync(
// serverSpace,
// localSpace,
// lastRemoteSync,
// lastLocalSync,
// "_trash/"
// );
// await syncer.syncPages(
// SpaceSync.primaryConflictResolver(serverSpace, localSpace)
// );
// localStorage.setItem("lastLocalSync", "" + syncer.secondaryLastSync);
// localStorage.setItem("lastRemoteSync", "" + syncer.primaryLastSync);
// console.log("Done!");
// };
let editor = new Editor(serverSpace, document.getElementById("root")!);
safeRun(async () => {
await editor.init();
});
// @ts-ignore
window.editor = editor;
if (!isDesktop) {
navigator.serviceWorker
.register(new URL("service_worker.ts", import.meta.url), { type: "module" })
.then((r) => {
console.log("Service worker registered", r);
});
}
+60
View File
@@ -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 { isMacLike } from "../../common/util";
import { FilterList } from "./filter";
import { faPersonRunning } from "@fortawesome/free-solid-svg-icons";
import { AppCommand } from "../hooks/command";
import { FilterOption } from "@silverbulletmd/common/types";
export function CommandPalette({
commands,
onTrigger,
}: {
commands: Map<string, AppCommand>;
onTrigger: (command: AppCommand | undefined) => void;
}) {
let options: FilterOption[] = [];
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,
});
}
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);
}
}}
/>
);
}
+219
View File
@@ -0,0 +1,219 @@
import React, { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FilterOption } from "@silverbulletmd/common/types";
function magicSorter(a: FilterOption, b: FilterOption): number {
if (a.orderId && b.orderId) {
return a.orderId < b.orderId ? -1 : 1;
}
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
}
function escapeRegExp(str: string): string {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
}
function fuzzyFilter(pattern: string, options: FilterOption[]): FilterOption[] {
let closeMatchRegex = escapeRegExp(pattern);
closeMatchRegex = closeMatchRegex.split(/\s+/).join(".*?");
closeMatchRegex = closeMatchRegex.replace(/\\\//g, ".*?\\/.*?");
const distantMatchRegex = escapeRegExp(pattern).split("").join(".*?");
const r1 = new RegExp(closeMatchRegex, "i");
const r2 = new RegExp(distantMatchRegex, "i");
let matches = [];
if (!pattern) {
return options;
}
for (let option of options) {
let m = r1.exec(option.name);
if (m) {
matches.push({
...option,
orderId: 100000 - (options.length - m[0].length - m.index),
});
} else {
// Let's try the distant matcher
var m2 = r2.exec(option.name);
if (m2) {
matches.push({
...option,
orderId: 10000 - (options.length - m2[0].length - m2.index),
});
}
}
}
return matches;
}
function simpleFilter(
pattern: string,
options: FilterOption[]
): FilterOption[] {
const lowerPattern = pattern.toLowerCase();
return options.filter((option) => {
return option.name.toLowerCase().includes(lowerPattern);
});
}
export function FilterList({
placeholder,
options,
label,
onSelect,
onKeyPress,
allowNew = false,
helpText = "",
completePrefix,
icon,
newHint,
}: {
placeholder: string;
options: FilterOption[];
label: string;
onKeyPress?: (key: string, currentText: string) => void;
onSelect: (option: FilterOption | undefined) => void;
allowNew?: boolean;
completePrefix?: string;
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);
function filterUpdate(e: React.ChangeEvent<HTMLInputElement>) {
updateFilter(e.target.value);
}
function updateFilter(originalPhrase: string) {
const searchPhrase = originalPhrase.toLowerCase();
if (searchPhrase) {
let foundExactMatch = false;
let results = simpleFilter(searchPhrase, options);
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(() => {
updateFilter(text);
}, [options]);
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={filterUpdate}
onKeyDown={(e: React.KeyboardEvent) => {
// console.log("Key up", e);
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;
case " ":
if (completePrefix && !text) {
updateFilter(completePrefix);
e.preventDefault();
}
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,49 @@
import { FilterList } from "./filter";
import { FilterOption, PageMeta } from "@silverbulletmd/common/types";
export function PageNavigator({
allPages,
onNavigate,
currentPage,
}: {
allPages: Set<PageMeta>;
onNavigate: (page: string | undefined) => void;
currentPage?: string;
}) {
let options: FilterOption[] = [];
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 in this session
if (pageMeta.lastOpened) {
orderId = -pageMeta.lastOpened;
}
options.push({
...pageMeta,
orderId: orderId,
});
}
let completePrefix: string | undefined = undefined;
if (currentPage && currentPage.includes("/")) {
const pieces = currentPage.split("/");
completePrefix = pieces.slice(0, pieces.length - 1).join("/") + "/";
}
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"
completePrefix={completePrefix}
onSelect={(opt) => {
onNavigate(opt?.name);
}}
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_top">
<script src="panel_page.ts"/>
</head>
<body>
Send me HTML
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
// @ts-ignore
import iframeHtml from "bundle-text:./panel.html";
export function Panel({ html, flex }: { html: string; flex: number }) {
const iFrameRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
function loadContent() {
if (iFrameRef.current?.contentWindow) {
iFrameRef.current.contentWindow.postMessage({
type: "html",
html: html,
});
}
}
if (!iFrameRef.current) {
return;
}
let iframe = iFrameRef.current;
iframe.onload = loadContent;
loadContent();
return () => {
iframe.onload = null;
};
}, [html]);
useEffect(() => {
let messageListener = (evt: any) => {
if (evt.source !== iFrameRef.current!.contentWindow) {
return;
}
let data = evt.data;
if (!data) return;
console.log("Got message from panel", data);
};
window.addEventListener("message", messageListener);
return () => {
window.removeEventListener("message", messageListener);
};
}, []);
return (
<div className="panel" style={{ flex }}>
<iframe srcDoc={iframeHtml} ref={iFrameRef} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
window.addEventListener("message", (message) => {
const data = message.data;
switch (data.type) {
case "html":
document.body.innerHTML = data.html;
break;
}
});
function sendEvent(data: any) {
window.parent.postMessage(
{
type: "event",
data: data,
},
"*"
);
}
//
// setInterval(() => {
// self.sendEvent("testing");
// }, 2000);
+19
View File
@@ -0,0 +1,19 @@
import { EditorView } from "@codemirror/view";
import * as util from "../../common/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">
<div className="inner">
{wordCount} words | {readingTime} min
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Notification } from "../types";
function prettyName(s: string | undefined): string {
if (!s) {
return "";
}
return s.replaceAll("/", " / ");
}
export function TopBar({
pageName,
unsavedChanges,
notifications,
onClick,
lhs,
rhs,
}: {
pageName?: string;
unsavedChanges: boolean;
notifications: Notification[];
onClick: () => void;
lhs?: React.ReactNode;
rhs?: React.ReactNode;
}) {
return (
<div id="top" onClick={onClick}>
{lhs}
<div className="main">
<div className="inner">
{/*<span className={`icon ${unsavedChanges ? "unsaved" : "saved"}`}>*/}
{/* <FontAwesomeIcon icon={faFileLines} />*/}
{/*</span>*/}
<span
className={`current-page ${unsavedChanges ? "unsaved" : "saved"}`}
>
{prettyName(pageName)}
</span>
{/*<button*/}
{/* onClick={(e) => {*/}
{/* // @ts-ignore*/}
{/* window.syncer();*/}
{/* e.stopPropagation();*/}
{/* }}*/}
{/*>*/}
{/* Sync*/}
{/*</button>*/}
{notifications.length > 0 && (
<div className="status">
{notifications.map((notification) => (
<div key={notification.id}>{notification.message}</div>
))}
</div>
)}
</div>
</div>
{rhs}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { Tag } from "@codemirror/highlight";
export const WikiLinkTag = Tag.define();
export const WikiLinkPageTag = Tag.define();
export const CodeInfoTag = 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();
+652
View File
@@ -0,0 +1,652 @@
import {
autocompletion,
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 } from "@codemirror/state";
import {
drawSelection,
dropCursor,
EditorView,
highlightSpecialChars,
KeyBinding,
keymap,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
import React, { useEffect, useReducer } from "react";
import ReactDOM from "react-dom";
import { createSandbox as createIFrameSandbox } from "@plugos/plugos/environments/webworker_sandbox";
import { AppEvent, ClickEvent } from "./app_event";
import * as commands from "./commands";
import { CommandPalette } from "./components/command_palette";
import { PageNavigator } from "./components/page_navigator";
import { TopBar } from "./components/top_bar";
import { lineWrapper } from "./line_wrapper";
import { markdown } from "./markdown";
import { PathPageNavigator } from "./navigator";
import customMarkDown from "./parser";
import buildMarkdown from "./parser";
import reducer from "./reducer";
import { smartQuoteKeymap } from "./smart_quotes";
import { Space } from "@silverbulletmd/common/spaces/space";
import customMarkdownStyle from "./style";
import { editorSyscalls } from "./syscalls/editor";
import { indexerSyscalls } from "./syscalls";
import { spaceSyscalls } from "./syscalls/space";
import { Action, AppViewState, initialViewState } from "./types";
import { SilverBulletHooks } from "@silverbulletmd/common/manifest";
import { safeRun, throttle } from "../common/util";
import { System } from "@plugos/plugos/system";
import { EventHook } from "@plugos/plugos/hooks/event";
import { systemSyscalls } from "./syscalls/system";
import { Panel } from "./components/panel";
import { CommandHook } from "./hooks/command";
import { SlashCommandHook } from "./hooks/slash_command";
import { pasteLinkExtension } from "./editor_paste";
import { markdownSyscalls } from "@silverbulletmd/common/syscalls/markdown";
import { clientStoreSyscalls } from "./syscalls/clientStore";
import { StatusBar } from "./components/status_bar";
import { loadMarkdownExtensions, MDExt } from "./markdown_ext";
import { FilterList } from "./components/filter";
import { FilterOption } from "@silverbulletmd/common/types";
import { syntaxTree } from "@codemirror/language";
class PageState {
scrollTop: number;
selection: EditorSelection;
constructor(scrollTop: number, selection: EditorSelection) {
this.scrollTop = scrollTop;
this.selection = selection;
}
}
const saveInterval = 1000;
export class Editor {
readonly commandHook: CommandHook;
readonly slashCommandHook: SlashCommandHook;
openPages = new Map<string, PageState>();
editorView?: EditorView;
viewState: AppViewState;
viewDispatch: React.Dispatch<Action>;
space: Space;
pageNavigator: PathPageNavigator;
eventHook: EventHook;
saveTimeout: any;
debouncedUpdateEvent = throttle(() => {
this.eventHook
.dispatchEvent("editor:updated")
.catch((e) => console.error("Error dispatching editor:updated event", e));
}, 1000);
private system = new System<SilverBulletHooks>("client");
private mdExtensions: MDExt[] = [];
constructor(space: Space, parent: Element) {
this.space = space;
this.viewState = initialViewState;
this.viewDispatch = () => {};
// Event hook
this.eventHook = new EventHook();
this.system.addHook(this.eventHook);
// Command hook
this.commandHook = new CommandHook();
this.commandHook.on({
commandsUpdated: (commandMap) => {
this.viewDispatch({
type: "update-commands",
commands: commandMap,
});
},
});
this.system.addHook(this.commandHook);
// Slash command hook
this.slashCommandHook = new SlashCommandHook(this);
this.system.addHook(this.slashCommandHook);
this.render(parent);
this.editorView = new EditorView({
state: this.createEditorState("", ""),
parent: document.getElementById("editor")!,
});
this.pageNavigator = new PathPageNavigator();
this.system.registerSyscalls([], editorSyscalls(this));
this.system.registerSyscalls([], spaceSyscalls(this));
this.system.registerSyscalls([], indexerSyscalls(this.space));
this.system.registerSyscalls([], systemSyscalls(this.space));
this.system.registerSyscalls(
[],
markdownSyscalls(buildMarkdown(this.mdExtensions))
);
this.system.registerSyscalls([], clientStoreSyscalls());
}
get currentPage(): string | undefined {
return this.viewState.currentPage;
}
async init() {
this.focus();
this.pageNavigator.subscribe(async (pageName, pos) => {
console.log("Now navigating to", pageName);
if (!this.editorView) {
return;
}
await this.loadPage(pageName);
if (pos) {
this.editorView.dispatch({
selection: { anchor: pos },
});
}
});
let throttledRebuildEditorState = throttle(() => {
this.rebuildEditorState();
}, 100);
this.space.on({
pageCreated: (meta) => {
console.log("Page created", meta);
},
pageDeleted: (meta) => {
console.log("Page delete", meta);
},
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,
});
},
plugLoaded: (plugName, plug) => {
safeRun(async () => {
console.log("Plug load", plugName);
await this.system.load(plugName, plug, createIFrameSandbox);
throttledRebuildEditorState();
});
},
plugUnloaded: (plugName) => {
safeRun(async () => {
console.log("Plug unload", plugName);
await this.system.unload(plugName);
throttledRebuildEditorState();
});
},
});
if (this.pageNavigator.getCurrentPage() === "") {
await this.pageNavigator.navigate("start");
}
}
async save(immediate: boolean = false): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.viewState.unsavedChanges) {
return resolve();
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(
() => {
if (this.currentPage) {
console.log("Saving page", this.currentPage);
this.space
.writePage(
this.currentPage,
this.editorView!.state.sliceDoc(0),
true
)
.then(() => {
this.viewDispatch({ type: "page-saved" });
resolve();
})
.catch(reject);
} else {
resolve();
}
},
immediate ? 0 : saveInterval
);
});
}
flashNotification(message: string) {
let id = Math.floor(Math.random() * 1000000);
this.viewDispatch({
type: "show-notification",
notification: {
id: id,
message: message,
date: new Date(),
},
});
setTimeout(() => {
this.viewDispatch({
type: "dismiss-notification",
id: id,
});
}, 2000);
}
filterBox(
label: string,
options: FilterOption[],
helpText: string = "",
placeHolder: string = ""
): Promise<FilterOption | undefined> {
return new Promise((resolve) => {
this.viewDispatch({
type: "show-filterbox",
label,
options,
placeHolder,
helpText,
onSelect: (option) => {
this.viewDispatch({ type: "hide-filterbox" });
this.focus();
resolve(option);
},
});
});
}
async dispatchAppEvent(name: AppEvent, data?: any): Promise<any[]> {
return this.eventHook.dispatchEvent(name, data);
}
createEditorState(pageName: string, text: string): EditorState {
let commandKeyBindings: KeyBinding[] = [];
for (let def of this.commandHook.editorCommands.values()) {
if (def.command.key) {
commandKeyBindings.push({
key: def.command.key,
mac: def.command.mac,
run: (): boolean => {
if (def.command.contexts) {
let context = this.getContext();
if (!context || !def.command.contexts.includes(context)) {
return false;
}
}
Promise.resolve()
.then(def.run)
.catch((e: any) => {
console.error(e);
this.flashNotification(`Error running command: ${e.message}`);
});
return true;
},
});
}
}
const editor = this;
return EditorState.create({
doc: text,
extensions: [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
customMarkdownStyle(this.mdExtensions),
bracketMatching(),
closeBrackets(),
autocompletion({
override: [
this.completer.bind(this),
this.slashCommandHook.slashCommandCompleter.bind(
this.slashCommandHook
),
],
}),
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" },
{ selector: "TableHeader", class: "line-tbl-header" },
]),
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" });
this.space.updatePageListAsync();
return true;
},
},
{
key: "Ctrl-/",
mac: "Cmd-/",
run: (): boolean => {
let context = this.getContext();
this.viewDispatch({
type: "show-palette",
context,
});
return true;
},
},
{
key: "Ctrl-l",
mac: "Cmd-l",
run: (): boolean => {
this.editorView?.dispatch({
effects: [
EditorView.scrollIntoView(
this.editorView.state.selection.main.anchor,
{
y: "center",
}
),
],
});
return true;
},
},
]),
EditorView.domEventHandlers({
click: (event: MouseEvent, view: EditorView) => {
safeRun(async () => {
let clickEvent: ClickEvent = {
page: pageName,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
pos: view.posAtCoords(event)!,
};
await this.dispatchAppEvent("page:click", clickEvent);
});
},
}),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
if (update.docChanged) {
editor.viewDispatch({ type: "page-changed" });
editor.debouncedUpdateEvent();
editor.save().catch((e) => console.error("Error saving", e));
}
}
}
),
pasteLinkExtension,
markdown({
base: customMarkDown(this.mdExtensions),
}),
],
});
}
rebuildEditorState() {
const editorView = this.editorView;
if (editorView && this.currentPage) {
this.mdExtensions = loadMarkdownExtensions(this.system);
// And reload the syscalls to use the new syntax extensions
this.system.registerSyscalls(
[],
markdownSyscalls(buildMarkdown(this.mdExtensions))
);
editorView.setState(
this.createEditorState(this.currentPage, editorView.state.sliceDoc())
);
if (editorView.contentDOM) {
editorView.contentDOM.spellcheck = true;
}
editorView.focus();
}
}
async completer(): Promise<CompletionResult | null> {
let results = await this.dispatchAppEvent("page:complete");
let actualResult = null;
for (const result of results) {
if (result) {
if (actualResult) {
console.error(
"Got completion results from multiple sources, cannot deal with that"
);
return null;
}
actualResult = result;
}
}
return actualResult;
}
reloadPage() {
console.log("Reloading page");
safeRun(async () => {
clearTimeout(this.saveTimeout);
await this.loadPage(this.currentPage!);
});
}
focus() {
this.editorView!.focus();
}
async navigate(name: string, pos?: number) {
await this.pageNavigator.navigate(name, pos);
}
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;
// console.log("Saved pageState", this.currentPage, pageState);
}
this.space.unwatchPage(this.currentPage);
await this.save(true);
}
// Fetch next page to open
let doc;
try {
doc = await this.space.readPage(pageName);
} catch (e: any) {
// Not found, new page
console.log("Creating new page", pageName);
doc = {
text: "",
meta: { name: pageName, lastModified: 0 },
};
}
let editorState = this.createEditorState(pageName, doc.text);
let pageState = this.openPages.get(pageName);
editorView.setState(editorState);
if (editorView.contentDOM) {
editorView.contentDOM.spellcheck = true;
}
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);
editorView.dispatch({
selection: pageState.selection,
});
editorView.scrollDOM.scrollTop = pageState!.scrollTop;
}
this.space.watchPage(pageName);
this.viewDispatch({
type: "page-loaded",
name: pageName,
});
await this.eventHook.dispatchEvent("editor:pageSwitched");
}
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 () => {
await editor.navigate(page);
});
}
}}
/>
)}
{viewState.showCommandPalette && (
<CommandPalette
onTrigger={(cmd) => {
dispatch({ type: "hide-palette" });
editor!.focus();
if (cmd) {
cmd.run().catch((e) => {
console.error("Error running command", e);
});
}
}}
commands={viewState.commands}
/>
)}
{viewState.showFilterBox && (
<FilterList
label={viewState.filterBoxLabel}
placeholder={viewState.filterBoxPlaceHolder}
options={viewState.filterBoxOptions}
allowNew={false}
// icon={faPersonRunning}
helpText={viewState.filterBoxHelpText}
onSelect={viewState.filterBoxOnSelect}
/>
)}
<TopBar
pageName={viewState.currentPage}
notifications={viewState.notifications}
unsavedChanges={viewState.unsavedChanges}
onClick={() => {
dispatch({ type: "start-navigate" });
}}
rhs={
!!viewState.showRHS && (
<div className="panel" style={{ flex: viewState.showRHS }} />
)
}
lhs={
!!viewState.showLHS && (
<div className="panel" style={{ flex: viewState.showLHS }} />
)
}
/>
<div id="main">
{!!viewState.showLHS && (
<Panel html={viewState.lhsHTML} flex={viewState.showLHS} />
)}
<div id="editor" />
{!!viewState.showRHS && (
<Panel html={viewState.rhsHTML} flex={viewState.showRHS} />
)}
</div>
<StatusBar editorView={editor.editorView} />
</>
);
}
render(container: ReactDOM.Container) {
const ViewComponent = this.ViewComponent.bind(this);
ReactDOM.render(<ViewComponent />, container);
}
private getContext(): string | undefined {
let state = this.editorView!.state;
let selection = state.selection.main;
if (selection.empty) {
return syntaxTree(state).resolveInner(selection.from).name;
}
return;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { ViewPlugin, ViewUpdate } from "@codemirror/view";
const urlRegexp =
/^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
export const pasteLinkExtension = ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
update.transactions.forEach((tr) => {
if (tr.isUserEvent("input.paste")) {
let pastedText: string[] = [];
let from = 0;
let to = 0;
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
pastedText.push(inserted.sliceString(0));
from = fromA;
to = toB;
});
let pastedString = pastedText.join("");
if (pastedString.match(urlRegexp)) {
let selection = update.startState.selection.main;
if (!selection.empty) {
setTimeout(() => {
update.view.dispatch({
changes: [
{
from: from,
to: to,
insert: `[${update.startState.sliceDoc(
selection.from,
selection.to
)}](${pastedString})`,
},
],
});
});
}
}
}
});
}
}
);
+77
View File
@@ -0,0 +1,77 @@
import { Hook, Manifest } from "@plugos/plugos/types";
import { System } from "@plugos/plugos/system";
import { EventEmitter } from "@plugos/plugos/event";
export type CommandDef = {
name: string;
contexts?: string[];
// Bind to keyboard shortcut
key?: string;
mac?: string;
};
export type AppCommand = {
command: CommandDef;
run: () => Promise<void>;
};
export type CommandHookT = {
command?: CommandDef;
};
export type CommandHookEvents = {
commandsUpdated(commandMap: Map<string, AppCommand>): void;
};
export class CommandHook
extends EventEmitter<CommandHookEvents>
implements Hook<CommandHookT>
{
editorCommands = new Map<string, AppCommand>();
buildAllCommands(system: System<CommandHookT>) {
this.editorCommands.clear();
for (let plug of system.loadedPlugs.values()) {
for (const [name, functionDef] of Object.entries(
plug.manifest!.functions
)) {
if (!functionDef.command) {
continue;
}
const cmd = functionDef.command;
this.editorCommands.set(cmd.name, {
command: cmd,
run: () => {
return plug.invoke(name, []);
},
});
}
}
this.emit("commandsUpdated", this.editorCommands);
}
apply(system: System<CommandHookT>): void {
this.buildAllCommands(system);
system.on({
plugLoaded: () => {
this.buildAllCommands(system);
},
});
}
validateManifest(manifest: Manifest<CommandHookT>): string[] {
let errors = [];
for (const [name, functionDef] of Object.entries(manifest.functions)) {
if (!functionDef.command) {
continue;
}
const cmd = functionDef.command;
if (!cmd.name) {
errors.push(`Function ${name} has a command but no name`);
}
}
return [];
}
}
+111
View File
@@ -0,0 +1,111 @@
import { Hook, Manifest } from "@plugos/plugos/types";
import { System } from "@plugos/plugos/system";
import {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import { slashCommandRegexp } from "../types";
import { safeRun } from "../../common/util";
import { Editor } from "../editor";
export type SlashCommandDef = {
name: string;
};
export type AppSlashCommand = {
slashCommand: SlashCommandDef;
run: () => Promise<void>;
};
export type SlashCommandHookT = {
slashCommand?: SlashCommandDef;
};
export class SlashCommandHook implements Hook<SlashCommandHookT> {
slashCommands = new Map<string, AppSlashCommand>();
private editor: Editor;
constructor(editor: Editor) {
this.editor = editor;
}
buildAllCommands(system: System<SlashCommandHookT>) {
this.slashCommands.clear();
for (let plug of system.loadedPlugs.values()) {
for (const [name, functionDef] of Object.entries(
plug.manifest!.functions
)) {
if (!functionDef.slashCommand) {
continue;
}
const cmd = functionDef.slashCommand;
this.slashCommands.set(cmd.name, {
slashCommand: cmd,
run: () => {
return plug.invoke(name, []);
},
});
}
}
}
// Completer for CodeMirror
public slashCommandCompleter(
ctx: CompletionContext
): CompletionResult | null {
let prefix = ctx.matchBefore(slashCommandRegexp);
if (!prefix) {
return null;
}
let options: Completion[] = [];
for (let [name, def] of this.slashCommands.entries()) {
options.push({
label: def.slashCommand.name,
detail: name,
apply: () => {
// Delete slash command part
this.editor.editorView?.dispatch({
changes: {
from: prefix!.from,
to: ctx.pos,
insert: "",
},
});
// Replace with whatever the completion is
safeRun(async () => {
await def.run();
});
},
});
}
return {
// + 1 because of the '/'
from: prefix.from + 1,
options: options,
};
}
apply(system: System<SlashCommandHookT>): void {
this.buildAllCommands(system);
system.on({
plugLoaded: () => {
this.buildAllCommands(system);
},
});
}
validateManifest(manifest: Manifest<SlashCommandHookT>): string[] {
let errors = [];
for (const [name, functionDef] of Object.entries(manifest.functions)) {
if (!functionDef.slashCommand) {
continue;
}
const cmd = functionDef.slashCommand;
if (!cmd.name) {
errors.push(`Function ${name} has a command but no name`);
}
}
return [];
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+15
View File
@@ -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>
+77
View File
@@ -0,0 +1,77 @@
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,
}
);
+17
View File
@@ -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"
}
+366
View File
@@ -0,0 +1,366 @@
import { ChangeSpec, EditorSelection, StateCommand, Text } 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;
};
+78
View File
@@ -0,0 +1,78 @@
import { Prec } from "@codemirror/state";
import { KeyBinding, keymap } from "@codemirror/view";
import { Language, LanguageDescription, LanguageSupport } from "@codemirror/language";
import { MarkdownExtension, MarkdownParser, parseCode } from "@lezer/markdown";
import { html } from "@codemirror/lang-html";
import { commonmarkLanguage, getCodeParser, markdownLanguage, mkLang } from "./markdown";
import { deleteMarkupBackward, insertNewlineContinueMarkup } from "./commands";
export {
commonmarkLanguage,
markdownLanguage,
insertNewlineContinueMarkup,
deleteMarkupBackward,
};
/// A small keymap with Markdown-specific bindings. Binds Enter to
/// [`insertNewlineContinueMarkup`](#lang-markdown.insertNewlineContinueMarkup)
/// and Backspace to
/// [`deleteMarkupBackward`](#lang-markdown.deleteMarkupBackward).
export const markdownKeymap: readonly KeyBinding[] = [
{ key: "Enter", run: insertNewlineContinueMarkup },
{ key: "Backspace", run: deleteMarkupBackward },
];
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);
}
+105
View File
@@ -0,0 +1,105 @@
import {
defineLanguageFacet,
foldNodeProp,
indentNodeProp,
Language,
languageDataProp,
LanguageDescription,
ParseContext
} from "@codemirror/language";
import { styleTags, tags as t } from "@codemirror/highlight";
import { Emoji, GFM, MarkdownParser, parser as baseParser, Subscript, Superscript } from "@lezer/markdown";
const data = defineLanguageFacet({ block: { open: "<!--", close: "-->" } });
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,
"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());
};
}
+66
View File
@@ -0,0 +1,66 @@
import { Tag } from "@codemirror/highlight";
import type { MarkdownConfig } from "@lezer/markdown";
import { System } from "@plugos/plugos/system";
import { Manifest } from "@silverbulletmd/common/manifest";
export type MDExt = {
// unicode char code for efficiency .charCodeAt(0)
firstCharCodes: number[];
regex: RegExp;
nodeType: string;
tag: Tag;
styles: { [key: string]: string };
};
export function mdExtensionSyntaxConfig({
regex,
firstCharCodes,
nodeType,
}: MDExt): MarkdownConfig {
return {
defineNodes: [nodeType],
parseInline: [
{
name: nodeType,
parse(cx, next, pos) {
if (!firstCharCodes.includes(next)) {
return -1;
}
let match = regex.exec(cx.slice(pos, cx.end));
if (!match) {
return -1;
}
return cx.addElement(cx.elt(nodeType, pos, pos + match[0].length));
},
// after: "Emphasis",
},
],
};
}
export function mdExtensionStyleTags({ nodeType, tag }: MDExt): {
[selector: string]: Tag | readonly Tag[];
} {
return {
[nodeType]: tag,
};
}
export function loadMarkdownExtensions(system: System<any>): MDExt[] {
let mdExtensions: MDExt[] = [];
for (let plug of system.loadedPlugs.values()) {
let manifest = plug.manifest as Manifest;
if (manifest.syntax) {
for (let [nodeType, def] of Object.entries(manifest.syntax)) {
mdExtensions.push({
nodeType,
tag: Tag.define(),
firstCharCodes: def.firstCharacters.map((ch) => ch.charCodeAt(0)),
regex: new RegExp("^" + def.regex),
styles: def.styles,
});
}
}
}
return mdExtensions;
}
+69
View File
@@ -0,0 +1,69 @@
import { safeRun } from "../common/util";
function encodePageUrl(name: string): string {
return name.replaceAll(" ", "_");
}
function decodePageUrl(url: string): string {
return url.replaceAll("_", " ");
}
export class PathPageNavigator {
navigationResolve?: () => void;
async navigate(page: string, pos?: number) {
window.history.pushState({ page, pos }, page, `/${encodePageUrl(page)}`);
window.dispatchEvent(
new PopStateEvent("popstate", {
state: { page, pos },
})
);
await new Promise<void>((resolve) => {
this.navigationResolve = resolve;
});
this.navigationResolve = undefined;
}
subscribe(
pageLoadCallback: (pageName: string, pos: number) => Promise<void>
): void {
const cb = (event?: PopStateEvent) => {
const gotoPage = this.getCurrentPage();
if (!gotoPage) {
return;
}
safeRun(async () => {
await pageLoadCallback(
this.getCurrentPage(),
event?.state && event.state.pos
);
if (this.navigationResolve) {
this.navigationResolve();
}
});
};
window.addEventListener("popstate", cb);
cb();
}
decodeURI(): [string, number] {
let parts = decodeURI(location.pathname).substring(1).split("@");
let page =
parts.length > 1 ? parts.slice(0, parts.length - 1).join("@") : parts[0];
let pos = parts.length > 1 ? parts[parts.length - 1] : "0";
if (pos.match(/^\d+$/)) {
return [page, +pos];
} else {
return [`${page}@${pos}`, 0];
}
}
getCurrentPage(): string {
return decodePageUrl(this.decodeURI()[0]);
}
getCurrentPos(): number {
console.log("Pos", this.decodeURI()[1]);
return this.decodeURI()[1];
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@silverbulletmd/web",
"version": "0.0.1",
"license": "MIT",
"scripts": {
"watch": "rm -rf .parcel-cache && parcel watch",
"build": "parcel build",
"clean": "rm -rf dist",
"plugs": "cd plugs && ../plugos/dist/plugos/plugos-bundle.js -w --dist dist */*.plug.yaml",
"server": "nodemon -w dist/server dist/server/server.js pages",
"test": "jest dist/test plugos/dist/test"
},
"targets": {
"webapp": {
"source": [
"index.html"
],
"context": "browser"
}
},
"dependencies": {
"@codemirror/autocomplete": "^0.19.1",
"@codemirror/closebrackets": "^0.19.1",
"@codemirror/commands": "^0.19.8",
"@codemirror/highlight": "^0.19.0",
"@codemirror/history": "^0.19.2",
"@codemirror/lang-html": "^0.19.4",
"@codemirror/lang-javascript": "^0.19.7",
"@codemirror/lang-markdown": "^0.19.6",
"@codemirror/language": "^0.19.0",
"@codemirror/legacy-modes": "^0.19.1",
"@codemirror/matchbrackets": "^0.19.4",
"@codemirror/search": "^0.19.9",
"@codemirror/state": "^0.19.7",
"@codemirror/stream-parser": "^0.19.9",
"@codemirror/text": "^0.19.6",
"@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",
"@jest/globals": "^27.5.1",
"@lezer/markdown": "^0.15.0",
"fake-indexeddb": "^3.1.7",
"jest": "^27.5.1",
"knex": "^1.0.4",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"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/cors": "^2.8.12",
"@types/events": "^3.0.0",
"@types/jest": "^27.4.1",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"assert": "^2.0.0",
"parcel": "2.3.2",
"prettier": "^2.5.1",
"typescript": "^4.6.2"
}
}
+137
View File
@@ -0,0 +1,137 @@
import { styleTags, tags as t } from "@codemirror/highlight";
import {
BlockContext,
LeafBlock,
LeafBlockParser,
MarkdownConfig,
parseCode,
Table,
TaskList,
} from "@lezer/markdown";
import { commonmark, getCodeParser, mkLang } from "./markdown/markdown";
import * as ct from "./customtags";
import {
Language,
LanguageDescription,
LanguageSupport,
} from "@codemirror/language";
import { StreamLanguage } from "@codemirror/stream-parser";
import { yaml } from "@codemirror/legacy-modes/mode/yaml";
import {
javascriptLanguage,
typescriptLanguage,
} from "@codemirror/lang-javascript";
import {
MDExt,
mdExtensionStyleTags,
mdExtensionSyntaxConfig,
} from "./markdown_ext";
export const pageLinkRegex = /^\[\[([^\]]+)\]\]/;
// 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 = pageLinkRegex.exec(cx.slice(pos, cx.end)))
) {
return -1;
}
return cx.addElement(
cx.elt("WikiLink", pos, pos + match[0].length, [
cx.elt("WikiLinkPage", pos + 2, pos + match[0].length - 2),
])
);
},
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",
},
],
};
export default function buildMarkdown(mdExtensions: MDExt[]): Language {
return mkLang(
commonmark.configure([
WikiLink,
TaskList,
Comment,
Table,
...mdExtensions.map(mdExtensionSyntaxConfig),
parseCode({
codeParser: getCodeParser([
LanguageDescription.of({
name: "yaml",
alias: ["meta", "data"],
support: new LanguageSupport(StreamLanguage.define(yaml)),
}),
LanguageDescription.of({
name: "javascript",
alias: ["js"],
support: new LanguageSupport(javascriptLanguage),
}),
LanguageDescription.of({
name: "typescript",
alias: ["ts"],
support: new LanguageSupport(typescriptLanguage),
}),
]),
}),
{
props: [
styleTags({
WikiLink: ct.WikiLinkTag,
WikiLinkPage: ct.WikiLinkPageTag,
Task: ct.TaskTag,
TaskMarker: ct.TaskMarkerTag,
Comment: ct.CommentTag,
"TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark":
t.processingInstruction,
"TableHeader/...": t.heading,
TableCell: t.content,
CodeInfo: ct.CodeInfoTag,
}),
...mdExtensions.map((mdExt) =>
styleTags(mdExtensionStyleTags(mdExt))
),
],
},
])
);
}
+126
View File
@@ -0,0 +1,126 @@
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 "page-changed":
return {
...state,
unsavedChanges: true,
};
case "page-saved":
return {
...state,
unsavedChanges: false,
};
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":
let commands = new Map(state.commands);
for (let [k, v] of state.commands.entries()) {
if (
v.command.contexts &&
(!action.context || !v.command.contexts.includes(action.context))
) {
commands.delete(k);
}
}
return {
...state,
commands,
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),
};
case "show-rhs":
return {
...state,
showRHS: action.flex,
rhsHTML: action.html,
};
case "hide-rhs":
return {
...state,
showRHS: 0,
rhsHTML: "",
};
case "show-lhs":
return {
...state,
showLHS: action.flex,
lhsHTML: action.html,
};
case "hide-lhs":
return {
...state,
showLHS: 0,
lhsHTML: "",
};
case "show-filterbox":
return {
...state,
showFilterBox: true,
filterBoxOnSelect: action.onSelect,
filterBoxPlaceHolder: action.placeHolder,
filterBoxOptions: action.options,
filterBoxLabel: action.label,
filterBoxHelpText: action.helpText,
};
case "hide-filterbox":
return {
...state,
showFilterBox: false,
filterBoxOnSelect: () => {},
filterBoxPlaceHolder: "",
filterBoxOptions: [],
filterBoxHelpText: "",
};
}
return state;
}
+44
View File
@@ -0,0 +1,44 @@
import { manifest, version } from "@parcel/service-worker";
async function install() {
const cache = await caches.open(version);
// console.log("Installing", manifest);
await cache.addAll(manifest);
// console.log("DOne");
}
//@ts-ignore
self.addEventListener("install", (e) => e.waitUntil(install()));
async function activate() {
const keys = await caches.keys();
// console.log("Activating");
await Promise.all(keys.map((key) => key !== version && caches.delete(key)));
// console.log("DOne activating");
}
//@ts-ignore
self.addEventListener("activate", (e) => e.waitUntil(activate()));
self.addEventListener("fetch", (event: any) => {
event.respondWith(
caches.open(version).then(async (cache) => {
let parsedUrl = new URL(event.request.url);
// console.log("Got fetch request", parsedUrl.pathname);
let response = await cache.match(event.request, {
ignoreSearch: true,
});
// console.log("Got cache result", response);
if (response) {
return response;
} else {
if (
parsedUrl.pathname !== "/fs" &&
!parsedUrl.pathname.startsWith("/fs/") &&
!parsedUrl.pathname.startsWith("/plug/")
) {
return cache.match("/index.html");
}
return fetch(event.request);
}
})
);
});
+53
View File
@@ -0,0 +1,53 @@
import { KeyBinding } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
const straightQuoteContexts = ["CommentBlock", "FencedCode", "InlineCode"];
// 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);
// Figure out the context, if in some sort of code/comment fragment don't be smart
let node = syntaxTree(target.state).resolveInner(cursorPos);
while (node) {
if (straightQuoteContexts.includes(node.type.name)) {
return false;
}
if (node.parent) {
node = node.parent;
} else {
break;
}
}
// Ok, still here, let's use a smart quote
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("'", "", ""),
];
+47
View File
@@ -0,0 +1,47 @@
import { HighlightStyle, tags as t } from "@codemirror/highlight";
import * as ct from "./customtags";
import { MDExt } from "./markdown_ext";
export default function highlightStyles(mdExtension: MDExt[]) {
return 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.TaskTag, class: "task" },
{ tag: ct.TaskMarkerTag, class: "task-marker" },
{ tag: ct.CodeInfoTag, class: "code-info" },
{ 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.keyword, class: "keyword" },
{ 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.typeName, class: "typeName" },
{ tag: t.comment, class: "comment" },
{ tag: t.invalid, class: "invalid" },
{ tag: t.processingInstruction, class: "meta" },
// { tag: t.content, class: "tbl-content" },
{ tag: t.punctuation, class: "punctuation" },
...mdExtension.map((mdExt) => {
return { tag: mdExt.tag, ...mdExt.styles };
}),
]);
}
+3
View File
@@ -0,0 +1,3 @@
$max-editor-width: 800px;
$top-bar-height: 55px;
$bottom-bar-height: 30px;
+256
View File
@@ -0,0 +1,256 @@
@import "constants.scss";
//div.rhs-open #editor .cm-editor .cm-content {
// max-width: 550px;
//}
.cm-editor {
font-size: var(--ident);
//overflow-y: hidden;
padding: 0 20px;
.cm-content {
font-family: var(--editor-font);
margin: auto;
max-width: $max-editor-width;
}
&.cm-focused {
outline: none !important;
}
.cm-selectionBackground {
background-color: #d7e1f6 !important;
}
.line-h1,
.line-h2,
.line-h3 {
background-color: rgba(0, 30, 77, 0.5);
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: rgba(72, 72, 72, 0.1);
.code {
background-color: transparent;
}
}
.line-tbl-header {
font-weight: bold;
.meta {
font-weight: normal;
}
}
.struct {
color: darkred;
}
.code {
background-color: rgba(72, 72, 72, 0.1);
}
.line-fenced-code {
background-color: rgba(72, 72, 72, 0.1);
.code {
background-color: transparent;
}
// Set this to defaults
.comment {
color: #989797;
background-color: transparent;
border-radius: 0;
font-style: inherit;
font-size: inherit;
line-height: inherit;
}
.keyword {
color: #830000;
}
.variableName {
color: #036d9b;
}
.typeName {
color: #038138;
}
.string,.string2 {
color: #440377;
}
//.code-info {
// background: black;
// border-radius: 3px;
//}
}
.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;
}
.atom {
color: darkred;
}
.wiki-link-page {
color: #0330cb;
background-color: rgba(77,141,255,0.07);
border-radius: 5px;
padding: 0 5px;
//text-decoration: underline;
}
.wiki-link {
color: #a8abbd;
//background-color: rgba(77,141,255,0.07);
}
// 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);
}
.comment {
color: #989797;
background-color: rgba(210, 210, 210, 0.2);
border-radius: 5px;
font-style: italic;
font-size: 75%;
line-height: 75%;
}
}
+88
View File
@@ -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;
}
}
+122
View File
@@ -0,0 +1,122 @@
@use "editor.scss";
@use "filter_box.scss";
@import "constants";
:root {
--ident: 18px;
/* --editor-font: "Avenir"; */
--editor-font: "Menlo";
--ui-font: "Arial";
--top-bar-bg: rgb(41, 41, 41);
--highlight-color: #464cfc;
}
html,
body {
margin: 0;
height: 100%;
padding: 0;
width: 100%;
overflow: hidden;
}
#root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
#top {
display: flex;
flex-direction: row;
height: $top-bar-height;
background-color: rgb(213, 213, 213);
border-bottom: rgb(193, 193, 193) 1px solid;
color: rgb(55, 55, 55);
.main {
flex: 2;
.inner {
max-width: $max-editor-width;
margin: auto;
font-size: 28px;
padding: 10px 20px;
.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;
}
.saved {
color: #111;
}
.unsaved {
color: #5e5e5e;
}
}
}
.panel {
flex: 1;
}
}
#main {
display: flex;
flex-direction: row;
flex-grow: 1;
height: 0;
.panel {
flex: 1;
border-left: 1px solid #eee;
iframe {
border: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
}
}
#editor {
overflow-y: scroll;
flex: 2;
height: 100%;
}
#bottom {
height: 40px;
line-height: 40px;
padding: 0 10px;
text-align: right;
background-color: rgb(213, 213, 213);
border-top: rgb(193, 193, 193) 1px solid;
.inner {
}
}
+13
View File
@@ -0,0 +1,13 @@
import { proxySyscalls } from "@plugos/plugos/syscalls/transport";
import { SysCallMapping } from "@plugos/plugos/system";
import { storeSyscalls } from "@plugos/plugos/syscalls/store.dexie_browser";
export function clientStoreSyscalls(): SysCallMapping {
const storeCalls = storeSyscalls("local", "localData");
return proxySyscalls(
["clientStore.get", "clientStore.set", "clientStore.delete"],
(ctx, name, ...args) => {
return storeCalls[name.replace("clientStore.", "store.")](ctx, ...args);
}
);
}
+163
View File
@@ -0,0 +1,163 @@
import { Editor } from "../editor";
import { Transaction } from "@codemirror/state";
import { SysCallMapping } from "@plugos/plugos/system";
import { FilterOption } from "@silverbulletmd/common/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 function editorSyscalls(editor: Editor): SysCallMapping {
return {
"editor.getCurrentPage": (): string => {
return editor.currentPage!;
},
// sets the current page name, without changing the content
"editor.setPage": (ctx, newName: string) => {
return editor.viewDispatch({
type: "page-loaded",
name: newName,
});
},
"editor.getText": () => {
return editor.editorView?.state.sliceDoc();
},
"editor.getCursor": (): number => {
return editor.editorView!.state.selection.main.from;
},
"editor.save": async () => {
return editor.save(true);
},
"editor.navigate": async (ctx, name: string, pos: number) => {
await editor.navigate(name, pos);
},
"editor.reloadPage": async (ctx) => {
await editor.reloadPage();
},
"editor.openUrl": async (ctx, url: string) => {
window.open(url, "_blank")!.focus();
},
"editor.flashNotification": (ctx, message: string) => {
editor.flashNotification(message);
},
"editor.filterBox": (
ctx,
label: string,
options: FilterOption[],
helpText: string = "",
placeHolder: string = ""
): Promise<FilterOption | undefined> => {
return editor.filterBox(label, options, helpText, placeHolder);
},
"editor.showRhs": (ctx, html: string, flex: number) => {
editor.viewDispatch({
type: "show-rhs",
flex,
html,
});
},
"editor.hideRhs": (ctx) => {
editor.viewDispatch({
type: "hide-rhs",
});
},
"editor.showLhs": (ctx, html: string, flex: number) => {
editor.viewDispatch({
type: "show-lhs",
flex,
html,
});
},
"editor.hideLhs": (ctx) => {
editor.viewDispatch({
type: "hide-lhs",
});
},
"editor.insertAtPos": (ctx, text: string, pos: number) => {
editor.editorView!.dispatch({
changes: {
insert: text,
from: pos,
},
});
},
"editor.replaceRange": (ctx, from: number, to: number, text: string) => {
editor.editorView!.dispatch({
changes: {
insert: text,
from: from,
to: to,
},
});
},
"editor.moveCursor": (ctx, pos: number) => {
editor.editorView!.dispatch({
selection: {
anchor: pos,
},
});
},
"editor.insertAtCursor": (ctx, 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.matchBefore": (
ctx,
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.dispatch": (ctx, change: Transaction) => {
editor.editorView!.dispatch(change);
},
"editor.prompt": (
ctx,
message: string,
defaultValue = ""
): string | null => {
return prompt(message, defaultValue);
},
};
}
+17
View File
@@ -0,0 +1,17 @@
import { SysCallMapping } from "@plugos/plugos/system";
import { proxySyscalls } from "@plugos/plugos/syscalls/transport";
import { Space } from "@silverbulletmd/common/spaces/space";
export function indexerSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
[
"index.scanPrefixForPage",
"index.scanPrefixGlobal",
"index.get",
"index.set",
"index.batchSet",
"index.delete",
],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args)
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Editor } from "../editor";
import { SysCallMapping } from "@plugos/plugos/system";
import { PageMeta } from "@silverbulletmd/common/types";
export function spaceSyscalls(editor: Editor): SysCallMapping {
return {
"space.listPages": async (): Promise<PageMeta[]> => {
return [...(await editor.space.listPages())];
},
"space.readPage": async (
ctx,
name: string
): Promise<{ text: string; meta: PageMeta }> => {
return await editor.space.readPage(name);
},
"space.writePage": async (
ctx,
name: string,
text: string
): Promise<PageMeta> => {
return await editor.space.writePage(name, text);
},
"space.deletePage": async (ctx, name: string) => {
// 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);
},
};
}
+23
View File
@@ -0,0 +1,23 @@
import { SysCallMapping } from "@plugos/plugos/system";
import { Space } from "@silverbulletmd/common/spaces/space";
export function systemSyscalls(space: Space): SysCallMapping {
return {
"system.invokeFunction": async (
ctx,
env: string,
name: string,
...args: any[]
) => {
if (!ctx.plug) {
throw Error("No plug associated with context");
}
if (env === "client") {
return ctx.plug.invoke(name, args);
}
return space.invokeFunction(ctx.plug, env, name, args);
},
};
}
+76
View File
@@ -0,0 +1,76 @@
import { AppCommand } from "./hooks/command";
import { FilterOption, PageMeta } from "@silverbulletmd/common/types";
export const slashCommandRegexp = /\/[\w\-]*/;
export type Notification = {
id: number;
message: string;
date: Date;
};
export type AppViewState = {
currentPage?: string;
showPageNavigator: boolean;
showCommandPalette: boolean;
unsavedChanges: boolean;
showLHS: number; // 0 = hide, > 0 = flex
showRHS: number; // 0 = hide, > 0 = flex
rhsHTML: string;
lhsHTML: string;
allPages: Set<PageMeta>;
commands: Map<string, AppCommand>;
notifications: Notification[];
showFilterBox: boolean;
filterBoxLabel: string;
filterBoxPlaceHolder: string;
filterBoxOptions: FilterOption[];
filterBoxHelpText: string;
filterBoxOnSelect: (option: FilterOption | undefined) => void;
};
export const initialViewState: AppViewState = {
showPageNavigator: false,
showCommandPalette: false,
unsavedChanges: false,
showLHS: 0,
showRHS: 0,
rhsHTML: "",
lhsHTML: "",
allPages: new Set(),
commands: new Map(),
notifications: [],
showFilterBox: false,
filterBoxHelpText: "",
filterBoxLabel: "",
filterBoxOnSelect: () => {},
filterBoxOptions: [],
filterBoxPlaceHolder: "",
};
export type Action =
| { type: "page-loaded"; name: string }
| { type: "pages-listed"; pages: Set<PageMeta> }
| { type: "page-changed" }
| { type: "page-saved" }
| { type: "start-navigate" }
| { type: "stop-navigate" }
| { type: "update-commands"; commands: Map<string, AppCommand> }
| { type: "show-palette"; context?: string }
| { type: "hide-palette" }
| { type: "show-notification"; notification: Notification }
| { type: "dismiss-notification"; id: number }
| { type: "show-rhs"; html: string; flex: number }
| { type: "hide-rhs" }
| { type: "show-lhs"; html: string; flex: number }
| { type: "hide-lhs" }
| {
type: "show-filterbox";
options: FilterOption[];
placeHolder: string;
helpText: string;
label: string;
onSelect: (option: FilterOption | undefined) => void;
}
| { type: "hide-filterbox" };
View File