|
|
@@ -0,0 +1,316 @@
|
|
|
+import { EditorState, TextSelection } from "prosemirror-state"
|
|
|
+import { EditorView } from "prosemirror-view"
|
|
|
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
|
+import {
|
|
|
+ insertLink,
|
|
|
+ isFormatActive,
|
|
|
+ setHeading,
|
|
|
+ toggleBold,
|
|
|
+ toggleCode,
|
|
|
+ toggleHighlight,
|
|
|
+ toggleItalic,
|
|
|
+ toggleStrikethrough,
|
|
|
+ toggleTask,
|
|
|
+} from "@/lib/formatting"
|
|
|
+import { schema } from "@/lib/schema"
|
|
|
+
|
|
|
+let views: EditorView[] = []
|
|
|
+
|
|
|
+beforeEach(() => {
|
|
|
+ views = []
|
|
|
+})
|
|
|
+
|
|
|
+afterEach(() => {
|
|
|
+ for (const v of views) {
|
|
|
+ v.destroy()
|
|
|
+ }
|
|
|
+ views = []
|
|
|
+})
|
|
|
+
|
|
|
+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)),
|
|
|
+ })
|
|
|
+ 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),
|
|
|
+ })
|
|
|
+ const dom = document.createElement("div")
|
|
|
+ const view = new EditorView(dom, { state })
|
|
|
+ views.push(view)
|
|
|
+ return view
|
|
|
+}
|
|
|
+
|
|
|
+describe("isFormatActive", () => {
|
|
|
+ it("returns true when cursor is inside bold", () => {
|
|
|
+ const view = getView("**bold**", 4)
|
|
|
+ expect(isFormatActive(view, "bold")).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("returns false when cursor is outside bold", () => {
|
|
|
+ const view = getView("**bold**", 0)
|
|
|
+ expect(isFormatActive(view, "bold")).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("returns false for plain text", () => {
|
|
|
+ const view = getView("hello", 2)
|
|
|
+ expect(isFormatActive(view, "bold")).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("detects italic", () => {
|
|
|
+ const view = getView("*italic*", 3)
|
|
|
+ expect(isFormatActive(view, "italic")).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("detects code", () => {
|
|
|
+ const view = getView("`code`", 2)
|
|
|
+ expect(isFormatActive(view, "code")).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("detects strikethrough", () => {
|
|
|
+ const view = getView("~~strike~~", 4)
|
|
|
+ expect(isFormatActive(view, "strikethrough")).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("detects highlight", () => {
|
|
|
+ const view = getView("^^highlight^^", 5)
|
|
|
+ expect(isFormatActive(view, "highlight")).toBe(true)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("toggleBold", () => {
|
|
|
+ it("wraps selection in bold", () => {
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
+ toggleBold(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello **world**")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("unwraps bold when cursor is inside", () => {
|
|
|
+ const view = getView("**bold**", 4)
|
|
|
+ toggleBold(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("bold")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("inserts empty delimiters at cursor when no selection", () => {
|
|
|
+ const view = getView("hello ", 6)
|
|
|
+ toggleBold(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello ****")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("toggleItalic", () => {
|
|
|
+ it("wraps selection in italic", () => {
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
+ toggleItalic(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello *world*")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("unwraps italic when cursor is inside", () => {
|
|
|
+ const view = getView("*italic*", 3)
|
|
|
+ toggleItalic(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("italic")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("toggleCode", () => {
|
|
|
+ it("wraps selection in code", () => {
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
+ toggleCode(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello `world`")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("unwraps code when cursor is inside", () => {
|
|
|
+ const view = getView("`code`", 3)
|
|
|
+ toggleCode(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("code")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("toggleStrikethrough", () => {
|
|
|
+ it("wraps selection in strikethrough", () => {
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
+ toggleStrikethrough(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello ~~world~~")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("unwraps strikethrough when cursor is inside", () => {
|
|
|
+ const view = getView("~~strike~~", 4)
|
|
|
+ toggleStrikethrough(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("strike")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("toggleHighlight", () => {
|
|
|
+ it("wraps selection in highlight", () => {
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
+ toggleHighlight(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello ^^world^^")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("unwraps highlight when cursor is inside", () => {
|
|
|
+ const view = getView("^^highlight^^", 5)
|
|
|
+ toggleHighlight(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("highlight")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("insertLink", () => {
|
|
|
+ it("wraps selection in link with url", () => {
|
|
|
+ const view = getViewWithRange("hello world", 6, 11)
|
|
|
+ insertLink(view, "https://example.com")
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe(
|
|
|
+ "hello [world](https://example.com)",
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ it("inserts placeholder link with selection on link text when no url", () => {
|
|
|
+ const view = getView("hello ", 6)
|
|
|
+ insertLink(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("hello [link](url)")
|
|
|
+ const sel = view.state.selection
|
|
|
+ const selected = doc.textBetween(sel.from, sel.to)
|
|
|
+ expect(selected).toBe("link")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("setHeading", () => {
|
|
|
+ it("adds heading markers to plain text", () => {
|
|
|
+ const view = getView("Title", 0)
|
|
|
+ setHeading(view, 1)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("# Title")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("changes heading level", () => {
|
|
|
+ const view = getView("## Section", 0)
|
|
|
+ setHeading(view, 3)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("### Section")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes heading when setting same level", () => {
|
|
|
+ const view = getView("# Title", 0)
|
|
|
+ setHeading(view, 1)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("Title")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes heading when level is null", () => {
|
|
|
+ const view = getView("## Section", 0)
|
|
|
+ setHeading(view, null)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("Section")
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe("toggleTask", () => {
|
|
|
+ it("adds TODO to plain text", () => {
|
|
|
+ const view = getView("do the thing", 0)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("TODO do the thing")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("cycles TODO → DOING", () => {
|
|
|
+ const view = getView("TODO fix bug", 4)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("DOING fix bug")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("cycles DOING → DONE", () => {
|
|
|
+ const view = getView("DOING fix bug", 5)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("DONE fix bug")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes task when cycling from DONE", () => {
|
|
|
+ const view = getView("DONE fix bug", 4)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("fix bug")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("sets specific task state", () => {
|
|
|
+ const view = getView("TODO fix bug", 4)
|
|
|
+ toggleTask(view, "DONE")
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("DONE fix bug")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes task when setting same state", () => {
|
|
|
+ const view = getView("TODO fix bug", 4)
|
|
|
+ toggleTask(view, "TODO")
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("fix bug")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes LATER task on cycle (orthogonal state)", () => {
|
|
|
+ const view = getView("LATER review", 6)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("review")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes WAITING task on cycle (orthogonal state)", () => {
|
|
|
+ const view = getView("WAITING review", 7)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("review")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("removes CANCELLED task on cycle (orthogonal state)", () => {
|
|
|
+ const view = getView("CANCELLED task", 10)
|
|
|
+ toggleTask(view)
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("task")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("sets orthogonal state explicitly", () => {
|
|
|
+ const view = getView("TODO fix bug", 4)
|
|
|
+ toggleTask(view, "WAITING")
|
|
|
+ const doc = view.state.doc
|
|
|
+ expect(doc.textBetween(1, doc.content.size)).toBe("WAITING fix bug")
|
|
|
+ })
|
|
|
+})
|