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' import { attachTreeDrag } from './treedrag.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 ` case 'blockquote': return `

${inlineHtml(b.inline)}

` case 'mathBlock': return `
` case 'horizontalRule': return '
' case 'image': return `${esc(b.attrs?.alt || '')}` default: return `

${inlineHtml(b.inline)}

` } } 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 = pageFragment() 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)) } // Hide a composer box AND drop the highlight that marked what it was about. function hideComposerBox(id) { document.getElementById(id)?.classList.add('hidden') const editor = window.__editor if (editor && !editor.isDestroyed) poke(editor) } 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 are positioned a frame after opening. On desktop they share the // document's scroll container, so scrollIntoView() would move the selected text // just to reveal the box beside it. The selection is already visible: leave the // page alone. In stacked mode, reveal it by scrolling only the comments sheet. function settleComposerAfterLayout(node) { requestAnimationFrame(() => requestAnimationFrame(() => { try { const items = document.getElementById('margin-items') // Stacked composers are the first children of this scrolling list. // Reset that list directly; never ask the browser to scroll ancestors. if (isStackedMargin() && items.contains(node)) items.scrollTop = 0 } 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() } // --- reader text size ------------------------------------------------------- // The other half of the toolbar's size pair, and the exact complement of zoom: // zoom magnifies the page whole and the line breaks are frozen, this re-sets the // type inside a page whose width never moves, so the lines re-break and the // words-per-line changes. That is a Kindle's A/A, and it is why the two cannot // be folded into one control however alike they look. // // What is stored is the factor, not the resulting size — see the // --doc-text-scale comment in app.css for why an absolute size loses to the // document style switch. Module scope rather than a local in buildHeaderTools // because the toolbar is rebuilt on every page switch and the choice must not // be: the label is re-hung each time, the value survives. const TEXT_SCALE_MIN = 0.8 const TEXT_SCALE_MAX = 1.6 let textScale = clampTextScale(Number(localStorage.getItem('doc-text-scale')) || 1) let docSizeLabel = null function clampTextScale(v) { // 0.8 and 1.6 are where the 624px column gives out at either end, measured on // a desktop at 100% zoom. At 0.8 Docs is 11.7px of Arial running 96 characters // a line — smaller and it is no longer a reading size, wider and it is no // longer a line. At 1.6 Reading is 25.6px running 42, which is a newspaper // column's measure with a book's words in it: any further and the rag is // doing more work than the text. Steps of 0.1, like zoom's, so the two // controls feel like one pair. // // A phone is a known gap in that reasoning, not a case it covers. The 8.5in // page only fits a handset at around zoom's 40% floor, and 1.6 on top of that // is 9.4px on the glass — the ceiling is generous in document units and // stingy in the only units the reader's eye has. Raising it is not the fix // either: it is the fixed page width the phone is really fighting, so this // stays measured against the desktop column until that changes. return Math.round(Math.max(TEXT_SCALE_MIN, Math.min(TEXT_SCALE_MAX, v)) * 100) / 100 } // The label reports the size the DOCUMENT is set in, not the size the screen is // showing: zoom is deliberately not folded in, the same way Docs' font-size box // keeps saying 11 at 200%. The base is read back out of the stylesheet rather // than kept here because it belongs to the document style — it is 14.6667px // under Docs and 16px under Reading, and it changes under us on a style switch. // // That analogy is not free, and this is the part of it that costs. Docs' box // says "11" — a unitless point size, a fact about the document. Ours says // "15px", and px reads as a promise about the screen that zoom can break: on a // phone at zoom's 40% floor with the text at 160%, the glass shows 9.4px while // this reads "23px". Docs' own unit (11pt, which is also what this app's PDF // export is set in) would have been immune to that, and was passed over for a // smaller reason: px next to zoom's % is what makes the two readouts legible as // different questions at a glance, and pt in a browser UI is a unit almost // nobody has a feel for. Two units for one document is the price. function paintDocSize() { if (!docSizeLabel) return const base = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-size')) || 16 docSizeLabel.textContent = `${Math.round(base * textScale)}px` } function applyTextSize(editor, step = 0) { textScale = clampTextScale(textScale + step) document.documentElement.style.setProperty('--doc-text-scale', textScale) localStorage.setItem('doc-text-scale', String(textScale)) paintDocSize() // the readout is the one part of the toolbar whose width changes as you use // it ("9px" against "26px"), which is enough to tip a header that was just // fitting — cheap to re-ask, and wrong exactly once if we don't fitHeaderTools() // no fitLayout(): the sheet is exactly as wide as it was, so the columns are // already fitted. Every line moved, though, and the comment cards are pinned // to lines — those do have to be laid out again. scheduleLayout(editor) } 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 = () => { // Two floors, because zoom means two different things either side of the // reflow width. Where the page is a page, 0.4 is how you fit 8.5in into a // narrow window — that is what the floor was dropped from 0.8 for. Where the // sheet is fluid (see the ≤840px block in app.css) fitting is already done // and --doc-zoom only reaches the type, so 0.4 would be 6.4px of body text // and nothing gained: 0.8 is the smallest step that is still reading size. // This also lifts a 0.5 left in localStorage by someone who once tried to // squeeze the page into their phone: applyZoom runs on every mount, so the // first paint after the fix corrects it. // It re-clamps on a crossing too. The first version of this read the floor // only when the zoom was applied, on the argument that a live clamp would // move a reader's setting under them mid-drag. That is a fair description of // a desktop window drag and a wrong one for the case that actually happens: // on a handset you cross 840 by ROTATING THE DEVICE, where nobody is // mid-anything, and the un-clamped result was 6.4px of body text — the // unreadable state this whole change exists to remove — held until the next // reload. A drag pays a one-step correction at the boundary; a rotation // stops being unreadable. See the resize listener near applyBreakpoint. const floor = isReflowColumn() ? 0.8 : 0.4 zoom = Math.round(Math.max(floor, 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)) // zooming changes how much room the page takes, so the columns are re-fitted fitLayout() 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() // re-apply on a crossing so the floor for the width you are now at is the one // in force; applyZoom is idempotent, so this costs nothing when already legal reclampZoomFloor = applyZoom // reader text size — same shape as zoom (a step down, a readout, a step up) // so the pair reads as a pair, but a small A and a large A instead of two // magnifiers, and px instead of %, so nobody has to click one to find out // which is which. The tooltips say the part the glyphs cannot. // // Both buttons need an aria-label, and zoom's pattern does not carry over to // them: an accessible name comes from the element's text before its title, so // zoom's icon-only buttons fall through to their titles while these two — an // "A" each — would both announce as "A, button" and differ in nothing. The // readout is a live region for the same reason: pressing either button // changes the whole page, and none of that change is announced otherwise. docSizeLabel = el('span', { id: 'docsize-label', 'aria-live': 'polite' }) const smallerB = btn('A', 'Smaller text — the page keeps its width, so the lines re-break', () => { applyTextSize(editor, -0.1) }) smallerB.id = 'text-smaller' smallerB.setAttribute('aria-label', 'Smaller text') const largerB = btn('A', 'Larger text — the page keeps its width, so the lines re-break', () => { applyTextSize(editor, 0.1) }) largerB.id = 'text-larger' largerB.setAttribute('aria-label', 'Larger text') bar.append(sep(), smallerB, docSizeLabel, largerB) applyTextSize(editor) // 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) // the bar was just rebuilt from nothing, so whether it still fits is an open // question — ask it before the frame paints rather than at the next resize fitHeaderTools() 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 && !state.viewer) { 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) { uiAlert('Could not open the comment box: ' + err.message) } }) suggestBtn.addEventListener('mousedown', e => { e.preventDefault() openSuggestComposer(window.__editor).catch(err => uiAlert('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) // A selection owns the comment flow while it lasts. Dropped here as well as // on hover, because a selection made with the keyboard never moves the mouse // and would otherwise leave the button standing next to it. if (!empty) hideHoverComment() 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' hideComposerBox(isSuggest ? 'suggest-composer' : 'composer') 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. // Which agent the box comes pre-mentioned with. Connected first: an agent that // is polling picks the work up now, where an offline one sits on the queue until // it comes back. Yours wins the tie, and an offline agent still beats none — // the mention is delivered whenever it next polls. function defaultAgentHandle() { const mine = a => a.owner === state.me?.username const rank = a => (a.online ? 0 : 2) + (mine(a) ? 0 : 1) return [...(state.agents || [])].sort((a, b) => rank(a) - rank(b))[0]?.handle || null } // --- comment composer ------------------------------------------------------------- function openComposer(editor, opts = {}) { state.composerAnchors = opts.anchors || selectionAnchors(editor) // A position-anchored comment quotes nothing, so the blockquote would be an // empty bar. It gets the locator line instead — see anchorLocText for why the // paragraph's opening words and not a paragraph number. const positional = state.composerAnchors.kind === 'position' const quote = document.getElementById('composer-quote') const loc = document.getElementById('composer-loc') quote.classList.toggle('hidden', positional) loc.classList.toggle('hidden', !positional) if (positional) loc.replaceChildren(icon('quote'), el('span', { class: 'words' }, anchorLocText(state.composerAnchors.context))) else quote.textContent = state.composerAnchors.excerpt hideComposerBox('suggest-composer') document.getElementById('composer').classList.remove('hidden') document.getElementById('selection-menu').classList.add('hidden') const text = document.getElementById('composer-text') if (!state.composerDirty) { const handle = defaultAgentHandle() text.value = handle ? `@${handle} ` : '' text.setSelectionRange(text.value.length, text.value.length) // presence is only fetched on load, so who is connected may be minutes out // of date by the time you comment. Re-check in the background and correct // the mention — but only while the box is still exactly as we left it, so // this can never overwrite something you typed or picked yourself. const prefilled = text.value refreshAgents().then(() => { if (state.composerDirty || text.value !== prefilled) return if (document.getElementById('composer').classList.contains('hidden')) return const fresh = defaultAgentHandle() const next = fresh ? `@${fresh} ` : '' if (next === text.value) return text.value = next if (document.activeElement === text) text.setSelectionRange(next.length, next.length) }) } state.autoComposer = opts.auto ? 'comment' : null poke(editor) // mark the pending range now that composerAnchors is set 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 }) settleComposerAfterLayout(document.getElementById('composer')) } // What a comment on the current selection is attached to: the two ends of the // selection as Yjs relative positions, plus the words themselves to quote. function selectionAnchors(editor) { const { from, to } = editor.state.selection const ystate = ySyncPluginKey.getState(editor.state) return { 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), } } // --- position anchors: a comment beside a paragraph, on no words at all --------- // // What a position-anchored comment stores, and why: // // ONE Yjs relative position, at the first position INSIDE the paragraph (or // heading, list item, cell, code block) the reader was beside — written into // both anchor fields so every path that already resolves a thread's anchors // (itemRange, layoutMargin, rangeFromCard) keeps working unchanged and simply // sees an empty range. A character offset would be wrong under concurrent // editing: someone typing higher up the page would slide the comment down the // document. This is the same machinery the span anchors of a normal comment use // — deliberately, so there is one way that comment positions survive edits. // // It is INSIDE the paragraph rather than at the fragment index of the block, // which is what a suggestion's anchors use. A suggestion needs to name a block it // will replace; a comment only needs to name a place, and a place inside the // paragraph rides along with the paragraph when blocks are inserted, split or // merged around it. // // What this does NOT give you is any signal about the BLOCK. Measured on this // branch, a position anchor whose paragraph stops existing resolves to a bare // boundary between blocks, and it resolves there for two very different reasons: // // delete the paragraph -> boundary where it used to be, words gone // Backspace at its start -> boundary after the paragraph it merged into, // every word still on the page // // Position alone cannot separate those, and there is no element identity to fall // back on either: y-prosemirror's updateYFragment can UPDATE a surviving sibling // element in place rather than delete one and build another (the reuse // stableAfterIndex already works around), so "is the element I anchored into // still alive" is not a question the CRDT answers usefully. threadAnchorState // therefore asks the text instead — see the comment there. function positionAnchors(editor, pos) { const ystate = ySyncPluginKey.getState(editor.state) if (!ystate?.binding) return null const $pos = editor.state.doc.resolve(pos) if (!$pos.parent.isTextblock) return null const rel = encodeRel(absolutePositionToRelativePosition(pos, ystate.type, ystate.binding.mapping)) return { start: rel, end: rel, kind: 'position', // Kept at creation time so the card and any @mentioned agent can say WHERE // the comment is even after the paragraph has been rewritten around it. It is // also what tells a merge from a deletion later, so it has to be built the // same way the text it will be compared against is — hence blockText. context: blockText($pos.parent).slice(0, 140), } } // The text of one block, the way anchorContext records it. Shared so that the // remembered words and the words we later look for are produced by the same call // — inline atoms included, since leafText gives a formula or a figure a stable // stand-in ("$E = mc^2$", "[figure]") where textContent gives nothing. function blockText(node) { return node.textBetween(0, node.content.size, ' ', leafText).trim() } // What the sidebar (and the open composer) shows in place of a quote. The // paragraph's opening words, not a line or paragraph NUMBER: a number is // meaningless to someone scanning a card, and it is also the least stable thing // on the page — every paragraph typed above it renumbers every comment below. // The opening words identify the place, stay recognisable while the paragraph is // edited, and read as a location rather than as something the author quoted. function anchorLocText(context) { const words = (context || '').trim() if (!words) return 'an empty paragraph' return words.length > 72 ? words.slice(0, 72).replace(/\s+\S*$/, '') + '…' : words } // Where a thread points, now. For a position-anchored thread `orphaned` has to // mean "the text this was left beside is gone", and that is NOT the same question // as "does the anchor still land inside a block" — which is where the first cut of // this got it wrong. Joining two paragraphs (Backspace at the start of the // anchored one, the most ordinary way a paragraph stops existing) leaves the // anchor on a bare boundary while every word survives one line up, and the card // then claimed the paragraph had been deleted while pointing straight at it. // // So when the anchor is not inside a block, ask the text rather than the // position: a merge leaves the remembered words in a block touching that // boundary, a deletion does not. What each case looks like, measured: // // Backspace-merge nodeBefore = "P4 delta here.P5 epsilon is a long..." -> live // delete mid-page nodeBefore = "P4 delta here.", nodeAfter = "P6 ..." -> orphan // delete last block nodeBefore = "P4 delta here.", nodeAfter = null -> orphan // // (Merging FORWARD — Delete at the end of the anchored paragraph — needs none of // this: the anchor is still inside the merged block, so it never reaches here.) // // Comparing text is the wrong tool for deciding whether a LIVE block is still the // one you meant — ordinary editing would false-positive, which is exactly why the // anchor is positional. It is the right tool here because it only ever runs once // the position has already given up, and the two ways it can be wrong are mild: // words edited away before a merge read as orphaned (what this did in every merge // before), and a short remembered phrase that happens to appear next to the gap // reads as live (a missing strikethrough, never a false claim of deletion). // // Either way the thread is kept and never closed on anyone's behalf — deleting // text must not end a conversation about it — and the position is still the place // the paragraph occupied, so the card holds its row in the panel instead of // dropping to the bottom. function threadAnchorState(editor, ythread) { const range = itemRange(editor, { kind: 'thread', ythread }) if (ythread.get('anchorKind') !== 'position') { return { range, positional: false, orphaned: !range || range.from >= range.to } } if (!range) return { range: null, positional: true, orphaned: true } if (insideTextblock(editor.state, range.from)) return { range, positional: true, orphaned: false } return { range, positional: true, orphaned: !contextSurvivesBeside(editor.state, range.from, ythread.get('anchorContext')) } } // Whitespace is not part of the identity of a place: a merge can put a newline // where a space was, and textBetween's block separator is not the one that built // the context. const squash = s => (s || '').replace(/\s+/g, ' ').trim() // Are the words we remembered still in a block touching this boundary? Only the // two immediate neighbours are considered: a merge puts them right there, and // searching further would start finding coincidences. function contextSurvivesBeside(pmState, pos, context) { const words = squash(context) // Nothing was remembered — a comment left beside an empty paragraph. There is // no text to find, and an empty paragraph that merged away is as gone as one // that was deleted, so the position's own verdict stands. if (!words) return false if (pos < 0 || pos > pmState.doc.content.size) return false const $pos = pmState.doc.resolve(pos) return [$pos.nodeBefore, $pos.nodeAfter].some(n => n && squash(blockText(n)).includes(words)) } function insideTextblock(pmState, pos) { if (pos < 0 || pos > pmState.doc.content.size) return false return pmState.doc.resolve(pos).parent.isTextblock } // A whole-block decoration for the paragraph a position anchor sits in. Used for // the open composer's pending marker and for the active thread; a position // comment marks nothing at all the rest of the time, which is the point. function anchorBlockDeco(pmState, pos, cls) { if (!insideTextblock(pmState, pos)) return null const $pos = pmState.doc.resolve(pos) if ($pos.depth < 1) return null return Decoration.node($pos.before($pos.depth), $pos.after($pos.depth), { class: cls }) } // --- the hover affordance ------------------------------------------------------ // // Hover anywhere over the right third of the page and a comment button appears // out in the right margin, level with the paragraph you are beside; click it and // the composer opens on a comment anchored to that place rather than to a span. // // Three things this deliberately does NOT do: // // - It adds no gutter. The button is absolutely positioned inside #editor-col // (like #selection-menu) and lives in the page's own 1in right margin, so // arming the zone never moves a single line of text. In the squeezed layout // where the comments panel overlaps the page (--margin-applied-overlap) a // card can sit over it; it is drawn above them so it stays clickable. // - It never shows while a selection is live, or while either composer is // open. The selection flow is the way to comment ON words and this must not // race it — a drag that ends in the right third would otherwise pop both. // - It does not exist on touch. There is no hover there to reveal it with, and // a permanently visible button in the margin is exactly the gutter we are // avoiding. Selecting text and using the Comment bubble is the whole flow on // a phone, and it already works. Hidden in CSS under (hover: none) as well // as never being armed, so a hybrid device cannot leave one behind. const HOVER_ZONE = 2 / 3 // fraction of the sheet's width you have to be past let hoverBlockPos = null // anchor position the button currently stands for function wireHoverComment() { const btn = document.getElementById('hover-comment') btn.appendChild(icon('commentadd')) let raf = null const onMove = e => { if (raf) return raf = requestAnimationFrame(() => { raf = null updateHoverComment(e.clientX, e.clientY) }) } const col = document.getElementById('editor-col') col.addEventListener('mousemove', onMove) col.addEventListener('mouseleave', () => hideHoverComment()) // Scrolling separates the button from the pointer that armed it. The button is // absolutely positioned in #editor-col, so it travels WITH the text and stays // beside its own paragraph — a wheel scroll of 400px measured the button and // its anchor still agreeing on paragraph 20 while the pointer had arrived at // paragraph 44. Self-consistent, but it is now a button offering to comment on // something nowhere near the cursor, possibly scrolled up under the header. The // pointer has not moved, so there are no fresh coordinates to re-aim with: // drop it and let the next mousemove arm it again. Capture, because scroll does // not bubble and the vertical scroller here is the window while #editor-col // scrolls horizontally — one listener has to see both. window.addEventListener('scroll', () => hideHoverComment(), { passive: true, capture: true }) // mousedown, not click: the same reason the selection menu uses it — the // default action would move focus out of the editor before we have read it. btn.addEventListener('mousedown', e => { e.preventDefault() const editor = window.__editor if (!isLive(editor) || hoverBlockPos == null) return const anchors = positionAnchors(editor, hoverBlockPos) if (!anchors) return hideHoverComment() try { openComposer(editor, { anchors }) } catch (err) { uiAlert('Could not open the comment box: ' + err.message) } }) } function hideHoverComment() { hoverBlockPos = null document.getElementById('hover-comment')?.classList.add('hidden') } function updateHoverComment(clientX, clientY) { const btn = document.getElementById('hover-comment') const editor = window.__editor const boxOpen = id => document.getElementById(id)?.classList.contains('hidden') === false if ( !isLive(editor) || state.previewPid || isStackedMargin() || window.matchMedia('(hover: none)').matches || !editor.state.selection.empty || boxOpen('composer') || boxOpen('suggest-composer') ) { return hideHoverComment() } const sheet = document.getElementById('editor') const textCol = sheet.querySelector('.tiptap') if (!textCol) return hideHoverComment() const sheetRect = sheet.getBoundingClientRect() const textRect = textCol.getBoundingClientRect() if (clientX < sheetRect.left + sheetRect.width * HOVER_ZONE || clientX > sheetRect.right) return hideHoverComment() // Vertically the zone is the text, not the sheet: a short page leaves inches // of blank paper below the last line, and there is no paragraph to be beside // out there. The 1in top and bottom margins are blank for the same reason. if (clientY < textRect.top || clientY > textRect.bottom) return hideHoverComment() const hit = textblockAtHeight(editor, clientY) if (!hit) return hideHoverComment() hoverBlockPos = hit.pos + 1 const colRect = document.getElementById('editor-col').getBoundingClientRect() const scrollLeft = document.getElementById('editor-col').scrollLeft // Level with the paragraph's FIRST line, which is also where its card and its // composer will be laid out (layoutMargin uses coordsAtPos of the same // position) — the button ends up pointing at the row the card will occupy. // Per-line alignment inside a paragraph was tried and rejected: the button // then twitches down the page as the pointer drifts, for an anchor that does // not change. let mid = hit.rect.top + Math.min(hit.rect.height, 18) / 2 try { const coords = editor.view.coordsAtPos(hoverBlockPos) if (Number.isFinite(coords.top)) mid = (coords.top + coords.bottom) / 2 } catch {} btn.classList.remove('hidden') btn.style.left = `${textRect.right - colRect.left + scrollLeft + 8}px` btn.style.top = `${mid - colRect.top - btn.offsetHeight / 2}px` } // The deepest text block whose rendered box this height falls in. Asking // posAtCoords for a position at the pointer would not do: the pointer is out in // the page margin where there is no text to hit, and the answer we want is a // block, not a character. Walking the rendered boxes also means atoms answer // nothing — hover beside a bare image, formula or rule and no button appears, // because those are commented on by clicking them, a flow that already exists // and already quotes them ("[figure]", "$E = mc^2$"). // // Document order breaks the ties, which only matters for a table: every cell in a // row shares one Y band, so the first one wins and hovering beside a row anchors // to its LEFTMOST cell whichever cell you were level with. Some cell has to be // picked, and the leftmost is where the row starts reading; the card's locator // then shows that cell's words, so what you get is visible rather than silent. function textblockAtHeight(editor, clientY) { let hit = null editor.state.doc.descendants((node, pos) => { if (hit) return false if (!node.isTextblock) return true // keep descending: lists, tables, quotes let dom = null try { dom = editor.view.nodeDOM(pos) } catch {} if (dom?.nodeType === 1) { const rect = dom.getBoundingClientRect() if (clientY >= rect.top && clientY < rect.bottom) hit = { pos, rect } } return false // nothing inside a text block is a block }) return hit } // --- document switcher (top left) -------------------------------------------------- // There is no separate index page: the document you are in is the whole app, and // this is how you reach the others. Switching is a normal navigation — the shell // is cheap and it keeps one document per URL, which is what sharing a link means. function wireDocsMenu() { const btn = document.getElementById('docs-btn') const pop = document.getElementById('docs-pop') document.getElementById('docs-caret').appendChild(icon('caret')) const render = async () => { const res = await api('/api/docs') const wrap = document.getElementById('docs-list') if (res.error) return const docs = [...(res.docs || [])].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) wrap.className = docs.length ? '' : 'muted' if (!docs.length) { wrap.textContent = 'No documents yet — create one below.' return } wrap.replaceChildren(...docs.map(d => { const row = el('div', { class: 'doc-row' + (d.id === docId ? ' current' : '') }) const link = el('a', { href: `/d/${d.id}`, class: 'doc-row-main' }) link.appendChild(el('span', { class: 'title' }, d.title || 'Untitled')) // how the project itself opens, so the list says what each one is about. // Nothing written under home's title yet = no second line at all. if (d.subtitle) link.appendChild(el('span', { class: 'doc-row-sub' }, d.subtitle)) const who = d.createdBy && d.createdBy !== state.me?.username ? `${d.createdBy} · ` : '' // timeAgo says 'now' for anything inside a minute, which does not take 'ago' const when = timeAgo(d.updatedAt) link.appendChild(el('span', { class: 'meta' }, who + (when === 'now' ? 'just now' : `${when} ago`))) row.appendChild(link) // deletion stays creator-only, as it was on the page this replaces if (!d.createdBy || d.createdBy === state.me?.username) { const del = el('button', { class: 'iconbtn', title: 'Delete document (creator only)' }) del.appendChild(icon('trash')) del.addEventListener('click', async e => { e.preventDefault() e.stopPropagation() const ok = await uiConfirm({ title: 'Delete document', message: `Delete "${d.title || 'Untitled'}"? All its pages are gone for everyone. This cannot be undone.`, confirmLabel: 'Delete', danger: true, }) if (!ok) return const res2 = await api(`/api/docs/${d.id}`, { method: 'DELETE' }) if (res2.error) return uiAlert(res2.error) // deleting the one you are reading leaves nowhere to be — go to another if (d.id === docId) return location.assign('/') render() }) row.appendChild(del) } return row })) } btn.addEventListener('click', () => { pop.classList.toggle('hidden') if (!pop.classList.contains('hidden')) render() }) document.addEventListener('click', e => { if (!pop.contains(e.target) && !btn.contains(e.target)) pop.classList.add('hidden') }) document.getElementById('new-doc').addEventListener('click', async () => { const title = await uiPrompt({ title: 'New document', label: 'Title for the new document', placeholder: 'Untitled', confirmLabel: 'Create', }) if (title === null) return const res = await api('/api/docs', { method: 'POST', body: { title } }) if (res.error) return uiAlert(res.error) location.assign(`/d/${res.id}`) }) document.getElementById('playground-btn').addEventListener('click', async () => { const res = await api('/api/playground', { method: 'POST' }) if (res.error) return uiAlert(res.error) location.assign(`/d/${res.id}`) }) } function wireComposer() { const text = document.getElementById('composer-text') text.addEventListener('input', () => { state.composerDirty = true }) document.getElementById('composer-cancel').addEventListener('click', () => { hideComposerBox('composer') 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 hideComposerBox('composer') 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) // A position-anchored comment leaves `excerpt` null on purpose: that field // means "the words this comment is about", and there are none. anchorContext // is the paragraph it sits beside, which is a location, not a quotation. if (anchors.kind === 'position') { t.set('anchorKind', 'position') t.set('anchorContext', anchors.context || 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 = pageFragment() state.suggestAnchors = { start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)), end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)), } hideComposerBox('composer') 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 }) settleComposerAfterLayout(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', () => { hideComposerBox('suggest-composer') 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 uiAlert(res.error) hideComposerBox('suggest-composer') 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)) return refreshAnchorLocators(editor) layoutMargin(editor) }) } const LOC_TIP = 'Jump to this place in the text' const LOC_TIP_GONE = 'The paragraph this comment was left beside was deleted' // Whether a position-anchored comment's paragraph still exists changes with any // edit, and cards are deliberately NOT rebuilt on every keystroke — that would // clobber a reply someone is halfway through typing. So the locator's orphaned // state is refreshed in place, on the same frame the cards are repositioned. function refreshAnchorLocators(editor) { for (const loc of document.querySelectorAll('#margin-items .card .anchor-loc')) { const ythread = threads?.get(loc.closest('.card')?.dataset.item) if (!ythread) continue const orphaned = threadAnchorState(editor, ythread).orphaned loc.classList.toggle('orphaned', orphaned) loc.title = orphaned ? LOC_TIP_GONE : LOC_TIP } } // --- 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 every standalone suggestion card 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 // Validate before touching the editor. A proposal can still have a live // Yjs anchor after another proposal split its old target into several // blocks; applying first would duplicate the newly inserted neighbours. const validation = await api(`/api/docs/${docId}/suggestions/${s.id}/accept`, { method: 'POST', body: { validate_only: true, page: pageSlug }, }) if (validation.error) { uiAlert(validation.error) 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) uiAlert(res.error) }) return [reject, accept] } function renderMargin(editor) { const wrap = document.getElementById('margin-items') const items = [] 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 } }) items.push({ kind: 'sugg', id: s.id, data: s, linkedThread, chain, originId: suggestionOriginThreadId(s) }) } threads.forEach((ythread, id) => { if (ythread.get('suggestionId')) return if (ythread.get('resolved') && !state.showResolved) return items.push({ kind: 'thread', id, ythread, originId: ythread.get('originThreadId') || null }) }) // 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)') // The hover zone is invisible by design, so this is the only place that says // it exists. Worth the extra clause: an affordance nobody finds is no // affordance, and the empty panel is exactly where someone is looking. // A visitor gets none of that: every way of adding to the panel is closed // to them, so naming those ways would only be a list of things they cannot do. if (!composerOpen) { wrap.appendChild(el('div', { class: 'margin-empty muted' }, state.viewer ? 'Read-only view. Comments and suggestions on this project appear here.' : 'Select text to comment or suggest, or hover the right of a paragraph to comment beside it. 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 activeOriginThreadId() { const active = threads?.get(state.activeItem) return active && !active.get('suggestionId') ? state.activeItem : null } function suggestionOriginThreadId(s) { // New items carry an explicit origin. threadId is the legacy attachment // field, retained only so pre-origin suggestions keep their visual trail. return s.originThreadId || (s.threadId && threads?.get(s.threadId) && !threads.get(s.threadId).get('suggestionId') ? s.threadId : null) } function cardShell(id, kind, expanded, originId = null) { const related = originId && activeOriginThreadId() === originId const attrs = { class: `card ${kind}${expanded ? ' expanded' : ''}${related ? ' origin-related' : ''}`, 'data-item': id, } if (originId) attrs['data-origin'] = originId const card = el('div', attrs) card.addEventListener('mousedown', e => { if (e.target.closest('button, input, textarea, a, .mention-menu')) return if (state.activeItem !== id) setActive(id) }) return card } function originLink(originId, editor) { if (!originId) return null const origin = threads.get(originId) const closed = origin?.get('resolved') const button = el('button', { class: 'origin-link', type: 'button', title: 'Focus the comment that prompted this item' }, closed ? 'from closed comment' : 'from comment') button.addEventListener('click', e => { e.stopPropagation() if (origin?.get('resolved')) state.showResolved = true setActive(originId) if (origin) jumpTo(editor, itemRange(editor, { kind: 'thread', ythread: origin })) }) return button } function cardHead(iconName, who, isAgent, preview, when) { const head = el('div', { class: 'head' }) const identity = el('span', { class: 'head-identity' }) identity.appendChild(icon(iconName)) identity.appendChild(el('span', { class: `who${isAgent ? ' agent' : ''}` }, who)) const context = el('span', { class: 'head-context' }) context.appendChild(el('span', { class: 'preview' }, preview)) context.appendChild(el('span', { class: 'when' }, timeAgo(when))) head.append(identity, el('span', { class: 'head-actions' }), context) 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, item.originId) 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 ? 'closed · ' : '') + (first.text || ''), first.ts) const origin = originLink(item.originId, editor) if (origin) head.querySelector('.head-context').prepend(origin) if (!resolved) { // closable without opening the card. A tick reads as "approved" or "done", // which a comment is not — it is a conversation you are finished with. const close = el('button', { class: 'btn small ghost close-btn', 'data-tip': 'Close this comment' }, 'Close') close.addEventListener('click', e => { e.stopPropagation() ythread.set('resolved', true) if (state.activeItem === id) state.activeItem = null }) head.querySelector('.head-actions').appendChild(close) } card.appendChild(head) if (!expanded) return card const body = el('div', { class: 'body' }) const { range, positional, orphaned } = threadAnchorState(editor, ythread) if (positional) { // No quote: this comment is beside the text, not about a phrase in it. The // locator says where, in the paragraph's own opening words, and clicking it // puts the caret exactly where the comment was left. Once the paragraph is // deleted the words are all that is left of the place, so they stay — struck // through, with the anchor now marking the gap the paragraph left behind. const loc = el('div', { class: 'anchor-loc' + (orphaned ? ' orphaned' : '') }) loc.append(icon('quote'), el('span', { class: 'words' }, anchorLocText(ythread.get('anchorContext')))) loc.title = orphaned ? LOC_TIP_GONE : LOC_TIP // resolved at click time, not captured: the paragraph may have moved (or // gone) since the card was built loc.addEventListener('click', () => jumpTo(editor, threadAnchorState(editor, ythread).range)) body.appendChild(loc) } else if (!ythread.get('anchorStart')) { // A comment on the page as a whole — what an agent opens when its remark is // about no particular block. It marks no text BY DESIGN, so say so: without // this it reads as a comment whose highlight has gone missing. Distinct from // the positional case above, which does have a place, just not a phrase. body.appendChild(el('div', { class: 'muted page-level' }, 'On this page')) } else 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 close = el('button', { class: 'btn small ghost close-btn' }, 'Close') close.addEventListener('click', () => { ythread.set('resolved', true) state.activeItem = null }) actions.appendChild(close) body.appendChild(actions) } else { body.appendChild(el('div', { class: 'status-note' }, 'closed')) } card.appendChild(body) return card } // --- suggestion card --- function suggestionCard(item, editor) { const s = item.data const expanded = state.activeItem === s.id const card = cardShell(s.id, 'suggestion', expanded, item.originId) // 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 ) const origin = originLink(item.originId, editor) if (origin) head.querySelector('.head-context').prepend(origin) const actions = head.querySelector('.head-actions') if (item.chain && item.chain.members.length > 1) actions.appendChild(chainNav(item.chain)) if (actionable) actions.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) uiToast(res.error) else { input.value = '' refreshAgents() if (res.key) pop.appendChild(agentKeyBox({ handle: res.handle, key: res.key, docId })) } }) } // --- download ----------------------------------------------------------------- // Two independent choices: how much (this page / whole project) and in what // shape (markdown / pdf). Markdown is a zip built by the server; PDF goes through // the browser's own print-to-PDF, which is the only way to get the document's // real typography — the fonts, math and tables are already laid out here, and no // headless browser exists in the Space image to re-do it server-side. let downloadChoice = { scope: 'page', format: 'md' } function wireDownloadPanel() { const btn = document.getElementById('download-btn') btn.appendChild(icon('download')) const pop = document.getElementById('download-pop') const go = document.getElementById('dl-go') const hint = document.getElementById('dl-hint') const options = { scope: [ ['page', 'This page'], ['project', 'Whole project'], ], format: [ ['md', 'Markdown'], ['pdf', 'PDF'], ], } const render = () => { for (const [kind, list] of Object.entries(options)) { const wrap = document.getElementById(`dl-${kind}`) wrap.replaceChildren() for (const [value, label] of list) { const b = el('button', { class: 'dl-opt' + (downloadChoice[kind] === value ? ' on' : ''), type: 'button', role: 'radio', 'aria-checked': downloadChoice[kind] === value ? 'true' : 'false', }, label) b.addEventListener('click', () => { downloadChoice[kind] = value render() }) wrap.appendChild(b) } } const pages = downloadChoice.scope === 'project' ? 'every page in this project' : 'this page' hint.textContent = downloadChoice.format === 'md' ? `A zip: ${pages} as .md files, plus an assets folder with the images they use.` : `Opens your print dialog — choose "Save as PDF" to keep ${pages}.` go.textContent = downloadChoice.format === 'md' ? 'Download zip' : 'Open print dialog' } render() btn.addEventListener('click', () => { for (const id of ['agents-pop', 'share-pop', 'docs-pop']) document.getElementById(id)?.classList.add('hidden') pop.classList.toggle('hidden') }) document.addEventListener('mousedown', e => { if (!e.target.closest('#download-pop, #download-btn')) pop.classList.add('hidden') }) go.addEventListener('click', async () => { pop.classList.add('hidden') if (downloadChoice.format === 'md') return downloadMarkdownZip(downloadChoice.scope) await printPages(downloadChoice.scope) }) } function downloadMarkdownZip(scope) { // a plain navigation, so the browser's own download machinery (and the // Content-Disposition filename) does the work const url = `/api/docs/${docId}/export?scope=${scope}${scope === 'page' ? `&page=${encodeURIComponent(pageSlug)}` : ''}` const a = el('a', { href: url, download: '' }) document.body.appendChild(a) a.click() a.remove() } // Build a plain-HTML rendition of the pages and hand it to the print dialog. // Printing the live editor would only ever cover the page you are on, and would // carry its decorations (comment highlights, suggestion bars) onto the paper. async function printPages(scope) { const slugs = [] if (scope === 'page') slugs.push(pageSlug) else { const s = sidebarSnapshot || (await api(`/api/docs/${docId}/structure`)) const walk = nodes => { for (const node of nodes || []) { if (node.group == null && node.slug && node.slug !== '_structure') slugs.push(node.slug) walk(node.children) } } walk(s.tree) for (const slug of s.unfiled || []) if (slug !== '_structure') slugs.push(slug) } const unique = [...new Set(slugs)] uiToast(unique.length > 1 ? `Preparing ${unique.length} pages…` : 'Preparing…') const pages = [] for (const slug of unique) { const doc = await api(`/api/docs/${docId}?page=${encodeURIComponent(slug)}`) if (doc?.error) continue pages.push({ slug, markdown: doc.markdown || '' }) } if (!pages.length) return uiToast('nothing to print') document.getElementById('print-view')?.remove() const view = el('div', { id: 'print-view' }) for (const [i, page] of pages.entries()) { const sheet = el('section', { class: 'print-page tiptap' + (i ? ' page-break' : '') }) sheet.innerHTML = markdownToBlocks(page.markdown).map(blockToHtml).join('') view.appendChild(sheet) } document.body.appendChild(view) // blockToHtml leaves formulas as placeholders carrying their source (it builds // a string), so they have to be typeset now or the paper shows blanks await loadKatex() typesetMathIn(view) // let layout and any images settle before the dialog freezes the rendering await new Promise(r => requestAnimationFrame(() => setTimeout(r, 120))) await Promise.all( [...view.querySelectorAll('img')].map(img => (img.complete ? null : new Promise(r => { img.onload = img.onerror = r }))) ) document.body.classList.add('printing') const cleanup = () => { document.body.classList.remove('printing') document.getElementById('print-view')?.remove() window.removeEventListener('afterprint', cleanup) } window.addEventListener('afterprint', cleanup) window.print() // Safari/Firefox fire afterprint reliably; this is the belt for the rest setTimeout(cleanup, 60000) } // --- 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 uiToast(res.error) input.value = '' renderShareList() }) } // Opening the panel and clicking "Create link" both render this list, so two // fetches can be in flight at once. Without a sequence check the slower (older) // answer lands last and paints a link that has just been created away again. let shareRenderSeq = 0 async function renderShareList() { const seq = ++shareRenderSeq const meta = await api(`/api/docs/${docId}`) if (seq !== shareRenderSeq) return // a newer render already answered 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) renderLinkShare(isCreator, meta.public_link || null) } // "Anyone with the link" — a URL that needs no account. Creator-only, like // every other sharing decision, and it says plainly what it hands out: the // whole project, comments included, because that is what the visitor's browser // receives over the socket. function renderLinkShare(isCreator, url) { const box = document.getElementById('link-share') box.classList.toggle('hidden', !isCreator) if (!isCreator) return const hint = document.getElementById('link-hint') const row = document.getElementById('link-row') const toggle = document.getElementById('link-toggle') const field = document.getElementById('link-url') row.classList.toggle('hidden', !url) toggle.textContent = url ? 'Turn off' : 'Create link' hint.textContent = url ? 'On — anyone with this link can read the project and its comments, without signing in. They cannot edit.' : 'Off — only the people above can open this.' // compose the visible link from THIS origin: the server's configured HOST is // right for the agent prompt's curl examples, but the link a person copies // has to work from where they are if (url) field.value = `${location.origin}/p/${url.split('/p/')[1]}` toggle.onclick = async () => { const res = url ? await api(`/api/docs/${docId}/public-link`, { method: 'DELETE' }) : await api(`/api/docs/${docId}/public-link`, { method: 'POST' }) if (res.error) return uiToast(res.error) renderShareList() } document.getElementById('link-copy').onclick = async () => { await navigator.clipboard.writeText(field.value) const btn = document.getElementById('link-copy') btn.textContent = 'Copied' setTimeout(() => (btn.textContent = 'Copy'), 1500) } } async function refreshAgents() { if (state.viewer) return // no account, no agents — and the endpoint would 401 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}`)) const detail = a.managed ? `${a.owner} · built in · ${a.model?.split('/').pop() || 'Kimi K3'} · private` : a.owner + (a.shared ? '' : ' · private') id.appendChild(el('span', { class: 'muted owner' }, detail)) row.appendChild(id) const actions = el('span', { class: 'actions' }) if (!a.managed) { 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 && !a.managed) { 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) uiToast(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) uiToast(res.error) else document.getElementById('agents-pop').appendChild(agentKeyBox({ handle: a.handle, key: res.key, docId })) }) actions.appendChild(rot) } if (!a.managed && (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 () => { const ok = await uiConfirm({ title: `Remove @${a.handle}`, message: `Remove @${a.handle}? Its key stops working and open tasks are cancelled.`, confirmLabel: 'Remove', danger: true, }) if (!ok) return const res = await api(`/api/agents/${a.handle}`, { method: 'DELETE' }) if (res.error) uiToast(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() // connected agents first, then your own — the preselected row is an agent // that can act on this now, and the green dot beside it says why it is top const mine = a => a.owner === state.me?.username const rank = a => (a.online ? 0 : 2) + (mine(a) ? 0 : 1) options = state.agents .filter(a => a.handle.startsWith(ctx.q)) .sort((a, b) => rank(a) - rank(b)) .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 (s.page !== pageSlug || !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) }