Przeglądaj źródła

feat: add inline formatting engine with toggle commands and keybindings

- Add `formatting.ts` with `toggleBold`/`toggleItalic`/`toggleCode`/`toggleStrikethrough`/`toggleHighlight`
  (wraps selection, unwraps cursor, inserts empty delimiters)
- Add `insertLink` with URL param or placeholder selection fallback
- Add `setHeading` to add, change, cycle, or remove heading markers
- Add `toggleTask` with TODO→DOING→DONE cycling, arbitrary state, and removal for orthogonal states
- Add `isFormatActive` for cursor-position format querying
- Wire `ctrl+B`/`ctrl+I`/`ctrl+``/`ctrl+Shift+S`/`ctrl+Shift+H`/`ctrl+K` keybindings in Block.vue
- Fill `formattingHandlers` object including `insertLink`, `setHeading`, `toggleTask`, `isActive`
- Add 316-line unit test covering all commands, edge cases, and overlapping syntax
- Add 77-line `tinybench` benchmark for parse and format-check throughput
- Add `"bench"` script and `tinybench` dev dependency
Zander Hawke 1 tydzień temu
rodzic
commit
8a612d5a0a

+ 2 - 1
packages/editor/package.json

@@ -17,7 +17,8 @@
     "build": "vite build && vue-tsc -b --declaration --emitDeclarationOnly",
     "dev": "vite build --watch",
     "test": "vitest run",
-    "test:watch": "vitest"
+    "test:watch": "vitest",
+    "bench": "vitest bench"
   },
   "devDependencies": {
     "@nuxt/ui": "^4.7.1",

+ 64 - 28
packages/editor/src/components/Block.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { watchDebounced } from "@vueuse/core"
+import { keymap } from "prosemirror-keymap"
 import { EditorState, Selection, type Transaction } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
@@ -25,6 +26,17 @@ import {
   tripleBacktickRule,
 } from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
+import {
+  insertLink,
+  isFormatActive,
+  setHeading,
+  toggleBold,
+  toggleCode,
+  toggleHighlight,
+  toggleItalic,
+  toggleStrikethrough,
+  toggleTask,
+} from "@/lib/formatting"
 import { createLogger } from "@/lib/logger"
 
 const content = defineModel<string>("content")
@@ -67,49 +79,41 @@ const handleKeyDown = createBlockKeyboardHandler({
 
 const formattingHandlers = {
   toggleBold: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleBold(view)
   },
   toggleItalic: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleItalic(view)
   },
   toggleCode: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleCode(view)
   },
   toggleStrikethrough: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleStrikethrough(view)
   },
   insertBold: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleBold(view)
   },
   insertItalic: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleItalic(view)
   },
   insertCode: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleCode(view)
   },
   insertStrikethrough: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+    if (view) toggleStrikethrough(view)
   },
-  insertLink: () => {
-    if (view) {
-      /* Phase 7 */
-    }
+  insertLink: (url?: string) => {
+    if (view) insertLink(view, url)
+  },
+  setHeading: (level: number | null) => {
+    if (view) setHeading(view, level)
+  },
+  toggleTask: (state?: string) => {
+    if (view) toggleTask(view, state as Parameters<typeof toggleTask>[1])
+  },
+  isActive: (format: string): boolean => {
+    if (!view) return false
+    return isFormatActive(view, format)
   },
 }
 
@@ -189,6 +193,38 @@ onMounted(() => {
         createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
         createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
       ]),
+      keymap({
+        "Mod-b": (_state, _dispatch, view) => {
+          if (!view) return false
+          toggleBold(view)
+          return true
+        },
+        "Mod-i": (_state, _dispatch, view) => {
+          if (!view) return false
+          toggleItalic(view)
+          return true
+        },
+        "Mod-`": (_state, _dispatch, view) => {
+          if (!view) return false
+          toggleCode(view)
+          return true
+        },
+        "Mod-Shift-s": (_state, _dispatch, view) => {
+          if (!view) return false
+          toggleStrikethrough(view)
+          return true
+        },
+        "Mod-Shift-h": (_state, _dispatch, view) => {
+          if (!view) return false
+          toggleHighlight(view)
+          return true
+        },
+        "Mod-k": (_state, _dispatch, view) => {
+          if (!view) return false
+          insertLink(view)
+          return true
+        },
+      }),
     ],
   })
 

+ 77 - 0
packages/editor/src/lib/__tests__/formatting.bench.ts

