@@ -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
@@ -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");
|
||||
// }
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = ``;
|
||||
}
|
||||
editor.editorView!.dispatch({
|
||||
changes: [
|
||||
{
|
||||
insert: attachmentMarkdown,
|
||||
from: editor.editorView!.state.selection.main.from,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
globalThis.addEventListener("fetch", function () {
|
||||
return;
|
||||
});
|
||||
@@ -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("'", "‘", "’"),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user