From c5849f881be61b882f06badd2c817ec0e60744f5 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Fri, 14 Jul 2023 13:58:16 +0200 Subject: [PATCH] Editor refactor: extract state --- plugs/directive/command.ts | 2 +- web/editor.tsx | 502 ++----------------------------------- web/editor_state.ts | 430 +++++++++++++++++++++++++++++++ web/open_pages.ts | 53 ++++ web/syscalls/space.ts | 2 +- 5 files changed, 502 insertions(+), 487 deletions(-) create mode 100644 web/editor_state.ts create mode 100644 web/open_pages.ts diff --git a/plugs/directive/command.ts b/plugs/directive/command.ts index 5828335..47a742e 100644 --- a/plugs/directive/command.ts +++ b/plugs/directive/command.ts @@ -1,4 +1,4 @@ -import { editor, markdown, space, sync } from "$sb/silverbullet-syscall/mod.ts"; +import { editor, markdown, space } from "$sb/silverbullet-syscall/mod.ts"; import { removeParentPointers, renderToText, diff --git a/web/editor.tsx b/web/editor.tsx index 737f262..9b6e086 100644 --- a/web/editor.tsx +++ b/web/editor.tsx @@ -1,70 +1,17 @@ // Third party web dependencies import { - autocompletion, - cLanguage, - closeBrackets, - closeBracketsKeymap, CompletionContext, - completionKeymap, CompletionResult, - cppLanguage, - csharpLanguage, - dartLanguage, - drawSelection, - dropCursor, EditorSelection, - EditorState, EditorView, gitIgnoreCompiler, - highlightSpecialChars, - history, - historyKeymap, - indentOnInput, - indentWithTab, - javaLanguage, - javascriptLanguage, - jsonLanguage, - KeyBinding, - keymap, - kotlinLanguage, - LanguageDescription, - LanguageSupport, - markdown, - objectiveCLanguage, - objectiveCppLanguage, - postgresqlLanguage, - protobufLanguage, - pythonLanguage, runScopeHandlers, - rustLanguage, - scalaLanguage, - searchKeymap, - shellLanguage, - sqlLanguage, - standardKeymap, - StreamLanguage, - syntaxHighlighting, syntaxTree, - tomlLanguage, - typescriptLanguage, - ViewPlugin, - ViewUpdate, - xmlLanguage, - yamlLanguage, } from "../common/deps.ts"; -import buildMarkdown from "../common/markdown_parser/parser.ts"; import { Space } from "./space.ts"; import { FilterOption, PageMeta } from "./types.ts"; import { isMacLike, parseYamlSettings, safeRun } from "../common/util.ts"; import { EventHook } from "../plugos/hooks/event.ts"; -import { cleanModePlugins } from "./cm_plugins/clean.ts"; -import { - attachmentExtension, - pasteLinkExtension, -} from "./cm_plugins/editor_paste.ts"; -import { inlineImagesPlugin } from "./cm_plugins/inline_image.ts"; -import { lineWrapper } from "./cm_plugins/line_wrapper.ts"; -import { smartQuoteKeymap } from "./cm_plugins/smart_quotes.ts"; import { Confirm, Prompt } from "./components/basic_modals.tsx"; import { CommandPalette } from "./components/command_palette.tsx"; import { FilterList } from "./components/filter.tsx"; @@ -73,18 +20,16 @@ import { Panel } from "./components/panel.tsx"; import { TopBar } from "./components/top_bar.tsx"; import { BookIcon, - codeFolding, HomeIcon, preactRender, TerminalIcon, useEffect, useReducer, - vim, } from "./deps.ts"; import { AppCommand } from "./hooks/command.ts"; import { PathPageNavigator } from "./navigator.ts"; import reducer from "./reducer.ts"; -import customMarkdownStyle from "./style.ts"; + import { Action, AppViewState, @@ -92,13 +37,8 @@ import { initialViewState, } from "./types.ts"; -import type { - AppEvent, - ClickEvent, - CompleteEvent, -} from "../plug-api/app_event.ts"; +import type { AppEvent, CompleteEvent } from "../plug-api/app_event.ts"; import { throttle } from "../common/async_util.ts"; -import { readonlyMode } from "./cm_plugins/readonly.ts"; import { PlugSpacePrimitives } from "../common/spaces/plug_space_primitives.ts"; import { IndexedDBSpacePrimitives } from "../common/spaces/indexeddb_space_primitives.ts"; import { FileMetaSpacePrimitives } from "../common/spaces/file_meta_space_primitives.ts"; @@ -112,13 +52,8 @@ import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primiti import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts"; import { isValidPageName } from "$sb/lib/page.ts"; import { ClientSystem } from "./client_system.ts"; - -class PageState { - constructor( - readonly scrollTop: number, - readonly selection: EditorSelection, - ) {} -} +import { createEditorState } from "./editor_state.ts"; +import { OpenPages } from "./open_pages.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; const autoSaveInterval = 1000; @@ -135,7 +70,6 @@ declare global { // TODO: Oh my god, need to refactor this export class Editor { - openPages = new Map(); editorView?: EditorView; viewState: AppViewState = initialViewState; viewDispatch: (action: Action) => void = () => {}; @@ -165,6 +99,7 @@ export class Editor { // Event bus used to communicate between components eventHook: EventHook; + openPages: OpenPages; constructor( parent: Element, @@ -252,10 +187,12 @@ export class Editor { this.render(parent); this.editorView = new EditorView({ - state: this.createEditorState("", "", false), + state: createEditorState(this, "", "", false), parent: document.getElementById("sb-editor")!, }); + this.openPages = new OpenPages(this.editorView); + // Make keyboard shortcuts work even when the editor is in read only mode or not focused globalThis.addEventListener("keydown", (ev) => { if (!this.editorView?.hasFocus) { @@ -588,374 +525,6 @@ export class Editor { return this.eventHook.dispatchEvent(name, ...args); } - createEditorState( - pageName: string, - text: string, - readOnly: boolean, - ): EditorState { - const commandKeyBindings: KeyBinding[] = []; - for (const def of this.system.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; - let touchCount = 0; - - const markdownLanguage = buildMarkdown(this.system.mdExtensions); - - return EditorState.create({ - doc: 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 - [...editor.viewState.uiOptions.vimMode ? [vim({ status: true })] : []], - [ - ...readOnly || editor.viewState.uiOptions.forcedROMode - ? [readonlyMode()] - : [], - ], - // The uber markdown mode - markdown({ - base: markdownLanguage, - codeLanguages: [ - LanguageDescription.of({ - name: "yaml", - alias: ["meta", "data", "embed"], - 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), - }), - LanguageDescription.of({ - name: "sql", - alias: ["sql"], - support: new LanguageSupport(StreamLanguage.define(sqlLanguage)), - }), - LanguageDescription.of({ - name: "postgresql", - alias: ["pgsql", "postgres"], - support: new LanguageSupport( - StreamLanguage.define(postgresqlLanguage), - ), - }), - LanguageDescription.of({ - name: "rust", - alias: ["rs"], - support: new LanguageSupport(StreamLanguage.define(rustLanguage)), - }), - LanguageDescription.of({ - name: "css", - support: new LanguageSupport(StreamLanguage.define(sqlLanguage)), - }), - LanguageDescription.of({ - name: "python", - alias: ["py"], - support: new LanguageSupport( - StreamLanguage.define(pythonLanguage), - ), - }), - LanguageDescription.of({ - name: "protobuf", - alias: ["proto"], - support: new LanguageSupport( - StreamLanguage.define(protobufLanguage), - ), - }), - LanguageDescription.of({ - name: "shell", - alias: ["sh", "bash", "zsh", "fish"], - support: new LanguageSupport( - StreamLanguage.define(shellLanguage), - ), - }), - LanguageDescription.of({ - name: "swift", - support: new LanguageSupport(StreamLanguage.define(rustLanguage)), - }), - LanguageDescription.of({ - name: "toml", - support: new LanguageSupport(StreamLanguage.define(tomlLanguage)), - }), - LanguageDescription.of({ - name: "json", - support: new LanguageSupport(StreamLanguage.define(jsonLanguage)), - }), - LanguageDescription.of({ - name: "xml", - support: new LanguageSupport(StreamLanguage.define(xmlLanguage)), - }), - LanguageDescription.of({ - name: "c", - support: new LanguageSupport(StreamLanguage.define(cLanguage)), - }), - LanguageDescription.of({ - name: "cpp", - alias: ["c++", "cxx"], - support: new LanguageSupport(StreamLanguage.define(cppLanguage)), - }), - LanguageDescription.of({ - name: "java", - support: new LanguageSupport(StreamLanguage.define(javaLanguage)), - }), - LanguageDescription.of({ - name: "csharp", - alias: ["c#", "cs"], - support: new LanguageSupport( - StreamLanguage.define(csharpLanguage), - ), - }), - LanguageDescription.of({ - name: "scala", - alias: ["sc"], - support: new LanguageSupport( - StreamLanguage.define(scalaLanguage), - ), - }), - LanguageDescription.of({ - name: "kotlin", - alias: ["kt", "kts"], - support: new LanguageSupport( - StreamLanguage.define(kotlinLanguage), - ), - }), - LanguageDescription.of({ - name: "objc", - alias: ["objective-c", "objectivec"], - support: new LanguageSupport( - StreamLanguage.define(objectiveCLanguage), - ), - }), - LanguageDescription.of({ - name: "objcpp", - alias: [ - "objc++", - "objective-cpp", - "objectivecpp", - "objective-c++", - "objectivec++", - ], - support: new LanguageSupport( - StreamLanguage.define(objectiveCppLanguage), - ), - }), - LanguageDescription.of({ - name: "dart", - support: new LanguageSupport(StreamLanguage.define(dartLanguage)), - }), - ], - addKeymap: true, - }), - markdownLanguage.data.of({ - closeBrackets: { brackets: ["(", "{", "[", "`"] }, - }), - syntaxHighlighting(customMarkdownStyle(this.system.mdExtensions)), - autocompletion({ - override: [ - this.editorComplete.bind(this), - this.system.slashCommandHook.slashCommandCompleter.bind( - this.system.slashCommandHook, - ), - ], - }), - inlineImagesPlugin(this), - highlightSpecialChars(), - history(), - drawSelection(), - dropCursor(), - codeFolding({ - placeholderText: "…", - }), - indentOnInput(), - ...cleanModePlugins(this), - EditorView.lineWrapping, - 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" }, - ]), - 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-.", - mac: "Cmd-.", - run: (): boolean => { - this.viewDispatch({ - type: "show-palette", - context: this.getContext(), - }); - return true; - }, - }, - ]), - EditorView.domEventHandlers({ - // This may result in duplicated touch events on mobile devices - touchmove: (event: TouchEvent, view: EditorView) => { - 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 clickEvent: ClickEvent = { - page: pageName, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - altKey: event.altKey, - pos: view.posAtCoords({ - x: touch.clientX, - y: touch.clientY, - })!, - }; - await this.dispatchAppEvent("page:click", clickEvent); - }); - } - touchCount = 0; - }, - - mousedown: (event: MouseEvent, view: EditorView) => { - safeRun(async () => { - const pos = view.posAtCoords(event); - if (!pos) { - return; - } - 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 this.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 this.dispatchAppEvent("page:click", potentialClickEvent); - } - }); - }, - }), - 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.system.reloadPlugsFromSpace(this.space); @@ -971,10 +540,11 @@ export class Editor { if (editorView && this.currentPage) { // And update the editor if a page is loaded - this.saveState(this.currentPage); + this.openPages.saveState(this.currentPage); editorView.setState( - this.createEditorState( + createEditorState( + this, this.currentPage, editorView.state.sliceDoc(), this.viewState.currentPageMeta?.perm === "ro", @@ -986,7 +556,7 @@ export class Editor { ); } - this.restoreState(this.currentPage); + this.openPages.restoreState(this.currentPage); } } @@ -1081,7 +651,7 @@ export class Editor { // Persist current page state and nicely close page if (previousPage) { - this.saveState(previousPage); + this.openPages.saveState(previousPage); this.space.unwatchPage(previousPage); if (previousPage !== pageName) { await this.save(true); @@ -1109,7 +679,8 @@ export class Editor { }; } - const editorState = this.createEditorState( + const editorState = createEditorState( + this, pageName, doc.text, doc.meta.perm === "ro", @@ -1118,7 +689,7 @@ export class Editor { if (editorView.contentDOM) { this.tweakEditorDOM(editorView.contentDOM); } - const stateRestored = this.restoreState(pageName); + const stateRestored = this.openPages.restoreState(pageName); this.space.watchPage(pageName); this.viewDispatch({ @@ -1166,45 +737,6 @@ export class Editor { } } - private restoreState(pageName: string): boolean { - const pageState = this.openPages.get(pageName); - const editorView = this.editorView!; - if (pageState) { - // Restore state - editorView.scrollDOM.scrollTop = pageState!.scrollTop; - try { - editorView.dispatch({ - selection: pageState.selection, - scrollIntoView: true, - }); - } catch { - // This is fine, just go to the top - editorView.dispatch({ - selection: { anchor: 0 }, - 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; @@ -1448,7 +980,7 @@ export class Editor { return commands; } - private getContext(): string | undefined { + getContext(): string | undefined { const state = this.editorView!.state; const selection = state.selection.main; if (selection.empty) { diff --git a/web/editor_state.ts b/web/editor_state.ts new file mode 100644 index 0000000..b6ecfd1 --- /dev/null +++ b/web/editor_state.ts @@ -0,0 +1,430 @@ +import buildMarkdown from "../common/markdown_parser/parser.ts"; +import { readonlyMode } from "./cm_plugins/readonly.ts"; +import customMarkdownStyle from "./style.ts"; +import { + autocompletion, + cLanguage, + closeBrackets, + closeBracketsKeymap, + codeFolding, + completionKeymap, + cppLanguage, + csharpLanguage, + dartLanguage, + drawSelection, + dropCursor, + EditorState, + EditorView, + highlightSpecialChars, + history, + historyKeymap, + indentOnInput, + indentWithTab, + javaLanguage, + javascriptLanguage, + jsonLanguage, + KeyBinding, + keymap, + kotlinLanguage, + LanguageDescription, + LanguageSupport, + markdown, + objectiveCLanguage, + objectiveCppLanguage, + postgresqlLanguage, + protobufLanguage, + pythonLanguage, + rustLanguage, + scalaLanguage, + searchKeymap, + shellLanguage, + sqlLanguage, + standardKeymap, + StreamLanguage, + syntaxHighlighting, + tomlLanguage, + typescriptLanguage, + ViewPlugin, + ViewUpdate, + xmlLanguage, + yamlLanguage, +} from "../common/deps.ts"; +import { Editor } from "./editor.tsx"; +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"; + +export function createEditorState( + editor: Editor, + pageName: string, + text: string, + readOnly: boolean, +): EditorState { + const commandKeyBindings: KeyBinding[] = []; + for (const def of editor.system.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 = editor.getContext(); + if (!context || !def.command.contexts.includes(context)) { + return false; + } + } + Promise.resolve() + .then(def.run) + .catch((e: any) => { + console.error(e); + editor.flashNotification( + `Error running command: ${e.message}`, + "error", + ); + }) + .then(() => { + // Always be focusing the editor after running a command + editor.focus(); + }); + return true; + }, + }); + } + } + let touchCount = 0; + + const markdownLanguage = buildMarkdown(editor.system.mdExtensions); + + return EditorState.create({ + doc: text, + extensions: [ + // Not using CM theming right now, but some extensions depend on the "dark" thing + EditorView.theme({}, { dark: editor.viewState.uiOptions.darkMode }), + // Enable vim mode, or not + [...editor.viewState.uiOptions.vimMode ? [vim({ status: true })] : []], + [ + ...readOnly || editor.viewState.uiOptions.forcedROMode + ? [readonlyMode()] + : [], + ], + // The uber markdown mode + markdown({ + base: markdownLanguage, + codeLanguages: [ + LanguageDescription.of({ + name: "yaml", + alias: ["meta", "data", "embed"], + 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), + }), + LanguageDescription.of({ + name: "sql", + alias: ["sql"], + support: new LanguageSupport(StreamLanguage.define(sqlLanguage)), + }), + LanguageDescription.of({ + name: "postgresql", + alias: ["pgsql", "postgres"], + support: new LanguageSupport( + StreamLanguage.define(postgresqlLanguage), + ), + }), + LanguageDescription.of({ + name: "rust", + alias: ["rs"], + support: new LanguageSupport(StreamLanguage.define(rustLanguage)), + }), + LanguageDescription.of({ + name: "css", + support: new LanguageSupport(StreamLanguage.define(sqlLanguage)), + }), + LanguageDescription.of({ + name: "python", + alias: ["py"], + support: new LanguageSupport( + StreamLanguage.define(pythonLanguage), + ), + }), + LanguageDescription.of({ + name: "protobuf", + alias: ["proto"], + support: new LanguageSupport( + StreamLanguage.define(protobufLanguage), + ), + }), + LanguageDescription.of({ + name: "shell", + alias: ["sh", "bash", "zsh", "fish"], + support: new LanguageSupport( + StreamLanguage.define(shellLanguage), + ), + }), + LanguageDescription.of({ + name: "swift", + support: new LanguageSupport(StreamLanguage.define(rustLanguage)), + }), + LanguageDescription.of({ + name: "toml", + support: new LanguageSupport(StreamLanguage.define(tomlLanguage)), + }), + LanguageDescription.of({ + name: "json", + support: new LanguageSupport(StreamLanguage.define(jsonLanguage)), + }), + LanguageDescription.of({ + name: "xml", + support: new LanguageSupport(StreamLanguage.define(xmlLanguage)), + }), + LanguageDescription.of({ + name: "c", + support: new LanguageSupport(StreamLanguage.define(cLanguage)), + }), + LanguageDescription.of({ + name: "cpp", + alias: ["c++", "cxx"], + support: new LanguageSupport(StreamLanguage.define(cppLanguage)), + }), + LanguageDescription.of({ + name: "java", + support: new LanguageSupport(StreamLanguage.define(javaLanguage)), + }), + LanguageDescription.of({ + name: "csharp", + alias: ["c#", "cs"], + support: new LanguageSupport( + StreamLanguage.define(csharpLanguage), + ), + }), + LanguageDescription.of({ + name: "scala", + alias: ["sc"], + support: new LanguageSupport( + StreamLanguage.define(scalaLanguage), + ), + }), + LanguageDescription.of({ + name: "kotlin", + alias: ["kt", "kts"], + support: new LanguageSupport( + StreamLanguage.define(kotlinLanguage), + ), + }), + LanguageDescription.of({ + name: "objc", + alias: ["objective-c", "objectivec"], + support: new LanguageSupport( + StreamLanguage.define(objectiveCLanguage), + ), + }), + LanguageDescription.of({ + name: "objcpp", + alias: [ + "objc++", + "objective-cpp", + "objectivecpp", + "objective-c++", + "objectivec++", + ], + support: new LanguageSupport( + StreamLanguage.define(objectiveCppLanguage), + ), + }), + LanguageDescription.of({ + name: "dart", + support: new LanguageSupport(StreamLanguage.define(dartLanguage)), + }), + ], + addKeymap: true, + }), + markdownLanguage.data.of({ + closeBrackets: { brackets: ["(", "{", "[", "`"] }, + }), + syntaxHighlighting(customMarkdownStyle(editor.system.mdExtensions)), + autocompletion({ + override: [ + editor.editorComplete.bind(editor), + editor.system.slashCommandHook.slashCommandCompleter.bind( + editor.system.slashCommandHook, + ), + ], + }), + inlineImagesPlugin(editor), + highlightSpecialChars(), + history(), + drawSelection(), + dropCursor(), + codeFolding({ + placeholderText: "…", + }), + indentOnInput(), + ...cleanModePlugins(editor), + EditorView.lineWrapping, + 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" }, + ]), + keymap.of([ + ...smartQuoteKeymap, + ...closeBracketsKeymap, + ...standardKeymap, + ...searchKeymap, + ...historyKeymap, + ...completionKeymap, + indentWithTab, + ...commandKeyBindings, + { + key: "Ctrl-k", + mac: "Cmd-k", + run: (): boolean => { + editor.viewDispatch({ type: "start-navigate" }); + editor.space.updatePageList(); + + return true; + }, + }, + { + key: "Ctrl-/", + mac: "Cmd-/", + run: (): boolean => { + editor.viewDispatch({ + type: "show-palette", + context: editor.getContext(), + }); + return true; + }, + }, + { + key: "Ctrl-.", + mac: "Cmd-.", + run: (): boolean => { + editor.viewDispatch({ + type: "show-palette", + context: editor.getContext(), + }); + return true; + }, + }, + ]), + 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 clickEvent: ClickEvent = { + page: pageName, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + pos: view.posAtCoords({ + x: touch.clientX, + y: touch.clientY, + })!, + }; + await editor.dispatchAppEvent("page:click", clickEvent); + }); + } + touchCount = 0; + }, + + mousedown: (event: MouseEvent, view: EditorView) => { + safeRun(async () => { + const pos = view.posAtCoords(event); + if (!pos) { + return; + } + 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 editor.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 editor.dispatchAppEvent("page:click", potentialClickEvent); + } + }); + }, + }), + 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(editor), + closeBrackets(), + ], + }); +} diff --git a/web/open_pages.ts b/web/open_pages.ts new file mode 100644 index 0000000..608f121 --- /dev/null +++ b/web/open_pages.ts @@ -0,0 +1,53 @@ +import { EditorSelection, EditorView } from "./deps.ts"; + +class PageState { + constructor( + readonly scrollTop: number, + readonly selection: EditorSelection, + ) {} +} + +export class OpenPages { + openPages = new Map(); + + constructor(private editorView: EditorView) {} + + restoreState(pageName: string): boolean { + const pageState = this.openPages.get(pageName); + const editorView = this.editorView; + if (pageState) { + // Restore state + editorView.scrollDOM.scrollTop = pageState!.scrollTop; + try { + editorView.dispatch({ + selection: pageState.selection, + scrollIntoView: true, + }); + } catch { + // This is fine, just go to the top + editorView.dispatch({ + selection: { anchor: 0 }, + scrollIntoView: true, + }); + } + } else { + editorView.scrollDOM.scrollTop = 0; + editorView.dispatch({ + selection: { anchor: 0 }, + scrollIntoView: true, + }); + } + editorView.focus(); + return !!pageState; + } + + saveState(currentPage: string) { + this.openPages.set( + currentPage, + new PageState( + this.editorView!.scrollDOM.scrollTop, + this.editorView!.state.selection, + ), + ); + } +} diff --git a/web/syscalls/space.ts b/web/syscalls/space.ts index 8df95eb..6bf7eaa 100644 --- a/web/syscalls/space.ts +++ b/web/syscalls/space.ts @@ -29,7 +29,7 @@ export function spaceSyscalls(editor: Editor): SysCallMapping { await editor.navigate(""); } // Remove page from open pages in editor - editor.openPages.delete(name); + editor.openPages.openPages.delete(name); console.log("Deleting page"); await editor.space.deletePage(name); },