Skip to content

Commit cc23c91

Browse files
committed
fix(editor): stabilize link and image input boundaries
1 parent 7ea3dc8 commit cc23c91

7 files changed

Lines changed: 276 additions & 24 deletions

File tree

packages/editor/src/codemirror/image-preview.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,39 @@ afterEach(() => {
4141
});
4242

4343
describe("imagePreviewPlugin", () => {
44+
it("waits for Enter before previewing a newly typed image", () => {
45+
const view = createView("");
46+
const markdown = "![a](https://example.test/image.png)";
47+
48+
view.dispatch({
49+
changes: { from: 0, insert: markdown },
50+
selection: { anchor: markdown.length },
51+
userEvent: "input",
52+
});
53+
54+
expect(
55+
view.dom.querySelector<HTMLInputElement>(".markra-image-node-source")
56+
?.value,
57+
).toBe(markdown);
58+
59+
view.dispatch({
60+
changes: { from: markdown.length, insert: "\n" },
61+
selection: { anchor: markdown.length + 1 },
62+
userEvent: "input",
63+
});
64+
65+
expect(view.dom.querySelector(".markra-image-node-source")).toBeNull();
66+
expect(view.dom.querySelector(".cm-markra-image")).not.toBeNull();
67+
});
68+
69+
it("renders an existing image when the initial caret is at its end", () => {
70+
const doc = "![Synthetic alt](https://example.test/image.png)";
71+
const view = createView(doc);
72+
73+
expect(view.dom.querySelector(".markra-image-node-source")).toBeNull();
74+
expect(view.dom.querySelector(".cm-markra-image")).not.toBeNull();
75+
});
76+
4477
it("renders a safe Markdown image without changing its source", () => {
4578
const doc =
4679
'Before ![Synthetic alt](https://example.test/image.png "Preview") after\n\nEdit';
@@ -103,6 +136,7 @@ describe("imagePreviewPlugin", () => {
103136
expect(source).not.toBeNull();
104137
if (!source) return;
105138

139+
source.focus();
106140
source.value = "![Changed](https://example.test/changed.png)";
107141
source.dispatchEvent(new Event("input", { bubbles: true }));
108142
expect(view.state.doc.toString()).toBe(
@@ -145,6 +179,38 @@ describe("imagePreviewPlugin", () => {
145179
expect(view.dom.querySelector(".markra-image-node-source")).toBeNull();
146180
});
147181

182+
it("moves below a selected image when Enter comes from the editor", () => {
183+
const imageMarkdown = "![Synthetic alt](./assets/mock.png)";
184+
const doc = `${imageMarkdown}\nFollowing`;
185+
const view = createView(doc);
186+
view.dom.querySelector<HTMLImageElement>(".cm-markra-image")?.dispatchEvent(
187+
new MouseEvent("click", { bubbles: true, cancelable: true }),
188+
);
189+
const event = new KeyboardEvent("keydown", {
190+
bubbles: true,
191+
cancelable: true,
192+
key: "Enter",
193+
});
194+
195+
expect(view.state.selection.main.head).toBe(1);
196+
view.contentDOM.dispatchEvent(event);
197+
198+
expect(event.defaultPrevented).toBe(true);
199+
expect(view.state.doc.toString()).toBe(doc);
200+
expect(view.state.selection.main.head).toBe(imageMarkdown.length + 1);
201+
expect(view.dom.querySelector(".markra-image-node-source")).toBeNull();
202+
203+
view.dispatch({
204+
changes: { from: imageMarkdown.length + 1, insert: "Plain " },
205+
selection: { anchor: imageMarkdown.length + 7 },
206+
userEvent: "input",
207+
});
208+
expect(view.state.doc.toString()).toBe(
209+
`${imageMarkdown}\nPlain Following`,
210+
);
211+
expect(view.dom.querySelector(".cm-markra-link")).toBeNull();
212+
});
213+
148214
it("rejects executable and local protocols by default", () => {
149215
const executable = createView(
150216
"![Unsafe](javascript:alert%281%29)\n\nEdit",

packages/editor/src/codemirror/image.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,14 +292,32 @@ class ImageWidget extends WidgetType {
292292
if (
293293
!current?.selected ||
294294
(event.target instanceof Node && current.sourceRow.contains(event.target)) ||
295-
(event.key !== "Backspace" && event.key !== "Delete") ||
296295
event.altKey ||
297296
event.ctrlKey ||
298297
event.metaKey ||
299298
event.shiftKey
300299
) {
301300
return;
302301
}
302+
303+
if (event.key === "Enter") {
304+
event.preventDefault();
305+
const imageEnd = Math.min(current.widget.to, view.state.doc.length);
306+
const hasFollowingLineBreak =
307+
view.state.sliceDoc(imageEnd, imageEnd + 1) === "\n";
308+
hideImageSource(root, current);
309+
view.dispatch({
310+
changes: hasFollowingLineBreak
311+
? undefined
312+
: { from: imageEnd, insert: "\n" },
313+
selection: EditorSelection.cursor(imageEnd + 1),
314+
userEvent: "input",
315+
});
316+
view.focus();
317+
return;
318+
}
319+
320+
if (event.key !== "Backspace" && event.key !== "Delete") return;
303321
event.preventDefault();
304322
const from = Math.min(current.widget.from, view.state.doc.length);
305323
const to = Math.min(current.widget.to, view.state.doc.length);
@@ -381,7 +399,9 @@ class ImageWidget extends WidgetType {
381399
state.widget = this;
382400
updateImageElement(state.image, this);
383401
const preserveInput = dom.ownerDocument.activeElement === state.sourceInput;
384-
if (this.selected || state.selected) {
402+
// Cursor-driven source mode is temporary: once Enter moves the caret
403+
// beyond the image, only an actively focused source input may keep it open.
404+
if (this.selected || preserveInput) {
385405
showImageSource(dom, state, preserveInput);
386406
} else {
387407
hideImageSource(dom, state);
@@ -466,7 +486,7 @@ export function imagePreviewPlugin(options: ImagePreviewPluginOptions = {}) {
466486
context.node.from,
467487
context.state.facet(EditorState.readOnly),
468488
options.resolveSource,
469-
context.revealed("node"),
489+
context.revealed("node-boundary"),
470490
source,
471491
context.node.to,
472492
context.view,

packages/editor/src/codemirror/live-markdown.test.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,55 @@ describe("liveMarkdown", () => {
178178
expect(view.dom.querySelector(".cm-markra-link-icon")).toBeNull();
179179
});
180180

181-
it("keeps a link rendered when the caret is immediately beside it", () => {
182-
const doc = "[label](https://example.test) after";
183-
const linkEnd = doc.indexOf(" after");
184-
const view = createView({ doc, anchor: linkEnd });
181+
it("keeps a completed link in source mode until Enter moves the caret away", () => {
182+
const markdown = "[label](https://example.test)";
183+
const view = createView({ doc: "", anchor: 0 });
184+
185+
view.dispatch({
186+
changes: { from: 0, insert: markdown },
187+
selection: { anchor: markdown.length },
188+
userEvent: "input",
189+
});
190+
191+
expect(renderedLines(view)[0]).toBe(markdown);
192+
193+
view.dispatch({
194+
changes: { from: markdown.length, insert: "\n" },
195+
selection: { anchor: markdown.length + 1 },
196+
userEvent: "input",
197+
});
198+
199+
expect(renderedLines(view)[0]).toBe("label");
200+
expect(view.dom.querySelector(".cm-markra-link-icon")).not.toBeNull();
201+
});
185202

186-
expect(renderedLines(view)[0]).toBe("label after");
203+
it("renders an existing link when the initial caret is at its end", () => {
204+
const doc = "[label](https://example.test)";
205+
const view = createView({ doc, anchor: doc.length });
206+
207+
expect(renderedLines(view)[0]).toBe("label");
208+
expect(view.dom.querySelector(".cm-markra-link")).not.toBeNull();
209+
});
210+
211+
it("does not preview an incomplete image label as a shortcut link", () => {
212+
const view = createView({ doc: "", anchor: 0 });
213+
214+
view.dispatch({
215+
changes: { from: 0, insert: "![a]" },
216+
selection: { anchor: 4 },
217+
});
218+
219+
expect(renderedLines(view)).toEqual(["![a]"]);
220+
expect(view.dom.querySelector(".cm-markra-link")).toBeNull();
221+
expect(view.dom.querySelector(".cm-markra-link-icon")).toBeNull();
222+
});
223+
224+
it("keeps an unfinished image destination fully visible while typing", () => {
225+
const doc = "![a](https://images.example.test/mock.jpg?w=1280&h=960";
226+
const view = createView({ doc, anchor: doc.length });
227+
228+
expect(renderedLines(view)).toEqual([doc]);
229+
expect(view.dom.querySelector(".cm-markra-link")).toBeNull();
187230
});
188231

189232
it("renders reference-style links and reveals their complete source when edited", () => {

packages/editor/src/codemirror/markdown-editing.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { defaultKeymap } from "@codemirror/commands";
12
import { EditorSelection, EditorState } from "@codemirror/state";
2-
import { EditorView, runScopeHandlers } from "@codemirror/view";
3+
import { EditorView, keymap, runScopeHandlers } from "@codemirror/view";
34
import { afterEach, describe, expect, it } from "vitest";
45
import { liveMarkdown } from "./index.ts";
56
import { markdownEditingPlugin } from "./markdown-editing.ts";
@@ -14,7 +15,10 @@ function createView(doc: string, position: number) {
1415
parent,
1516
state: EditorState.create({
1617
doc,
17-
extensions: [liveMarkdown({ plugins: [markdownEditingPlugin()] })],
18+
extensions: [
19+
keymap.of(defaultKeymap),
20+
liveMarkdown({ plugins: [markdownEditingPlugin()] }),
21+
],
1822
selection: EditorSelection.cursor(position),
1923
}),
2024
});
@@ -40,6 +44,15 @@ afterEach(() => {
4044
});
4145

4246
describe("markdownEditingPlugin", () => {
47+
it("completes a typed image destination when Enter confirms it", () => {
48+
const doc = "![a](https://images.example.test/mock.jpg?w=1280&h=960";
49+
const view = createView(doc, doc.length);
50+
51+
expect(press(view, "Enter")).toBe(true);
52+
expect(view.state.doc.toString()).toBe(`${doc})\n`);
53+
expect(view.state.selection.main.head).toBe(doc.length + 2);
54+
});
55+
4356
it("keeps CodeMirror's native Markdown list continuation", () => {
4457
const view = createView("- First", "- First".length);
4558

packages/editor/src/codemirror/markdown-editing.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { syntaxTree } from "@codemirror/language";
22
import {
33
EditorSelection,
44
EditorState,
5+
Prec,
56
type ChangeSpec,
67
type SelectionRange,
78
} from "@codemirror/state";
@@ -11,6 +12,8 @@ import { defineMarkraPlugin } from "./plugin.ts";
1112
const indentation = " ";
1213
const listMarkerPattern = /^((?:[\t ]*>[\t ]*)*)([\t ]*)(?:[-+*]|\d+[.)])[\t ]+/u;
1314
const quotePrefixPattern = /^([\t ]*(?:>[\t ]*)+)/u;
15+
const incompleteInlineDestinationPattern =
16+
/(?:^|[^\\])!?\[(?:\\.|[^\]\\])*\]\((?:\\.|[^)\n])*$/u;
1417

1518
function isEditable(view: EditorView) {
1619
return !view.state.facet(EditorState.readOnly);
@@ -89,6 +92,29 @@ function handleShiftTab(view: EditorView) {
8992
return indentList(view, true);
9093
}
9194

95+
function confirmIncompleteInlineDestination(view: EditorView) {
96+
if (!isEditable(view)) return false;
97+
const { ranges } = view.state.selection;
98+
if (ranges.length !== 1 || !ranges[0]?.empty) return false;
99+
100+
const position = ranges[0].head;
101+
const line = view.state.doc.lineAt(position);
102+
if (position !== line.to) return false;
103+
const sourceBeforeCursor = view.state.sliceDoc(line.from, position);
104+
if (!incompleteInlineDestinationPattern.test(sourceBeforeCursor)) {
105+
return false;
106+
}
107+
108+
// Enter confirms the destination instead of placing a line break inside
109+
// Markdown link syntax, which would leave both links and images unparseable.
110+
view.dispatch({
111+
changes: { from: position, insert: ")\n" },
112+
selection: EditorSelection.cursor(position + 2),
113+
userEvent: "input",
114+
});
115+
return true;
116+
}
117+
92118
function insideTableCell(view: EditorView, position: number) {
93119
let node: ReturnType<typeof syntaxTree>["topNode"] | null =
94120
syntaxTree(view.state).resolveInner(position, -1);
@@ -128,9 +154,10 @@ function insertContextualHardBreak(view: EditorView) {
128154
export function markdownEditingPlugin() {
129155
return defineMarkraPlugin({
130156
id: "markra.markdown-editing",
131-
extension: keymap.of([
157+
extension: Prec.high(keymap.of([
158+
{ key: "Enter", run: confirmIncompleteInlineDestination },
132159
{ key: "Tab", run: handleTab, shift: handleShiftTab },
133160
{ key: "Shift-Enter", run: insertContextualHardBreak },
134-
]),
161+
])),
135162
});
136163
}

packages/editor/src/codemirror/policy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { EditorState } from "@codemirror/state";
22
import type { EditorView, ViewUpdate } from "@codemirror/view";
33

4-
export type RevealScope = "line" | "node" | "heading";
4+
export type RevealScope = "line" | "node" | "node-boundary" | "heading";
55

66
export interface RevealContext {
77
view: EditorView;
@@ -64,7 +64,7 @@ export const revealActiveLine: RevealPolicy = ({
6464
);
6565
}
6666

67-
if (scope === "node") {
67+
if (scope === "node" || scope === "node-boundary") {
6868
return cursors.some(
6969
(selection) => selection.head > from && selection.head < to,
7070
);

0 commit comments

Comments
 (0)