import * as Y from 'yjs' import { HocuspocusProvider } from '@hocuspocus/provider' import { Editor, Extension } 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' // 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'), }, } }, }) // IMPORTANT: use tiptap's y-prosemirror fork — the Collaboration extension registers // its sync plugin from @tiptap/y-tiptap, so y-prosemirror's ySyncPluginKey is a // *different* PluginKey instance and getState() would return undefined. import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, relativePositionToAbsolutePosition } from '@tiptap/y-tiptap' import { markdownToBlocks } from '../../server/md.js' import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox } from './common.js' import { wordDiff, arrayDiff } from './diff.js' const docId = location.pathname.split('/').filter(Boolean)[1] // current page — mutable: switching pages swaps the document client-side // without reloading the shell (header, sidebar) let pageSlug = '' let docName = '' let pageQS = '' function setPage(slug) { pageSlug = slug docName = slug === 'home' ? docId : `${docId}::${slug}` pageQS = slug === 'home' ? '' : `?page=${slug}` } setPage(location.pathname.split('/').filter(Boolean)[2] || 'home') const state = { me: null, agents: [], activeItem: null, // id of the expanded card (thread or suggestion) showResolved: false, composerAnchors: null, suggestAnchors: null, chainPos: {}, // revision-chain root id -> displayed generation index previewPid: null, // proposed-page currently previewed } let ydoc = null let threads = null let suggestions = null let provider = null main() async function main() { state.me = await initAuth(`/d/${docId}`) if (!state.me) return // --- one-time shell wiring (survives page switches) --- document.getElementById('back-link').appendChild(icon('back')) wireSelectionMenu() wireMarginToggle() wireComposer() wireSuggestComposer() wireAgentsPanel() wireSharePanel() wireSidebar() document.getElementById('show-resolved').addEventListener('change', e => { state.showResolved = e.target.checked renderMargin(window.__editor) }) window.addEventListener('resize', () => scheduleLayout(window.__editor)) new ResizeObserver(() => scheduleLayout(window.__editor)).observe(document.getElementById('editor')) // collapse the active card when clicking outside cards / composers / menus document.addEventListener('mousedown', e => { if (e.target.closest('.card, .composer-box, #selection-menu, .mention-menu, #agents-pop, #agents-btn, #share-pop, #share-btn, #link-pop')) return if (state.activeItem) { state.activeItem = null renderMargin(window.__editor) } for (const id of ['agents-pop', 'share-pop']) { document.getElementById(id).classList.add('hidden') } }) // back/forward navigates between pages client-side window.addEventListener('popstate', () => { const slug = location.pathname.split('/').filter(Boolean)[2] || 'home' if (slug !== pageSlug) openPage(slug, { push: false }) }) await initPage() loadStructure() setInterval(loadStructure, 15000) window.addEventListener('focus', () => loadStructure()) refreshAgents() setInterval(refreshAgents, 20000) // playground projects get a reset button (creator only) const meta = await api(`/api/docs/${docId}`) if (meta?.playground && meta.created_by === state.me.username) { const reset = el('button', { class: 'btn ghost small', id: 'reset-btn', title: 'Restore the playground to its seeded state' }, 'Reset') reset.addEventListener('click', async () => { if (!confirm('Reset the playground to its original seeded state? All changes here are lost.')) return reset.disabled = true const res = await api(`/api/docs/${docId}/reset`, { method: 'POST' }) if (res.error) { alert(res.error) reset.disabled = false return } location.href = `/d/${docId}` }) document.getElementById('share-btn').before(reset) } } // --- per-page lifecycle ------------------------------------------------------- async function initPage() { // visiting a page that the structure yaml references but that doesn't exist // yet (a "ghost") creates it, titled after its slug if (pageSlug !== 'home' && pageSlug !== '_structure') { const s = await api(`/api/docs/${docId}/structure`) if (s?.titles && !(pageSlug in s.titles)) { const pretty = pageSlug.replace(/-/g, ' ') await api(`/api/docs/${docId}/pages`, { method: 'POST', body: { title: pretty.charAt(0).toUpperCase() + pretty.slice(1), slug: pageSlug }, }) } } exitProposalPreview() ydoc = new Y.Doc() threads = ydoc.getMap('threads') suggestions = ydoc.getMap('suggestions') const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/collab` provider = new HocuspocusProvider({ url: wsUrl, name: docName, document: ydoc, // the build id lets the server refuse stale tabs (diverged replicas) token: 'cookie:' + (state.me.buildId || ''), onStatus: ({ status }) => { document.getElementById('conn').className = status === 'connected' ? 'on' : '' if (status === 'connected') sessionStorage.removeItem('cw-reloaded') }, onAuthenticationFailed: handleAuthFailed, }) const editor = buildEditor(provider) window.__editor = editor window.__ydoc = ydoc window.__Y = Y buildHeaderTools(editor) bindSelectionMenu(editor) // re-render cards AND poke the editor so decorations (highlights, inline // diffs) refresh — remote changes don't produce an editor transaction threads.observeDeep(() => { renderMargin(editor) poke(editor) }) suggestions.observeDeep(() => { renderMargin(editor) poke(editor) }) editor.on('update', () => { updateTitle(editor) scheduleLayout(editor) }) provider.on('awarenessUpdate', () => renderPresence(provider)) wireUndoReopens(editor) wireImageBubble(editor) wireTableControls(editor) wirePageLinkMenu(editor) if (pageSlug === '_structure') { // editing the yaml updates the tree almost live let t = null editor.on('update', () => { clearTimeout(t) t = setTimeout(loadStructure, 500) }) } document.getElementById('layout').classList.toggle('structure-mode', pageSlug === '_structure') document.getElementById('structure-hint')?.remove() if (pageSlug === '_structure') { const hint = el( 'div', { id: 'structure-hint' }, 'This YAML is the page tree: one "- slug" line per page, indent two spaces to nest. Pages missing here show under "unfiled" — nothing is ever deleted by editing this. Changes can also be proposed as suggestions.' ) document.getElementById('editor').before(hint) } renderMargin(editor) setTimeout(() => updateTitle(editor), 600) } function teardownPage() { try { provider?.destroy() } catch {} try { window.__editor?.destroy() } catch {} try { ydoc?.destroy() } catch {} provider = null state.activeItem = null state.chainPos = {} state.composerAnchors = null state.suggestAnchors = null document.getElementById('composer').classList.add('hidden') document.getElementById('suggest-composer').classList.add('hidden') document.getElementById('selection-menu').classList.add('hidden') document.querySelectorAll('#margin-items .card, #margin-items .margin-empty').forEach(n => n.remove()) } // switch pages client-side: swap the document, keep header + sidebar async function openPage(slug, { push = true } = {}) { if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) { // leaving a proposal preview back to the same page } else if (slug === pageSlug) { return } exitProposalPreview() teardownPage() setPage(slug) if (push) history.pushState({}, '', slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`) await initPage() loadStructure() } function handleAuthFailed({ reason }) { if (!String(reason || '').includes('outdated')) return try { provider?.destroy() } catch {} if (!sessionStorage.getItem('cw-reloaded')) { sessionStorage.setItem('cw-reloaded', '1') location.reload() return } const bar = el('div', { id: 'update-banner' }, 'A new version of cowrite is available — reload the page to keep editing. ') const a = el('a', { href: location.pathname }, 'Reload now') bar.appendChild(a) document.body.appendChild(bar) } // --- [[ page cross-references --------------------------------------------------- // Type "[[" to link another page: an autocomplete of page titles appears; // selecting one inserts the title as a link. Ctrl/Cmd+click follows links. let pageLinkController = null function pageLinkKey(key) { return pageLinkController ? pageLinkController(key) : false } function wirePageLinkMenu(editor) { document.getElementById('pagelink-menu')?.remove() const menu = el('div', { class: 'mention-menu hidden', id: 'pagelink-menu' }) document.body.appendChild(menu) let items = [] let sel = 0 let range = null const hide = () => { menu.classList.add('hidden') range = null items = [] } const apply = item => { if (!range) return const href = item.slug === 'home' ? `/d/${docId}` : `/d/${docId}/${item.slug}` editor .chain() .focus() .deleteRange(range) .insertContent([ { type: 'text', text: item.title, marks: [{ type: 'link', attrs: { href } }] }, { type: 'text', text: ' ' }, ]) .run() hide() } const render = () => { menu.replaceChildren( ...items.map((item, i) => { const row = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') }) row.appendChild(el('span', {}, item.title)) row.appendChild(el('span', { class: 'muted', style: 'margin-left:8px;font-size:11px' }, item.slug)) row.addEventListener('mousedown', e => { e.preventDefault() apply(item) }) return row }) ) } const update = () => { const { state: pmState } = editor const { from, empty } = pmState.selection if (!empty) return hide() const before = pmState.doc.textBetween(Math.max(0, from - 42), from, '\n', '\n') const m = before.match(/\[\[([^\[\]]{0,32})$/) if (!m) return hide() const q = m[1].toLowerCase() items = Object.entries(state.pageTitles || {}) .filter(([slug]) => slug !== '_structure' && slug !== pageSlug) .map(([slug, title]) => ({ slug, title })) .filter(p => p.title.toLowerCase().includes(q) || p.slug.includes(q)) .slice(0, 8) if (!items.length) return hide() range = { from: from - m[0].length, to: from } sel = Math.min(sel, items.length - 1) render() menu.classList.remove('hidden') try { const c = editor.view.coordsAtPos(from) menu.style.left = `${Math.max(8, c.left)}px` menu.style.top = `${c.bottom + 4}px` } catch {} } editor.on('transaction', () => setTimeout(update, 0)) pageLinkController = key => { if (!range || menu.classList.contains('hidden')) return false if (key === 'Enter') { apply(items[sel]) return true } if (key === 'Down') { sel = (sel + 1) % items.length render() return true } if (key === 'Up') { sel = (sel - 1 + items.length) % items.length render() return true } if (key === 'Esc') { hide() return true } return false } } // --- table controls ----------------------------------------------------------- // A floating bar that appears above the table your cursor is in, with add/remove // row & column and delete-table actions (TipTap has no default table UI). function wireTableControls(editor) { document.getElementById('table-controls')?.remove() const bar = el('div', { id: 'table-controls', class: 'hidden' }) const add = (label, tip, cmd) => { const b = el('button', { class: 'iconbtn', 'data-tip': tip }, label) b.addEventListener('mousedown', e => { e.preventDefault() cmd() }) bar.appendChild(b) return b } add('+ Row', 'Add a row below', () => editor.chain().focus().addRowAfter().run()) add('− Row', 'Delete this row', () => editor.chain().focus().deleteRow().run()) bar.appendChild(el('span', { class: 'sep' })) add('+ Col', 'Add a column to the right', () => editor.chain().focus().addColumnAfter().run()) add('− Col', 'Delete this column', () => editor.chain().focus().deleteColumn().run()) bar.appendChild(el('span', { class: 'sep' })) const del = add('⌫', 'Delete the whole table', () => { if (confirm('Delete this table?')) editor.chain().focus().deleteTable().run() }) del.classList.add('danger') document.body.appendChild(bar) const update = () => { if (editor.isDestroyed || !editor.isActive('table')) { bar.classList.add('hidden') return } let dom = editor.view.domAtPos(editor.state.selection.from)?.node if (dom?.nodeType === 3) dom = dom.parentElement const table = dom?.closest?.('table') if (!table) { bar.classList.add('hidden') return } const r = table.getBoundingClientRect() bar.classList.remove('hidden') bar.style.left = `${Math.max(8, r.left)}px` bar.style.top = `${Math.max(8, r.top - bar.offsetHeight - 6)}px` } editor.on('selectionUpdate', update) editor.on('transaction', update) window.addEventListener('scroll', update, true) } // --- image layout bubble ------------------------------------------------------- function wireImageBubble(editor) { document.getElementById('image-bubble')?.remove() const bubble = el('div', { id: 'image-bubble', class: 'hidden' }) const btn = (label, title, onClick) => { const b = el('button', { class: 'iconbtn', title }, label) b.addEventListener('mousedown', e => { e.preventDefault() onClick() }) bubble.appendChild(b) return b } const sizeBtns = [ ['S', 33], ['M', 50], ['L', 75], ['◻', 100], ].map(([label, width]) => ({ width, node: btn(label, `Width ${width}%`, () => editor.chain().focus().updateAttributes('image', { width: width === 100 ? null : width }).run()) })) bubble.appendChild(el('span', { class: 'sep' })) const alignBtns = [ ['⇤', 'left'], ['↔', 'center'], ['⇥', 'right'], ].map(([label, align]) => ({ align, node: btn(label, `Align ${align}`, () => editor.chain().focus().updateAttributes('image', { align }).run()) })) document.body.appendChild(bubble) const update = () => { if (editor.isDestroyed) return const sel = editor.state.selection const node = sel.node?.type?.name === 'image' ? sel.node : null if (!node) { bubble.classList.add('hidden') return } const dom = editor.view.nodeDOM(sel.from) if (!dom?.getBoundingClientRect) { bubble.classList.add('hidden') return } const rect = dom.getBoundingClientRect() bubble.classList.remove('hidden') bubble.style.left = `${Math.max(8, rect.left + rect.width / 2 - bubble.offsetWidth / 2)}px` bubble.style.top = `${Math.max(8, rect.top - bubble.offsetHeight - 8)}px` const width = node.attrs.width || 100 for (const s of sizeBtns) s.node.classList.toggle('on', width === s.width) for (const a of alignBtns) a.node.classList.toggle('on', (node.attrs.align || 'left') === a.align) } editor.on('selectionUpdate', update) editor.on('transaction', update) window.addEventListener('scroll', update, true) } // --- comments margin toggle ---------------------------------------------------- function setMarginHidden(hidden, persist = true) { document.getElementById('margin-col').classList.toggle('hidden', hidden) document.getElementById('margin-toggle').textContent = hidden ? '◂' : '▸' if (persist) localStorage.setItem('margin-hidden', hidden ? '1' : '0') scheduleLayout(window.__editor) } function wireMarginToggle() { const stored = localStorage.getItem('margin-hidden') // when the window is narrow the comments panel is hidden by default const hidden = stored == null ? window.matchMedia('(max-width: 1150px)').matches : stored === '1' setMarginHidden(hidden, false) document.getElementById('margin-toggle').addEventListener('click', () => { setMarginHidden(!document.getElementById('margin-col').classList.contains('hidden')) }) } // On the structure page: a reference of page titles -> slugs (the yaml wants // slugs, but everywhere else shows titles). Click a slug to copy it. function renderSlugLegend(s) { const existing = document.getElementById('slug-legend') if (pageSlug !== '_structure') { existing?.remove() return } const box = existing || el('div', { id: 'slug-legend' }) const table = el('table', {}) const thead = el('thead', {}) const hr = el('tr', {}) hr.append(el('th', {}, 'Page'), el('th', {}, 'Slug (click to copy)')) thead.appendChild(hr) table.appendChild(thead) const tbody = el('tbody', {}) for (const [slug, title] of Object.entries(s.titles || {})) { if (slug === '_structure') continue const tr = el('tr', {}) const tdTitle = el('td', {}) const a = el('a', { href: slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}` }, title || slug) a.addEventListener('click', e => { if (e.metaKey || e.ctrlKey || e.button !== 0) return e.preventDefault() openPage(slug) }) tdTitle.appendChild(a) const tdSlug = el('td', {}) const code = el('code', { title: 'Click to copy' }, slug) code.addEventListener('click', () => { navigator.clipboard?.writeText(slug) code.classList.add('copied') setTimeout(() => code.classList.remove('copied'), 800) }) tdSlug.appendChild(code) tr.append(tdTitle, tdSlug) tbody.appendChild(tr) } table.appendChild(tbody) box.replaceChildren(el('div', { class: 'legend-title' }, 'Pages in this project'), table) if (!existing) document.getElementById('editor').after(box) } // --- pages sidebar ----------------------------------------------------------- function wireSidebar() { const sidebar = document.getElementById('sidebar') const toggle = document.getElementById('sidebar-toggle') const stored = localStorage.getItem('sidebar-hidden') // on narrow screens the sidebar is a drawer — closed by default const hidden = stored == null ? window.matchMedia('(max-width: 980px)').matches : stored === '1' sidebar.classList.toggle('hidden', hidden) toggle.textContent = hidden ? '▸' : '◂' toggle.addEventListener('click', () => { const nowHidden = !sidebar.classList.contains('hidden') sidebar.classList.toggle('hidden', nowHidden) toggle.textContent = nowHidden ? '▸' : '◂' localStorage.setItem('sidebar-hidden', nowHidden ? '1' : '0') scheduleLayout(window.__editor) }) document.getElementById('page-add').addEventListener('click', async () => { const title = prompt('Page title') if (!title?.trim()) return const res = await api(`/api/docs/${docId}/pages`, { method: 'POST', body: { title: title.trim() } }) if (res.error) return alert(res.error) openPage(res.slug) }) const structureLink = document.getElementById('structure-link') structureLink.href = `/d/${docId}/_structure` structureLink.replaceChildren(icon('tree'), document.createTextNode(' Structure')) structureLink.addEventListener('click', e => { if (e.metaKey || e.ctrlKey || e.button !== 0) return e.preventDefault() openPage('_structure') }) if (pageSlug === '_structure') structureLink.classList.add('current') } async function loadStructure() { const s = await api(`/api/docs/${docId}/structure`) if (s?.error) return state.pageTitles = s.titles || {} document.getElementById('structure-link')?.classList.toggle('current', pageSlug === '_structure') renderSlugLegend(s) const container = document.getElementById('page-tree') const known = new Set(Object.keys(s.titles || {})) known.add(pageSlug) // visiting auto-creates, so the current page is never a ghost const pageHref = slug => (slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`) const deletablePage = slug => slug !== 'home' && slug !== '_structure' && known.has(slug) const pageDeleteBtn = slug => { const b = el('button', { class: 'iconbtn page-del', 'data-tip': `Delete page "${s.titles?.[slug] || slug}" for everyone` }) b.appendChild(icon('trash')) b.addEventListener('click', async e => { e.preventDefault() e.stopPropagation() if (!confirm(`Delete the page "${s.titles?.[slug] || slug}" for everyone? This cannot be undone.`)) return const res = await api(`/api/docs/${docId}/pages/${slug}`, { method: 'DELETE' }) if (res.error) return alert(res.error) if (slug === pageSlug) openPage('home') else loadStructure() }) return b } const renderNodes = nodes => { const ul = el('ul', {}) for (const node of nodes) { const li = el('li', {}) const a = el( 'a', { href: pageHref(node.slug), class: (node.slug === pageSlug ? 'current' : '') + (known.has(node.slug) ? '' : ' ghost-page'), title: known.has(node.slug) ? '' : `no page "${node.slug}" yet — create it or fix the structure yaml`, }, s.titles?.[node.slug] || node.slug ) a.addEventListener('click', e => { if (e.metaKey || e.ctrlKey || e.button !== 0) return e.preventDefault() openPage(node.slug) }) if (deletablePage(node.slug)) a.appendChild(pageDeleteBtn(node.slug)) li.appendChild(a) if (node.children?.length) li.appendChild(renderNodes(node.children)) ul.appendChild(li) } return ul } container.innerHTML = '' container.appendChild(renderNodes(s.tree || [])) if (s.unfiled?.length) { container.appendChild(el('div', { class: 'unfiled-label' }, 'unfiled')) const ul = el('ul', {}) for (const slug of s.unfiled) { if (slug === 'home') continue const li = el('li', {}) const a = el('a', { href: pageHref(slug), class: slug === pageSlug ? 'current' : '' }, s.titles?.[slug] || slug) a.addEventListener('click', e => { if (e.metaKey || e.ctrlKey || e.button !== 0) return e.preventDefault() openPage(slug) }) if (deletablePage(slug)) a.appendChild(pageDeleteBtn(slug)) li.appendChild(a) ul.appendChild(li) } if (ul.children.length) container.appendChild(ul) } // proposed new pages (open new-page suggestions) — visible before acceptance if (s.pending?.length) { container.appendChild(el('div', { class: 'unfiled-label' }, 'proposed')) const ul = el('ul', {}) for (const p of s.pending) { const li = el('li', {}) const a = el( 'a', { href: '#', class: 'pending-page' + (state.previewPid === p.id ? ' current' : ''), title: `Proposed by ${p.author_type === 'agent' ? '@' + p.author : p.author}${p.rationale ? ' — ' + p.rationale : ''}`, }, p.title || p.slug ) a.appendChild(el('span', { class: 'pending-badge' }, p.author_type === 'agent' ? '@' + p.author : 'proposed')) a.addEventListener('click', e => { e.preventDefault() openProposalPreview(p.id) }) li.appendChild(a) ul.appendChild(li) } container.appendChild(ul) } } // --- new-page proposal preview ------------------------------------------------ function exitProposalPreview() { const view = document.getElementById('proposal-view') if (!view) return view.classList.add('hidden') view.replaceChildren() document.getElementById('editor').style.display = '' state.previewPid = null } async function openProposalPreview(pid) { exitProposalPreview() teardownPage() state.previewPid = pid document.getElementById('editor').style.display = 'none' const view = document.getElementById('proposal-view') view.classList.remove('hidden') loadStructure() const p = await api(`/api/docs/${docId}/page-suggestions/${pid}`) if (p.error) { view.textContent = p.error return } document.getElementById('doc-title').textContent = p.title + ' — proposed' document.title = p.title + ' — proposed' const bar = el('div', { class: 'proposal-bar' }) bar.appendChild( el('div', { class: 'proposal-meta' }, `Proposed new page by ${p.author_type === 'agent' ? '@' + p.author : p.author}${p.rationale ? ' — ' + p.rationale : ''}`) ) const actions = el('div', { class: 'proposal-actions' }) const reject = el('button', { class: 'btn small ghost' }, 'Reject') reject.addEventListener('click', async () => { if (!confirm(`Reject the proposed page "${p.title}"?`)) return const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/reject`, { method: 'POST' }) if (r.error) return alert(r.error) exitProposalPreview() openPage('home') }) const accept = el('button', { class: 'btn small primary' }, 'Accept & create page') accept.addEventListener('click', async () => { const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/accept`, { method: 'POST' }) if (r.error) return alert(r.error) exitProposalPreview() openPage(r.slug) }) actions.append(reject, accept) bar.appendChild(actions) const content = el('div', { class: 'proposal-content tiptap' }) content.innerHTML = markdownToBlocks(p.content_markdown).map(blockToHtml).join('') view.replaceChildren(bar, content) } function updateTitle(editor) { if (pageSlug === '_structure') { document.getElementById('doc-title').textContent = 'Structure' document.title = 'Structure' return } let title = 'Untitled' editor.state.doc.descendants(node => { if (title === 'Untitled' && node.isTextblock && node.textContent.trim()) { title = node.textContent.trim().slice(0, 80) return false } return title === 'Untitled' }) document.getElementById('doc-title').textContent = title document.title = title const cur = document.querySelector('#page-tree a.current') if (cur && title !== 'Untitled' && cur.textContent !== title) cur.textContent = title } // --- editor ----------------------------------------------------------------- const highlightKey = new PluginKey('collab-highlights') function buildEditor(provider) { const Highlights = Extension.create({ name: 'collabHighlights', priority: 10000, addKeyboardShortcuts() { return { 'Mod-z': () => runUndo(this.editor), 'Mod-Z': () => runRedo(this.editor), 'Mod-Shift-z': () => runRedo(this.editor), 'Mod-y': () => runRedo(this.editor), Enter: () => pageLinkKey('Enter'), ArrowDown: () => pageLinkKey('Down'), ArrowUp: () => pageLinkKey('Up'), Escape: () => pageLinkKey('Esc'), Tab: () => pageLinkKey('Enter'), } }, addProseMirrorPlugins() { return [ new Plugin({ key: highlightKey, props: { decorations: buildDecorations, handleClickOn: (view, pos, node, nodePos, event) => { const target = event.target?.closest?.('.comment-hl, [data-sugg]') if (target?.dataset.thread) { setActive(target.dataset.thread) return false } if (target?.dataset.sugg) { setActive(target.dataset.sugg) return false } }, }, }), ] }, }) return new Editor({ element: document.getElementById('editor'), extensions: [ StarterKit.configure({ undoRedo: false, link: { openOnClick: false } }), LayoutImage, CheckboxInList, TaskItem.configure({ nested: false }), Table.configure({ resizable: false }), TableRow, TableHeader, TableCell, Collaboration.configure({ document: ydoc }), CollaborationCaret.configure({ provider, user: { name: state.me.username, color: colorFor(state.me.username) }, }), Highlights, ], editorProps: { handleClick: (view, pos, event) => { const a = event.target?.closest?.('a') if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey)) return false const url = new URL(a.getAttribute('href'), location.origin) const internal = url.origin === location.origin && url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?$`)) if (internal) openPage(internal[1] || 'home') else if (url.origin === location.origin) location.href = url.pathname + url.search else window.open(url.href, '_blank', 'noopener') return true }, handlePaste: (view, event) => { const file = [...(event.clipboardData?.files || [])].find(f => f.type.startsWith('image/')) if (!file) return false uploadAndInsertImage(file) return true }, handleDrop: (view, event) => { const file = [...(event.dataTransfer?.files || [])].find(f => f.type.startsWith('image/')) if (!file) return false event.preventDefault() uploadAndInsertImage(file) return true }, }, }) } async function uploadAndInsertImage(file) { if (file.size > 10 * 1024 * 1024) return alert('Image too large (max 10 MB)') const res = await fetch(`/api/docs/${docId}/upload`, { method: 'POST', headers: { 'content-type': file.type }, body: file, }).then(r => r.json()) if (res.error) return alert(res.error) window.__editor.chain().focus().setImage({ src: res.url }).run() } function buildDecorations(pmState) { const ystate = ySyncPluginKey.getState(pmState) if (!ystate?.binding) return DecorationSet.empty const decos = [] threads.forEach((ythread, id) => { if (ythread.get('resolved') || ythread.get('suggestionId')) return const range = anchorsToRange(pmState, ystate, ythread.get('anchorStart'), ythread.get('anchorEnd')) if (!range || range.from >= range.to) return const cls = 'comment-hl' + (state.activeItem === id ? ' active' : '') decos.push(Decoration.inline(range.from, range.to, { class: cls, 'data-thread': id })) }) // one generation per revision chain — the one the user is looking at for (const chain of suggestionChains()) { const s = chain.display if (s.status !== 'open' && s.status !== 'superseded') continue const range = suggestionRange(pmState, s) if (!range) continue const hlCls = 'suggestion-hl' + (state.activeItem === s.id ? ' active' : '') let firstInRange = true pmState.doc.forEach((node, offset) => { if (offset >= range.from && offset < Math.max(range.to, range.from + 1)) { decos.push(Decoration.node(offset, offset + node.nodeSize, { class: hlCls + (firstInRange ? '' : ' hl-join'), 'data-sugg': s.id })) firstInRange = false } }) try { suggestionDiffDecos(pmState, s, range, decos, chain.members[chain.members.length - 1]) } catch {} } return DecorationSet.create(pmState.doc, decos) } // Inline track-changes rendering, block-aware. Unchanged blocks stay clean; // edited blocks get an in-place word diff; whole new blocks render as properly // formatted content in an insertion panel. The shared doc is never touched. function suggestionDiffDecos(pmState, s, range, decos, chainLatest = null) { const A = state.activeItem === s.id ? ' active' : '' // old top-level blocks fully inside the anchored range, with positions + text const oldBlocks = [] pmState.doc.forEach((node, offset) => { if (offset >= range.from && offset + node.nodeSize <= Math.max(range.to, range.from + 1)) { oldBlocks.push({ node, pos: offset, ...blockCharMap(node, offset) }) } }) const newBlocks = markdownToBlocks(s.replacementMarkdown).map(block => ({ block, text: blockDescText(block) })) const ops = arrayDiff(oldBlocks, newBlocks, (a, b) => a.text === b.text) let widgetIdx = 0 let k = 0 let cursorEnd = range.from // boundary after the last old block handled while (k < ops.length) { const op = ops[k] if (op.type === 'eq') { cursorEnd = oldBlocks[op.aIdx].pos + oldBlocks[op.aIdx].node.nodeSize k++ continue } const dels = [] const inss = [] while (k < ops.length && ops[k].type !== 'eq') { if (ops[k].type === 'del') dels.push(oldBlocks[ops[k].aIdx]) else inss.push(newBlocks[ops[k].bIdx]) k++ } // pair up rewritten blocks for in-place word diffs — but only while the // texts genuinely overlap; word-diffing an empty/unrelated block against a // brand-new section produces an unreadable flat text blob. For revision // chains, judge similarity against the LATEST generation so flipping // through generations keeps one consistent rendering mode. let biasText = null if (chainLatest && chainLatest.id !== s.id) { try { const latestBlocks = markdownToBlocks(chainLatest.replacementMarkdown) biasText = latestBlocks.length ? blockDescText(latestBlocks[0]) : null } catch {} } let pairs = 0 while ( pairs < Math.min(dels.length, inss.length) && dels[pairs].node.type.name !== 'table' && inss[pairs].block.type !== 'table' && similarBlocks(dels[pairs].text, biasText ?? inss[pairs].text) ) pairs++ for (let p = 0; p < pairs; p++) { if (dels[p].node.type.name === 'codeBlock' && inss[p].block.type === 'codeBlock') { widgetIdx = codeBlockDiff(dels[p], inss[p], s.id, A, decos, widgetIdx) } else { widgetIdx = inlineBlockDiff(dels[p], inss[p], s.id, A, decos, widgetIdx) } } // leftover old blocks: struck through wholesale; empty ones collapse // visually (a strikethrough on nothing just reads as a blank line) for (let p = pairs; p < dels.length; p++) { const emptyCls = dels[p].text.trim() ? '' : ' sugg-del-empty' decos.push(Decoration.node(dels[p].pos, dels[p].pos + dels[p].node.nodeSize, { class: 'sugg-del-block' + emptyCls + A, 'data-sugg': s.id })) } if (dels.length) cursorEnd = dels[dels.length - 1].pos + dels[dels.length - 1].node.nodeSize // leftover new blocks: one formatted insertion panel. Anchor it to the // FOLLOWING content (next old block in the run sequence, or the block the // suggestion recorded as coming after it) so lines typed at the boundary // land above the panel and push it down, like real anchored content. if (inss.length > pairs) { const blocks = inss.slice(pairs).map(x => x.block) const id = s.id let widgetPos = cursorEnd if (k < ops.length && ops[k].type === 'eq') { widgetPos = oldBlocks[ops[k].aIdx].pos } else { const after = afterAnchorPos(pmState, s) if (after != null && after >= cursorEnd) widgetPos = after } // the panel visually continues the suggestion's bar when it directly // follows one of its blocks (trailing or between range blocks) — but not // when the forward anchor moved it past foreign content const adjacent = widgetPos === cursorEnd || (k < ops.length && ops[k].type === 'eq' && widgetPos === oldBlocks[ops[k].aIdx].pos) const join = adjacent && (dels.length > 0 || cursorEnd > range.from) decos.push(Decoration.widget(widgetPos, () => blockInsWidget(id, blocks, A + (join ? ' hl-join' : '')), { key: `${id}:blk${widgetIdx++}${A}` })) } } } function similarBlocks(a, b) { if (!a?.trim() || !b?.trim()) return false const ta = new Set(a.toLowerCase().split(/\s+/)) const tb = b.toLowerCase().split(/\s+/) const common = tb.filter(w => ta.has(w)).length return common / Math.max(ta.size, tb.length) >= 0.4 } // PM position of the block the suggestion recorded as following it (or doc end) function afterAnchorPos(pmState, s) { if (!s.anchorAfter) return null try { const frag = ydoc.getXmlFragment('default') const abs = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorAfter), ydoc) if (!abs || abs.type !== frag) return null if (abs.index >= pmState.doc.childCount) return pmState.doc.content.size let pos = null pmState.doc.forEach((node, offset, i) => { if (i === abs.index) pos = offset }) return pos } catch { return null } } // word-diff one rewritten block in place function inlineBlockDiff(oldB, newB, suggId, A, decos, widgetIdx) { const ops = wordDiff(oldB.text, newB.text) const charToPm = off => (off >= oldB.map.length ? oldB.pos + oldB.node.nodeSize - 1 : oldB.map[off]) const listType = ['bulletList', 'orderedList'].includes(oldB.node.type.name) ? oldB.node.type.name : null for (const op of ops) { if (op.type === 'del' && op.text.trim()) { const from = charToPm(op.oldStart) const to = charToPm(op.oldEnd - 1) + 1 if (to > from) decos.push(Decoration.inline(from, to, { class: 'sugg-del' + A, 'data-sugg': suggId })) } else if (op.type === 'ins' && op.text.trim()) { const pos = charToPm(op.oldStart) const text = op.text decos.push(Decoration.widget(pos, () => insWidget(suggId, text, A, listType), { key: `${suggId}:${widgetIdx}${A}`, marks: [] })) widgetIdx++ } } return widgetIdx } function insWidget(suggId, text, A, listType = null) { const span = document.createElement('span') span.className = 'sugg-ins' + A span.dataset.sugg = suggId span.addEventListener('click', () => setActive(suggId)) // inside a list block, segments after a newline are NEW ITEMS — give them a // marker so they read as list entries, not a bare continuation line. The // outer span becomes display:contents so its inline underline can't paint // stray fragments around the block-level children. if (listType && text.includes('\n')) { span.className = 'sugg-ins sugg-ins-multi' + A const segs = text.split('\n') if (segs[0].trim()) { const seg = document.createElement('span') seg.className = 'sugg-ins-seg' seg.textContent = segs[0] span.appendChild(seg) } for (const segText of segs.slice(1)) { if (!segText.trim()) continue const li = document.createElement('span') li.className = 'sugg-ins-li' + (listType === 'orderedList' ? ' ordered' : '') li.textContent = segText span.appendChild(li) } } else { span.textContent = text } return span } // line-level diff inside a code block: struck old lines + inserted green lines, // like a real code diff (word-diffing code reads terribly) function codeBlockDiff(oldB, newB, suggId, A, decos, widgetIdx) { const oldLines = oldB.text.split('\n') const newLines = String(newB.text ?? '').split('\n') const lineStart = [] let off = 0 for (const l of oldLines) { lineStart.push(off) off += l.length + 1 } const charToPm = c => (c >= oldB.map.length ? oldB.pos + oldB.node.nodeSize - 1 : oldB.map[c]) const ops = arrayDiff(oldLines, newLines, (a, b) => a === b) let k = 0 while (k < ops.length) { if (ops[k].type === 'eq') { k++ continue } const delIdx = [] const insTxt = [] while (k < ops.length && ops[k].type !== 'eq') { if (ops[k].type === 'del') delIdx.push(ops[k].aIdx) else insTxt.push(newLines[ops[k].bIdx]) k++ } for (const li of delIdx) { if (!oldLines[li].length) continue const from = charToPm(lineStart[li]) const to = charToPm(lineStart[li] + oldLines[li].length - 1) + 1 if (to > from) decos.push(Decoration.inline(from, to, { class: 'sugg-del' + A, 'data-sugg': suggId })) } if (insTxt.length) { let posChar if (delIdx.length) { const last = delIdx[delIdx.length - 1] posChar = lineStart[last] + oldLines[last].length } else if (k < ops.length && ops[k].type === 'eq') { posChar = lineStart[ops[k].aIdx] } else { posChar = oldB.map.length } const lines = insTxt decos.push( Decoration.widget(charToPm(Math.min(posChar, oldB.map.length)), () => { const span = document.createElement('span') span.className = 'sugg-ins sugg-ins-multi' + A span.dataset.sugg = suggId for (const line of lines) { const div = document.createElement('span') div.className = 'sugg-ins-codeline' div.textContent = line || ' ' span.appendChild(div) } span.addEventListener('click', () => setActive(suggId)) return span }, { key: `${suggId}:code${widgetIdx}${A}`, marks: [] }) ) widgetIdx++ } } return widgetIdx } function blockInsWidget(suggId, blocks, A) { const div = document.createElement('div') div.className = 'sugg-ins-block' + A div.dataset.sugg = suggId div.innerHTML = blocks.map(blockToHtml).join('') div.addEventListener('click', () => setActive(suggId)) return div } // plain text of a block plus char -> PM-position map (for word-diff anchoring) function blockCharMap(node, pos) { let text = '' const map = [] if (node.type.name === 'image') { for (const ch of '[figure]') { text += ch map.push(pos) } return { text, map } } node.descendants((child, childPos) => { if (child.isText) { if (text && !text.endsWith('\n') && map.length && pos + 1 + childPos > map[map.length - 1] + 1) { // gap between text nodes across block boundaries (e.g. list items) } const base = pos + 1 + childPos for (let c = 0; c < child.text.length; c++) { text += child.text[c] map.push(base + c) } } else if (child.type.name === 'image') { for (const ch of '[figure]') { text += ch map.push(pos + 1 + childPos) } } else if (child.isBlock && text && !text.endsWith('\n')) { text += '\n' map.push(pos + 1 + childPos) } return true }) return { text, map } } // plain text of a markdownToBlocks descriptor — must mirror blockCharMap's shape function blockDescText(b) { const seg = ss => (ss || []).map(x => x.text).join('') switch (b.type) { case 'bulletList': case 'orderedList': return (b.items || []).map(seg).join('\n') case 'taskList': return (b.items || []).map(it => `[${it.checked ? 'x' : ' '}] ` + seg(it.inline)).join('\n') case 'table': return (b.rows || []).map(r => r.map(seg).join(' | ')).join('\n') case 'codeBlock': return b.text || '' case 'image': return '[figure]' case 'horizontalRule': return '' default: return seg(b.inline) } } // render a markdown block descriptor as HTML for the insertion panel function inlineHtml(segments) { return (segments || []) .map(s => { let h = esc(s.text) const a = s.attrs || {} if (a.code) h = `${h}` if (a.bold) h = `${h}` if (a.italic) h = `${h}` if (a.strike) h = `${h}` if (a.link) h = `${h}` return h }) .join('') } function blockToHtml(b) { switch (b.type) { case 'heading': return `${inlineHtml(b.inline)}` case 'bulletList': return `` case 'orderedList': return `
    ${(b.items || []).map(i => `
  1. ${inlineHtml(i)}
  2. `).join('')}
