Migrate to Deno (#86)

Big bang migration to Deno 🤯
This commit is contained in:
Zef Hemel
2022-10-10 14:50:21 +02:00
committed by GitHub
parent 78f83c70d8
commit 561aa6891f
287 changed files with 4577 additions and 25087 deletions
+26
View File
@@ -0,0 +1,26 @@
import type { ParseTree } from "../common/tree.ts";
export type AppEvent =
| "page:click"
| "page:complete"
| "page:load"
| "editor:init"
| "plugs:loaded";
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;
};
+64
View File
@@ -0,0 +1,64 @@
import { Editor } from "./editor.tsx";
import { parseYamlSettings, safeRun } from "../common/util.ts";
import { Space } from "../common/spaces/space.ts";
import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
safeRun(async () => {
let password: string | undefined = localStorage.getItem("password") ||
undefined;
let httpPrimitives = new HttpSpacePrimitives("", password);
let settingsPageText = "";
while (true) {
try {
settingsPageText = (
await httpPrimitives.readFile("SETTINGS.md", "string")
).data as string;
break;
} catch (e: any) {
if (e.message === "Unauthorized") {
password = prompt("Password: ") || undefined;
if (!password) {
alert("Sorry, need a password");
return;
}
localStorage.setItem("password", password!);
httpPrimitives = new HttpSpacePrimitives("", password);
}
}
}
const serverSpace = new Space(httpPrimitives);
serverSpace.watch();
console.log("Booting...");
const settings = parseYamlSettings(settingsPageText);
const editor = new Editor(
serverSpace,
document.getElementById("sb-root")!,
"",
settings.indexPage || "index",
);
await editor.init();
// @ts-ignore: for convenience
window.editor = editor;
});
// if (localStorage.getItem("disable_sw") !== "true") {
if (navigator.serviceWorker) {
navigator.serviceWorker
.register(new URL("service_worker.js", location.href), {
type: "module",
})
.then((r) => {
console.log("Service worker registered...");
});
} else {
console.log(
"No launching service worker (not present, maybe because not running on localhost or over SSL)",
);
}
// } else {
// console.log("Service worker disabled via disable_sw");
// }
+60
View File
@@ -0,0 +1,60 @@
import { EditorSelection, StateCommand, Transaction } from "@codemirror/state";
import { Text } from "@codemirror/state";
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;
};
}
+44
View File
@@ -0,0 +1,44 @@
import { isMacLike } from "../../common/util.ts";
import { FilterList } from "./filter.tsx";
import { faPersonRunning } from "../deps.ts";
import { AppCommand } from "../hooks/command.ts";
import { FilterOption } from "../../common/types.ts";
export function CommandPalette({
commands,
recentCommands,
onTrigger,
}: {
commands: Map<string, AppCommand>;
recentCommands: Map<string, Date>;
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,
orderId: recentCommands.has(name)
? -recentCommands.get(name)!.getTime()
: 0,
});
}
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);
}
}}
/>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useRef, useState } from "../deps.ts";
import { FontAwesomeIcon } from "../deps.ts";
import type { IconDefinition } from "../deps.ts";
import { FilterOption } from "../../common/types.ts";
import fuzzysort from "https://esm.sh/fuzzysort@2.0.1";
function magicSorter(a: FilterOption, b: FilterOption): number {
if (a.orderId && b.orderId) {
return a.orderId < b.orderId ? -1 : 1;
}
if (a.orderId) {
return -1;
}
if (b.orderId) {
return 1;
}
return 0;
}
type FilterResult = FilterOption & {
result?: any;
};
function simpleFilter(
pattern: string,
options: FilterOption[],
): FilterOption[] {
const lowerPattern = pattern.toLowerCase();
return options.filter((option) => {
return option.name.toLowerCase().includes(lowerPattern);
});
}
function escapeHtml(unsafe: string): string {
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function fuzzySorter(pattern: string, options: FilterOption[]): FilterResult[] {
return fuzzysort
.go(pattern, options, {
all: true,
key: "name",
})
.map((result: any) => ({ ...result.obj, result: result }))
.sort(magicSorter);
}
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(
fuzzySorter("", options),
);
const [selectedOption, setSelectionOption] = useState(0);
const selectedElementRef = useRef<HTMLDivElement>(null);
function updateFilter(originalPhrase: string) {
const foundExactMatch = false;
const results = fuzzySorter(originalPhrase, options);
if (allowNew && !foundExactMatch && originalPhrase) {
results.push({
name: originalPhrase,
hint: newHint,
});
}
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);
};
}, []);
let exiting = false;
const returnEl = (
<div className="sb-filter-wrapper">
<div className="sb-filter-box">
<div className="sb-header">
<label>{label}</label>
<input
type="text"
value={text}
placeholder={placeholder}
ref={searchBoxRef}
// onChange={filterUpdate}
onBlur={(e) => {
if (!exiting) {
searchBoxRef.current!.focus();
}
}}
onKeyDown={(e) => {
// 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":
exiting = true;
onSelect(matchingOptions[selectedOption]);
e.preventDefault();
break;
case "PageUp":
setSelectionOption(Math.max(0, selectedOption - 5));
break;
case "PageDown":
setSelectionOption(Math.max(0, selectedOption + 5));
break;
case "Home":
setSelectionOption(0);
break;
case "End":
setSelectionOption(matchingOptions.length - 1);
break;
case "Escape":
exiting = true;
onSelect(undefined);
e.preventDefault();
break;
case " ":
if (completePrefix && !text) {
updateFilter(completePrefix);
e.preventDefault();
}
break;
default:
setTimeout(() => {
updateFilter((e.target as any).value);
});
}
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
<div
className="sb-help-text"
dangerouslySetInnerHTML={{ __html: helpText }}
>
</div>
<div className="sb-result-list">
{matchingOptions && matchingOptions.length > 0
? matchingOptions.map((option, idx) => (
<div
key={"" + idx}
ref={selectedOption === idx ? selectedElementRef : undefined}
className={selectedOption === idx
? "sb-selected-option"
: "sb-option"}
onMouseOver={(e) => {
setSelectionOption(idx);
}}
onClick={(e) => {
e.preventDefault();
onSelect(option);
}}
>
<span className="sb-icon">
{icon && <FontAwesomeIcon icon={icon} />}
</span>
<span
className="sb-name"
dangerouslySetInnerHTML={{
__html: option?.result?.indexes
? fuzzysort.highlight(option.result, "<b>", "</b>")!
: escapeHtml(option.name),
}}
>
</span>
{option.hint && <span className="sb-hint">{option.hint}</span>}
</div>
))
: null}
</div>
</div>
</div>
);
useEffect(() => {
selectedElementRef.current?.scrollIntoView({
block: "nearest",
});
});
return returnEl;
}
+53
View File
@@ -0,0 +1,53 @@
import { FilterList } from "./filter.tsx";
import { FilterOption, PageMeta } from "../../common/types.ts";
export function PageNavigator({
allPages,
onNavigate,
currentPage,
}: {
allPages: Set<PageMeta>;
onNavigate: (page: string | undefined) => void;
currentPage?: string;
}) {
const options: FilterOption[] = [];
for (const pageMeta of allPages) {
// 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;
}
// Or it's the currently open page
if (currentPage && currentPage === pageMeta.name) {
// ... then we put it all the way to the end
orderId = Infinity;
}
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("/") + "/";
} else if (currentPage && currentPage.includes(" ")) {
completePrefix = currentPage.split(" ")[0] + " ";
}
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);
}}
/>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useRef } from "../deps.ts";
import { Editor } from "../editor.tsx";
import { PanelConfig } from "../types.ts";
const panelHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base target="_top">
<script>
window.addEventListener("message", (message) => {
const data = message.data;
switch (data.type) {
case "html":
document.body.innerHTML = data.html;
if (data.script) {
try {
eval(data.script);
} catch (e) {
console.error("Error evaling script", e);
}
}
break;
}
});
function sendEvent(name, ...args) {
window.parent.postMessage(
{
type: "event",
name,
args,
},
"*"
);
}
</script>
</head>
<body>
Send me HTML
</body>
</html>`;
export function Panel({
config,
editor,
}: {
config: PanelConfig;
editor: Editor;
}) {
const iFrameRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
function loadContent() {
if (iFrameRef.current?.contentWindow) {
iFrameRef.current.contentWindow.postMessage({
type: "html",
html: config.html,
script: config.script,
});
}
}
if (!iFrameRef.current) {
return;
}
const iframe = iFrameRef.current;
iframe.onload = loadContent;
loadContent();
return () => {
iframe.onload = null;
};
}, [config.html, config.script]);
useEffect(() => {
const messageListener = (evt: any) => {
if (evt.source !== iFrameRef.current!.contentWindow) {
return;
}
const data = evt.data;
if (!data) {
return;
}
if (data.type === "event") {
editor.dispatchAppEvent(data.name, ...data.args);
}
};
globalThis.addEventListener("message", messageListener);
return () => {
globalThis.removeEventListener("message", messageListener);
};
}, []);
return (
<div className="sb-panel" style={{ flex: config.mode }}>
<iframe srcDoc={panelHtml} ref={iFrameRef} />
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
// import { Fragment, h } from "../deps.ts";
import {
faHome,
faMoon,
faRunning,
faSun,
} from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0";
import { FontAwesomeIcon } from "../deps.ts";
import { ComponentChildren, useState } from "../deps.ts";
import { Notification } from "../types.ts";
import { isMacLike } from "../../common/util.ts";
function prettyName(s: string | undefined): string {
if (!s) {
return "";
}
return s.replaceAll("/", " / ");
}
export function TopBar({
pageName,
unsavedChanges,
isLoading,
notifications,
onClick,
onThemeClick,
onHomeClick,
onActionClick,
lhs,
rhs,
}: {
pageName?: string;
unsavedChanges: boolean;
isLoading: boolean;
notifications: Notification[];
onClick: () => void;
onThemeClick: () => void;
onHomeClick: () => void;
onActionClick: () => void;
lhs?: ComponentChildren;
rhs?: ComponentChildren;
}) {
const [theme, setTheme] = useState<string>(localStorage.theme ?? "light");
const isMac = isMacLike();
return (
<div id="sb-top" onClick={onClick}>
{lhs}
<div className="main">
<div className="inner">
<span
className={`sb-current-page ${
isLoading
? "sb-loading"
: unsavedChanges
? "sb-unsaved"
: "sb-saved"
}`}
>
{prettyName(pageName)}
</span>
{notifications.length > 0 && (
<div className="sb-notifications">
{notifications.map((notification) => (
<div
key={notification.id}
className={`sb-notification-${notification.type}`}
>
{notification.message}
</div>
))}
</div>
)}
<div className="sb-actions">
<button
onClick={(e) => {
onHomeClick();
e.stopPropagation();
}}
title="Navigate to the 'index' page"
>
<FontAwesomeIcon icon={faHome} />
</button>
<button
onClick={(e) => {
onActionClick();
e.stopPropagation();
}}
title={"Open the command palette (" + (isMac ? "Cmd" : "Ctrl") +
"+/)"}
>
<FontAwesomeIcon icon={faRunning} />
</button>
<button
onClick={(e) => {
onThemeClick();
setTheme(localStorage.theme ?? "light");
e.stopPropagation();
}}
title="Toggle theme"
>
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
</button>
</div>
</div>
</div>
{rhs}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export * from "../common/deps.ts";
export {
Fragment,
h,
render as preactRender,
} from "https://esm.sh/preact@10.11.1";
export type { ComponentChildren } from "https://esm.sh/preact@10.11.1";
export {
useEffect,
useReducer,
useRef,
useState,
} from "https://esm.sh/preact@10.11.1/hooks";
export { FontAwesomeIcon } from "https://esm.sh/@aduh95/preact-fontawesome@0.1.5?external=@fortawesome/fontawesome-common-types";
export { faPersonRunning } from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0";
export type { IconDefinition } from "https://esm.sh/@fortawesome/free-solid-svg-icons@6.2.0";
+872
View File
@@ -0,0 +1,872 @@
import { preactRender, useEffect, useReducer } from "./deps.ts";
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
CompletionResult,
drawSelection,
dropCursor,
EditorSelection,
EditorState,
EditorView,
highlightSpecialChars,
history,
historyKeymap,
indentOnInput,
indentWithTab,
javascriptLanguage,
KeyBinding,
keymap,
LanguageDescription,
LanguageSupport,
runScopeHandlers,
searchKeymap,
standardKeymap,
StreamLanguage,
syntaxHighlighting,
syntaxTree,
typescriptLanguage,
ViewPlugin,
ViewUpdate,
yamlLanguage,
} from "../common/deps.ts";
import { SilverBulletHooks } from "../common/manifest.ts";
// import { markdown } from "../common/_markdown/index.ts";
import { markdown } from "../common/deps.ts";
import { loadMarkdownExtensions, MDExt } from "../common/markdown_ext.ts";
import buildMarkdown from "../common/parser.ts";
import { Space } from "../common/spaces/space.ts";
import { markdownSyscalls } from "../common/syscalls/markdown.ts";
import { FilterOption, PageMeta } from "../common/types.ts";
import { safeRun, throttle } from "../common/util.ts";
import { createSandbox as createIFrameSandbox } from "../plugos/environments/webworker_sandbox.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { eventSyscalls } from "../plugos/syscalls/event.ts";
import sandboxSyscalls from "../plugos/syscalls/sandbox.ts";
import { System } from "../plugos/system.ts";
import { AppEvent, ClickEvent } from "./app_event.ts";
import { CommandPalette } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { PageNavigator } from "./components/page_navigator.tsx";
import { Panel } from "./components/panel.tsx";
import { TopBar } from "./components/top_bar.tsx";
import { attachmentExtension, pasteLinkExtension } from "./editor_paste.ts";
import { CommandHook } from "./hooks/command.ts";
import { SlashCommandHook } from "./hooks/slash_command.ts";
import { inlineImagesPlugin } from "./inline_image.ts";
import { lineWrapper } from "./line_wrapper.ts";
import { PathPageNavigator } from "./navigator.ts";
import reducer from "./reducer.ts";
import { smartQuoteKeymap } from "./smart_quotes.ts";
import customMarkdownStyle from "./style.ts";
import { clientStoreSyscalls } from "./syscalls/clientStore.ts";
import { editorSyscalls } from "./syscalls/editor.ts";
import { fulltextSyscalls } from "./syscalls/fulltext.ts";
import { indexerSyscalls } from "./syscalls/index.ts";
import { spaceSyscalls } from "./syscalls/space.ts";
import { storeSyscalls } from "./syscalls/store.ts";
import { systemSyscalls } from "./syscalls/system.ts";
import { Action, AppViewState, initialViewState } from "./types.ts";
import assetSyscalls from "../plugos/syscalls/asset.ts";
class PageState {
constructor(
readonly scrollTop: number,
readonly selection: EditorSelection,
) {}
}
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[] = [];
urlPrefix: string;
indexPage: string;
constructor(
space: Space,
parent: Element,
urlPrefix: string,
indexPage: string,
) {
this.space = space;
this.urlPrefix = urlPrefix;
this.viewState = initialViewState;
this.viewDispatch = () => {};
this.indexPage = indexPage;
// 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("sb-editor")!,
});
this.pageNavigator = new PathPageNavigator(indexPage, urlPrefix);
this.system.registerSyscalls(
[],
eventSyscalls(this.eventHook),
editorSyscalls(this),
spaceSyscalls(this),
indexerSyscalls(this.space),
fulltextSyscalls(this.space),
systemSyscalls(this),
markdownSyscalls(buildMarkdown(this.mdExtensions)),
clientStoreSyscalls(),
storeSyscalls(this.space),
sandboxSyscalls(this.system),
assetSyscalls(this.system),
);
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
globalThis.addEventListener("keydown", (ev) => {
if (!this.editorView?.hasFocus) {
// console.log(
// "Window-level keyboard event",
// ev
// );
if ((ev.target as any).classList.contains("cm-textfield")) {
// Search & replace feature, ignore this
return;
}
if (runScopeHandlers(this.editorView!, ev, "editor")) {
ev.preventDefault();
}
}
});
globalThis.addEventListener("touchstart", (ev) => {
// Launch the command palette using a three-finger tap
if (ev.touches.length > 2) {
ev.stopPropagation();
ev.preventDefault();
this.viewDispatch({ type: "show-palette" });
}
});
}
get currentPage(): string | undefined {
return this.viewState.currentPage;
}
async init() {
this.focus();
this.pageNavigator.subscribe(async (pageName, pos: number | string) => {
console.log("Now navigating to", pageName);
if (!this.editorView) {
return;
}
const stateRestored = await this.loadPage(pageName);
if (pos) {
if (typeof pos === "string") {
// console.log("Navigating to anchor", pos);
// We're going to look up the anchor through a direct page store query...
const posLookup = await this.system.localSyscall(
"core",
"index.get",
[
pageName,
`a:${pageName}:@${pos}`,
],
);
if (!posLookup) {
return this.flashNotification(
`Could not find anchor @${pos}`,
"error",
);
} else {
pos = +posLookup;
}
}
this.editorView.dispatch({
selection: { anchor: pos },
scrollIntoView: true,
});
} else if (!stateRestored) {
this.editorView.dispatch({
selection: { anchor: 0 },
scrollIntoView: true,
});
}
});
const globalModules: any = await (
await fetch(`${this.urlPrefix}/global.plug.json`)
).json();
this.system.on({
plugLoaded: async (plug) => {
for (
const [modName, code] of Object.entries(
globalModules.dependencies,
)
) {
await plug.sandbox.loadDependency(modName, code as string);
}
},
});
this.space.on({
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,
});
},
});
await this.reloadPlugs();
await this.dispatchAppEvent("editor:init");
}
save(immediate = 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((e) => {
this.flashNotification(
"Could not save page, retrying again in 10 seconds",
"error",
);
this.saveTimeout = setTimeout(this.save.bind(this), 10000);
reject(e);
});
} else {
resolve();
}
},
immediate ? 0 : saveInterval,
);
});
}
flashNotification(message: string, type: "info" | "error" = "info") {
const id = Math.floor(Math.random() * 1000000);
this.viewDispatch({
type: "show-notification",
notification: {
id,
type,
message,
date: new Date(),
},
});
setTimeout(
() => {
this.viewDispatch({
type: "dismiss-notification",
id: id,
});
},
type === "info" ? 2000 : 5000,
);
}
filterBox(
label: string,
options: FilterOption[],
helpText = "",
placeHolder = "",
): 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);
},
});
});
}
dispatchAppEvent(name: AppEvent, data?: any): Promise<any[]> {
return this.eventHook.dispatchEvent(name, data);
}
createEditorState(pageName: string, text: string): EditorState {
const commandKeyBindings: KeyBinding[] = [];
for (const 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) {
const 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}`,
"error",
);
})
.then(() => {
// Always be focusing the editor after running a command
editor.focus();
});
return true;
},
});
}
}
// deno-lint-ignore no-this-alias
const editor = this;
return EditorState.create({
doc: text,
extensions: [
markdown({
base: buildMarkdown(this.mdExtensions),
codeLanguages: [
LanguageDescription.of({
name: "yaml",
alias: ["meta", "data"],
support: new LanguageSupport(StreamLanguage.define(yamlLanguage)),
}),
LanguageDescription.of({
name: "javascript",
alias: ["js"],
support: new LanguageSupport(javascriptLanguage),
}),
LanguageDescription.of({
name: "typescript",
alias: ["ts"],
support: new LanguageSupport(typescriptLanguage),
}),
],
addKeymap: true,
}),
syntaxHighlighting(customMarkdownStyle(this.mdExtensions)),
autocompletion({
override: [
this.completer.bind(this),
this.slashCommandHook.slashCommandCompleter.bind(
this.slashCommandHook,
),
],
}),
inlineImagesPlugin(),
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
indentOnInput(),
EditorView.lineWrapping,
lineWrapper([
{ selector: "ATXHeading1", class: "sb-line-h1" },
{ selector: "ATXHeading2", class: "sb-line-h2" },
{ selector: "ATXHeading3", class: "sb-line-h3" },
{ selector: "ListItem", class: "sb-line-li", nesting: true },
{ selector: "Blockquote", class: "sb-line-blockquote" },
{ selector: "Task", class: "sb-line-task" },
{ selector: "CodeBlock", class: "sb-line-code" },
{ selector: "FencedCode", class: "sb-line-fenced-code" },
{ selector: "Comment", class: "sb-line-comment" },
{ selector: "BulletList", class: "sb-line-ul" },
{ selector: "OrderedList", class: "sb-line-ol" },
{ selector: "TableHeader", class: "sb-line-tbl-header" },
]),
keymap.of([
...smartQuoteKeymap,
...closeBracketsKeymap,
...standardKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
indentWithTab,
...commandKeyBindings,
{
key: "Ctrl-k",
mac: "Cmd-k",
run: (): boolean => {
this.viewDispatch({ type: "start-navigate" });
this.space.updatePageList();
return true;
},
},
{
key: "Ctrl-/",
mac: "Cmd-/",
run: (): boolean => {
this.viewDispatch({
type: "show-palette",
context: this.getContext(),
});
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 () => {
const 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,
attachmentExtension(this),
closeBrackets(),
],
});
}
async reloadPlugs() {
console.log("Loading plugs");
await this.space.updatePageList();
await this.system.unloadAll();
console.log("(Re)loading plugs");
for (const plugName of await this.space.listPlugs()) {
// console.log("Loading plug", pageInfo.name);
const { data } = await this.space.readAttachment(plugName, "string");
await this.system.load(JSON.parse(data as string), createIFrameSandbox);
}
this.rebuildEditorState();
await this.dispatchAppEvent("plugs:loaded");
}
rebuildEditorState() {
const editorView = this.editorView;
console.log("Rebuilding editor state");
if (editorView && this.currentPage) {
console.log("Getting all syntax extensions");
this.mdExtensions = loadMarkdownExtensions(this.system);
// And reload the syscalls to use the new syntax extensions
this.system.registerSyscalls(
[],
markdownSyscalls(buildMarkdown(this.mdExtensions)),
);
this.saveState(this.currentPage);
editorView.setState(
this.createEditorState(this.currentPage, editorView.state.sliceDoc()),
);
if (editorView.contentDOM) {
this.tweakEditorDOM(
editorView.contentDOM,
this.viewState.perm === "ro",
);
}
this.restoreState(this.currentPage);
}
}
async completer(): Promise<CompletionResult | null> {
const 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 | string, replaceState = false) {
if (!name) {
name = this.indexPage;
}
await this.pageNavigator.navigate(name, pos, replaceState);
}
async loadPage(pageName: string): Promise<boolean> {
const loadingDifferentPage = pageName !== this.currentPage;
const editorView = this.editorView;
if (!editorView) {
return false;
}
const previousPage = this.currentPage;
// Persist current page state and nicely close page
if (previousPage) {
this.saveState(previousPage);
this.space.unwatchPage(previousPage);
await this.save(true);
}
this.viewDispatch({
type: "page-loading",
name: pageName,
});
// 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, perm: "rw" } as PageMeta,
};
}
const editorState = this.createEditorState(pageName, doc.text);
editorView.setState(editorState);
if (editorView.contentDOM) {
this.tweakEditorDOM(editorView.contentDOM, doc.meta.perm === "ro");
}
const stateRestored = this.restoreState(pageName);
this.space.watchPage(pageName);
this.viewDispatch({
type: "page-loaded",
meta: doc.meta,
});
if (loadingDifferentPage) {
await this.eventHook.dispatchEvent("editor:pageLoaded", pageName);
} else {
await this.eventHook.dispatchEvent("editor:pageReloaded", pageName);
}
return stateRestored;
}
tweakEditorDOM(contentDOM: HTMLElement, readOnly: boolean) {
contentDOM.spellcheck = true;
contentDOM.setAttribute("autocorrect", "on");
contentDOM.setAttribute("autocapitalize", "on");
contentDOM.setAttribute(
"contenteditable",
readOnly || this.viewState.forcedROMode ? "false" : "true",
);
if (isMobileSafari() && readOnly) {
console.log("Safari read only hack");
contentDOM.classList.add("ios-safari-readonly");
} else {
contentDOM.classList.remove("ios-safari-readonly");
}
function isMobileSafari() {
return (
navigator.userAgent.match(/(iPod|iPhone|iPad)/) &&
navigator.userAgent.match(/AppleWebKit/)
);
}
}
private restoreState(pageName: string): boolean {
const pageState = this.openPages.get(pageName);
const editorView = this.editorView!;
if (pageState) {
// Restore state
// console.log("Restoring selection state", pageState);
editorView.scrollDOM.scrollTop = pageState!.scrollTop;
editorView.dispatch({
selection: pageState.selection,
scrollIntoView: true,
});
} else {
editorView.scrollDOM.scrollTop = 0;
editorView.dispatch({
selection: { anchor: 0 },
scrollIntoView: true,
});
}
editorView.focus();
return !!pageState;
}
private saveState(currentPage: string) {
this.openPages.set(
currentPage,
new PageState(
this.editorView!.scrollDOM.scrollTop,
this.editorView!.state.selection,
),
);
}
ViewComponent() {
const [viewState, dispatch] = useReducer(reducer, initialViewState);
this.viewState = viewState;
this.viewDispatch = dispatch;
// deno-lint-ignore no-this-alias
const editor = this;
useEffect(() => {
if (viewState.currentPage) {
document.title = viewState.currentPage;
}
}, [viewState.currentPage]);
useEffect(() => {
if (editor.editorView) {
editor.tweakEditorDOM(
editor.editorView.contentDOM,
viewState.perm === "ro",
);
}
}, [viewState.forcedROMode]);
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) {
dispatch({ type: "command-run", command: cmd.command.name });
cmd
.run()
.catch((e: any) => {
console.error("Error running command", e.message);
})
.then(() => {
// Always be focusing the editor after running a command
editor.focus();
});
}
}}
commands={viewState.commands}
recentCommands={viewState.recentCommands}
/>
)}
{viewState.showFilterBox && (
<FilterList
label={viewState.filterBoxLabel}
placeholder={viewState.filterBoxPlaceHolder}
options={viewState.filterBoxOptions}
allowNew={false}
helpText={viewState.filterBoxHelpText}
onSelect={viewState.filterBoxOnSelect}
/>
)}
<TopBar
pageName={viewState.currentPage}
notifications={viewState.notifications}
unsavedChanges={viewState.unsavedChanges}
isLoading={viewState.isLoading}
onClick={() => {
dispatch({ type: "start-navigate" });
}}
onThemeClick={() => {
if (localStorage.theme === "dark") localStorage.theme = "light";
else localStorage.theme = "dark";
document.documentElement.dataset.theme = localStorage.theme;
}}
onHomeClick={() => {
editor.navigate("");
}}
onActionClick={() => {
dispatch({ type: "show-palette" });
}}
rhs={!!viewState.panels.rhs.mode && (
<div
className="panel"
style={{ flex: viewState.panels.rhs.mode }}
/>
)}
lhs={!!viewState.panels.lhs.mode && (
<div
className="panel"
style={{ flex: viewState.panels.lhs.mode }}
/>
)}
/>
<div id="sb-main">
{!!viewState.panels.lhs.mode && (
<Panel config={viewState.panels.lhs} editor={editor} />
)}
<div id="sb-editor" />
{!!viewState.panels.rhs.mode && (
<Panel config={viewState.panels.rhs} editor={editor} />
)}
</div>
{!!viewState.panels.modal.mode && (
<div
className="sb-modal"
style={{ inset: `${viewState.panels.modal.mode}px` }}
>
<Panel config={viewState.panels.modal} editor={editor} />
</div>
)}
{!!viewState.panels.bhs.mode && (
<div className="sb-bhs">
<Panel config={viewState.panels.bhs} editor={editor} />
</div>
)}
</>
);
}
async runCommandByName(name: string) {
const cmd = this.viewState.commands.get(name);
if (cmd) {
await cmd.run();
} else {
throw new Error(`Command ${name} not found`);
}
}
render(container: Element) {
const ViewComponent = this.ViewComponent.bind(this);
// console.log(<ViewComponent />);
preactRender(<ViewComponent />, container);
}
private getContext(): string | undefined {
const state = this.editorView!.state;
const selection = state.selection.main;
if (selection.empty) {
return syntaxTree(state).resolveInner(selection.from).name;
}
return;
}
}
+137
View File
@@ -0,0 +1,137 @@
import { EditorView, ViewPlugin, ViewUpdate } from "./deps.ts";
import { safeRun } from "../plugos/util.ts";
import { maximumAttachmentSize } from "../common/types.ts";
import { Editor } from "./editor.tsx";
const urlRegexp =
/^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
// Known iOS Safari paste issue (unrelated to this implementation): https://voxpelli.com/2015/03/ios-safari-url-copy-paste-bug/
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})`,
},
],
});
});
}
}
}
});
}
},
);
export function attachmentExtension(editor: Editor) {
return EditorView.domEventHandlers({
dragover: (event) => {
event.preventDefault();
},
drop: (event: DragEvent) => {
// TODO: This doesn't take into account the target cursor position,
// it just drops the attachment wherever the cursor was last.
if (event.dataTransfer) {
let payload = [...event.dataTransfer.files];
if (!payload.length) {
return;
}
safeRun(async () => {
await processFileTransfer(payload);
});
}
},
paste: (event: ClipboardEvent) => {
let payload = [...event.clipboardData!.items];
if (!payload.length || payload.length === 0) {
return false;
}
safeRun(async () => {
await processItemTransfer(payload);
});
},
});
async function processFileTransfer(payload: File[]) {
let data = await payload[0].arrayBuffer();
await saveFile(data!, payload[0].name, payload[0].type);
}
async function processItemTransfer(payload: DataTransferItem[]) {
let file = payload.find((item) => item.kind === "file");
if (!file) {
return false;
}
const fileType = file.type;
let ext = fileType.split("/")[1];
let fileName = new Date()
.toISOString()
.split(".")[0]
.replace("T", "_")
.replaceAll(":", "-");
let data = await file!.getAsFile()?.arrayBuffer();
await saveFile(data!, `${fileName}.${ext}`, fileType);
}
async function saveFile(
data: ArrayBuffer,
suggestedName: string,
mimeType: string,
) {
if (data!.byteLength > maximumAttachmentSize) {
editor.flashNotification(
`Attachment is too large, maximum is ${
maximumAttachmentSize / 1024 / 1024
}MB`,
"error",
);
return;
}
let finalFileName = prompt(
"File name for pasted attachment",
suggestedName,
);
if (!finalFileName) {
return;
}
await editor.space.writeAttachment(finalFileName, "arraybuffer", data!);
let attachmentMarkdown = `[${finalFileName}](${finalFileName})`;
if (mimeType.startsWith("image/")) {
attachmentMarkdown = `![](${finalFileName})`;
}
editor.editorView!.dispatch({
changes: [
{
insert: attachmentMarkdown,
from: editor.editorView!.state.selection.main.from,
},
],
});
}
}
+100
View File
@@ -0,0 +1,100 @@
# iA Writer Typeface
Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer"
# Based on IBM Plex Typeface
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
# License
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
import { Hook, Manifest } from "../../plugos/types.ts";
import { System } from "../../plugos/system.ts";
import { EventEmitter } from "../../plugos/event.ts";
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, [cmd]);
},
});
}
}
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 [];
}
}
+124
View File
@@ -0,0 +1,124 @@
import { Hook, Manifest } from "../../plugos/types.ts";
import { System } from "../../plugos/system.ts";
import {
Completion,
CompletionContext,
CompletionResult,
} from "../deps.ts";
import { safeRun } from "../../common/util.ts";
import { Editor } from "../editor.tsx";
import { syntaxTree } from "../deps.ts";
export type SlashCommandDef = {
name: string;
description?: string;
};
export type AppSlashCommand = {
slashCommand: SlashCommandDef;
run: () => Promise<void>;
};
export type SlashCommandHookT = {
slashCommand?: SlashCommandDef;
};
const slashCommandRegexp = /([^\w]|^)\/[\w\-]*/;
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, [cmd]);
},
});
}
}
}
// Completer for CodeMirror
public slashCommandCompleter(
ctx: CompletionContext,
): CompletionResult | null {
let prefix = ctx.matchBefore(slashCommandRegexp);
if (!prefix) {
return null;
}
const prefixText = prefix.text;
let options: Completion[] = [];
// No slash commands in comment blocks (queries and such)
let currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos);
if (currentNode.type.name === "CommentBlock") {
return null;
}
for (let [name, def] of this.slashCommands.entries()) {
options.push({
label: def.slashCommand.name,
detail: def.slashCommand.description,
apply: () => {
// Delete slash command part
this.editor.editorView?.dispatch({
changes: {
from: prefix!.from + prefixText.indexOf("/"),
to: ctx.pos,
insert: "",
},
});
// Replace with whatever the completion is
safeRun(async () => {
await def.run();
this.editor.focus();
});
},
});
}
return {
// + 1 because of the '/'
from: prefix.from + prefixText.indexOf("/") + 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: 1.6 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<base href="/" />
<title>Silver Bullet</title>
<script>
Deno = {
args: [],
build: {
arch: "x86_64",
},
env: {
get(key) {
// return undefined;
},
},
};
</script>
<style>
html,
body {
margin: 0;
height: 100%;
padding: 0;
width: 100%;
overflow: hidden;
}
</style>
<script>
document.documentElement.dataset.theme = localStorage.theme ?? "light";
</script>
<link rel="stylesheet" href="/main.css" />
<script type="module" src="/client.js"></script>
<link rel="manifest" href="/manifest.json" -->
<link rel="icon" type="image/x-icon" href="/favicon.gif" />
</head>
<body>
<div id="sb-root"></div>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
import { syntaxTree } from "./deps.ts";
import { Range } from "./deps.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "./deps.ts";
class InlineImageWidget extends WidgetType {
constructor(readonly url: string, readonly title: string) {
super();
}
eq(other: InlineImageWidget) {
return other.url === this.url && other.title === this.title;
}
toDOM() {
const img = document.createElement("img");
if (this.url.startsWith("http")) {
img.src = this.url;
} else {
img.src = `fs/${this.url}`;
}
img.alt = this.title;
img.title = this.title;
img.style.display = "block";
img.className = "sb-inline-img";
return img;
}
}
const inlineImages = (view: EditorView) => {
let widgets: Range<Decoration>[] = [];
const imageRegex = /!\[(?<title>[^\]]*)\]\((?<url>.+)\)/;
for (let { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== "Image") {
return;
}
const imageRexexResult = imageRegex.exec(
view.state.sliceDoc(node.from, node.to),
);
if (imageRexexResult === null || !imageRexexResult.groups) {
return;
}
const url = imageRexexResult.groups.url;
const title = imageRexexResult.groups.title;
let deco = Decoration.widget({
widget: new InlineImageWidget(url, title),
});
widgets.push(deco.range(node.to));
},
});
}
return Decoration.set(widgets, true);
};
export const inlineImagesPlugin = () =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = inlineImages(view);
}
update(update: ViewUpdate) {
if (update.docChanged) {
this.decorations = inlineImages(update.view);
}
}
},
{
decorations: (v) => v.decorations,
},
);
+85
View File
@@ -0,0 +1,85 @@
import { syntaxTree } from "../common/deps.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "../common/deps.ts";
import { Range } from "./deps.ts";
interface WrapElement {
selector: string;
class: string;
nesting?: boolean;
}
function wrapLines(view: EditorView, wrapElements: WrapElement[]) {
let widgets: Range<Decoration>[] = [];
let elementStack: string[] = [];
const doc = view.state.doc;
// Disabling the visible ranges for now, because it may be a bit buggy.
// RISK: this may actually become slow for large documents.
for (let { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: ({ type, 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 }) {
for (let wrapElement of wrapElements) {
if (type.name == wrapElement.selector && wrapElement.nesting) {
elementStack.pop();
}
}
},
});
}
// Widgets have to be sorted by `from` in ascending order
widgets = widgets.sort((a, b) => {
return a.from < b.from ? -1 : 1;
});
return Decoration.set(widgets);
}
export const lineWrapper = (wrapElements: WrapElement[]) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = wrapLines(view, wrapElements);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = wrapLines(update.view, wrapElements);
}
}
},
{
decorations: (v) => v.decorations,
},
);
+17
View File
@@ -0,0 +1,17 @@
{
"short_name": "Silver Bullet",
"name": "Silver Bullet",
"icons": [
{
"src": "/logo.png",
"type": "image/png",
"sizes": "1024x1024"
}
],
"capture_links": "new-client",
"start_url": "/",
"display": "standalone",
"scope": "/",
"theme_color": "#000",
"description": "Markdown as a platform"
}
+90
View File
@@ -0,0 +1,90 @@
import { safeRun } from "../common/util.ts";
function encodePageUrl(name: string): string {
return name.replaceAll(" ", "_");
}
function decodePageUrl(url: string): string {
return url.replaceAll("_", " ");
}
export class PathPageNavigator {
navigationResolve?: () => void;
constructor(readonly indexPage: string, readonly root: string = "") {}
async navigate(page: string, pos?: number | string, replaceState = false) {
let encodedPage = encodePageUrl(page);
if (page === this.indexPage) {
encodedPage = "";
}
if (replaceState) {
window.history.replaceState(
{ page, pos },
page,
`${this.root}/${encodedPage}`,
);
} else {
window.history.pushState(
{ page, pos },
page,
`${this.root}/${encodedPage}`,
);
}
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 | string) => Promise<void>,
): void {
const cb = (event?: PopStateEvent) => {
const gotoPage = this.getCurrentPage();
if (!gotoPage) {
return;
}
safeRun(async () => {
await pageLoadCallback(
this.getCurrentPage(),
event?.state?.pos || this.getCurrentPos(),
);
if (this.navigationResolve) {
this.navigationResolve();
}
});
};
window.addEventListener("popstate", cb);
cb();
}
decodeURI(): [string, number | string] {
let [page, pos] = decodeURI(
location.pathname.substring(this.root.length + 1),
).split("@");
if (pos) {
if (pos.match(/^\d+$/)) {
return [page, +pos];
} else {
return [page, pos];
}
} else {
return [page, 0];
}
}
getCurrentPage(): string {
return decodePageUrl(this.decodeURI()[0]) || this.indexPage;
}
getCurrentPos(): number | string {
// console.log("Pos", this.decodeURI()[1]);
return this.decodeURI()[1];
}
}
+148
View File
@@ -0,0 +1,148 @@
import { Action, AppViewState } from "./types.ts";
let m = new Map();
m.size;
export default function reducer(
state: AppViewState,
action: Action,
): AppViewState {
// console.log("Got action", action);
switch (action.type) {
case "page-loading":
return {
...state,
isLoading: true,
currentPage: action.name,
};
case "page-loaded":
return {
...state,
isLoading: false,
allPages: new Set(
[...state.allPages].map((pageMeta) =>
pageMeta.name === action.meta.name
? { ...pageMeta, lastOpened: Date.now() }
: pageMeta
),
),
perm: action.meta.perm,
currentPage: action.meta.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":
// Let's move over any "lastOpened" times to the "allPages" list
let oldPageMeta = new Map([...state.allPages].map((pm) => [pm.name, pm]));
for (let pageMeta of action.pages) {
let oldPageMetaItem = oldPageMeta.get(pageMeta.name);
if (oldPageMetaItem && oldPageMetaItem.lastOpened) {
pageMeta.lastOpened = oldPageMetaItem.lastOpened;
}
}
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 "command-run":
return {
...state,
recentCommands: state.recentCommands.set(action.command, new Date()),
};
case "update-commands":
return {
...state,
commands: action.commands,
};
case "show-notification":
return {
...state,
notifications: [...state.notifications, action.notification],
};
case "dismiss-notification":
return {
...state,
notifications: state.notifications.filter((n) => n.id !== action.id),
};
case "show-panel":
return {
...state,
panels: {
...state.panels,
[action.id]: action.config,
},
};
case "hide-panel":
return {
...state,
panels: {
...state.panels,
[action.id]: {},
},
};
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: "",
};
case "set-editor-ro":
return {
...state,
forcedROMode: action.enabled,
};
}
return state;
}
+3
View File
@@ -0,0 +1,3 @@
globalThis.addEventListener("fetch", function () {
return;
});
+53
View File
@@ -0,0 +1,53 @@
import { KeyBinding } from "./deps.ts";
import { syntaxTree } from "../common/deps.ts";
const straightQuoteContexts = ["CommentBlock", "FencedCode", "InlineCode"];
// TODO: Add support for selection (put quotes around or create blockquote block?)
function keyBindingForQuote(
quote: string,
left: string,
right: string,
): KeyBinding {
return {
key: quote,
run: (target): boolean => {
let cursorPos = target.state.selection.main.from;
let chBefore = target.state.sliceDoc(cursorPos - 1, cursorPos);
// Figure out the context, if in some sort of code/comment fragment don't be smart
let node = syntaxTree(target.state).resolveInner(cursorPos);
while (node) {
if (straightQuoteContexts.includes(node.type.name)) {
return false;
}
if (node.parent) {
node = node.parent;
} else {
break;
}
}
// Ok, still here, let's use a smart quote
let quote = right;
if (/\W/.exec(chBefore) && !/[!\?,\.\-=“]/.exec(chBefore)) {
quote = left;
}
target.dispatch({
changes: {
insert: quote,
from: cursorPos,
},
selection: {
anchor: cursorPos + 1,
},
});
return true;
},
};
}
export const smartQuoteKeymap: KeyBinding[] = [
keyBindingForQuote('"', "“", "”"),
keyBindingForQuote("'", "", ""),
];
+60
View File
@@ -0,0 +1,60 @@
import { HighlightStyle } from "../common/deps.ts";
import { tagHighlighter, tags as t } from "./deps.ts";
import * as ct from "../common/customtags.ts";
import { MDExt } from "../common/markdown_ext.ts";
export default function highlightStyles(mdExtension: MDExt[]) {
tagHighlighter;
const hls = HighlightStyle.define([
{ tag: t.heading1, class: "sb-h1" },
{ tag: t.heading2, class: "sb-h2" },
{ tag: t.heading3, class: "sb-h3" },
{ tag: t.link, class: "sb-link" },
{ tag: t.meta, class: "sb-meta" },
{ tag: t.quote, class: "sb-quote" },
{ tag: t.monospace, class: "sb-code" },
{ tag: t.url, class: "sb-url" },
{ tag: ct.WikiLinkTag, class: "sb-wiki-link" },
{ tag: ct.WikiLinkPageTag, class: "sb-wiki-link-page" },
{ tag: ct.TaskTag, class: "sb-task" },
{ tag: ct.TaskMarkerTag, class: "sb-task-marker" },
{ tag: ct.CodeInfoTag, class: "sb-code-info" },
{ tag: ct.CommentTag, class: "sb-comment" },
{ tag: ct.CommentMarkerTag, class: "sb-comment-marker" },
{ tag: ct.Highlight, class: "sb-highlight" },
{ tag: t.emphasis, class: "sb-emphasis" },
{ tag: t.strong, class: "sb-strong" },
{ tag: t.atom, class: "sb-atom" },
{ tag: t.bool, class: "sb-bool" },
{ tag: t.url, class: "sb-url" },
{ tag: t.inserted, class: "sb-inserted" },
{ tag: t.deleted, class: "sb-deleted" },
{ tag: t.literal, class: "sb-literal" },
{ tag: t.keyword, class: "sb-keyword" },
{ tag: t.list, class: "sb-list" },
// { tag: t.def, class: "sb-li" },
{ tag: t.string, class: "sb-string" },
{ tag: t.number, class: "sb-number" },
{ tag: [t.regexp, t.escape, t.special(t.string)], class: "sb-string2" },
{ tag: t.variableName, class: "sb-variableName" },
{ tag: t.typeName, class: "sb-typeName" },
{ tag: t.comment, class: "sb-comment" },
{ tag: t.invalid, class: "sb-invalid" },
{ tag: t.processingInstruction, class: "sb-meta" },
// { tag: t.content, class: "tbl-content" },
{ tag: t.punctuation, class: "sb-punctuation" },
{ tag: ct.HorizontalRuleTag, class: "sb-hr" },
...mdExtension.map((mdExt) => {
return { tag: mdExt.tag, ...mdExt.styles, class: mdExt.className };
}),
]);
const fn0 = hls.style;
// Hack: https://discuss.codemirror.net/t/highlighting-that-seems-ignored-in-cm6/4320/16
// @ts-ignore
hls.style = (tags) => {
// console.log("Tags", tags);
return fn0(tags || []);
};
return hls;
}
+88
View File
@@ -0,0 +1,88 @@
.cm-editor {
font-size: 18px;
padding: 0 20px;
--max-width: 800px;
.cm-content {
margin: auto;
max-width: var(--max-width);
}
.sb-inline-img {
max-width: calc(var(--max-width) * 0.9);
}
&.cm-focused {
outline: none !important;
}
// Weird hack to readjust iOS's safari font-size when contenteditable is disabled
.ios-safari-readonly {
font-size: 60%;
}
// Indentation of follow-up lines
@mixin lineOverflow($baseIndent) {
text-indent: -1 * ($baseIndent + 2ch);
padding-left: $baseIndent + 2ch;
&.sb-line-task {
text-indent: -1 * ($baseIndent + 6ch);
padding-left: $baseIndent + 6ch;
}
&.sb-line-blockquote {
text-indent: -1 * ($baseIndent + 4ch);
padding-left: $baseIndent + 4ch;
}
}
.sb-line-ul {
&.sb-line-li-1 {
@include lineOverflow(0);
}
&.sb-line-li-1.sb-line-li-2 {
@include lineOverflow(2);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
@include lineOverflow(4);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
@include lineOverflow(6);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
@include lineOverflow(8);
}
}
.sb-line-ol {
&.sb-line-li-1 {
@include lineOverflow(1);
}
&.sb-line-li-1.sb-line-li-2 {
@include lineOverflow(2);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
@include lineOverflow(4);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
@include lineOverflow(6);
}
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
@include lineOverflow(8);
}
}
.sb-line-comment {
text-indent: -1 * 3ch;
padding-left: 3ch;
}
}
+76
View File
@@ -0,0 +1,76 @@
.sb-filter-wrapper {
position: absolute;
margin: auto;
max-width: 500px;
height: 600px;
left: 0;
right: 0;
top: 0;
bottom: 0;
max-height: 290px;
z-index: 100;
}
.sb-filter-box {
border-radius: 8px;
overflow: hidden;
margin: 10px;
.sb-header {
padding: 13px 10px 10px 10px;
display: flex;
label {
color: var(--highlight-color);
margin: 3px;
}
input {
background: transparent;
border: 0;
padding: 3px;
outline: 0;
font-size: 1em;
flex-grow: 100;
}
}
.sb-help-text {
padding: 5px;
}
.sb-result-list {
max-height: 216px;
overflow-y: scroll;
.sb-icon {
padding: 0 8px 0 5px;
}
.sb-name {
padding-top: -3px;
}
}
.sb-option,
.sb-selected-option {
padding: 8px;
cursor: pointer;
height: 20px;
line-height: 20px;
}
.sb-selected-option {
background-color: var(--highlight-color);
}
.sb-option .sb-hint,
.sb-selected-option .sb-hint {
float: right;
margin-right: 0;
margin-top: -4px;
padding-left: 5px;
padding-right: 5px;
padding-top: 3px;
padding-bottom: 3px;
border-radius: 5px;
}
}
+154
View File
@@ -0,0 +1,154 @@
@use "editor";
@use "filter_box";
@use "theme";
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-Regular.woff2");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-Bold.woff2");
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-Italic.woff2");
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-BoldItalic.woff2");
font-weight: bold;
font-style: italic;
}
#sb-root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
#sb-top {
display: flex;
flex-direction: row;
z-index: 20;
height: 55px;
.main {
flex: 2;
max-width: 100%;
.inner {
max-width: 800px;
margin: auto;
font-size: 28px;
padding: 10px 20px;
display: flex;
flex-direction: row;
.sb-notifications {
position: absolute;
bottom: 0;
left: 5px;
right: 5px;
font-size: 15px;
z-index: 100;
> div {
padding: 3px;
margin-bottom: 3px;
border-radius: 5px;
}
}
.sb-current-page {
font-weight: bold;
flex: 1;
font-size: 28px;
overflow: hidden;
white-space: nowrap;
text-align: left;
display: block;
text-overflow: ellipsis;
}
}
.sb-actions {
text-align: right;
}
}
.sb-panel {
flex: 1;
}
}
#sb-main {
display: flex;
flex-direction: row;
flex-grow: 1;
height: 0;
.sb-panel {
flex: 1;
iframe {
border: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
}
}
#sb-editor {
overflow-y: scroll;
flex: 2;
height: 100%;
}
.sb-bhs {
height: 300px;
width: 100%;
.sb-panel {
height: 100%;
iframe {
border: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
}
}
.sb-modal {
position: absolute;
z-index: 100;
.sb-panel {
height: 100%;
iframe {
border: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
}
}
+390
View File
@@ -0,0 +1,390 @@
#sb-root {
font-family: Arial;
--highlight-color: #464cfc;
}
#sb-top {
background-color: rgb(213, 213, 213);
border-bottom: rgb(193, 193, 193) 1px solid;
color: rgb(55, 55, 55);
}
.sb-panel {
border-left: 1px solid #eee;
}
.sb-bhs {
border-top: rgb(193, 193, 193) 1px solid;
}
.sb-modal {
border: 1px solid #000;
background-color: #fff;
}
.sb-notifications {
font-family: "iA-Mono";
}
.sb-notifications > div {
border: rgb(41, 41, 41) 1px solid;
}
.sb-notification-info {
background-color: rgb(187, 221, 247);
}
.sb-notification-error {
background-color: rgb(255, 84, 84);
}
.sb-saved {
color: #111;
}
.sb-unsaved {
color: #5e5e5e;
}
.sb-loading {
color: #7a7a7a;
}
.sb-actions button {
border: 1px solid #7897d0;
border-radius: 3px;
margin: 3px;
font-size: 15px;
padding: 5px;
background-color: #e6e6e6;
}
/* Filter boxes */
.sb-filter-box {
background-color: #fff;
border: rgb(103, 103, 103) 1px solid;
box-shadow: rgba(0, 0, 0, 0.35) 0px 20px 20px;
}
.sb-filter-box .sb-header {
border-bottom: 1px rgb(108, 108, 108) solid;
}
.sb-filter-box .sb-header input {
font-family: "Arial";
color: #000;
}
.sb-filter-box .sb-header input::placeholder {
color: rgb(199, 199, 199);
font-weight: normal;
}
.sb-filter-box .sb-help-text {
background-color: #eee;
border-bottom: 1px rgb(108, 108, 108) solid;
color: #555;
}
.sb-filter-box .sb-selected-option {
color: #eee;
}
.sb-filter-box .sb-option .sb-hint,
.sb-filter-box .sb-selected-option .sb-hint {
color: #eee;
background-color: #212476;
}
/* Editor */
.cm-content {
font-family: "iA-Mono", "Menlo";
}
.cm-selectionBackground {
background-color: #d7e1f6 !important;
}
.cm-editor .cm-tooltip-autocomplete {
.cm-completionDetail {
font-style: normal;
display: block;
font-size: 80%;
margin-left: 5px;
color: #555;
}
li[aria-selected] .cm-completionDetail {
color: #d2d2d2;
}
.cm-completionLabel {
display: block;
margin-left: 5px;
}
.cm-completionIcon {
display: none;
}
}
.sb-line-h1,
.sb-line-h2,
.sb-line-h3 {
background-color: rgba(0, 30, 77, 0.5);
color: #fff;
font-weight: bold;
padding: 2px 2px !important;
}
.sb-line-h1 .sb-meta,
.sb-line-h2 .sb-meta,
.sb-line-h3 .sb-meta {
color: orange;
}
.sb-line-h1 {
font-size: 1.5em;
}
.sb-line-h2 {
font-size: 1.2em;
}
.sb-line-h3 {
font-size: 1.1em;
}
.sb-hashtag {
color: blue;
}
.sb-hr {
background-color: #f5f5f5;
line-height: 0.9em;
display: block;
color: #8d8a8a;
}
.sb-naked-url {
color: #0330cb;
cursor: pointer;
}
.sb-named-anchor {
color: #959595;
}
.sb-command-link {
background-color: #e3dfdf;
cursor: pointer;
border-top: 1px solid silver;
border-left: 1px solid silver;
border-bottom: 1px solid gray;
border-right: 1px solid gray;
border-radius: 4px;
padding: 0 4px;
}
/* Color list item this way */
.sb-line-li .sb-meta {
color: rgb(150, 150, 150);
}
/* Then undo other meta */
.sb-line-li .sb-meta ~ .sb-meta {
color: #650007;
}
.sb-line-code {
background-color: rgba(72, 72, 72, 0.1);
}
.sb-line-code .sb-code {
background-color: transparent;
}
.sb-line-tbl-header {
font-weight: bold;
}
.sb-line-tbl-header .meta {
font-weight: normal;
}
.sb-struct {
color: darkred;
}
.sb-code {
background-color: rgba(72, 72, 72, 0.1);
}
.sb-highlight {
background-color: rgba(255, 255, 0, 0.5);
}
.sb-line-fenced-code {
background-color: rgba(72, 72, 72, 0.1);
}
/* Mostly for JS when that comes back */
.sb-line-fenced-code .sb-code {
background-color: transparent;
}
.sb-line-fenced-code .sb-comment {
color: #989797;
background-color: transparent;
border-radius: 0;
font-style: inherit;
font-size: inherit;
line-height: inherit;
}
.sb-line-fenced-code .sb-keyword {
color: #830000;
}
.sb-line-fenced-code .sb-variableName {
color: #036d9b;
}
.sb-line-fenced-code .sb-typeName {
color: #038138;
}
.sb-line-fenced-code .sb-string,
.sb-line-fenced-code .sb-string2 {
color: #440377;
}
.sb-meta {
color: #650007;
}
.sb-line-blockquote {
background-color: rgba(220, 220, 220, 0.5);
color: #676767;
text-indent: -2ch;
padding-left: 2ch;
}
.sb-emphasis {
font-style: italic;
}
.sb-strong {
font-weight: 900;
}
.sb-link {
cursor: pointer;
}
.sb-link:not(.sb-meta, .sb-url) {
color: #0330cb;
text-decoration: underline;
}
.sb-link.sb-url {
color: #7e7d7d;
}
.sb-url:not(.sb-link) {
color: #0330cb;
text-decoration: underline;
cursor: pointer;
}
.sb-atom {
color: darkred;
}
.sb-wiki-link-page {
color: #0330cb;
background-color: rgba(77, 141, 255, 0.07);
border-radius: 5px;
padding: 0 5px;
white-space: nowrap;
cursor: pointer;
}
.sb-wiki-link {
cursor: pointer;
color: #8f96c2;
}
.sb-task-marker {
background-color: #ddd;
}
.sb-line-comment {
background-color: rgba(255, 255, 0, 0.5);
}
.sb-comment {
color: #989797;
font-size: 75%;
line-height: 75%;
}
html[data-theme="dark"] {
#sb-root {
background-color: #555;
color: rgb(200, 200, 200);
}
#sb-top {
background-color: rgb(38, 38, 38);
border-bottom: rgb(62, 62, 62) 1px solid;
color: rgb(200, 200, 200);
}
.sb-saved {
color: rgb(225, 225, 225);
}
.sb-filter-box,
/* duplicating the class name to increase specificity */
.sb-help-text.sb-help-text {
color: #ccc;
background-color: rgb(38, 38, 38);
}
.sb-help-text {
border-bottom: 1px solid #6c6c6c;
}
.sb-line-li .sb-meta ~ .sb-meta,
.sb-line-fenced-code .sb-meta {
color: #d17278;
}
.sb-wiki-link-page {
color: #7e99fc;
background-color: #a3bce712;
}
.sb-code,
.sb-line-fenced-code,
.sb-task-marker {
background-color: #333;
}
.sb-notifications > div {
border: rgb(197, 197, 197) 1px solid;
background-color: #333;
}
.sb-hashtag {
color: #94b0f4;
}
.sb-naked-url {
color: #94b0f4;
}
.sb-command-link {
background-color: #595959;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { SysCallMapping } from "../../plugos/system.ts";
import { storeSyscalls } from "../../plugos/syscalls/store.dexie_browser.ts";
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);
},
);
}
+203
View File
@@ -0,0 +1,203 @@
import { Editor } from "../editor.tsx";
import { Transaction } from "../deps.ts";
import { SysCallMapping } from "../../plugos/system.ts";
import { FilterOption } from "../../common/types.ts";
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 {
const syscalls: SysCallMapping = {
"editor.getCurrentPage": (): string => {
return editor.currentPage!;
},
"editor.getText": () => {
return editor.editorView?.state.sliceDoc();
},
"editor.getCursor": (): number => {
return editor.editorView!.state.selection.main.from;
},
"editor.getSelection": (): { from: number; to: number } => {
return editor.editorView!.state.selection.main;
},
"editor.save": async () => {
return editor.save(true);
},
"editor.navigate": async (
ctx,
name: string,
pos: number | string,
replaceState = false,
) => {
await editor.navigate(name, pos, replaceState);
},
"editor.reloadPage": async (ctx) => {
await editor.reloadPage();
},
"editor.openUrl": async (ctx, url: string) => {
let win = window.open(url, "_blank");
if (win) {
win.focus();
}
},
"editor.flashNotification": (
ctx,
message: string,
type: "error" | "info" = "info",
) => {
editor.flashNotification(message, type);
},
"editor.filterBox": (
ctx,
label: string,
options: FilterOption[],
helpText: string = "",
placeHolder: string = "",
): Promise<FilterOption | undefined> => {
return editor.filterBox(label, options, helpText, placeHolder);
},
"editor.showPanel": (
ctx,
id: string,
mode: number,
html: string,
script: string,
) => {
editor.viewDispatch({
type: "show-panel",
id: id as any,
config: { html, script, mode },
});
},
"editor.hidePanel": (ctx, id: string) => {
editor.viewDispatch({
type: "hide-panel",
id: id as any,
});
},
// Deprecated in favor of using "hidePanel" and "showPanel"
"editor.showRhs": (ctx, html: string, script: string, flex: number) => {
syscalls["editor.showPanel"](ctx, "rhs", flex, html, script);
},
"editor.hideRhs": (ctx) => {
syscalls["editor.hidePanel"](ctx, "rhs");
},
"editor.showLhs": (ctx, html: string, script: string, flex: number) => {
syscalls["editor.showPanel"](ctx, "lhs", flex, html, script);
},
"editor.hideLhs": (ctx) => {
syscalls["editor.hidePanel"](ctx, "lhs");
},
"editor.showBhs": (ctx, html: string, script: string, flex: number) => {
syscalls["editor.showPanel"](ctx, "bhs", flex, html, script);
},
"editor.hideBhs": (ctx) => {
syscalls["editor.hidePanel"](ctx, "bhs");
},
"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.setSelection": (ctx, from: number, to: number) => {
let editorView = editor.editorView!;
editorView.dispatch({
selection: {
anchor: from,
head: to,
},
});
},
"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);
},
"editor.enableReadOnlyMode": (ctx, enabled: boolean) => {
editor.viewDispatch({
type: "set-editor-ro",
enabled,
});
},
};
return syscalls;
}
+10
View File
@@ -0,0 +1,10 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { Space } from "../../common/spaces/space.ts";
export function fulltextSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
["fulltext.search", "fulltext.delete", "fulltext.index"],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
}
+16
View File
@@ -0,0 +1,16 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { Space } from "../../common/spaces/space.ts";
export function indexerSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
[
"index.queryPrefix",
"index.get",
"index.set",
"index.batchSet",
"index.delete",
],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
}
+70
View File
@@ -0,0 +1,70 @@
import { Editor } from "../editor.tsx";
import { SysCallMapping } from "../../plugos/system.ts";
import { AttachmentMeta, PageMeta } from "../../common/types.ts";
import {
FileData,
FileEncoding,
} from "../../common/spaces/space_primitives.ts";
export function spaceSyscalls(editor: Editor): SysCallMapping {
return {
"space.listPages": (): PageMeta[] => {
return [...editor.space.listPages()];
},
"space.readPage": async (
_ctx,
name: string,
): Promise<{ text: string; meta: PageMeta }> => {
return await editor.space.readPage(name);
},
"space.getPageMeta": async (_ctx, name: string): Promise<PageMeta> => {
return await editor.space.getPageMeta(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 index page
if (editor.currentPage === name) {
await editor.navigate("");
}
// Remove page from open pages in editor
editor.openPages.delete(name);
console.log("Deleting page");
await editor.space.deletePage(name);
},
"space.listPlugs": (): Promise<string[]> => {
return editor.space.listPlugs();
},
"space.listAttachments": (): Promise<AttachmentMeta[]> => {
return editor.space.fetchAttachmentList();
},
"space.readAttachment": async (
_ctx,
name: string,
): Promise<{ data: FileData; meta: AttachmentMeta }> => {
return await editor.space.readAttachment(name, "dataurl");
},
"space.getAttachmentMeta": async (
_ctx,
name: string,
): Promise<AttachmentMeta> => {
return await editor.space.getAttachmentMeta(name);
},
"space.writeAttachment": async (
_ctx,
name: string,
encoding: FileEncoding,
data: FileData,
): Promise<AttachmentMeta> => {
return await editor.space.writeAttachment(name, encoding, data);
},
"space.deleteAttachment": async (_ctx, name: string) => {
await editor.space.deleteAttachment(name);
},
};
}
+17
View File
@@ -0,0 +1,17 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { Space } from "../../common/spaces/space.ts";
export function storeSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
[
"store.queryPrefix",
"store.get",
"store.set",
"store.batchSet",
"store.delete",
"store.deletePrefix",
],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
}
+43
View File
@@ -0,0 +1,43 @@
import { SysCallMapping } from "../../plugos/system.ts";
import type { Editor } from "../editor.tsx";
import { CommandDef } from "../hooks/command.ts";
export function systemSyscalls(editor: Editor): 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 editor.space.invokeFunction(ctx.plug, env, name, args);
},
"system.invokeCommand": async (ctx, name: string) => {
return editor.runCommandByName(name);
},
"system.listCommands": async (
ctx,
): Promise<{ [key: string]: CommandDef }> => {
let allCommands: { [key: string]: CommandDef } = {};
for (let [cmd, def] of editor.commandHook.editorCommands) {
allCommands[cmd] = def.command;
}
return allCommands;
},
"system.reloadPlugs": async () => {
return editor.reloadPlugs();
},
"sandbox.getServerLogs": async (ctx) => {
return editor.space.proxySyscall(ctx.plug, "sandbox.getLogs", []);
},
};
}
+100
View File
@@ -0,0 +1,100 @@
import { AppCommand } from "./hooks/command.ts";
import { FilterOption, PageMeta } from "../common/types.ts";
export type Notification = {
id: number;
message: string;
type: "info" | "error";
date: Date;
};
type EditorMode = "ro" | "rw";
export type PanelMode = number;
export type PanelConfig = {
mode?: PanelMode;
html?: string;
script?: string;
};
export type AppViewState = {
currentPage?: string;
perm: EditorMode;
forcedROMode: boolean;
isLoading: boolean;
showPageNavigator: boolean;
showCommandPalette: boolean;
unsavedChanges: boolean;
panels: { [key: string]: PanelConfig };
allPages: Set<PageMeta>;
commands: Map<string, AppCommand>;
notifications: Notification[];
recentCommands: Map<string, Date>;
showFilterBox: boolean;
filterBoxLabel: string;
filterBoxPlaceHolder: string;
filterBoxOptions: FilterOption[];
filterBoxHelpText: string;
filterBoxOnSelect: (option: FilterOption | undefined) => void;
};
export const initialViewState: AppViewState = {
perm: "rw",
forcedROMode: false,
isLoading: false,
showPageNavigator: false,
showCommandPalette: false,
unsavedChanges: false,
panels: {
lhs: {},
rhs: {},
bhs: {},
modal: {},
},
allPages: new Set(),
commands: new Map(),
recentCommands: new Map(),
notifications: [],
showFilterBox: false,
filterBoxHelpText: "",
filterBoxLabel: "",
filterBoxOnSelect: () => {},
filterBoxOptions: [],
filterBoxPlaceHolder: "",
};
export type Action =
| { type: "page-loaded"; meta: PageMeta }
| { type: "page-loading"; 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-panel";
id: "rhs" | "lhs" | "bhs" | "modal";
config: PanelConfig;
}
| { type: "hide-panel"; id: string }
| { type: "command-run"; command: string }
| {
type: "show-filterbox";
options: FilterOption[];
placeHolder: string;
helpText: string;
label: string;
onSelect: (option: FilterOption | undefined) => void;
}
| { type: "hide-filterbox" }
| { type: "set-editor-ro"; enabled: boolean };
View File