File size: 11,511 Bytes
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f361450
 
 
 
 
 
735a30f
 
f361450
735a30f
 
f361450
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f361450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f361450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// LaTeX math: an inline atom (`$x^2$`) and a display block (`$$...$$`), both
// holding their source in a `latex` attribute and typeset with KaTeX.
//
// KaTeX is ~270KB and most documents have no math, so it is NOT in the main
// bundle: the first math node on the page pulls it in, and every node renders
// its source as a placeholder until it lands.
import { Node, InputRule } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { el } from './common.js'

// --- the lazy KaTeX loader ---------------------------------------------------

let katexPromise = null

function assetVersion() {
  return window.__BOOT?.build_id ? `?v=${window.__BOOT.build_id}` : ''
}

export function loadKatex() {
  if (window.katex) return Promise.resolve(window.katex)
  if (katexPromise) return katexPromise
  katexPromise = new Promise((resolve, reject) => {
    const v = assetVersion()
    if (!document.getElementById('katex-css')) {
      const css = el('link', { id: 'katex-css', rel: 'stylesheet', href: `/katex.css${v}` })
      document.head.appendChild(css)
    }
    const script = document.createElement('script')
    script.src = `/katex.js${v}`
    script.onload = () => (window.katex ? resolve(window.katex) : reject(new Error('katex did not load')))
    script.onerror = () => reject(new Error('katex failed to load'))
    document.head.appendChild(script)
  })
  // a failed fetch must not poison every later attempt — the nodes stay
  // readable as source, and the next one retries
  katexPromise.catch(() => {
    katexPromise = null
  })
  return katexPromise
}

// Typeset `latex` into `target`. Renders the source as a placeholder first, so
// the formula is always readable — including when KaTeX never arrives.
export function renderMath(target, latex, { display = false } = {}) {
  const src = latex || ''
  target.textContent = display ? `$$${src}$$` : `$${src}$`
  target.classList.add('math-raw')
  if (!src.trim()) {
    target.classList.remove('math-raw')
    target.textContent = display ? '$$ $$' : 'empty formula'
    target.classList.add('math-empty')
    return
  }
  loadKatex()
    .then(katex => {
      // throwOnError:false makes KaTeX render bad input in red rather than
      // blowing up the editor; the message goes in the tooltip
      katex.render(src, target, { displayMode: display, throwOnError: false, errorColor: 'var(--danger)', output: 'html' })
      target.classList.remove('math-raw')
    })
    .catch(() => {}) // placeholder text stands
}

// --- shared node behaviour --------------------------------------------------

// A formula inserted by the user (toolbar, `$$`, `$…$`) should open its source
// editor immediately — an empty formula is useless until it has one. But a
// NodeView is also created whenever ProseMirror re-renders a node: after an
// undo, an accepted suggestion, a remote edit. Auto-opening on those would yank
// the caret out of whatever the user was typing, so the intent is recorded at
// the insertion site instead of guessed at render time.
let insertedAt = 0

function markUserInsert() {
  insertedAt = Date.now()
}

function isUserInsert() {
  return Date.now() - insertedAt < 400
}

// Clicking a formula opens its source in a plain input; Enter or blur commits,
// Escape reverts. Editing the source beats a WYSIWYG formula editor here: the
// source is what round-trips to markdown and what agents propose.
function mathNodeView({ display }) {
  return ({ node, getPos, editor }) => {
    let current = node
    let editing = false
    const wrap = el(display ? 'div' : 'span', {
      class: display ? 'math-block' : 'math-inline',
      contenteditable: 'false',
    })
    const view = el('span', { class: 'math-view' })
    const input = el('input', { class: 'math-src', spellcheck: 'false' })
    input.type = 'text'
    wrap.append(view, input)

    const paint = () => renderMath(view, current.attrs.latex, { display })

    // Leaving the source editor must land the caret just AFTER the formula.
    // Without this the selection stays on the atom itself, and the next Enter
    // splits the paragraph at the formula instead of starting a new line.
    const caretAfter = tr => {
      const pos = typeof getPos === 'function' ? getPos() : null
      if (pos == null) return tr
      const size = tr.doc.nodeAt(pos)?.nodeSize ?? 1
      try {
        return tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(pos + size, tr.doc.content.size))))
      } catch {
        return tr
      }
    }

    const stopEditing = ({ save }) => {
      if (!editing) return
      editing = false
      wrap.classList.remove('editing')
      const latex = input.value.trim()
      const pos = typeof getPos === 'function' ? getPos() : null
      let tr = editor.view.state.tr
      if (save && pos != null && latex !== current.attrs.latex) {
        tr = tr.setNodeMarkup(pos, undefined, { ...current.attrs, latex })
      }
      editor.view.dispatch(caretAfter(tr))
      paint()
    }

    const startEditing = () => {
      if (editing || !editor.isEditable) return
      editing = true
      wrap.classList.add('editing')
      input.value = current.attrs.latex || ''
      // width follows the source so the line does not jump around while typing
      const size = () => (input.size = Math.max(6, Math.min(input.value.length + 2, 60)))
      size()
      input.oninput = size
      input.focus()
      input.select()
    }

    // Editing opens on DOUBLE click. A single click has to stay with
    // ProseMirror: it is how you select the formula (to comment on it) and how a
    // drag across the line selects text through it. Opening the editor on a
    // single click stole both — a drag that happened to end on a formula lost
    // the selection, and the formula could not be selected at all.
    view.addEventListener('dblclick', e => {
      if (editing) return
      e.preventDefault()
      e.stopPropagation()
      startEditing()
    })
    view.title = 'Double-click to edit the LaTeX'
    input.addEventListener('keydown', e => {
      if (e.key === 'Enter') {
        e.preventDefault()
        stopEditing({ save: true })
        editor.view.focus()
      } else if (e.key === 'Escape') {
        e.preventDefault()
        stopEditing({ save: false })
        editor.view.focus()
      }
      e.stopPropagation() // the document's own shortcuts must not fire here
    })
    input.addEventListener('blur', () => stopEditing({ save: true }))

    paint()
    if (!current.attrs.latex && editor.isEditable && isUserInsert()) {
      setTimeout(() => {
        if (!current.attrs.latex && isUserInsert()) startEditing()
      }, 0)
    }
    return {
      dom: wrap,
      // the input is not part of the document — ProseMirror must keep out
      ignoreMutation: () => true,
      stopEvent: () => editing,
      selectNode() {
        wrap.classList.add('selected')
      },
      deselectNode() {
        wrap.classList.remove('selected')
      },
      update(updated) {
        if (updated.type.name !== current.type.name) return false
        const changed = updated.attrs.latex !== current.attrs.latex
        current = updated
        if (changed && !editing) paint()
        return true
      },
      destroy() {
        input.oninput = null
      },
    }
  }
}

