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() {