lvwerra HF Staff commited on
Commit
735a30f
·
verified ·
1 Parent(s): ec6b346

LaTeX math via lazily-loaded KaTeX ($x$ inline, $$...$$ display), agent-proposable; Enter sends a comment, Shift+Enter adds a line

Browse files
client/app.css CHANGED
@@ -334,7 +334,10 @@ textarea { resize: vertical; }
334
  .chip.done::before { background: var(--accent); }
335
  .chip.failed { background: var(--chip-bg); color: var(--text); text-decoration: line-through; }
336
  .reply-row { display: flex; gap: 6px; margin-top: 10px; }
337
- .reply-row input { font-size: 13px; }
 
 
 
338
  .card .actions { display: flex; gap: 8px; margin-top: 10px; justify-content: flex-end; align-items: center; }
339
 
340
  /* card header actions (accept / reject — visible even collapsed) */
@@ -400,6 +403,37 @@ textarea { resize: vertical; }
400
  /* suggesting mode: the document is read-only, so the text cursor would lie */
401
  body.suggesting .tiptap { caret-color: transparent; cursor: default; }
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  /* --- loading skeletons -----------------------------------------------------
404
  Shown from the first paint (they are in the HTML, not built by JS) so a slow
405
  load looks like it is working rather than finished-and-empty. */
@@ -982,7 +1016,7 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
982
  .card { padding: 12px 14px; }
983
 
984
  /* 16px+ inputs: anything smaller makes iOS Safari zoom in on focus */
985
- input, textarea, .composer-box textarea, .composer-box input, .reply-row input { font-size: 16px; }
986
  #suggest-text { min-height: 30vh; }
987
 
988
  /* --- selection menu: thumb-sized --- */
 
334
  .chip.done::before { background: var(--accent); }
335
  .chip.failed { background: var(--chip-bg); color: var(--text); text-decoration: line-through; }
336
  .reply-row { display: flex; gap: 6px; margin-top: 10px; }
337
+ .reply-row textarea {
338
+ font-size: 13px; resize: none; overflow-y: auto; min-height: 32px;
339
+ line-height: 1.45; /* one line at rest, grows to ~4 with Shift+Enter */
340
+ }
341
  .card .actions { display: flex; gap: 8px; margin-top: 10px; justify-content: flex-end; align-items: center; }
342
 
343
  /* card header actions (accept / reject — visible even collapsed) */
 
403
  /* suggesting mode: the document is read-only, so the text cursor would lie */
404
  body.suggesting .tiptap { caret-color: transparent; cursor: default; }
405
 
406
+ /* --- math (KaTeX) ----------------------------------------------------------
407
+ Formulas are atoms: one click opens the LaTeX source in an input, because the
408
+ source is what round-trips to markdown and what agents propose. */
409
+ .math-inline { display: inline; border-radius: 3px; }
410
+ .math-block {
411
+ display: block; margin: 0.75em 0; text-align: center;
412
+ padding: 4px 0; overflow-x: auto; overflow-y: hidden;
413
+ }
414
+ /* only the formula itself is clickable — a display block is as wide as the
415
+ text column, and clicking empty space beside it must still move the caret */
416
+ .math-view { cursor: pointer; border-radius: 3px; padding: 0 2px; }
417
+ .math-block .math-view { display: inline-block; }
418
+ .math-view:hover { background: var(--hl-weak); }
419
+ .math-inline.selected .math-view, .math-block.selected .math-view { background: var(--hl); outline: 1px solid var(--hl-edge); }
420
+ /* before KaTeX lands (and if it never does) the source stays readable */
421
+ .math-view.math-raw { font-family: var(--mono); font-size: 0.92em; color: var(--text-2); }
422
+ .math-view.math-empty { font-family: var(--ui); font-size: 0.85em; color: var(--text-3); }
423
+ /* the source editor: shown in place of the rendering, never both */
424
+ .math-src { display: none; }
425
+ .math-inline.editing .math-view, .math-block.editing .math-view { display: none; }
426
+ .math-inline.editing .math-src, .math-block.editing .math-src {
427
+ display: inline-block; font-family: var(--mono); font-size: 0.92em;
428
+ border: 1px solid var(--accent); border-radius: 4px; padding: 1px 5px;
429
+ background: var(--input-bg); color: var(--text); outline: none; min-width: 60px;
430
+ }
431
+ .math-block.editing .math-src { display: block; margin: 0 auto; width: min(100%, 46em); text-align: center; }
432
+ .math-tool-label { font-family: var(--doc); font-size: 15px; line-height: 1; }
433
+ /* KaTeX renders its own colours; only the surroundings are ours */
434
+ .katex { font-size: 1.06em; }
435
+ .katex-display { margin: 0; }
436
+
437
  /* --- loading skeletons -----------------------------------------------------
438
  Shown from the first paint (they are in the HTML, not built by JS) so a slow
439
  load looks like it is working rather than finished-and-empty. */
 
1016
  .card { padding: 12px 14px; }
1017
 
1018
  /* 16px+ inputs: anything smaller makes iOS Safari zoom in on focus */
1019
+ input, textarea, .composer-box textarea, .composer-box input, .reply-row textarea { font-size: 16px; }
1020
  #suggest-text { min-height: 30vh; }
1021
 
1022
  /* --- selection menu: thumb-sized --- */
client/doc.html CHANGED
@@ -127,7 +127,7 @@
127
  <div id="margin-items">
128
  <div id="composer" class="composer-box hidden">
129
  <blockquote id="composer-quote"></blockquote>
130
- <textarea id="composer-text" rows="3" placeholder="Comment — mention an agent with @handle"></textarea>
131
  <div id="composer-mentions" class="mention-menu hidden"></div>
132
  <div class="row">
133
  <button class="btn small ghost" id="composer-cancel">Cancel</button>
 
127
  <div id="margin-items">
128
  <div id="composer" class="composer-box hidden">
129
  <blockquote id="composer-quote"></blockquote>
130
+ <textarea id="composer-text" rows="3" placeholder="Comment — @handle to hand it to an agent. Enter sends, Shift+Enter for a new line."></textarea>
131
  <div id="composer-mentions" class="mention-menu hidden"></div>
132
  <div class="row">
133
  <button class="btn small ghost" id="composer-cancel">Cancel</button>
client/src/diff.js CHANGED
@@ -6,6 +6,7 @@
6
  export function flattenMarkdown(md) {
7
  return String(md || '')
8
  .replace(/```\w*\n?([\s\S]*?)```/g, '$1')
 
9
  .replace(/!\[[^\]]*\]\([^)]*\)/g, '[figure]')
10
  .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
11
  .replace(/\*\*([^*]+)\*\*/g, '$1')
@@ -20,8 +21,13 @@ export function flattenMarkdown(md) {
20
  .trim()
21
  }
22
 
 
 
 
 
 
23
  function tokenize(s) {
24
- return s.match(/\S+|\s+/g) || []
25
  }
26
 
27
  // Word diff over *styled* segments: [{ text, markKey }], where markKey encodes
 
6
  export function flattenMarkdown(md) {
7
  return String(md || '')
8
  .replace(/```\w*\n?([\s\S]*?)```/g, '$1')
9
+ .replace(/^\$\$\n?([\s\S]*?)\n?\$\$$/gm, '$1') // display math -> its source
10
  .replace(/!\[[^\]]*\]\([^)]*\)/g, '[figure]')
11
  .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
12
  .replace(/\*\*([^*]+)\*\*/g, '$1')
 
21
  .trim()
22
  }
23
 
24
+ // A `$...$` formula is ONE token: its source contains spaces (`\|`, `x + y`), so
25
+ // splitting on whitespace let the diff align *inside* a formula and render half
26
+ // of its LaTeX as inserted text.
27
+ const TOKEN_RE = /(?<!\$)\$(?![\s$])[^$\n]*[^\s$]\$(?!\$)|\S+|\s+/g
28
+
29
  function tokenize(s) {
30
+ return s.match(TOKEN_RE) || []
31
  }
32
 
33
  // Word diff over *styled* segments: [{ text, markKey }], where markKey encodes
client/src/doc.js CHANGED
@@ -7,6 +7,7 @@ import CollaborationCaret from '@tiptap/extension-collaboration-caret'
7
  import Image from '@tiptap/extension-image'
8
  import { TaskList, TaskItem } from '@tiptap/extension-list'
9
  import { InputRule } from '@tiptap/core'
 
10
 
11
  // Typing "[ ] " / "[x] " at the start of a paragraph inside a bullet list item
12
  // (i.e. the natural "- [ ] ") lifts that item out and turns the list into a
@@ -1145,6 +1146,7 @@ async function openProposalPreview(pid) {
1145
  bar.appendChild(actions)
1146
  const content = el('div', { class: 'proposal-content tiptap' })
1147
  content.innerHTML = markdownToBlocks(p.content_markdown).map(blockToHtml).join('')
 
1148
  view.replaceChildren(bar, content)
1149
  }
1150
 
@@ -1218,6 +1220,8 @@ function buildEditor(provider) {
1218
  StarterKit.configure({ undoRedo: false, link: { openOnClick: false } }),
1219
  LayoutImage,
1220
  HtmlBlock,
 
 
1221
  CheckboxInList,
1222
  TaskItem.configure({ nested: false }),
1223
  Table.configure({ resizable: false }),
@@ -1459,7 +1463,12 @@ function sliceSegments(segments, from, to) {
1459
  const end = pos + seg.text.length
1460
  const s = Math.max(from, pos)
1461
  const e = Math.min(to, end)
1462
- if (e > s) out.push({ text: seg.text.slice(s - pos, e - pos), attrs: seg.attrs })
 
 
 
 
 
1463
  pos = end
1464
  if (pos >= to) break
1465
  }
@@ -1484,10 +1493,12 @@ function insWidget(suggId, segments, A, listType = null) {
1484
  const piece = document.createElement('span')
1485
  piece.className = i === 0 ? 'sugg-ins-seg' : 'sugg-ins-li' + (listType === 'orderedList' ? ' ordered' : '')
1486
  piece.innerHTML = inlineHtml(line)
 
1487
  span.appendChild(piece)
1488
  })
