|
@@ -0,0 +1,305 @@
|
|
|
|
|
+import type { Node } from "prosemirror-model"
|
|
|
|
|
+import { EditorState, TextSelection } from "prosemirror-state"
|
|
|
|
|
+import { EditorView } from "prosemirror-view"
|
|
|
|
|
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
|
|
|
+import {
|
|
|
|
|
+ autoClosePlugin,
|
|
|
|
|
+ createPairRule,
|
|
|
|
|
+ doubleAsteriskRule,
|
|
|
|
|
+ doubleTildeRule,
|
|
|
|
|
+ singleUnderscoreRule,
|
|
|
|
|
+ tripleBacktickRule,
|
|
|
|
|
+} from "@/lib/auto-close-plugin"
|
|
|
|
|
+import { schema } from "@/lib/schema"
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Position mapping for doc(paragraph("content")):
|
|
|
|
|
+ * doc starts at 0
|
|
|
|
|
+ * paragraph starts at 1 → text content starts at 1
|
|
|
|
|
+ * character positions: 1, 2, 3, ..., length
|
|
|
|
|
+ * end-of-text position: 1 + length
|
|
|
|
|
+ * So textStart = 1.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+let views: EditorView[] = []
|
|
|
|
|
+
|
|
|
|
|
+beforeEach(() => {
|
|
|
|
|
+ views = []
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+afterEach(() => {
|
|
|
|
|
+ for (const v of views) {
|
|
|
|
|
+ v.destroy()
|
|
|
|
|
+ }
|
|
|
|
|
+ views = []
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+function createPlugin() {
|
|
|
|
|
+ return autoClosePlugin([
|
|
|
|
|
+ tripleBacktickRule,
|
|
|
|
|
+ doubleAsteriskRule,
|
|
|
|
|
+ doubleTildeRule,
|
|
|
|
|
+ singleUnderscoreRule,
|
|
|
|
|
+ createPairRule("(", ")"),
|
|
|
|
|
+ createPairRule("[", "]"),
|
|
|
|
|
+ createPairRule("{", "}"),
|
|
|
|
|
+ createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
|
|
|
|
|
+ createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
|
|
|
|
|
+ ])
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function makeParaDoc(content: string) {
|
|
|
|
|
+ const textNode = content.length > 0 ? schema.text(content) : undefined
|
|
|
|
|
+ const para = textNode
|
|
|
|
|
+ ? schema.node("paragraph", {}, [textNode])
|
|
|
|
|
+ : schema.node("paragraph")
|
|
|
|
|
+ return schema.node("doc", {}, [para])
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getView(content: string, cursorOffset?: number) {
|
|
|
|
|
+ const doc = makeParaDoc(content)
|
|
|
|
|
+ const pos = 1 + (cursorOffset ?? content.length)
|
|
|
|
|
+ const state = EditorState.create({
|
|
|
|
|
+ doc,
|
|
|
|
|
+ schema,
|
|
|
|
|
+ selection: TextSelection.near(doc.resolve(pos)),
|
|
|
|
|
+ plugins: [createPlugin()],
|
|
|
|
|
+ })
|
|
|
|
|
+ const dom = document.createElement("div")
|
|
|
|
|
+ const view = new EditorView(dom, { state })
|
|
|
|
|
+ views.push(view)
|
|
|
|
|
+ return view
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getViewWithRange(
|
|
|
|
|
+ content: string,
|
|
|
|
|
+ fromOffset: number,
|
|
|
|
|
+ toOffset: number,
|
|
|
|
|
+) {
|
|
|
|
|
+ const doc = makeParaDoc(content)
|
|
|
|
|
+ const from = 1 + fromOffset
|
|
|
|
|
+ const to = 1 + toOffset
|
|
|
|
|
+ const state = EditorState.create({
|
|
|
|
|
+ doc,
|
|
|
|
|
+ schema,
|
|
|
|
|
+ selection: TextSelection.create(doc, from, to),
|
|
|
|
|
+ plugins: [createPlugin()],
|
|
|
|
|
+ })
|
|
|
|
|
+ const dom = document.createElement("div")
|
|
|
|
|
+ const view = new EditorView(dom, { state })
|
|
|
|
|
+ views.push(view)
|
|
|
|
|
+ return view
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function typeAtCursor(view: EditorView, text: string) {
|
|
|
|
|
+ const { head } = view.state.selection
|
|
|
|
|
+ let handled = false
|
|
|
|
|
+ view.someProp("handleTextInput", (fn) => {
|
|
|
|
|
+ handled = fn(view, head, head, text)
|
|
|
|
|
+ })
|
|
|
|
|
+ return handled
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function typeChar(
|
|
|
|
|
+ view: EditorView,
|
|
|
|
|
+ from: number,
|
|
|
|
|
+ to: number,
|
|
|
|
|
+ text: string,
|
|
|
|
|
+): boolean {
|
|
|
|
|
+ let handled = false
|
|
|
|
|
+ view.someProp("handleTextInput", (fn) => {
|
|
|
|
|
+ handled = fn(view, from, to, text)
|
|
|
|
|
+ })
|
|
|
|
|
+ return handled
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function pressBackspace(view: EditorView): boolean {
|
|
|
|
|
+ let handled = false
|
|
|
|
|
+ for (const plugin of view.state.plugins) {
|
|
|
|
|
+ const handler = plugin.props.handleDOMEvents?.keydown
|
|
|
|
|
+ if (handler) {
|
|
|
|
|
+ handled = handler(
|
|
|
|
|
+ view,
|
|
|
|
|
+ new KeyboardEvent("keydown", { key: "Backspace" }),
|
|
|
|
|
+ )
|
|
|
|
|
+ if (handled) break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return handled
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function textAt(doc: Node, startOffset: number, length: number) {
|
|
|
|
|
+ return doc.textBetween(1 + startOffset, 1 + startOffset + length)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe("createPairRule", () => {
|
|
|
|
|
+ it("auto-closes parentheses when typing ( at end of text", () => {
|
|
|
|
|
+ const view = getView("hello", 5)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "(")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 7)).toBe("hello()")
|
|
|
|
|
+ // cursor between ( and ) → pos = 1 + 5 + 1 = 7
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(7)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("wraps selection in parentheses", () => {
|
|
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
|
|
+
|
|
|
|
|
+ const { from, to } = view.state.selection
|
|
|
|
|
+ const handled = typeChar(view, from, to, "(")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 13)).toBe("hello (world)")
|
|
|
|
|
+ // cursor after closing paren: from + open + selected + close = 7 + 1 + 5 + 1 = 14
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(14)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("skips over closing paren when next character matches", () => {
|
|
|
|
|
+ const view = getView("()", 1)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, ")")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 2)).toBe("()")
|
|
|
|
|
+ // cursor moved past ) → pos = 1 + 2 = 3
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(3)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not skip over when there is a selection", () => {
|
|
|
|
|
+ const view = getViewWithRange("()", 0, 2)
|
|
|
|
|
+
|
|
|
|
|
+ const { from, to } = view.state.selection
|
|
|
|
|
+ const handled = typeChar(view, from, to, ")")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lets normal close-paren pass when next char does not match", () => {
|
|
|
|
|
+ const view = getView("hello", 5)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, ")")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("createPairRule context-aware quotes", () => {
|
|
|
|
|
+ it("auto-closes double-quote after space", () => {
|
|
|
|
|
+ const view = getView("hello ", 6)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, '"')
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 8)).toBe('hello ""')
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does NOT auto-close double-quote mid-word", () => {
|
|
|
|
|
+ const view = getView("hello", 5)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, '"')
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("createPairRule backspace delete-pair", () => {
|
|
|
|
|
+ it("deletes empty paren pair on backspace", () => {
|
|
|
|
|
+ const view = getView("()", 1)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = pressBackspace(view)
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ // Both parens removed → empty paragraph
|
|
|
|
|
+ expect(view.state.doc.textBetween(1, 2)).toBe("")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lets normal backspace pass when not in a pair", () => {
|
|
|
|
|
+ const view = getView("hello", 5)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = pressBackspace(view)
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("doubleAsteriskRule (toggle)", () => {
|
|
|
|
|
+ it("auto-closes ** when user types second asterisk", () => {
|
|
|
|
|
+ const view = getView("hello *", 7)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "*")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 10)).toBe("hello ****")
|
|
|
|
|
+ // cursor between ** pairs: 1 + 7 + 1 = 9
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(9)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("doubleTildeRule (toggle)", () => {
|
|
|
|
|
+ it("auto-closes ~~ when user types second tilde", () => {
|
|
|
|
|
+ const view = getView("~", 1)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "~")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 4)).toBe("~~~~")
|
|
|
|
|
+ // cursor between ~~ pairs: start(1) + delimiter.length(2) = 3
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(3)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("singleUnderscoreRule", () => {
|
|
|
|
|
+ it("wraps selection in underscores regardless of preceding character", () => {
|
|
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
|
|
+
|
|
|
|
|
+ const { from, to } = view.state.selection
|
|
|
|
|
+ const handled = typeChar(view, from, to, "_")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 13)).toBe("hello _world_")
|
|
|
|
|
+ // cursor after closing underscore: from(7) + 1 + 5 + 1 = 14
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(14)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("auto-closes _ at word boundary", () => {
|
|
|
|
|
+ const view = getView("hello ", 6)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "_")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ expect(textAt(view.state.doc, 0, 8)).toBe("hello __")
|
|
|
|
|
+ // cursor between underscores: 1 + 6 + 1 = 8
|
|
|
|
|
+ expect(view.state.selection.anchor).toBe(8)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does NOT auto-close mid-word", () => {
|
|
|
|
|
+ const view = getView("hello", 3)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "_")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("tripleBacktickRule", () => {
|
|
|
|
|
+ it("creates code block when ``` typed at start of paragraph", () => {
|
|
|
|
|
+ const view = getView("``", 2)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "`")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(true)
|
|
|
|
|
+ const doc = view.state.doc
|
|
|
|
|
+ expect(doc.childCount).toBe(1)
|
|
|
|
|
+ expect(doc.child(0).type.name).toBe("code_block")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does NOT create code block mid-line", () => {
|
|
|
|
|
+ const view = getView("hello ``", 8)
|
|
|
|
|
+
|
|
|
|
|
+ const handled = typeAtCursor(view, "`")
|
|
|
|
|
+
|
|
|
|
|
+ expect(handled).toBe(false)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|