@@ -0,0 +1,77 @@
+import { EditorState, TextSelection } from "prosemirror-state"
+import { EditorView } from "prosemirror-view"
+import { bench, describe } from "vitest"
+import { isFormatActive } from "@/lib/formatting"
+import { MarkdownRuleEngine, parseMarkdown } from "@/lib/markdown-parser"
+import { schema } from "@/lib/schema"
+
+const engine = new MarkdownRuleEngine()
+
+const DOCUMENTS = {
+  plain: "hello world",
+  bold: "this is **bold** and **more bold** text",
+  mixed:
+    "**bold** *italic* `code` ~~strike~~ ^^highlight^^ and [[Page Ref]] ((block-id)) #tag",
+  heading:
+    "# Title\n\n## Section\n\n### Subsection\n\nTODO task here\n\n> [!NOTE] A note",
+  kitchenSink: [
+    "# Documentation\n\n",
+    "This is a **paragraph** with *multiple* `inline` ~~styles~~.\n\n",
+    "## Features\n\n",
+    "> [!TIP] Use callouts for **important** notes\n\n",
+    "TODO Add [[Page Reference]] and ((block-123)) support\n\n",
+    "priority:: high\nauthor:: John\n",
+  ].join(""),
+}
+
+describe("MarkdownRuleEngine.parse", () => {
+  bench("plain text", () => {
+    parseMarkdown(DOCUMENTS.plain, engine)
+  })
+
+  bench("bold text", () => {
+    parseMarkdown(DOCUMENTS.bold, engine)
+  })
+
+  bench("mixed inline tokens", () => {
+    parseMarkdown(DOCUMENTS.mixed, engine)
+  })
+
+  bench("block tokens (headings, tasks, callouts)", () => {
+    parseMarkdown(DOCUMENTS.heading, engine)
+  })
+
+  bench("kitchen sink (all syntax)", () => {
+    parseMarkdown(DOCUMENTS.kitchenSink, engine)
+  })
+})
+
+describe("isFormatActive", () => {
+  let view: EditorView
+
+  const boldContent = "this is **bold** text"
+  const dom = document.createElement("div")
+
+  bench("bold content at cursor inside bold", () => {
+    if (!view) {
+      const docText = schema.text(boldContent)
+      const para = schema.node("paragraph", {}, [docText])
+      const doc = schema.node("doc", {}, [para])
+      const state = EditorState.create({
+        doc,
+        schema,
+        selection: TextSelection.create(doc, 13, 13),
+      })
+      view = new EditorView(dom, { state })
+    }
+    isFormatActive(view, "bold")
+  })
+
+  bench("bold content at cursor outside bold", () => {
+    isFormatActive(view, "italic")
+  })
+
+  bench("mixed document at cursor inside bold", () => {
+    isFormatActive(view, "bold")
+  })
+})

+ 316 - 0
packages/editor/src/lib/__tests__/formatting.test.ts

@@ -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")
+  })
+})

+ 336 - 0
packages/editor/src/lib/formatting.ts

