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