useCodeBlockView.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { defaultKeymap, indentWithTab } from "@codemirror/commands"
  2. import { css } from "@codemirror/lang-css"
  3. import { html } from "@codemirror/lang-html"
  4. import { javascript } from "@codemirror/lang-javascript"
  5. import { json } from "@codemirror/lang-json"
  6. import { markdown as cmMarkdown } from "@codemirror/lang-markdown"
  7. import { python } from "@codemirror/lang-python"
  8. import { xml } from "@codemirror/lang-xml"
  9. import { syntaxHighlighting } from "@codemirror/language"
  10. import { Compartment, EditorState } from "@codemirror/state"
  11. import { EditorView as CodeMirrorView, keymap } from "@codemirror/view"
  12. import { classHighlighter } from "@lezer/highlight"
  13. import type { Node as ProsemirrorNode } from "prosemirror-model"
  14. import type { EditorView, NodeView } from "prosemirror-view"
  15. import { createLogger } from "@/lib/logger"
  16. /**
  17. * Delegate through which CodeBlockView signals navigation intent.
  18. *
  19. * The node view never mutates document structure directly — it emits
  20. * intent and lets the owning layer (Block / Editor) decide what to do.
  21. *
  22. * Methods receive `nodePos` — the ProseMirror position of the code_block
  23. * node at call time — so the delegate can resolve it to find neighbour
  24. * nodes without depending on PM's node view lifecycle.
  25. */
  26. export interface CodeBlockNavigationDelegate {
  27. /** User pressed ArrowUp at first line or ArrowDown at last line. */
  28. requestNavigateOut(direction: "up" | "down", nodePos: number): void
  29. /** User pressed Shift+Enter on first or last line of a code block. */
  30. requestInsertParagraphAround(
  31. direction: "before" | "after",
  32. nodePos: number,
  33. ): void
  34. /** User pressed Backspace on an empty code block. */
  35. requestRemoveBlock(nodePos: number): void
  36. /** User selected a new language from the dropdown. */
  37. requestChangeLanguage(
  38. newLanguage: string,
  39. body: string,
  40. nodePos: number,
  41. ): void
  42. }
  43. function instanceLogger(componentFilter?: string) {
  44. return createLogger("CodeBlockView", componentFilter)
  45. }
  46. /**
  47. * Strip fence markers from a code block's text content.
  48. *
  49. * Input: "```typescript\nconst x = 1\n```"
  50. * Output: "const x = 1"
  51. *
  52. * Handles edge cases:
  53. * - Empty block: "```\n```" (2 lines) → ""
  54. * - Block with language but no body: "```js\n```" (2 lines) → ""
  55. * - Single-line body: "```js\nconst x = 1\n```" (3 lines) → "const x = 1"
  56. */
  57. export function stripFences(content: string): string {
  58. const lines = content.split("\n")
  59. return lines.slice(1, -1).join("\n")
  60. }
  61. /**
  62. * Re-add fence markers around code body for PM storage.
  63. * Normalizes trailing newlines to prevent blank-line drift
  64. * before the closing fence.
  65. */
  66. export function addFences(content: string, language: string): string {
  67. const body = content.replace(/\n$/, "")
  68. return `\`\`\`${language}\n${body}\n\`\`\``
  69. }
  70. /**
  71. * Map language aliases to canonical short forms used in the dropdown.
  72. *
  73. * "typescript" → "ts", "javascript" → "js", "py" → "python", etc.
  74. * Unrecognized languages return "" to show "Plain" in the select.
  75. */
  76. function normalizeLanguage(lang: string): string {
  77. switch (lang.toLowerCase()) {
  78. case "js":
  79. case "javascript":
  80. case "jsx":
  81. case "mjs":
  82. case "cjs":
  83. return "js"
  84. case "ts":
  85. case "typescript":
  86. case "tsx":
  87. case "mts":
  88. case "cts":
  89. return "ts"
  90. case "python":
  91. case "py":
  92. return "python"
  93. case "html":
  94. case "htm":
  95. return "html"
  96. case "css":
  97. return "css"
  98. case "json":
  99. return "json"
  100. case "xml":
  101. case "svg":
  102. return "xml"
  103. case "md":
  104. case "markdown":
  105. return "md"
  106. default:
  107. return ""
  108. }
  109. }
  110. function resolveLanguage(lang: string) {
  111. switch (lang.toLowerCase()) {
  112. case "js":
  113. case "javascript":
  114. case "jsx":
  115. case "mjs":
  116. case "cjs":
  117. return javascript()
  118. case "ts":
  119. case "typescript":
  120. case "tsx":
  121. case "mts":
  122. case "cts":
  123. return javascript({ typescript: true })
  124. case "python":
  125. case "py":
  126. return python()
  127. case "html":
  128. case "htm":
  129. return html()
  130. case "css":
  131. return css()
  132. case "json":
  133. return json()
  134. case "xml":
  135. case "svg":
  136. return xml()
  137. case "md":
  138. case "markdown":
  139. return cmMarkdown()
  140. default:
  141. return []
  142. }
  143. }
  144. const LANG_OPTIONS = [
  145. { value: "", label: "Plain" },
  146. { value: "js", label: "JavaScript" },
  147. { value: "ts", label: "TypeScript" },
  148. { value: "python", label: "Python" },
  149. { value: "html", label: "HTML" },
  150. { value: "css", label: "CSS" },
  151. { value: "json", label: "JSON" },
  152. { value: "xml", label: "XML" },
  153. { value: "svg", label: "SVG" },
  154. { value: "md", label: "Markdown" },
  155. ]
  156. export class CodeBlockView implements NodeView {
  157. dom: HTMLElement
  158. cmView: CodeMirrorView
  159. private pmView: EditorView
  160. private getPos: () => number
  161. private delegate: CodeBlockNavigationDelegate
  162. private languageCompartment = new Compartment()
  163. private lastLanguage = ""
  164. private langSelect: HTMLSelectElement
  165. private log: ReturnType<typeof createLogger>
  166. constructor(
  167. node: ProsemirrorNode,
  168. view: EditorView,
  169. getPos: () => number,
  170. delegate: CodeBlockNavigationDelegate,
  171. componentFilter?: string,
  172. ) {
  173. this.pmView = view
  174. this.getPos = getPos
  175. this.delegate = delegate
  176. this.log = instanceLogger(componentFilter)
  177. this.dom = document.createElement("div")
  178. this.dom.className = "cm-code-block"
  179. const header = document.createElement("div")
  180. header.className = "cm-code-block-header"
  181. this.langSelect = document.createElement("select")
  182. this.langSelect.className = "cm-code-block-lang-select"
  183. this.langSelect.addEventListener("mousedown", (e) => e.stopPropagation())
  184. const language = (node.attrs.language as string) || ""
  185. for (const opt of LANG_OPTIONS) {
  186. const option = document.createElement("option")
  187. option.value = opt.value
  188. option.textContent = opt.label
  189. this.langSelect.appendChild(option)
  190. }
  191. this.langSelect.value = normalizeLanguage(language)
  192. this.langSelect.addEventListener("change", () => {
  193. const body = this.cmView.state.doc.toString()
  194. this.delegate.requestChangeLanguage(
  195. this.langSelect.value,
  196. body,
  197. this.getPos(),
  198. )
  199. })
  200. const deleteBtn = document.createElement("button")
  201. deleteBtn.className = "cm-code-block-delete-btn"
  202. deleteBtn.textContent = "×"
  203. deleteBtn.title = "Delete code block"
  204. deleteBtn.addEventListener("mousedown", (e) => e.stopPropagation())
  205. deleteBtn.addEventListener("click", () => {
  206. this.delegate.requestRemoveBlock(this.getPos())
  207. })
  208. header.appendChild(this.langSelect)
  209. header.appendChild(deleteBtn)
  210. this.dom.appendChild(header)
  211. this.lastLanguage = language
  212. const langExt = resolveLanguage(language)
  213. this.log.info("constructor", {
  214. language,
  215. langExt: langExt?.constructor?.name ?? typeof langExt,
  216. })
  217. const exitKeymap = keymap.of([
  218. {
  219. key: "ArrowUp",
  220. run: (cm6View) => {
  221. const sel = cm6View.state.selection.main
  222. if (sel.from === 0 && sel.empty) {
  223. this.delegate.requestNavigateOut("up", this.getPos())
  224. return true
  225. }
  226. return false
  227. },
  228. },
  229. {
  230. key: "ArrowDown",
  231. run: (cm6View) => {
  232. const sel = cm6View.state.selection.main
  233. if (sel.to === cm6View.state.doc.length && sel.empty) {
  234. this.delegate.requestNavigateOut("down", this.getPos())
  235. return true
  236. }
  237. return false
  238. },
  239. },
  240. {
  241. key: "Shift-Enter",
  242. run: (cm6View) => {
  243. const sel = cm6View.state.selection.main
  244. if (sel.from === 0 && sel.empty) {
  245. this.delegate.requestInsertParagraphAround("before", this.getPos())
  246. return true
  247. }
  248. const docLen = cm6View.state.doc.length
  249. if (sel.from === docLen && sel.empty) {
  250. this.delegate.requestInsertParagraphAround("after", this.getPos())
  251. return true
  252. }
  253. return false
  254. },
  255. },
  256. {
  257. key: "Backspace",
  258. run: (cm6View) => {
  259. if (cm6View.state.doc.length === 0) {
  260. this.delegate.requestRemoveBlock(this.getPos())
  261. return true
  262. }
  263. return false
  264. },
  265. },
  266. ])
  267. const cmState = EditorState.create({
  268. doc: stripFences(node.textContent),
  269. extensions: [
  270. this.languageCompartment.of(langExt),
  271. exitKeymap,
  272. keymap.of([indentWithTab, ...defaultKeymap]),
  273. CodeMirrorView.lineWrapping,
  274. syntaxHighlighting(classHighlighter),
  275. ],
  276. })
  277. this.cmView = new CodeMirrorView({
  278. state: cmState,
  279. parent: this.dom,
  280. dispatch: (tr) => {
  281. this.cmView.update([tr])
  282. if (!tr.docChanged) return
  283. this.syncToPM()
  284. },
  285. })
  286. // Only auto-focus empty code blocks (user-created via auto-close).
  287. // Non-empty blocks loaded from content shouldn't steal the cursor.
  288. if (stripFences(node.textContent).length === 0) {
  289. requestAnimationFrame(() => this.cmView.focus())
  290. }
  291. }
  292. private syncToPM() {
  293. const content = this.cmView.state.doc.toString()
  294. const pos = this.getPos()
  295. const node = this.pmView.state.doc.nodeAt(pos)
  296. if (!node) return
  297. const language = node.attrs.language as string
  298. const fencedContent = addFences(content, language)
  299. if (node.textContent === fencedContent) return
  300. this.log.debug("syncToPM content sync", {
  301. pmLen: node.textContent.length,
  302. cmLen: content.length,
  303. })
  304. const textNode = this.pmView.state.schema.text(fencedContent)
  305. const tr = this.pmView.state.tr.replaceWith(
  306. pos + 1,
  307. pos + node.nodeSize - 1,
  308. textNode,
  309. )
  310. this.pmView.dispatch(tr)
  311. }
  312. update(node: ProsemirrorNode) {
  313. if (node.type.name !== "code_block") return false
  314. const language = (node.attrs.language as string) || ""
  315. const langExt = resolveLanguage(language)
  316. this.log.debug("update", {
  317. language,
  318. langExt: langExt?.constructor?.name ?? typeof langExt,
  319. contentMatch:
  320. this.cmView.state.doc.toString() === stripFences(node.textContent),
  321. })
  322. if (language !== this.lastLanguage) {
  323. this.lastLanguage = language
  324. this.langSelect.value = normalizeLanguage(language)
  325. this.cmView.dispatch({
  326. effects: this.languageCompartment.reconfigure(langExt),
  327. })
  328. }
  329. const body = stripFences(node.textContent)
  330. if (this.cmView.state.doc.toString() === body) return true
  331. this.cmView.dispatch({
  332. changes: { from: 0, to: this.cmView.state.doc.length, insert: body },
  333. })
  334. return true
  335. }
  336. destroy() {
  337. this.cmView.destroy()
  338. }
  339. stopEvent() {
  340. return true
  341. }
  342. }