@@ -0,0 +1,336 @@
+import { TextSelection } from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
+import { MarkdownRuleEngine, parseMarkdown } from "@/lib/markdown-parser"
+import type {
+  BlockDecoration,
+  DecorationRange,
+  ParsedDecorations,
+  TaskState,
+} from "@/lib/markdown-rules"
+
+const engine = new MarkdownRuleEngine()
+
+// ── Config ────────────────────────────────────────────────────────
+
+interface FormatConfig {
+  parseType: string
+  delim: string
+}
+
+const FORMAT_CONFIGS = {
+  bold: { parseType: "strong", delim: "**" },
+  italic: { parseType: "em", delim: "*" },
+  code: { parseType: "code", delim: "`" },
+  strikethrough: { parseType: "strike", delim: "~~" },
+  highlight: { parseType: "highlight", delim: "^^" },
+} as const
+
+function getFormat(key: string): FormatConfig | undefined {
+  return FORMAT_CONFIGS[key as keyof typeof FORMAT_CONFIGS]
+}
+
+// ── Block helpers ─────────────────────────────────────────────────
+
+/** Find the current textblock and return its start offset + text */
+function currentBlock(
+  view: EditorView,
+): { blockStart: number; text: string } | null {
+  const { $from } = view.state.selection
+  if (!$from.parent.isTextblock) return null
+  return {
+    blockStart: $from.before() + 1,
+    text: $from.parent.textContent,
+  }
+}
+
+// ── isActive ──────────────────────────────────────────────────────
+
+export function isFormatActive(view: EditorView, format: string): boolean {
+  const config = getFormat(format)
+  if (!config) return false
+  const block = currentBlock(view)
+  if (!block) return false
+  const cursorInBlock = view.state.selection.$from.parentOffset
+  const parsed = parseMarkdown(block.text, engine)
+  for (const token of parsed.content) {
+    if (token.type === config.parseType) {
+      for (const range of token.decorationRanges) {
+        if (
+          range.type === "content" &&
+          range.from <= cursorInBlock &&
+          cursorInBlock <= range.to
+        ) {
+          return true
+        }
+      }
+    }
+  }
+  return false
+}
+
+// ── Find token at cursor in parsed result ──────────────────────────
+
+function findActiveToken(
+  parsed: ParsedDecorations,
+  config: FormatConfig,
+  cursorInBlock: number,
+): { ranges: DecorationRange[] } | null {
+  for (const token of parsed.content) {
+    if (token.type === config.parseType) {
+      for (const range of token.decorationRanges) {
+        if (
+          range.type === "content" &&
+          range.from <= cursorInBlock &&
+          cursorInBlock <= range.to
+        ) {
+          return { ranges: token.decorationRanges }
+        }
+      }
+    }
+  }
+  return null
+}
+
+// ── Toggle inline format ──────────────────────────────────────────
+
+function toggleFormat(view: EditorView, config: FormatConfig) {
+  const block = currentBlock(view)
+  if (!block) return
+
+  const cursorInBlock = view.state.selection.$from.parentOffset
+  const parsed = parseMarkdown(block.text, engine)
+  const active = findActiveToken(parsed, config, cursorInBlock)
+
+  // Unwrap
+  if (active) {
+    const tr = view.state.tr
+    const hidden = active.ranges.filter((r) => r.type === "hidden")
+    hidden.sort((a, b) => b.from - a.from)
+    for (const range of hidden) {
+      tr.delete(block.blockStart + range.from, block.blockStart + range.to)
+    }
+    view.dispatch(tr)
+    return
+  }
+
+  const { from, to, empty } = view.state.selection
+
+  // Wrap selection
+  if (!empty) {
+    const selected = view.state.doc.textBetween(from, to)
+    const tr = view.state.tr
+    tr.replaceWith(
+      from,
+      to,
+      view.state.schema.text(config.delim + selected + config.delim),
+    )
+    const cursorPos =
+      from + config.delim.length + selected.length + config.delim.length
+    tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+    view.dispatch(tr)
+    return
+  }
+
+  // Insert empty delimiters
+  const cursor = view.state.selection.head
+  const tr = view.state.tr
+  tr.replaceWith(
+    cursor,
+    cursor,
+    view.state.schema.text(config.delim + config.delim),
+  )
+  tr.setSelection(TextSelection.create(tr.doc, cursor + config.delim.length))
+  view.dispatch(tr)
+}
+
+// ── Exported handlers ─────────────────────────────────────────────
+
+export function toggleBold(view: EditorView) {
+  toggleFormat(view, FORMAT_CONFIGS.bold)
+}
+
+export function toggleItalic(view: EditorView) {
+  toggleFormat(view, FORMAT_CONFIGS.italic)
+}
+
+export function toggleCode(view: EditorView) {
+  toggleFormat(view, FORMAT_CONFIGS.code)
+}
+
+export function toggleStrikethrough(view: EditorView) {
+  toggleFormat(view, FORMAT_CONFIGS.strikethrough)
+}
+
+export function toggleHighlight(view: EditorView) {
+  toggleFormat(view, FORMAT_CONFIGS.highlight)
+}
+
+// ── insertLink ────────────────────────────────────────────────────
+
+export function insertLink(view: EditorView, url?: string) {
+  const { from, to, empty } = view.state.selection
+  const placeholderUrl = url ?? "url"
+  const placeholderText = "link"
+
+  if (!empty) {
+    const selected = view.state.doc.textBetween(from, to)
+    const text = `[${selected}](${placeholderUrl})`
+    const tr = view.state.tr
+    tr.replaceWith(from, to, view.state.schema.text(text))
+    if (!url) {
+      const urlStart = from + selected.length + 3
+      const urlEnd = urlStart + placeholderUrl.length
+      tr.setSelection(TextSelection.create(tr.doc, urlStart, urlEnd))
+    } else {
+      tr.setSelection(TextSelection.create(tr.doc, from + text.length))
+    }
+    view.dispatch(tr)
+    return
+  }
+
+  const cursor = view.state.selection.head
+  const text = `[${placeholderText}](${placeholderUrl})`
+  const tr = view.state.tr
+  tr.replaceWith(cursor, cursor, view.state.schema.text(text))
+  if (!url) {
+    const textStart = cursor + 1
+    const textEnd = textStart + placeholderText.length
+    tr.setSelection(TextSelection.create(tr.doc, textStart, textEnd))
+  } else {
+    tr.setSelection(TextSelection.create(tr.doc, cursor + text.length))
+  }
+  view.dispatch(tr)
+}
+
+// ── setHeading ────────────────────────────────────────────────────
+
+function parseCurrentHeading(view: EditorView): number | null {
+  const block = currentBlock(view)
+  if (!block) return null
+  const parsed = parseMarkdown(block.text, engine)
+  const heading = parsed.blocks.find((b) => b.type === "heading")
+  return heading?.metadata?.level ?? null
+}
+
+const HEADING_RE = /^(#{1,6}) ?/
+
+export function setHeading(view: EditorView, level: number | null) {
+  const block = currentBlock(view)
+  if (!block) return
+
+  const currentLevel = parseCurrentHeading(view)
+
+  if (level === null || level === currentLevel) {
+    const match = HEADING_RE.exec(block.text)
+    if (match) {
+      const tr = view.state.tr
+      tr.delete(block.blockStart, block.blockStart + match[0].length)
+      view.dispatch(tr)
+    }
+    return
+  }
+
+  const prefix = `${"#".repeat(level)} `
+  const existing = HEADING_RE.exec(block.text)
+  if (existing) {
+    const tr = view.state.tr
+    tr.replaceWith(
+      block.blockStart,
+      block.blockStart + existing[0].length,
+      view.state.schema.text(prefix),
+    )
+    view.dispatch(tr)
+  } else {
+    const tr = view.state.tr
+    tr.replaceWith(
+      block.blockStart,
+      block.blockStart,
+      view.state.schema.text(prefix),
+    )
+    view.dispatch(tr)
+  }
+}
+
+// ── toggleTask ────────────────────────────────────────────────────
+
+const TASK_CYCLE: TaskState[] = ["TODO", "DOING", "DONE"]
+
+function parseCurrentTask(
+  view: EditorView,
+): { state: TaskState; token: BlockDecoration } | null {
+  const block = currentBlock(view)
+  if (!block) return null
+  const parsed = parseMarkdown(block.text, engine)
+  for (const b of parsed.blocks) {
+    if (b.type === "task") {
+      const ts = b.metadata?.taskState
+      if (ts) return { state: ts, token: b }
+    }
+  }
+  return null
+}
+
+/**
+ * Find the start of the task content (after the marker keyword and its
+ * trailing separator).  The decoration ranges from createTaskRule give us
+ * the marker keyword range and the content range starting at contentStart.
+ */
+function taskContentStart(token: BlockDecoration): number | null {
+  const contentRange = token.decorationRanges.find(
+    (r) => r.className === "md-task-content",
+  )
+  return contentRange?.from ?? null
+}
+
+export function toggleTask(view: EditorView, state?: TaskState) {
+  const block = currentBlock(view)
+  if (!block) return
+
+  const active = parseCurrentTask(view)
+  const markerRange = active?.token.decorationRanges.find(
+    (r) => r.className === "md-task-marker",
+  )
+
+  if (active && markerRange) {
+    const markerFrom = block.blockStart + markerRange.from
+    const contentFrom = taskContentStart(active.token)
+    const markerTo = block.blockStart + markerRange.to
+    const deleteTo =
+      contentFrom != null ? block.blockStart + contentFrom : markerTo + 1
+
+    if (state) {
+      if (state === active.state) {
+        const tr = view.state.tr
+        tr.delete(markerFrom, deleteTo)
+        view.dispatch(tr)
+        return
+      }
+      const tr = view.state.tr
+      tr.replaceWith(markerFrom, markerTo, view.state.schema.text(state))
+      view.dispatch(tr)
+      return
+    }
+
+    const idx = TASK_CYCLE.indexOf(active.state)
+    if (idx === -1 || idx === TASK_CYCLE.length - 1) {
+      const tr = view.state.tr
+      tr.delete(markerFrom, deleteTo)
+      view.dispatch(tr)
+      return
+    }
+
+    const next = TASK_CYCLE[idx + 1] as TaskState
+    const tr = view.state.tr
+    tr.replaceWith(markerFrom, markerTo, view.state.schema.text(next))
+    view.dispatch(tr)
+    return
+  }
+
+  const tr = view.state.tr
+  tr.replaceWith(
+    block.blockStart,
+    block.blockStart,
+    view.state.schema.text("TODO "),
+  )
+  view.dispatch(tr)
+}