Преглед изворни кода

feat(editor): Phase 7 — reference and tag insertion handlers

- Add insertPageRef, insertBlockRef, insertTag to formatting.ts
  with guard conditions (reject empty/spaced input)
- Wire handlers into Block.vue formattingHandlers (focus event)
- Add 18 unit tests covering insertion, wrapping, cursor placement,
  alias+selection combo, and no-op edge cases
- Add 3 benchmark suites with fresh state per iteration
- Update READMEs and development plan
Zander Hawke пре 1 недеља
родитељ
комит
be09111458

+ 1 - 1
README.md

@@ -15,7 +15,7 @@ Enesis is being built as a complete block-editing substrate:
 | 1–5 | Core editor: clean content model, keyboard boundaries, focus/cursor, inline + block decorations, Lezer-only AST pipeline | **Done** |
 | 6 | Code block node views with CodeMirror 6, inline/block LaTeX with KaTeX | **Done** |
 | 6.75 | Build system — type declarations pass cleanly, dev app as interactive docs site | **Done** |
-| 7 | Toolbar handler API — `toggleBold`, `insertLink`, `setHeading`, `toggleTask` from Lezer tree queries | Planned |
+| 7 | Reference & tag insertion handlers — `insertPageRef`, `insertBlockRef`, `insertTag` | **Done** |
 | 8 | Asset handling — image drop, upload protocol, inline preview | Planned |
 | 9 | Reference interactivity — clickable `[[page]]`, `((block))`, `#tag` chips with preview | Planned |
 | 10 | Multi-block editing — shared toolbar, drag handle, suggestion/mention menus | Planned |

+ 1 - 0
packages/editor/README.md