` case 'taskList': return `` case 'table': return `${(b.rows || []) .map((r, ri) => `${r.map(c => (ri === 0 ? `` : ``)).join('')}`) .join('')}
${inlineHtml(c)}${inlineHtml(c)}
` case 'codeBlock': return `
${esc(b.text || '')}
` case 'blockquote': return `

${inlineHtml(b.inline)}

` 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 = ydoc.getXmlFragment('default') const absS = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorStart), ydoc) const absE = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorEndIncl), ydoc) if (!absS || !absE || absS.type !== frag || absE.type !== frag) return null const si = Math.min(absS.index, absE.index) const ei = Math.max(absS.index, absE.index) let from = null let to = null pmState.doc.forEach((node, offset, i) => { if (i === si) from = offset if (i === ei) to = offset + node.nodeSize }) if (from == null || to == null || to <= from) return null return { from, to } } catch { return null } } const ystate = ySyncPluginKey.getState(pmState) if (!ystate?.binding) return null return anchorsToRange(pmState, ystate, s.anchorStart, s.anchorEnd) } function decodeRel(b64) { const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/')) const arr = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) return Y.decodeRelativePosition(arr) } function encodeRel(rel) { const bytes = Y.encodeRelativePosition(rel) let bin = '' for (const b of bytes) bin += String.fromCharCode(b) return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } function poke(editor) { editor.view.dispatch(editor.view.state.tr.setMeta('collab-refresh', true)) } function setActive(id) { state.activeItem = id // revealing a specific item un-hides the comments panel if (id && document.getElementById('margin-col').classList.contains('hidden')) setMarginHidden(false) renderMargin(window.__editor) poke(window.__editor) } // composers get absolutely positioned a frame after opening — focusing them // before that scrolls the page to the container top. Focus with preventScroll, // then nudge the placed box into view. function revealAfterLayout(node) { requestAnimationFrame(() => requestAnimationFrame(() => { try { node.scrollIntoView({ block: 'nearest' }) } catch {} }) ) } function jumpTo(editor, range) { if (!range) return const pos = Math.min(range.from, editor.state.doc.content.size) editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)).scrollIntoView()) } // --- header toolbar ----------------------------------------------------------- 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(--serif)' 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()), sep(), btn(icon('link'), 'Link', () => toggleLink(editor), () => editor.isActive('link')) ) // undo / redo (from the Collaboration extension's shared-editing undo manager) const undoB = btn(icon('undo'), 'Undo', () => runUndo(editor)) undoB.id = 'undo-btn' const redoB = btn(icon('redo'), 'Redo', () => runRedo(editor)) redoB.id = 'redo-btn' bar.append(sep(), undoB, redoB) // zoom let zoom = Number(localStorage.getItem('doc-zoom')) || 1 const zoomLabel = el('span', { id: 'zoom-label' }) const applyZoom = () => { zoom = Math.round(Math.max(0.8, Math.min(1.6, zoom)) * 100) / 100 document.documentElement.style.setProperty('--doc-zoom', zoom) zoomLabel.textContent = `${Math.round(zoom * 100)}%` localStorage.setItem('doc-zoom', String(zoom)) scheduleLayout(editor) } const zoomOutB = btn(icon('zoomOut'), 'Zoom out', () => { zoom -= 0.1 applyZoom() }) zoomOutB.id = 'zoom-out' const zoomInB = btn(icon('zoomIn'), 'Zoom in', () => { zoom += 0.1 applyZoom() }) zoomInB.id = 'zoom-in' bar.append(sep(), zoomOutB, zoomLabel, zoomInB) applyZoom() // image upload const imgInput = el('input', { type: 'file', accept: 'image/*', style: 'display:none' }) imgInput.addEventListener('change', () => { if (imgInput.files[0]) uploadAndInsertImage(imgInput.files[0]) imgInput.value = '' }) const imgBtn = btn(icon('image'), 'Insert image', () => imgInput.click()) imgBtn.id = 'image-btn' bar.append(imgBtn, imgInput) wireLinkPop(editor) } function toggleLink(editor) { if (editor.isActive('link')) { editor.chain().focus().extendMarkRange('link').unsetLink().run() return } if (editor.state.selection.empty) return const pop = document.getElementById('link-pop') const tools = document.getElementById('header-tools').getBoundingClientRect() pop.style.left = `${Math.max(12, tools.right - 320)}px` pop.style.top = `${tools.bottom + 8}px` pop.classList.remove('hidden') document.getElementById('link-url').focus() } function wireLinkPop(editor) { const pop = document.getElementById('link-pop') const input = document.getElementById('link-url') const apply = () => { let href = input.value.trim() if (href && !/^https?:\/\//.test(href)) href = 'https://' + href if (href) editor.chain().focus().extendMarkRange('link').setLink({ href }).run() input.value = '' pop.classList.add('hidden') } document.getElementById('link-apply').addEventListener('click', apply) input.addEventListener('keydown', e => { if (e.key === 'Enter') apply() if (e.key === 'Escape') pop.classList.add('hidden') }) document.addEventListener('mousedown', e => { if (!e.target.closest('#link-pop') && !pop.classList.contains('hidden') && !e.target.closest('#header-tools')) { pop.classList.add('hidden') } }) } // --- 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')) commentBtn.addEventListener('mousedown', e => { e.preventDefault() try { openComposer(window.__editor) } catch (err) { alert('Could not open the comment box: ' + err.message) } }) suggestBtn.addEventListener('mousedown', e => { e.preventDefault() openSuggestComposer(window.__editor).catch(err => alert('Could not open suggest mode: ' + err.message)) }) } function bindSelectionMenu(editor) { const menu = document.getElementById('selection-menu') editor.on('selectionUpdate', () => { const { from, to, empty } = editor.state.selection if (empty || to - from < 1) { menu.classList.add('hidden') return } const coords = editor.view.coordsAtPos(to) const wrap = document.getElementById('editor-col').getBoundingClientRect() menu.style.left = `${Math.max(0, Math.min(coords.left - wrap.left, wrap.width - 230))}px` menu.style.top = `${coords.bottom - wrap.top + 8}px` menu.classList.remove('hidden') }) } // --- comment composer ------------------------------------------------------------- function openComposer(editor) { const { from, to } = editor.state.selection const ystate = ySyncPluginKey.getState(editor.state) state.composerAnchors = { start: encodeRel(absolutePositionToRelativePosition(from, ystate.type, ystate.binding.mapping)), end: encodeRel(absolutePositionToRelativePosition(to, ystate.type, ystate.binding.mapping)), excerpt: editor.state.doc.textBetween(from, to, ' ').slice(0, 200), } document.getElementById('composer-quote').textContent = state.composerAnchors.excerpt document.getElementById('suggest-composer').classList.add('hidden') document.getElementById('composer').classList.remove('hidden') document.getElementById('selection-menu').classList.add('hidden') renderMargin(editor) document.getElementById('composer-text').focus({ preventScroll: true }) revealAfterLayout(document.getElementById('composer')) } function wireComposer() { const text = document.getElementById('composer-text') document.getElementById('composer-cancel').addEventListener('click', () => { document.getElementById('composer').classList.add('hidden') text.value = '' renderMargin(window.__editor) }) document.getElementById('composer-send').addEventListener('click', () => { const value = text.value.trim() if (!value || !state.composerAnchors) return const id = createThread(state.composerAnchors, value) text.value = '' document.getElementById('composer').classList.add('hidden') setActive(id) }) wireMentionMenu(text, document.getElementById('composer-mentions')) } function createThread(anchors, text, suggestionId = null) { const id = randomId() const messages = new Y.Array() messages.push([makeMessage(text)]) const t = new Y.Map() t.set('id', id) t.set('anchorStart', anchors.start) t.set('anchorEnd', anchors.end) t.set('excerpt', anchors.excerpt || null) t.set('createdBy', state.me.username) t.set('createdAt', Date.now()) t.set('resolved', false) if (suggestionId) t.set('suggestionId', suggestionId) t.set('messages', messages) threads.set(id, t) return id } function makeMessage(text) { return { id: randomId(), author: state.me.username, authorType: 'user', text, ts: Date.now() } } function randomId() { return Math.random().toString(36).slice(2, 12) } // --- suggest composer (humans propose block rewrites, like agents do) -------------- async function openSuggestComposer(editor) { const { from, to } = editor.state.selection const blockFrom = editor.state.doc.resolve(from).index(0) const blockTo = editor.state.doc.resolve(to).index(0) const fragment = ydoc.getXmlFragment('default') state.suggestAnchors = { start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)), end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)), } document.getElementById('composer').classList.add('hidden') document.getElementById('selection-menu').classList.add('hidden') const composer = document.getElementById('suggest-composer') composer.classList.remove('hidden') 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}`) textEl.value = (snap.blocks || []) .slice(blockFrom, blockTo + 1) .map(b => b.markdown) .join('\n\n') textEl.focus({ preventScroll: true }) revealAfterLayout(document.getElementById('suggest-composer')) scheduleLayout(editor) } function wireSuggestComposer() { document.getElementById('suggest-cancel').addEventListener('click', () => { document.getElementById('suggest-composer').classList.add('hidden') renderMargin(window.__editor) }) document.getElementById('suggest-send').addEventListener('click', async () => { if (!state.suggestAnchors) return const res = await api(`/api/docs/${docId}/suggestions`, { method: 'POST', body: { page: pageSlug, anchor_start: state.suggestAnchors.start, anchor_end: state.suggestAnchors.end, replacement_markdown: document.getElementById('suggest-text').value, rationale: document.getElementById('suggest-rationale').value.trim() || undefined, }, }) if (res.error) return alert(res.error) document.getElementById('suggest-composer').classList.add('hidden') document.getElementById('suggest-text').value = '' document.getElementById('suggest-rationale').value = '' setActive(res.suggestion_id) }) } // --- margin: unified comments + suggestions, positioned next to their text --------- let layoutRaf = null function scheduleLayout(editor) { if (layoutRaf) return layoutRaf = requestAnimationFrame(() => { layoutRaf = null layoutMargin(editor) }) } // --- revision chains --- // Suggestions linked by `revises` form a chain; only ONE generation renders at // a time (default: the newest actionable one), with ‹ n/N › navigation. function suggestionChains() { const all = suggestionValues() const byId = new Map(all.map(s => [s.id, s])) const rootOf = start => { let cur = start const seen = new Set([cur.id]) while (cur.revises && byId.has(cur.revises) && !seen.has(cur.revises)) { cur = byId.get(cur.revises) seen.add(cur.id) } return cur.id } const groups = new Map() for (const s of all) { const root = rootOf(s) if (!groups.has(root)) groups.set(root, []) groups.get(root).push(s) } const actionable = x => x.status === 'open' || x.status === 'superseded' return [...groups.entries()].map(([root, members]) => { members.sort((a, b) => (a.ts || 0) - (b.ts || 0)) let def = members.length - 1 for (let i = members.length - 1; i >= 0; i--) { if (actionable(members[i])) { def = i break } } let idx = state.chainPos[root] if (!(Number.isInteger(idx) && idx >= 0 && idx < members.length)) idx = def return { root, members, idx, display: members[idx] } }) } function chainNav(chain) { const nav = el('span', { class: 'chain-nav' }) const go = idx => { state.chainPos[chain.root] = idx renderMargin(window.__editor) poke(window.__editor) } const prev = el('button', { class: 'iconbtn', title: 'Earlier revision' }, '‹') prev.disabled = chain.idx === 0 prev.addEventListener('click', e => { e.stopPropagation() go(chain.idx - 1) }) const next = el('button', { class: 'iconbtn', title: 'Later revision' }, '›') next.disabled = chain.idx === chain.members.length - 1 next.addEventListener('click', e => { e.stopPropagation() go(chain.idx + 1) }) nav.append(prev, el('span', { class: 'chain-pos' }, `${chain.idx + 1}/${chain.members.length}`), next) return nav } // accept/reject buttons shared by suggestion cards and thread-attached strips function suggestionActionButtons(s, editor) { const reject = el('button', { class: 'iconbtn reject-btn', title: 'Reject suggestion' }) reject.appendChild(icon('x')) reject.addEventListener('click', e => { e.stopPropagation() 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() // apply in this editor so it's undoable; server then only flips the status const appliedLocally = tryClientApply(editor, s) const res = await api(`/api/docs/${docId}/suggestions/${s.id}/accept`, { method: 'POST', body: { mark_only: appliedLocally, page: pageSlug }, }) if (res.error) alert(res.error) }) return [reject, accept] } function renderMargin(editor) { const wrap = document.getElementById('margin-items') const items = [] const attachedByThread = new Map() // comment thread id -> [{chain, linkedThread}] for (const chain of suggestionChains()) { const s = chain.display const visible = s.status === 'open' || s.status === 'superseded' || state.showResolved if (!visible) continue let linkedThread = null threads.forEach((yt, tid) => { if (yt.get('suggestionId') === s.id) linkedThread = { id: tid, ythread: yt } }) // a suggestion posted into a comment thread renders INSIDE that thread's // card instead of as a separate margin widget const hostId = s.threadId && threads.get(s.threadId) && !threads.get(s.threadId).get('suggestionId') ? s.threadId : null const hostVisible = hostId && (!threads.get(hostId).get('resolved') || state.showResolved) if (hostId && hostVisible) { if (!attachedByThread.has(hostId)) attachedByThread.set(hostId, []) attachedByThread.get(hostId).push({ chain, linkedThread }) continue } items.push({ kind: 'sugg', id: s.id, data: s, linkedThread, chain }) } threads.forEach((ythread, id) => { if (ythread.get('suggestionId')) return if (ythread.get('resolved') && !state.showResolved) return items.push({ kind: 'thread', id, ythread, attached: attachedByThread.get(id) || [] }) }) // composers live inside the container too — never clobber them wrap.querySelectorAll('.card, .margin-empty').forEach(n => n.remove()) if (!items.length) { const composerOpen = wrap.querySelector('.composer-box:not(.hidden)') if (!composerOpen) wrap.appendChild(el('div', { class: 'margin-empty muted' }, 'Select text to comment or suggest. Mention @an-agent to hand it work.')) } else { wrap.append(...items.map(item => (item.kind === 'sugg' ? suggestionCard(item, editor) : threadCard(item, editor)))) } scheduleLayout(editor) } 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)')] if (!els.length) { container.style.minHeight = '0px' return } const containerRect = container.getBoundingClientRect() const ystate = ySyncPluginKey.getState(editor.state) const entries = els.map(node => { let range = null if (node.classList.contains('composer-box')) { const anchors = node.id === 'composer' ? state.composerAnchors : state.suggestAnchors if (anchors && ystate?.binding) range = anchorsToRange(editor.state, ystate, anchors.start, anchors.end) } else { range = rangeFromCard(editor, node) } let desired = Infinity if (range) { try { desired = editor.view.coordsAtPos(Math.min(range.from, editor.state.doc.content.size)).top - containerRect.top } catch {} } if (Number.isFinite(desired) && node.classList.contains('composer-box')) desired -= 1 // wins ties return { node, desired: Number.isFinite(desired) ? Math.max(0, desired) : Infinity } }) entries.sort((a, b) => a.desired - b.desired) let cursor = 0 for (const entry of entries) { const top = entry.desired === Infinity ? cursor : Math.max(entry.desired, cursor) entry.node.style.top = `${top}px` cursor = top + entry.node.offsetHeight + 10 } container.style.minHeight = `${cursor}px` } function rangeFromCard(editor, card) { const id = card.dataset.item const suggestion = [...suggestionValues()].find(s => s.id === id) if (suggestion) return itemRange(editor, { kind: 'sugg', data: suggestion }) const ythread = threads.get(id) if (ythread) return itemRange(editor, { kind: 'thread', ythread }) return null } function suggestionValues() { const out = [] suggestions.forEach(s => out.push(s)) return out } function cardShell(id, kind, expanded) { const card = el('div', { class: `card ${kind}${expanded ? ' expanded' : ''}`, 'data-item': id }) card.addEventListener('mousedown', e => { if (e.target.closest('button, input, textarea, a, .mention-menu')) return if (state.activeItem !== id) setActive(id) }) return card } function cardHead(iconName, who, isAgent, preview, when) { const head = el('div', { class: 'head' }) head.appendChild(icon(iconName)) head.appendChild(el('span', { class: `who${isAgent ? ' agent' : ''}` }, who)) head.appendChild(el('span', { class: 'preview' }, preview)) head.appendChild(el('span', { class: 'when' }, timeAgo(when))) return head } function messageEl(msg) { const m = el('div', { class: 'msg' }) const author = el('div', { class: 'author' }) author.appendChild(el('span', {}, msg.authorType === 'agent' ? `@${msg.author}` : msg.author)) if (msg.authorType === 'agent') author.appendChild(el('span', { class: 'agent-tag' }, 'agent')) author.appendChild(el('span', { class: 'when' }, timeAgo(msg.ts))) m.appendChild(author) const textEl = el('div', { class: 'text' }) textEl.innerHTML = renderMentionText(msg.text) m.appendChild(textEl) for (const chip of msg.mentions || []) { m.appendChild(el('span', { class: `chip ${chip.status}` }, `@${chip.handle} · ${statusLabel(chip.status)}`)) } return m } function replyRow(placeholder, onSend) { const row = el('div', { class: 'reply-row' }) const input = el('input', { placeholder }) const menu = el('div', { class: 'mention-menu hidden' }) const send = el('button', { class: 'iconbtn', title: 'Send' }) send.appendChild(icon('send')) send.addEventListener('click', () => { if (!input.value.trim()) return onSend(input.value.trim()) input.value = '' }) input.addEventListener('keydown', e => { if (e.key === 'Enter' && menu.classList.contains('hidden')) send.click() }) row.append(input, send) wireMentionMenu(input, menu) return { row, menu } } // --- thread card --- function threadCard(item, editor) { const { id, ythread } = item const expanded = state.activeItem === id const card = cardShell(id, 'thread', expanded) const msgs = ythread.get('messages')?.toArray() || [] const first = msgs[0] || {} const resolved = ythread.get('resolved') const head = cardHead('comment', first.authorType === 'agent' ? `@${first.author}` : first.author || '?', first.authorType === 'agent', (resolved ? 'resolved · ' : '') + (first.text || ''), first.ts) if (!resolved) { // resolvable without opening the card const resolve = el('button', { class: 'iconbtn resolve-btn', title: 'Resolve comment' }) resolve.appendChild(icon('check')) resolve.addEventListener('click', e => { e.stopPropagation() ythread.set('resolved', true) if (state.activeItem === id) state.activeItem = null }) head.appendChild(resolve) } card.appendChild(head) // suggestions posted into this thread: one compact strip each, visible collapsed for (const att of item.attached || []) { card.appendChild(suggestionStrip(att.chain, editor)) } if (!expanded) return card const body = el('div', { class: 'body' }) const range = itemRange(editor, item) const orphaned = !range || range.from >= range.to if (ythread.get('excerpt')) { const quote = el('blockquote', { class: orphaned ? 'orphaned' : '' }, ythread.get('excerpt')) quote.title = orphaned ? 'The commented text was deleted' : 'Jump to text' quote.addEventListener('click', () => jumpTo(editor, range)) body.appendChild(quote) } for (const msg of msgs) body.appendChild(messageEl(msg)) if (!resolved) { const { row, menu } = replyRow('Reply — @agent to hand off', text => { ythread.get('messages').push([makeMessage(text)]) }) body.appendChild(row) body.appendChild(menu) const actions = el('div', { class: 'actions' }) const resolve = el('button', { class: 'btn small ghost' }) resolve.append(icon('resolve'), document.createTextNode('Resolve')) resolve.addEventListener('click', () => { ythread.set('resolved', true) state.activeItem = null }) actions.appendChild(resolve) body.appendChild(actions) } else { body.appendChild(el('div', { class: 'status-note' }, 'resolved')) } card.appendChild(body) return card } // --- suggestion card --- // suggestion attached to a comment thread: one compact row, actionable collapsed function suggestionStrip(chain, editor) { const s = chain.display const actionable = s.status === 'open' || s.status === 'superseded' const strip = el('div', { class: 'sugg-strip' + (state.activeItem === s.id ? ' active' : ''), 'data-sugg-strip': s.id }) strip.appendChild(icon('edit')) strip.appendChild(el('span', { class: `who${s.authorType === 'agent' ? ' agent' : ''}` }, s.authorType === 'agent' ? `@${s.author}` : s.author)) strip.appendChild(el('span', { class: 'preview' }, actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`)) if (chain.members.length > 1) strip.appendChild(chainNav(chain)) if (actionable) strip.append(...suggestionActionButtons(s, editor)) strip.addEventListener('mousedown', e => { if (e.target.closest('button')) return setActive(s.id) }) return strip } function suggestionCard(item, editor) { const s = item.data const expanded = state.activeItem === s.id const card = cardShell(s.id, 'suggestion', expanded) // the diff lives in the document itself — the card only carries identity, // rationale, actions (always visible, even collapsed) and the discussion const actionable = s.status === 'open' || s.status === 'superseded' const head = cardHead( 'edit', s.authorType === 'agent' ? `@${s.author}` : s.author, s.authorType === 'agent', actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`, s.ts ) if (item.chain && item.chain.members.length > 1) head.appendChild(chainNav(item.chain)) if (actionable) head.append(...suggestionActionButtons(s, editor)) card.appendChild(head) if (!expanded) return card const body = el('div', { class: 'body' }) if (actionable && s.rationale) body.appendChild(el('div', { class: 'rationale' }, s.rationale)) if (!actionable) body.appendChild(el('div', { class: 'status-note' }, `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`)) // discussion on the suggestion (e.g. to guide the agent to revise it) const msgs = item.linkedThread?.ythread.get('messages')?.toArray() || [] for (const msg of msgs) body.appendChild(messageEl(msg)) const { row, menu } = replyRow('Discuss — @agent to request changes', text => { if (item.linkedThread) { item.linkedThread.ythread.get('messages').push([makeMessage(text)]) } else { createThread({ start: s.anchorStart, end: s.anchorEnd, excerpt: null }, text, s.id) } }) body.appendChild(row) body.appendChild(menu) card.appendChild(body) return card } // --- accepting suggestions locally (so undo works) -------------------------------- // Run undo/redo against the Yjs UndoManager directly. Tiptap's wrapped // undo command intermittently no-ops on large accept transactions (stale // chained state), while the manager itself is always reliable. function runUndo(editor) { yUndoPluginKey.getState(editor.state)?.undoManager?.undo() return true } function runRedo(editor) { yUndoPluginKey.getState(editor.state)?.undoManager?.redo() return true } // When the accepting user's own editor applies the replacement, the change lands // in their collaborative undo history. The server then only records the status. let pendingAcceptSuggestion = null function wireUndoReopens(editor) { const undoManager = yUndoPluginKey.getState(editor.state)?.undoManager if (!undoManager) return undoManager.on('stack-item-added', event => { if (pendingAcceptSuggestion) { event.stackItem.meta.set('acceptedSuggestion', pendingAcceptSuggestion) pendingAcceptSuggestion = null } }) undoManager.on('stack-item-popped', event => { if (event.type !== 'undo') return const sid = event.stackItem.meta.get('acceptedSuggestion') if (sid) api(`/api/docs/${docId}/suggestions/${sid}/reopen`, { method: 'POST', body: { page: pageSlug } }) }) } function inlineToPmContent(segments) { return (segments || []) .filter(seg => seg.text) .map(seg => ({ type: 'text', text: seg.text, marks: Object.entries(seg.attrs || {}).map(([type, attrs]) => ({ type, attrs })), })) } function blockToPmJSON(block) { switch (block.type) { 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 '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' } } } } function tryClientApply(editor, s) { try { const range = suggestionRange(editor.state, s) if (!range) return false const nodes = markdownToBlocks(s.replacementMarkdown).map(b => editor.schema.nodeFromJSON(blockToPmJSON(b))) pendingAcceptSuggestion = s.id editor.view.dispatch(editor.state.tr.replaceWith(range.from, range.to, nodes)) // stack-item-added fires synchronously during dispatch; if the change was // merged into an existing stack item instead, don't tag a later one setTimeout(() => { pendingAcceptSuggestion = null }, 0) return true } catch (err) { pendingAcceptSuggestion = null console.warn('client-side apply failed, falling back to server apply', err) return false } } // --- agents --------------------------------------------------------------------- function wireAgentsPanel() { const btn = document.getElementById('agents-btn') const pop = document.getElementById('agents-pop') btn.addEventListener('click', () => pop.classList.toggle('hidden')) document.getElementById('agent-form').addEventListener('submit', async e => { e.preventDefault() const input = document.getElementById('agent-handle') const handle = input.value.trim().toLowerCase() if (!handle) return const res = await api('/api/agents', { method: 'POST', body: { handle } }) if (res.error) alert(res.error) else { input.value = '' refreshAgents() if (res.key) pop.appendChild(agentKeyBox({ handle: res.handle, key: res.key, docId })) } }) } // --- sharing ------------------------------------------------------------------ function wireSharePanel() { const btn = document.getElementById('share-btn') const pop = document.getElementById('share-pop') btn.addEventListener('click', () => { pop.classList.toggle('hidden') if (!pop.classList.contains('hidden')) renderShareList() }) document.getElementById('share-form').addEventListener('submit', async e => { e.preventDefault() const input = document.getElementById('share-user') const username = input.value.trim() if (!username) return const res = await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username } }) if (res.error) return alert(res.error) input.value = '' renderShareList() }) } async function renderShareList() { const meta = await api(`/api/docs/${docId}`) const wrap = document.getElementById('share-list') if (meta.error) { wrap.textContent = meta.error return } const isCreator = !meta.created_by || meta.created_by === state.me.username document.getElementById('share-form').classList.toggle('hidden', !isCreator) wrap.className = '' const rows = [] if (meta.created_by) { const owner = el('div', { class: 'share-row' }) owner.append(el('span', { class: 'handle' }, meta.created_by), el('span', { class: 'muted' }, 'owner')) rows.push(owner) } for (const username of meta.shared_with || []) { const row = el('div', { class: 'share-row' }) row.appendChild(el('span', { class: 'handle' }, username)) if (isCreator) { const rm = el('button', { class: 'iconbtn', title: 'Remove access' }) rm.appendChild(icon('x')) rm.addEventListener('click', async () => { await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username, remove: true } }) renderShareList() }) row.appendChild(rm) } rows.push(row) } if (!rows.length) rows.push(el('div', { class: 'muted' }, 'Not shared with anyone yet.')) wrap.replaceChildren(...rows) } async function refreshAgents() { const res = await api(`/api/agents?doc=${docId}`) if (!res.agents) return state.agents = res.agents const wrap = document.getElementById('agents-list') if (!res.agents.length) { wrap.className = 'muted' wrap.textContent = 'No agents registered yet.' return } wrap.className = '' wrap.replaceChildren( ...res.agents.map(a => { const row = el('div', { class: 'agent-row' }) row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''} tip-below`, 'data-tip': a.online ? 'Online — the agent is polling for work' : 'Offline — the agent is not polling right now' })) const id = el('span', { class: 'agent-id' }) id.appendChild(el('span', { class: 'handle' }, `@${a.handle}`)) id.appendChild(el('span', { class: 'muted owner' }, a.owner + (a.shared === false ? ' · private' : ''))) row.appendChild(id) const actions = el('span', { class: 'actions' }) const copy = el('button', { class: 'iconbtn', 'data-tip': 'Copy the agent prompt (paste it into your coding agent)' }) copy.appendChild(icon('copy')) copy.addEventListener('click', async () => { const text = await fetch(`/api/agent-prompt?doc=${docId}&handle=${a.handle}`).then(r => r.text()) await navigator.clipboard.writeText(text) copy.replaceChildren(icon('check')) setTimeout(() => copy.replaceChildren(icon('copy')), 1500) }) actions.appendChild(copy) if (a.owner === state.me.username) { const vis = el('button', { class: 'iconbtn' + (a.shared ? ' on' : ''), 'data-tip': a.shared ? 'Shared: collaborators can @mention this agent — click to make it private' : 'Private: only you can @mention it — click to share', }) vis.appendChild(icon(a.shared ? 'eye' : 'eyeoff')) vis.addEventListener('click', async () => { const res = await api(`/api/agents/${a.handle}/visibility`, { method: 'POST', body: { shared: !a.shared } }) if (res.error) alert(res.error) else refreshAgents() }) actions.appendChild(vis) const rot = el('button', { class: 'iconbtn', 'data-tip': 'Issue a new key (the old one stops working)' }) rot.appendChild(icon('key')) rot.addEventListener('click', async () => { const res = await api(`/api/agents/${a.handle}/rotate`, { method: 'POST' }) if (res.error) alert(res.error) else document.getElementById('agents-pop').appendChild(agentKeyBox({ handle: a.handle, key: res.key, docId })) }) actions.appendChild(rot) } if (a.owner === state.me.username || state.me.isAdmin) { const del = el('button', { class: 'iconbtn', 'data-tip': 'Remove this agent (its key stops working, open tasks are cancelled)' }) del.appendChild(icon('trash')) del.addEventListener('click', async () => { if (!confirm(`Remove @${a.handle}? Its key stops working and open tasks are cancelled.`)) return const res = await api(`/api/agents/${a.handle}`, { method: 'DELETE' }) if (res.error) alert(res.error) refreshAgents() }) actions.appendChild(del) } row.appendChild(actions) return row }) ) } // --- mention autocomplete ---------------------------------------------------------- function wireMentionMenu(input, menu) { const getQuery = () => { const pos = input.selectionStart ?? input.value.length const before = input.value.slice(0, pos) const m = before.match(/@([a-z0-9_.-]*)$/i) return m ? { q: m[1].toLowerCase(), start: pos - m[1].length } : null } const render = () => { const ctx = getQuery() if (!ctx) return menu.classList.add('hidden') const options = state.agents.filter(a => a.handle.startsWith(ctx.q)).slice(0, 6) if (!options.length) return menu.classList.add('hidden') menu.replaceChildren( ...options.map(a => { const opt = el('div') opt.append(el('span', { class: `dot${a.online ? ' on' : ''}` }), document.createTextNode(`@${a.handle}`)) opt.addEventListener('mousedown', e => { e.preventDefault() input.value = input.value.slice(0, ctx.start) + a.handle + ' ' + input.value.slice(input.selectionStart) menu.classList.add('hidden') input.focus({ preventScroll: true }) }) return opt }) ) menu.classList.remove('hidden') } input.addEventListener('input', render) input.addEventListener('blur', () => setTimeout(() => menu.classList.add('hidden'), 150)) } // --- presence ----------------------------------------------------------------------- function renderPresence(provider) { const wrap = document.getElementById('presence') const seen = new Set() const avatars = [] provider.awareness?.getStates().forEach(s => { const u = s.user if (!u?.name || seen.has(u.name)) return seen.add(u.name) const a = el('div', { class: 'avatar', title: u.name }, u.name.slice(0, 2).toUpperCase()) a.style.background = u.color || '#999' avatars.push(a) }) wrap.replaceChildren(...avatars) }