508 lines
17 KiB
TypeScript
508 lines
17 KiB
TypeScript
import customMarkdownStyle from "../style.ts";
|
|
import {
|
|
history,
|
|
insertNewlineAndIndent,
|
|
isolateHistory,
|
|
} from "@codemirror/commands";
|
|
import {
|
|
autocompletion,
|
|
closeBrackets,
|
|
closeBracketsKeymap,
|
|
} from "@codemirror/autocomplete";
|
|
import {
|
|
codeFolding,
|
|
foldEffect,
|
|
indentOnInput,
|
|
indentUnit,
|
|
LanguageDescription,
|
|
LanguageSupport,
|
|
syntaxHighlighting,
|
|
unfoldEffect,
|
|
} from "@codemirror/language";
|
|
import {
|
|
Compartment,
|
|
EditorState,
|
|
type Extension,
|
|
Prec,
|
|
} from "@codemirror/state";
|
|
import {
|
|
drawSelection,
|
|
dropCursor,
|
|
EditorView,
|
|
highlightSpecialChars,
|
|
type KeyBinding,
|
|
keymap,
|
|
ViewPlugin,
|
|
type ViewUpdate,
|
|
} from "@codemirror/view";
|
|
import {
|
|
deleteMarkupBackward,
|
|
markdown,
|
|
} from "@codemirror/lang-markdown";
|
|
import { customEnterCommand } from "./markdown_enter.ts";
|
|
import type { Client } from "../client.ts";
|
|
import { loadVim } from "../vim_loader.ts";
|
|
import { inlineContentPlugin } from "./inline_content.ts";
|
|
import { cleanModePlugins } from "./clean.ts";
|
|
import { lineWrapper } from "./line_wrapper.ts";
|
|
import { createSmartQuoteKeyBindings } from "./smart_quotes.ts";
|
|
import { documentExtension, pasteLinkExtension } from "./editor_paste.ts";
|
|
import type { TextChange } from "./change.ts";
|
|
import { postScriptPrefacePlugin } from "./top_bottom_panels.ts";
|
|
import { lazyLanguages, languageFor, loadLanguageFor } from "../languages.ts";
|
|
import { plugLinter } from "./lint.ts";
|
|
import { buildExtendedMarkdownLanguage } from "../markdown_parser/parser.ts";
|
|
import { safeRun } from "@silverbulletmd/silverbullet/lib/async";
|
|
import { codeCopyPlugin } from "../codemirror/code_copy.ts";
|
|
import { disableSpellcheck } from "../codemirror/spell_checking.ts";
|
|
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
|
|
|
|
export function createEditorState(
|
|
client: Client,
|
|
pageName: string,
|
|
text: string,
|
|
readOnly: boolean,
|
|
): EditorState {
|
|
let touchCount = 0;
|
|
|
|
// Ugly: keep the commandKeyHandler compartment in the client, to be replaced
|
|
// later once more commands are loaded
|
|
client.commandKeyHandlerCompartment = new Compartment();
|
|
client.vimCompartment = new Compartment();
|
|
const commandKeyBindings = client.commandKeyHandlerCompartment.of(
|
|
createCommandKeyBindings(client),
|
|
);
|
|
// Regular key bindings are not dynamically updated and do not require a
|
|
// compartment.
|
|
const regularKeyBindings = createRegularKeyBindings(client);
|
|
|
|
client.indentUnitCompartment = new Compartment();
|
|
const indentUnits = client.indentUnitCompartment.of(indentUnit.of(" "));
|
|
|
|
client.undoHistoryCompartment = new Compartment();
|
|
const undoHistory = client.undoHistoryCompartment.of([history()]);
|
|
|
|
// Build the markdown language with any custom syntax extensions
|
|
client.markdownLanguageCompartment = new Compartment();
|
|
const markdownLanguageExtension = client.markdownLanguageCompartment.of(
|
|
buildMarkdownLanguageExtension(client),
|
|
);
|
|
|
|
const vimMode = client.ui.viewState.uiOptions.vimMode;
|
|
|
|
// If vim mode is requested, load it async and reconfigure the compartment
|
|
if (vimMode) {
|
|
void enableVimMode(client);
|
|
}
|
|
|
|
const readOnlyExtensions: Extension[] =
|
|
readOnly ||
|
|
client.ui.viewState.uiOptions.forcedROMode ||
|
|
client.bootConfig.readOnly
|
|
? [EditorView.editable.of(false), EditorState.readOnly.of(true)]
|
|
: [];
|
|
|
|
return EditorState.create({
|
|
doc: text,
|
|
extensions: [
|
|
// Not using CM theming right now, but some extensions depend on the "dark" thing
|
|
EditorView.theme(
|
|
{},
|
|
{
|
|
dark: client.ui.viewState.uiOptions.darkMode,
|
|
},
|
|
),
|
|
|
|
// Insert our command key bindings *before* vim mode. Vim in normal-mode is
|
|
// greedy and captures all key events, preventing them from reaching our
|
|
// own handlers to trigger commands. This will mean some vim-mode
|
|
// bindings wont trigger if they have the same keys.
|
|
commandKeyBindings,
|
|
|
|
// Vim mode compartment — starts empty, loaded async if needed
|
|
client.vimCompartment.of([]),
|
|
readOnlyExtensions,
|
|
|
|
// The uber markdown mode (in a compartment so it can be reconfigured on reload)
|
|
markdownLanguageExtension,
|
|
syntaxHighlighting(customMarkdownStyle()),
|
|
autocompletion({
|
|
override: [
|
|
client.editorComplete.bind(client),
|
|
client.clientSystem.slashCommandHook!.slashCommandCompleter.bind(
|
|
client.clientSystem.slashCommandHook,
|
|
),
|
|
],
|
|
optionClass(completion: any) {
|
|
if (completion.cssClass) {
|
|
return `sb-decorated-object ${completion.cssClass}`;
|
|
} else {
|
|
return "";
|
|
}
|
|
},
|
|
}),
|
|
EditorView.contentAttributes.of({
|
|
spellcheck: "true",
|
|
autocorrect: "on",
|
|
autocapitalize: "on",
|
|
}),
|
|
inlineContentPlugin(client),
|
|
codeCopyPlugin(client),
|
|
highlightSpecialChars(),
|
|
undoHistory,
|
|
dropCursor(),
|
|
codeFolding({
|
|
placeholderText: "…",
|
|
}),
|
|
indentUnits,
|
|
indentOnInput(),
|
|
...cleanModePlugins(client),
|
|
EditorView.lineWrapping,
|
|
plugLinter(client),
|
|
drawSelection(),
|
|
postScriptPrefacePlugin(client),
|
|
lineWrapper([
|
|
{ selector: "ATXHeading1", class: "sb-line-h1" },
|
|
{ selector: "ATXHeading2", class: "sb-line-h2" },
|
|
{ selector: "ATXHeading3", class: "sb-line-h3" },
|
|
{ selector: "ATXHeading4", class: "sb-line-h4" },
|
|
{ selector: "ATXHeading5", class: "sb-line-h5" },
|
|
{ selector: "ATXHeading6", class: "sb-line-h6" },
|
|
{ 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" },
|
|
{
|
|
selector: "FrontMatter",
|
|
class: "sb-frontmatter",
|
|
},
|
|
]),
|
|
disableSpellcheck(["InlineCode", "CodeText", "CodeInfo", "FrontMatter"]),
|
|
regularKeyBindings,
|
|
EditorView.domEventHandlers({
|
|
// This may result in duplicated touch events on mobile devices
|
|
touchmove: () => {
|
|
touchCount++;
|
|
},
|
|
touchend: (event: TouchEvent, view: EditorView) => {
|
|
if (touchCount === 0) {
|
|
safeRun(async () => {
|
|
const touch = event.changedTouches.item(0)!;
|
|
if (!event.altKey && event.target instanceof Element) {
|
|
// prevent the browser from opening the link twice
|
|
const parentA = event.target.closest("a");
|
|
if (parentA) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
const pos = view.posAtCoords({
|
|
x: touch.clientX,
|
|
y: touch.clientY,
|
|
})!;
|
|
|
|
const potentialClickEvent: ClickEvent = {
|
|
page: pageName,
|
|
ctrlKey: event.ctrlKey,
|
|
metaKey: event.metaKey,
|
|
altKey: event.altKey,
|
|
pos: pos,
|
|
};
|
|
|
|
const distanceX = touch.clientX - view.coordsAtPos(pos)!.left;
|
|
// What we're trying to determine here is if the tap occured anywhere near the looked up position
|
|
// this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks
|
|
// Fixes #585
|
|
//
|
|
if (distanceX <= view.defaultCharacterWidth) {
|
|
await client.dispatchAppEvent(
|
|
"page:click",
|
|
potentialClickEvent,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
touchCount = 0;
|
|
},
|
|
|
|
click: (event: MouseEvent, view: EditorView) => {
|
|
const pos = view.posAtCoords(event);
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
if (!pos) {
|
|
return;
|
|
}
|
|
safeRun(async () => {
|
|
const potentialClickEvent: ClickEvent = {
|
|
page: pageName,
|
|
ctrlKey: event.ctrlKey,
|
|
metaKey: event.metaKey,
|
|
altKey: event.altKey,
|
|
pos: view.posAtCoords({
|
|
x: event.x,
|
|
y: event.y,
|
|
})!,
|
|
};
|
|
// Make sure <a> tags are clicked without moving the cursor there
|
|
if (!event.altKey && event.target instanceof Element) {
|
|
const parentA = event.target.closest("a");
|
|
if (parentA) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
await client.dispatchAppEvent(
|
|
"page:click",
|
|
potentialClickEvent,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const distanceX = event.x - view.coordsAtPos(pos)!.left;
|
|
// What we're trying to determine here is if the click occured anywhere near the looked up position
|
|
// this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks
|
|
// Fixes #357
|
|
if (distanceX <= view.defaultCharacterWidth) {
|
|
await client.dispatchClickEvent(potentialClickEvent);
|
|
}
|
|
});
|
|
},
|
|
}),
|
|
ViewPlugin.fromClass(
|
|
class {
|
|
// Track file changed during an IME composition session
|
|
private composingDirty = false;
|
|
|
|
update(update: ViewUpdate): void {
|
|
if (update.transactions.length > 0) {
|
|
for (const tr of update.transactions) {
|
|
for (const e of tr.effects) {
|
|
if (e.is(foldEffect)) {
|
|
void client.dispatchAppEvent("editor:fold", e.value);
|
|
}
|
|
if (e.is(unfoldEffect)) {
|
|
void client.dispatchAppEvent("editor:unfold", e.value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (update.docChanged) {
|
|
// Find if there's a history isolate in the transaction, if so it came from a local reload and we don't do anything
|
|
if (
|
|
update.transactions.some((t) => t.annotation(isolateHistory))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Defer save and event dispatch during IME composition
|
|
if (update.view.composing) {
|
|
// Mark dirty so we flush when composition ends
|
|
this.composingDirty = true;
|
|
client.ui.viewDispatch({ type: "page-changed" });
|
|
return;
|
|
}
|
|
|
|
const changes: TextChange[] = [];
|
|
update.changes.iterChanges((fromA, toA, fromB, toB, inserted) =>
|
|
changes.push({
|
|
inserted: inserted.toString(),
|
|
oldRange: { from: fromA, to: toA },
|
|
newRange: { from: fromB, to: toB },
|
|
}),
|
|
);
|
|
void client.dispatchAppEvent("editor:pageModified", { changes });
|
|
client.ui.viewDispatch({ type: "page-changed" });
|
|
client.contentManager.debouncedUpdateEvent();
|
|
client.save().catch((e) => console.error("Error saving", e));
|
|
this.composingDirty = false;
|
|
} else if (this.composingDirty && !update.view.composing) {
|
|
// Flush now because composition ended without file changes
|
|
this.composingDirty = false;
|
|
client.contentManager.debouncedUpdateEvent();
|
|
client.save().catch((e) => console.error("Error saving", e));
|
|
}
|
|
}
|
|
},
|
|
),
|
|
pasteLinkExtension,
|
|
documentExtension(client),
|
|
closeBrackets(),
|
|
],
|
|
});
|
|
}
|
|
|
|
// TODO: Move this elsewhere
|
|
export function isValidEditor(
|
|
currentEditor: string | undefined,
|
|
requiredEditor: string | undefined,
|
|
): boolean {
|
|
return (
|
|
requiredEditor === undefined ||
|
|
(currentEditor === undefined && requiredEditor === "page") ||
|
|
requiredEditor === "any" ||
|
|
currentEditor === requiredEditor ||
|
|
(currentEditor !== undefined && requiredEditor === "notpage")
|
|
);
|
|
}
|
|
|
|
export function createCommandKeyBindings(
|
|
client: Client,
|
|
options: { miniEditor?: boolean } = {},
|
|
): Extension {
|
|
const commandKeyBindings: KeyBinding[] = [];
|
|
const vimMode = client.ui.viewState.uiOptions.vimMode;
|
|
|
|
// Then add bindings for plug commands
|
|
for (const def of client.clientSystem.commandHook
|
|
.buildAllCommands()
|
|
.values()) {
|
|
const currentEditor = client.contentManager.documentEditor?.name;
|
|
const requiredEditor = def.requireEditor;
|
|
|
|
if (def.disableInVim && vimMode) {
|
|
continue;
|
|
}
|
|
|
|
// Mini editors (filter box / page picker) are not page editors, so
|
|
// commands that target the page document should not capture keys there.
|
|
if (options.miniEditor && requiredEditor === "page") {
|
|
continue;
|
|
}
|
|
|
|
if ((def.key || def.mac) && isValidEditor(currentEditor, requiredEditor)) {
|
|
const run = (): boolean => {
|
|
if (def.contexts) {
|
|
const context = client.getContext();
|
|
if (!context || !def.contexts.includes(context)) {
|
|
return false;
|
|
}
|
|
}
|
|
void Promise.resolve([])
|
|
.then(def.run)
|
|
.catch((e: any) => {
|
|
client.reportError(e, "key");
|
|
})
|
|
.then((returnValue: any) => {
|
|
// Always be focusing the editor after running a command UNLESS it returns false
|
|
if (returnValue !== false) {
|
|
client.focus();
|
|
}
|
|
});
|
|
|
|
return true;
|
|
};
|
|
// Only create a generic key handler (non-mac specific) when
|
|
// EITHER we're not on a mac, or we're on a mac AND not specific mac key binding is set
|
|
if (def.key && (!isMacLike || (isMacLike && !def.mac))) {
|
|
if (Array.isArray(def.key)) {
|
|
for (const key of def.key) {
|
|
commandKeyBindings.push({ key, run });
|
|
}
|
|
} else {
|
|
commandKeyBindings.push({ key: def.key, run });
|
|
}
|
|
}
|
|
// Only set mac key handlers if we're on a mac, because... you know, logic
|
|
if (def.mac && isMacLike) {
|
|
if (Array.isArray(def.mac)) {
|
|
for (const key of def.mac) {
|
|
commandKeyBindings.push({ mac: key, run });
|
|
}
|
|
} else {
|
|
commandKeyBindings.push({ mac: def.mac, run });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return keymap.of([...commandKeyBindings]);
|
|
}
|
|
|
|
export function createRegularKeyBindings(client: Client): Extension {
|
|
if (client.contentManager.isDocumentEditor()) {
|
|
return keymap.of([]);
|
|
} else {
|
|
return keymap.of([
|
|
...createSmartQuoteKeyBindings(client),
|
|
...closeBracketsKeymap,
|
|
...(client.ui.viewState.uiOptions.vimMode
|
|
? [
|
|
// Workaround for https://github.com/replit/codemirror-vim/issues/182;
|
|
// without this, Enter does nothing for ordinary paragraphs in insert
|
|
// mode.
|
|
{
|
|
key: "Enter",
|
|
run: insertNewlineAndIndent,
|
|
shift: insertNewlineAndIndent,
|
|
},
|
|
]
|
|
: []),
|
|
]);
|
|
}
|
|
}
|
|
|
|
async function enableVimMode(client: Client) {
|
|
const { vim } = await loadVim();
|
|
if (client.editorView && client.vimCompartment) {
|
|
client.editorView.dispatch({
|
|
effects: client.vimCompartment.reconfigure([
|
|
vim({ status: true }),
|
|
EditorState.allowMultipleSelections.of(true),
|
|
]),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the current platform is Mac-like (Mac, iPhone, iPod, iPad).
|
|
* @returns A boolean indicating if the platform is Mac-like.
|
|
*/
|
|
export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
|
|
|
export function buildMarkdownLanguageExtension(client: Client): Extension[] {
|
|
const syntaxExtensions = client.config.get("syntaxExtensions", {});
|
|
const markdownLanguage = buildExtendedMarkdownLanguage(syntaxExtensions);
|
|
return [
|
|
markdown({
|
|
base: markdownLanguage,
|
|
codeLanguages: (info) => {
|
|
const lang = languageFor(info);
|
|
if (lang) {
|
|
return LanguageDescription.of({
|
|
name: info,
|
|
support: new LanguageSupport(lang),
|
|
});
|
|
}
|
|
if (info in lazyLanguages) {
|
|
return LanguageDescription.of({
|
|
name: info,
|
|
load: async () =>
|
|
new LanguageSupport((await loadLanguageFor(info))!),
|
|
});
|
|
}
|
|
return null;
|
|
},
|
|
addKeymap: false,
|
|
}),
|
|
Prec.high(
|
|
keymap.of([
|
|
{
|
|
key: "Enter",
|
|
run: customEnterCommand,
|
|
},
|
|
{ key: "Backspace", run: deleteMarkupBackward },
|
|
]),
|
|
),
|
|
markdownLanguage.data.of({
|
|
closeBrackets: {
|
|
brackets: client.config.get("autoCloseBrackets", "([{").split(""),
|
|
},
|
|
}),
|
|
];
|
|
}
|