Spaces:
Sleeping
Sleeping
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 +36 -2
- client/doc.html +1 -1
- client/src/diff.js +7 -1
- client/src/doc.js +100 -8
- client/src/math.js +295 -0
- package-lock.json +26 -0
- package.json +2 -1
- scripts/build.js +12 -0
- server/api.js +1 -0
- server/collab.js +33 -2
- server/md.js +54 -2
- test/browser.js +123 -26
- test/smoke.js +36 -0
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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 —
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 ==
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2740 |
-
|
| 2741 |
-
|
| 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,
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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' } })
|