|
|
@@ -6,19 +6,10 @@ 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 { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"
|
|
|
-import {
|
|
|
- Compartment,
|
|
|
- EditorState,
|
|
|
- type Range,
|
|
|
- StateField,
|
|
|
-} from "@codemirror/state"
|
|
|
-import type { DecorationSet } from "@codemirror/view"
|
|
|
-import {
|
|
|
- EditorView as CodeMirrorView,
|
|
|
- Decoration,
|
|
|
- keymap,
|
|
|
-} from "@codemirror/view"
|
|
|
+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 { TextSelection } from "prosemirror-state"
|
|
|
import type { EditorView, NodeView } from "prosemirror-view"
|
|
|
@@ -28,33 +19,71 @@ function instanceLogger(componentFilter?: string) {
|
|
|
return createLogger("CodeBlockView", componentFilter)
|
|
|
}
|
|
|
|
|
|
-const FENCE_RE = /^(```|~~~)\s*(\w*)/
|
|
|
-
|
|
|
-function fenceMarkerExtension() {
|
|
|
- const fenceLineDeco = Decoration.line({ class: "cm-fence-marker" })
|
|
|
- const CLOSE_FENCE_RE = /^(```|~~~)\s*$/
|
|
|
- function buildDecos(state: EditorState) {
|
|
|
- const decos: Range<Decoration>[] = []
|
|
|
- const doc = state.doc
|
|
|
- if (doc.lines >= 2) {
|
|
|
- const first = doc.line(1)
|
|
|
- if (FENCE_RE.test(first.text)) decos.push(fenceLineDeco.range(first.from))
|
|
|
- const last = doc.line(doc.lines)
|
|
|
- if (CLOSE_FENCE_RE.test(last.text))
|
|
|
- decos.push(fenceLineDeco.range(last.from))
|
|
|
- }
|
|
|
- return Decoration.set(decos)
|
|
|
+/**
|
|
|
+ * 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"
|
|
|
+ */
|
|
|
+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.
|
|
|
+ */
|
|
|
+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 ""
|
|
|
}
|
|
|
- return StateField.define<DecorationSet>({
|
|
|
- create(state) {
|
|
|
- return buildDecos(state)
|
|
|
- },
|
|
|
- update(decos, tr) {
|
|
|
- if (!tr.docChanged) return decos
|
|
|
- return buildDecos(tr.state)
|
|
|
- },
|
|
|
- provide: (f) => CodeMirrorView.decorations.from(f),
|
|
|
- })
|
|
|
}
|
|
|
|
|
|
function resolveLanguage(lang: string) {
|
|
|
@@ -92,6 +121,19 @@ function resolveLanguage(lang: string) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+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
|
|
|
@@ -99,6 +141,7 @@ export class CodeBlockView implements NodeView {
|
|
|
private getPos: () => number
|
|
|
private languageCompartment = new Compartment()
|
|
|
private lastLanguage = ""
|
|
|
+ private langSelect: HTMLSelectElement
|
|
|
private log: ReturnType<typeof createLogger>
|
|
|
|
|
|
constructor(
|
|
|
@@ -114,7 +157,39 @@ export class CodeBlockView implements NodeView {
|
|
|
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", () => {
|
|
|
+ this.changeLanguage(this.langSelect.value)
|
|
|
+ })
|
|
|
+
|
|
|
+ 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.removeBlock()
|
|
|
+ })
|
|
|
+
|
|
|
+ header.appendChild(this.langSelect)
|
|
|
+ header.appendChild(deleteBtn)
|
|
|
+ this.dom.appendChild(header)
|
|
|
+
|
|
|
this.lastLanguage = language
|
|
|
const langExt = resolveLanguage(language)
|
|
|
this.log.info("constructor", {
|
|
|
@@ -144,13 +219,10 @@ export class CodeBlockView implements NodeView {
|
|
|
},
|
|
|
},
|
|
|
{
|
|
|
- key: "Enter",
|
|
|
+ key: "Backspace",
|
|
|
run: (cm6View) => {
|
|
|
- const sel = cm6View.state.selection.main
|
|
|
- if (!sel.empty) return false
|
|
|
- const line = cm6View.state.doc.lineAt(sel.head)
|
|
|
- if (line.to === sel.head && /^```\s*$/.test(line.text)) {
|
|
|
- return this.exitBlock("down")
|
|
|
+ if (cm6View.state.doc.length === 0) {
|
|
|
+ return this.removeBlock()
|
|
|
}
|
|
|
return false
|
|
|
},
|
|
|
@@ -158,14 +230,13 @@ export class CodeBlockView implements NodeView {
|
|
|
])
|
|
|
|
|
|
const cmState = EditorState.create({
|
|
|
- doc: node.textContent,
|
|
|
+ doc: stripFences(node.textContent),
|
|
|
extensions: [
|
|
|
this.languageCompartment.of(langExt),
|
|
|
exitKeymap,
|
|
|
keymap.of([indentWithTab, ...defaultKeymap]),
|
|
|
CodeMirrorView.lineWrapping,
|
|
|
- syntaxHighlighting(defaultHighlightStyle),
|
|
|
- fenceMarkerExtension(),
|
|
|
+ syntaxHighlighting(classHighlighter),
|
|
|
],
|
|
|
})
|
|
|
|
|
|
@@ -179,32 +250,13 @@ export class CodeBlockView implements NodeView {
|
|
|
},
|
|
|
})
|
|
|
|
|
|
- // Place CM6 cursor at the end of the first line (after the opening fence
|
|
|
- // marker), and focus CM6 so keyboard input lands in the code block.
|
|
|
- const content = node.textContent
|
|
|
- const firstLine = content.split("\n")[0] ?? ""
|
|
|
- if (FENCE_RE.test(firstLine)) {
|
|
|
- const cursorPos = firstLine.length
|
|
|
- this.cmView.dispatch({
|
|
|
- selection: { anchor: cursorPos, head: cursorPos },
|
|
|
- })
|
|
|
+ // 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 convertToParagraph(content: string) {
|
|
|
- this.log.info("convertToParagraph", { content: content.slice(0, 40) })
|
|
|
- const pos = this.getPos()
|
|
|
- const node = this.pmView.state.doc.nodeAt(pos)
|
|
|
- if (!node || node.type.name !== "code_block") return
|
|
|
- const para = this.pmView.state.schema.nodes.paragraph!.create(
|
|
|
- null,
|
|
|
- this.pmView.state.schema.text(content),
|
|
|
- )
|
|
|
- const tr = this.pmView.state.tr.replaceWith(pos, pos + node.nodeSize, para)
|
|
|
- this.pmView.dispatch(tr)
|
|
|
- }
|
|
|
-
|
|
|
private exitBlock(dir: "up" | "down"): boolean {
|
|
|
const pos = this.getPos()
|
|
|
const node = this.pmView.state.doc.nodeAt(pos)
|
|
|
@@ -223,41 +275,77 @@ export class CodeBlockView implements NodeView {
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
- private syncToPM() {
|
|
|
- const content = this.cmView.state.doc.toString()
|
|
|
- const firstLine = content.split("\n")[0] ?? ""
|
|
|
+ private removeBlock(): boolean {
|
|
|
+ const pos = this.getPos()
|
|
|
+ const node = this.pmView.state.doc.nodeAt(pos)
|
|
|
+ if (!node) return false
|
|
|
|
|
|
- // If the first line no longer starts with a fence marker,
|
|
|
- // revert the code block back to a regular paragraph.
|
|
|
- if (!FENCE_RE.test(firstLine)) {
|
|
|
- this.convertToParagraph(content)
|
|
|
- return
|
|
|
+ // Replacing the only node with a paragraph is safer than deleting it,
|
|
|
+ // since ProseMirror requires at least one block in the document.
|
|
|
+ if (this.pmView.state.doc.childCount === 1) {
|
|
|
+ const para = this.pmView.state.schema.nodes.paragraph!.create()
|
|
|
+ const tr = this.pmView.state.tr.replaceWith(
|
|
|
+ pos,
|
|
|
+ pos + node.nodeSize,
|
|
|
+ para,
|
|
|
+ )
|
|
|
+ this.pmView.dispatch(tr)
|
|
|
+ this.pmView.focus()
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
- const language = firstLine.match(FENCE_RE)?.[2] ?? ""
|
|
|
+ const tr = this.pmView.state.tr.delete(pos, pos + node.nodeSize)
|
|
|
+ const $pos = tr.doc.resolve(pos)
|
|
|
+ const sel = TextSelection.near($pos, -1)
|
|
|
+ if (!sel) return false
|
|
|
+ this.pmView.dispatch(tr.setSelection(sel).scrollIntoView())
|
|
|
+ this.pmView.focus()
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ private changeLanguage(newLanguage: string) {
|
|
|
+ const pos = this.getPos()
|
|
|
+ const node = this.pmView.state.doc.nodeAt(pos)
|
|
|
+ if (!node) return
|
|
|
+
|
|
|
+ const body = this.cmView.state.doc.toString()
|
|
|
+ const fencedContent = addFences(body, newLanguage)
|
|
|
+ const textNode = this.pmView.state.schema.text(fencedContent)
|
|
|
+
|
|
|
+ const tr = this.pmView.state.tr
|
|
|
+ .setNodeAttribute(pos, "language", newLanguage)
|
|
|
+ .replaceWith(pos + 1, pos + node.nodeSize - 1, textNode)
|
|
|
+
|
|
|
+ this.log.info("changeLanguage", {
|
|
|
+ from: node.attrs.language,
|
|
|
+ to: newLanguage,
|
|
|
+ })
|
|
|
+
|
|
|
+ this.pmView.dispatch(tr)
|
|
|
+ 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 pmText = node.textContent
|
|
|
- if (pmText === content && node.attrs.language === language) return
|
|
|
- const textNode = this.pmView.state.schema.text(content)
|
|
|
- let tr = this.pmView.state.tr.replaceWith(
|
|
|
+
|
|
|
+ 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,
|
|
|
)
|
|
|
- if (node.attrs.language !== language) {
|
|
|
- this.log.info("syncToPM language change", {
|
|
|
- from: node.attrs.language,
|
|
|
- to: language,
|
|
|
- })
|
|
|
- tr = tr.setNodeAttribute(pos, "language", language)
|
|
|
- } else {
|
|
|
- this.log.debug("syncToPM content sync", {
|
|
|
- pmLen: pmText.length,
|
|
|
- cmLen: content.length,
|
|
|
- })
|
|
|
- }
|
|
|
this.pmView.dispatch(tr)
|
|
|
}
|
|
|
|
|
|
@@ -269,23 +357,22 @@ export class CodeBlockView implements NodeView {
|
|
|
this.log.debug("update", {
|
|
|
language,
|
|
|
langExt: langExt?.constructor?.name ?? typeof langExt,
|
|
|
- contentMatch: this.cmView.state.doc.toString() === node.textContent,
|
|
|
+ contentMatch:
|
|
|
+ this.cmView.state.doc.toString() === stripFences(node.textContent),
|
|
|
})
|
|
|
|
|
|
- // Only reconfigure the language compartment when the language
|
|
|
- // actually changed — avoids re-entering the CM6 dispatch callback
|
|
|
- // and breaking the syncToPM → update → syncToPM cycle.
|
|
|
if (language !== this.lastLanguage) {
|
|
|
this.lastLanguage = language
|
|
|
+ this.langSelect.value = normalizeLanguage(language)
|
|
|
this.cmView.dispatch({
|
|
|
effects: this.languageCompartment.reconfigure(langExt),
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- const content = node.textContent
|
|
|
- if (this.cmView.state.doc.toString() === content) return true
|
|
|
+ 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: content },
|
|
|
+ changes: { from: 0, to: this.cmView.state.doc.length, insert: body },
|
|
|
})
|
|
|
return true
|
|
|
}
|