cowrite / client /src /doc.js
lvwerra's picture
lvwerra HF Staff
Math interaction: double-click to edit, single click selects, selection drags through a formula, comment box keeps the selection and quotes the source
f361450 verified
Raw
History Blame Contribute Delete
127 kB
import * as Y from 'yjs'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { Editor, Extension, Node } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import Image from '@tiptap/extension-image'
import { TaskList, TaskItem } from '@tiptap/extension-list'
import { InputRule } from '@tiptap/core'
import { MathInline, MathBlock, mathPreviewEl, renderMath, loadKatex } from './math.js'
// Typing "[ ] " / "[x] " at the start of a paragraph inside a bullet list item
// (i.e. the natural "- [ ] ") lifts that item out and turns the list into a
// task list. (Plain "[ ] " on an empty paragraph is already handled by TaskList.)
const CheckboxInList = TaskList.extend({
name: 'taskList',
addInputRules() {
return [
...(this.parent?.() || []),
new InputRule({
find: /^\[([ xX]?)\]\s$/,
handler: ({ state, range, match, chain }) => {
const parent = state.selection.$from.node(-1)?.type.name
const checked = match[1].toLowerCase() === 'x'
if (parent === 'listItem') {
// "- [ ] " inside a bullet item → convert the list to a task list
chain().deleteRange(range).toggleList('taskList', 'taskItem').updateAttributes('taskItem', { checked }).run()
} else if (parent === 'taskItem') {
// "- [x] " already in a task list → just set this item's checked state
chain().deleteRange(range).updateAttributes('taskItem', { checked }).run()
} else {
return null // bare paragraph: TaskList's own rule handles it
}
},
}),
]
},
})
import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table'
// Image with minimal layout: width (%) and alignment, kept as node attrs so
// they sync collaboratively. (v1 limitation: markdown export drops them.)
const LayoutImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
renderHTML: attrs => (attrs.width ? { style: `width:${attrs.width}%` } : {}),
parseHTML: element => {
const m = (element.getAttribute('style') || '').match(/width:\s*(\d+)%/)
return m ? Number(m[1]) : null
},
},
align: {
default: null,
renderHTML: attrs => (attrs.align ? { 'data-align': attrs.align } : {}),
parseHTML: element => element.getAttribute('data-align'),
},
}
},
})
// --- Arbitrary HTML embeds ---
// A model (or a person) can drop a live HTML snippet into the doc — for aligning
// figures, animated explainers, small interactive widgets. The snippet is
// UNTRUSTED and, in a shared doc, runs in every collaborator's browser, so it is
// NEVER inserted into the page DOM. It renders inside an <iframe sandbox> that
// gets an opaque origin (allow-scripts WITHOUT allow-same-origin) — scripts and
// animations run, but the frame cannot touch the parent DOM, cookies, the auth
// session, or the Yjs doc. A CSP inside the srcdoc blocks all network egress
// (default-src 'none') so a snippet can't phone home, while still allowing inline
// styles/scripts and images loaded over https/data:.
const EMBED_CSP = [
"default-src 'none'",
"img-src data: https:",
"media-src data: https: blob:",
"style-src 'unsafe-inline'",
"font-src data: https:",
"script-src 'unsafe-inline'",
"frame-src 'none'",
].join('; ')
function buildEmbedSrcdoc(html) {
return (
'<!doctype html><html><head><meta charset="utf-8">' +
`<meta http-equiv="Content-Security-Policy" content="${EMBED_CSP}">` +
'<style>html,body{margin:0;padding:0}*{box-sizing:border-box}' +
'html,body{overflow:hidden!important}' +
'body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#1a1a1a}</style>' +
'</head><body>' +
(html || '') +
// report full content height to the parent so the iframe can size itself.
// scrollHeight (not getBoundingClientRect) captures overflow, so the frame is
// never a hair too short — which is what produced a stray inner scrollbar.
'<script>(function(){function r(){try{var b=document.body,d=document.documentElement,' +
'h=Math.max(b?b.scrollHeight:0,b?b.offsetHeight:0,d.scrollHeight,d.offsetHeight);' +
'parent.postMessage({__cowriteEmbed:1,h:Math.ceil(h)},"*")}catch(e){}}' +
'if(window.ResizeObserver){new ResizeObserver(r).observe(document.documentElement)}' +
'window.addEventListener("load",r);setTimeout(r,50);setTimeout(r,400);r()})()<\/script>' +
'</body></html>'
)
}
function mountEmbedIframe(iframe, html) {
iframe.setAttribute('sandbox', 'allow-scripts allow-popups')
iframe.setAttribute('referrerpolicy', 'no-referrer')
iframe.setAttribute('loading', 'lazy')
iframe.className = 'html-embed'
iframe.srcdoc = buildEmbedSrcdoc(html)
}
// modal textarea for authoring/editing a snippet's HTML
function editHtmlOverlay(initial, onSave) {
const overlay = el('div', { class: 'embed-overlay' })
const box = el('div', { class: 'embed-overlay-box' })
const title = el('div', { class: 'embed-overlay-title' }, 'HTML embed')
const hint = el('div', { class: 'embed-overlay-hint' }, 'Runs sandboxed — no access to the page, your account, or the network.')
const ta = document.createElement('textarea')
ta.className = 'embed-overlay-ta'
ta.spellcheck = false
ta.value = initial || ''
const barEl = el('div', { class: 'embed-overlay-bar' })
const cancel = el('button', { class: 'btn', type: 'button' }, 'Cancel')
const save = el('button', { class: 'btn primary', type: 'button' }, 'Save')
const close = () => overlay.remove()
cancel.addEventListener('click', close)
save.addEventListener('click', () => { onSave(ta.value); close() })
overlay.addEventListener('mousedown', e => { if (e.target === overlay) close() })
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc) }
})
barEl.append(cancel, save)
box.append(title, hint, ta, barEl)
overlay.appendChild(box)
document.body.appendChild(overlay)
ta.focus()
}
const HtmlBlock = Node.create({
name: 'htmlBlock',
group: 'block',
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return { html: { default: '' } }
},
parseHTML() {
return [{ tag: 'div[data-html-embed]', getAttrs: node => ({ html: node.getAttribute('data-html') || '' }) }]
},
renderHTML({ HTMLAttributes }) {
return ['div', { 'data-html-embed': '', 'data-html': HTMLAttributes.html || '' }]
},
addNodeView() {
return ({ node, getPos, editor }) => {
let current = node
const wrap = el('div', { class: 'html-embed-wrap', contenteditable: 'false' })
const iframe = document.createElement('iframe')
mountEmbedIframe(iframe, current.attrs.html)
const onMsg = e => {
if (e.source !== iframe.contentWindow) return
const d = e.data
if (d && d.__cowriteEmbed && typeof d.h === 'number') {
iframe.style.height = Math.max(24, Math.min(d.h, 4000)) + 'px'
}
}
window.addEventListener('message', onMsg)
const editBtn = el('button', { class: 'html-embed-edit', type: 'button', contenteditable: 'false' }, 'Edit HTML')
editBtn.addEventListener('mousedown', e => e.preventDefault())
editBtn.addEventListener('click', () => {
if (!editor.isEditable) return
editHtmlOverlay(current.attrs.html, html => {
const pos = typeof getPos === 'function' ? getPos() : null
if (pos == null) return
editor.view.dispatch(editor.view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, html }))
})
})
wrap.append(iframe, editBtn)
return {
dom: wrap,
update(updated) {
if (updated.type.name !== 'htmlBlock') return false
if (updated.attrs.html !== current.attrs.html) iframe.srcdoc = buildEmbedSrcdoc(updated.attrs.html)
current = updated
return true
},
destroy() { window.removeEventListener('message', onMsg) },
ignoreMutation() { return true },
}
}
},
})
// IMPORTANT: use tiptap's y-prosemirror fork — the Collaboration extension registers
// its sync plugin from @tiptap/y-tiptap, so y-prosemirror's ySyncPluginKey is a
// *different* PluginKey instance and getState() would return undefined.
import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, relativePositionToAbsolutePosition } from '@tiptap/y-tiptap'
import { markdownToBlocks } from '../../server/md.js'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme } from './common.js'
import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
const docId = location.pathname.split('/').filter(Boolean)[1]
// current page — mutable: switching pages swaps the document client-side
// without reloading the shell (header, sidebar)
let pageSlug = ''
let docName = ''
let pageQS = ''
function setPage(slug) {
pageSlug = slug
docName = slug === 'home' ? docId : `${docId}::${slug}`
pageQS = slug === 'home' ? '' : `?page=${slug}`
}
setPage(location.pathname.split('/').filter(Boolean)[2] || 'home')
const state = {
me: null,
agents: [],
activeItem: null, // id of the expanded card (thread or suggestion)
showResolved: false,
composerAnchors: null,
suggestAnchors: null,
suggestRange: null, // block range the open suggest box was built for
mode: 'editing', // 'editing' | 'suggesting' — the header switch, like Docs
autoComposer: null, // which composer a selection opened, so it can close itself
composerDirty: false, // the user typed in it — never clobber or auto-close then
chainPos: {}, // revision-chain root id -> displayed generation index
previewPid: null, // proposed-page currently previewed
}
let ydoc = null
let threads = null
let suggestions = null
let provider = null
main()
async function main() {
state.me = await initAuth(`/d/${docId}`)
if (!state.me) return
// --- one-time shell wiring (survives page switches) ---
document.getElementById('back-link').appendChild(icon('back'))
wireTheme(document.getElementById('theme-btn'))
wireSelectionMenu()
wireMobileChrome()
wireMarginToggle()
wireComposer()
wireSuggestComposer()
wireAgentsPanel()
wireSharePanel()
wireSidebar()
document.getElementById('show-resolved').addEventListener('change', e => {
state.showResolved = e.target.checked
renderMargin(window.__editor)
})
window.addEventListener('resize', () => scheduleLayout(window.__editor))
new ResizeObserver(() => scheduleLayout(window.__editor)).observe(document.getElementById('editor'))
// collapse the active card when clicking outside cards / composers / menus
document.addEventListener('mousedown', e => {
if (e.target.closest('#more-pop, #m-more, #margin-head')) return
// switching mode is not "clicking away" — it must not dismiss what you are reading
if (e.target.closest('.card, .composer-box, #selection-menu, .mention-menu, #agents-pop, #agents-btn, #share-pop, #share-btn, #link-pop, #mode-switch')) return
if (state.activeItem) {
state.activeItem = null
renderMargin(window.__editor)
}
for (const id of ['agents-pop', 'share-pop', 'more-pop']) {
document.getElementById(id).classList.add('hidden')
}
document.getElementById('m-more').classList.remove('on')
})
// back/forward navigates between pages client-side
window.addEventListener('popstate', () => {
const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
if (slug !== pageSlug) openPage(slug, { push: false })
})
loadStructure() // in flight while the document syncs, not after it
await initPage()
setInterval(loadStructure, 15000)
window.addEventListener('focus', () => loadStructure())
refreshAgents()
setInterval(refreshAgents, 20000)
// playground projects get a reset button (creator only)
const meta = await api(`/api/docs/${docId}`)
if (meta?.playground && meta.created_by === state.me.username) {
const reset = el('button', { class: 'btn ghost small', id: 'reset-btn', title: 'Restore the playground to its seeded state' }, 'Reset')
reset.addEventListener('click', async () => {
if (!confirm('Reset the playground to its original seeded state? All changes here are lost.')) return
reset.disabled = true
const res = await api(`/api/docs/${docId}/reset`, { method: 'POST' })
if (res.error) {
alert(res.error)
reset.disabled = false
return
}
location.href = `/d/${docId}`
})
// into the header itself (not next to #share-btn, which on a phone has
// already moved into the ⋯ sheet), then adopted if we are on a phone
document.getElementById('topbar').insertBefore(reset, document.getElementById('m-fmt'))
adoptIntoMoreMenu(reset)
}
}
// --- per-page lifecycle -------------------------------------------------------
// The skeleton sheet covers the editor until the document has actually synced.
// On an in-app page switch it only appears if the switch is slow enough to
// notice — flashing it for 100ms reads as a glitch.
let skeletonTimer = null
function showDocSkeleton({ delay = 0 } = {}) {
const sk = document.getElementById('doc-skeleton')
if (!sk) return
clearTimeout(skeletonTimer)
if (!delay) return sk.classList.remove('hidden')
skeletonTimer = setTimeout(() => sk.classList.remove('hidden'), delay)
}
function hideDocSkeleton() {
clearTimeout(skeletonTimer)
document.getElementById('doc-skeleton')?.classList.add('hidden')
}
async function initPage() {
// Visiting a page that the structure yaml references but that doesn't exist
// yet (a "ghost") creates it, titled after its slug. Started here but NOT
// awaited: it is a write on the side, and making the editor wait for two
// round trips before it can even connect was most of a subpage's load time.
// Access is checked per project, so the socket does not need the page record.
const ghostPage =
pageSlug !== 'home' && pageSlug !== '_structure'
? api(`/api/docs/${docId}/structure`).then(s => {
if (!s?.titles || pageSlug in s.titles) return
const pretty = pageSlug.replace(/-/g, ' ')
return api(`/api/docs/${docId}/pages`, {
method: 'POST',
body: { title: pretty.charAt(0).toUpperCase() + pretty.slice(1), slug: pageSlug },
})
})
: null
exitProposalPreview()
ydoc = new Y.Doc()
threads = ydoc.getMap('threads')
suggestions = ydoc.getMap('suggestions')
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/collab`
provider = new HocuspocusProvider({
url: wsUrl,
name: docName,
document: ydoc,
// the build id lets the server refuse stale tabs (diverged replicas)
token: 'cookie:' + (state.me.buildId || ''),
onSynced: () => {
hideDocSkeleton()
},
onStatus: ({ status }) => {
wsLive = status === 'connected'
document.getElementById('conn').className = status === 'connected' ? 'on' : ''
if (status === 'connected') sessionStorage.removeItem('cw-reloaded')
updateOfflineNotice()
},
onDisconnect: () => {
wsLive = false
updateOfflineNotice()
},
onAuthenticationFailed: e => {
hideDocSkeleton()
handleAuthFailed(e)
},
})
const editor = buildEditor(provider)
window.__editor = editor
window.__provider = provider
window.__ydoc = ydoc
window.__Y = Y
buildHeaderTools(editor)
buildModeSwitch(editor)
bindSelectionMenu(editor)
// re-render cards AND poke the editor so decorations (highlights, inline
// diffs) refresh — remote changes don't produce an editor transaction
threads.observeDeep(() => {
renderMargin(editor)
poke(editor)
})
suggestions.observeDeep(() => {
renderMargin(editor)
poke(editor)
})
editor.on('update', () => {
updateTitle(editor)
scheduleLayout(editor)
})
provider.on('awarenessUpdate', () => renderPresence(provider))
wireUndoReopens(editor)
wireImageBubble(editor)
wireTableControls(editor)
wirePageLinkMenu(editor)
if (pageSlug === '_structure') {
// editing the yaml updates the tree almost live
let t = null
editor.on('update', () => {
clearTimeout(t)
t = setTimeout(loadStructure, 500)
})
}
document.getElementById('layout').classList.toggle('structure-mode', pageSlug === '_structure')
document.getElementById('structure-hint')?.remove()
if (pageSlug === '_structure') {
const hint = el(
'div',
{ id: 'structure-hint' },
'This YAML is the page tree: one "- slug" line per page, indent two spaces to nest. Pages missing here show under "unfiled" — nothing is ever deleted by editing this. Changes can also be proposed as suggestions.'
)
document.getElementById('editor').before(hint)
}
renderMargin(editor)
setTimeout(() => isLive(editor) && updateTitle(editor), 600)
// a ghost page appears in the tree as soon as its record lands
ghostPage?.then(() => loadStructure()).catch(() => {})
// never leave the skeleton up on a doc that will not sync (dead socket,
// rejected build): the real state, including the offline notice, must show.
// Scoped to this page, so it cannot lift a later page's skeleton early.
setTimeout(() => isLive(editor) && hideDocSkeleton(), 8000)
}
function teardownPage() {
try {
provider?.destroy()
} catch {}
try {
window.__editor?.destroy()
} catch {}
window.__editor = null // no stale handle for a deferred callback to find
try {
ydoc?.destroy()
} catch {}
provider = null
state.activeItem = null
state.chainPos = {}
state.composerAnchors = null
state.suggestAnchors = null
document.getElementById('composer').classList.add('hidden')
document.getElementById('suggest-composer').classList.add('hidden')
document.getElementById('selection-menu').classList.add('hidden')
document.querySelectorAll('#margin-items .card, #margin-items .margin-empty').forEach(n => n.remove())
}
// switch pages client-side: swap the document, keep header + sidebar
async function openPage(slug, { push = true } = {}) {
closeDrawerOnPhone() // the drawer covers the document it just navigated to
if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) {
// leaving a proposal preview back to the same page
} else if (slug === pageSlug) {
return
}
exitProposalPreview()
teardownPage()
setPage(slug)
if (push) history.pushState({}, '', slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`)
showDocSkeleton({ delay: 180 })
await initPage()
loadStructure()
}
// Is this tab's collaboration socket actually delivering changes? Everything
// typed or accepted while it is down never reaches the server, so actions that
// must persist check this first.
let wsLive = false
let wsDoomed = false // rejected as outdated: reconnecting cannot help
function isWsLive() {
return wsLive && !wsDoomed
}
function updateOfflineNotice() {
const existing = document.getElementById('offline-notice')
if (isWsLive() || wsDoomed) {
existing?.remove()
return
}
if (existing) return
const bar = el('div', { id: 'offline-notice' }, 'Disconnected — changes are not being saved. Reconnecting…')
document.body.appendChild(bar)
}
// A dead socket means edits are being thrown away. Say so instead of letting the
// action look like it worked.
function requireLiveConnection(what) {
if (isWsLive()) return true
if (wsDoomed) {
showUpdateBanner()
alert(`This tab is running an outdated version, so ${what} cannot be saved. Reload the page and try again.`)
} else {
alert(`Not connected right now, so ${what} cannot be saved. Once the dot next to the title turns green, try again.`)
}
return false
}
function handleAuthFailed({ reason }) {
if (!String(reason || '').includes('outdated')) return
wsLive = false
wsDoomed = true
// stop the retry loop: this tab can never sync again, and every reconnect
// attempt was another rejection (one a minute, for hours, in the wild)
try {
provider?.destroy()
} catch {}
try {
// the provider owns its socket; without this the connection checker kept
// reconnecting and being rejected once a minute (188 times in one session)
provider?.configuration?.websocketProvider?.destroy?.()
} catch {}
if (!sessionStorage.getItem('cw-reloaded')) {
sessionStorage.setItem('cw-reloaded', '1')
location.reload()
return
}
// the reload already happened once and we are still outdated (a cached
// bundle): make the tab read-only rather than let edits pile up unsaved
lockedOut = true
try {
window.__editor?.setEditable(false)
} catch {}
document.getElementById('offline-notice')?.remove()
showUpdateBanner()
}
function showUpdateBanner() {
if (document.getElementById('update-banner')) return
const bar = el(
'div',
{ id: 'update-banner' },
'This tab is out of date — it is not saving changes. Reload to keep editing. '
)
const a = el('a', { href: location.pathname }, 'Reload now')
bar.appendChild(a)
document.body.appendChild(bar)
}
// --- [[ page cross-references ---------------------------------------------------
// Type "[[" to link another page: an autocomplete of page titles appears;
// selecting one inserts the title as a link. Ctrl/Cmd+click follows links.
let pageLinkController = null
function pageLinkKey(key) {
return pageLinkController ? pageLinkController(key) : false
}
function wirePageLinkMenu(editor) {
document.getElementById('pagelink-menu')?.remove()
const menu = el('div', { class: 'mention-menu hidden', id: 'pagelink-menu' })
document.body.appendChild(menu)
let items = []
let sel = 0
let range = null
const hide = () => {
menu.classList.add('hidden')
range = null
items = []
}
const apply = item => {
if (!range) return
const href = item.slug === 'home' ? `/d/${docId}` : `/d/${docId}/${item.slug}`
editor
.chain()
.focus()
.deleteRange(range)
.insertContent([
{ type: 'text', text: item.title, marks: [{ type: 'link', attrs: { href } }] },
{ type: 'text', text: ' ' },
])
.run()
hide()
}
const render = () => {
menu.replaceChildren(
...items.map((item, i) => {
const row = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') })
row.appendChild(el('span', {}, item.title))
row.appendChild(el('span', { class: 'muted', style: 'margin-left:8px;font-size:11px' }, item.slug))
row.addEventListener('mousedown', e => {
e.preventDefault()
apply(item)
})
return row
})
)
}
const update = () => {
const { state: pmState } = editor
const { from, empty } = pmState.selection
if (!empty) return hide()
const before = pmState.doc.textBetween(Math.max(0, from - 42), from, '\n', '\n')
const m = before.match(/\[\[([^\[\]]{0,32})$/)
if (!m) return hide()
const q = m[1].toLowerCase()
items = Object.entries(state.pageTitles || {})
.filter(([slug]) => slug !== '_structure' && slug !== pageSlug)
.map(([slug, title]) => ({ slug, title }))
.filter(p => p.title.toLowerCase().includes(q) || p.slug.includes(q))
.slice(0, 8)
if (!items.length) return hide()
range = { from: from - m[0].length, to: from }
sel = Math.min(sel, items.length - 1)
render()
menu.classList.remove('hidden')
try {
const c = editor.view.coordsAtPos(from)
menu.style.left = `${Math.max(8, c.left)}px`
menu.style.top = `${c.bottom + 4}px`
} catch {}
}
editor.on('transaction', () => setTimeout(update, 0))
pageLinkController = key => {
if (!range || menu.classList.contains('hidden')) return false
if (key === 'Enter') {
apply(items[sel])
return true
}
if (key === 'Down') {
sel = (sel + 1) % items.length
render()
return true
}
if (key === 'Up') {
sel = (sel - 1 + items.length) % items.length
render()
return true
}
if (key === 'Esc') {
hide()
return true
}
return false
}
}
// --- table controls -----------------------------------------------------------
// A floating bar that appears above the table your cursor is in, with add/remove
// row & column and delete-table actions (TipTap has no default table UI).
function wireTableControls(editor) {
document.getElementById('table-controls')?.remove()
const bar = el('div', { id: 'table-controls', class: 'hidden' })
const add = (label, tip, cmd) => {
const b = el('button', { class: 'iconbtn', 'data-tip': tip }, label)
b.addEventListener('mousedown', e => {
e.preventDefault()
cmd()
})
bar.appendChild(b)
return b
}
add('+ Row', 'Add a row below', () => editor.chain().focus().addRowAfter().run())
add('− Row', 'Delete this row', () => editor.chain().focus().deleteRow().run())
bar.appendChild(el('span', { class: 'sep' }))
add('+ Col', 'Add a column to the right', () => editor.chain().focus().addColumnAfter().run())
add('− Col', 'Delete this column', () => editor.chain().focus().deleteColumn().run())
bar.appendChild(el('span', { class: 'sep' }))
const del = add('⌫', 'Delete the whole table', () => {
if (confirm('Delete this table?')) editor.chain().focus().deleteTable().run()
})
del.classList.add('danger')
document.body.appendChild(bar)
const update = () => {
if (editor.isDestroyed || !editor.isActive('table')) {
bar.classList.add('hidden')
return
}
let dom = editor.view.domAtPos(editor.state.selection.from)?.node
if (dom?.nodeType === 3) dom = dom.parentElement
const table = dom?.closest?.('table')
if (!table) {
bar.classList.add('hidden')
return
}
const r = table.getBoundingClientRect()
bar.classList.remove('hidden')
bar.style.left = `${Math.max(8, r.left)}px`
bar.style.top = `${Math.max(8, r.top - bar.offsetHeight - 6)}px`
}
editor.on('selectionUpdate', update)
editor.on('transaction', update)
window.addEventListener('scroll', update, true)
}
// --- image layout bubble -------------------------------------------------------
function wireImageBubble(editor) {
document.getElementById('image-bubble')?.remove()
const bubble = el('div', { id: 'image-bubble', class: 'hidden' })
const btn = (label, title, onClick) => {
const b = el('button', { class: 'iconbtn', title }, label)
b.addEventListener('mousedown', e => {
e.preventDefault()
onClick()
})
bubble.appendChild(b)
return b
}
const sizeBtns = [
['S', 33],
['M', 50],
['L', 75],
['◻', 100],
].map(([label, width]) => ({ width, node: btn(label, `Width ${width}%`, () => editor.chain().focus().updateAttributes('image', { width: width === 100 ? null : width }).run()) }))
bubble.appendChild(el('span', { class: 'sep' }))
const alignBtns = [
['⇤', 'left'],
['↔', 'center'],
['⇥', 'right'],
].map(([label, align]) => ({ align, node: btn(label, `Align ${align}`, () => editor.chain().focus().updateAttributes('image', { align }).run()) }))
document.body.appendChild(bubble)
const update = () => {
if (editor.isDestroyed) return
const sel = editor.state.selection
const node = sel.node?.type?.name === 'image' ? sel.node : null
if (!node) {
bubble.classList.add('hidden')
return
}
const dom = editor.view.nodeDOM(sel.from)
if (!dom?.getBoundingClientRect) {
bubble.classList.add('hidden')
return
}
const rect = dom.getBoundingClientRect()
bubble.classList.remove('hidden')
bubble.style.left = `${Math.max(8, rect.left + rect.width / 2 - bubble.offsetWidth / 2)}px`
bubble.style.top = `${Math.max(8, rect.top - bubble.offsetHeight - 8)}px`
const width = node.attrs.width || 100
for (const s of sizeBtns) s.node.classList.toggle('on', width === s.width)
for (const a of alignBtns) a.node.classList.toggle('on', (node.attrs.align || 'left') === a.align)
}
editor.on('selectionUpdate', update)
editor.on('transaction', update)
window.addEventListener('scroll', update, true)
}
// --- comments margin toggle ----------------------------------------------------
// Phones get a different shell: compact header, drawer, bottom sheet. Kept as a
// live query (not a one-off boolean) so rotating a device re-evaluates.
function isPhone() {
return window.matchMedia('(max-width: 700px)').matches
}
// below this width cards stack instead of floating next to their anchor text
function isStackedMargin() {
return window.matchMedia('(max-width: 980px)').matches
}
function setMarginHidden(hidden, persist = true) {
document.getElementById('margin-col').classList.toggle('hidden', hidden)
document.body.classList.toggle('sheet-open', !hidden && isStackedMargin())
document.getElementById('margin-toggle').textContent = hidden ? '◂' : '▸'
document.getElementById('m-comments').classList.toggle('on', !hidden)
// narrow windows show it as a transient sheet — don't remember that as a preference
if (persist && !isStackedMargin()) localStorage.setItem('margin-hidden', hidden ? '1' : '0')
// text can only be measured for clamping once the panel is really visible
if (!hidden) requestAnimationFrame(() => clampLongText(document.getElementById('margin-items')))
scheduleLayout(window.__editor)
}
function wireMarginToggle() {
const stored = localStorage.getItem('margin-hidden')
// narrow windows show the panel as a sheet over the document, so it always
// starts closed there; wide ones remember the last choice
const hidden = isStackedMargin() ? true : stored == null ? window.matchMedia('(max-width: 1150px)').matches : stored === '1'
setMarginHidden(hidden, false)
const toggle = () => setMarginHidden(!document.getElementById('margin-col').classList.contains('hidden'))
document.getElementById('margin-toggle').addEventListener('click', toggle)
document.getElementById('m-comments').addEventListener('click', toggle)
document.getElementById('margin-close').addEventListener('click', () => setMarginHidden(true))
}
// --- phone chrome ------------------------------------------------------------
// The header only fits a handful of controls on a phone, so Share / Agents /
// identity move into a "⋯" sheet and the formatting toolbar becomes a second,
// horizontally scrollable header row toggled by "Aa". The nodes themselves are
// MOVED, not rebuilt, so every listener wired elsewhere keeps working.
const moreMenuHome = [] // [{ node, parent, next }] to restore on wide screens
function wireMobileChrome() {
document.getElementById('m-pages').appendChild(icon('tree'))
document.getElementById('m-comments').appendChild(icon('comment'))
document.getElementById('m-comments').appendChild(el('span', { class: 'count hidden' }, '0'))
document.getElementById('sidebar-close').appendChild(icon('x'))
document.getElementById('margin-close').appendChild(icon('x'))
const pop = document.getElementById('more-pop')
document.getElementById('m-more').addEventListener('click', e => {
e.stopPropagation()
const opening = pop.classList.contains('hidden')
for (const id of ['agents-pop', 'share-pop']) document.getElementById(id).classList.add('hidden')
pop.classList.toggle('hidden', !opening)
document.getElementById('m-more').classList.toggle('on', opening)
})
// choosing anything inside the sheet closes it
pop.addEventListener('click', e => {
if (!e.target.closest('button, a')) return
pop.classList.add('hidden')
document.getElementById('m-more').classList.remove('on')
})
const fmt = document.getElementById('m-fmt')
fmt.addEventListener('click', () => {
const on = !document.body.classList.contains('fmt-open')
document.body.classList.toggle('fmt-open', on)
fmt.classList.toggle('on', on)
syncHeaderHeight()
})
applyBreakpoint()
let wasPhone = isPhone()
let wasNarrow = isStackedMargin()
window.addEventListener('resize', () => {
syncHeaderHeight()
if (isPhone() !== wasPhone) {
wasPhone = isPhone()
applyBreakpoint()
}
if (isStackedMargin() !== wasNarrow) {
wasNarrow = isStackedMargin()
// crossing into drawer/sheet territory: nothing may cover the document
// unasked; crossing back out: restore the remembered panel state
if (wasNarrow) {
setSidebarHidden(true, false)
setMarginHidden(true, false)
} else {
document.getElementById('scrim').classList.add('hidden')
setSidebarHidden(localStorage.getItem('sidebar-hidden') === '1', false)
setMarginHidden(localStorage.getItem('margin-hidden') === '1', false)
}
}
})
new ResizeObserver(syncHeaderHeight).observe(document.getElementById('topbar'))
syncHeaderHeight()
}
// Popovers, the drawer and the sheet all hang off the header's real height,
// which changes when the formatting row opens or the title wraps.
function syncHeaderHeight() {
const h = document.getElementById('topbar').offsetHeight
document.documentElement.style.setProperty('--hdr-h', `${h}px`)
}
// Move the crowded-out header controls in and out of the ⋯ sheet.
function applyBreakpoint() {
const pop = document.getElementById('more-pop')
const movable = () => ['mode-switch', 'theme-btn', 'share-btn', 'agents-btn', 'reset-btn', 'whoami'].map(id => document.getElementById(id)).filter(Boolean)
if (isPhone()) {
for (const node of movable()) {
if (pop.contains(node)) continue
moreMenuHome.push({ node, parent: node.parentNode, next: node.nextSibling })
pop.appendChild(node)
}
} else {
document.body.classList.remove('fmt-open')
document.getElementById('m-fmt').classList.remove('on')
while (moreMenuHome.length) {
const { node, parent, next } = moreMenuHome.pop()
parent.insertBefore(node, next && next.parentNode === parent ? next : null)
}
pop.classList.add('hidden')
document.getElementById('scrim').classList.add('hidden')
}
syncHeaderHeight()
}
// A control added to the header after startup (the playground Reset button)
// belongs in the ⋯ sheet on a phone.
function adoptIntoMoreMenu(node) {
if (!isPhone()) return
moreMenuHome.push({ node, parent: node.parentNode, next: node.nextSibling })
const pop = document.getElementById('more-pop')
// identity stays last in the sheet, below the divider
pop.insertBefore(node, pop.querySelector('#whoami'))
syncHeaderHeight()
}
// On the comments button: how many open threads/suggestions this page has.
function setCommentCount(n) {
const badge = document.querySelector('#m-comments .count')
if (!badge) return
badge.textContent = n > 99 ? '99+' : String(n)
badge.classList.toggle('hidden', n === 0)
}
// On the structure page: a reference of page titles -> slugs (the yaml wants
// slugs, but everywhere else shows titles). Click a slug to copy it.
function renderSlugLegend(s) {
const existing = document.getElementById('slug-legend')
if (pageSlug !== '_structure') {
existing?.remove()
return
}
const box = existing || el('div', { id: 'slug-legend' })
const table = el('table', {})
const thead = el('thead', {})
const hr = el('tr', {})
hr.append(el('th', {}, 'Page'), el('th', {}, 'Slug (click to copy)'))
thead.appendChild(hr)
table.appendChild(thead)
const tbody = el('tbody', {})
for (const [slug, title] of Object.entries(s.titles || {})) {
if (slug === '_structure') continue
const tr = el('tr', {})
const tdTitle = el('td', {})
const a = el('a', { href: slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}` }, title || slug)
a.addEventListener('click', e => {
if (e.metaKey || e.ctrlKey || e.button !== 0) return
e.preventDefault()
openPage(slug)
})
tdTitle.appendChild(a)
const tdSlug = el('td', {})
const code = el('code', { title: 'Click to copy' }, slug)
code.addEventListener('click', () => {
navigator.clipboard?.writeText(slug)
code.classList.add('copied')
setTimeout(() => code.classList.remove('copied'), 800)
})
tdSlug.appendChild(code)
tr.append(tdTitle, tdSlug)
tbody.appendChild(tr)
}
table.appendChild(tbody)
box.replaceChildren(el('div', { class: 'legend-title' }, 'Pages in this project'), table)
if (!existing) document.getElementById('editor').after(box)
}
// --- pages sidebar -----------------------------------------------------------
function setSidebarHidden(hidden, persist = true) {
document.getElementById('sidebar').classList.toggle('hidden', hidden)
document.getElementById('sidebar-toggle').textContent = hidden ? '▸' : '◂'
document.getElementById('m-pages').classList.toggle('on', !hidden)
// as a drawer it floats over the document, so it needs a dismiss scrim
document.getElementById('scrim').classList.toggle('hidden', hidden || !isStackedMargin())
if (persist && !isStackedMargin()) localStorage.setItem('sidebar-hidden', hidden ? '1' : '0')
scheduleLayout(window.__editor)
}
// tapping a page inside the drawer should get out of the way immediately
function closeDrawerOnPhone() {
if (isStackedMargin()) setSidebarHidden(true, false)
}
function wireSidebar() {
const sidebar = document.getElementById('sidebar')
const stored = localStorage.getItem('sidebar-hidden')
// on narrow screens the sidebar is a drawer — closed by default
const hidden = isStackedMargin() ? true : stored === '1'
setSidebarHidden(hidden, false)
const toggle = () => setSidebarHidden(!sidebar.classList.contains('hidden'))
document.getElementById('sidebar-toggle').addEventListener('click', toggle)
document.getElementById('m-pages').addEventListener('click', toggle)
document.getElementById('sidebar-close').addEventListener('click', () => setSidebarHidden(true))
document.getElementById('scrim').addEventListener('click', () => setSidebarHidden(true))
document.getElementById('page-add').addEventListener('click', async () => {
const title = prompt('Page title')
if (!title?.trim()) return
const res = await api(`/api/docs/${docId}/pages`, { method: 'POST', body: { title: title.trim() } })
if (res.error) return alert(res.error)
openPage(res.slug)
})
const structureLink = document.getElementById('structure-link')
structureLink.href = `/d/${docId}/_structure`
structureLink.replaceChildren(icon('tree'), document.createTextNode(' Structure'))
structureLink.addEventListener('click', e => {
if (e.metaKey || e.ctrlKey || e.button !== 0) return
e.preventDefault()
openPage('_structure')
})
if (pageSlug === '_structure') structureLink.classList.add('current')
}
async function loadStructure() {
const s = await api(`/api/docs/${docId}/structure`)
if (s?.error) return
state.pageTitles = s.titles || {}
document.getElementById('structure-link')?.classList.toggle('current', pageSlug === '_structure')
renderSlugLegend(s)
const container = document.getElementById('page-tree')
const known = new Set(Object.keys(s.titles || {}))
known.add(pageSlug) // visiting auto-creates, so the current page is never a ghost
const pageHref = slug => (slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`)
const deletablePage = slug => slug !== 'home' && slug !== '_structure' && known.has(slug)
const pageDeleteBtn = slug => {
const b = el('button', { class: 'iconbtn page-del', 'data-tip': `Delete page "${s.titles?.[slug] || slug}" for everyone` })
b.appendChild(icon('trash'))
b.addEventListener('click', async e => {
e.preventDefault()
e.stopPropagation()
if (!confirm(`Delete the page "${s.titles?.[slug] || slug}" for everyone? This cannot be undone.`)) return
const res = await api(`/api/docs/${docId}/pages/${slug}`, { method: 'DELETE' })
if (res.error) return alert(res.error)
if (slug === pageSlug) openPage('home')
else loadStructure()
})
return b
}
const renderNodes = nodes => {
const ul = el('ul', {})
for (const node of nodes) {
const li = el('li', {})
const a = el(
'a',
{
href: pageHref(node.slug),
class: (node.slug === pageSlug ? 'current' : '') + (known.has(node.slug) ? '' : ' ghost-page'),
title: known.has(node.slug) ? '' : `no page "${node.slug}" yet — create it or fix the structure yaml`,
},
s.titles?.[node.slug] || node.slug
)
a.addEventListener('click', e => {
if (e.metaKey || e.ctrlKey || e.button !== 0) return
e.preventDefault()
openPage(node.slug)
})
if (deletablePage(node.slug)) a.appendChild(pageDeleteBtn(node.slug))
li.appendChild(a)
if (node.children?.length) li.appendChild(renderNodes(node.children))
ul.appendChild(li)
}
return ul
}
container.innerHTML = ''
container.appendChild(renderNodes(s.tree || []))
if (s.unfiled?.length) {
container.appendChild(el('div', { class: 'unfiled-label' }, 'unfiled'))
const ul = el('ul', {})
for (const slug of s.unfiled) {
if (slug === 'home') continue
const li = el('li', {})
const a = el('a', { href: pageHref(slug), class: slug === pageSlug ? 'current' : '' }, s.titles?.[slug] || slug)
a.addEventListener('click', e => {
if (e.metaKey || e.ctrlKey || e.button !== 0) return
e.preventDefault()
openPage(slug)
})
if (deletablePage(slug)) a.appendChild(pageDeleteBtn(slug))
li.appendChild(a)
ul.appendChild(li)
}
if (ul.children.length) container.appendChild(ul)
}
// proposed new pages (open new-page suggestions) — visible before acceptance
if (s.pending?.length) {
container.appendChild(el('div', { class: 'unfiled-label' }, 'proposed'))
const ul = el('ul', {})
for (const p of s.pending) {
const li = el('li', {})
const a = el(
'a',
{
href: '#',
class: 'pending-page' + (state.previewPid === p.id ? ' current' : ''),
title: `Proposed by ${p.author_type === 'agent' ? '@' + p.author : p.author}${p.rationale ? ' — ' + p.rationale : ''}`,
},
p.title || p.slug
)
a.appendChild(el('span', { class: 'pending-badge' }, p.author_type === 'agent' ? '@' + p.author : 'proposed'))
a.addEventListener('click', e => {
e.preventDefault()
openProposalPreview(p.id)
})
li.appendChild(a)
ul.appendChild(li)
}
container.appendChild(ul)
}
}
// --- new-page proposal preview ------------------------------------------------
function exitProposalPreview() {
const view = document.getElementById('proposal-view')
if (!view) return
view.classList.add('hidden')
view.replaceChildren()
document.getElementById('editor').style.display = ''
state.previewPid = null
}
async function openProposalPreview(pid) {
exitProposalPreview()
teardownPage()
state.previewPid = pid
document.getElementById('editor').style.display = 'none'
const view = document.getElementById('proposal-view')
view.classList.remove('hidden')
loadStructure()
const p = await api(`/api/docs/${docId}/page-suggestions/${pid}`)
if (p.error) {
view.textContent = p.error
return
}
document.getElementById('doc-title').textContent = p.title + ' — proposed'
document.title = p.title + ' — proposed'
const bar = el('div', { class: 'proposal-bar' })
bar.appendChild(
el('div', { class: 'proposal-meta' }, `Proposed new page by ${p.author_type === 'agent' ? '@' + p.author : p.author}${p.rationale ? ' — ' + p.rationale : ''}`)
)
const actions = el('div', { class: 'proposal-actions' })
const reject = el('button', { class: 'btn small ghost' }, 'Reject')
reject.addEventListener('click', async () => {
if (!confirm(`Reject the proposed page "${p.title}"?`)) return
const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/reject`, { method: 'POST' })
if (r.error) return alert(r.error)
exitProposalPreview()
openPage('home')
})
const accept = el('button', { class: 'btn small primary' }, 'Accept & create page')
accept.addEventListener('click', async () => {
const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/accept`, { method: 'POST' })
if (r.error) return alert(r.error)
exitProposalPreview()
openPage(r.slug)
})
actions.append(reject, accept)
bar.appendChild(actions)
const content = el('div', { class: 'proposal-content tiptap' })
content.innerHTML = markdownToBlocks(p.content_markdown).map(blockToHtml).join('')
typesetMathIn(content)
view.replaceChildren(bar, content)
}
function updateTitle(editor) {
if (pageSlug === '_structure') {
document.getElementById('doc-title').textContent = 'Structure'
document.title = 'Structure'
return
}
let title = 'Untitled'
editor.state.doc.descendants(node => {
if (title === 'Untitled' && node.isTextblock && node.textContent.trim()) {
title = node.textContent.trim().slice(0, 80)
return false
}
return title === 'Untitled'
})
document.getElementById('doc-title').textContent = title
document.title = title
const cur = document.querySelector('#page-tree a.current')
if (cur && title !== 'Untitled' && cur.textContent !== title) cur.textContent = title
}
// --- editor -----------------------------------------------------------------
const highlightKey = new PluginKey('collab-highlights')
function buildEditor(provider) {
const Highlights = Extension.create({
name: 'collabHighlights',
priority: 10000,
addKeyboardShortcuts() {
return {
'Mod-z': () => runUndo(this.editor),
'Mod-Z': () => runRedo(this.editor),
'Mod-Shift-z': () => runRedo(this.editor),
'Mod-y': () => runRedo(this.editor),
Enter: () => pageLinkKey('Enter'),
ArrowDown: () => pageLinkKey('Down'),
ArrowUp: () => pageLinkKey('Up'),
Escape: () => pageLinkKey('Esc'),
Tab: () => pageLinkKey('Enter'),
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: highlightKey,
props: {
decorations: buildDecorations,
handleClickOn: (view, pos, node, nodePos, event) => {
const target = event.target?.closest?.('.comment-hl, [data-sugg]')
if (target?.dataset.thread) {
setActive(target.dataset.thread)
return false
}
if (target?.dataset.sugg) {
setActive(target.dataset.sugg)
return false
}
},
},
}),
]
},
})
return new Editor({
element: document.getElementById('editor'),
extensions: [
StarterKit.configure({ undoRedo: false, link: { openOnClick: false } }),
LayoutImage,
HtmlBlock,
MathInline,
MathBlock,
CheckboxInList,
TaskItem.configure({ nested: false }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Collaboration.configure({ document: ydoc }),
CollaborationCaret.configure({
provider,
user: { name: state.me.username, color: colorFor(state.me.username) },
}),
Highlights,
],
editorProps: {
handleClick: (view, pos, event) => {
const a = event.target?.closest?.('a')
// desktop keeps ctrl/cmd+click (a plain click places the caret to edit);
// on touch there is no modifier, so a tap has to follow the link
const touch = window.matchMedia('(hover: none)').matches
if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey || touch)) return false
const url = new URL(a.getAttribute('href'), location.origin)
const internal = url.origin === location.origin && url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?$`))
if (internal) openPage(internal[1] || 'home')
else if (url.origin === location.origin) location.href = url.pathname + url.search
else window.open(url.href, '_blank', 'noopener')
return true
},
handlePaste: (view, event) => {
const file = [...(event.clipboardData?.files || [])].find(f => f.type.startsWith('image/'))
if (!file) return false
uploadAndInsertImage(file)
return true
},
handleDrop: (view, event) => {
const file = [...(event.dataTransfer?.files || [])].find(f => f.type.startsWith('image/'))
if (!file) return false
event.preventDefault()
uploadAndInsertImage(file)
return true
},
},
})
}
async function uploadAndInsertImage(file) {
if (file.size > 10 * 1024 * 1024) return alert('Image too large (max 10 MB)')
const res = await fetch(`/api/docs/${docId}/upload`, {
method: 'POST',
headers: { 'content-type': file.type },
body: file,
}).then(r => r.json())
if (res.error) return alert(res.error)
window.__editor.chain().focus().setImage({ src: res.url }).run()
}
function buildDecorations(pmState) {
const ystate = ySyncPluginKey.getState(pmState)
if (!ystate?.binding) return DecorationSet.empty
const decos = []
threads.forEach((ythread, id) => {
if (ythread.get('resolved') || ythread.get('suggestionId')) return
const range = anchorsToRange(pmState, ystate, ythread.get('anchorStart'), ythread.get('anchorEnd'))
if (!range || range.from >= range.to) return
const cls = 'comment-hl' + (state.activeItem === id ? ' active' : '')
decos.push(Decoration.inline(range.from, range.to, { class: cls, 'data-thread': id }))
})
// one generation per revision chain — the one the user is looking at
for (const chain of suggestionChains()) {
const s = chain.display
if (s.status !== 'open' && s.status !== 'superseded') continue
const range = suggestionRange(pmState, s)
if (!range) continue
const hlCls = 'suggestion-hl' + (state.activeItem === s.id ? ' active' : '')
let firstInRange = true
pmState.doc.forEach((node, offset) => {
if (offset >= range.from && offset < Math.max(range.to, range.from + 1)) {
decos.push(Decoration.node(offset, offset + node.nodeSize, { class: hlCls + (firstInRange ? '' : ' hl-join'), 'data-sugg': s.id }))
firstInRange = false
}
})
try {
suggestionDiffDecos(pmState, s, range, decos, chain.members[chain.members.length - 1])
} catch {}
}
return DecorationSet.create(pmState.doc, decos)
}
// Inline track-changes rendering, block-aware. Unchanged blocks stay clean;
// edited blocks get an in-place word diff; whole new blocks render as properly
// formatted content in an insertion panel. The shared doc is never touched.
function suggestionDiffDecos(pmState, s, range, decos, chainLatest = null) {
const A = state.activeItem === s.id ? ' active' : ''
// old top-level blocks fully inside the anchored range, with positions + text
const oldBlocks = []
pmState.doc.forEach((node, offset) => {
if (offset >= range.from && offset + node.nodeSize <= Math.max(range.to, range.from + 1)) {
const mapped = blockCharMap(node, offset)
oldBlocks.push({ node, pos: offset, ...mapped, sig: descSignature(mapped.segs) })
}
})
const newBlocks = markdownToBlocks(s.replacementMarkdown).map(block => ({ block, text: blockDescText(block), sig: descSignature(blockDescSegments(block)) }))
// compare text AND formatting: a paragraph whose only change is a new link
// reads as identical on text alone, so the whole suggestion rendered nothing
const ops = arrayDiff(oldBlocks, newBlocks, (a, b) => a.sig === b.sig)
let widgetIdx = 0
let k = 0
let cursorEnd = range.from // boundary after the last old block handled
while (k < ops.length) {
const op = ops[k]
if (op.type === 'eq') {
cursorEnd = oldBlocks[op.aIdx].pos + oldBlocks[op.aIdx].node.nodeSize
k++
continue
}
const dels = []
const inss = []
while (k < ops.length && ops[k].type !== 'eq') {
if (ops[k].type === 'del') dels.push(oldBlocks[ops[k].aIdx])
else inss.push(newBlocks[ops[k].bIdx])
k++
}
// pair up rewritten blocks for in-place word diffs — but only while the
// texts genuinely overlap; word-diffing an empty/unrelated block against a
// brand-new section produces an unreadable flat text blob. For revision
// chains, judge similarity against the LATEST generation so flipping
// through generations keeps one consistent rendering mode.
let biasText = null
if (chainLatest && chainLatest.id !== s.id) {
try {
const latestBlocks = markdownToBlocks(chainLatest.replacementMarkdown)
biasText = latestBlocks.length ? blockDescText(latestBlocks[0]) : null
} catch {}
}
let pairs = 0
while (
pairs < Math.min(dels.length, inss.length) &&
dels[pairs].node.type.name !== 'table' &&
inss[pairs].block.type !== 'table' &&
similarBlocks(dels[pairs].text, biasText ?? inss[pairs].text)
)
pairs++
for (let p = 0; p < pairs; p++) {
if (dels[p].node.type.name === 'codeBlock' && inss[p].block.type === 'codeBlock') {
widgetIdx = codeBlockDiff(dels[p], inss[p], s.id, A, decos, widgetIdx)
} else {
widgetIdx = inlineBlockDiff(dels[p], inss[p], s.id, A, decos, widgetIdx)
}
}
// leftover old blocks: struck through wholesale; empty ones collapse
// visually (a strikethrough on nothing just reads as a blank line)
for (let p = pairs; p < dels.length; p++) {
const emptyCls = dels[p].text.trim() ? '' : ' sugg-del-empty'
decos.push(Decoration.node(dels[p].pos, dels[p].pos + dels[p].node.nodeSize, { class: 'sugg-del-block' + emptyCls + A, 'data-sugg': s.id }))
}
if (dels.length) cursorEnd = dels[dels.length - 1].pos + dels[dels.length - 1].node.nodeSize
// leftover new blocks: one formatted insertion panel. Anchor it to the
// FOLLOWING content (next old block in the run sequence, or the block the
// suggestion recorded as coming after it) so lines typed at the boundary
// land above the panel and push it down, like real anchored content.
if (inss.length > pairs) {
const blocks = inss.slice(pairs).map(x => x.block)
const id = s.id
let widgetPos = cursorEnd
if (k < ops.length && ops[k].type === 'eq') {
widgetPos = oldBlocks[ops[k].aIdx].pos
} else {
const after = afterAnchorPos(pmState, s)
if (after != null && after >= cursorEnd) widgetPos = after
}
// the panel visually continues the suggestion's bar when it directly
// follows one of its blocks (trailing or between range blocks) — but not
// when the forward anchor moved it past foreign content
const adjacent = widgetPos === cursorEnd || (k < ops.length && ops[k].type === 'eq' && widgetPos === oldBlocks[ops[k].aIdx].pos)
const join = adjacent && (dels.length > 0 || cursorEnd > range.from)
decos.push(Decoration.widget(widgetPos, () => blockInsWidget(id, blocks, A + (join ? ' hl-join' : '')), { key: `${id}:blk${widgetIdx++}${A}` }))
}
}
}
function similarBlocks(a, b) {
if (!a?.trim() || !b?.trim()) return false
const ta = new Set(a.toLowerCase().split(/\s+/))
const tb = b.toLowerCase().split(/\s+/)
const common = tb.filter(w => ta.has(w)).length
return common / Math.max(ta.size, tb.length) >= 0.4
}
// PM position of the block the suggestion recorded as following it (or doc end)
function afterAnchorPos(pmState, s) {
if (!s.anchorAfter) return null
try {
const frag = ydoc.getXmlFragment('default')
const abs = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorAfter), ydoc)
if (!abs || abs.type !== frag) return null
if (abs.index >= pmState.doc.childCount) return pmState.doc.content.size
let pos = null
pmState.doc.forEach((node, offset, i) => {
if (i === abs.index) pos = offset
})
return pos
} catch {
return null
}
}
// word-diff one rewritten block in place
function inlineBlockDiff(oldB, newB, suggId, A, decos, widgetIdx) {
const newSegs = withMarkKeys(blockDescSegments(newB.block))
const ops = styledWordDiff(oldB.segs, newSegs)
const charToPm = off => (off >= oldB.map.length ? oldB.pos + oldB.node.nodeSize - 1 : oldB.map[off])
const listType = ['bulletList', 'orderedList'].includes(oldB.node.type.name) ? oldB.node.type.name : null
for (const op of ops) {
if (op.type === 'del' && op.text.trim()) {
const from = charToPm(op.oldStart)
const to = charToPm(op.oldEnd - 1) + 1
if (to > from) decos.push(Decoration.inline(from, to, { class: 'sugg-del' + A, 'data-sugg': suggId }))
} else if (op.type === 'ins' && op.text.trim()) {
const pos = charToPm(op.oldStart)
// the inserted run with its formatting intact — a suggested link has to
// read as a link, not as its bare text
const segments = sliceSegments(blockDescSegments(newB.block), op.newStart, op.newEnd)
decos.push(Decoration.widget(pos, () => insWidget(suggId, segments, A, listType), { key: `${suggId}:${widgetIdx}${A}`, marks: [] }))
widgetIdx++
}
}
return widgetIdx
}
// character range [from, to) of a segment list, keeping each piece's marks
function sliceSegments(segments, from, to) {
const out = []
let pos = 0
for (const seg of segments) {
const end = pos + seg.text.length
const s = Math.max(from, pos)
const e = Math.min(to, end)
// a formula slice keeps its latex only when the WHOLE formula is inside the
// range: half a formula is not a formula, and renders as its source
if (e > s) {
const whole = s === pos && e === end
out.push({ text: seg.text.slice(s - pos, e - pos), attrs: seg.attrs, ...(seg.math != null && whole ? { math: seg.math } : {}) })
}
pos = end
if (pos >= to) break
}
return out
}
function insWidget(suggId, segments, A, listType = null) {
const span = document.createElement('span')
span.className = 'sugg-ins' + A
span.dataset.sugg = suggId
span.addEventListener('click', () => setActive(suggId))
const lines = splitSegmentsOnNewline(segments)
// inside a list block, segments after a newline are NEW ITEMS — give them a
// marker so they read as list entries, not a bare continuation line. The
// outer span becomes display:contents so its inline underline can't paint
// stray fragments around the block-level children.
if (listType && lines.length > 1) {
span.className = 'sugg-ins sugg-ins-multi' + A
lines.forEach((line, i) => {
const lineText = line.map(s => s.text).join('')
if (!lineText.trim()) return
const piece = document.createElement('span')
piece.className = i === 0 ? 'sugg-ins-seg' : 'sugg-ins-li' + (listType === 'orderedList' ? ' ordered' : '')
piece.innerHTML = inlineHtml(line)
typesetMathIn(piece)
span.appendChild(piece)
})
} else {
span.innerHTML = inlineHtml(segments)
typesetMathIn(span)
}
wirePreviewLinks(span, suggId)
return span
}
function splitSegmentsOnNewline(segments) {
const lines = [[]]
for (const seg of segments) {
const parts = seg.text.split('\n')
parts.forEach((part, i) => {
if (i) lines.push([])
if (part) lines[lines.length - 1].push({ text: part, attrs: seg.attrs })
})
}
return lines
}
// A link inside a *proposed* change is not navigation yet: a plain click selects
// the suggestion (so the card opens), ctrl/cmd-click opens the target for review.
function wirePreviewLinks(root, suggId) {
for (const a of root.querySelectorAll('a[href]')) {
a.title = a.getAttribute('href')
a.addEventListener('click', e => {
e.preventDefault()
e.stopPropagation()
if (e.metaKey || e.ctrlKey) window.open(a.href, '_blank', 'noopener')
else setActive(suggId)
})
}
}
// line-level diff inside a code block: struck old lines + inserted green lines,
// like a real code diff (word-diffing code reads terribly)
function codeBlockDiff(oldB, newB, suggId, A, decos, widgetIdx) {
const oldLines = oldB.text.split('\n')
const newLines = String(newB.text ?? '').split('\n')
const lineStart = []
let off = 0
for (const l of oldLines) {
lineStart.push(off)
off += l.length + 1
}
const charToPm = c => (c >= oldB.map.length ? oldB.pos + oldB.node.nodeSize - 1 : oldB.map[c])
const ops = arrayDiff(oldLines, newLines, (a, b) => a === b)
let k = 0
while (k < ops.length) {
if (ops[k].type === 'eq') {
k++
continue
}
const delIdx = []
const insTxt = []
while (k < ops.length && ops[k].type !== 'eq') {
if (ops[k].type === 'del') delIdx.push(ops[k].aIdx)
else insTxt.push(newLines[ops[k].bIdx])
k++
}
for (const li of delIdx) {
if (!oldLines[li].length) continue
const from = charToPm(lineStart[li])
const to = charToPm(lineStart[li] + oldLines[li].length - 1) + 1
if (to > from) decos.push(Decoration.inline(from, to, { class: 'sugg-del' + A, 'data-sugg': suggId }))
}
if (insTxt.length) {
let posChar
if (delIdx.length) {
const last = delIdx[delIdx.length - 1]
posChar = lineStart[last] + oldLines[last].length
} else if (k < ops.length && ops[k].type === 'eq') {
posChar = lineStart[ops[k].aIdx]
} else {
posChar = oldB.map.length
}
const lines = insTxt
decos.push(
Decoration.widget(charToPm(Math.min(posChar, oldB.map.length)), () => {
const span = document.createElement('span')
span.className = 'sugg-ins sugg-ins-multi' + A
span.dataset.sugg = suggId
for (const line of lines) {
const div = document.createElement('span')
div.className = 'sugg-ins-codeline'
div.textContent = line || ' '
span.appendChild(div)
}
span.addEventListener('click', () => setActive(suggId))
return span
}, { key: `${suggId}:code${widgetIdx}${A}`, marks: [] })
)
widgetIdx++
}
}
return widgetIdx
}
function blockInsWidget(suggId, blocks, A) {
const div = document.createElement('div')
div.className = 'sugg-ins-block' + A
div.dataset.sugg = suggId
div.innerHTML = blocks.map(blockToHtml).join('')
typesetMathIn(div)
div.addEventListener('click', () => setActive(suggId))
wirePreviewLinks(div, suggId)
return div
}
// plain text of a block plus char -> PM-position map (for word-diff anchoring)
// text of a block + a char -> document-position map, plus the same text split
// into runs carrying their mark key (so the diff can see formatting changes)
function blockCharMap(node, pos) {
let text = ''
const map = []
const segs = []
const push = (chunk, markKey, posFor) => {
for (let c = 0; c < chunk.length; c++) {
text += chunk[c]
map.push(posFor(c))
}
const last = segs[segs.length - 1]
if (last && last.markKey === markKey) last.text += chunk
else segs.push({ text: chunk, markKey })
}
if (node.type.name === 'image') {
push('[figure]', '', () => pos)
return { text, map, segs }
}
// a display formula's text is its source, every char anchored to the node
if (node.type.name === 'mathBlock') {
push(node.attrs.latex || '', '', () => pos)
return { text, map, segs }
}
node.descendants((child, childPos) => {
const base = pos + 1 + childPos
if (child.isText) {
push(child.text, pmMarkKey(child.marks), c => base + c)
} else if (child.type.name === 'image') {
push('[figure]', '', () => base)
} else if (child.type.name === 'mathInline') {
// matches the markdown side's `$latex$`, so an edited formula diffs as text
push('$' + (child.attrs.latex || '') + '$', '', () => base)
} else if (child.isBlock && text && !text.endsWith('\n')) {
push('\n', '', () => base)
}
return true
})
return { text, map, segs }
}
// plain text of a markdownToBlocks descriptor — must mirror blockCharMap's shape
// The reader-visible text of a parsed block, as *styled* segments — the diff
// compares these, so a run that is only re-formatted (linked, bolded) still
// counts as a change, and an inserted run can be rendered with its marks.
// Structural glue (newlines between list items, " | " between cells) is emitted
// as unmarked segments so the concatenation equals blockDescText exactly.
function blockDescSegments(b) {
const plain = text => ({ text, attrs: {} })
const inline = ss => (ss || []).filter(x => x.text)
const joined = (parts, sep) => {
const out = []
parts.forEach((part, i) => {
if (i) out.push(plain(sep))
out.push(...part)
})
return out
}
switch (b.type) {
case 'bulletList':
case 'orderedList':
return joined((b.items || []).map(inline), '\n')
case 'taskList':
return joined(
(b.items || []).map(it => [plain(`[${it.checked ? 'x' : ' '}] `), ...inline(it.inline)]),
'\n'
)
case 'table':
return joined(
(b.rows || []).map(r => joined(r.map(inline), ' | ')),
'\n'
)
case 'codeBlock':
case 'htmlBlock':
return [plain(b.text || '')]
case 'mathBlock':
return [plain(b.attrs?.latex || '')]
case 'image':
return [plain('[figure]')]
case 'horizontalRule':
return []
default:
return inline(b.inline)
}
}
function blockDescText(b) {
return blockDescSegments(b)
.map(s => s.text)
.join('')
}
// mark key for a segment coming from the markdown parser (attrs: name -> attrs)
function segMarkKey(seg) {
return markKeyOf(Object.entries(seg.attrs || {}).map(([name, attrs]) => (name === 'link' ? `link:${attrs?.href || ''}` : name)))
}
// mark key for an inline node in the document (a ProseMirror mark set)
function pmMarkKey(marks) {
return markKeyOf((marks || []).map(m => (m.type.name === 'link' ? `link:${m.attrs?.href || ''}` : m.type.name)))
}
// text + formatting fingerprint, used to decide whether a block changed at all.
// Adjacent runs with the same marks are merged first: ProseMirror may split a
// text node where the markdown parser does not, and an accidental difference
// here would make an unchanged block look modified.
function descSignature(segments) {
const runs = []
for (const seg of segments || []) {
if (!seg.text) continue
const key = seg.markKey != null ? seg.markKey : segMarkKey(seg)
const last = runs[runs.length - 1]
if (last && last.key === key) last.text += seg.text
else runs.push({ key, text: seg.text })
}
return runs.map(r => `${r.key}\u0001${r.text}`).join('\u0002')
}
function withMarkKeys(segments) {
return segments.map(seg => ({ text: seg.text, markKey: segMarkKey(seg) }))
}
// render a markdown block descriptor as HTML for the insertion panel
function inlineHtml(segments) {
return (segments || [])
.map(s => {
// typeset after the string is in the DOM — see typesetMathIn()
if (s.math != null) return `<span class="math-inline" data-latex="${esc(s.math)}"><span class="math-view"></span></span>`
let h = esc(s.text)
const a = s.attrs || {}
if (a.code) h = `<code>${h}</code>`
if (a.bold) h = `<strong>${h}</strong>`
if (a.italic) h = `<em>${h}</em>`
if (a.strike) h = `<s>${h}</s>`
if (a.link) h = `<a href="${esc(a.link.href)}">${h}</a>`
return h
})
.join('')
}
// Preview HTML is built as a string, so formulas arrive as placeholders that
// carry their source. This turns them into real typeset math once mounted.
function typesetMathIn(root) {
for (const node of root.querySelectorAll('[data-latex] > .math-view')) {
const host = node.parentElement
renderMath(node, host.dataset.latex, { display: host.classList.contains('math-block') })
}
}
function blockToHtml(b) {
switch (b.type) {
case 'heading':
return `<h${Math.min(b.attrs?.level || 1, 3)}>${inlineHtml(b.inline)}</h${Math.min(b.attrs?.level || 1, 3)}>`
case 'bulletList':
return `<ul>${(b.items || []).map(i => `<li>${inlineHtml(i)}</li>`).join('')}</ul>`
case 'orderedList':
return `<ol>${(b.items || []).map(i => `<li>${inlineHtml(i)}</li>`).join('')}</ol>`
case 'taskList':
return `<ul data-type="taskList">${(b.items || [])
.map(it => `<li class="task-item${it.checked ? ' checked' : ''}"><input type="checkbox" disabled${it.checked ? ' checked' : ''}><span>${inlineHtml(it.inline)}</span></li>`)
.join('')}</ul>`
case 'table':
return `<table><tbody>${(b.rows || [])
.map((r, ri) => `<tr>${r.map(c => (ri === 0 ? `<th>${inlineHtml(c)}</th>` : `<td>${inlineHtml(c)}</td>`)).join('')}</tr>`)
.join('')}</tbody></table>`
case 'codeBlock':
return `<pre><code>${esc(b.text || '')}</code></pre>`
case 'htmlBlock':
return `<iframe class="html-embed" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer" loading="lazy" srcdoc="${esc(buildEmbedSrcdoc(b.text || ''))}"></iframe>`
case 'blockquote':
return `<blockquote><p>${inlineHtml(b.inline)}</p></blockquote>`
case 'mathBlock':
return `<div class="math-block" data-latex="${esc(b.attrs?.latex || '')}"><span class="math-view"></span></div>`
case 'horizontalRule':
return '<hr>'
case 'image':
return `<img src="${esc(b.attrs?.src || '')}" alt="${esc(b.attrs?.alt || '')}">`
default:
return `<p>${inlineHtml(b.inline)}</p>`
}
}
function anchorsToRange(pmState, ystate, startB64, endB64) {
try {
const from = relativePositionToAbsolutePosition(ystate.doc, ystate.type, decodeRel(startB64), ystate.binding.mapping)
const to = relativePositionToAbsolutePosition(ystate.doc, ystate.type, decodeRel(endB64), ystate.binding.mapping)
if (from == null || to == null) return null
return { from: Math.min(from, to), to: Math.max(from, to) }
} catch {
return null
}
}
// Range of a suggestion in PM positions. New suggestions carry anchorEndIncl
// (references the last target block itself) so neighbours typed before/after
// stay outside the range; legacy ones fall back to the exclusive-end anchors.
function suggestionRange(pmState, s) {
if (s.anchorEndIncl) {
try {
const frag = ydoc.getXmlFragment('default')
const absS = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorStart), ydoc)
const absE = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorEndIncl), ydoc)
if (!absS || !absE || absS.type !== frag || absE.type !== frag) return null
const si = Math.min(absS.index, absE.index)
const ei = Math.max(absS.index, absE.index)
let from = null
let to = null
pmState.doc.forEach((node, offset, i) => {
if (i === si) from = offset
if (i === ei) to = offset + node.nodeSize
})
if (from == null || to == null || to <= from) return null
return { from, to }
} catch {
return null
}
}
const ystate = ySyncPluginKey.getState(pmState)
if (!ystate?.binding) return null
return anchorsToRange(pmState, ystate, s.anchorStart, s.anchorEnd)
}
function decodeRel(b64) {
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'))
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
return Y.decodeRelativePosition(arr)
}
function encodeRel(rel) {
const bytes = Y.encodeRelativePosition(rel)
let bin = ''
for (const b of bytes) bin += String.fromCharCode(b)
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function poke(editor) {
editor.view.dispatch(editor.view.state.tr.setMeta('collab-refresh', true))
}
function setActive(id) {
state.activeItem = id
// revealing a specific item un-hides the comments panel
if (id && document.getElementById('margin-col').classList.contains('hidden')) setMarginHidden(false)
renderMargin(window.__editor)
poke(window.__editor)
// stacked cards (phone sheet / narrow window) are not positioned next to the
// text, so the expanded one has to be scrolled to inside its own container
if (id && isStackedMargin()) {
requestAnimationFrame(() => document.querySelector('#margin-items .card.expanded')?.scrollIntoView({ block: 'nearest' }))
}
}
// composers get absolutely positioned a frame after opening — focusing them
// before that scrolls the page to the container top. Focus with preventScroll,
// then nudge the placed box into view.
function revealAfterLayout(node) {
requestAnimationFrame(() =>
requestAnimationFrame(() => {
try {
// stacked layout: composers are the first children of the scrolling
// list, so whatever an expanded card scrolled to has to be undone —
// otherwise the box you are typing into sits above the visible area
const items = document.getElementById('margin-items')
if (isStackedMargin() && items.contains(node)) items.scrollTop = 0
node.scrollIntoView({ block: 'nearest' })
} catch {}
})
)
}
function jumpTo(editor, range) {
if (!range) return
const pos = Math.min(range.from, editor.state.doc.content.size)
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)).scrollIntoView())
}
// --- header toolbar -----------------------------------------------------------
// The toolbar label is the one bit of the UI that has to look like maths
// without KaTeX being loaded yet.
function mathToolLabel() {
const span = el('span', { class: 'math-tool-label' })
span.textContent = '\u2211'
return span
}
// An empty selection opens a display formula on its own line — that is what the
// button is for. With text selected, the selection is treated as the source, so
// pasted LaTeX can be promoted in place.
function insertMath(editor) {
const { from, to, empty } = editor.state.selection
if (empty) {
editor.chain().focus().insertMathBlock('').run()
return
}
const latex = editor.state.doc.textBetween(from, to, ' ', leafText).trim().replace(/^\$+|\$+$/g, '')
const block = /\n/.test(latex) || latex.length > 60
editor
.chain()
.focus()
.deleteSelection()
.insertContent(block ? { type: 'mathBlock', attrs: { latex } } : { type: 'mathInline', attrs: { latex } })
.run()
}
function buildHeaderTools(editor) {
const bar = document.getElementById('header-tools')
bar.replaceChildren() // rebuilt per page switch
const sep = () => el('span', { class: 'sep' })
const btn = (label, title, run, isOn) => {
const b = el('button', { type: 'button', title })
if (typeof label === 'string') b.textContent = label
else b.appendChild(label)
b.addEventListener('mousedown', e => {
e.preventDefault()
run()
})
if (isOn) editor.on('transaction', () => b.classList.toggle('on', isOn()))
return b
}
const boldB = btn('B', 'Bold', () => editor.chain().focus().toggleBold().run(), () => editor.isActive('bold'))
boldB.style.fontWeight = '700'
const italicB = btn('I', 'Italic', () => editor.chain().focus().toggleItalic().run(), () => editor.isActive('italic'))
italicB.style.fontStyle = 'italic'
italicB.style.fontFamily = 'var(--doc)'
const strikeB = btn('S', 'Strikethrough', () => editor.chain().focus().toggleStrike().run(), () => editor.isActive('strike'))
strikeB.style.textDecoration = 'line-through'
bar.append(
btn('H1', 'Heading 1', () => editor.chain().focus().toggleHeading({ level: 1 }).run(), () => editor.isActive('heading', { level: 1 })),
btn('H2', 'Heading 2', () => editor.chain().focus().toggleHeading({ level: 2 }).run(), () => editor.isActive('heading', { level: 2 })),
btn('H3', 'Heading 3', () => editor.chain().focus().toggleHeading({ level: 3 }).run(), () => editor.isActive('heading', { level: 3 })),
sep(),
boldB,
italicB,
strikeB,
btn('</>', 'Inline code', () => editor.chain().focus().toggleCode().run(), () => editor.isActive('code')),
sep(),
btn('• List', 'Bullet list', () => editor.chain().focus().toggleBulletList().run(), () => editor.isActive('bulletList')),
btn('1. List', 'Ordered list', () => editor.chain().focus().toggleOrderedList().run(), () => editor.isActive('orderedList')),
btn(icon('quote'), 'Blockquote', () => editor.chain().focus().toggleBlockquote().run(), () => editor.isActive('blockquote')),
btn('{ }', 'Code block', () => editor.chain().focus().toggleCodeBlock().run(), () => editor.isActive('codeBlock')),
btn(icon('todo'), 'Checklist', () => editor.chain().focus().toggleTaskList().run(), () => editor.isActive('taskList')),
btn(icon('table'), 'Insert table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()),
btn('—', 'Divider', () => editor.chain().focus().setHorizontalRule().run()),
btn(mathToolLabel(), 'Math — inline $x^2$, or a display formula on its own line', () => insertMath(editor), () =>
editor.isActive('mathInline') || editor.isActive('mathBlock')
),
btn('HTML', 'Embed live HTML (sandboxed)', () =>
editHtmlOverlay('', html => {
if (html.trim()) editor.chain().focus().insertContent({ type: 'htmlBlock', attrs: { html } }).run()
})
),
sep(),
btn(icon('link'), 'Link', () => toggleLink(editor), () => editor.isActive('link'))
)
// undo / redo (from the Collaboration extension's shared-editing undo manager)
const undoB = btn(icon('undo'), 'Undo', () => runUndo(editor))
undoB.id = 'undo-btn'
const redoB = btn(icon('redo'), 'Redo', () => runRedo(editor))
redoB.id = 'redo-btn'
bar.append(sep(), undoB, redoB)
// zoom
let zoom = Number(localStorage.getItem('doc-zoom')) || 1
const zoomLabel = el('span', { id: 'zoom-label' })
const applyZoom = () => {
zoom = Math.round(Math.max(0.8, Math.min(1.6, zoom)) * 100) / 100
document.documentElement.style.setProperty('--doc-zoom', zoom)
zoomLabel.textContent = `${Math.round(zoom * 100)}%`
localStorage.setItem('doc-zoom', String(zoom))
scheduleLayout(editor)
}
const zoomOutB = btn(icon('zoomOut'), 'Zoom out', () => {
zoom -= 0.1
applyZoom()
})
zoomOutB.id = 'zoom-out'
const zoomInB = btn(icon('zoomIn'), 'Zoom in', () => {
zoom += 0.1
applyZoom()
})
zoomInB.id = 'zoom-in'
bar.append(sep(), zoomOutB, zoomLabel, zoomInB)
applyZoom()
// image upload
const imgInput = el('input', { type: 'file', accept: 'image/*', style: 'display:none' })
imgInput.addEventListener('change', () => {
if (imgInput.files[0]) uploadAndInsertImage(imgInput.files[0])
imgInput.value = ''
})
const imgBtn = btn(icon('image'), 'Insert image', () => imgInput.click())
imgBtn.id = 'image-btn'
bar.append(imgBtn, imgInput)
wireLinkPop(editor)
}
function toggleLink(editor) {
if (editor.isActive('link')) {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
if (editor.state.selection.empty) return
const pop = document.getElementById('link-pop')
const tools = document.getElementById('header-tools').getBoundingClientRect()
pop.style.left = `${Math.max(12, tools.right - 320)}px`
pop.style.top = `${tools.bottom + 8}px`
pop.classList.remove('hidden')
document.getElementById('link-url').focus()
}
function wireLinkPop(editor) {
const pop = document.getElementById('link-pop')
const input = document.getElementById('link-url')
const apply = () => {
let href = input.value.trim()
if (href && !/^https?:\/\//.test(href)) href = 'https://' + href
if (href) editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
input.value = ''
pop.classList.add('hidden')
}
document.getElementById('link-apply').addEventListener('click', apply)
input.addEventListener('keydown', e => {
if (e.key === 'Enter') apply()
if (e.key === 'Escape') pop.classList.add('hidden')
})
document.addEventListener('mousedown', e => {
if (!e.target.closest('#link-pop') && !pop.classList.contains('hidden') && !e.target.closest('#header-tools')) {
pop.classList.add('hidden')
}
})
}
// --- editing / suggesting mode --------------------------------------------------
// Docs keeps this at the top of the window instead of on the selection: in
// Editing mode keystrokes land in the document; in Suggesting mode the document
// is read-only and selecting text opens a proposal for those blocks.
//
// The switch is HIDDEN for now: proposing a change should mean typing in the
// document itself and having the edit captured, not filling in a markdown box.
// Until that exists, the mode is not worth showing — the flow underneath still
// works (agents use it, and the phone flow could) so flipping this back on is
// all it takes. Humans comment; agents propose.
const SUGGEST_MODE_UI = false
let lockedOut = false // the build gate made this tab read-only — mode must not undo that
function buildModeSwitch(editor) {
const wrap = document.getElementById('mode-switch')
wrap.replaceChildren()
if (!SUGGEST_MODE_UI) {
// force Editing, so a mode left over in localStorage cannot strand someone
// in a read-only document with no visible way back
wrap.classList.add('hidden')
setMode('editing', editor)
return
}
wrap.classList.remove('hidden')
const tab = (mode, label, tip) => {
const b = el('button', { type: 'button', title: tip, 'data-mode': mode }, label)
b.addEventListener('mousedown', e => {
e.preventDefault()
setMode(mode, editor)
})
return b
}
wrap.append(
tab('editing', 'Editing', 'Type straight into the document'),
tab('suggesting', 'Suggesting', 'Propose changes — select text and a suggestion opens in the margin')
)
setMode(localStorage.getItem(`cw-mode:${docId}`) === 'suggesting' ? 'suggesting' : 'editing', editor)
}
function setMode(mode, editor) {
state.mode = mode === 'suggesting' ? 'suggesting' : 'editing'
localStorage.setItem(`cw-mode:${docId}`, state.mode)
for (const b of document.querySelectorAll('#mode-switch button')) {
b.classList.toggle('on', b.dataset.mode === state.mode)
}
document.body.classList.toggle('suggesting', state.mode === 'suggesting')
if (!lockedOut) {
try {
editor?.setEditable(state.mode === 'editing')
} catch {}
}
closeAutoComposer()
}
// --- selection menu -------------------------------------------------------------
function wireSelectionMenu() {
const commentBtn = document.getElementById('comment-btn')
const suggestBtn = document.getElementById('suggest-btn')
commentBtn.append(icon('comment'), document.createTextNode('Comment'))
suggestBtn.append(icon('edit'), document.createTextNode('Suggest'))
if (!SUGGEST_MODE_UI) suggestBtn.classList.add('hidden')
commentBtn.addEventListener('mousedown', e => {
e.preventDefault()
try {
openComposer(window.__editor)
} catch (err) {
alert('Could not open the comment box: ' + err.message)
}
})
suggestBtn.addEventListener('mousedown', e => {
e.preventDefault()
openSuggestComposer(window.__editor).catch(err => alert('Could not open suggest mode: ' + err.message))
})
}
function bindSelectionMenu(editor) {
const menu = document.getElementById('selection-menu')
let settle = null
editor.on('selectionUpdate', () => {
const { from, to, empty } = editor.state.selection
clearTimeout(settle)
if (empty || to - from < 1) {
menu.classList.add('hidden')
// A blur is not a dismissal. Selecting an atom (a formula, an image) makes
// a NodeSelection, and that collapses when the editor loses focus — so
// clicking into the comment box closed the very box you just clicked.
// Only a collapse the user made *in the document* dismisses it.
if (editor.isFocused && !document.activeElement?.closest('.composer-box')) closeAutoComposer()
return
}
// Desktop has a margin to put the box in, so the selection opens the real
// composer there and no floating widget is needed. A phone has no margin
// (the panel is a bottom sheet that would cover the selection), so there the
// little Comment / Suggest bubble stays as the way in.
if (!isStackedMargin()) {
menu.classList.add('hidden')
// wait for the drag/shift-arrow run to settle before popping a box open
settle = setTimeout(() => isLive(editor) && openForSelection(editor), 220)
return
}
const coords = editor.view.coordsAtPos(to)
const wrap = document.getElementById('editor-col').getBoundingClientRect()
menu.classList.remove('hidden')
// measure instead of assuming a width — the buttons are bigger on phones,
// where a hardcoded guess pushed the menu off the right edge
const w = menu.offsetWidth || 230
menu.style.left = `${Math.max(0, Math.min(coords.left - wrap.left, wrap.width - w))}px`
menu.style.top = `${coords.bottom - wrap.top + 8}px`
})
}
// A settled selection opens the box the current mode calls for, in the margin
// next to the text. Failures here are not worth an alert on every selection —
// they just mean no box this time.
function openForSelection(editor) {
if (state.previewPid || state.composerDirty) return
if (state.mode === 'suggesting') {
openSuggestComposer(editor, { focus: false, auto: true }).catch(() => {})
return
}
try {
openComposer(editor, { focus: false, auto: true })
} catch {}
}
// Close a box the selection opened, unless the user has started writing in it.
function closeAutoComposer() {
if (!state.autoComposer || state.composerDirty) return
const isSuggest = state.autoComposer === 'suggest'
document.getElementById(isSuggest ? 'suggest-composer' : 'composer').classList.add('hidden')
if (!isSuggest) document.getElementById('composer-text').value = ''
state.autoComposer = null
state.suggestRange = null
renderMargin(window.__editor)
}
// ProseMirror's textBetween skips leaf nodes unless told what they say. Without
// this a comment on a formula quoted an empty string — the atom holds no text.
function leafText(node) {
if (node.type.name === 'mathInline' || node.type.name === 'mathBlock') return `$${node.attrs.latex || ''}$`
if (node.type.name === 'image') return '[figure]'
return ''
}
// Handing work to your own agent is the common case for a comment, so the box
// opens with your first agent already mentioned.
function myFirstAgentHandle() {
const mine = (state.agents || []).filter(a => a.owner === state.me?.username)
return mine[0]?.handle || null
}
// --- comment composer -------------------------------------------------------------
function openComposer(editor, opts = {}) {
const { from, to } = editor.state.selection
const ystate = ySyncPluginKey.getState(editor.state)
state.composerAnchors = {
start: encodeRel(absolutePositionToRelativePosition(from, ystate.type, ystate.binding.mapping)),
end: encodeRel(absolutePositionToRelativePosition(to, ystate.type, ystate.binding.mapping)),
excerpt: editor.state.doc.textBetween(from, to, ' ', leafText).slice(0, 200),
}
document.getElementById('composer-quote').textContent = state.composerAnchors.excerpt
document.getElementById('suggest-composer').classList.add('hidden')
document.getElementById('composer').classList.remove('hidden')
document.getElementById('selection-menu').classList.add('hidden')
const text = document.getElementById('composer-text')
if (!state.composerDirty) {
const handle = myFirstAgentHandle()
text.value = handle ? `@${handle} ` : ''
text.setSelectionRange(text.value.length, text.value.length)
}
state.autoComposer = opts.auto ? 'comment' : null
setMarginHidden(false, false) // the composer lives in the comments panel
renderMargin(editor)
// an auto-opened box must not steal the keyboard: selecting text and typing
// over it is ordinary editing, so the caret stays in the document
if (opts.focus !== false) text.focus({ preventScroll: true })
revealAfterLayout(document.getElementById('composer'))
}
function wireComposer() {
const text = document.getElementById('composer-text')
text.addEventListener('input', () => {
state.composerDirty = true
})
document.getElementById('composer-cancel').addEventListener('click', () => {
document.getElementById('composer').classList.add('hidden')
text.value = ''
state.composerDirty = false
state.autoComposer = null
renderMargin(window.__editor)
})
// Enter sends, Shift+Enter breaks the line — the messaging convention, and
// Enter did nothing here before. The mention menu owns Enter while it is open
// (it is picking an agent), and it is wired below, i.e. after this handler.
text.addEventListener('keydown', e => {
if (e.key !== 'Enter' || e.shiftKey) return
if (!document.getElementById('composer-mentions').classList.contains('hidden')) return
e.preventDefault()
document.getElementById('composer-send').click()
})
document.getElementById('composer-send').addEventListener('click', () => {
const value = text.value.trim()
// the box comes pre-mentioned, so a bare "@handle" counts as empty
const body = value.replace(/^(?:@[a-z0-9_.-]+\s+)+/i, '').trim()
if (!value || !body || !state.composerAnchors) return
const id = createThread(state.composerAnchors, value)
text.value = ''
state.composerDirty = false
state.autoComposer = null
document.getElementById('composer').classList.add('hidden')
setActive(id)
})
wireMentionMenu(text, document.getElementById('composer-mentions'))
}
function createThread(anchors, text, suggestionId = null) {
const id = randomId()
const messages = new Y.Array()
messages.push([makeMessage(text)])
const t = new Y.Map()
t.set('id', id)
t.set('anchorStart', anchors.start)
t.set('anchorEnd', anchors.end)
t.set('excerpt', anchors.excerpt || null)
t.set('createdBy', state.me.username)
t.set('createdAt', Date.now())
t.set('resolved', false)
if (suggestionId) t.set('suggestionId', suggestionId)
t.set('messages', messages)
threads.set(id, t)
return id
}
function makeMessage(text) {
return { id: randomId(), author: state.me.username, authorType: 'user', text, ts: Date.now() }
}
function randomId() {
return Math.random().toString(36).slice(2, 12)
}
// --- suggest composer (humans propose block rewrites, like agents do) --------------
async function openSuggestComposer(editor, opts = {}) {
const { from, to } = editor.state.selection
const blockFrom = editor.state.doc.resolve(from).index(0)
const blockTo = editor.state.doc.resolve(to).index(0)
// while the selection stays inside the same blocks there is nothing to redo —
// re-fetching on every selection change would be a request per drag
const range = `${blockFrom}-${blockTo}`
if (opts.auto && state.autoComposer === 'suggest' && state.suggestRange === range) return
state.suggestRange = range
const fragment = ydoc.getXmlFragment('default')
state.suggestAnchors = {
start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)),
end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)),
}
document.getElementById('composer').classList.add('hidden')
document.getElementById('selection-menu').classList.add('hidden')
const composer = document.getElementById('suggest-composer')
composer.classList.remove('hidden')
state.autoComposer = opts.auto ? 'suggest' : null
setMarginHidden(false, false) // the composer lives in the comments panel
renderMargin(editor)
document.getElementById('suggest-quote').textContent =
blockTo > blockFrom ? `Rewriting blocks ${blockFrom + 1}${blockTo + 1}` : 'Rewriting the selected block'
const textEl = document.getElementById('suggest-text')
textEl.value = '…'
const snap = await api(`/api/docs/${docId}${pageQS}`)
if (state.composerDirty) return // the user started writing while the fetch was in flight
textEl.value = (snap.blocks || [])
.slice(blockFrom, blockTo + 1)
.map(b => b.markdown)
.join('\n\n')
if (opts.focus !== false) textEl.focus({ preventScroll: true })
revealAfterLayout(document.getElementById('suggest-composer'))
scheduleLayout(editor)
}
function wireSuggestComposer() {
for (const id of ['suggest-text', 'suggest-rationale']) {
document.getElementById(id).addEventListener('input', () => {
state.composerDirty = true
})
}
document.getElementById('suggest-cancel').addEventListener('click', () => {
document.getElementById('suggest-composer').classList.add('hidden')
state.composerDirty = false
state.autoComposer = null
state.suggestRange = null
renderMargin(window.__editor)
})
document.getElementById('suggest-send').addEventListener('click', async () => {
if (!state.suggestAnchors) return
const res = await api(`/api/docs/${docId}/suggestions`, {
method: 'POST',
body: {
page: pageSlug,
anchor_start: state.suggestAnchors.start,
anchor_end: state.suggestAnchors.end,
replacement_markdown: document.getElementById('suggest-text').value,
rationale: document.getElementById('suggest-rationale').value.trim() || undefined,
},
})
if (res.error) return alert(res.error)
document.getElementById('suggest-composer').classList.add('hidden')
document.getElementById('suggest-text').value = ''
document.getElementById('suggest-rationale').value = ''
state.composerDirty = false
state.autoComposer = null
state.suggestRange = null
setActive(res.suggestion_id)
})
}
// --- margin: unified comments + suggestions, positioned next to their text ---------
// A page switch destroys the editor, and a destroyed editor has no view — so
// every deferred callback (timer, rAF, settle) must check it is still the live
// one first. Switching pages is fast enough now that these fire mid-teardown.
function isLive(editor) {
return Boolean(editor) && !editor.isDestroyed && window.__editor === editor
}
let layoutRaf = null
function scheduleLayout(editor) {
if (layoutRaf) return
layoutRaf = requestAnimationFrame(() => {
layoutRaf = null
if (isLive(editor)) layoutMargin(editor)
})
}
// --- revision chains ---
// Suggestions linked by `revises` form a chain; only ONE generation renders at
// a time (default: the newest actionable one), with ‹ n/N › navigation.
function suggestionChains() {
const all = suggestionValues()
const byId = new Map(all.map(s => [s.id, s]))
const rootOf = start => {
let cur = start
const seen = new Set([cur.id])
while (cur.revises && byId.has(cur.revises) && !seen.has(cur.revises)) {
cur = byId.get(cur.revises)
seen.add(cur.id)
}
return cur.id
}
const groups = new Map()
for (const s of all) {
const root = rootOf(s)
if (!groups.has(root)) groups.set(root, [])
groups.get(root).push(s)
}
const actionable = x => x.status === 'open' || x.status === 'superseded'
return [...groups.entries()].map(([root, members]) => {
members.sort((a, b) => (a.ts || 0) - (b.ts || 0))
let def = members.length - 1
for (let i = members.length - 1; i >= 0; i--) {
if (actionable(members[i])) {
def = i
break
}
}
let idx = state.chainPos[root]
if (!(Number.isInteger(idx) && idx >= 0 && idx < members.length)) idx = def
return { root, members, idx, display: members[idx] }
})
}
function chainNav(chain) {
const nav = el('span', { class: 'chain-nav' })
const go = idx => {
state.chainPos[chain.root] = idx
renderMargin(window.__editor)
poke(window.__editor)
}
const prev = el('button', { class: 'iconbtn', title: 'Earlier revision' }, '‹')
prev.disabled = chain.idx === 0
prev.addEventListener('click', e => {
e.stopPropagation()
go(chain.idx - 1)
})
const next = el('button', { class: 'iconbtn', title: 'Later revision' }, '›')
next.disabled = chain.idx === chain.members.length - 1
next.addEventListener('click', e => {
e.stopPropagation()
go(chain.idx + 1)
})
nav.append(prev, el('span', { class: 'chain-pos' }, `${chain.idx + 1}/${chain.members.length}`), next)
return nav
}
// accept/reject buttons shared by suggestion cards and thread-attached strips
function suggestionActionButtons(s, editor) {
const reject = el('button', { class: 'iconbtn reject-btn', title: 'Reject suggestion' })
reject.appendChild(icon('x'))
reject.addEventListener('click', e => {
e.stopPropagation()
if (!requireLiveConnection('rejecting a suggestion')) return
api(`/api/docs/${docId}/suggestions/${s.id}/reject`, { method: 'POST', body: { page: pageSlug } })
})
const accept = el('button', { class: 'iconbtn accept-btn', title: 'Accept suggestion' })
accept.appendChild(icon('check'))
accept.addEventListener('click', async e => {
e.stopPropagation()
// A local apply only counts if this tab can actually send it. Accepting from
// a disconnected tab used to mark the suggestion accepted (that call is
// plain HTTP and still succeeds) while the content went nowhere.
if (!requireLiveConnection('accepting a suggestion')) return
// apply in this editor so it's undoable; server then only flips the status
const appliedLocally = tryClientApply(editor, s)
const res = await api(`/api/docs/${docId}/suggestions/${s.id}/accept`, {
method: 'POST',
body: { mark_only: appliedLocally, page: pageSlug },
})
if (res.error) alert(res.error)
})
return [reject, accept]
}
function renderMargin(editor) {
const wrap = document.getElementById('margin-items')
const items = []
const attachedByThread = new Map() // comment thread id -> [{chain, linkedThread}]
for (const chain of suggestionChains()) {
const s = chain.display
const visible = s.status === 'open' || s.status === 'superseded' || state.showResolved
if (!visible) continue
let linkedThread = null
threads.forEach((yt, tid) => {
if (yt.get('suggestionId') === s.id) linkedThread = { id: tid, ythread: yt }
})
// a suggestion posted into a comment thread renders INSIDE that thread's
// card instead of as a separate margin widget
const hostId = s.threadId && threads.get(s.threadId) && !threads.get(s.threadId).get('suggestionId') ? s.threadId : null
const hostVisible = hostId && (!threads.get(hostId).get('resolved') || state.showResolved)
if (hostId && hostVisible) {
if (!attachedByThread.has(hostId)) attachedByThread.set(hostId, [])
attachedByThread.get(hostId).push({ chain, linkedThread })
continue
}
items.push({ kind: 'sugg', id: s.id, data: s, linkedThread, chain })
}
threads.forEach((ythread, id) => {
if (ythread.get('suggestionId')) return
if (ythread.get('resolved') && !state.showResolved) return
items.push({ kind: 'thread', id, ythread, attached: attachedByThread.get(id) || [] })
})
// composers live inside the container too — never clobber them
wrap.querySelectorAll('.card, .margin-empty').forEach(n => n.remove())
if (!items.length) {
const composerOpen = wrap.querySelector('.composer-box:not(.hidden)')
if (!composerOpen) wrap.appendChild(el('div', { class: 'margin-empty muted' }, 'Select text to comment or suggest. Mention @an-agent to hand it work.'))
} else {
wrap.append(...items.map(item => (item.kind === 'sugg' ? suggestionCard(item, editor) : threadCard(item, editor))))
}
setCommentCount(items.length)
document.getElementById('margin-head-title').textContent = items.length ? `Comments · ${items.length}` : 'Comments'
clampLongText(wrap)
scheduleLayout(editor)
}
// Agents can write essays into a thread. Cap every comment body (and rationale)
// at a readable height once it is in the DOM and measurable, with a toggle for
// the rest — a long reply must never bury the next card.
function clampLongText(root) {
for (const node of root.querySelectorAll('.msg .text, .rationale')) {
if (node.dataset.clampChecked === 'open') continue
node.classList.add('clamped')
if (node.scrollHeight - node.clientHeight < 12) {
node.classList.remove('clamped')
continue
}
if (node.dataset.clampChecked) continue
node.dataset.clampChecked = '1'
const more = el('button', { class: 'more-btn', type: 'button' }, 'Show more')
more.addEventListener('click', e => {
e.stopPropagation()
const open = node.classList.toggle('clamped') === false
node.dataset.clampChecked = open ? 'open' : '1'
more.textContent = open ? 'Show less' : 'Show more'
scheduleLayout(window.__editor)
})
node.after(more)
}
}
function itemRange(editor, item) {
const ystate = ySyncPluginKey.getState(editor.state)
if (!ystate?.binding) return null
if (item.kind === 'sugg') return suggestionRange(editor.state, item.data)
return anchorsToRange(editor.state, ystate, item.ythread.get('anchorStart'), item.ythread.get('anchorEnd'))
}
function layoutMargin(editor) {
const container = document.getElementById('margin-items')
const els = [...container.querySelectorAll('.card, .composer-box:not(.hidden)')]
// stacked/sheet layout: the cards flow normally, so drop any inline
// positioning left over from the desktop layout (a stale minHeight would
// leave a screen-tall gap under the last card)
if (isStackedMargin()) {
container.style.minHeight = ''
for (const node of els) node.style.top = ''
return
}
if (!els.length) {
container.style.minHeight = '0px'
return
}
const containerRect = container.getBoundingClientRect()
const ystate = ySyncPluginKey.getState(editor.state)
const entries = els.map(node => {
let range = null
if (node.classList.contains('composer-box')) {
const anchors = node.id === 'composer' ? state.composerAnchors : state.suggestAnchors
if (anchors && ystate?.binding) range = anchorsToRange(editor.state, ystate, anchors.start, anchors.end)
} else {
range = rangeFromCard(editor, node)
}
let desired = Infinity
if (range) {
try {
desired = editor.view.coordsAtPos(Math.min(range.from, editor.state.doc.content.size)).top - containerRect.top
} catch {}
}
if (Number.isFinite(desired) && node.classList.contains('composer-box')) desired -= 1 // wins ties
return { node, desired: Number.isFinite(desired) ? Math.max(0, desired) : Infinity }
})
entries.sort((a, b) => a.desired - b.desired)
let cursor = 0
for (const entry of entries) {
const top = entry.desired === Infinity ? cursor : Math.max(entry.desired, cursor)
entry.node.style.top = `${top}px`
cursor = top + entry.node.offsetHeight + 10
}
container.style.minHeight = `${cursor}px`
}
function rangeFromCard(editor, card) {
const id = card.dataset.item
const suggestion = [...suggestionValues()].find(s => s.id === id)
if (suggestion) return itemRange(editor, { kind: 'sugg', data: suggestion })
const ythread = threads.get(id)
if (ythread) return itemRange(editor, { kind: 'thread', ythread })
return null
}
function suggestionValues() {
const out = []
suggestions.forEach(s => out.push(s))
return out
}
function cardShell(id, kind, expanded) {
const card = el('div', { class: `card ${kind}${expanded ? ' expanded' : ''}`, 'data-item': id })
card.addEventListener('mousedown', e => {
if (e.target.closest('button, input, textarea, a, .mention-menu')) return
if (state.activeItem !== id) setActive(id)
})
return card
}
function cardHead(iconName, who, isAgent, preview, when) {
const head = el('div', { class: 'head' })
head.appendChild(icon(iconName))
head.appendChild(el('span', { class: `who${isAgent ? ' agent' : ''}` }, who))
head.appendChild(el('span', { class: 'preview' }, preview))
head.appendChild(el('span', { class: 'when' }, timeAgo(when)))
return head
}
function messageEl(msg) {
const m = el('div', { class: 'msg' })
const author = el('div', { class: 'author' })
author.appendChild(el('span', {}, msg.authorType === 'agent' ? `@${msg.author}` : msg.author))
if (msg.authorType === 'agent') author.appendChild(el('span', { class: 'agent-tag' }, 'agent'))
author.appendChild(el('span', { class: 'when' }, timeAgo(msg.ts)))
m.appendChild(author)
const textEl = el('div', { class: 'text' })
textEl.innerHTML = renderMentionText(msg.text)
m.appendChild(textEl)
for (const chip of msg.mentions || []) {
m.appendChild(el('span', { class: `chip ${chip.status}` }, `@${chip.handle} · ${statusLabel(chip.status)}`))
}
return m
}
function replyRow(placeholder, onSend) {
const row = el('div', { class: 'reply-row' })
// a textarea, not an input: replies need Shift+Enter to add a line too, and
// it grows with the text instead of scrolling a one-line box
const input = el('textarea', { placeholder, rows: '1' })
const menu = el('div', { class: 'mention-menu hidden' })
const send = el('button', { class: 'iconbtn', title: 'Send' })
send.appendChild(icon('send'))
const autoGrow = () => {
input.style.height = 'auto'
input.style.height = `${Math.min(input.scrollHeight, 140)}px`
}
send.addEventListener('click', () => {
if (!input.value.trim()) return
onSend(input.value.trim())
input.value = ''
autoGrow()
})
input.addEventListener('input', autoGrow)
input.addEventListener('keydown', e => {
if (e.key !== 'Enter' || e.shiftKey) return
if (!menu.classList.contains('hidden')) return // the mention menu is picking
e.preventDefault()
send.click()
})
row.append(input, send)
wireMentionMenu(input, menu)
return { row, menu }
}
// --- thread card ---
function threadCard(item, editor) {
const { id, ythread } = item
const expanded = state.activeItem === id
const card = cardShell(id, 'thread', expanded)
const msgs = ythread.get('messages')?.toArray() || []
const first = msgs[0] || {}
const resolved = ythread.get('resolved')
const head = cardHead('comment', first.authorType === 'agent' ? `@${first.author}` : first.author || '?', first.authorType === 'agent', (resolved ? 'resolved · ' : '') + (first.text || ''), first.ts)
if (!resolved) {
// resolvable without opening the card
const resolve = el('button', { class: 'iconbtn resolve-btn', title: 'Resolve comment' })
resolve.appendChild(icon('check'))
resolve.addEventListener('click', e => {
e.stopPropagation()
ythread.set('resolved', true)
if (state.activeItem === id) state.activeItem = null
})
head.appendChild(resolve)
}
card.appendChild(head)
// suggestions posted into this thread: one compact strip each, visible collapsed
for (const att of item.attached || []) {
card.appendChild(suggestionStrip(att.chain, editor))
}
if (!expanded) return card
const body = el('div', { class: 'body' })
const range = itemRange(editor, item)
const orphaned = !range || range.from >= range.to
if (ythread.get('excerpt')) {
const quote = el('blockquote', { class: orphaned ? 'orphaned' : '' }, ythread.get('excerpt'))
quote.title = orphaned ? 'The commented text was deleted' : 'Jump to text'
quote.addEventListener('click', () => jumpTo(editor, range))
body.appendChild(quote)
}
for (const msg of msgs) body.appendChild(messageEl(msg))
if (!resolved) {
const { row, menu } = replyRow('Reply — @agent to hand off', text => {
ythread.get('messages').push([makeMessage(text)])
})
body.appendChild(row)
body.appendChild(menu)
const actions = el('div', { class: 'actions' })
const resolve = el('button', { class: 'btn small ghost' })
resolve.append(icon('resolve'), document.createTextNode('Resolve'))
resolve.addEventListener('click', () => {
ythread.set('resolved', true)
state.activeItem = null
})
actions.appendChild(resolve)
body.appendChild(actions)
} else {
body.appendChild(el('div', { class: 'status-note' }, 'resolved'))
}
card.appendChild(body)
return card
}
// --- suggestion card ---
// suggestion attached to a comment thread: one compact row, actionable collapsed
function suggestionStrip(chain, editor) {
const s = chain.display
const actionable = s.status === 'open' || s.status === 'superseded'
const strip = el('div', { class: 'sugg-strip' + (state.activeItem === s.id ? ' active' : ''), 'data-sugg-strip': s.id })
strip.appendChild(icon('edit'))
strip.appendChild(el('span', { class: `who${s.authorType === 'agent' ? ' agent' : ''}` }, s.authorType === 'agent' ? `@${s.author}` : s.author))
strip.appendChild(el('span', { class: 'preview' }, actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`))
if (chain.members.length > 1) strip.appendChild(chainNav(chain))
if (actionable) strip.append(...suggestionActionButtons(s, editor))
strip.addEventListener('mousedown', e => {
if (e.target.closest('button')) return
setActive(s.id)
})
return strip
}
function suggestionCard(item, editor) {
const s = item.data
const expanded = state.activeItem === s.id
const card = cardShell(s.id, 'suggestion', expanded)
// the diff lives in the document itself — the card only carries identity,
// rationale, actions (always visible, even collapsed) and the discussion
const actionable = s.status === 'open' || s.status === 'superseded'
const head = cardHead(
'edit',
s.authorType === 'agent' ? `@${s.author}` : s.author,
s.authorType === 'agent',
actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`,
s.ts
)
if (item.chain && item.chain.members.length > 1) head.appendChild(chainNav(item.chain))
if (actionable) head.append(...suggestionActionButtons(s, editor))
card.appendChild(head)
if (!expanded) return card
const body = el('div', { class: 'body' })
if (actionable && s.rationale) body.appendChild(el('div', { class: 'rationale' }, s.rationale))
if (!actionable) body.appendChild(el('div', { class: 'status-note' }, `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`))
// discussion on the suggestion (e.g. to guide the agent to revise it)
const msgs = item.linkedThread?.ythread.get('messages')?.toArray() || []
for (const msg of msgs) body.appendChild(messageEl(msg))
const { row, menu } = replyRow('Discuss — @agent to request changes', text => {
if (item.linkedThread) {
item.linkedThread.ythread.get('messages').push([makeMessage(text)])
} else {
createThread({ start: s.anchorStart, end: s.anchorEnd, excerpt: null }, text, s.id)
}
})
body.appendChild(row)
body.appendChild(menu)
card.appendChild(body)
return card
}
// --- accepting suggestions locally (so undo works) --------------------------------
// Run undo/redo against the Yjs UndoManager directly. Tiptap's wrapped
// undo command intermittently no-ops on large accept transactions (stale
// chained state), while the manager itself is always reliable.
function runUndo(editor) {
yUndoPluginKey.getState(editor.state)?.undoManager?.undo()
return true
}
function runRedo(editor) {
yUndoPluginKey.getState(editor.state)?.undoManager?.redo()
return true
}
// When the accepting user's own editor applies the replacement, the change lands
// in their collaborative undo history. The server then only records the status.
let pendingAcceptSuggestion = null
function wireUndoReopens(editor) {
const undoManager = yUndoPluginKey.getState(editor.state)?.undoManager
if (!undoManager) return
undoManager.on('stack-item-added', event => {
if (pendingAcceptSuggestion) {
event.stackItem.meta.set('acceptedSuggestion', pendingAcceptSuggestion)
pendingAcceptSuggestion = null
}
})
undoManager.on('stack-item-popped', event => {
if (event.type !== 'undo') return
const sid = event.stackItem.meta.get('acceptedSuggestion')
if (sid) api(`/api/docs/${docId}/suggestions/${sid}/reopen`, { method: 'POST', body: { page: pageSlug } })
})
}
function inlineToPmContent(segments) {
return (segments || [])
.filter(seg => seg.text)
.map(seg =>
// math is an atom carrying its source, not marked-up text
seg.math != null
? { type: 'mathInline', attrs: { latex: seg.math } }
: {
type: 'text',
text: seg.text,
marks: Object.entries(seg.attrs || {}).map(([type, attrs]) => ({ type, attrs })),
}
)
}
function blockToPmJSON(block) {
switch (block.type) {
case 'mathBlock':
return { type: 'mathBlock', attrs: { latex: block.attrs?.latex || '' } }
case 'heading':
return { type: 'heading', attrs: { level: block.attrs?.level || 1 }, content: inlineToPmContent(block.inline) }
case 'codeBlock':
return { type: 'codeBlock', attrs: block.attrs || {}, content: block.text ? [{ type: 'text', text: block.text }] : undefined }
case 'htmlBlock':
return { type: 'htmlBlock', attrs: { html: block.text || '' } }
case 'bulletList':
case 'orderedList':
return {
type: block.type,
content: (block.items || []).map(segments => ({
type: 'listItem',
content: [{ type: 'paragraph', content: inlineToPmContent(segments) }],
})),
}
case 'taskList':
return {
type: 'taskList',
content: (block.items || []).map(it => ({
type: 'taskItem',
attrs: { checked: !!it.checked },
content: [{ type: 'paragraph', content: inlineToPmContent(it.inline) }],
})),
}
case 'table':
return {
type: 'table',
content: (block.rows || []).map((cells, r) => ({
type: 'tableRow',
content: cells.map(segs => ({
type: r === 0 ? 'tableHeader' : 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: 'paragraph', content: inlineToPmContent(segs) }],
})),
})),
}
case 'blockquote':
return { type: 'blockquote', content: [{ type: 'paragraph', content: inlineToPmContent(block.inline) }] }
case 'horizontalRule':
return { type: 'horizontalRule' }
case 'image':
return { type: 'image', attrs: { src: block.attrs?.src || '', alt: block.attrs?.alt || null } }
default: {
const content = inlineToPmContent(block.inline)
return content.length ? { type: 'paragraph', content } : { type: 'paragraph' }
}
}
}
// Every block of the replacement must be findable in the result. A distinctive
// slice of each block's text is enough — exact node comparison would trip over
// harmless schema normalisation.
function replacementLanded(doc, from, blocks) {
const after = doc.textBetween(Math.max(0, from), doc.content.size, '\n', '\n')
return blocks.every(b => {
const want = blockDescText(b).replace(/\s+/g, ' ').trim()
if (!want) return true
const probe = want.slice(0, 40)
return after.replace(/\s+/g, ' ').includes(probe)
})
}
function tryClientApply(editor, s) {
try {
const range = suggestionRange(editor.state, s)
if (!range) return false
const blocks = markdownToBlocks(s.replacementMarkdown)
const nodes = blocks.map(b => editor.schema.nodeFromJSON(blockToPmJSON(b)))
// Build the transaction first and check what it would actually produce.
// ProseMirror silently drops whatever does not fit the target position, so a
// range that resolved inside a list item (or any nested block) can apply a
// partial replacement — one live doc ended up with an entry's detail text
// nested under the *previous* person, its heading line gone. Let the server
// do the replacement in that case: it works on whole blocks.
const tr = editor.state.tr.replaceWith(range.from, range.to, nodes)
if (!replacementLanded(tr.doc, range.from, blocks)) {
console.warn('client-side apply would drop content, falling back to server apply')
return false
}
pendingAcceptSuggestion = s.id
editor.view.dispatch(tr)
// stack-item-added fires synchronously during dispatch; if the change was
// merged into an existing stack item instead, don't tag a later one
setTimeout(() => {
pendingAcceptSuggestion = null
}, 0)
return true
} catch (err) {
pendingAcceptSuggestion = null
console.warn('client-side apply failed, falling back to server apply', err)
return false
}
}
// --- agents ---------------------------------------------------------------------
function wireAgentsPanel() {
const btn = document.getElementById('agents-btn')
const pop = document.getElementById('agents-pop')
btn.addEventListener('click', () => pop.classList.toggle('hidden'))
document.getElementById('agent-form').addEventListener('submit', async e => {
e.preventDefault()
const input = document.getElementById('agent-handle')
const handle = input.value.trim().toLowerCase()
if (!handle) return
const res = await api('/api/agents', { method: 'POST', body: { handle } })
if (res.error) alert(res.error)
else {
input.value = ''
refreshAgents()
if (res.key) pop.appendChild(agentKeyBox({ handle: res.handle, key: res.key, docId }))
}
})
}
// --- sharing ------------------------------------------------------------------
function wireSharePanel() {
const btn = document.getElementById('share-btn')
const pop = document.getElementById('share-pop')
btn.addEventListener('click', () => {
pop.classList.toggle('hidden')
if (!pop.classList.contains('hidden')) renderShareList()
})
document.getElementById('share-form').addEventListener('submit', async e => {
e.preventDefault()
const input = document.getElementById('share-user')
const username = input.value.trim()
if (!username) return
const res = await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username } })
if (res.error) return alert(res.error)
input.value = ''
renderShareList()
})
}
async function renderShareList() {
const meta = await api(`/api/docs/${docId}`)
const wrap = document.getElementById('share-list')
if (meta.error) {
wrap.textContent = meta.error
return
}
const isCreator = !meta.created_by || meta.created_by === state.me.username
document.getElementById('share-form').classList.toggle('hidden', !isCreator)
wrap.className = ''
const rows = []
if (meta.created_by) {
const owner = el('div', { class: 'share-row' })
owner.append(el('span', { class: 'handle' }, meta.created_by), el('span', { class: 'muted' }, 'owner'))
rows.push(owner)
}
for (const username of meta.shared_with || []) {
const row = el('div', { class: 'share-row' })
row.appendChild(el('span', { class: 'handle' }, username))
if (isCreator) {
const rm = el('button', { class: 'iconbtn', title: 'Remove access' })
rm.appendChild(icon('x'))
rm.addEventListener('click', async () => {
await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username, remove: true } })
renderShareList()
})
row.appendChild(rm)
}
rows.push(row)
}
if (!rows.length) rows.push(el('div', { class: 'muted' }, 'Not shared with anyone yet.'))
wrap.replaceChildren(...rows)
}
async function refreshAgents() {
const res = await api(`/api/agents?doc=${docId}`)
if (!res.agents) return
state.agents = res.agents
const wrap = document.getElementById('agents-list')
if (!res.agents.length) {
wrap.className = 'muted'
wrap.textContent = 'No agents registered yet.'
return
}
wrap.className = ''
wrap.replaceChildren(
...res.agents.map(a => {
const row = el('div', { class: 'agent-row' })
row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''} tip-below`, 'data-tip': a.online ? 'Online — the agent is polling for work' : 'Offline — the agent is not polling right now' }))
const id = el('span', { class: 'agent-id' })
id.appendChild(el('span', { class: 'handle' }, `@${a.handle}`))
id.appendChild(el('span', { class: 'muted owner' }, a.owner + (a.shared === false ? ' · private' : '')))
row.appendChild(id)
const actions = el('span', { class: 'actions' })
const copy = el('button', { class: 'iconbtn', 'data-tip': 'Copy the agent prompt (paste it into your coding agent)' })
copy.appendChild(icon('copy'))
copy.addEventListener('click', async () => {
const text = await fetch(`/api/agent-prompt?doc=${docId}&handle=${a.handle}`).then(r => r.text())
await navigator.clipboard.writeText(text)
copy.replaceChildren(icon('check'))
setTimeout(() => copy.replaceChildren(icon('copy')), 1500)
})
actions.appendChild(copy)
if (a.owner === state.me.username) {
const vis = el('button', {
class: 'iconbtn' + (a.shared ? ' on' : ''),
'data-tip': a.shared ? 'Shared: collaborators can @mention this agent — click to make it private' : 'Private: only you can @mention it — click to share',
})
vis.appendChild(icon(a.shared ? 'eye' : 'eyeoff'))
vis.addEventListener('click', async () => {
const res = await api(`/api/agents/${a.handle}/visibility`, { method: 'POST', body: { shared: !a.shared } })
if (res.error) alert(res.error)
else refreshAgents()
})
actions.appendChild(vis)
const rot = el('button', { class: 'iconbtn', 'data-tip': 'Issue a new key (the old one stops working)' })
rot.appendChild(icon('key'))
rot.addEventListener('click', async () => {
const res = await api(`/api/agents/${a.handle}/rotate`, { method: 'POST' })
if (res.error) alert(res.error)
else document.getElementById('agents-pop').appendChild(agentKeyBox({ handle: a.handle, key: res.key, docId }))
})
actions.appendChild(rot)
}
if (a.owner === state.me.username || state.me.isAdmin) {
const del = el('button', { class: 'iconbtn', 'data-tip': 'Remove this agent (its key stops working, open tasks are cancelled)' })
del.appendChild(icon('trash'))
del.addEventListener('click', async () => {
if (!confirm(`Remove @${a.handle}? Its key stops working and open tasks are cancelled.`)) return
const res = await api(`/api/agents/${a.handle}`, { method: 'DELETE' })
if (res.error) alert(res.error)
refreshAgents()
})
actions.appendChild(del)
}
row.appendChild(actions)
return row
})
)
}
// --- mention autocomplete ----------------------------------------------------------
function wireMentionMenu(input, menu) {
const getQuery = () => {
const pos = input.selectionStart ?? input.value.length
const before = input.value.slice(0, pos)
const m = before.match(/@([a-z0-9_.-]*)$/i)
return m ? { q: m[1].toLowerCase(), start: pos - m[1].length } : null
}
let options = []
let sel = 0
const hide = () => {
menu.classList.add('hidden')
options = []
}
const insert = a => {
const ctx = getQuery()
if (!a || !ctx) return
input.value = input.value.slice(0, ctx.start) + a.handle + ' ' + input.value.slice(input.selectionStart)
const caret = ctx.start + a.handle.length + 1
input.setSelectionRange(caret, caret)
hide()
input.focus({ preventScroll: true })
}
const render = () => {
const ctx = getQuery()
if (!ctx) return hide()
// your own agents first, so the preselected row is the one you meant
const matches = state.agents.filter(a => a.handle.startsWith(ctx.q))
const mine = matches.filter(a => a.owner === state.me?.username)
options = [...mine, ...matches.filter(a => !mine.includes(a))].slice(0, 6)
if (!options.length) return hide()
sel = Math.min(sel, options.length - 1)
menu.replaceChildren(
...options.map((a, i) => {
const opt = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') })
opt.append(el('span', { class: `dot${a.online ? ' on' : ''}` }), document.createTextNode(`@${a.handle}`))
opt.addEventListener('mouseenter', () => {
if (sel === i) return
sel = i
render()
})
opt.addEventListener('mousedown', e => {
e.preventDefault()
insert(a)
})
return opt
})
)
menu.classList.remove('hidden')
}
input.addEventListener('input', () => {
sel = 0 // a new query means a new list — start at your first agent again
render()
})
// arrows move through the list, Enter/Tab picks, Escape closes it without
// touching the comment box itself
input.addEventListener('keydown', e => {
if (menu.classList.contains('hidden') || !options.length) return
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
sel = (sel + (e.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length
render()
menu.querySelector('.selected')?.scrollIntoView({ block: 'nearest' })
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
insert(options[sel])
return
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
hide()
}
})
input.addEventListener('blur', () => setTimeout(hide, 150))
}
// --- presence -----------------------------------------------------------------------
function renderPresence(provider) {
const wrap = document.getElementById('presence')
const seen = new Set()
const avatars = []
provider.awareness?.getStates().forEach(s => {
const u = s.user
if (!u?.name || seen.has(u.name)) return
seen.add(u.name)
const a = el('div', { class: 'avatar', title: u.name }, u.name.slice(0, 2).toUpperCase())
a.style.background = u.color || '#999'
avatars.push(a)
})
wrap.replaceChildren(...avatars)
}