| |
| |
| |
| |
| |
| |
| import { Node, InputRule } from '@tiptap/core' |
| import { TextSelection } from '@tiptap/pm/state' |
| import { el } from './common.js' |
|
|
| |
|
|
| 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) |
| }) |
| |
| |
| katexPromise.catch(() => { |
| katexPromise = null |
| }) |
| return katexPromise |
| } |
|
|
| |
| |
| 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 => { |
| |
| |
| katex.render(src, target, { displayMode: display, throwOnError: false, errorColor: 'var(--danger)', output: 'html' }) |
| target.classList.remove('math-raw') |
| }) |
| .catch(() => {}) |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| let insertedAt = 0 |
|
|
| function markUserInsert() { |
| insertedAt = Date.now() |
| } |
|
|
| function isUserInsert() { |
| return Date.now() - insertedAt < 400 |
| } |
|
|
| |
| |
| |
| 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 }) |
|
|
| |
| |
| |
| 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 || '' |
| |
| const size = () => (input.size = Math.max(6, Math.min(input.value.length + 2, 60))) |
| size() |
| input.oninput = size |
| input.focus() |
| input.select() |
| } |
|
|
| |
| |
| |
| |
| |
| 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() |
| }) |
| 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, |
| |
| 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 |
| }, |
| } |
| } |
| } |
|
|
| |
|
|
| 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 { |
| |
| |
| 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 [ |
| |
| |
| 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 { |
| |
| |
| 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 [ |
| |
| new InputRule({ |
| find: /^\$\$$/, |
| handler: ({ range, chain }) => { |
| markUserInsert() |
| chain().deleteRange(range).insertContent({ type: 'mathBlock', attrs: { latex: '' } }).run() |
| }, |
| }), |
| ] |
| }, |
| }) |
|
|
| |
| |
| 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 |
| } |
|
|