import { commandLinkRegex } from "../common/markdown_parser/parser.ts"; import { readonlyMode } from "./cm_plugins/readonly.ts"; import customMarkdownStyle from "./style.ts"; import { autocompletion, closeBrackets, closeBracketsKeymap, codeFolding, completionKeymap, drawSelection, dropCursor, EditorState, EditorView, highlightSpecialChars, history, historyKeymap, indentOnInput, indentWithTab, KeyBinding, keymap, LanguageDescription, LanguageSupport, markdown, searchKeymap, standardKeymap, syntaxHighlighting, ViewPlugin, ViewUpdate, } from "../common/deps.ts"; import { Client } from "./client.ts"; import { vim } from "./deps.ts"; import { inlineImagesPlugin } from "./cm_plugins/inline_image.ts"; import { cleanModePlugins } from "./cm_plugins/clean.ts"; import { lineWrapper } from "./cm_plugins/line_wrapper.ts"; import { smartQuoteKeymap } from "./cm_plugins/smart_quotes.ts"; import { safeRun } from "../common/util.ts"; import { ClickEvent } from "$sb/app_event.ts"; import { attachmentExtension, pasteLinkExtension, } from "./cm_plugins/editor_paste.ts"; import { TextChange } from "$sb/lib/change.ts"; import { postScriptPrefacePlugin } from "./cm_plugins/top_bottom_panels.ts"; import { languageFor } from "../common/languages.ts"; import { plugLinter } from "./cm_plugins/lint.ts"; import { Compartment, Extension } from "@codemirror/state"; import { extendedMarkdownLanguage } from "../common/markdown_parser/parser.ts"; export function createEditorState( client: Client, pageName: string, text: string, readOnly: boolean, ): EditorState { let touchCount = 0; // Ugly: keep the keyhandler compartment in the client, to be replaced later once more commands are loaded client.keyHandlerCompartment = new Compartment(); const keyBindings = client.keyHandlerCompartment.of( createKeyBindings(client), ); 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, }), // Enable vim mode, or not [ ...client.ui.viewState.uiOptions.vimMode ? [vim({ status: true })] : [], ], [ ...readOnly || client.ui.viewState.uiOptions.forcedROMode ? [readonlyMode()] : [], ], // The uber markdown mode markdown({ base: extendedMarkdownLanguage, codeLanguages: (info) => { const lang = languageFor(info); if (lang) { return LanguageDescription.of({ name: info, support: new LanguageSupport(lang), }); } return null; }, addKeymap: true, }), extendedMarkdownLanguage.data.of({ closeBrackets: { brackets: ["(", "{", "[", "`"] }, }), syntaxHighlighting(customMarkdownStyle()), autocompletion({ override: [ client.editorComplete.bind(client), client.system.slashCommandHook.slashCommandCompleter.bind( client.system.slashCommandHook, ), ], }), inlineImagesPlugin(client), highlightSpecialChars(), history(), drawSelection(), dropCursor(), codeFolding({ placeholderText: "…", }), indentOnInput(), ...cleanModePlugins(client), EditorView.lineWrapping, plugLinter(client), // lintGutter(), // gutters(), 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: "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", disableSpellCheck: true, }, { 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: true, }, ]), keyBindings, 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; }, mousedown: (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 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.dispatchAppEvent("page:click", potentialClickEvent); } }); }, }), ViewPlugin.fromClass( class { update(update: ViewUpdate): void { if (update.docChanged) { 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 }, }) ); client.dispatchAppEvent("editor:pageModified", { changes }); client.ui.viewDispatch({ type: "page-changed" }); client.debouncedUpdateEvent(); client.save().catch((e) => console.error("Error saving", e)); } } }, ), pasteLinkExtension, attachmentExtension(client), closeBrackets(), ], }); } export function createCommandKeyBindings(client: Client): KeyBinding[] { const commandKeyBindings: KeyBinding[] = []; // Track which keyboard shortcuts for which commands we've overridden, so we can skip them later const overriddenCommands = new Set(); // Keyboard shortcuts from SETTINGS take precedense if (client.settings?.shortcuts) { for (const shortcut of client.settings.shortcuts) { // Figure out if we're using the command link syntax here, if so: parse it out const commandMatch = commandLinkRegex.exec(shortcut.command); let cleanCommandName = shortcut.command; let args: any[] = []; if (commandMatch) { cleanCommandName = commandMatch[1]; args = commandMatch[5] ? JSON.parse(`[${commandMatch[5]}]`) : []; } if (args.length === 0) { // If there was no "specialization" of this command (that is, we effectively created a keybinding for an existing command but with arguments), let's add it to the overridden command set: overriddenCommands.add(cleanCommandName); } commandKeyBindings.push({ key: shortcut.key, mac: shortcut.mac, run: (): boolean => { client.runCommandByName(cleanCommandName, args).catch((e: any) => { console.error(e); client.flashNotification( `Error running command: ${e.message}`, "error", ); }).then(() => { // Always be focusing the editor after running a command client.focus(); }); return true; }, }); } } // Then add bindings for plug commands for (const def of client.system.commandHook.editorCommands.values()) { if (def.command.key) { // If we've already overridden this command, skip it if (overriddenCommands.has(def.command.key)) { continue; } commandKeyBindings.push({ key: def.command.key, mac: def.command.mac, run: (): boolean => { if (def.command.contexts) { const context = client.getContext(); if (!context || !def.command.contexts.includes(context)) { return false; } } Promise.resolve([]) .then(def.run) .catch((e: any) => { console.error(e); client.flashNotification( `Error running command: ${e.message}`, "error", ); }) .then(() => { // Always be focusing the editor after running a command client.focus(); }); return true; }, }); } } return commandKeyBindings; } export function createKeyBindings(client: Client): Extension { return keymap.of([ ...createCommandKeyBindings(client), ...smartQuoteKeymap, ...closeBracketsKeymap, ...standardKeymap, ...searchKeymap, ...historyKeymap, ...completionKeymap, indentWithTab, ]); }