diff --git a/packages/editor-codemirror/src/index.ts b/packages/editor-codemirror/src/index.ts index 5348ef9e..3d6ecabb 100644 --- a/packages/editor-codemirror/src/index.ts +++ b/packages/editor-codemirror/src/index.ts @@ -42,6 +42,7 @@ export function codeMirrorExtension( // return plugin return { + focus: view => codeMirrorNodeViews.focus(view), plugins: () => [ new Plugin({ key: codeMirrorPluginKey, diff --git a/packages/editor-codemirror/src/nodeview.ts b/packages/editor-codemirror/src/nodeview.ts index 7e803f40..a8c5a752 100644 --- a/packages/editor-codemirror/src/nodeview.ts +++ b/packages/editor-codemirror/src/nodeview.ts @@ -131,6 +131,10 @@ export const codeMirrorNodeView: ( // detection between views for gap cursor handling) const cmNodeView : CodeEditorNodeView = { isFocused: () => codeMirrorView.hasFocus, + focus: (anchor, head) => withState(State.Updating, () => { + codeMirrorView.dispatch({ selection: { anchor, head } }); + codeMirrorView.focus(); + }), getPos: typeof(getPos) === "function" ? getPos : (() => 0), dom, setGapCursorPending diff --git a/packages/editor-codemirror/test/focus.html b/packages/editor-codemirror/test/focus.html new file mode 100644 index 00000000..302999a2 --- /dev/null +++ b/packages/editor-codemirror/test/focus.html @@ -0,0 +1,9 @@ + + + +CodeMirror focus regression tests + +
Running…
+ + + diff --git a/packages/editor-codemirror/test/focus.ts b/packages/editor-codemirror/test/focus.ts new file mode 100644 index 00000000..d7280ea4 --- /dev/null +++ b/packages/editor-codemirror/test/focus.ts @@ -0,0 +1,131 @@ +/// + +/* + * Browser regression tests: run `node node_modules/vite/bin/vite.js + * packages/editor-codemirror/test` from the repository root, then open /focus.html. + * Real DOM selection and focus behavior are required (not jsdom). + */ +import { Schema } from "prosemirror-model"; +import { AllSelection, EditorState, NodeSelection, TextSelection } from "prosemirror-state"; +import { DecorationSet, EditorView } from "prosemirror-view"; +import { EditorView as CodeMirrorView } from "@codemirror/view"; +import { DispatchEvent, ExtensionContext } from "editor"; +import { DOMEditorEvents } from "editor/src/api/events"; +import { Editor } from "editor/src/editor/editor"; +import { ExtensionManager } from "editor/src/editor/editor-extensions"; +import { codeMirrorExtension } from "../src/index"; + +const schema = new Schema({ nodes: { + doc: { content: "block+" }, + text: { group: "inline" }, + paragraph: { group: "block", content: "text*", toDOM: () => ["p", 0] }, + code_block: { group: "block", content: "text*", code: true, toDOM: () => ["pre", 0] }, + blockquote: { group: "block", content: "block+", toDOM: () => ["blockquote", 0] }, +} }); + +function fixture(nested = false, empty = false) { + const host = document.body.appendChild(document.createElement("div")); + const outside = host.appendChild(document.createElement("button")); + outside.textContent = "Outside editor"; + const events = new DOMEditorEvents(host); + // Only services used by a plain code block are needed; no server is involved. + const context = { + events, + ui: { + context: { getDocumentPath: () => null, translateText: (text: string) => text }, + prefs: new Proxy({}, { get: (_, key) => () => key === "tabWidth" ? 2 : false }), + }, + theme: () => ({ darkMode: false, fixedWidthFontSizePt: 12 }), + find: { decorations: () => DecorationSet.empty }, + options: {}, format: {}, + } as unknown as ExtensionContext; + const extensions = new ExtensionManager(context); + extensions.register([codeMirrorExtension({ code_block: { lang: () => null } })]); + const chunk = schema.node("code_block", null, empty ? [] : schema.text("alpha bravo\ncharlie delta")); + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, schema.text("prose before")), + nested ? schema.node("blockquote", null, chunk) : chunk, + schema.node("paragraph", null, schema.text("prose after")), + ]); + const view = new EditorView(host.appendChild(document.createElement("div")), { + state: EditorState.create({ schema, doc, plugins: extensions.plugins(schema) }), + dispatchTransaction(tr) { + view.updateState(view.state.apply(tr)); + events.emit(DispatchEvent, tr); + }, + }); + const cm = CodeMirrorView.findFromDOM(host.querySelector(".cm-content") as HTMLElement)!; + let outerFocus = 0; + view.dom.addEventListener("focus", () => outerFocus++); + // Exercise the actual public focus implementation without initializing Pandoc. + const focus = () => Editor.prototype.focus.call({ view, extensions } as unknown as Editor); + return { view, cm, outside, focus, extensions, start: nested ? 16 : 15, + outerFocus: () => outerFocus, + destroy: () => { view.destroy(); host.remove(); }, + }; +} + +function assert(value: boolean, message: string) { + if (!value) throw new Error(message); +} +const settle = () => new Promise(resolve => setTimeout(resolve, 100)); +const results: string[] = []; + +async function run() { + for (const [name, anchor, head, nested, empty] of [ + ["middle", 7, 7, false, false], + ["start", 0, 0, false, false], + ["end", 24, 24, false, false], + ["forward selection", 3, 10, false, false], + ["backward selection", 10, 3, false, false], + ["nested chunk", 7, 7, true, false], + ["empty chunk", 0, 0, false, true], + ] as const) { + const f = fixture(nested, empty); + try { + f.outside.focus(); + f.view.dispatch(f.view.state.tr.setSelection(TextSelection.create( + f.view.state.doc, f.start + anchor, f.start + head))); + f.focus(); + await settle(); + f.outside.focus(); + f.focus(); + await settle(); + assert(f.cm.hasFocus, `${name}: code editor did not regain focus`); + assert(f.cm.state.selection.main.anchor === anchor && f.cm.state.selection.main.head === head, + `${name}: selection changed`); + assert(f.outerFocus() === 0, `${name}: outer editor received focus during restoration`); + results.push(`PASS ${name}`); + } finally { f.destroy(); } + } + for (const name of ["prose", "cross-block selection", "node selection", "whole document"]) { + const f = fixture(); + try { + const doc = f.view.state.doc; + const selection = name === "prose" ? TextSelection.create(doc, 3) : + name === "cross-block selection" ? TextSelection.create(doc, 3, f.start + 7) : + name === "node selection" ? NodeSelection.create(doc, f.start - 1) : new AllSelection(doc); + if (name === "prose") { + f.view.dispatch(f.view.state.tr.setSelection(selection)); + f.focus(); + assert(f.view.hasFocus(), "prose: editor did not regain focus"); + } else { + // Verify routing without invoking unrelated cross-block/node selection behavior. + let fallback = false; + const view = { + state: EditorState.create({ schema, doc, selection }), + focus: () => { fallback = true; }, + } as unknown as EditorView; + Editor.prototype.focus.call({ view, extensions: f.extensions } as unknown as Editor); + assert(fallback, `${name}: existing focus path was bypassed`); + } + results.push(`PASS ${name} fallback`); + } finally { f.destroy(); } + } + document.querySelector("#results")!.textContent = results.join("\n"); + document.documentElement.dataset.result = "passed"; +} +run().catch(error => { + document.querySelector("#results")!.textContent = [...results, `FAIL ${error.stack}`].join("\n"); + document.documentElement.dataset.result = "failed"; +}); diff --git a/packages/editor/src/api/codeview.ts b/packages/editor/src/api/codeview.ts index c6efbdf0..99164d3c 100644 --- a/packages/editor/src/api/codeview.ts +++ b/packages/editor/src/api/codeview.ts @@ -8,7 +8,7 @@ import { Node as ProsemirrorNode, NodeType } from 'prosemirror-model'; import { GapCursor } from 'prosemirror-gapcursor'; import { EditorView } from 'prosemirror-view'; -import { EditorState } from 'prosemirror-state'; +import { EditorState, TextSelection } from 'prosemirror-state'; import { Position } from "vscode-languageserver-types"; @@ -48,6 +48,7 @@ export interface CodeViewOptions { export interface CodeEditorNodeView { isFocused(): boolean; + focus?(anchor: number, head: number): void; getPos(): number; dom: HTMLElement; setGapCursorPending(pending: boolean): void; @@ -74,6 +75,21 @@ export class CodeEditorNodeViews { return this.nodeViews.find(view => view.isFocused()); } + public focus(view: EditorView): boolean { + const selection = view.state.selection; + if (!(selection instanceof TextSelection) || selection.$from.depth === 0 || + !selection.$from.sameParent(selection.$to)) { + return false; + } + const pos = selection.$from.before(); + const nodeView = this.nodeViews.find(nodeView => nodeView.getPos() === pos); + if (!nodeView?.focus) { + return false; + } + nodeView.focus(selection.anchor - pos - 1, selection.head - pos - 1); + return true; + } + public handleClick(view: EditorView, event: Event): boolean { // alias to mouseEvent const mouseEvent = event as MouseEvent; diff --git a/packages/editor/src/api/extension-types.ts b/packages/editor/src/api/extension-types.ts index ac6dfbb0..b62c23f3 100644 --- a/packages/editor/src/api/extension-types.ts +++ b/packages/editor/src/api/extension-types.ts @@ -32,6 +32,8 @@ import { EditorMarkdown } from './markdown-types'; export interface Extension { view?: (view: EditorView) => void; + // Return true when an embedded editor has handled focus. + focus?: (view: EditorView) => boolean; marks?: PandocMark[]; nodes?: PandocNode[]; baseKeys?: (schema: Schema) => readonly BaseKeyBinding[]; diff --git a/packages/editor/src/editor/editor-extensions.ts b/packages/editor/src/editor/editor-extensions.ts index 1cfc7974..50370b0a 100644 --- a/packages/editor/src/editor/editor-extensions.ts +++ b/packages/editor/src/editor/editor-extensions.ts @@ -268,6 +268,10 @@ export class ExtensionManager { }); } + public focus(view: EditorView): boolean { + return this.extensions.some(extension => extension.focus?.(view) === true); + } + public pandocMarks(): readonly PandocMark[] { return this.collect(extension => extension.marks); } diff --git a/packages/editor/src/editor/editor.ts b/packages/editor/src/editor/editor.ts index aae80244..3cd01fc3 100644 --- a/packages/editor/src/editor/editor.ts +++ b/packages/editor/src/editor/editor.ts @@ -830,7 +830,11 @@ export class Editor { } public focus() { - this.view.focus(); + // Focusing the outer contenteditable first can reset an embedded editor's + // DOM selection before it regains focus. + if (!this.extensions.focus(this.view)) { + this.view.focus(); + } } public hasFocus() {