Monorepo with yarn workspaces requires yarn 3.2
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Editor } from "./editor";
|
||||
import { safeRun } from "./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";
|
||||
|
||||
// @ts-ignore
|
||||
let url = isDesktop ? window.desktop.url : "";
|
||||
|
||||
let serverSpace = new Space(new HttpSpacePrimitives(url), 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);
|
||||
});
|
||||
}
|
||||
@@ -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 "../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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,19 @@
|
||||
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">
|
||||
<div className="inner">
|
||||
{wordCount} words | {readingTime} min
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,648 @@
|
||||
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/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 "./util";
|
||||
import { System } from "../plugos/system";
|
||||
import { EventHook } from "../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;
|
||||
}
|
||||
}
|
||||
@@ -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})`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Hook, Manifest } from "@silverbulletmd/plugos/types";
|
||||
import { System } from "@silverbulletmd/plugos/system";
|
||||
import { EventEmitter } from "@silverbulletmd/common/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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Hook, Manifest } from "../../plugos/types";
|
||||
import { System } from "../../plugos/system";
|
||||
import { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import { slashCommandRegexp } from "../types";
|
||||
import { safeRun } from "../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 |
@@ -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,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,
|
||||
}
|
||||
);
|
||||
@@ -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,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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Tag } from "@codemirror/highlight";
|
||||
import type { MarkdownConfig } from "@lezer/markdown";
|
||||
import { System } from "@silverbulletmd/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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { safeRun } from "./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();
|
||||
}
|
||||
|
||||
getCurrentPage(): string {
|
||||
let [page] = decodeURI(location.pathname).substring(1).split("@");
|
||||
return decodePageUrl(page);
|
||||
}
|
||||
|
||||
getCurrentPos(): number {
|
||||
let [, pos] = decodeURI(location.pathname).substring(1).split("@");
|
||||
return +pos || 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"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/closebrackets": "^0.19.1",
|
||||
"@codemirror/collab": "^0.19.0",
|
||||
"@codemirror/commands": "^0.19.8",
|
||||
"@codemirror/highlight": "^0.19.0",
|
||||
"@codemirror/history": "^0.19.2",
|
||||
"@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/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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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 } 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),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
{
|
||||
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,
|
||||
}),
|
||||
...mdExtensions.map((mdExt) =>
|
||||
styleTags(mdExtensionStyleTags(mdExt))
|
||||
),
|
||||
],
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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,46 @@
|
||||
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.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.processingInstruction, class: "meta" },
|
||||
// { tag: t.content, class: "tbl-content" },
|
||||
{ tag: t.punctuation, class: "punctuation" },
|
||||
...mdExtension.map((mdExt) => {
|
||||
return { tag: mdExt.tag, ...mdExt.styles };
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
$max-editor-width: 800px;
|
||||
$top-bar-height: 55px;
|
||||
$bottom-bar-height: 30px;
|
||||
@@ -0,0 +1,224 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
@@ -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,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 {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { proxySyscalls } from "../../plugos/syscalls/transport";
|
||||
import { SysCallMapping } from "../../plugos/system";
|
||||
import { storeSyscalls } from "../../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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Editor } from "../editor";
|
||||
import { Transaction } from "@codemirror/state";
|
||||
import { SysCallMapping } from "@silverbulletmd/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!;
|
||||
},
|
||||
"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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SysCallMapping } from "@silverbulletmd/plugos/system";
|
||||
import { proxySyscalls } from "@silverbulletmd/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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Editor } from "../editor";
|
||||
import { SysCallMapping } from "@silverbulletmd/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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SysCallMapping } from "../../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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" };
|
||||
@@ -0,0 +1,31 @@
|
||||
export function countWords(str: string): number {
|
||||
const 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user