1489
  } else {
1490
  span.innerHTML = inlineHtml(segments)
 
1491
  }
1492
  wirePreviewLinks(span, suggId)
1493
  return span
@@ -1588,6 +1599,7 @@ function blockInsWidget(suggId, blocks, A) {
1588
  div.className = 'sugg-ins-block' + A
1589
  div.dataset.sugg = suggId
1590
  div.innerHTML = blocks.map(blockToHtml).join('')
 
1591
  div.addEventListener('click', () => setActive(suggId))
1592
  wirePreviewLinks(div, suggId)
1593
  return div
@@ -1613,12 +1625,20 @@ function blockCharMap(node, pos) {
1613
  push('[figure]', '', () => pos)
1614
  return { text, map, segs }
1615
  }
 
 
 
 
 
1616
  node.descendants((child, childPos) => {
1617
  const base = pos + 1 + childPos
1618
  if (child.isText) {
1619
  push(child.text, pmMarkKey(child.marks), c => base + c)
1620
  } else if (child.type.name === 'image') {
1621
  push('[figure]', '', () => base)
 
 
 
1622
  } else if (child.isBlock && text && !text.endsWith('\n')) {
1623
  push('\n', '', () => base)
1624
  }
@@ -1661,6 +1681,8 @@ function blockDescSegments(b) {
1661
  case 'codeBlock':
1662
  case 'htmlBlock':
1663
  return [plain(b.text || '')]
 
 
1664
  case 'image':
1665
  return [plain('[figure]')]
1666
  case 'horizontalRule':
@@ -1710,6 +1732,8 @@ function withMarkKeys(segments) {
1710
  function inlineHtml(segments) {
1711
  return (segments || [])
1712
  .map(s => {
 
 
1713
  let h = esc(s.text)
1714
  const a = s.attrs || {}
1715
  if (a.code) h = `<code>${h}</code>`
@@ -1722,6 +1746,15 @@ function inlineHtml(segments) {
1722
  .join('')
1723
  }
1724
 
 
 
 
 
 
 
 
 
 
1725
  function blockToHtml(b) {
1726
  switch (b.type) {
1727
  case 'heading':
@@ -1744,6 +1777,8 @@ function blockToHtml(b) {
1744
  return `<iframe class="html-embed" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer" loading="lazy" srcdoc="${esc(buildEmbedSrcdoc(b.text || ''))}"></iframe>`
1745
  case 'blockquote':
1746
  return `<blockquote><p>${inlineHtml(b.inline)}</p></blockquote>`
 
 
1747
  case 'horizontalRule':
1748
  return '<hr>'
1749
  case 'image':
@@ -1850,6 +1885,33 @@ function jumpTo(editor, range) {
1850
 
1851
  // --- header toolbar -----------------------------------------------------------
1852
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1853
  function buildHeaderTools(editor) {
1854
  const bar = document.getElementById('header-tools')
1855
  bar.replaceChildren() // rebuilt per page switch
@@ -1891,6 +1953,9 @@ function buildHeaderTools(editor) {
1891
  btn(icon('todo'), 'Checklist', () => editor.chain().focus().toggleTaskList().run(), () => editor.isActive('taskList')),
1892
  btn(icon('table'), 'Insert table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()),
1893
  btn('—', 'Divider', () => editor.chain().focus().setHorizontalRule().run()),
 
 
 
1894
  btn('HTML', 'Embed live HTML (sandboxed)', () =>
1895
  editHtmlOverlay('', html => {
1896
  if (html.trim()) editor.chain().focus().insertContent({ type: 'htmlBlock', attrs: { html } }).run()
@@ -2161,6 +2226,15 @@ function wireComposer() {
2161
  state.autoComposer = null
2162
  renderMargin(window.__editor)
2163
  })
 
 
 
 
 
 
 
 
 
2164
  document.getElementById('composer-send').addEventListener('click', () => {
2165
  const value = text.value.trim()
2166
  // the box comes pre-mentioned, so a bare "@handle" counts as empty
@@ -2555,17 +2629,28 @@ function messageEl(msg) {
2555
 
2556
  function replyRow(placeholder, onSend) {
2557
  const row = el('div', { class: 'reply-row' })
2558
- const input = el('input', { placeholder })
 
 
2559
  const menu = el('div', { class: 'mention-menu hidden' })
2560
  const send = el('button', { class: 'iconbtn', title: 'Send' })
2561
  send.appendChild(icon('send'))
 
 
 
 
2562
  send.addEventListener('click', () => {
2563
  if (!input.value.trim()) return
2564
  onSend(input.value.trim())
2565
  input.value = ''
 
2566
  })
 
2567
  input.addEventListener('keydown', e => {
2568
- if (e.key === 'Enter' && menu.classList.contains('hidden')) send.click()
 
 
 
2569
  })
2570
  row.append(input, send)
2571
  wireMentionMenu(input, menu)
@@ -2735,15 +2820,22 @@ function wireUndoReopens(editor) {
2735
  function inlineToPmContent(segments) {
2736
  return (segments || [])
2737
  .filter(seg => seg.text)
2738
- .map(seg => ({
2739
- type: 'text',
2740
- text: seg.text,
2741
- marks: Object.entries(seg.attrs || {}).map(([type, attrs]) => ({ type, attrs })),
2742
- }))
 
 
 
 
 
2743
  }
2744
 
2745
  function blockToPmJSON(block) {
2746
  switch (block.type) {
 
 
2747
  case 'heading':
2748
  return { type: 'heading', attrs: { level: block.attrs?.level || 1 }, content: inlineToPmContent(block.inline) }
2749
  case 'codeBlock':
 
7
  import Image from '@tiptap/extension-image'
8
  import { TaskList, TaskItem } from '@tiptap/extension-list'
9
  import { InputRule } from '@tiptap/core'
10
+ import { MathInline, MathBlock, mathPreviewEl, renderMath, loadKatex } from './math.js'
11
 
12
  // Typing "[ ] " / "[x] " at the start of a paragraph inside a bullet list item
13
  // (i.e. the natural "- [ ] ") lifts that item out and turns the list into a
 
1146
  bar.appendChild(actions)
1147
  const content = el('div', { class: 'proposal-content tiptap' })
1148
  content.innerHTML = markdownToBlocks(p.content_markdown).map(blockToHtml).join('')
1149
+ typesetMathIn(content)
1150
  view.replaceChildren(bar, content)
1151
  }
1152
 
 
1220
  StarterKit.configure({ undoRedo: false, link: { openOnClick: false } }),
1221
  LayoutImage,
1222
  HtmlBlock,
1223
+ MathInline,
1224
+ MathBlock,
1225
  CheckboxInList,
1226
  TaskItem.configure({ nested: false }),
1227
  Table.configure({ resizable: false }),
 
1463
  const end = pos + seg.text.length
1464
  const s = Math.max(from, pos)
1465
  const e = Math.min(to, end)
1466
+ // a formula slice keeps its latex only when the WHOLE formula is inside the
1467
+ // range: half a formula is not a formula, and renders as its source
1468
+ if (e > s) {
1469
+ const whole = s === pos && e === end
1470
+ out.push({ text: seg.text.slice(s - pos, e - pos), attrs: seg.attrs, ...(seg.math != null && whole ? { math: seg.math } : {}) })
1471
+ }
1472
  pos = end
1473
  if (pos >= to) break
1474
  }
 
1493
  const piece = document.createElement('span')
1494
  piece.className = i === 0 ? 'sugg-ins-seg' : 'sugg-ins-li' + (listType === 'orderedList' ? ' ordered' : '')
1495
  piece.innerHTML = inlineHtml(line)
1496
+ typesetMathIn(piece)
1497
  span.appendChild(piece)
1498
  })
1499
  } else {
1500
  span.innerHTML = inlineHtml(segments)
1501
+ typesetMathIn(span)
1502
  }
1503
  wirePreviewLinks(span, suggId)
1504
  return span
 
1599
  div.className = 'sugg-ins-block' + A
1600
  div.dataset.sugg = suggId
1601
  div.innerHTML = blocks.map(blockToHtml).join('')
1602
+ typesetMathIn(div)
1603
  div.addEventListener('click', () => setActive(suggId))
1604
  wirePreviewLinks(div, suggId)
1605
  return div
 
1625
  push('[figure]', '', () => pos)
1626
  return { text, map, segs }
1627
  }
1628
+ // a display formula's text is its source, every char anchored to the node
1629
+ if (node.type.name === 'mathBlock') {
1630
+ push(node.attrs.latex || '', '', () => pos)
1631
+ return { text, map, segs }
1632
+ }
1633
  node.descendants((child, childPos) => {
1634
  const base = pos + 1 + childPos
1635
  if (child.isText) {
1636
  push(child.text, pmMarkKey(child.marks), c => base + c)
1637
  } else if (child.type.name === 'image') {
1638
  push('[figure]', '', () => base)
1639
+ } else if (child.type.name === 'mathInline') {
1640
+ // matches the markdown side's `$latex$`, so an edited formula diffs as text
1641
+ push('$' + (child.attrs.latex || '') + '$', '', () => base)
1642
  } else if (child.isBlock && text && !text.endsWith('\n')) {
1643
  push('\n', '', () => base)
1644
  }
 
1681
  case 'codeBlock':
1682
  case 'htmlBlock':
1683
  return [plain(b.text || '')]
1684
+ case 'mathBlock':
1685
+ return [plain(b.attrs?.latex || '')]
1686
  case 'image':
1687
  return [plain('[figure]')]
1688
  case 'horizontalRule':
 
1732
  function inlineHtml(segments) {
1733
  return (segments || [])
1734
  .map(s => {
1735
+ // typeset after the string is in the DOM — see typesetMathIn()
1736
+ if (s.math != null) return `<span class="math-inline" data-latex="${esc(s.math)}"><span class="math-view"></span></span>`
1737
  let h = esc(s.text)
1738
  const a = s.attrs || {}
1739
  if (a.code) h = `<code>${h}</code>`
 
1746
  .join('')
1747
  }
1748
 
1749
+ // Preview HTML is built as a string, so formulas arrive as placeholders that
1750
+ // carry their source. This turns them into real typeset math once mounted.
1751
+ function typesetMathIn(root) {
1752
+ for (const node of root.querySelectorAll('[data-latex] > .math-view')) {
1753
+ const host = node.parentElement
1754
+ renderMath(node, host.dataset.latex, { display: host.classList.contains('math-block') })
1755
+ }
1756
+ }
1757
+
1758
  function blockToHtml(b) {
1759
  switch (b.type) {
1760
  case 'heading':
 
1777
  return `<iframe class="html-embed" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer" loading="lazy" srcdoc="${esc(buildEmbedSrcdoc(b.text || ''))}"></iframe>`
1778
  case 'blockquote':
1779
  return `<blockquote><p>${inlineHtml(b.inline)}</p></blockquote>`
1780
+ case 'mathBlock':
1781
+ return `<div class="math-block" data-latex="${esc(b.attrs?.latex || '')}"><span class="math-view"></span></div>`
1782
  case 'horizontalRule':
1783
  return '<hr>'
1784
  case 'image':
 
1885
 
1886
  // --- header toolbar -----------------------------------------------------------
1887
 
1888
+ // The toolbar label is the one bit of the UI that has to look like maths
1889
+ // without KaTeX being loaded yet.
1890
+ function mathToolLabel() {
1891
+ const span = el('span', { class: 'math-tool-label' })
1892
+ span.textContent = '\u2211'
1893
+ return span
1894
+ }
1895
+
1896
+ // An empty selection opens a display formula on its own line — that is what the
1897
+ // button is for. With text selected, the selection is treated as the source, so
1898
+ // pasted LaTeX can be promoted in place.
1899
+ function insertMath(editor) {
1900
+ const { from, to, empty } = editor.state.selection
1901
+ if (empty) {
1902
+ editor.chain().focus().insertMathBlock('').run()
1903
+ return
1904
+ }
1905
+ const latex = editor.state.doc.textBetween(from, to, ' ').trim().replace(/^\$+|\$+$/g, '')
1906
+ const block = /\n/.test(latex) || latex.length > 60
1907
+ editor
1908
+ .chain()
1909
+ .focus()
1910
+ .deleteSelection()
1911
+ .insertContent(block ? { type: 'mathBlock', attrs: { latex } } : { type: 'mathInline', attrs: { latex } })
1912
+ .run()
1913
+ }
1914
+
1915
  function buildHeaderTools(editor) {
1916
  const bar = document.getElementById('header-tools')
1917
  bar.replaceChildren() // rebuilt per page switch
 
1953
  btn(icon('todo'), 'Checklist', () => editor.chain().focus().toggleTaskList().run(), () => editor.isActive('taskList')),
1954
  btn(icon('table'), 'Insert table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()),
1955
  btn('—', 'Divider', () => editor.chain().focus().setHorizontalRule().run()),
1956
+ btn(mathToolLabel(), 'Math — inline $x^2$, or a display formula on its own line', () => insertMath(editor), () =>
1957
+ editor.isActive('mathInline') || editor.isActive('mathBlock')
1958
+ ),
1959
  btn('HTML', 'Embed live HTML (sandboxed)', () =>
1960
  editHtmlOverlay('', html => {
1961
  if (html.trim()) editor.chain().focus().insertContent({ type: 'htmlBlock', attrs: { html } }).run()
 
2226
  state.autoComposer = null
2227
  renderMargin(window.__editor)
2228
  })
2229
+ // Enter sends, Shift+Enter breaks the line — the messaging convention, and
2230
+ // Enter did nothing here before. The mention menu owns Enter while it is open
2231
+ // (it is picking an agent), and it is wired below, i.e. after this handler.
2232
+ text.addEventListener('keydown', e => {
2233
+ if (e.key !== 'Enter' || e.shiftKey) return
2234
+ if (!document.getElementById('composer-mentions').classList.contains('hidden')) return
2235
+ e.preventDefault()
2236
+ document.getElementById('composer-send').click()
2237
+ })
2238
  document.getElementById('composer-send').addEventListener('click', () => {
2239
  const value = text.value.trim()
2240
  // the box comes pre-mentioned, so a bare "@handle" counts as empty
 
2629
 
2630
  function replyRow(placeholder, onSend) {
2631
  const row = el('div', { class: 'reply-row' })
2632
+ // a textarea, not an input: replies need Shift+Enter to add a line too, and
2633
+ // it grows with the text instead of scrolling a one-line box
2634
+ const input = el('textarea', { placeholder, rows: '1' })
2635
  const menu = el('div', { class: 'mention-menu hidden' })
2636
  const send = el('button', { class: 'iconbtn', title: 'Send' })
2637
  send.appendChild(icon('send'))
2638
+ const autoGrow = () => {
2639
+ input.style.height = 'auto'
2640
+ input.style.height = `${Math.min(input.scrollHeight, 140)}px`
2641
+ }
2642
  send.addEventListener('click', () => {
2643
  if (!input.value.trim()) return
2644
  onSend(input.value.trim())
2645
  input.value = ''
2646
+ autoGrow()
2647
  })
2648
+ input.addEventListener('input', autoGrow)
2649
  input.addEventListener('keydown', e => {
2650
+ if (e.key !== 'Enter' || e.shiftKey) return
2651
+ if (!menu.classList.contains('hidden')) return // the mention menu is picking
2652
+ e.preventDefault()
2653
+ send.click()
2654
  })
2655
  row.append(input, send)
2656
  wireMentionMenu(input, menu)
 
2820
  function inlineToPmContent(segments) {
2821
  return (segments || [])
2822
  .filter(seg => seg.text)
2823
+ .map(seg =>
2824
+ // math is an atom carrying its source, not marked-up text
2825
+ seg.math != null
2826
+ ? { type: 'mathInline', attrs: { latex: seg.math } }
2827
+ : {
2828
+ type: 'text',
2829
+ text: seg.text,
2830
+ marks: Object.entries(seg.attrs || {}).map(([type, attrs]) => ({ type, attrs })),
2831
+ }
2832
+ )
2833
  }
2834
 
2835
  function blockToPmJSON(block) {
2836
  switch (block.type) {
2837
+ case 'mathBlock':
2838
+ return { type: 'mathBlock', attrs: { latex: block.attrs?.latex || '' } }
2839
  case 'heading':
2840
  return { type: 'heading', attrs: { level: block.attrs?.level || 1 }, content: inlineToPmContent(block.inline) }
2841
  case 'codeBlock':
client/src/math.js ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // LaTeX math: an inline atom (`$x^2$`) and a display block (`$$...$$`), both
2
+ // holding their source in a `latex` attribute and typeset with KaTeX.
3
+ //
4
+ // KaTeX is ~270KB and most documents have no math, so it is NOT in the main
5
+ // bundle: the first math node on the page pulls it in, and every node renders
6
+ // its source as a placeholder until it lands.
7
+ import { Node, InputRule } from '@tiptap/core'
8
+ import { TextSelection } from '@tiptap/pm/state'
9
+ import { el } from './common.js'
10
+
11
+ // --- the lazy KaTeX loader ---------------------------------------------------
12
+
13
+ let katexPromise = null
14
+
15
+ function assetVersion() {
16
+ return window.__BOOT?.build_id ? `?v=${window.__BOOT.build_id}` : ''
17
+ }
18
+
19
+ export function loadKatex() {
20
+ if (window.katex) return Promise.resolve(window.katex)
21
+ if (katexPromise) return katexPromise
22
+ katexPromise = new Promise((resolve, reject) => {
23
+ const v = assetVersion()
24
+ if (!document.getElementById('katex-css')) {
25
+ const css = el('link', { id: 'katex-css', rel: 'stylesheet', href: `/katex.css${v}` })
26
+ document.head.appendChild(css)
27
+ }
28
+ const script = document.createElement('script')
29
+ script.src = `/katex.js${v}`
30
+ script.onload = () => (window.katex ? resolve(window.katex) : reject(new Error('katex did not load')))
31
+ script.onerror = () => reject(new Error('katex failed to load'))
32
+ document.head.appendChild(script)
33
+ })
34
+ // a failed fetch must not poison every later attempt — the nodes stay
35
+ // readable as source, and the next one retries
36
+ katexPromise.catch(() => {
37
+ katexPromise = null
38
+ })
39
+ return katexPromise
40
+ }
41
+
42
+ // Typeset `latex` into `target`. Renders the source as a placeholder first, so
43
+ // the formula is always readable — including when KaTeX never arrives.
44
+ export function renderMath(target, latex, { display = false } = {}) {
45
+ const src = latex || ''
46
+ target.textContent = display ? `$$${src}$$` : `$${src}$`
47
+ target.classList.add('math-raw')
48
+ if (!src.trim()) {
49
+ target.classList.remove('math-raw')
50
+ target.textContent = display ? '$$ $$' : 'empty formula'
51
+ target.classList.add('math-empty')
52
+ return
53
+ }
54
+ loadKatex()
55
+ .then(katex => {
56
+ // throwOnError:false makes KaTeX render bad input in red rather than
57
+ // blowing up the editor; the message goes in the tooltip
58
+ katex.render(src, target, { displayMode: display, throwOnError: false, errorColor: 'var(--danger)', output: 'html' })
59
+ target.classList.remove('math-raw')
60
+ })
61
+ .catch(() => {}) // placeholder text stands
62
+ }
63
+
64
+ // --- shared node behaviour --------------------------------------------------
65
+
66
+ // A formula inserted by the user (toolbar, `$$`, `$…$`) should open its source
67
+ // editor immediately — an empty formula is useless until it has one. But a
68
+ // NodeView is also created whenever ProseMirror re-renders a node: after an
69
+ // undo, an accepted suggestion, a remote edit. Auto-opening on those would yank
70
+ // the caret out of whatever the user was typing, so the intent is recorded at
71
+ // the insertion site instead of guessed at render time.
72
+ let insertedAt = 0
73
+
74
+ function markUserInsert() {
75
+ insertedAt = Date.now()
76
+ }
77
+
78
+ function isUserInsert() {
79
+ return Date.now() - insertedAt < 400
80
+ }
81
+
82
+ // Clicking a formula opens its source in a plain input; Enter or blur commits,
83
+ // Escape reverts. Editing the source beats a WYSIWYG formula editor here: the
84
+ // source is what round-trips to markdown and what agents propose.
85
+ function mathNodeView({ display }) {
86
+ return ({ node, getPos, editor }) => {
87
+ let current = node
88
+ let editing = false
89
+ const wrap = el(display ? 'div' : 'span', {
90
+ class: display ? 'math-block' : 'math-inline',
91
+ contenteditable: 'false',
92
+ })
93
+ const view = el('span', { class: 'math-view' })
94
+ const input = el('input', { class: 'math-src', spellcheck: 'false' })
95
+ input.type = 'text'
96
+ wrap.append(view, input)
97
+
98
+ const paint = () => renderMath(view, current.attrs.latex, { display })
99
+
100
+ // Leaving the source editor must land the caret just AFTER the formula.
101
+ // Without this the selection stays on the atom itself, and the next Enter
102
+ // splits the paragraph at the formula instead of starting a new line.
103
+ const caretAfter = tr => {
104
+ const pos = typeof getPos === 'function' ? getPos() : null
105
+ if (pos == null) return tr
106
+ const size = tr.doc.nodeAt(pos)?.nodeSize ?? 1
107
+ try {
108
+ return tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(pos + size, tr.doc.content.size))))
109
+ } catch {
110
+ return tr
111
+ }
112
+ }
113
+
114
+ const stopEditing = ({ save }) => {
115
+ if (!editing) return
116
+ editing = false
117
+ wrap.classList.remove('editing')
118
+ const latex = input.value.trim()
119
+ const pos = typeof getPos === 'function' ? getPos() : null
120
+ let tr = editor.view.state.tr
121
+ if (save && pos != null && latex !== current.attrs.latex) {
122
+ tr = tr.setNodeMarkup(pos, undefined, { ...current.attrs, latex })
123
+ }
124
+ editor.view.dispatch(caretAfter(tr))
125
+ paint()
126
+ }
127
+
128
+ const startEditing = () => {
129
+ if (editing || !editor.isEditable) return
130
+ editing = true
131
+ wrap.classList.add('editing')
132
+ input.value = current.attrs.latex || ''
133
+ // width follows the source so the line does not jump around while typing
134
+ const size = () => (input.size = Math.max(6, Math.min(input.value.length + 2, 60)))
135
+ size()
136
+ input.oninput = size
137
+ input.focus()
138
+ input.select()
139
+ }
140
+
141
+ // The hit target is the rendered formula, NOT the wrapper: a display block
142
+ // spans the full text column, and making that clickable meant a click
143
+ // anywhere on the line — or in the empty space beside a short formula —
144
+ // opened the source editor instead of placing the caret.
145
+ view.addEventListener('click', e => {
146
+ if (editing) return
147
+ e.preventDefault()
148
+ startEditing()
149
+ })
150
+ input.addEventListener('keydown', e => {
151
+ if (e.key === 'Enter') {
152
+ e.preventDefault()
153
+ stopEditing({ save: true })
154
+ editor.view.focus()
155
+ } else if (e.key === 'Escape') {
156
+ e.preventDefault()
157
+ stopEditing({ save: false })
158
+ editor.view.focus()
159
+ }
160
+ e.stopPropagation() // the document's own shortcuts must not fire here
161
+ })
162
+ input.addEventListener('blur', () => stopEditing({ save: true }))
163
+
164
+ paint()
165
+ if (!current.attrs.latex && editor.isEditable && isUserInsert()) {
166
+ setTimeout(() => {
167
+ if (!current.attrs.latex && isUserInsert()) startEditing()
168
+ }, 0)
169
+ }
170
+ return {
171
+ dom: wrap,
172
+ // the input is not part of the document — ProseMirror must keep out
173
+ ignoreMutation: () => true,
174
+ stopEvent: () => editing,
175
+ selectNode() {
176
+ wrap.classList.add('selected')
177
+ },
178
+ deselectNode() {
179
+ wrap.classList.remove('selected')
180
+ },
181
+ update(updated) {
182
+ if (updated.type.name !== current.type.name) return false
183
+ const changed = updated.attrs.latex !== current.attrs.latex
184
+ current = updated
185
+ if (changed && !editing) paint()
186
+ return true
187
+ },
188
+ destroy() {
189
+ input.oninput = null
190
+ },
191
+ }
192
+ }
193
+ }
194
+
195
+ // --- the nodes --------------------------------------------------------------
196
+
197
+ export const MathInline = Node.create({
198
+ name: 'mathInline',
199
+ group: 'inline',
200
+ inline: true,
201
+ atom: true,
202
+ selectable: true,
203
+ addAttributes() {
204
+ return { latex: { default: '' } }
205
+ },
206
+ parseHTML() {
207
+ return [{ tag: 'span[data-math]', getAttrs: n => ({ latex: n.getAttribute('data-latex') || '' }) }]
208
+ },
209
+ renderHTML({ HTMLAttributes }) {
210
+ return ['span', { 'data-math': 'inline', 'data-latex': HTMLAttributes.latex || '' }]
211
+ },
212
+ addNodeView() {
213
+ return mathNodeView({ display: false })
214
+ },
215
+ addCommands() {
216
+ return {
217
+ insertMathInline:
218
+ (latex = '') =>
219
+ ({ chain }) => {
220
+ if (!latex) markUserInsert()
221
+ return chain().insertContent({ type: 'mathInline', attrs: { latex } }).run()
222
+ },
223
+ }
224
+ },
225
+ addInputRules() {
226
+ return [
227
+ // typing `$x^2$` turns into a formula as soon as the closing $ lands.
228
+ // Same strictness as the markdown tokenizer, so money is left alone.
229
+ new InputRule({
230
+ find: /(?<!\$)\$(?![\s$])([^$\n]*[^\s$])\$$/,
231
+ handler: ({ state, range, match, chain }) => {
232
+ const latex = match[1]
233
+ if (!latex.trim()) return
234
+ chain()
235
+ .deleteRange(range)
236
+ .insertContent({ type: 'mathInline', attrs: { latex } })
237
+ .run()
238
+ void state
239
+ },
240
+ }),
241
+ ]
242
+ },
243
+ })
244
+
245
+ export const MathBlock = Node.create({
246
+ name: 'mathBlock',
247
+ group: 'block',
248
+ atom: true,
249
+ selectable: true,
250
+ draggable: true,
251
+ addAttributes() {
252
+ return { latex: { default: '' } }
253
+ },
254
+ parseHTML() {
255
+ return [{ tag: 'div[data-math-block]', getAttrs: n => ({ latex: n.getAttribute('data-latex') || '' }) }]
256
+ },
257
+ renderHTML({ HTMLAttributes }) {
258
+ return ['div', { 'data-math-block': '', 'data-latex': HTMLAttributes.latex || '' }]
259
+ },
260
+ addNodeView() {
261
+ return mathNodeView({ display: true })
262
+ },
263
+ addCommands() {
264
+ return {
265
+ insertMathBlock:
266
+ (latex = '') =>
267
+ ({ chain }) => {
268
+ if (!latex) markUserInsert()
269
+ return chain().insertContent({ type: 'mathBlock', attrs: { latex } }).run()
270
+ },
271
+ }
272
+ },
273
+ addInputRules() {
274
+ return [
275
+ // `$$` on an empty line opens a display formula ready to type into
276
+ new InputRule({
277
+ find: /^\$\$$/,
278
+ handler: ({ range, chain }) => {
279
+ markUserInsert()
280
+ chain().deleteRange(range).insertContent({ type: 'mathBlock', attrs: { latex: '' } }).run()
281
+ },
282
+ }),
283
+ ]
284
+ },
285
+ })
286
+
287
+ // Preview/insertion panels build plain HTML rather than a live editor, so they
288
+ // need a detached element that typesets itself once KaTeX is around.
289
+ export function mathPreviewEl(latex, { display = false } = {}) {
290
+ const wrap = el(display ? 'div' : 'span', { class: display ? 'math-block' : 'math-inline' })
291
+ const view = el('span', { class: 'math-view' })
292
+ wrap.appendChild(view)
293
+ renderMath(view, latex, { display })
294
+ return wrap
295
+ }
package-lock.json CHANGED
@@ -20,6 +20,7 @@
20
  "@tiptap/y-tiptap": "latest",
21
  "express": "latest",
22
  "express-ws": "latest",
 
23
  "ws": "latest",
24
  "y-prosemirror": "latest",
25
  "y-protocols": "latest",
@@ -1066,6 +1067,15 @@
1066
  "url": "https://github.com/sponsors/ljharb"
1067
  }
1068
  },
 
 
 
 
 
 
 
 
 
1069
  "node_modules/content-disposition": {
1070
  "version": "1.1.0",
1071
  "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1529,6 +1539,22 @@
1529
  "url": "https://github.com/sponsors/dmonad"
1530
  }
1531
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1532
  "node_modules/kleur": {
1533
  "version": "4.1.5",
1534
  "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
 
20
  "@tiptap/y-tiptap": "latest",
21
  "express": "latest",
22
  "express-ws": "latest",
23
+ "katex": "latest",
24
  "ws": "latest",
25
  "y-prosemirror": "latest",
26
  "y-protocols": "latest",
 
1067
  "url": "https://github.com/sponsors/ljharb"
1068
  }
1069
  },
1070
+ "node_modules/commander": {
1071
+ "version": "8.3.0",
1072
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
1073
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
1074
+ "license": "MIT",
1075
+ "engines": {
1076
+ "node": ">= 12"
1077
+ }
1078
+ },
1079
  "node_modules/content-disposition": {
1080
  "version": "1.1.0",
1081
  "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
 
1539
  "url": "https://github.com/sponsors/dmonad"
1540
  }
1541
  },
1542
+ "node_modules/katex": {
1543
+ "version": "0.18.1",
1544
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.1.tgz",
1545
+ "integrity": "sha512-Td8GCYSxDAoMhHOlKmCFMJ/hz5qlAAb71n66Dryw9nfCVfumLo7nhuotbvKom/XPADmrYC3O5QR71EPq4DarJQ==",
1546
+ "funding": [
1547
+ "https://opencollective.com/katex",
1548
+ "https://github.com/sponsors/katex"
1549
+ ],
1550
+ "license": "MIT",
1551
+ "dependencies": {
1552
+ "commander": "^8.3.0"
1553
+ },
1554
+ "bin": {
1555
+ "katex": "cli.js"
1556
+ }
1557
+ },
1558
  "node_modules/kleur": {
1559
  "version": "4.1.5",
1560
  "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
package.json CHANGED
@@ -24,7 +24,8 @@
24
  "ws": "latest",
25
  "@tiptap/extension-image": "latest",
26
  "@tiptap/y-tiptap": "latest",
27
- "@tiptap/extension-table": "latest"
 
28
  },
29
  "devDependencies": {
30
  "esbuild": "latest",
 
24
  "ws": "latest",
25
  "@tiptap/extension-image": "latest",
26
  "@tiptap/y-tiptap": "latest",
27
+ "@tiptap/extension-table": "latest",
28
+ "katex": "latest"
29
  },
30
  "devDependencies": {
31
  "esbuild": "latest",
scripts/build.js CHANGED
@@ -42,6 +42,18 @@ for (const f of fs.readdirSync(path.join(root, 'client/fonts'))) {
42
  fs.copyFileSync(path.join(root, 'client/fonts', f), path.join(pub, 'fonts', f))
43
  }
44
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  fs.writeFileSync(path.join(pub, 'build-id'), buildId)
46
 
47
  // Pre-compress the bundles. The editor bundle is ~640KB raw and ~160KB brotli,
 
42
  fs.copyFileSync(path.join(root, 'client/fonts', f), path.join(pub, 'fonts', f))
43
  }
44
 
45
+ // KaTeX, kept OUT of the main bundle: it is only fetched by a page that
46
+ // actually has math on it. The prebuilt UMD file already assigns window.katex,
47
+ // so there is nothing to re-bundle. Its stylesheet points at fonts/KaTeX_*,
48
+ // which resolves against /fonts — the same directory, already immutable-cached.
49
+ // Only woff2 is copied; every browser that runs this app prefers it.
50
+ const katexDist = path.join(root, 'node_modules/katex/dist')
51
+ fs.copyFileSync(path.join(katexDist, 'katex.min.js'), path.join(pub, 'katex.js'))
52
+ fs.copyFileSync(path.join(katexDist, 'katex.min.css'), path.join(pub, 'katex.css'))
53
+ for (const f of fs.readdirSync(path.join(katexDist, 'fonts'))) {
54
+ if (f.endsWith('.woff2')) fs.copyFileSync(path.join(katexDist, 'fonts', f), path.join(pub, 'fonts', f))
55
+ }
56
+
57
  fs.writeFileSync(path.join(pub, 'build-id'), buildId)
58
 
59
  // Pre-compress the bundles. The editor bundle is ~640KB raw and ~160KB brotli,
server/api.js CHANGED
@@ -491,6 +491,7 @@ Work loop — repeat until the user tells you to stop:
491
  itself (replaces the addressed block(s); markdown supports
492
  paragraphs, # headings, - lists, - [ ]/- [x] task lists, GFM tables (| a | b |
493
  with a | --- | --- | separator row), \`\`\`code\`\`\`, **bold**, *italic*, [links](url),
 
494
  and live HTML embeds — see below):
495
  curl -s $AUTH -X POST "${HOST}/api/docs/<doc_id>/suggestions" \\
496
  -H 'content-type: application/json' \\
 
491
  itself (replaces the addressed block(s); markdown supports
492
  paragraphs, # headings, - lists, - [ ]/- [x] task lists, GFM tables (| a | b |
493
  with a | --- | --- | separator row), \`\`\`code\`\`\`, **bold**, *italic*, [links](url),
494
+ LaTeX math (inline \$x^2\$, or \$\$...\$\$ on its own line for a display formula),
495
  and live HTML embeds — see below):
496
  curl -s $AUTH -X POST "${HOST}/api/docs/<doc_id>/suggestions" \\
497
  -H 'content-type: application/json' \\
server/collab.js CHANGED
@@ -791,9 +791,35 @@ function yInlineText(segments) {
791
  return t
792
  }
793
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794
  function paragraphOf(segments) {
795
  const p = new Y.XmlElement('paragraph')
796
- p.insert(0, [yInlineText(segments)])
797
  return p
798
  }
799
 
@@ -802,7 +828,12 @@ export function buildYBlock(block) {
802
  case 'heading': {
803
  const el = new Y.XmlElement('heading')
804
  el.setAttribute('level', block.attrs?.level || 1)
805
- el.insert(0, [yInlineText(block.inline)])
 
 
 
 
 
806
  return el
807
  }
808
  case 'codeBlock': {
 
791
  return t
792
  }
793
 
794
+ // Inline content is usually one XmlText run, but a formula is an element, so a
795
+ // paragraph containing math becomes an alternating list of text runs and math
796
+ // elements. Each text run is built on its own — formatting must not leak across
797
+ // a formula, and the offsets above are per-run.
798
+ function yInlineChildren(segments) {
799
+ const out = []
800
+ let run = []
801
+ const flush = () => {
802
+ if (run.length) out.push(yInlineText(run))
803
+ run = []
804
+ }
805
+ for (const seg of segments || []) {
806
+ if (seg.math != null) {
807
+ flush()
808
+ const m = new Y.XmlElement('mathInline')
809
+ m.setAttribute('latex', seg.math)
810
+ out.push(m)
811
+ } else if (seg.text) {
812
+ run.push(seg)
813
+ }
814
+ }
815
+ flush()
816
+ // an empty paragraph still needs its text node, or the editor sees no content
817
+ return out.length ? out : [yInlineText([])]
818
+ }
819
+
820
  function paragraphOf(segments) {
821
  const p = new Y.XmlElement('paragraph')
822
+ p.insert(0, yInlineChildren(segments))
823
  return p
824
  }
825
 
 
828
  case 'heading': {
829
  const el = new Y.XmlElement('heading')
830
  el.setAttribute('level', block.attrs?.level || 1)
831
+ el.insert(0, yInlineChildren(block.inline))
832
+ return el
833
+ }
834
+ case 'mathBlock': {
835
+ const el = new Y.XmlElement('mathBlock')
836
+ el.setAttribute('latex', block.attrs?.latex || '')
837
  return el
838
  }
839
  case 'codeBlock': {
server/md.js CHANGED
@@ -1,6 +1,6 @@
1
  // Markdown <-> ProseMirror JSON helpers. Deliberately minimal: paragraphs,
2
  // headings 1-3, bullet/ordered lists (flat), code blocks, blockquotes,
3
- // horizontal rules; inline bold/italic/code/strike/links.
4
 
5
  // --- ProseMirror JSON -> markdown ---
6
 
@@ -28,6 +28,9 @@ export function blockToMarkdown(node) {
28
  return '```html-embed\n' + (node.attrs?.html || '') + '\n```'
29
  case 'blockquote':
30
  return (node.content || []).map(blockToMarkdown).join('\n\n').split('\n').map(l => '> ' + l).join('\n')
 
 
 
31
  case 'horizontalRule':
32
  return '---'
33
  case 'image':
@@ -74,6 +77,8 @@ function inlineToMarkdown(content) {
74
  if (!content) return ''
75
  return content.map(n => {
76
  if (n.type === 'hardBreak') return ' \n'
 
 
77
  let text = n.text || ''
78
  for (const mark of n.marks || []) {
79
  if (mark.type === 'bold') text = `**${text}**`
@@ -110,6 +115,27 @@ export function markdownToBlocks(md) {
110
  }
111
  if (/^(---|\*\*\*)\s*$/.test(line.trim())) { blocks.push({ type: 'horizontalRule' }); i++; continue }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  const image = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/)
114
  if (image) {
115
  blocks.push({ type: 'image', attrs: { alt: image[1] || null, src: image[2] } })
@@ -186,7 +212,7 @@ export function markdownToBlocks(md) {
186
 
187
  // paragraph: consume until blank line or block marker
188
  const body = []
189
- while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+[.)]\s|>\s?|---\s*$)/.test(lines[i])) {
190
  body.push(lines[i])
191
  i++
192
  }
@@ -205,11 +231,37 @@ export function markdownToBlocks(md) {
205
  // containing a filename underscore never matched and rendered as plain text.
206
  const INLINE_RE = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(~~([^~]+)~~)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)\s]+)\))|((?<![A-Za-z0-9])__(?=\S)(.+?)(?<=\S)__(?![A-Za-z0-9]))|((?<![A-Za-z0-9])_(?=\S)(?!_)(.+?)(?<=\S)_(?![A-Za-z0-9]))/
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  export function tokenizeInline(text) {
209
  const segments = []
210
  let rest = text
211
  while (rest) {
 
212
  const m = rest.match(INLINE_RE)
 
 
 
 
 
 
 
213
  if (!m) { segments.push({ text: rest, attrs: {} }); break }
214
  if (m.index > 0) segments.push({ text: rest.slice(0, m.index), attrs: {} })
215
  if (m[1]) segments.push({ text: m[2], attrs: { bold: {} } })
 
1
  // Markdown <-> ProseMirror JSON helpers. Deliberately minimal: paragraphs,
2
  // headings 1-3, bullet/ordered lists (flat), code blocks, blockquotes,
3
+ // horizontal rules, $-delimited math; inline bold/italic/code/strike/links.
4
 
5
  // --- ProseMirror JSON -> markdown ---
6
 
 
28
  return '```html-embed\n' + (node.attrs?.html || '') + '\n```'
29
  case 'blockquote':
30
  return (node.content || []).map(blockToMarkdown).join('\n\n').split('\n').map(l => '> ' + l).join('\n')
31
+ case 'mathBlock':
32
+ // canonical fenced form; the single-line `$$x$$` spelling also parses
33
+ return '$$\n' + (node.attrs?.latex || '').trim() + '\n$$'
34
  case 'horizontalRule':
35
  return '---'
36
  case 'image':
 
77
  if (!content) return ''
78
  return content.map(n => {
79
  if (n.type === 'hardBreak') return ' \n'
80
+ // an inline atom: its markdown IS its source, and it carries no marks
81
+ if (n.type === 'mathInline') return '$' + (n.attrs?.latex || '') + '$'
82
  let text = n.text || ''
83
  for (const mark of n.marks || []) {
84
  if (mark.type === 'bold') text = `**${text}**`
 
115
  }
116
  if (/^(---|\*\*\*)\s*$/.test(line.trim())) { blocks.push({ type: 'horizontalRule' }); i++; continue }
117
 
118
+ // display math: `$$ ... $$` on one line, or fenced over several
119
+ if (line.trim().startsWith('$$')) {
120
+ const oneLine = line.trim().match(/^\$\$(.+)\$\$$/)
121
+ if (oneLine) {
122
+ blocks.push({ type: 'mathBlock', attrs: { latex: oneLine[1].trim() } })
123
+ i++
124
+ continue
125
+ }
126
+ const body = []
127
+ i++
128
+ while (i < lines.length && !lines[i].trim().endsWith('$$')) { body.push(lines[i]); i++ }
129
+ if (i < lines.length) {
130
+ // the closing line may carry the last of the formula: `... \end{aligned}$$`
131
+ const tail = lines[i].trim().replace(/\$\$$/, '')
132
+ if (tail) body.push(tail)
133
+ i++
134
+ }
135
+ blocks.push({ type: 'mathBlock', attrs: { latex: body.join('\n').trim() } })
136
+ continue
137
+ }
138
+
139
  const image = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/)
140
  if (image) {
141
  blocks.push({ type: 'image', attrs: { alt: image[1] || null, src: image[2] } })
 
212
 
213
  // paragraph: consume until blank line or block marker
214
  const body = []
215
+ while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\$\$|\s*[-*]\s|\s*\d+[.)]\s|>\s?|---\s*$)/.test(lines[i])) {
216
  body.push(lines[i])
217
  i++
218
  }
 
231
  // containing a filename underscore never matched and rendered as plain text.
232
  const INLINE_RE = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(~~([^~]+)~~)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)\s]+)\))|((?<![A-Za-z0-9])__(?=\S)(.+?)(?<=\S)__(?![A-Za-z0-9]))|((?<![A-Za-z0-9])_(?=\S)(?!_)(.+?)(?<=\S)_(?![A-Za-z0-9]))/
233
 
234
+ // Inline math, `$...$`. Matched BEFORE any emphasis so a formula's `*`, `_`,
235
+ // backticks and brackets cannot be eaten as markdown — `$\{x\}_{i}$ and
236
+ // $\{y\}_{j}$` used to turn into italics spanning both formulas.
237
+ //
238
+ // The delimiters are deliberately strict, because `$` is also money: no space
239
+ // just inside either delimiter (so "costs $5 and $10 more" is left alone), no
240
+ // newline, and never adjacent to a second `$` (so a `$$...$$` display block is
241
+ // not chewed into inline math from its second character).
242
+ const MATH_RE = /(?<!\$)\$(?![\s$])([^$\n]*[^\s$])\$(?!\$)/
243
+
244
+ // A math segment carries BOTH its source text and its latex: every consumer
245
+ // that only reads `.text` (diffing, signatures, plain-text descriptions) then
246
+ // sees `$x^2$` and behaves correctly, and only the few places that build real
247
+ // nodes — ProseMirror JSON, Yjs XML, preview HTML — look at `.math`.
248
+ function mathSegment(latex) {
249
+ return { text: '$' + latex + '$', attrs: {}, math: latex }
250
+ }
251
+
252
  export function tokenizeInline(text) {
253
  const segments = []
254
  let rest = text
255
  while (rest) {
256
+ const math = rest.match(MATH_RE)
257
  const m = rest.match(INLINE_RE)
258
+ // whichever opens first wins; math breaks ties so `$a * b$` stays math
259
+ if (math && (!m || math.index <= m.index)) {
260
+ if (math.index > 0) segments.push({ text: rest.slice(0, math.index), attrs: {} })
261
+ segments.push(mathSegment(math[1]))
262
+ rest = rest.slice(math.index + math[0].length)
263
+ continue
264
+ }
265
  if (!m) { segments.push({ text: rest, attrs: {} }); break }
266
  if (m.index > 0) segments.push({ text: rest.slice(0, m.index), attrs: {} })
267
  if (m[1]) segments.push({ text: m[2], attrs: { bold: {} } })
test/browser.js CHANGED
@@ -24,6 +24,25 @@ const server = spawn('node', ['server/index.js'], {
24
  })
25
  server.stderr.on('data', d => process.stderr.write('[server] ' + d))
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const consoleErrors = []
28
 
29
  async function main() {
@@ -131,6 +150,48 @@ async function main() {
131
  await page.click('#share-btn') // close
132
  console.log('✓ share panel: add collaborator')
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  // --- loading skeleton ---
135
  const docHtml = await (await page.request.get(page.url())).text()
136
  assert.ok(docHtml.includes('id="doc-skeleton"') && (docHtml.match(/sk-line/g) || []).length > 3,
@@ -148,8 +209,7 @@ async function main() {
148
  console.log('✓ loading: skeleton sheet ships in the shell and lifts after sync')
149
 
150
  // --- type into the editor ---
151
- await page.click('.tiptap')
152
- await page.keyboard.press('Control+End')
153
  await page.keyboard.type('The quick brown fox jumps over the lazy dog.')
154
  await waitFor(async () => (await page.textContent('.tiptap')).includes('quick brown fox'), 'typed text')
155
  console.log('✓ typing works')
@@ -174,6 +234,44 @@ async function main() {
174
  await page.waitForSelector('.tiptap .comment-hl', { timeout: 5000 })
175
  console.log('✓ comment card created: chip + text highlight visible')
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  // --- @ menu: arrows pick an agent, no mouse needed ---
178
  await selectText(page, 'over the lazy')
179
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
@@ -239,17 +337,23 @@ async function main() {
239
 
240
  // inline track-changes rendering in the document
241
  await page.waitForSelector('.tiptap .sugg-ins', { timeout: 5000 })
242
- const insText = await page.textContent('.tiptap .sugg-ins')
243
- assert.ok(insText.includes('energetic'), 'inserted words shown inline: ' + insText)
244
- const delText = await page.textContent('.tiptap .sugg-del')
245
- assert.ok(delText.includes('lazy'), 'deleted words struck through inline: ' + delText)
 
 
 
 
 
 
246
  // slim card: accept/reject in the header, no diff repetition
247
  assert.ok(await page.$('.card.suggestion .accept-btn'), 'accept button in card header')
248
  assert.ok(!(await page.$('.card.suggestion .udiff')), 'card does not repeat the diff')
249
  console.log('✓ suggestion rendered inline (strikethrough + insertion); card is slim')
250
 
251
  // --- discuss the suggestion (guide the agent) ---
252
- await page.fill('.card.suggestion .reply-row input', '@ui-agent could you keep the dog lazy though?')
253
  await page.click('.card.suggestion .reply-row button')
254
  await waitFor(async () => (await page.textContent('.card.suggestion')).includes('keep the dog lazy'), 'discussion message')
255
  // the discussion thread must NOT appear as a separate card
@@ -340,8 +444,8 @@ async function main() {
340
  fs.writeFileSync(`${SHOT}/test.png`, png)
341
  const [chooser] = await Promise.all([page.waitForEvent('filechooser'), page.click('#image-btn')])
342
  await chooser.setFiles(`${SHOT}/test.png`)
343
- await page.waitForSelector('.tiptap img', { timeout: 10000 })
344
- const src = await page.getAttribute('.tiptap img', 'src')
345
  assert.ok(src.startsWith('/files/'), 'image served from /files: ' + src)
346
  const imgRes = await page.evaluate(async u => (await fetch(u)).status, src)
347
  assert.equal(imgRes, 200, 'image fetchable')
@@ -350,14 +454,13 @@ async function main() {
350
  // --- reply + resolve on the standalone thread ---
351
  await page.click('.card.thread')
352
  await page.waitForSelector('.card.thread.expanded', { timeout: 5000 })
353
- await page.fill('.card.thread .reply-row input', 'looks good, thanks!')
354
  await page.click('.card.thread .reply-row button')
355
  await waitFor(async () => (await page.textContent('.card.thread')).includes('looks good'), 'reply visible')
356
  console.log('✓ reply works')
357
 
358
  // --- list scenario: agent adds one item, only that item shows as inserted ---
359
- await page.click('.tiptap')
360
- await page.keyboard.press('Control+End')
361
  await page.keyboard.press('Enter')
362
  await page.keyboard.type('- alpha')
363
  await page.keyboard.press('Enter')
@@ -383,8 +486,7 @@ async function main() {
383
  const delSpans = await page.$$eval('.tiptap .sugg-del', els => els.map(e => e.textContent).filter(t => ['alpha', 'beta', 'gamma'].some(w => t.includes(w))))
384
  assert.equal(delSpans.length, 0, 'unchanged list items are NOT struck through: ' + JSON.stringify(delSpans))
385
  // typing a new block right after the suggested range must not get highlighted
386
- await page.click('.tiptap')
387
- await page.keyboard.press('Control+End')
388
  await page.keyboard.press('Enter')
389
  await page.keyboard.press('Enter') // second Enter exits the list into a fresh paragraph
390
  await page.keyboard.type('untouched trailing line')
@@ -436,8 +538,7 @@ async function main() {
436
  ]
437
  // its own paragraph, verified to be a standalone block: the point of these
438
  // cases is that ONLY the formatting differs from the replacement markdown
439
- await page.click('.tiptap')
440
- await page.keyboard.press('Control+End')
441
  await page.keyboard.press('Enter')
442
  await page.keyboard.type('A paragraph to link up here.')
443
  await waitFor(async () => (await page.textContent('.tiptap')).includes('A paragraph to link up here.'), 'mark-case paragraph typed')
@@ -532,8 +633,7 @@ async function main() {
532
  console.log('✓ dissimilar rewrites: whole-block strike + formatted panel (no raw blob)')
533
 
534
  // --- typing at a suggestion boundary pushes the insertion panel down ---
535
- await page.click('.tiptap')
536
- await page.keyboard.press('Control+End')
537
  await page.keyboard.press('Enter')
538
  await page.keyboard.type('ordering base line')
539
  await page.waitForTimeout(400)
@@ -585,8 +685,7 @@ async function main() {
585
  console.log('✓ zoom controls')
586
 
587
  // --- undo / redo ---
588
- await page.click('.tiptap')
589
- await page.keyboard.press('Control+End')
590
  await page.waitForTimeout(600)
591
  await page.keyboard.type(' UNDOME')
592
  await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'typed marker')
@@ -601,8 +700,7 @@ async function main() {
601
 
602
  // "- [ ] " markdown shortcut converts to a real task list (done last so it
603
  // doesn't perturb the earlier suggestion tests on this doc)
604
- await page.click('.tiptap')
605
- await page.keyboard.press('Control+End')
606
  await page.keyboard.press('Enter')
607
  await page.keyboard.type('- [ ] shortcut task')
608
  await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), '"- [ ] " creates a task list')
@@ -694,7 +792,7 @@ async function main() {
694
  console.log('✓ comment resolve without opening')
695
 
696
  // image bubble: width + alignment
697
- const img = await page.$('.tiptap img')
698
  await img.scrollIntoViewIfNeeded()
699
  await img.click()
700
  await page.waitForTimeout(400)
@@ -706,7 +804,7 @@ async function main() {
706
  await page.waitForTimeout(300)
707
  }
708
  const imgAttrs = await page.evaluate(() => {
709
- const i = document.querySelector('.tiptap img')
710
  return { style: i.getAttribute('style') || '', align: i.getAttribute('data-align') }
711
  })
712
  assert.ok(imgAttrs.style.includes('50%') && imgAttrs.align === 'center', 'image resized + centered: ' + JSON.stringify(imgAttrs))
@@ -720,8 +818,7 @@ async function main() {
720
  assert.deepEqual(stLink, { text: 'Structure', hasIcon: true }, 'structure link polished: ' + JSON.stringify(stLink))
721
 
722
  // [[ cross-reference: autocomplete inserts a page link
723
- await page.click('.tiptap')
724
- await page.keyboard.press('Control+End')
725
  await page.keyboard.press('Enter')
726
  await page.keyboard.type('See also [[not')
727
  await waitFor(async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))), 'pagelink menu appears')
 
24
  })
25
  server.stderr.on('data', d => process.stderr.write('[server] ' + d))
26
 
27
+ // Put the caret at the end of the document without depending on where things
28
+ // happen to sit: Playwright clicks an element's centre, and if an atom node (a
29
+ // formula, an image) is there, the click opens THAT instead of placing a caret.
30
+ async function focusDocEnd(page) {
31
+ // Put the caret at the end of the document, reliably. Two traps here:
32
+ // - typing before the document has synced loses those keystrokes, because
33
+ // the remote state then replaces them (the skeleton lifting is the signal);
34
+ // - editor.commands.focus() sets the selection but does NOT take DOM focus
35
+ // in headless Chromium, so the keystrokes go to the body instead.
36
+ // Hence a real click, at a point that is always text: the title line. Clicking
37
+ // the element's centre would be a gamble — an atom (a formula, an image)
38
+ // sitting there swallows the click and every keystroke after it.
39
+ await page.waitForFunction(() => document.getElementById('doc-skeleton')?.classList.contains('hidden'), null, { timeout: 20000 })
40
+ await page.waitForFunction(() => window.__editor && !window.__editor.isDestroyed)
41
+ await page.click('.tiptap', { position: { x: 24, y: 10 } })
42
+ await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 })
43
+ await page.keyboard.press('Control+End')
44
+ }
45
+
46
  const consoleErrors = []
47
 
48
  async function main() {
 
150
  await page.click('#share-btn') // close
151
  console.log('✓ share panel: add collaborator')
152
 
153
+ // --- LaTeX math: typed, typeset by KaTeX, source editable ---
154
+ await focusDocEnd(page)
155
+ await page.keyboard.press('Enter')
156
+ await page.keyboard.type('Einstein said $E = mc^2$ and meant it.')
157
+ await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 })
158
+ // KaTeX is lazy: it must NOT be in the main bundle, and must arrive on demand
159
+ await waitFor(async () => await page.evaluate(() => !!window.katex), 'katex loads on demand')
160
+ const katexFetches = await page.evaluate(() =>
161
+ performance.getEntriesByType('resource').filter(r => /katex\.(js|css)/.test(r.name)).map(r => r.name.split('/').pop())
162
+ )
163
+ assert.equal(katexFetches.length, 2, 'katex js + css fetched separately from the main bundle: ' + katexFetches)
164
+ await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 5000 })
165
+ const rendered = await page.textContent('.tiptap .math-inline .katex')
166
+ assert.ok(rendered.includes('E') && rendered.includes('mc'), 'formula is typeset, not raw source: ' + rendered)
167
+ assert.ok(!(await page.textContent('.tiptap')).includes('$E = mc^2$'), 'the $ delimiters are gone from the text')
168
+
169
+ // clicking a formula opens its LaTeX source; Enter commits the edit
170
+ await page.click('.tiptap .math-inline .math-view')
171
+ await page.waitForSelector('.tiptap .math-inline.editing .math-src', { timeout: 5000 })
172
+ assert.equal(await page.inputValue('.math-inline.editing .math-src'), 'E = mc^2', 'the source is what you edit')
173
+ await page.fill('.math-inline.editing .math-src', 'E = mc^3')
174
+ await page.keyboard.press('Enter')
175
+ await waitFor(async () => (await page.textContent('.tiptap .math-inline .katex')).includes('mc'), 'edited formula re-renders')
176
+ assert.ok(!(await page.$('.math-inline.editing')), 'Enter closes the source editor')
177
+
178
+ // a display formula from $$ on its own line
179
+ await focusDocEnd(page) // the caret was in the previous formula's source input
180
+ await page.keyboard.press('Enter')
181
+ await page.keyboard.type('$$')
182
+ await page.waitForSelector('.tiptap .math-block', { timeout: 5000 })
183
+ await page.waitForSelector('.tiptap .math-block.editing .math-src', { timeout: 5000 })
184
+ await page.fill('.math-block.editing .math-src', '\\int_0^1 x^2 dx')
185
+ await page.keyboard.press('Enter')
186
+ await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 5000 })
187
+ console.log('✓ math: $x$ typesets via lazily-loaded KaTeX, source click-to-edit, $$ display block')
188
+
189
+ // it must survive a reload — i.e. it is really in the shared document
190
+ await page.reload()
191
+ await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 15000 })
192
+ await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 15000 })
193
+ console.log('✓ math persists across a reload (stored in the collaborative doc)')
194
+
195
  // --- loading skeleton ---
196
  const docHtml = await (await page.request.get(page.url())).text()
197
  assert.ok(docHtml.includes('id="doc-skeleton"') && (docHtml.match(/sk-line/g) || []).length > 3,
 
209
  console.log('✓ loading: skeleton sheet ships in the shell and lifts after sync')
210
 
211
  // --- type into the editor ---
212
+ await focusDocEnd(page)
 
213
  await page.keyboard.type('The quick brown fox jumps over the lazy dog.')
214
  await waitFor(async () => (await page.textContent('.tiptap')).includes('quick brown fox'), 'typed text')
215
  console.log('✓ typing works')
 
234
  await page.waitForSelector('.tiptap .comment-hl', { timeout: 5000 })
235
  console.log('✓ comment card created: chip + text highlight visible')
236
 
237
+ // --- Enter sends a comment, Shift+Enter adds a line ---
238
+ await selectText(page, 'brown fox jumps')
239
+ await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
240
+ await page.click('#composer-text')
241
+ await page.fill('#composer-text', '')
242
+ await page.keyboard.type('first line')
243
+ await page.keyboard.down('Shift')
244
+ await page.keyboard.press('Enter')
245
+ await page.keyboard.up('Shift')
246
+ await page.keyboard.type('second line')
247
+ const twoLines = await page.inputValue('#composer-text')
248
+ assert.ok(twoLines.includes('\n'), 'Shift+Enter adds a newline: ' + JSON.stringify(twoLines))
249
+ assert.ok(await page.isVisible('#composer'), 'Shift+Enter does not send')
250
+ await page.keyboard.press('Enter')
251
+ await waitFor(async () => await page.isHidden('#composer'), 'Enter sends the comment')
252
+ await waitFor(async () => (await page.textContent('#margin-items')).includes('second line'), 'both lines posted')
253
+ console.log('✓ comment composer: Enter sends, Shift+Enter adds a line')
254
+
255
+ // the reply box behaves the same way
256
+ await page.click('.card.thread')
257
+ await page.waitForSelector('.card.thread.expanded .reply-row textarea', { timeout: 5000 })
258
+ const replyBox = '.card.thread.expanded .reply-row textarea'
259
+ await page.click(replyBox)
260
+ await page.keyboard.type('reply one')
261
+ await page.keyboard.down('Shift')
262
+ await page.keyboard.press('Enter')
263
+ await page.keyboard.up('Shift')
264
+ await page.keyboard.type('reply two')
265
+ assert.ok((await page.inputValue(replyBox)).includes('\n'), 'Shift+Enter adds a line in a reply')
266
+ await page.keyboard.press('Enter')
267
+ await waitFor(async () => (await page.textContent('.card.thread')).includes('reply two'), 'Enter sends the reply')
268
+ assert.equal(await page.inputValue(replyBox), '', 'the reply box clears after sending')
269
+ // resolve it again: later assertions count the open threads, and this test's
270
+ // own thread would otherwise look like a second discussion
271
+ await page.click('.card.thread.expanded .resolve-btn')
272
+ await waitFor(async () => (await page.$$('.card.thread')).length === 1, 'this test cleans up its thread')
273
+ console.log('✓ reply box: Enter sends, Shift+Enter adds a line')
274
+
275
  // --- @ menu: arrows pick an agent, no mouse needed ---
276
  await selectText(page, 'over the lazy')
277
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
 
337
 
338
  // inline track-changes rendering in the document
339
  await page.waitForSelector('.tiptap .sugg-ins', { timeout: 5000 })
340
+ // wait for the decoration set to settle: the widgets appear as it is rebuilt,
341
+ // so reading the first paint was always a race
342
+ await waitFor(
343
+ async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('energetic'),
344
+ 'inserted words shown inline'
345
+ )
346
+ await waitFor(
347
+ async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes('lazy'),
348
+ 'deleted words struck through inline'
349
+ )
350
  // slim card: accept/reject in the header, no diff repetition
351
  assert.ok(await page.$('.card.suggestion .accept-btn'), 'accept button in card header')
352
  assert.ok(!(await page.$('.card.suggestion .udiff')), 'card does not repeat the diff')
353
  console.log('✓ suggestion rendered inline (strikethrough + insertion); card is slim')
354
 
355
  // --- discuss the suggestion (guide the agent) ---
356
+ await page.fill('.card.suggestion .reply-row textarea', '@ui-agent could you keep the dog lazy though?')
357
  await page.click('.card.suggestion .reply-row button')
358
  await waitFor(async () => (await page.textContent('.card.suggestion')).includes('keep the dog lazy'), 'discussion message')
359
  // the discussion thread must NOT appear as a separate card
 
444
  fs.writeFileSync(`${SHOT}/test.png`, png)
445
  const [chooser] = await Promise.all([page.waitForEvent('filechooser'), page.click('#image-btn')])
446
  await chooser.setFiles(`${SHOT}/test.png`)
447
+ await page.waitForSelector('.tiptap img:not(.ProseMirror-separator)', { timeout: 10000 })
448
+ const src = await page.getAttribute('.tiptap img:not(.ProseMirror-separator)', 'src')
449
  assert.ok(src.startsWith('/files/'), 'image served from /files: ' + src)
450
  const imgRes = await page.evaluate(async u => (await fetch(u)).status, src)
451
  assert.equal(imgRes, 200, 'image fetchable')
 
454
  // --- reply + resolve on the standalone thread ---
455
  await page.click('.card.thread')
456
  await page.waitForSelector('.card.thread.expanded', { timeout: 5000 })
457
+ await page.fill('.card.thread .reply-row textarea', 'looks good, thanks!')
458
  await page.click('.card.thread .reply-row button')
459
  await waitFor(async () => (await page.textContent('.card.thread')).includes('looks good'), 'reply visible')
460
  console.log('✓ reply works')
461
 
462
  // --- list scenario: agent adds one item, only that item shows as inserted ---
463
+ await focusDocEnd(page)
 
464
  await page.keyboard.press('Enter')
465
  await page.keyboard.type('- alpha')
466
  await page.keyboard.press('Enter')
 
486
  const delSpans = await page.$$eval('.tiptap .sugg-del', els => els.map(e => e.textContent).filter(t => ['alpha', 'beta', 'gamma'].some(w => t.includes(w))))
487
  assert.equal(delSpans.length, 0, 'unchanged list items are NOT struck through: ' + JSON.stringify(delSpans))
488
  // typing a new block right after the suggested range must not get highlighted
489
+ await focusDocEnd(page)
 
490
  await page.keyboard.press('Enter')
491
  await page.keyboard.press('Enter') // second Enter exits the list into a fresh paragraph
492
  await page.keyboard.type('untouched trailing line')
 
538
  ]
539
  // its own paragraph, verified to be a standalone block: the point of these
540
  // cases is that ONLY the formatting differs from the replacement markdown
541
+ await focusDocEnd(page)
 
542
  await page.keyboard.press('Enter')
543
  await page.keyboard.type('A paragraph to link up here.')
544
  await waitFor(async () => (await page.textContent('.tiptap')).includes('A paragraph to link up here.'), 'mark-case paragraph typed')
 
633
  console.log('✓ dissimilar rewrites: whole-block strike + formatted panel (no raw blob)')
634
 
635
  // --- typing at a suggestion boundary pushes the insertion panel down ---
636
+ await focusDocEnd(page)
 
637
  await page.keyboard.press('Enter')
638
  await page.keyboard.type('ordering base line')
639
  await page.waitForTimeout(400)
 
685
  console.log('✓ zoom controls')
686
 
687
  // --- undo / redo ---
688
+ await focusDocEnd(page)
 
689
  await page.waitForTimeout(600)
690
  await page.keyboard.type(' UNDOME')
691
  await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'typed marker')
 
700
 
701
  // "- [ ] " markdown shortcut converts to a real task list (done last so it
702
  // doesn't perturb the earlier suggestion tests on this doc)
703
+ await focusDocEnd(page)
 
704
  await page.keyboard.press('Enter')
705
  await page.keyboard.type('- [ ] shortcut task')
706
  await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), '"- [ ] " creates a task list')
 
792
  console.log('✓ comment resolve without opening')
793
 
794
  // image bubble: width + alignment
795
+ const img = await page.$('.tiptap img:not(.ProseMirror-separator)')
796
  await img.scrollIntoViewIfNeeded()
797
  await img.click()
798
  await page.waitForTimeout(400)
 
804
  await page.waitForTimeout(300)
805
  }
806
  const imgAttrs = await page.evaluate(() => {
807
+ const i = document.querySelector('.tiptap img:not(.ProseMirror-separator)')
808
  return { style: i.getAttribute('style') || '', align: i.getAttribute('data-align') }
809
  })
810
  assert.ok(imgAttrs.style.includes('50%') && imgAttrs.align === 'center', 'image resized + centered: ' + JSON.stringify(imgAttrs))
 
818
  assert.deepEqual(stLink, { text: 'Structure', hasIcon: true }, 'structure link polished: ' + JSON.stringify(stLink))
819
 
820
  // [[ cross-reference: autocomplete inserts a page link
821
+ await focusDocEnd(page)
 
822
  await page.keyboard.press('Enter')
823
  await page.keyboard.type('See also [[not')
824
  await waitFor(async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))), 'pagelink menu appears')
test/smoke.js CHANGED
@@ -596,6 +596,42 @@ async function main() {
596
  await api('dev:alice', `/api/docs/${ttProj.id}`, { method: 'DELETE' })
597
  console.log('✓ task lists + GFM tables: agent suggestion, accept, markdown round-trip')
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  // 11h. new-page suggestions: agent npProposes a whole page, human accepts/rejects
600
  const npjProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'NP Wiki' } })
601
  await api('dev:alice', `/api/docs/${npjProj.id}/share`, { method: 'POST', body: { username: 'bob' } })
 
596
  await api('dev:alice', `/api/docs/${ttProj.id}`, { method: 'DELETE' })
597
  console.log('✓ task lists + GFM tables: agent suggestion, accept, markdown round-trip')
598
 
599
+ // 11g-math. LaTeX survives the whole agent path: markdown -> Yjs -> markdown
600
+ const mProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'Math Smoke' } })
601
+ await api('dev:alice', `/api/docs/${mProj.id}/share`, { method: 'POST', body: { username: 'bob' } })
602
+ const mSnap = await api(AGENT, `/api/docs/${mProj.id}`)
603
+ assert.ok(mSnap.blocks, 'agent can read the math project: ' + JSON.stringify(mSnap))
604
+ const mathMd = [
605
+ 'The bound $\\|Ax - b\\|_2^2$ is tight when $a * b$ and $c * d$ agree.',
606
+ '',
607
+ '$$',
608
+ '\\int_0^\\infty e^{-x} dx = 1',
609
+ '$$',
610
+ '',
611
+ '| term | value |',
612
+ '| --- | --- |',
613
+ '| $e^{i\\pi}$ | $-1$ |',
614
+ '',
615
+ 'It costs $5 and $10 more.',
616
+ ].join('\n')
617
+ const mSug = await api(AGENT, `/api/docs/${mProj.id}/suggestions`, {
618
+ method: 'POST',
619
+ body: { block_index: mSnap.blocks.length - 1, replacement_markdown: mathMd },
620
+ })
621
+ assert.ok(mSug.ok, 'math suggestion accepted by the API: ' + JSON.stringify(mSug))
622
+ const mAcc = await api('dev:alice', `/api/docs/${mProj.id}/suggestions/${mSug.suggestion_id}/accept`, { method: 'POST' })
623
+ assert.equal(mAcc.ok, true, 'accept math: ' + JSON.stringify(mAcc))
624
+ await waitFor(async () => (await api('dev:alice', `/api/docs/${mProj.id}`)).markdown.includes('e^{i\\pi}'), 'math applied')
625
+ const mOut = (await api('dev:alice', `/api/docs/${mProj.id}`)).markdown
626
+ assert.ok(mOut.includes('$\\|Ax - b\\|_2^2$'), 'inline math round-trips: ' + mOut)
627
+ // the two formulas that markdown emphasis used to swallow
628
+ assert.ok(mOut.includes('$a * b$') && mOut.includes('$c * d$'), 'stars inside math survive: ' + mOut)
629
+ assert.ok(/\$\$\n\\int_0\^\\infty e\^\{-x\} dx = 1\n\$\$/.test(mOut), 'display math round-trips fenced: ' + mOut)
630
+ assert.ok(mOut.includes('| $e^{i\\pi}$ | $-1$ |'), 'math inside a table cell round-trips: ' + mOut)
631
+ assert.ok(mOut.includes('costs $5 and $10 more'), 'currency is not treated as math: ' + mOut)
632
+ await api('dev:alice', `/api/docs/${mProj.id}`, { method: 'DELETE' })
633
+ console.log('✓ LaTeX math: agent suggestion, accept, markdown round-trip (inline, display, table, currency)')
634
+
635
  // 11h. new-page suggestions: agent npProposes a whole page, human accepts/rejects
636
  const npjProj = await api('dev:alice', '/api/docs', { method: 'POST', body: { title: 'NP Wiki' } })
637
  await api('dev:alice', `/api/docs/${npjProj.id}/share`, { method: 'POST', body: { username: 'bob' } })