// --- the nodes --------------------------------------------------------------

export const MathInline = Node.create({
  name: 'mathInline',
  group: 'inline',
  inline: true,
  atom: true,
  selectable: true,
  addAttributes() {
    return { latex: { default: '' } }
  },
  parseHTML() {
    return [{ tag: 'span[data-math]', getAttrs: n => ({ latex: n.getAttribute('data-latex') || '' }) }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['span', { 'data-math': 'inline', 'data-latex': HTMLAttributes.latex || '' }]
  },
  addKeyboardShortcuts() {
    return {
      // a selected formula opens its source with Enter — double-click is not the
      // only way in, and this is the only way in from the keyboard
      Enter: () => {
        const { selection } = this.editor.state
        if (selection.node?.type?.name !== this.name) return false
        const dom = this.editor.view.nodeDOM(selection.from)
        const target = dom?.querySelector?.('.math-view')
        if (!target) return false
        target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
        return true
      },
    }
  },
  addNodeView() {
    return mathNodeView({ display: false })
  },
  addCommands() {
    return {
      insertMathInline:
        (latex = '') =>
        ({ chain }) => {
          if (!latex) markUserInsert()
          return chain().insertContent({ type: 'mathInline', attrs: { latex } }).run()
        },
    }
  },
  addInputRules() {
    return [
      // typing `$x^2$` turns into a formula as soon as the closing $ lands.
      // Same strictness as the markdown tokenizer, so money is left alone.
      new InputRule({
        find: /(?<!\$)\$(?![\s$])([^$\n]*[^\s$])\$$/,
        handler: ({ state, range, match, chain }) => {
          const latex = match[1]
          if (!latex.trim()) return
          chain()
            .deleteRange(range)
            .insertContent({ type: 'mathInline', attrs: { latex } })
            .run()
          void state
        },
      }),
    ]
  },
})

export const MathBlock = Node.create({
  name: 'mathBlock',
  group: 'block',
  atom: true,
  selectable: true,
  draggable: true,
  addAttributes() {
    return { latex: { default: '' } }
  },
  parseHTML() {
    return [{ tag: 'div[data-math-block]', getAttrs: n => ({ latex: n.getAttribute('data-latex') || '' }) }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['div', { 'data-math-block': '', 'data-latex': HTMLAttributes.latex || '' }]
  },
  addKeyboardShortcuts() {
    return {
      // a selected formula opens its source with Enter — double-click is not the
      // only way in, and this is the only way in from the keyboard
      Enter: () => {
        const { selection } = this.editor.state
        if (selection.node?.type?.name !== this.name) return false
        const dom = this.editor.view.nodeDOM(selection.from)
        const target = dom?.querySelector?.('.math-view')
        if (!target) return false
        target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
        return true
      },
    }
  },
  addNodeView() {
    return mathNodeView({ display: true })
  },
  addCommands() {
    return {
      insertMathBlock:
        (latex = '') =>
        ({ chain }) => {
          if (!latex) markUserInsert()
          return chain().insertContent({ type: 'mathBlock', attrs: { latex } }).run()
        },
    }
  },
  addInputRules() {
    return [
      // `$$` on an empty line opens a display formula ready to type into
      new InputRule({
        find: /^\$\$$/,
        handler: ({ range, chain }) => {
          markUserInsert()
          chain().deleteRange(range).insertContent({ type: 'mathBlock', attrs: { latex: '' } }).run()
        },
      }),
    ]
  },
})

// Preview/insertion panels build plain HTML rather than a live editor, so they
// need a detached element that typesets itself once KaTeX is around.
export function mathPreviewEl(latex, { display = false } = {}) {
  const wrap = el(display ? 'div' : 'span', { class: display ? 'math-block' : 'math-inline' })
  const view = el('span', { class: 'math-view' })
  wrap.appendChild(view)
  renderMath(view, latex, { display })
  return wrap
}