@@ -176,6 +176,7 @@ pnpm dev          # Watch mode rebuild
 | `src/lib/katex.ts` | Dynamic KaTeX import + async/sync render helpers |
 | `src/lib/auto-close-plugin.ts` | Auto-close for ```, **, _ |
 | `src/lib/content-model.ts` | Markdown ↔ ProseMirror doc conversion |
+| `src/lib/formatting.ts` | Formatting handlers — toggle/wrap/insert for bold, link, math, page refs, block refs, tags, heading, task |
 | `src/lib/logger.ts` | Namespace-based debug logger |
 | `src/lib/schema.ts` | ProseMirror schema |
 | `src/lib/markdown-rules/engine.ts` | 3-stage rule engine |

+ 12 - 0
packages/editor/src/components/Block.vue

@@ -30,8 +30,11 @@ import {
 import { contentToDoc, docToContent } from "@/lib/content-model"
 import {
   insertBlockMath,
+  insertBlockRef,
   insertInlineMath,
   insertLink,
+  insertPageRef,
+  insertTag,
   isFormatActive,
   setHeading,
   toggleBold,
@@ -115,6 +118,15 @@ const formattingHandlers = {
   insertBlockMath: () => {
     if (view) insertBlockMath(view)
   },
+  insertPageRef: (pageName: string, alias?: string) => {
+    if (view) insertPageRef(view, pageName, alias)
+  },
+  insertBlockRef: (blockId: string) => {
+    if (view) insertBlockRef(view, blockId)
+  },
+  insertTag: (tag: string) => {
+    if (view) insertTag(view, tag)
+  },
   setHeading: (level: number | null) => {
     if (view) setHeading(view, level)
   },

+ 58 - 1
packages/editor/src/lib/__tests__/formatting.bench.ts

@@ -1,12 +1,27 @@
 import { EditorState, TextSelection } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { bench, describe } from "vitest"
-import { isFormatActive } from "@/lib/formatting"
+import {
+  insertBlockRef,
+  insertPageRef,
+  insertTag,
+  isFormatActive,
+} from "@/lib/formatting"
 import { MarkdownRuleEngine, parseMarkdown } from "@/lib/markdown-parser"
 import { schema } from "@/lib/schema"
 
 const engine = new MarkdownRuleEngine()
 
+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])
+}
+
+const BENCH_DOM = document.createElement("div")
+
 const DOCUMENTS = {
   plain: "hello world",
   bold: "this is **bold** and **more bold** text",
@@ -75,3 +90,45 @@ describe("isFormatActive", () => {
     isFormatActive(view, "bold")
   })
 })
+
+describe("insertPageRef", () => {
+  bench("insert [[Page]] at cursor end", () => {
+    const doc = makeParaDoc("hello ")
+    const state = EditorState.create({
+      doc,
+      schema,
+      selection: TextSelection.create(doc, 7, 7),
+    })
+    const view = new EditorView(BENCH_DOM, { state })
+    insertPageRef(view, "Page")
+    view.destroy()
+  })
+})
+
+describe("insertBlockRef", () => {
+  bench("insert ((block-id)) at cursor end", () => {
+    const doc = makeParaDoc("hello ")
+    const state = EditorState.create({
+      doc,
+      schema,
+      selection: TextSelection.create(doc, 7, 7),
+    })
+    const view = new EditorView(BENCH_DOM, { state })
+    insertBlockRef(view, "abc123")
+    view.destroy()
+  })
+})
+
+describe("insertTag", () => {
+  bench("insert #tag at cursor end", () => {
+    const doc = makeParaDoc("hello ")
+    const state = EditorState.create({
+      doc,
+      schema,
+      selection: TextSelection.create(doc, 7, 7),
+    })
+    const view = new EditorView(BENCH_DOM, { state })
+    insertTag(view, "bug")
+    view.destroy()
+  })
+})

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

@@ -3,8 +3,11 @@ import { EditorView } from "prosemirror-view"
 import { afterEach, beforeEach, describe, expect, it } from "vitest"
 import {
   insertBlockMath,
+  insertBlockRef,
   insertInlineMath,
   insertLink,
+  insertPageRef,
+  insertTag,
   isFormatActive,
   setHeading,
   toggleBold,
@@ -373,3 +376,130 @@ describe("insertInlineMath", () => {
     expect(doc.textBetween(1, doc.content.size)).toBe("hello $$")
   })
 })
+
+describe("insertPageRef", () => {
+  it("inserts [[Page Name]] at cursor", () => {
+    const view = getView("hello ", 6)
+    insertPageRef(view, "Page Name")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello [[Page Name]]")
+  })
+
+  it("inserts [[Page Name|Alias]] when alias is given", () => {
+    const view = getView("hello ", 6)
+    insertPageRef(view, "Page Name", "Alias")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello [[Page Name|Alias]]")
+  })
+
+  it("wraps selected text as the page ref", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertPageRef(view, "Renamed")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello [[Renamed]]")
+  })
+
+  it("does nothing when pageName is empty", () => {
+    const view = getView("hello", 5)
+    insertPageRef(view, "")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello")
+  })
+
+  it("places cursor after closing ]]", () => {
+    const view = getView("hello ", 6)
+    insertPageRef(view, "Page")
+    expect(view.state.selection.head).toBe(1 + 6 + "[[Page]]".length)
+  })
+
+  it("discards selection when alias is given", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertPageRef(view, "Page", "Alias")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello [[Page|Alias]]")
+  })
+
+  it("places cursor after closing ]] with selection", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertPageRef(view, "Page")
+    expect(view.state.selection.head).toBe(1 + 6 + "[[Page]]".length)
+  })
+})
+
+describe("insertBlockRef", () => {
+  it("inserts ((block-id)) at cursor", () => {
+    const view = getView("hello ", 6)
+    insertBlockRef(view, "abc123")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello ((abc123))")
+  })
+
+  it("wraps selected text as the block ref", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertBlockRef(view, "xyz")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello ((xyz))")
+  })
+
+  it("does nothing when blockId is empty", () => {
+    const view = getView("hello", 5)
+    insertBlockRef(view, "")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello")
+  })
+
+  it("places cursor after closing ))", () => {
+    const view = getView("hello ", 6)
+    insertBlockRef(view, "id")
+    expect(view.state.selection.head).toBe(1 + 6 + "((id))".length)
+  })
+})
+
+describe("insertTag", () => {
+  it("inserts #tag at cursor", () => {
+    const view = getView("hello ", 6)
+    insertTag(view, "bug")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello #bug")
+  })
+
+  it("does nothing when tag contains space", () => {
+    const view = getView("hello ", 6)
+    insertTag(view, "my tag")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello ")
+  })
+
+  it("does nothing when tag is empty", () => {
+    const view = getView("hello ", 6)
+    insertTag(view, "")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello ")
+  })
+
+  it("replaces selection with tagged text", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertTag(view, "greeting")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello #greeting")
+  })
+
+  it("does nothing when selection contains space", () => {
+    const view = getViewWithRange("hello big world", 6, 10)
+    insertTag(view, "greeting")
+    const doc = view.state.doc
+    expect(doc.textBetween(1, doc.content.size)).toBe("hello big world")
+  })
+
+  it("places cursor after the complete tag", () => {
+    const view = getView("hello ", 6)
+    insertTag(view, "bug")
+    expect(view.state.selection.head).toBe(1 + 6 + "#bug".length)
+  })
+
+  it("places cursor after tag when replacing selection", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+    insertTag(view, "greeting")
+    expect(view.state.selection.head).toBe(1 + 6 + "#greeting".length)
+  })
+})

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

@@ -235,6 +235,86 @@ export function insertLink(view: EditorView, url?: string) {
   view.dispatch(tr)
 }
 
+// ── insertPageRef ─────────────────────────────────────────────────
+
+export function insertPageRef(
+  view: EditorView,
+  pageName: string,
+  alias?: string,
+) {
+  if (!pageName) return
+  // The explicit pageName wins over any selection text — unlike insertLink
+  // which uses the selection as link text, this handler is called after the
+  // user has already chosen a page from a suggestion menu, so the chosen
+  // name is authoritative and the selection is just a position marker.
+  const { from, to, empty } = view.state.selection
+  const ref = alias ? `[[${pageName}|${alias}]]` : `[[${pageName}]]`
+
+  if (!empty) {
+    const tr = view.state.tr
+    tr.replaceWith(from, to, view.state.schema.text(ref))
+    tr.setSelection(TextSelection.create(tr.doc, from + ref.length))
+    view.dispatch(tr)
+    return
+  }
+
+  const cursor = view.state.selection.head
+  const tr = view.state.tr
+  tr.replaceWith(cursor, cursor, view.state.schema.text(ref))
+  tr.setSelection(TextSelection.create(tr.doc, cursor + ref.length))
+  view.dispatch(tr)
+}
+
+// ── insertBlockRef ─────────────────────────────────────────────────
+
+export function insertBlockRef(view: EditorView, blockId: string) {
+  if (!blockId) return
+  const { from, to, empty } = view.state.selection
+  const ref = `((${blockId}))`
+
+  if (!empty) {
+    const tr = view.state.tr
+    tr.replaceWith(from, to, view.state.schema.text(ref))
+    tr.setSelection(TextSelection.create(tr.doc, from + ref.length))
+    view.dispatch(tr)
+    return
+  }
+
+  const cursor = view.state.selection.head
+  const tr = view.state.tr
+  tr.replaceWith(cursor, cursor, view.state.schema.text(ref))
+  tr.setSelection(TextSelection.create(tr.doc, cursor + ref.length))
+  view.dispatch(tr)
+}
+
+// ── insertTag ──────────────────────────────────────────────────────
+
+export function insertTag(view: EditorView, tag: string) {
+  if (!tag || tag.includes(" ")) return
+  // Like insertPageRef, the explicit tag wins over selection text. The
+  // handler is called after the user picks a tag from a menu; the selection
+  // is just a position marker. If the selected text contains spaces, bail
+  // out since that would produce invalid syntax regardless.
+  const { from, to, empty } = view.state.selection
+  const ref = `#${tag}`
+
+  if (!empty) {
+    const selected = view.state.doc.textBetween(from, to)
+    if (selected.includes(" ")) return
+    const tr = view.state.tr
+    tr.replaceWith(from, to, view.state.schema.text(ref))
+    tr.setSelection(TextSelection.create(tr.doc, from + ref.length))
+    view.dispatch(tr)
+    return
+  }
+
+  const cursor = view.state.selection.head
+  const tr = view.state.tr
+  tr.replaceWith(cursor, cursor, view.state.schema.text(ref))
+  tr.setSelection(TextSelection.create(tr.doc, cursor + ref.length))
+  view.dispatch(tr)
+}
+
 // ── setHeading ────────────────────────────────────────────────────
 
 function parseCurrentHeading(view: EditorView): number | null {