Major mini editor refactoring (#225)
Replaces most editing components with CM components, enabling vim mode and completions everywhere Fixes #205 Fixes #221 Fixes #222 Fixes #223
This commit is contained in:
@@ -46,6 +46,9 @@ export function directivePlugin() {
|
||||
widgets.push(
|
||||
Decoration.line({
|
||||
class: "sb-directive-start sb-directive-start-outside",
|
||||
attributes: {
|
||||
spellcheck: "false",
|
||||
},
|
||||
}).range(
|
||||
from,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isMacLike } from "../../common/util.ts";
|
||||
import { FilterList } from "./filter.tsx";
|
||||
import { TerminalIcon } from "../deps.ts";
|
||||
import { CompletionContext, CompletionResult, TerminalIcon } from "../deps.ts";
|
||||
import { AppCommand } from "../hooks/command.ts";
|
||||
import { FilterOption } from "../../common/types.ts";
|
||||
|
||||
@@ -8,14 +8,20 @@ export function CommandPalette({
|
||||
commands,
|
||||
recentCommands,
|
||||
onTrigger,
|
||||
vimMode,
|
||||
darkMode,
|
||||
completer,
|
||||
}: {
|
||||
commands: Map<string, AppCommand>;
|
||||
recentCommands: Map<string, Date>;
|
||||
vimMode: boolean;
|
||||
darkMode: boolean;
|
||||
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
|
||||
onTrigger: (command: AppCommand | undefined) => void;
|
||||
}) {
|
||||
let options: FilterOption[] = [];
|
||||
const options: FilterOption[] = [];
|
||||
const isMac = isMacLike();
|
||||
for (let [name, def] of commands.entries()) {
|
||||
for (const [name, def] of commands.entries()) {
|
||||
options.push({
|
||||
name: name,
|
||||
hint: isMac && def.command.mac ? def.command.mac : def.command.key,
|
||||
@@ -31,6 +37,9 @@ export function CommandPalette({
|
||||
options={options}
|
||||
allowNew={false}
|
||||
icon={TerminalIcon}
|
||||
completer={completer}
|
||||
vimMode={vimMode}
|
||||
darkMode={darkMode}
|
||||
helpText="Start typing the command name to filter results, press <code>Return</code> to run."
|
||||
onSelect={(opt) => {
|
||||
if (opt) {
|
||||
|
||||
+51
-48
@@ -1,8 +1,15 @@
|
||||
import { useEffect, useRef, useState } from "../deps.ts";
|
||||
import {
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "../deps.ts";
|
||||
import { FilterOption } from "../../common/types.ts";
|
||||
import fuzzysort from "https://esm.sh/fuzzysort@2.0.1";
|
||||
import { FunctionalComponent } from "https://esm.sh/v99/preact@10.11.3/src/index";
|
||||
import { FeatherProps } from "https://esm.sh/v99/preact-feather@4.2.1/dist/types";
|
||||
import { MiniEditor } from "./mini_editor.tsx";
|
||||
|
||||
function magicSorter(a: FilterOption, b: FilterOption): number {
|
||||
if (a.orderId && b.orderId) {
|
||||
@@ -56,6 +63,9 @@ export function FilterList({
|
||||
label,
|
||||
onSelect,
|
||||
onKeyPress,
|
||||
completer,
|
||||
vimMode,
|
||||
darkMode,
|
||||
allowNew = false,
|
||||
helpText = "",
|
||||
completePrefix,
|
||||
@@ -67,13 +77,15 @@ export function FilterList({
|
||||
label: string;
|
||||
onKeyPress?: (key: string, currentText: string) => void;
|
||||
onSelect: (option: FilterOption | undefined) => void;
|
||||
vimMode: boolean;
|
||||
darkMode: boolean;
|
||||
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
|
||||
allowNew?: boolean;
|
||||
completePrefix?: string;
|
||||
helpText: string;
|
||||
newHint?: string;
|
||||
icon?: FunctionalComponent<FeatherProps>;
|
||||
}) {
|
||||
const searchBoxRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [matchingOptions, setMatchingOptions] = useState(
|
||||
fuzzySorter("", options),
|
||||
@@ -93,7 +105,7 @@ export function FilterList({
|
||||
}
|
||||
setMatchingOptions(results);
|
||||
|
||||
setText(originalPhrase);
|
||||
// setText(originalPhrase);
|
||||
setSelectionOption(0);
|
||||
}
|
||||
|
||||
@@ -101,12 +113,9 @@ export function FilterList({
|
||||
updateFilter(text);
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
searchBoxRef.current!.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function closer() {
|
||||
console.log("Invoking closer");
|
||||
onSelect(undefined);
|
||||
}
|
||||
|
||||
@@ -117,73 +126,67 @@ export function FilterList({
|
||||
};
|
||||
}, []);
|
||||
|
||||
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}
|
||||
onBlur={(e) => {
|
||||
if (!exiting && searchBoxRef.current) {
|
||||
searchBoxRef.current.focus();
|
||||
}
|
||||
<MiniEditor
|
||||
text={text}
|
||||
vimMode={vimMode}
|
||||
vimStartInInsertMode={true}
|
||||
focus={true}
|
||||
darkMode={darkMode}
|
||||
completer={completer}
|
||||
placeholderText={placeholder}
|
||||
onEnter={() => {
|
||||
onSelect(matchingOptions[selectedOption]);
|
||||
return true;
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
onEscape={() => {
|
||||
onSelect(undefined);
|
||||
}}
|
||||
onChange={(text) => {
|
||||
updateFilter(text);
|
||||
}}
|
||||
onKeyUp={(view, e) => {
|
||||
if (onKeyPress) {
|
||||
onKeyPress(e.key, text);
|
||||
onKeyPress(e.key, view.state.sliceDoc());
|
||||
}
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
setSelectionOption(Math.max(0, selectedOption - 1));
|
||||
break;
|
||||
return true;
|
||||
case "ArrowDown":
|
||||
setSelectionOption(
|
||||
Math.min(matchingOptions.length - 1, selectedOption + 1),
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
exiting = true;
|
||||
onSelect(matchingOptions[selectedOption]);
|
||||
e.preventDefault();
|
||||
break;
|
||||
return true;
|
||||
case "PageUp":
|
||||
setSelectionOption(Math.max(0, selectedOption - 5));
|
||||
break;
|
||||
return true;
|
||||
case "PageDown":
|
||||
setSelectionOption(Math.max(0, selectedOption + 5));
|
||||
break;
|
||||
return true;
|
||||
case "Home":
|
||||
setSelectionOption(0);
|
||||
break;
|
||||
return true;
|
||||
case "End":
|
||||
setSelectionOption(matchingOptions.length - 1);
|
||||
break;
|
||||
case "Escape":
|
||||
exiting = true;
|
||||
onSelect(undefined);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case " ":
|
||||
if (completePrefix && !text) {
|
||||
return true;
|
||||
case " ": {
|
||||
const text = view.state.sliceDoc();
|
||||
if (completePrefix && text === " ") {
|
||||
console.log("Doing the complete thing");
|
||||
setText(completePrefix);
|
||||
updateFilter(completePrefix);
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
updateFilter((e.target as any).value);
|
||||
}
|
||||
}
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -204,8 +207,8 @@ export function FilterList({
|
||||
setSelectionOption(idx);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
exiting = true;
|
||||
console.log("Selecting", option);
|
||||
e.stopPropagation();
|
||||
onSelect(option);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
autocompletion,
|
||||
closeBracketsKeymap,
|
||||
CompletionContext,
|
||||
completionKeymap,
|
||||
CompletionResult,
|
||||
EditorState,
|
||||
EditorView,
|
||||
highlightSpecialChars,
|
||||
history,
|
||||
historyKeymap,
|
||||
keymap,
|
||||
placeholder,
|
||||
standardKeymap,
|
||||
useEffect,
|
||||
useRef,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
Vim,
|
||||
vim,
|
||||
vimGetCm,
|
||||
} from "../deps.ts";
|
||||
|
||||
type MiniEditorEvents = {
|
||||
onEnter: (newText: string) => void;
|
||||
onEscape?: (newText: string) => void;
|
||||
onBlur?: (newText: string) => void | Promise<void>;
|
||||
onChange?: (newText: string) => void;
|
||||
onKeyUp?: (view: EditorView, event: KeyboardEvent) => boolean;
|
||||
};
|
||||
|
||||
export function MiniEditor(
|
||||
{
|
||||
text,
|
||||
placeholderText,
|
||||
vimMode,
|
||||
darkMode,
|
||||
vimStartInInsertMode,
|
||||
onBlur,
|
||||
onEscape,
|
||||
onKeyUp,
|
||||
onEnter,
|
||||
onChange,
|
||||
focus,
|
||||
completer,
|
||||
}: {
|
||||
text: string;
|
||||
placeholderText?: string;
|
||||
vimMode: boolean;
|
||||
darkMode: boolean;
|
||||
vimStartInInsertMode?: boolean;
|
||||
focus?: boolean;
|
||||
completer?: (
|
||||
context: CompletionContext,
|
||||
) => Promise<CompletionResult | null>;
|
||||
} & MiniEditorEvents,
|
||||
) {
|
||||
const editorDiv = useRef<HTMLDivElement>(null);
|
||||
const editorViewRef = useRef<EditorView>();
|
||||
const vimModeRef = useRef<string>("normal");
|
||||
// TODO: This super duper ugly, but I don't know how to avoid it
|
||||
// Due to how MiniCodeEditor is built, it captures the closures of all callback functions
|
||||
// which results in them pointing to old state variables, to avoid this we do this...
|
||||
const callbacksRef = useRef<MiniEditorEvents>();
|
||||
|
||||
useEffect(() => {
|
||||
if (editorDiv.current) {
|
||||
console.log("Creating editor view");
|
||||
const editorView = new EditorView({
|
||||
state: buildEditorState(),
|
||||
parent: editorDiv.current!,
|
||||
});
|
||||
editorViewRef.current = editorView;
|
||||
|
||||
if (focus) {
|
||||
editorView.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (editorViewRef.current) {
|
||||
editorViewRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [editorDiv]);
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onBlur, onEnter, onEscape, onKeyUp, onChange };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current) {
|
||||
editorViewRef.current.setState(buildEditorState());
|
||||
editorViewRef.current.dispatch({
|
||||
selection: { anchor: text.length },
|
||||
});
|
||||
}
|
||||
}, [text, vimMode]);
|
||||
|
||||
let onBlurred = false, onEntered = false;
|
||||
|
||||
// console.log("Rendering editor");
|
||||
|
||||
return <div class="sb-mini-editor" ref={editorDiv} />;
|
||||
|
||||
function buildEditorState() {
|
||||
// When vim mode is active, we need for CM to have created the new state
|
||||
// and the subscribe to the vim mode's events
|
||||
// This needs to happen in the next tick, so we wait a tick with setTimeout
|
||||
if (vimMode) {
|
||||
// Only applies to vim mode
|
||||
setTimeout(() => {
|
||||
const cm = vimGetCm(editorViewRef.current!)!;
|
||||
cm.on("vim-mode-change", ({ mode }: { mode: string }) => {
|
||||
vimModeRef.current = mode;
|
||||
});
|
||||
if (vimStartInInsertMode) {
|
||||
Vim.handleKey(cm, "i");
|
||||
}
|
||||
});
|
||||
}
|
||||
return EditorState.create({
|
||||
doc: text,
|
||||
extensions: [
|
||||
EditorView.theme({}, { dark: darkMode }),
|
||||
// Enable vim mode, or not
|
||||
[...vimMode ? [vim()] : []],
|
||||
|
||||
autocompletion({
|
||||
override: completer ? [completer] : [],
|
||||
}),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
[...placeholderText ? [placeholder(placeholderText)] : []],
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: (view) => {
|
||||
onEnter(view);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
run: (view) => {
|
||||
callbacksRef.current!.onEscape &&
|
||||
callbacksRef.current!.onEscape(view.state.sliceDoc());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
...closeBracketsKeymap,
|
||||
...standardKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
]),
|
||||
EditorView.domEventHandlers({
|
||||
click: (e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
keyup: (event, view) => {
|
||||
if (event.key === "Escape") {
|
||||
// Esc should be handled by the keymap
|
||||
return false;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
// Enter should be handled by the keymap, except when in Vim normal mode
|
||||
// because then it's disabled
|
||||
if (vimMode && vimModeRef.current === "normal") {
|
||||
onEnter(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (callbacksRef.current!.onKeyUp) {
|
||||
return callbacksRef.current!.onKeyUp(view, event);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
blur: (_e, view) => {
|
||||
onBlur(view);
|
||||
},
|
||||
}),
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged) {
|
||||
callbacksRef.current!.onChange &&
|
||||
callbacksRef.current!.onChange(update.state.sliceDoc());
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Avoid double triggering these events (may happen due to onkeypress vs onkeyup delay)
|
||||
function onEnter(view: EditorView) {
|
||||
if (onEntered) {
|
||||
return;
|
||||
}
|
||||
onEntered = true;
|
||||
callbacksRef.current!.onEnter(view.state.sliceDoc());
|
||||
// Event may occur again in 500ms
|
||||
setTimeout(() => {
|
||||
onEntered = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onBlur(view: EditorView) {
|
||||
if (onBlurred || onEntered) {
|
||||
return;
|
||||
}
|
||||
onBlurred = true;
|
||||
if (callbacksRef.current!.onBlur) {
|
||||
Promise.resolve(callbacksRef.current!.onBlur(view.state.sliceDoc()))
|
||||
.catch((e) => {
|
||||
// Reset the state
|
||||
view.setState(buildEditorState());
|
||||
});
|
||||
}
|
||||
// Event may occur again in 500ms
|
||||
setTimeout(() => {
|
||||
onBlurred = false;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import { FilterList } from "./filter.tsx";
|
||||
import { FilterOption, PageMeta } from "../../common/types.ts";
|
||||
import { CompletionContext, CompletionResult } from "../deps.ts";
|
||||
|
||||
export function PageNavigator({
|
||||
allPages,
|
||||
onNavigate,
|
||||
completer,
|
||||
vimMode,
|
||||
darkMode,
|
||||
currentPage,
|
||||
}: {
|
||||
allPages: Set<PageMeta>;
|
||||
vimMode: boolean;
|
||||
darkMode: boolean;
|
||||
onNavigate: (page: string | undefined) => void;
|
||||
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
|
||||
currentPage?: string;
|
||||
}) {
|
||||
const options: FilterOption[] = [];
|
||||
@@ -40,7 +47,9 @@ export function PageNavigator({
|
||||
placeholder="Page"
|
||||
label="Open"
|
||||
options={options}
|
||||
// icon={faFileLines}
|
||||
vimMode={vimMode}
|
||||
darkMode={darkMode}
|
||||
completer={completer}
|
||||
allowNew={true}
|
||||
helpText="Start typing the page name to filter results, press <code>Return</code> to open."
|
||||
newHint="Create page"
|
||||
|
||||
@@ -83,10 +83,8 @@ export function Panel({
|
||||
editor.dispatchAppEvent(data.name, ...data.args);
|
||||
}
|
||||
};
|
||||
console.log("Registering event handler");
|
||||
globalThis.addEventListener("message", messageListener);
|
||||
return () => {
|
||||
console.log("Unregistering event handler");
|
||||
globalThis.removeEventListener("message", messageListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
+74
-34
@@ -1,15 +1,13 @@
|
||||
import { useRef } from "../deps.ts";
|
||||
import { ComponentChildren } from "../deps.ts";
|
||||
import {
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "../deps.ts";
|
||||
import type { ComponentChildren, FunctionalComponent } from "../deps.ts";
|
||||
import { Notification } from "../types.ts";
|
||||
import { FunctionalComponent } from "https://esm.sh/v99/preact@10.11.1/src/index";
|
||||
import { FeatherProps } from "https://esm.sh/v99/preact-feather@4.2.1/dist/types";
|
||||
|
||||
function prettyName(s: string | undefined): string {
|
||||
if (!s) {
|
||||
return "";
|
||||
}
|
||||
return s.replaceAll("/", " / ");
|
||||
}
|
||||
import { MiniEditor } from "./mini_editor.tsx";
|
||||
|
||||
export type ActionButton = {
|
||||
icon: FunctionalComponent<FeatherProps>;
|
||||
@@ -24,6 +22,9 @@ export function TopBar({
|
||||
notifications,
|
||||
onRename,
|
||||
actionButtons,
|
||||
darkMode,
|
||||
vimMode,
|
||||
completer,
|
||||
lhs,
|
||||
rhs,
|
||||
}: {
|
||||
@@ -31,7 +32,10 @@ export function TopBar({
|
||||
unsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
notifications: Notification[];
|
||||
onRename: (newName?: string) => void;
|
||||
darkMode: boolean;
|
||||
vimMode: boolean;
|
||||
onRename: (newName?: string) => Promise<void>;
|
||||
completer: (context: CompletionContext) => Promise<CompletionResult | null>;
|
||||
actionButtons: ActionButton[];
|
||||
lhs?: ComponentChildren;
|
||||
rhs?: ComponentChildren;
|
||||
@@ -39,6 +43,31 @@ export function TopBar({
|
||||
// const [theme, setTheme] = useState<string>(localStorage.theme ?? "light");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Another one of my less proud moments:
|
||||
// Somehow I cannot seem to proerply limit the width of the page name, so I'm doing
|
||||
// it this way. If you have a better way to do this, please let me know!
|
||||
useEffect(() => {
|
||||
function resizeHandler() {
|
||||
const currentPageElement = document.getElementById("sb-current-page");
|
||||
if (currentPageElement) {
|
||||
// Temporarily make it very narrow to give the parent space
|
||||
currentPageElement.style.width = "10px";
|
||||
const innerDiv = currentPageElement.parentElement!.parentElement!;
|
||||
|
||||
// Then calculate a new width
|
||||
currentPageElement.style.width = `${
|
||||
Math.min(650, innerDiv.clientWidth - 150)
|
||||
}px`;
|
||||
}
|
||||
}
|
||||
globalThis.addEventListener("resize", resizeHandler);
|
||||
|
||||
// Stop listening on unmount
|
||||
return () => {
|
||||
globalThis.removeEventListener("resize", resizeHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="sb-top">
|
||||
{lhs}
|
||||
@@ -46,32 +75,43 @@ export function TopBar({
|
||||
<div className="inner">
|
||||
<div className="wrapper">
|
||||
<span
|
||||
className={`sb-current-page ${
|
||||
isLoading
|
||||
? "sb-loading"
|
||||
: unsavedChanges
|
||||
? "sb-unsaved"
|
||||
: "sb-saved"
|
||||
}`}
|
||||
id="sb-current-page"
|
||||
className={isLoading
|
||||
? "sb-loading"
|
||||
: unsavedChanges
|
||||
? "sb-unsaved"
|
||||
: "sb-saved"}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
value={pageName}
|
||||
className="sb-edit-page-name"
|
||||
onBlur={(e) => {
|
||||
(e.target as any).value = pageName;
|
||||
<MiniEditor
|
||||
text={pageName ?? ""}
|
||||
vimMode={vimMode}
|
||||
darkMode={darkMode}
|
||||
onBlur={(newName) => {
|
||||
if (newName !== pageName) {
|
||||
return onRename(newName);
|
||||
} else {
|
||||
return onRename();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const newName = (e.target as any).value;
|
||||
onRename(newName);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onRename();
|
||||
onKeyUp={(view, event) => {
|
||||
// When moving cursor down, cancel and move back to editor
|
||||
if (event.key === "ArrowDown") {
|
||||
const parent =
|
||||
(event.target as any).parentElement.parentElement;
|
||||
// Unless we have autocomplete open
|
||||
if (
|
||||
parent.getElementsByClassName("cm-tooltip-autocomplete")
|
||||
.length === 0
|
||||
) {
|
||||
onRename();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
completer={completer}
|
||||
onEnter={(newName) => {
|
||||
onRename(newName);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
+7
-9
@@ -1,11 +1,7 @@
|
||||
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 { Fragment, h, render as preactRender } from "preact";
|
||||
export type { ComponentChildren, FunctionalComponent } from "preact";
|
||||
export {
|
||||
useEffect,
|
||||
useReducer,
|
||||
@@ -16,8 +12,6 @@ export {
|
||||
export {
|
||||
Book as BookIcon,
|
||||
Home as HomeIcon,
|
||||
Moon as MoonIcon,
|
||||
Sun as SunIcon,
|
||||
Terminal as TerminalIcon,
|
||||
} from "https://esm.sh/preact-feather@4.2.1";
|
||||
|
||||
@@ -30,4 +24,8 @@ export {
|
||||
export { WebsocketProvider } from "https://esm.sh/y-websocket@1.4.5?external=yjs";
|
||||
|
||||
// Vim mode
|
||||
export { vim } from "https://esm.sh/@replit/codemirror-vim@6.0.4?external=@codemirror/state,@codemirror/language,@codemirror/view,@codemirror/search,@codemirror/commands";
|
||||
export {
|
||||
getCM as vimGetCm,
|
||||
Vim,
|
||||
vim,
|
||||
} from "https://esm.sh/@replit/codemirror-vim@6.0.4?external=@codemirror/state,@codemirror/language,@codemirror/view,@codemirror/search,@codemirror/commands";
|
||||
|
||||
+101
-42
@@ -2,9 +2,7 @@
|
||||
import {
|
||||
BookIcon,
|
||||
HomeIcon,
|
||||
MoonIcon,
|
||||
preactRender,
|
||||
SunIcon,
|
||||
TerminalIcon,
|
||||
useEffect,
|
||||
useReducer,
|
||||
@@ -16,6 +14,7 @@ import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
CompletionContext,
|
||||
completionKeymap,
|
||||
CompletionResult,
|
||||
drawSelection,
|
||||
@@ -86,7 +85,11 @@ import {
|
||||
BuiltinSettings,
|
||||
initialViewState,
|
||||
} from "./types.ts";
|
||||
import type { AppEvent, ClickEvent } from "../plug-api/app_event.ts";
|
||||
import type {
|
||||
AppEvent,
|
||||
ClickEvent,
|
||||
CompleteEvent,
|
||||
} from "../plug-api/app_event.ts";
|
||||
|
||||
// UI Components
|
||||
import { CommandPalette } from "./components/command_palette.tsx";
|
||||
@@ -109,7 +112,7 @@ import customMarkdownStyle from "./style.ts";
|
||||
// Real-time collaboration
|
||||
import { CollabState } from "./cm_plugins/collab.ts";
|
||||
import { collabSyscalls } from "./syscalls/collab.ts";
|
||||
import { vim } from "./deps.ts";
|
||||
import { Vim, vim, vimGetCm } from "./deps.ts";
|
||||
|
||||
const frontMatterRegex = /^---\n(.*?)---\n/ms;
|
||||
|
||||
@@ -146,7 +149,7 @@ export class Editor {
|
||||
|
||||
// Runtime state (that doesn't make sense in viewState)
|
||||
collabState?: CollabState;
|
||||
enableVimMode = false;
|
||||
// enableVimMode = false;
|
||||
|
||||
constructor(
|
||||
space: Space,
|
||||
@@ -188,6 +191,7 @@ export class Editor {
|
||||
state: this.createEditorState("", ""),
|
||||
parent: document.getElementById("sb-editor")!,
|
||||
});
|
||||
|
||||
this.pageNavigator = new PathPageNavigator(
|
||||
builtinSettings.indexPage,
|
||||
urlPrefix,
|
||||
@@ -212,8 +216,8 @@ export class Editor {
|
||||
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
|
||||
globalThis.addEventListener("keydown", (ev) => {
|
||||
if (!this.editorView?.hasFocus) {
|
||||
if ((ev.target as any).closest(".cm-panel")) {
|
||||
// In some CM panel, let's back out
|
||||
if ((ev.target as any).closest(".cm-editor")) {
|
||||
// In some cm element, let's back out
|
||||
return;
|
||||
}
|
||||
if (runScopeHandlers(this.editorView!, ev, "editor")) {
|
||||
@@ -340,7 +344,10 @@ export class Editor {
|
||||
this.saveTimeout = setTimeout(
|
||||
() => {
|
||||
if (this.currentPage) {
|
||||
if (!this.viewState.unsavedChanges || this.viewState.forcedROMode) {
|
||||
if (
|
||||
!this.viewState.unsavedChanges ||
|
||||
this.viewState.uiOptions.forcedROMode
|
||||
) {
|
||||
// No unsaved changes, or read-only mode, not gonna save
|
||||
return resolve();
|
||||
}
|
||||
@@ -458,8 +465,10 @@ export class Editor {
|
||||
return EditorState.create({
|
||||
doc: this.collabState ? this.collabState.ytext.toString() : text,
|
||||
extensions: [
|
||||
// Not using CM theming right now, but some extensions depend on the "dark" thing
|
||||
EditorView.theme({}, { dark: this.viewState.uiOptions.darkMode }),
|
||||
// Enable vim mode, or not
|
||||
[...this.enableVimMode ? [vim({ status: true })] : []],
|
||||
[...editor.viewState.uiOptions.vimMode ? [vim({ status: true })] : []],
|
||||
// The uber markdown mode
|
||||
markdown({
|
||||
base: buildMarkdown(this.mdExtensions),
|
||||
@@ -485,7 +494,7 @@ export class Editor {
|
||||
syntaxHighlighting(customMarkdownStyle(this.mdExtensions)),
|
||||
autocompletion({
|
||||
override: [
|
||||
this.completer.bind(this),
|
||||
this.editorComplete.bind(this),
|
||||
this.slashCommandHook.slashCommandCompleter.bind(
|
||||
this.slashCommandHook,
|
||||
),
|
||||
@@ -494,8 +503,6 @@ export class Editor {
|
||||
inlineImagesPlugin(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
// Enable vim mode
|
||||
[...this.enableVimMode ? [vim()] : []],
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
@@ -518,6 +525,23 @@ export class Editor {
|
||||
{ selector: "FrontMatter", class: "sb-frontmatter" },
|
||||
]),
|
||||
keymap.of([
|
||||
{
|
||||
key: "ArrowUp",
|
||||
run: (view): boolean => {
|
||||
// When going up while at the top of the document, focus the page name
|
||||
const selection = view.state.selection.main;
|
||||
const line = view.state.doc.lineAt(selection.from);
|
||||
// Are we at the top of the document?
|
||||
if (line.number === 1) {
|
||||
// This can be done much nicer, but this is shorter, so... :)
|
||||
document.querySelector<HTMLDivElement>(
|
||||
"#sb-current-page .cm-content",
|
||||
)!.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
...smartQuoteKeymap,
|
||||
...closeBracketsKeymap,
|
||||
...standardKeymap,
|
||||
@@ -548,7 +572,6 @@ export class Editor {
|
||||
},
|
||||
},
|
||||
]),
|
||||
|
||||
EditorView.domEventHandlers({
|
||||
click: (event: MouseEvent, view: EditorView) => {
|
||||
safeRun(async () => {
|
||||
@@ -625,8 +648,20 @@ export class Editor {
|
||||
}
|
||||
}
|
||||
|
||||
async completer(): Promise<CompletionResult | null> {
|
||||
const results = await this.dispatchAppEvent("page:complete");
|
||||
// Code completion support
|
||||
private async completeWithEvent(
|
||||
context: CompletionContext,
|
||||
eventName: AppEvent,
|
||||
): Promise<CompletionResult | null> {
|
||||
const editorState = context.state;
|
||||
const selection = editorState.selection.main;
|
||||
const line = editorState.doc.lineAt(selection.from);
|
||||
const linePrefix = line.text.slice(0, selection.from - line.from);
|
||||
|
||||
const results = await this.dispatchAppEvent(eventName, {
|
||||
linePrefix,
|
||||
pos: selection.from,
|
||||
} as CompleteEvent);
|
||||
let actualResult = null;
|
||||
for (const result of results) {
|
||||
if (result) {
|
||||
@@ -642,6 +677,18 @@ export class Editor {
|
||||
return actualResult;
|
||||
}
|
||||
|
||||
editorComplete(
|
||||
context: CompletionContext,
|
||||
): Promise<CompletionResult | null> {
|
||||
return this.completeWithEvent(context, "editor:complete");
|
||||
}
|
||||
|
||||
miniEditorComplete(
|
||||
context: CompletionContext,
|
||||
): Promise<CompletionResult | null> {
|
||||
return this.completeWithEvent(context, "minieditor:complete");
|
||||
}
|
||||
|
||||
async reloadPage() {
|
||||
console.log("Reloading page");
|
||||
clearTimeout(this.saveTimeout);
|
||||
@@ -746,7 +793,7 @@ export class Editor {
|
||||
contentDOM.setAttribute("autocapitalize", "on");
|
||||
contentDOM.setAttribute(
|
||||
"contenteditable",
|
||||
readOnly || this.viewState.forcedROMode ? "false" : "true",
|
||||
readOnly || this.viewState.uiOptions.forcedROMode ? "false" : "true",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -802,7 +849,22 @@ export class Editor {
|
||||
viewState.perm === "ro",
|
||||
);
|
||||
}
|
||||
}, [viewState.forcedROMode]);
|
||||
}, [viewState.uiOptions.forcedROMode]);
|
||||
|
||||
useEffect(() => {
|
||||
this.rebuildEditorState();
|
||||
}, [viewState.uiOptions.vimMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = viewState.uiOptions.darkMode
|
||||
? "dark"
|
||||
: "light";
|
||||
}, [viewState.uiOptions.darkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Need to dispatch a resize event so that the top_bar can pick it up
|
||||
globalThis.dispatchEvent(new Event("resize"));
|
||||
}, [viewState.panels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -810,6 +872,9 @@ export class Editor {
|
||||
<PageNavigator
|
||||
allPages={viewState.allPages}
|
||||
currentPage={this.currentPage}
|
||||
completer={this.miniEditorComplete.bind(this)}
|
||||
vimMode={viewState.uiOptions.vimMode}
|
||||
darkMode={viewState.uiOptions.darkMode}
|
||||
onNavigate={(page) => {
|
||||
dispatch({ type: "stop-navigate" });
|
||||
editor.focus();
|
||||
@@ -840,6 +905,9 @@ export class Editor {
|
||||
}
|
||||
}}
|
||||
commands={viewState.commands}
|
||||
vimMode={viewState.uiOptions.vimMode}
|
||||
darkMode={viewState.uiOptions.darkMode}
|
||||
completer={this.miniEditorComplete.bind(this)}
|
||||
recentCommands={viewState.recentCommands}
|
||||
/>
|
||||
)}
|
||||
@@ -848,7 +916,10 @@ export class Editor {
|
||||
label={viewState.filterBoxLabel}
|
||||
placeholder={viewState.filterBoxPlaceHolder}
|
||||
options={viewState.filterBoxOptions}
|
||||
vimMode={viewState.uiOptions.vimMode}
|
||||
darkMode={viewState.uiOptions.darkMode}
|
||||
allowNew={false}
|
||||
completer={this.miniEditorComplete.bind(this)}
|
||||
helpText={viewState.filterBoxHelpText}
|
||||
onSelect={viewState.filterBoxOnSelect}
|
||||
/>
|
||||
@@ -858,17 +929,24 @@ export class Editor {
|
||||
notifications={viewState.notifications}
|
||||
unsavedChanges={viewState.unsavedChanges}
|
||||
isLoading={viewState.isLoading}
|
||||
onRename={(newName) => {
|
||||
vimMode={viewState.uiOptions.vimMode}
|
||||
darkMode={viewState.uiOptions.darkMode}
|
||||
completer={editor.miniEditorComplete.bind(editor)}
|
||||
onRename={async (newName) => {
|
||||
if (!newName) {
|
||||
return editor.focus();
|
||||
// Always move cursor to the start of the page
|
||||
editor.editorView?.dispatch({
|
||||
selection: { anchor: 0 },
|
||||
});
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
console.log("Now renaming page to...", newName);
|
||||
editor.system.loadedPlugs.get("core")!.invoke(
|
||||
await editor.system.loadedPlugs.get("core")!.invoke(
|
||||
"renamePage",
|
||||
[{ page: newName }],
|
||||
).then(() => {
|
||||
editor.focus();
|
||||
}).catch(console.error);
|
||||
);
|
||||
editor.focus();
|
||||
}}
|
||||
actionButtons={[
|
||||
{
|
||||
@@ -892,20 +970,6 @@ export class Editor {
|
||||
dispatch({ type: "show-palette", context: this.getContext() });
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: localStorage.theme === "dark" ? SunIcon : MoonIcon,
|
||||
description: "Toggle dark mode",
|
||||
callback: () => {
|
||||
if (localStorage.theme === "dark") {
|
||||
localStorage.theme = "light";
|
||||
} else {
|
||||
localStorage.theme = "dark";
|
||||
}
|
||||
document.documentElement.dataset.theme = localStorage.theme;
|
||||
// Trigger rerender: TERRIBLE IMPLEMENTATION
|
||||
dispatch({ type: "page-saved" });
|
||||
},
|
||||
},
|
||||
]}
|
||||
rhs={!!viewState.panels.rhs.mode && (
|
||||
<div
|
||||
@@ -985,9 +1049,4 @@ export class Editor {
|
||||
});
|
||||
this.rebuildEditorState();
|
||||
}
|
||||
|
||||
setVimMode(vimMode: boolean) {
|
||||
this.enableVimMode = vimMode;
|
||||
this.rebuildEditorState();
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -139,10 +139,13 @@ export default function reducer(
|
||||
filterBoxOptions: [],
|
||||
filterBoxHelpText: "",
|
||||
};
|
||||
case "set-editor-ro":
|
||||
case "set-ui-option":
|
||||
return {
|
||||
...state,
|
||||
forcedROMode: action.enabled,
|
||||
uiOptions: {
|
||||
...state.uiOptions,
|
||||
[action.key]: action.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
|
||||
+41
-13
@@ -1,4 +1,8 @@
|
||||
.cm-editor {
|
||||
.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
#sb-main .cm-editor {
|
||||
font-size: 18px;
|
||||
--max-width: 800px;
|
||||
height: 100%;
|
||||
@@ -18,9 +22,6 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// Indentation of follow-up lines
|
||||
@mixin lineOverflow($baseIndent, $bulletIndent: 0) {
|
||||
@@ -74,19 +75,41 @@
|
||||
}
|
||||
|
||||
&.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 {
|
||||
@include lineOverflow(7);
|
||||
}
|
||||
|
||||
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
|
||||
@include lineOverflow(6);
|
||||
@include lineOverflow(10);
|
||||
}
|
||||
|
||||
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
|
||||
@include lineOverflow(8);
|
||||
@include lineOverflow(13);
|
||||
}
|
||||
}
|
||||
|
||||
.sb-line-ol.sb-line-ul {
|
||||
// &.sb-line-li-1 {
|
||||
// @include lineOverflow(1);
|
||||
// }
|
||||
|
||||
&.sb-line-li-1.sb-line-li-2 {
|
||||
@include lineOverflow(3);
|
||||
}
|
||||
|
||||
&.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
|
||||
@include lineOverflow(6);
|
||||
}
|
||||
|
||||
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
|
||||
@include lineOverflow(9);
|
||||
}
|
||||
|
||||
&.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
|
||||
@include lineOverflow(12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +219,14 @@
|
||||
margin-left: -1ch;
|
||||
}
|
||||
|
||||
|
||||
.cm-scroller {
|
||||
// Give some breathing space at the bottom of the screen
|
||||
padding-bottom: 20em;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
// Give some breathing space at the bottom of the screen
|
||||
padding-bottom: 20em;
|
||||
div:not(.cm-focused).cm-fat-cursor {
|
||||
outline: none !important;
|
||||
}
|
||||
@@ -25,14 +25,6 @@
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 3px;
|
||||
outline: 0;
|
||||
font-size: 1em;
|
||||
flex-grow: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.sb-help-text {
|
||||
|
||||
+12
-13
@@ -77,26 +77,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sb-current-page {
|
||||
#sb-current-page {
|
||||
flex: 1;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
display: block;
|
||||
text-overflow: ellipsis;
|
||||
// Action buttons width
|
||||
margin-right: 140px;
|
||||
}
|
||||
|
||||
input.sb-edit-page-name {
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
.cm-scroller {
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
padding: 0;
|
||||
|
||||
.cm-line {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+471
-447
File diff suppressed because it is too large
Load Diff
+7
-52
@@ -3,29 +3,6 @@ import { EditorView, 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) {
|
||||
let _a;
|
||||
const { source } = expr;
|
||||
const 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 => {
|
||||
@@ -155,26 +132,6 @@ export function editorSyscalls(editor: Editor): SysCallMapping {
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
"editor.matchBefore": (
|
||||
_ctx,
|
||||
regexp: string,
|
||||
): { from: number; to: number; text: string } | null => {
|
||||
const editorState = editor.editorView!.state;
|
||||
const selection = editorState.selection.main;
|
||||
const from = selection.from;
|
||||
if (selection.empty) {
|
||||
const line = editorState.doc.lineAt(from);
|
||||
const start = Math.max(line.from, from - 250);
|
||||
const str = line.text.slice(start - line.from, from - line.from);
|
||||
const 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);
|
||||
},
|
||||
@@ -191,18 +148,16 @@ export function editorSyscalls(editor: Editor): SysCallMapping {
|
||||
): boolean => {
|
||||
return confirm(message);
|
||||
},
|
||||
"editor.enableReadOnlyMode": (_ctx, enabled: boolean) => {
|
||||
"editor.getUiOption": (_ctx, key: string): any => {
|
||||
return (editor.viewState.uiOptions as any)[key];
|
||||
},
|
||||
"editor.setUiOption": (_ctx, key: string, value: any) => {
|
||||
editor.viewDispatch({
|
||||
type: "set-editor-ro",
|
||||
enabled,
|
||||
type: "set-ui-option",
|
||||
key,
|
||||
value,
|
||||
});
|
||||
},
|
||||
"editor.getVimEnabled": (): boolean => {
|
||||
return editor.enableVimMode;
|
||||
},
|
||||
"editor.setVimEnabled": (_ctx, enabled: boolean) => {
|
||||
editor.setVimMode(enabled);
|
||||
},
|
||||
};
|
||||
|
||||
return syscalls;
|
||||
|
||||
+12
-3
@@ -26,7 +26,6 @@ export type AppViewState = {
|
||||
currentPage?: string;
|
||||
editingPageName: boolean;
|
||||
perm: EditorMode;
|
||||
forcedROMode: boolean;
|
||||
isLoading: boolean;
|
||||
showPageNavigator: boolean;
|
||||
showCommandPalette: boolean;
|
||||
@@ -37,6 +36,12 @@ export type AppViewState = {
|
||||
notifications: Notification[];
|
||||
recentCommands: Map<string, Date>;
|
||||
|
||||
uiOptions: {
|
||||
vimMode: boolean;
|
||||
darkMode: boolean;
|
||||
forcedROMode: boolean;
|
||||
};
|
||||
|
||||
showFilterBox: boolean;
|
||||
filterBoxLabel: string;
|
||||
filterBoxPlaceHolder: string;
|
||||
@@ -48,11 +53,15 @@ export type AppViewState = {
|
||||
export const initialViewState: AppViewState = {
|
||||
perm: "rw",
|
||||
editingPageName: false,
|
||||
forcedROMode: false,
|
||||
isLoading: false,
|
||||
showPageNavigator: false,
|
||||
showCommandPalette: false,
|
||||
unsavedChanges: false,
|
||||
uiOptions: {
|
||||
vimMode: false,
|
||||
darkMode: false,
|
||||
forcedROMode: false,
|
||||
},
|
||||
panels: {
|
||||
lhs: {},
|
||||
rhs: {},
|
||||
@@ -103,4 +112,4 @@ export type Action =
|
||||
onSelect: (option: FilterOption | undefined) => void;
|
||||
}
|
||||
| { type: "hide-filterbox" }
|
||||
| { type: "set-editor-ro"; enabled: boolean };
|
||||
| { type: "set-ui-option"; key: string; value: any };
|
||||
|
||||
Reference in New Issue
Block a user