| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- import { defaultKeymap, indentWithTab } from "@codemirror/commands"
- import { css } from "@codemirror/lang-css"
- import { html } from "@codemirror/lang-html"
- import { javascript } from "@codemirror/lang-javascript"
- import { json } from "@codemirror/lang-json"
- import { markdown as cmMarkdown } from "@codemirror/lang-markdown"
- import { python } from "@codemirror/lang-python"
- import { xml } from "@codemirror/lang-xml"
- import { syntaxHighlighting } from "@codemirror/language"
- import { Compartment, EditorState } from "@codemirror/state"
- import { EditorView as CodeMirrorView, keymap } from "@codemirror/view"
- import { classHighlighter } from "@lezer/highlight"
- import type { Node as ProsemirrorNode } from "prosemirror-model"
- import type { EditorView, NodeView } from "prosemirror-view"
- import { createLogger } from "@/lib/logger"
- /**
- * Delegate through which CodeBlockView signals navigation intent.
- *
- * The node view never mutates document structure directly — it emits
- * intent and lets the owning layer (Block / Editor) decide what to do.
- *
- * Methods receive `nodePos` — the ProseMirror position of the code_block
- * node at call time — so the delegate can resolve it to find neighbour
- * nodes without depending on PM's node view lifecycle.
- */
- export interface CodeBlockNavigationDelegate {
- /** User pressed ArrowUp at first line or ArrowDown at last line. */
- requestNavigateOut(direction: "up" | "down", nodePos: number): void
- /** User pressed Shift+Enter on first or last line of a code block. */
- requestInsertParagraphAround(
- direction: "before" | "after",
- nodePos: number,
- ): void
- /** User pressed Backspace on an empty code block. */
- requestRemoveBlock(nodePos: number): void
- /** User selected a new language from the dropdown. */
- requestChangeLanguage(
- newLanguage: string,
- body: string,
- nodePos: number,
- ): void
- }
- function instanceLogger(componentFilter?: string) {
- return createLogger("CodeBlockView", componentFilter)
- }
- /**
- * Strip fence markers from a code block's text content.
- *
- * Input: "```typescript\nconst x = 1\n```"
- * Output: "const x = 1"
- *
- * Handles edge cases:
- * - Empty block: "```\n```" (2 lines) → ""
- * - Block with language but no body: "```js\n```" (2 lines) → ""
- * - Single-line body: "```js\nconst x = 1\n```" (3 lines) → "const x = 1"
- */
- export function stripFences(content: string): string {
- const lines = content.split("\n")
- return lines.slice(1, -1).join("\n")
- }
- /**
- * Re-add fence markers around code body for PM storage.
- * Normalizes trailing newlines to prevent blank-line drift
- * before the closing fence.
- */
- export function addFences(content: string, language: string): string {
- const body = content.replace(/\n$/, "")
- return `\`\`\`${language}\n${body}\n\`\`\``
- }
- /**
- * Map language aliases to canonical short forms used in the dropdown.
- *
- * "typescript" → "ts", "javascript" → "js", "py" → "python", etc.
- * Unrecognized languages return "" to show "Plain" in the select.
- */
- function normalizeLanguage(lang: string): string {
- switch (lang.toLowerCase()) {
- case "js":
- case "javascript":
- case "jsx":
- case "mjs":
- case "cjs":
- return "js"
- case "ts":
- case "typescript":
- case "tsx":
- case "mts":
- case "cts":
- return "ts"
- case "python":
- case "py":
- return "python"
- case "html":
- case "htm":
- return "html"
- case "css":
- return "css"
- case "json":
- return "json"
- case "xml":
- case "svg":
- return "xml"
- case "md":
- case "markdown":
- return "md"
- default:
- return ""
- }
- }
- function resolveLanguage(lang: string) {
- switch (lang.toLowerCase()) {
- case "js":
- case "javascript":
- case "jsx":
- case "mjs":
- case "cjs":
- return javascript()
- case "ts":
- case "typescript":
- case "tsx":
- case "mts":
- case "cts":
- return javascript({ typescript: true })
- case "python":
- case "py":
- return python()
- case "html":
- case "htm":
- return html()
- case "css":
- return css()
- case "json":
- return json()
- case "xml":
- case "svg":
- return xml()
- case "md":
- case "markdown":
- return cmMarkdown()
- default:
- return []
- }
- }
- const LANG_OPTIONS = [
- { value: "", label: "Plain" },
- { value: "js", label: "JavaScript" },
- { value: "ts", label: "TypeScript" },
- { value: "python", label: "Python" },
- { value: "html", label: "HTML" },
- { value: "css", label: "CSS" },
- { value: "json", label: "JSON" },
- { value: "xml", label: "XML" },
- { value: "svg", label: "SVG" },
- { value: "md", label: "Markdown" },
- ]
- export class CodeBlockView implements NodeView {
- dom: HTMLElement
- cmView: CodeMirrorView
- private pmView: EditorView
- private getPos: () => number
- private delegate: CodeBlockNavigationDelegate
- private languageCompartment = new Compartment()
- private lastLanguage = ""
- private langSelect: HTMLSelectElement
- private log: ReturnType<typeof createLogger>
- constructor(
- node: ProsemirrorNode,
- view: EditorView,
- getPos: () => number,
- delegate: CodeBlockNavigationDelegate,
- componentFilter?: string,
- ) {
- this.pmView = view
- this.getPos = getPos
- this.delegate = delegate
- this.log = instanceLogger(componentFilter)
- this.dom = document.createElement("div")
- this.dom.className = "cm-code-block"
- const header = document.createElement("div")
- header.className = "cm-code-block-header"
- this.langSelect = document.createElement("select")
- this.langSelect.className = "cm-code-block-lang-select"
- this.langSelect.addEventListener("mousedown", (e) => e.stopPropagation())
- const language = (node.attrs.language as string) || ""
- for (const opt of LANG_OPTIONS) {
- const option = document.createElement("option")
- option.value = opt.value
- option.textContent = opt.label
- this.langSelect.appendChild(option)
- }
- this.langSelect.value = normalizeLanguage(language)
- this.langSelect.addEventListener("change", () => {
- const body = this.cmView.state.doc.toString()
- this.delegate.requestChangeLanguage(
- this.langSelect.value,
- body,
- this.getPos(),
- )
- })
- const deleteBtn = document.createElement("button")
- deleteBtn.className = "cm-code-block-delete-btn"
- deleteBtn.textContent = "×"
- deleteBtn.title = "Delete code block"
- deleteBtn.addEventListener("mousedown", (e) => e.stopPropagation())
- deleteBtn.addEventListener("click", () => {
- this.delegate.requestRemoveBlock(this.getPos())
- })
- header.appendChild(this.langSelect)
- header.appendChild(deleteBtn)
- this.dom.appendChild(header)
- this.lastLanguage = language
- const langExt = resolveLanguage(language)
- this.log.info("constructor", {
- language,
- langExt: langExt?.constructor?.name ?? typeof langExt,
- })
- const exitKeymap = keymap.of([
- {
- key: "ArrowUp",
- run: (cm6View) => {
- const sel = cm6View.state.selection.main
- if (sel.from === 0 && sel.empty) {
- this.delegate.requestNavigateOut("up", this.getPos())
- return true
- }
- return false
- },
- },
- {
- key: "ArrowDown",
- run: (cm6View) => {
- const sel = cm6View.state.selection.main
- if (sel.to === cm6View.state.doc.length && sel.empty) {
- this.delegate.requestNavigateOut("down", this.getPos())
- return true
- }
- return false
- },
- },
- {
- key: "Shift-Enter",
- run: (cm6View) => {
- const sel = cm6View.state.selection.main
- if (sel.from === 0 && sel.empty) {
- this.delegate.requestInsertParagraphAround("before", this.getPos())
- return true
- }
- const docLen = cm6View.state.doc.length
- if (sel.from === docLen && sel.empty) {
- this.delegate.requestInsertParagraphAround("after", this.getPos())
- return true
- }
- return false
- },
- },
- {
- key: "Backspace",
- run: (cm6View) => {
- if (cm6View.state.doc.length === 0) {
- this.delegate.requestRemoveBlock(this.getPos())
- return true
- }
- return false
- },
- },
- ])
- const cmState = EditorState.create({
- doc: stripFences(node.textContent),
- extensions: [
- this.languageCompartment.of(langExt),
- exitKeymap,
- keymap.of([indentWithTab, ...defaultKeymap]),
- CodeMirrorView.lineWrapping,
- syntaxHighlighting(classHighlighter),
- ],
- })
- this.cmView = new CodeMirrorView({
- state: cmState,
- parent: this.dom,
- dispatch: (tr) => {
- this.cmView.update([tr])
- if (!tr.docChanged) return
- this.syncToPM()
- },
- })
- // Only auto-focus empty code blocks (user-created via auto-close).
- // Non-empty blocks loaded from content shouldn't steal the cursor.
- if (stripFences(node.textContent).length === 0) {
- requestAnimationFrame(() => this.cmView.focus())
- }
- }
- private syncToPM() {
- const content = this.cmView.state.doc.toString()
- const pos = this.getPos()
- const node = this.pmView.state.doc.nodeAt(pos)
- if (!node) return
- const language = node.attrs.language as string
- const fencedContent = addFences(content, language)
- if (node.textContent === fencedContent) return
- this.log.debug("syncToPM content sync", {
- pmLen: node.textContent.length,
- cmLen: content.length,
- })
- const textNode = this.pmView.state.schema.text(fencedContent)
- const tr = this.pmView.state.tr.replaceWith(
- pos + 1,
- pos + node.nodeSize - 1,
- textNode,
- )
- this.pmView.dispatch(tr)
- }
- update(node: ProsemirrorNode) {
- if (node.type.name !== "code_block") return false
- const language = (node.attrs.language as string) || ""
- const langExt = resolveLanguage(language)
- this.log.debug("update", {
- language,
- langExt: langExt?.constructor?.name ?? typeof langExt,
- contentMatch:
- this.cmView.state.doc.toString() === stripFences(node.textContent),
- })
- if (language !== this.lastLanguage) {
- this.lastLanguage = language
- this.langSelect.value = normalizeLanguage(language)
- this.cmView.dispatch({
- effects: this.languageCompartment.reconfigure(langExt),
- })
- }
- const body = stripFences(node.textContent)
- if (this.cmView.state.doc.toString() === body) return true
- this.cmView.dispatch({
- changes: { from: 0, to: this.cmView.state.doc.length, insert: body },
- })
- return true
- }
- destroy() {
- this.cmView.destroy()
- }
- stopEvent() {
- return true
- }
- }
|