@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user