Spaces:
Running
Running
Download client/src/doc.js from lvwerra/cowrite-dev: direct link, hf CLI and curl.
- Browser
- Download file 229 kB
-
https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/client/src/doc.js
- Command line
-
hf download hf://spaces/lvwerra/cowrite-dev/client/src/doc.js
-
curl -L -o doc.js https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/client/src/doc.js
229 kB
| import * as Y from 'yjs' | |
| import { HocuspocusProvider } from '@hocuspocus/provider' | |
| import { Editor, Extension, Node } from '@tiptap/core' | |
| import StarterKit from '@tiptap/starter-kit' | |
| import Collaboration from '@tiptap/extension-collaboration' | |
| import CollaborationCaret from '@tiptap/extension-collaboration-caret' | |
| import Image from '@tiptap/extension-image' | |
| import { TaskList, TaskItem } from '@tiptap/extension-list' | |
| import { InputRule } from '@tiptap/core' | |
| import { MathInline, MathBlock, mathPreviewEl, renderMath, loadKatex } from './math.js' | |
| import { attachTreeDrag } from './treedrag.js' | |
| // Typing "[ ] " / "[x] " at the start of a paragraph inside a bullet list item | |
| // (i.e. the natural "- [ ] ") lifts that item out and turns the list into a | |
| // task list. (Plain "[ ] " on an empty paragraph is already handled by TaskList.) | |
| const CheckboxInList = TaskList.extend({ | |
| name: 'taskList', | |
| addInputRules() { | |
| return [ | |
| ...(this.parent?.() || []), | |
| new InputRule({ | |
| find: /^\[([ xX]?)\]\s$/, | |
| handler: ({ state, range, match, chain }) => { | |
| const parent = state.selection.$from.node(-1)?.type.name | |
| const checked = match[1].toLowerCase() === 'x' | |
| if (parent === 'listItem') { | |
| // "- [ ] " inside a bullet item → convert the list to a task list | |
| chain().deleteRange(range).toggleList('taskList', 'taskItem').updateAttributes('taskItem', { checked }).run() | |
| } else if (parent === 'taskItem') { | |
| // "- [x] " already in a task list → just set this item's checked state | |
| chain().deleteRange(range).updateAttributes('taskItem', { checked }).run() | |
| } else { | |
| return null // bare paragraph: TaskList's own rule handles it | |
| } | |
| }, | |
| }), | |
| ] | |
| }, | |
| }) | |
| import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table' | |
| // Image with minimal layout: width (%) and alignment, kept as node attrs so | |
| // they sync collaboratively. (v1 limitation: markdown export drops them.) | |
| const LayoutImage = Image.extend({ | |
| addAttributes() { | |
| return { | |
| ...this.parent?.(), | |
| width: { | |
| default: null, | |
| renderHTML: attrs => (attrs.width ? { style: `width:${attrs.width}%` } : {}), | |
| parseHTML: element => { | |
| const m = (element.getAttribute('style') || '').match(/width:\s*(\d+)%/) | |
| return m ? Number(m[1]) : null | |
| }, | |
| }, | |
| align: { | |
| default: null, | |
| renderHTML: attrs => (attrs.align ? { 'data-align': attrs.align } : {}), | |
| parseHTML: element => element.getAttribute('data-align'), | |
| }, | |
| } | |
| }, | |
| }) | |
| // --- Arbitrary HTML embeds --- | |
| // A model (or a person) can drop a live HTML snippet into the doc — for aligning | |
| // figures, animated explainers, small interactive widgets. The snippet is | |
| // UNTRUSTED and, in a shared doc, runs in every collaborator's browser, so it is | |
| // NEVER inserted into the page DOM. It renders inside an <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, yXmlFragmentToProsemirrorJSON } 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, wireDocStyle, uiConfirm, uiAlert, uiPrompt, uiToast } from './common.js' | |
| import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js' | |
| // A visitor arrives on /p/<token>, where the first path segment is the token | |
| // and not a document id — the server resolved it and told us which project the | |
| // link opens. Everything downstream keeps working on real ids. | |
| const shareView = window.__BOOT?.share || null | |
| const docId = shareView ? shareView.doc_id : 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 = '' | |
| // the header's project name (two sources) and the page part of the tab title — | |
| // see paintTitles | |
| let projectTitleLive = '' | |
| let projectTitleServer = '' | |
| let pageTitleShown = '' | |
| function setPage(slug) { | |
| pageSlug = slug | |
| docName = slug === 'home' ? docId : `${docId}::${slug}` | |
| pageQS = slug === 'home' ? '' : `?page=${slug}` | |
| } | |
| setPage(location.pathname.split('/').filter(Boolean)[2] || 'home') | |
| // The shell server-renders #doc-title, but with the *page* title on a subpage. | |
| // Fix it before the first frame: on home the rendered value already is the | |
| // project, elsewhere the name we saw last time, and a placeholder only for a | |
| // project this browser has never opened (the structure poll fills it in). | |
| { | |
| const head = document.getElementById('doc-title') | |
| if (pageSlug === 'home') { | |
| rememberServerProjectTitle(head?.textContent) | |
| } else { | |
| if (document.title && document.title !== 'Document') pageTitleShown = document.title | |
| try { | |
| rememberServerProjectTitle(localStorage.getItem(`cw-proj-title:${docId}`)) | |
| } catch {} | |
| if (head) head.textContent = projectTitleServer || '…' | |
| } | |
| } | |
| 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 | |
| viewer: false, // opened through a public link: read-only, no account | |
| } | |
| let ydoc = null | |
| let threads = null | |
| let suggestions = null | |
| let provider = null | |
| let pageUnsubscribers = [] | |
| const pageField = slug => (slug === 'home' ? 'default' : `page:${slug}`) | |
| const pageMap = (base, slug = pageSlug) => ydoc.getMap(slug === 'home' ? base : `${base}:${slug}`) | |
| // The fragment the editor is bound to. Anchors are relative positions INTO this | |
| // fragment, so anything that encodes or resolves one must use the page's own — | |
| // reaching for 'default' silently means "home", and on any other page an anchor | |
| // then resolves into the wrong fragment (or gets written pointing at one). | |
| const pageFragment = (slug = pageSlug) => ydoc.getXmlFragment(pageField(slug)) | |
| main() | |
| async function main() { | |
| state.me = await initAuth(docId ? `/d/${docId}` : '/') | |
| if (!state.me) return | |
| // Read-only is enforced on the socket (the server drops writes from a link | |
| // connection); this is what keeps the UI from offering actions that would | |
| // silently do nothing. | |
| // Signed in as someone with no access to THIS project is still a visitor — | |
| // the link is what is carrying them, and the socket will treat them as one. | |
| state.viewer = state.me.viewer === true || window.__BOOT?.share?.read_only === true | |
| if (state.viewer) { | |
| document.body.classList.add('viewer') | |
| // a signed-out visitor already sees "Read-only link" where the account goes; | |
| // a signed-in one needs telling why their own account cannot type here | |
| if (state.me.username) { | |
| document.getElementById('whoami')?.prepend(el('span', { class: 'muted' }, 'Read-only link · ')) | |
| } | |
| } | |
| // Reached / with nothing to open — the server only sends the shell here when | |
| // you have no documents. Wire what can make one and stop; there is no doc to | |
| // connect a provider to. | |
| if (!docId) { | |
| wireTheme(document.getElementById('theme-btn')) | |
| wireDocsMenu() | |
| // the shell ships the skeleton up, and nothing here will ever sync to take | |
| // it down — leaving it would read as a document stuck loading forever | |
| hideDocSkeleton() | |
| document.getElementById('doc-title').textContent = 'No documents' | |
| document.getElementById('editor').replaceChildren( | |
| el('div', { class: 'empty-doc' }, | |
| 'Nothing here yet. Create your first document from the menu at the top left.') | |
| ) | |
| document.getElementById('docs-btn').click() // toggles it open, and renders | |
| return | |
| } | |
| // --- one-time shell wiring (survives page switches) --- | |
| wireDocsMenu() | |
| wireTheme(document.getElementById('theme-btn')) | |
| // each style sets its own --doc-size, so the px readout on the text-size | |
| // control means something different after a switch even though nothing about | |
| // the reader's own choice changed — and the serif A's are wider than the sans | |
| // ones, so the toolbar may stop fitting on the way past | |
| wireDocStyle(document.getElementById('docstyle-btn'), () => { | |
| paintDocSize() | |
| fitHeaderTools() | |
| }) | |
| wireSelectionMenu() | |
| wireHoverComment() | |
| wireMobileChrome() | |
| wireMarginToggle() | |
| wireComposer() | |
| wireSuggestComposer() | |
| wireAgentsPanel() | |
| wireSharePanel() | |
| wireDownloadPanel() | |
| wireSidebar() | |
| fitLayout() | |
| fitHeaderTools() | |
| document.getElementById('show-resolved').addEventListener('change', e => { | |
| state.showResolved = e.target.checked | |
| renderMargin(window.__editor) | |
| }) | |
| window.addEventListener('resize', () => { | |
| fitLayout() | |
| fitHeaderTools() | |
| 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, #download-pop, #download-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, and carries the search | |
| // that page was found through (?q=) back with it | |
| window.addEventListener('popstate', () => { | |
| const slug = location.pathname.split('/').filter(Boolean)[2] || 'home' | |
| const searchChanged = adoptSearchFromUrl() | |
| if (slug !== pageSlug) return openPage(slug, { push: false }) | |
| if (!searchChanged) return | |
| if (sidebarSnapshot) renderStructure(sidebarSnapshot) | |
| refreshDocHighlights() | |
| settleDeepLinkQuery() | |
| }) | |
| // in flight while the document syncs, not after it. Every refresh of it also | |
| // carries the project title (as its `home` entry), so the header follows. | |
| loadStructure().finally(() => paintTitles()) | |
| initProjectCollaboration() | |
| await initPage() | |
| setInterval(() => loadStructure().finally(() => paintTitles()), 15000) | |
| window.addEventListener('focus', () => loadStructure().finally(() => paintTitles())) | |
| 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 () => { | |
| const ok = await uiConfirm({ | |
| title: 'Reset the playground', | |
| message: 'Reset the playground to its original seeded state? All changes here are lost.', | |
| confirmLabel: 'Reset', | |
| danger: true, | |
| }) | |
| if (!ok) return | |
| reset.disabled = true | |
| const res = await api(`/api/docs/${docId}/reset`, { method: 'POST' }) | |
| if (res.error) { | |
| uiAlert(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') | |
| } | |
| function initProjectCollaboration() { | |
| ydoc = new Y.Doc() | |
| const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/collab` | |
| provider = new HocuspocusProvider({ | |
| url: wsUrl, | |
| // All pages are named fragments of this one project document. The socket | |
| // syncs them together once; switching pages below is then local-only. | |
| name: docId, | |
| document: ydoc, | |
| 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) | |
| }, | |
| }) | |
| window.__provider = provider | |
| window.__ydoc = ydoc | |
| window.__Y = Y | |
| } | |
| 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() | |
| threads = pageMap('threads') | |
| suggestions = pageMap('suggestions') | |
| // Awareness now belongs to the whole project, so identify the page as well | |
| // as the user. Presence and the server's "client already applied this" | |
| // check must not confuse a collaborator on another page with this editor. | |
| provider.setAwarenessField('page', pageSlug) | |
| const editor = buildEditor(provider) | |
| window.__editor = editor | |
| 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 | |
| const onThreads = () => { | |
| renderMargin(editor) | |
| poke(editor) | |
| } | |
| const onSuggestions = () => { | |
| renderMargin(editor) | |
| poke(editor) | |
| } | |
| threads.observeDeep(onThreads) | |
| suggestions.observeDeep(onSuggestions) | |
| pageUnsubscribers.push( | |
| () => threads.unobserveDeep(onThreads), | |
| () => suggestions.unobserveDeep(onSuggestions) | |
| ) | |
| editor.on('update', () => { | |
| updateTitle(editor) | |
| scheduleLayout(editor) | |
| }) | |
| const onAwareness = () => renderPresence(provider) | |
| provider.on('awarenessUpdate', onAwareness) | |
| pageUnsubscribers.push(() => provider.off('awarenessUpdate', onAwareness)) | |
| wireUndoReopens(editor) | |
| wireImageBubble(editor) | |
| wireLinkBubble(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: use one "- slug" line per page or - "Group label": for a visual group, and 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) | |
| // A search survives a page switch, and a ?q= deep link starts one before this | |
| // editor existed: repaint the hit list against this page's text, mark it, and | |
| // — for a deep link only — scroll to the hit it named. | |
| if (sidebarFilter) { | |
| if (sidebarSnapshot) renderStructure(sidebarSnapshot) | |
| refreshDocHighlights() | |
| settleDeepLinkQuery() | |
| } | |
| 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() { | |
| for (const un of pageUnsubscribers.splice(0)) { | |
| try { un() } catch {} | |
| } | |
| try { | |
| window.__editor?.destroy() | |
| } catch {} | |
| window.__editor = null // no stale handle for a deferred callback to find | |
| 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') | |
| hideHoverComment() // its anchor position belongs to the document being torn down | |
| document.querySelectorAll('#margin-items .card, #margin-items .margin-empty').forEach(n => n.remove()) | |
| } | |
| // Switch pages client-side: remount the editor on another named fragment while | |
| // keeping the synced project document, socket, header, and 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) | |
| // the tab's page part until the new editor reports its own first line; the | |
| // sidebar already knows it, and keeping the previous page's name would be wrong | |
| paintTitles(state.pageTitles?.[slug] || '') | |
| if (push) history.pushState({}, '', searchHref(slug)) | |
| await initPage() | |
| loadStructure().finally(() => paintTitles()) | |
| } | |
| // 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() | |
| uiAlert(`This tab is running an outdated version, so ${what} cannot be saved. Reload the page and try again.`) | |
| } else { | |
| uiAlert(`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', async () => { | |
| if (await uiConfirm({ message: 'Delete this table?', confirmLabel: 'Delete', danger: true })) { | |
| 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) | |
| } | |
| // --- link bubble --------------------------------------------------------------- | |
| // In an editor a plain click on a link has to place the caret — you must be able | |
| // to edit the words a link is made of. But that left NO way to follow a link | |
| // with a plain click, and nothing on screen saying so: clicking a cross- | |
| // reference looked broken. So clicking one now raises a small bubble naming the | |
| // target with an Open button (ctrl/cmd+click still goes straight there). | |
| function wireLinkBubble(editor) { | |
| document.getElementById('link-bubble')?.remove() | |
| const bubble = el('div', { id: 'link-bubble', class: 'hidden' }) | |
| const label = el('button', { class: 'lb-target', 'data-tip': 'Open' }) | |
| const open = el('button', { class: 'btn small' }, 'Open') | |
| const unlink = el('button', { class: 'iconbtn', 'data-tip': 'Remove link' }) | |
| unlink.appendChild(icon('unlink')) // el()'s third argument is text, not a node | |
| bubble.append(label, open, unlink) | |
| document.body.appendChild(bubble) | |
| let href = null | |
| const hide = () => bubble.classList.add('hidden') | |
| const go = () => { | |
| if (href) followLink(href) | |
| hide() | |
| } | |
| for (const node of [label, open]) { | |
| node.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| go() | |
| }) | |
| } | |
| unlink.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| editor.chain().focus().extendMarkRange('link').unsetLink().run() | |
| hide() | |
| }) | |
| const update = () => { | |
| if (editor.isDestroyed) return | |
| // the caret sits inside a link, or a click landed on one | |
| const mark = editor.getAttributes('link') | |
| if (!mark?.href || !editor.state.selection.empty) return hide() | |
| href = mark.href | |
| const internal = internalTarget(href) | |
| label.textContent = internal | |
| ? state.pageTitles?.[internal.slug] || internal.slug | |
| : href.replace(/^https?:\/\//, '').slice(0, 48) | |
| label.classList.toggle('internal', !!internal) | |
| // anchored to the link itself, which is where the eye already is | |
| const anchor = editor.view.domAtPos(editor.state.selection.from)?.node | |
| const linkEl = (anchor?.nodeType === 3 ? anchor.parentElement : anchor)?.closest?.('a') | |
| const rect = (linkEl || editor.view.dom).getBoundingClientRect() | |
| bubble.classList.remove('hidden') | |
| bubble.style.left = `${Math.max(8, Math.min(window.innerWidth - bubble.offsetWidth - 8, rect.left))}px` | |
| const below = rect.bottom + 6 | |
| bubble.style.top = `${below + bubble.offsetHeight > window.innerHeight - 8 ? rect.top - bubble.offsetHeight - 6 : below}px` | |
| } | |
| editor.on('selectionUpdate', update) | |
| window.addEventListener('scroll', hide, true) | |
| document.addEventListener('mousedown', e => { | |
| if (!e.target.closest('#link-bubble, .tiptap a')) hide() | |
| }) | |
| } | |
| // "/d/<this doc>/<page>" → {slug}, for any link pointing inside this project; | |
| // null for everything else. One rule, used by the click handler and the bubble. | |
| function internalTarget(href) { | |
| try { | |
| const url = new URL(href, location.origin) | |
| if (url.origin !== location.origin) return null | |
| const m = url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?/?$`)) | |
| return m ? { slug: m[1] || 'home' } : null | |
| } catch { | |
| return null | |
| } | |
| } | |
| function followLink(href) { | |
| const internal = internalTarget(href) | |
| if (internal) return openPage(internal.slug) | |
| let url | |
| try { | |
| url = new URL(href, location.origin) | |
| } catch { | |
| return | |
| } | |
| if (url.origin === location.origin) location.href = url.pathname + url.search | |
| else window.open(url.href, '_blank', 'noopener') | |
| } | |
| // --- 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 | |
| } | |
| // below this width the sheet stops being a fixed-width page and reflows into a | |
| // fluid column. 840 = the 816px page plus the 2×8px gutter the layout keeps, | |
| // with slack; the reasoning and every consequence are in app.css's ≤840 block. | |
| function isReflowColumn() { | |
| return window.matchMedia('(max-width: 840px)').matches | |
| } | |
| // set by buildHeaderTools; called when a window crosses 840 in either direction, | |
| // because the zoom floor is different either side of it (see applyZoom) | |
| let reclampZoomFloor = () => {} | |
| // --- fitting the page into the window ------------------------------------------ | |
| // The sheet has a fixed width — it is a page, not a fluid column — so when the | |
| // window shrinks something else has to give. In order: the Pages sidebar folds | |
| // away first, then the comments margin, and only a window narrower than the | |
| // page itself is left scrolling sideways. | |
| // | |
| // Pages is derived entirely from available space: it is open whenever it fits | |
| // and comes back automatically when the window grows. Comments still remembers | |
| // a reader preference. | |
| const prefOpen = key => localStorage.getItem(key) !== '1' | |
| let sidebarFits = true | |
| let sidebarPeekOpen = false | |
| function cssPx(name, fallback) { | |
| const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name)) | |
| return Number.isFinite(v) ? v : fallback | |
| } | |
| function fitLayout() { | |
| // the narrow shell has no columns to trade away: a drawer and a bottom sheet | |
| // float over the document instead | |
| if (isStackedMargin()) { | |
| document.documentElement.style.setProperty('--margin-applied-overlap', '0px') | |
| return | |
| } | |
| const layout = document.getElementById('layout') | |
| const style = getComputedStyle(layout) | |
| const room = | |
| layout.clientWidth - parseFloat(style.paddingLeft || 0) - parseFloat(style.paddingRight || 0) | |
| const gap = parseFloat(style.columnGap || 0) || 0 | |
| // the sheet is fixed-width, so its own box is the honest measure of what it | |
| // needs — including whatever the zoom control has done to it | |
| const editor = document.getElementById('editor') | |
| const sheet = editor.offsetWidth | |
| const needs = (sidebar, margin) => | |
| sheet + (sidebar ? cssPx('--sidebar-w', 220) + gap : 0) + (margin ? cssPx('--margin-w', 300) + gap : 0) | |
| let sidebar = true | |
| let margin = prefOpen('margin-hidden') | |
| let overlap = 0 | |
| if (sidebar && margin) { | |
| const shortage = Math.max(0, needs(true, true) - room) | |
| const pagePadding = parseFloat(getComputedStyle(editor).paddingRight) || 0 | |
| const maxOverlap = gap + pagePadding + cssPx('--comment-text-overlap', 24) | |
| if (shortage <= maxOverlap) overlap = shortage | |
| else sidebar = false | |
| } | |
| if (sidebar && !margin && needs(true, false) > room) sidebar = false | |
| // Once Pages yields, comments returns outside the page. If that clean | |
| // two-column layout also does not fit, comments yields next. | |
| if (!sidebar) overlap = 0 | |
| if (margin && needs(sidebar, true) - overlap > room) margin = false | |
| document.documentElement.style.setProperty('--margin-applied-overlap', `${overlap}px`) | |
| sidebarFits = sidebar | |
| if (sidebarFits) sidebarPeekOpen = false | |
| setSidebarHidden(!sidebar && !sidebarPeekOpen) | |
| setMarginHidden(!margin, false) | |
| } | |
| // The same question fitLayout() asks of the columns, asked of the header: does | |
| // the toolbar fit beside the title and the actions, and if not, what gives way? | |
| // It takes a row of its own — the layout the narrow breakpoint already uses. | |
| // | |
| // Measured, not keyed to a width, because the room left on that row is not a | |
| // function of the window: the title is up to 260px of it, each collaborator's | |
| // avatar another 18px, and the toolbar's own width moves with the document | |
| // style (the serif A's are wider than the sans ones). The same window is 200px | |
| // better or worse off depending on all three. #header-tools scrolls with its | |
| // scrollbar hidden, so guessing wrong is invisible — the toolbar just ends, | |
| // mid-glyph, with buttons behind an edge nothing says you can scroll. | |
| function fitHeaderTools() { | |
| const bar = document.getElementById('header-tools') | |
| if (!bar) return | |
| // Always measure from the un-wrapped state, so the answer depends on what is | |
| // in the header and not on what the last call happened to decide. Removing | |
| // and restoring the class in one synchronous block never reaches a paint. | |
| document.body.classList.remove('tools-own-row') | |
| // a pixel of tolerance: sub-pixel layout rounds scrollWidth up on its own | |
| document.body.classList.toggle('tools-own-row', bar.scrollWidth > bar.clientWidth + 1) | |
| } | |
| 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() { | |
| // narrow windows show the panel as a sheet over the document, so it always | |
| // starts closed there; wide ones remember the last choice, and fitLayout() | |
| // then folds it away anyway if the page leaves no room for it | |
| const hidden = isStackedMargin() ? true : localStorage.getItem('margin-hidden') === '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')) | |
| // Initial Yjs sync can render the margin before phone-only chrome is wired. | |
| // Seed the newly-created badge from that already-rendered state; subsequent | |
| // renderMargin() calls keep it current as usual. | |
| setCommentCount(document.querySelectorAll('#margin-items .card').length) | |
| 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() | |
| let wasReflow = isReflowColumn() | |
| window.addEventListener('resize', () => { | |
| syncHeaderHeight() | |
| if (isPhone() !== wasPhone) { | |
| wasPhone = isPhone() | |
| applyBreakpoint() | |
| } | |
| if (isReflowColumn() !== wasReflow) { | |
| wasReflow = isReflowColumn() | |
| // a rotation into the fluid column: 40% zoom means 6.4px type here | |
| reclampZoomFloor() | |
| } | |
| 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) { | |
| sidebarPeekOpen = false | |
| setSidebarHidden(true) | |
| setMarginHidden(true, false) | |
| } else { | |
| document.getElementById('scrim').classList.add('hidden') | |
| // back in column territory: restore what was open, minus whatever the | |
| // page needs the room for | |
| fitLayout() | |
| } | |
| } | |
| }) | |
| 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', 'docstyle-btn', 'theme-btn', 'download-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) { | |
| const sidebar = document.getElementById('sidebar') | |
| const toggle = document.getElementById('sidebar-toggle') | |
| sidebar.classList.toggle('hidden', hidden) | |
| sidebar.classList.toggle('space-drawer', !hidden && !sidebarFits && !isStackedMargin()) | |
| // The edge arrow is an affordance for an automatically collapsed sidebar, | |
| // not a permanent manual-close control in an otherwise spacious layout. | |
| toggle.classList.toggle('hidden', !hidden) | |
| toggle.textContent = '▸' | |
| 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()) | |
| scheduleLayout(window.__editor) | |
| } | |
| // Tapping a page inside either drawer form should get it out of the way. | |
| function closeDrawerOnPhone() { | |
| if (isStackedMargin() || document.getElementById('sidebar').classList.contains('space-drawer')) { | |
| sidebarPeekOpen = false | |
| setSidebarHidden(true) | |
| } | |
| } | |
| const collapsedStorageKey = `cowrite:collapsed:${docId}` | |
| function collapsedSidebarKeys() { | |
| try { | |
| const saved = JSON.parse(localStorage.getItem(collapsedStorageKey) || '[]') | |
| return new Set(Array.isArray(saved) ? saved.filter(key => typeof key === 'string') : []) | |
| } catch { | |
| return new Set() | |
| } | |
| } | |
| function saveCollapsedSidebarKeys(keys) { | |
| localStorage.setItem(collapsedStorageKey, JSON.stringify([...keys])) | |
| } | |
| function setSidebarCollapsed(key, collapsed) { | |
| const keys = collapsedSidebarKeys() | |
| if (collapsed) keys.add(key) | |
| else keys.delete(key) | |
| saveCollapsedSidebarKeys(keys) | |
| loadStructure() | |
| } | |
| // Page refs are globally unique slugs. Group refs retain every path element, | |
| // including the quotes that distinguish group labels from page slugs. | |
| function sidebarKeyToRef(key) { | |
| return key.includes('"') ? key.split('/') : key | |
| } | |
| let detachTreeDrag = null | |
| const sidebarSelection = new Set() | |
| let sidebarSelectionAnchor = null | |
| let sidebarSnapshot = null | |
| let sidebarFilter = '' | |
| let pendingSidebarRename = null | |
| let sidebarToastTimer = null | |
| /* ============================================================ | |
| SEARCH — the sidebar box searches page titles AND page text. | |
| ============================================================ | |
| Where the index comes from. Every page of a project is a named fragment of | |
| the ONE Yjs document this tab already syncs (see initProjectCollaboration), | |
| so the text of every page the reader is allowed to see is sitting in memory | |
| before the first keystroke. That makes a client-side index the cheap option | |
| here rather than the expensive one: no endpoint, no per-page fetch, no | |
| permission logic to re-derive (the socket handshake already decided access), | |
| and the index follows a collaborator's typing because it is built from the | |
| live CRDT, not from a snapshot the server took. | |
| Two walks read that content, and they MUST enumerate matchable text in the | |
| same order, because the sidebar identifies a hit by its ordinal within the | |
| page and the jump then reveals the Nth <mark> in the document: | |
| - indexBlocks() walks the Yjs->ProseMirror JSON of any page, current or not; | |
| - docHitRanges() walks the live ProseMirror doc of the open page. | |
| The invariant that keeps them in step: only text nodes contribute characters, | |
| every other inline node contributes exactly one character that no query can | |
| match, and text blocks are separated by a newline that no (trimmed, | |
| single-line) query can span. */ | |
| // One character per non-text inline node. Same width as the node's ProseMirror | |
| // position, so an offset in the text we index is also an offset in the | |
| // document; U+FFFC is the object-replacement character, which the search box | |
| // strips from the query (see paintFilter) and so can never be part of a hit. | |
| const INLINE_ATOM = '\uFFFC' | |
| // Below this, the box is the title filter it has always been: one letter matches | |
| // half the prose, which buries the tree in snippets and marks that point at | |
| // nothing in particular. A page called "R" is still worth finding by name, so | |
| // titles and slugs keep matching from the first character — it is searching the | |
| // *text* that waits for a second one, and it waits in all three places at once | |
| // (which pages match, which snippets show, what the document marks). | |
| const MIN_TEXT_QUERY = 2 | |
| // Snippets per page. rl-wiki showed 20; this sidebar is 220px wide, where a | |
| // snippet is two or three wrapped lines, so 20 of them bury the rest of the tree. | |
| const SNIPPET_CAP = 8 | |
| // Blocks of a page as { text, lc, heading }: the plain text of every text-bearing | |
| // block, its lowercase twin, and the nearest heading at or above it. `lc` is | |
| // computed here, once per edit, because the alternative is once per block per | |
| // repaint — and a repaint happens every 200ms while anyone in the project types, | |
| // plus every 15s from the structure poll. | |
| // | |
| // In rl-wiki the heading had to be re-derived by re-implementing the renderer's | |
| // slugify, to guess an anchor id from markdown source; here it is simply the | |
| // heading node we last walked past, and it is shown next to the hit rather than | |
| // encoded into a URL — cowrite renders an editor, which has positions but no | |
| // heading anchors. | |
| function indexBlocks(json) { | |
| const blocks = [] | |
| let heading = '' | |
| const visit = node => { | |
| const content = node.content | |
| if (!Array.isArray(content) || !content.length) return | |
| // A ProseMirror node's children are either all blocks or all inline, so one | |
| // text child means this node is a text block and recursion stops here. | |
| if (!content.some(c => c.type === 'text')) { | |
| for (const child of content) visit(child) | |
| return | |
| } | |
| let text = '' | |
| for (const child of content) { | |
| if (child.type === 'text') text += child.text || '' | |
| else text += child.type === 'hardBreak' ? ' ' : INLINE_ATOM | |
| } | |
| if (node.type === 'heading') heading = text | |
| blocks.push({ text, lc: text.toLocaleLowerCase(), heading }) | |
| } | |
| visit(json) | |
| return blocks | |
| } | |
| // slug -> { lc, blocks } for every page of the project, built lazily and dropped | |
| // again when that page's fragment changes. `lc` is the whole page lowercased, | |
| // blocks joined by a newline, so "does this page match at all?" is one | |
| // String.includes before anything walks the blocks. Kept across page switches: | |
| // the sidebar it feeds is part of the shell, not of the open document. | |
| // | |
| // "Lazily" means per page, not in the background: the first keystroke that | |
| // reaches MIN_TEXT_QUERY walks every page of the project synchronously, inside | |
| // the 120ms debounce, because filterNodes needs a yes/no for every row before it | |
| // can render any of them. Measured at 121 pages / ~1.8MB of prose: one 144ms | |
| // hitch, once, and then nothing — every later keystroke reads the cache and only | |
| // an edited page is rebuilt. Backgrounding it would buy that 144ms at the cost of | |
| // rendering the tree twice, once without body matches, on the keystroke where the | |
| // reader is most likely to still be typing. | |
| // | |
| // Keyed by slug with no project in the key: switching projects is a full | |
| // navigation (location.assign), so this Map dies with the page rather than | |
| // outliving the docId its slugs were relative to. | |
| const searchIndex = new Map() | |
| const searchWatched = new Set() | |
| function pageSearchEntry(slug) { | |
| const cached = searchIndex.get(slug) | |
| if (cached) return cached | |
| if (!ydoc) return null | |
| // Two slugs that name no searchable body, both of which must return BEFORE | |
| // getXmlFragment, because that call *creates* the type it fails to find — a | |
| // write, and one that syncs to every other client, from a path that is supposed | |
| // to only read. `_structure` is the sidebar's own yaml (already kept out of the | |
| // page list, for the same reason nobody wants to search it); a ghost page is a | |
| // line in that yaml with nothing behind it yet. Unreachable as written — every | |
| // caller passes a slug off the page list — so this is about making it | |
| // structurally impossible rather than incidentally safe. | |
| if (slug === '_structure') return null | |
| if (slug !== pageSlug && !(slug in (state.pageTitles || {}))) return null | |
| const fragment = ydoc.getXmlFragment(pageField(slug)) | |
| if (!searchWatched.has(slug)) { | |
| searchWatched.add(slug) | |
| // Precise invalidation, one observer per page: a keystroke anywhere in the | |
| // project would otherwise rebuild every page's text. | |
| fragment.observeDeep(() => { | |
| searchIndex.delete(slug) | |
| if (sidebarFilter) scheduleSearchRefresh() | |
| }) | |
| } | |
| let blocks = [] | |
| try { | |
| blocks = indexBlocks(yXmlFragmentToProsemirrorJSON(fragment)) | |
| } catch { | |
| blocks = [] // an unreadable page must not take the search down with it | |
| } | |
| const entry = { lc: blocks.map(b => b.text).join('\n').toLocaleLowerCase(), blocks } | |
| searchIndex.set(slug, entry) | |
| return entry | |
| } | |
| // Every occurrence of q in a page: a snippet with its surrounding words, the | |
| // heading it sits under, and its ordinal so the document can be scrolled to | |
| // this hit and no other. `total` is the true count even when the list is capped. | |
| function pageHits(slug, q) { | |
| if (q.length < MIN_TEXT_QUERY) return { hits: [], total: 0 } | |
| const entry = pageSearchEntry(slug) | |
| if (!entry || !entry.lc.includes(q)) return { hits: [], total: 0 } | |
| const hits = [] | |
| let total = 0 | |
| for (const block of entry.blocks) { | |
| const lc = block.lc | |
| for (let i = lc.indexOf(q); i >= 0; i = lc.indexOf(q, i + q.length)) { | |
| if (hits.length < SNIPPET_CAP) { | |
| // 30 before / 40 after: enough of the sentence to tell two hits apart, | |
| // and about two wrapped lines in a 220px sidebar. The text is already | |
| // plain — no markdown syntax to strip out of it, unlike rl-wiki, which | |
| // indexed the source. | |
| // | |
| // Cut in three pieces around THIS occurrence instead of excerpting and | |
| // then searching the excerpt again: two hits in one sentence produce | |
| // near-identical excerpts, and re-finding the query in one of those | |
| // bolds whichever occurrence comes first — so the second hit would point | |
| // at the first hit's words. | |
| const from = Math.max(0, i - 30) | |
| const to = i + q.length + 40 | |
| const flat = s => s.replace(/\s+/g, ' ') | |
| hits.push({ | |
| before: (from > 0 ? '…' : '') + flat(block.text.slice(from, i)).trimStart(), | |
| match: flat(block.text.slice(i, i + q.length)), | |
| after: flat(block.text.slice(i + q.length, to)).trimEnd() + (to < block.text.length ? '…' : ''), | |
| heading: block.heading, | |
| ordinal: total, | |
| }) | |
| } | |
| total++ | |
| } | |
| } | |
| return { hits, total } | |
| } | |
| // A page matches on its title, its slug, or anywhere in its text. | |
| function pageMatches(slug, title, q) { | |
| if ((title || '').toLocaleLowerCase().includes(q) || slug.toLocaleLowerCase().includes(q)) return true | |
| if (q.length < MIN_TEXT_QUERY) return false | |
| const entry = pageSearchEntry(slug) | |
| return !!entry && entry.lc.includes(q) | |
| } | |
| // Bold the matched substring of a page title. Carried over from rl-wiki's | |
| // highlightMatch more or less verbatim — five lines, and the obvious way to write | |
| // them. Returns HTML, so everything around the <b> is escaped. (Snippets already | |
| // know where their match is — see pageHits.) | |
| function highlightMatch(text, q) { | |
| const i = q ? text.toLocaleLowerCase().indexOf(q) : -1 | |
| if (i < 0) return esc(text) | |
| return esc(text.slice(0, i)) + '<b>' + esc(text.slice(i, i + q.length)) + '</b>' + esc(text.slice(i + q.length)) | |
| } | |
| // Ranges of the active query in a live ProseMirror doc, in document order — the | |
| // same order pageHits() counts in, so hit N here is hit N there. | |
| function docHitRanges(doc, q) { | |
| const ranges = [] | |
| if (!q) return ranges | |
| doc.descendants((node, pos) => { | |
| if (!node.isTextblock) return true | |
| let text = '' | |
| const spans = [] // where each child's characters start, in text and in the doc | |
| node.forEach((child, offset) => { | |
| spans.push({ at: text.length, from: pos + 1 + offset }) | |
| if (child.isText) text += child.text || '' | |
| else text += child.type.name === 'hardBreak' ? ' ' : INLINE_ATOM | |
| }) | |
| const lc = text.toLocaleLowerCase() | |
| // Text offset -> document position. Given the one-character-per-inline-node | |
| // rule above this is arithmetically identical to `pos + 1 + off`, and the | |
| // span table looks like dead weight — it is not. It keeps this conversion | |
| // correct for every character BEFORE a node that breaks the rule, so adding | |
| // an inline node type that spans two characters misplaces marks after it | |
| // rather than corrupting the whole block. Verified by mutation: dropping the | |
| // per-atom character alone leaves marks landing correctly, because these | |
| // spans absorb it; drop both and marks slide left by one per atom. | |
| const docPos = off => { | |
| let span = spans[0] | |
| for (const s of spans) if (s.at <= off) span = s | |
| return span ? span.from + (off - span.at) : pos + 1 | |
| } | |
| for (let i = lc.indexOf(q); i >= 0; i = lc.indexOf(q, i + q.length)) { | |
| ranges.push({ from: docPos(i), to: docPos(i + q.length) }) | |
| } | |
| return false | |
| }) | |
| return ranges | |
| } | |
| // Highlighting the open document is the expensive half of the search: it runs | |
| // on every editor transaction, including every keystroke. Memoised on the doc | |
| // and the query, both of which are immutable, so an unrelated transaction (a | |
| // selection move, a decoration poke) costs one comparison. | |
| let hitRangeMemo = { doc: null, q: '', ranges: [] } | |
| function memoHitRanges(doc, q) { | |
| if (hitRangeMemo.doc !== doc || hitRangeMemo.q !== q) { | |
| hitRangeMemo = { doc, q, ranges: docHitRanges(doc, q) } | |
| } | |
| return hitRangeMemo.ranges | |
| } | |
| // Poke the open editor so buildDecorations runs again: the query changed but the | |
| // document did not, and nothing else would ask for fresh decorations. | |
| function refreshDocHighlights() { | |
| const editor = window.__editor | |
| if (editor && !editor.isDestroyed) poke(editor) | |
| } | |
| // Scroll the nth mark of the open document into view. Waits for it: a page | |
| // switch remounts the editor and a deep link lands before the socket has | |
| // synced, so the mark is not in the DOM on the next line. Bounded in frames | |
| // rather than milliseconds, which also means a background tab spends none of | |
| // its budget — rAF does not fire there, and there is nothing to scroll anyway. | |
| async function scrollToMark(ordinal, frames) { | |
| let seen = -1 | |
| let stable = 0 | |
| for (let frame = 0; frame < frames; frame++) { | |
| // nothing is ever going to be marked, so there is nothing to wait for | |
| if (sidebarFilter.length < MIN_TEXT_QUERY) return false | |
| const marks = document.querySelectorAll('#editor mark.search-hl') | |
| if (marks.length > ordinal) { | |
| marks[ordinal].scrollIntoView({ block: 'center', behavior: 'smooth' }) | |
| return true | |
| } | |
| // `n=` is whatever was in the URL, so it can name a hit this page does not | |
| // have. Marks arrive in batches as the socket syncs, which is what the frame | |
| // budget is for — but once they have stopped arriving, waiting out the rest | |
| // of it changes nothing. Settle for the last hit: the link asked to be shown | |
| // a match, and the alternative is a page that just sits there. | |
| stable = marks.length === seen ? stable + 1 : 0 | |
| seen = marks.length | |
| if (marks.length && stable >= 20) { | |
| marks[marks.length - 1].scrollIntoView({ block: 'center', behavior: 'smooth' }) | |
| return false | |
| } | |
| await new Promise(done => requestAnimationFrame(done)) | |
| } | |
| return false // the text went away between the render and the click | |
| } | |
| // Reveal one hit: the page it is on, scrolled to that hit and no other. The | |
| // document's marks are in the same order pageHits() counted in, so the ordinal | |
| // picks one out. | |
| async function revealHit(slug, ordinal) { | |
| // One history entry per click, wherever the hit is. Going to another page | |
| // pushes on its own (openPage) and reflectSearchInUrl then adds the ordinal to | |
| // that entry; a hit on the page already open would otherwise only ever | |
| // replaceState, so clicking through eight hits and pressing Back would leave | |
| // the document instead of walking back through them. | |
| if (slug !== pageSlug) await openPage(slug) | |
| else history.pushState({}, '', searchHref(slug, ordinal)) | |
| reflectSearchInUrl(ordinal) | |
| await scrollToMark(ordinal, 30) | |
| } | |
| // The hit a deep link arrived at, spent once. The page it names scrolls to that | |
| // hit as soon as the text has synced; every later page switch keeps the search | |
| // running but must not yank the reader to a mark they did not ask for. | |
| let pendingDeepLinkHit = -1 | |
| function settleDeepLinkQuery() { | |
| if (pendingDeepLinkHit < 0) return | |
| const ordinal = pendingDeepLinkHit | |
| pendingDeepLinkHit = -1 | |
| scrollToMark(ordinal, 600) // ~10s of frames: a cold load has to sync first | |
| } | |
| // Take the query from the URL — on load, and again when back/forward lands on a | |
| // history entry with a different one. Returns whether anything needs repainting: | |
| // the same query at a different `n=` is a different entry to land on, now that | |
| // clicking a hit pushes one, and answering "no change" there would leave Back | |
| // showing the right URL beside the wrong scroll position. | |
| function adoptSearchFromUrl() { | |
| const { raw, q, hit } = urlSearchQuery() | |
| const changed = q !== sidebarFilter | |
| sidebarFilter = q | |
| // `raw`, not `q`: matching is case-insensitive, but a link someone sent as | |
| // ?q=Zebra should not read as though it got mangled on the way here | |
| const box = document.getElementById('sidebar-search') | |
| if (box && changed) box.value = raw | |
| pendingDeepLinkHit = q ? hit : -1 | |
| return changed || pendingDeepLinkHit > 0 | |
| } | |
| // Throttled tree re-render, for when the index is invalidated under an active | |
| // search — a collaborator typing on another page changes what matches. | |
| let searchRefreshTimer = null | |
| function scheduleSearchRefresh() { | |
| if (searchRefreshTimer) return | |
| searchRefreshTimer = setTimeout(() => { | |
| searchRefreshTimer = null | |
| if (!sidebarFilter || !sidebarSnapshot) return | |
| renderStructure(sidebarSnapshot) | |
| }, 200) | |
| } | |
| // The active query lives in the URL so that what you are looking at is what you | |
| // can send someone: /d/<id>/<page>?q=<term>[&n=<hit>] opens the page with the | |
| // search already running and jumps to that hit. | |
| function searchHref(slug, ordinal = 0) { | |
| const base = slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}` | |
| if (!sidebarFilter) return base | |
| return base + `?q=${encodeURIComponent(sidebarFilter)}` + (ordinal ? `&n=${ordinal}` : '') | |
| } | |
| // replaceState, not pushState — a query is typed one character at a time, and | |
| // back should leave the page rather than walk backwards through the letters. | |
| function reflectSearchInUrl(ordinal = 0) { | |
| const want = searchHref(pageSlug, ordinal) | |
| if (location.pathname + location.search !== want) history.replaceState({}, '', want) | |
| } | |
| function urlSearchQuery() { | |
| try { | |
| const params = new URLSearchParams(location.search) | |
| const raw = (params.get('q') || '').trim().replaceAll(INLINE_ATOM, '') | |
| const q = raw.toLocaleLowerCase() | |
| const n = Number.parseInt(params.get('n') || '0', 10) | |
| return { raw, q, hit: q && Number.isInteger(n) && n > 0 ? n : 0 } | |
| } catch { | |
| return { raw: '', q: '', hit: 0 } | |
| } | |
| } | |
| // --- what a search looks like in the sidebar --------------------------------- | |
| // The query bolded inside a page's title, plus the page's true hit count. Only | |
| // under an active search: without one the row keeps the plain text label it has | |
| // always had, so nothing about the unfiltered tree changes. | |
| function paintPageLabel(a, title, total) { | |
| if (!sidebarFilter) return | |
| const label = el('span', { class: 'tree-title' }) | |
| label.innerHTML = highlightMatch(title, sidebarFilter) | |
| a.replaceChildren(label) | |
| if (total > 1) a.appendChild(el('span', { class: 'tree-hit-count' }, String(total))) | |
| } | |
| // The hit list under a page row: one clickable snippet per occurrence in the | |
| // page, each labelled with the heading it sits under. A page that matched on its | |
| // title alone gets no list — that is how a title match stays quiet. | |
| function pageHitList(slug) { | |
| const { hits, total } = pageHits(slug, sidebarFilter) | |
| if (!hits.length) return { list: null, total } | |
| const list = el('div', { class: 'tree-hits' }) | |
| for (const hit of hits) { | |
| // A real link, so a middle-click or ⌘-click opens the hit in its own tab and | |
| // the context menu can copy it. n= is the hit's ordinal, dropped for the | |
| // first one: ?q= alone already lands there. | |
| const row = el('a', { class: 'tree-hit', href: searchHref(slug, hit.ordinal) }) | |
| if (hit.heading) row.appendChild(el('span', { class: 'tree-hit-sec' }, hit.heading)) | |
| const text = el('span', { class: 'tree-hit-text' }) | |
| text.append(hit.before, el('b', {}, hit.match), hit.after) | |
| row.appendChild(text) | |
| row.addEventListener('click', e => { | |
| if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey) return | |
| e.preventDefault() | |
| revealHit(slug, hit.ordinal) | |
| }) | |
| list.appendChild(row) | |
| } | |
| const rest = total - hits.length | |
| if (rest > 0) list.appendChild(el('div', { class: 'tree-hit more' }, `+${rest} more match${rest === 1 ? '' : 'es'}`)) | |
| return { list, total } | |
| } | |
| function showSidebarToast(message) { | |
| let toast = document.querySelector('.sidebar-toast') | |
| if (!toast) { | |
| toast = el('div', { class: 'sidebar-toast', role: 'status', 'aria-live': 'polite' }) | |
| document.body.appendChild(toast) | |
| } | |
| toast.textContent = String(message || 'Something went wrong') | |
| toast.classList.add('show') | |
| clearTimeout(sidebarToastTimer) | |
| sidebarToastTimer = setTimeout(() => toast.classList.remove('show'), 3000) | |
| } | |
| function armSidebarDestructive(button, action) { | |
| const idleTip = button.dataset.tip || '' | |
| const idleLabel = button.getAttribute('aria-label') || '' | |
| let timer = null | |
| let armed = false | |
| const disarm = () => { | |
| armed = false | |
| clearTimeout(timer) | |
| button.classList.remove('arming') | |
| button.dataset.tip = idleTip | |
| if (idleLabel) button.setAttribute('aria-label', idleLabel) | |
| } | |
| button.addEventListener('click', async e => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| if (!armed) { | |
| armed = true | |
| button.classList.add('arming') | |
| button.dataset.tip = 'Click again to confirm' | |
| button.setAttribute('aria-label', 'Click again to confirm') | |
| timer = setTimeout(disarm, 2500) | |
| button.closest('.tree-row')?.addEventListener('pointerleave', disarm, { once: true }) | |
| return | |
| } | |
| disarm() | |
| button.disabled = true | |
| try { | |
| await action() | |
| } catch (error) { | |
| showSidebarToast(error?.message || error) | |
| } finally { | |
| if (button.isConnected) button.disabled = false | |
| } | |
| }) | |
| return button | |
| } | |
| function allGroupLabels(nodes, labels = new Set()) { | |
| for (const node of nodes || []) { | |
| if (node.group != null) labels.add(node.group) | |
| allGroupLabels(node.children, labels) | |
| } | |
| return labels | |
| } | |
| function newGroupLabel() { | |
| const labels = allGroupLabels(sidebarSnapshot?.tree) | |
| if (!labels.has('New group')) return 'New group' | |
| let n = 2 | |
| while (labels.has(`New group ${n}`)) n++ | |
| return `New group ${n}` | |
| } | |
| function focusEmptyPageTitle() { | |
| // the page's seeded (empty) H1 arrives with the Yjs sync, after initPage | |
| // resolves — focusing before that lands in a doc about to be replaced | |
| const t0 = Date.now() | |
| const tick = () => { | |
| const editor = window.__editor | |
| const synced = editor?.state.doc.firstChild?.type.name === 'heading' | |
| if (editor && (synced || Date.now() - t0 > 4000)) return editor.commands.focus('start') | |
| setTimeout(tick, 80) | |
| } | |
| tick() | |
| } | |
| function wireSidebar() { | |
| const sidebar = document.getElementById('sidebar') | |
| document.getElementById('page-add').appendChild(icon('file')) | |
| document.getElementById('group-add').appendChild(icon('folder')) | |
| // on narrow screens the sidebar is a drawer — closed by default | |
| const hidden = isStackedMargin() | |
| setSidebarHidden(hidden) | |
| const toggle = () => { | |
| const opening = sidebar.classList.contains('hidden') | |
| sidebarPeekOpen = opening | |
| setSidebarHidden(!opening) | |
| } | |
| document.getElementById('sidebar-toggle').addEventListener('click', toggle) | |
| document.getElementById('m-pages').addEventListener('click', toggle) | |
| document.getElementById('sidebar-close').addEventListener('click', () => { | |
| sidebarPeekOpen = false | |
| setSidebarHidden(true) | |
| }) | |
| document.getElementById('scrim').addEventListener('click', () => { | |
| sidebarPeekOpen = false | |
| setSidebarHidden(true) | |
| }) | |
| document.getElementById('page-add').addEventListener('click', async () => { | |
| const res = await api(`/api/docs/${docId}/pages`, { method: 'POST', body: { title: '' } }) | |
| if (res.error) return showSidebarToast(res.error) | |
| sidebarSelection.clear() | |
| sidebarSelection.add(res.slug) | |
| sidebarSelectionAnchor = res.slug | |
| await openPage(res.slug) | |
| focusEmptyPageTitle() | |
| }) | |
| document.getElementById('group-add').addEventListener('click', async () => { | |
| const label = newGroupLabel() | |
| const res = await api(`/api/docs/${docId}/structure/groups`, { method: 'POST', body: { label } }) | |
| if (res.error) return showSidebarToast(res.error) | |
| pendingSidebarRename = `"${label}"` | |
| await loadStructure() | |
| }) | |
| const search = document.getElementById('sidebar-search') | |
| const searchKey = document.getElementById('sidebar-search-key') | |
| // One element, two jobs: ⌘K while the box is empty, ✕ once it is not. The badge | |
| // swaps its glyph in CSS (a ::after), which assistive tech cannot see, so the | |
| // accessible name has to be swapped here or a button that clears announces | |
| // itself as one that focuses. | |
| const paintSearchKeyLabel = () => | |
| searchKey.setAttribute('aria-label', search.value ? 'Clear search' : 'Focus search') | |
| // A keystroke now repaints the tree AND the document's marks, but it is not | |
| // news about the structure: it renders from the snapshot we already hold | |
| // rather than asking the server again, which is what the box used to do on | |
| // every character. | |
| const applyFilter = () => { | |
| sidebarFilter = search.value.trim().replaceAll(INLINE_ATOM, '').toLocaleLowerCase() | |
| if (sidebarSnapshot) renderStructure(sidebarSnapshot) | |
| else loadStructure() | |
| reflectSearchInUrl() | |
| refreshDocHighlights() | |
| } | |
| // 120ms, as rl-wiki used: typing "suggestion" would otherwise rebuild the tree | |
| // and re-scan the open document ten times on the way to the word. The box | |
| // itself does not wait on this — its ✕ badge is CSS — so it still feels live. | |
| let filterPaintTimer = null | |
| const paintFilter = () => { | |
| paintSearchKeyLabel() // not debounced: the glyph already changed | |
| clearTimeout(filterPaintTimer) | |
| filterPaintTimer = setTimeout(applyFilter, 120) | |
| } | |
| const clearFilter = () => { | |
| search.value = '' | |
| paintSearchKeyLabel() | |
| search.blur() | |
| clearTimeout(filterPaintTimer) | |
| applyFilter() | |
| } | |
| search.addEventListener('input', paintFilter) | |
| search.addEventListener('keydown', e => { | |
| if (e.key !== 'Escape') return | |
| e.preventDefault() | |
| clearFilter() | |
| }) | |
| searchKey.addEventListener('click', () => { | |
| if (search.value) clearFilter() | |
| else search.focus() | |
| }) | |
| // A ?q= deep link arrives with the shell, before the socket has synced — the | |
| // hit list and the marks can only be painted once the text is here, so seed | |
| // the box now and let initPage do the rest once it is. | |
| adoptSearchFromUrl() | |
| paintSearchKeyLabel() // a ?q= link arrives with the box already full | |
| document.addEventListener('keydown', e => { | |
| if ((e.metaKey || e.ctrlKey) && e.key.toLocaleLowerCase() === 'k') { | |
| e.preventDefault() | |
| search.focus() | |
| search.select() | |
| return | |
| } | |
| if (e.key === 'Escape' && !sidebar.querySelector('.sidebar-rename') && (sidebar.matches(':hover') || sidebar.contains(document.activeElement))) { | |
| sidebarSelection.clear() | |
| sidebarSelectionAnchor = null | |
| loadStructure() | |
| } | |
| }) | |
| detachTreeDrag?.() | |
| // attached to #sidebar (the scroll container) so edge auto-scroll works; | |
| // only [data-td-row] descendants react to the pointer | |
| detachTreeDrag = attachTreeDrag(document.getElementById('sidebar'), { | |
| onMove: async ({ keys, parentKey, index }) => { | |
| const res = await api(`/api/docs/${docId}/structure/move`, { | |
| method: 'POST', | |
| body: { | |
| nodes: keys.map(sidebarKeyToRef), | |
| parent: parentKey ? sidebarKeyToRef(parentKey) : null, | |
| index, | |
| }, | |
| }) | |
| if (res.error) showSidebarToast(res.error) | |
| loadStructure() | |
| }, | |
| onHoverExpand: key => { | |
| const collapsed = collapsedSidebarKeys() | |
| if (!collapsed.delete(key)) return | |
| saveCollapsedSidebarKeys(collapsed) | |
| loadStructure() | |
| }, | |
| }) | |
| } | |
| async function loadStructure() { | |
| const s = await api(`/api/docs/${docId}/structure`) | |
| if (s?.error) return showSidebarToast(s.error) | |
| sidebarSnapshot = s | |
| renderStructure(s) | |
| } | |
| // Painting the tree is split from fetching it because the search box repaints on | |
| // every keystroke, and the structure has not changed just because someone typed. | |
| function renderStructure(s) { | |
| state.pageTitles = s.titles || {} | |
| 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 deletablePage = slug => slug !== 'home' && slug !== '_structure' && known.has(slug) | |
| const pageDeleteBtn = slug => { | |
| const title = s.titles?.[slug] || slug | |
| const b = el('button', { | |
| class: 'iconbtn page-del', | |
| 'data-tip': `Delete page "${title}" for everyone`, | |
| 'aria-label': `Delete page "${title}"`, | |
| }) | |
| b.appendChild(icon('trash')) | |
| return armSidebarDestructive(b, async () => { | |
| const res = await api(`/api/docs/${docId}/pages/${slug}`, { method: 'DELETE' }) | |
| if (res.error) return showSidebarToast(res.error) | |
| if (slug === pageSlug) openPage('home') | |
| else loadStructure() | |
| }) | |
| } | |
| const groupPathPart = label => `"${label}"` | |
| const nodeKey = (node, path) => | |
| node.proposal ? `proposal:${node.proposal.id}` : node.group != null ? [...path, groupPathPart(node.group)].join('/') : node.slug | |
| const nodePath = (node, path) => [...path, node.group != null ? groupPathPart(node.group) : node.slug] | |
| // A proposed page belongs where it would land, not in a list underneath | |
| // everything: grafted into the tree at its {parent, index} it answers "where | |
| // is this going?" at a glance. Unplaceable proposals (a parent dissolved since) | |
| // sit at the top level rather than vanishing. | |
| const treeWithProposals = () => { | |
| const copy = nodes => (nodes || []).map(n => ({ ...n, children: copy(n.children) })) | |
| const tree = copy(s.tree) | |
| const findParent = (nodes, ref, path = []) => { | |
| for (const node of nodes) { | |
| if (node.proposal) continue | |
| const here = nodePath(node, path) | |
| const hit = typeof ref === 'string' ? node.group == null && node.slug === ref : ref.length === here.length && ref.every((e, i) => e === here[i]) | |
| if (hit) return node | |
| const deeper = findParent(node.children, ref, here) | |
| if (deeper) return deeper | |
| } | |
| return null | |
| } | |
| for (const p of s.pending || []) { | |
| const parent = p.parent == null ? null : findParent(tree, p.parent) | |
| const siblings = parent ? (parent.children = parent.children || []) : tree | |
| const at = Number.isInteger(p.index) ? Math.max(0, Math.min(p.index, siblings.length)) : siblings.length | |
| siblings.splice(at, 0, { proposal: p, children: [] }) | |
| } | |
| return tree | |
| } | |
| const everyKey = new Set() | |
| const collectKeys = (nodes, path = []) => { | |
| for (const node of nodes || []) { | |
| everyKey.add(nodeKey(node, path)) | |
| collectKeys(node.children, nodePath(node, path)) | |
| } | |
| } | |
| const graftedTree = treeWithProposals() | |
| collectKeys(graftedTree) | |
| for (const slug of s.unfiled || []) everyKey.add(slug) | |
| for (const key of sidebarSelection) if (!everyKey.has(key)) sidebarSelection.delete(key) | |
| if (sidebarSelectionAnchor && !everyKey.has(sidebarSelectionAnchor)) sidebarSelectionAnchor = null | |
| // A page is kept if the query is in its title, its slug, or its text; a group | |
| // or a proposed page only has a label to go on. Non-matching ancestors remain | |
| // so the location of each result is legible, but unrelated descendants do not. | |
| const filterNodes = (nodes, path = []) => { | |
| if (!sidebarFilter) return nodes || [] | |
| const kept = [] | |
| for (const node of nodes || []) { | |
| const children = filterNodes(node.children, nodePath(node, path)) | |
| const label = node.proposal | |
| ? node.proposal.title || node.proposal.slug | |
| : node.group != null | |
| ? node.group | |
| : s.titles?.[node.slug] || node.slug | |
| const self = | |
| node.proposal || node.group != null | |
| ? label.toLocaleLowerCase().includes(sidebarFilter) | |
| : pageMatches(node.slug, label, sidebarFilter) | |
| if (self || children.length) kept.push({ ...node, children }) | |
| } | |
| return kept | |
| } | |
| const visibleTree = filterNodes(graftedTree) | |
| // A collapsed preference is ignored while it would hide the current page. | |
| // Keep it in storage so navigating elsewhere restores the user's choice. | |
| const forcedOpen = new Set() | |
| const findCurrent = (nodes, path = [], ancestors = []) => { | |
| for (const node of nodes) { | |
| const key = nodeKey(node, path) | |
| if (node.proposal) continue | |
| if (node.group == null && node.slug === pageSlug) { | |
| for (const ancestor of ancestors) forcedOpen.add(ancestor) | |
| return true | |
| } | |
| if (findCurrent(node.children || [], nodePath(node, path), [...ancestors, key])) return true | |
| } | |
| return false | |
| } | |
| findCurrent(visibleTree) | |
| const collapsed = collapsedSidebarKeys() | |
| const caret = (key, isCollapsed) => { | |
| const b = el('button', { | |
| class: 'tree-caret' + (isCollapsed ? ' collapsed' : ''), | |
| type: 'button', | |
| 'aria-label': isCollapsed ? 'Expand' : 'Collapse', | |
| 'aria-expanded': isCollapsed ? 'false' : 'true', | |
| }, '▾') | |
| b.addEventListener('click', e => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| setSidebarCollapsed(key, !isCollapsed) | |
| }) | |
| return b | |
| } | |
| const rowAttrs = (key, parentKey, index, depth, isCollapsed = false) => { | |
| const attrs = { | |
| 'data-key': key, | |
| } | |
| if (!sidebarFilter) Object.assign(attrs, { | |
| 'data-td-row': '', | |
| 'data-parent': parentKey, | |
| 'data-index': String(index), | |
| 'data-depth': String(depth), | |
| }) | |
| if (isCollapsed) attrs['data-collapsed'] = '1' | |
| if (sidebarSelection.has(key)) attrs['data-selected'] = '1' | |
| return attrs | |
| } | |
| const visibleRowKeys = () => [...container.querySelectorAll('.tree-row[data-key]')].map(row => row.dataset.key) | |
| const updateSelection = (event, key) => { | |
| if (event.shiftKey && sidebarSelectionAnchor) { | |
| const keys = visibleRowKeys() | |
| const from = keys.indexOf(sidebarSelectionAnchor) | |
| const to = keys.indexOf(key) | |
| if (from >= 0 && to >= 0) { | |
| if (!event.metaKey && !event.ctrlKey) sidebarSelection.clear() | |
| for (const selected of keys.slice(Math.min(from, to), Math.max(from, to) + 1)) sidebarSelection.add(selected) | |
| } | |
| return false | |
| } | |
| if (event.metaKey || event.ctrlKey) { | |
| if (sidebarSelection.has(key)) sidebarSelection.delete(key) | |
| else sidebarSelection.add(key) | |
| sidebarSelectionAnchor = key | |
| return false | |
| } | |
| sidebarSelection.clear() | |
| sidebarSelection.add(key) | |
| sidebarSelectionAnchor = key | |
| return true | |
| } | |
| const beginRename = ({ labelNode, oldLabel, ariaLabel, maxlength = 60, commit }) => { | |
| const input = el('input', { class: 'sidebar-rename', value: oldLabel, maxlength: String(maxlength), 'aria-label': ariaLabel }) | |
| labelNode.replaceWith(input) | |
| input.focus({ preventScroll: true }) | |
| input.select() | |
| let finished = false | |
| const finish = async shouldCommit => { | |
| if (finished) return | |
| finished = true | |
| pendingSidebarRename = null | |
| const label = input.value.trim() | |
| if (!shouldCommit || label === oldLabel) return loadStructure() | |
| if (!label) return loadStructure() | |
| const res = await commit(label) | |
| if (res.error) showSidebarToast(res.error) | |
| loadStructure() | |
| } | |
| input.addEventListener('keydown', e => { | |
| if (e.key === 'Escape') { | |
| e.preventDefault() | |
| finish(false) | |
| } else if (e.key === 'Enter') { | |
| e.preventDefault() | |
| finish(true) | |
| } | |
| }) | |
| input.addEventListener('blur', () => finish(true)) | |
| } | |
| const beginGroupRename = (labelNode, path, oldLabel) => beginRename({ | |
| labelNode, | |
| oldLabel, | |
| ariaLabel: 'Group label', | |
| commit: label => api(`/api/docs/${docId}/structure/groups/rename`, { method: 'POST', body: { path, label } }), | |
| }) | |
| const beginPageRename = (labelNode, slug, oldLabel) => beginRename({ | |
| labelNode, | |
| oldLabel, | |
| ariaLabel: 'Page title', | |
| maxlength: 120, | |
| commit: title => api(`/api/docs/${docId}/pages/${slug}/rename`, { method: 'POST', body: { title } }), | |
| }) | |
| const groupDeleteBtn = (path, label) => { | |
| const b = el('button', { | |
| class: 'iconbtn group-del', | |
| 'data-tip': `Dissolve group "${label}"`, | |
| 'aria-label': `Dissolve group "${label}"`, | |
| }) | |
| b.appendChild(icon('trash')) | |
| return armSidebarDestructive(b, async () => { | |
| const res = await api(`/api/docs/${docId}/structure/groups/dissolve`, { method: 'POST', body: { path } }) | |
| if (res.error) showSidebarToast(res.error) | |
| loadStructure() | |
| }) | |
| } | |
| const renderNodes = (nodes, path = [], parentKey = '', depth = 0) => { | |
| const ul = el('ul', {}) | |
| nodes.forEach((node, index) => { | |
| const group = node.group != null | |
| const key = nodeKey(node, path) | |
| const refPath = nodePath(node, path) | |
| const hasChildren = Boolean(node.children?.length) | |
| const isCollapsed = !sidebarFilter && hasChildren && collapsed.has(key) && !forcedOpen.has(key) | |
| const li = el('li', { class: group ? 'nav-cat' : '' }) | |
| let hits = null | |
| if (node.proposal) { | |
| // not a line in the structure yaml yet, so it takes no drag attributes: | |
| // there is nothing to move until it is accepted | |
| const p = node.proposal | |
| const row = el('div', { 'data-key': key }) | |
| row.className = 'tree-row page-row proposal-row' | |
| row.appendChild(el('span', { class: 'tree-caret-spacer', 'aria-hidden': 'true' })) | |
| 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) | |
| }) | |
| row.appendChild(a) | |
| li.appendChild(row) | |
| ul.appendChild(li) | |
| return | |
| } | |
| const row = el('div', rowAttrs(key, parentKey, index, depth, isCollapsed)) | |
| row.className = 'tree-row ' + (group ? `group-row lvl-${Math.min(depth + 1, 4)}` : 'page-row') | |
| row.appendChild(hasChildren ? caret(key, isCollapsed) : el('span', { class: 'tree-caret-spacer', 'aria-hidden': 'true' })) | |
| if (group) { | |
| // data-td-drag: the label is a <button>, which treedrag's control guard | |
| // would otherwise ignore — it's the group's drag handle | |
| const label = el('button', { class: 'group-label', type: 'button', 'data-td-drag': '1', title: 'Click to collapse; double-click to rename' }, node.group) | |
| let clickTimer = null | |
| label.addEventListener('click', e => { | |
| clearTimeout(clickTimer) | |
| const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey } | |
| clickTimer = setTimeout(() => { | |
| const plain = updateSelection(modifiers, key) | |
| if (!plain) return loadStructure() | |
| if (hasChildren) setSidebarCollapsed(key, !isCollapsed) | |
| else loadStructure() | |
| }, 280) | |
| }) | |
| label.addEventListener('dblclick', e => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| clearTimeout(clickTimer) | |
| beginGroupRename(label, refPath, node.group) | |
| }) | |
| row.append(label, groupDeleteBtn(refPath, node.group)) | |
| if (pendingSidebarRename === key) { | |
| requestAnimationFrame(() => beginGroupRename(label, refPath, node.group)) | |
| } | |
| } else { | |
| const pageTitle = s.titles?.[node.slug] || node.slug | |
| const a = el( | |
| 'a', | |
| { | |
| // searchHref, so opening a row in its own tab arrives with the | |
| // search that found it | |
| href: searchHref(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`, | |
| }, | |
| pageTitle | |
| ) | |
| let clickTimer = null | |
| a.addEventListener('click', e => { | |
| if (e.button !== 0) return | |
| e.preventDefault() | |
| clearTimeout(clickTimer) | |
| const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey } | |
| clickTimer = setTimeout(() => { | |
| const plain = updateSelection(modifiers, key) | |
| if (!plain) return loadStructure() | |
| if (node.slug === pageSlug) loadStructure() | |
| else openPage(node.slug) | |
| }, 280) | |
| }) | |
| a.addEventListener('dblclick', e => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| clearTimeout(clickTimer) | |
| if (!known.has(node.slug)) return | |
| beginPageRename(a, node.slug, pageTitle) | |
| }) | |
| row.appendChild(a) | |
| if (node.slug === 'home') { | |
| // The star marks the project's front page. It used to sit before the | |
| // label, which pushed home's title right of every other top-level | |
| // page; it now hangs in the row's own left gutter so all the titles | |
| // start at the same x — except when home has children, because then | |
| // the caret already owns that gutter, so the star follows the title. | |
| const star = icon('star') | |
| star.classList.add('home-star') | |
| if (hasChildren) star.classList.add('trailing') | |
| row.appendChild(star) | |
| } | |
| if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug)) | |
| hits = sidebarFilter ? pageHitList(node.slug) : null | |
| paintPageLabel(a, pageTitle, hits?.total || 0) | |
| } | |
| li.appendChild(row) | |
| // The hit list hangs off the <li>, not the row: the row is a flex line | |
| // with an absolutely-positioned trash can, and snippets belong under the | |
| // whole thing, above any child pages. | |
| if (hits?.list) li.appendChild(hits.list) | |
| if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1)) | |
| ul.appendChild(li) | |
| }) | |
| return ul | |
| } | |
| container.innerHTML = '' | |
| container.appendChild(renderNodes(visibleTree)) | |
| const visibleUnfiled = sidebarFilter | |
| ? (s.unfiled || []).filter(slug => pageMatches(slug, s.titles?.[slug] || slug, sidebarFilter)) | |
| : (s.unfiled || []) | |
| if (visibleUnfiled.length) { | |
| container.appendChild(el('div', { class: 'unfiled-label' }, 'unfiled')) | |
| const ul = el('ul', {}) | |
| for (const [unfiledIndex, slug] of visibleUnfiled.entries()) { | |
| if (slug === 'home') continue | |
| const li = el('li', {}) | |
| // far offset so a drop at the tree's root end never looks like a no-op | |
| // (unfiled rows pose as root children for the drag index math) | |
| const row = el('div', rowAttrs(slug, '', 1000 + unfiledIndex, 0)) | |
| row.className = 'tree-row page-row unfiled-row' | |
| row.appendChild(el('span', { class: 'tree-caret-spacer', 'aria-hidden': 'true' })) | |
| const pageTitle = s.titles?.[slug] || slug | |
| const a = el('a', { href: searchHref(slug), class: slug === pageSlug ? 'current' : '' }, pageTitle) | |
| let clickTimer = null | |
| a.addEventListener('click', e => { | |
| if (e.button !== 0) return | |
| e.preventDefault() | |
| clearTimeout(clickTimer) | |
| const modifiers = { shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey } | |
| clickTimer = setTimeout(() => { | |
| const plain = updateSelection(modifiers, slug) | |
| if (!plain) return loadStructure() | |
| if (slug === pageSlug) loadStructure() | |
| else openPage(slug) | |
| }, 280) | |
| }) | |
| a.addEventListener('dblclick', e => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| clearTimeout(clickTimer) | |
| beginPageRename(a, slug, pageTitle) | |
| }) | |
| row.appendChild(a) | |
| if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug)) | |
| const hits = sidebarFilter ? pageHitList(slug) : null | |
| paintPageLabel(a, pageTitle, hits?.total || 0) | |
| li.appendChild(row) | |
| if (hits?.list) li.appendChild(hits.list) | |
| ul.appendChild(li) | |
| } | |
| if (ul.children.length) container.appendChild(ul) | |
| } | |
| // Searching text can come up empty where filtering titles rarely did, and an | |
| // empty tree on its own reads as "the sidebar broke", not as "no matches". | |
| if (sidebarFilter && !container.querySelector('.tree-row')) { | |
| container.appendChild(el('div', { class: 'tree-empty' }, `Nothing matches “${sidebarFilter}”`)) | |
| } | |
| } | |
| // --- 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) { | |
| closeDrawerOnPhone() // same as opening a page: the drawer would cover the preview | |
| 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 | |
| } | |
| // the header stays the project (it switches projects); the preview says so in | |
| // the tab and in its own bar below | |
| paintTitles(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 () => { | |
| const ok = await uiConfirm({ message: `Reject the proposed page "${p.title}"?`, confirmLabel: 'Reject', danger: true }) | |
| if (!ok) return | |
| const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/reject`, { method: 'POST' }) | |
| if (r.error) return uiAlert(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 uiAlert(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) | |
| } | |
| // --- the two titles ------------------------------------------------------------ | |
| // The header selector (#doc-title) is the button that switches PROJECTS, so it | |
| // shows the project — never the page you happen to be on, which is what made it | |
| // look like the wrong control. The page belongs in the tab: "<page> · <project>". | |
| // | |
| // The project's name is home's H1: the server keeps the registry title in step | |
| // with it, and reports it back as the `home` entry of the structure poll — which | |
| // is also how a collaborator's rename arrives. But the registry only catches up | |
| // one save later, so typing home's H1 and switching page immediately would get | |
| // the header reverted by the next poll. Hence the two sources declared at the | |
| // top: what was typed here wins until the server reports it back, and the | |
| // server owns it after that. | |
| function cacheProjectTitle(title) { | |
| try { | |
| localStorage.setItem(`cw-proj-title:${docId}`, title) | |
| } catch {} | |
| } | |
| function rememberLiveProjectTitle(title) { | |
| const clean = String(title ?? '').trim() | |
| if (!clean || clean === projectTitleLive) return | |
| projectTitleLive = clean | |
| cacheProjectTitle(clean) | |
| } | |
| function rememberServerProjectTitle(title) { | |
| const clean = String(title ?? '').trim() | |
| if (!clean || clean === '…') return | |
| // the save landed: stop preferring the local value, so later renames show | |
| if (clean === projectTitleLive) projectTitleLive = '' | |
| if (clean === projectTitleServer) return | |
| projectTitleServer = clean | |
| cacheProjectTitle(clean) | |
| } | |
| // Paints both titles. `pageTitle` omitted = repaint with whatever the project | |
| // title is now, keeping the page part (a poll landing must not clear the tab). | |
| function paintTitles(pageTitle, { live = false } = {}) { | |
| if (pageTitle != null) pageTitleShown = pageTitle | |
| if (live && pageSlug === 'home' && pageTitle && pageTitle !== 'Untitled') rememberLiveProjectTitle(pageTitle) | |
| rememberServerProjectTitle(state.pageTitles?.home) | |
| const project = projectTitleLive || projectTitleServer || pageTitleShown || 'Untitled' | |
| document.getElementById('doc-title').textContent = project | |
| document.title = pageTitleShown && pageTitleShown !== project ? `${pageTitleShown} · ${project}` : project | |
| } | |
| function pageTitleFromEditor(editor) { | |
| // the structure page is YAML: its first line is not its name | |
| if (pageSlug === '_structure') return 'Structure' | |
| 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' | |
| }) | |
| return title | |
| } | |
| function updateTitle(editor) { | |
| const title = pageTitleFromEditor(editor) | |
| paintTitles(title, { live: true }) | |
| if (pageSlug === '_structure') return | |
| 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 }), | |
| // Drag a cell border to set a column's width. The last column is left out | |
| // of it on purpose: the table is width:100% of a fixed page, so leaving the | |
| // last one elastic lets it absorb the slack and keeps the table pinned to | |
| // the margins instead of growing past them. Widths are cell attributes, so | |
| // they travel through Yjs like any other edit and persist with the doc. | |
| Table.configure({ resizable: true, cellMinWidth: 48, handleWidth: 6, lastColumnResizable: false }), | |
| TableRow, | |
| TableHeader, | |
| TableCell, | |
| Collaboration.configure({ document: ydoc, field: pageField(pageSlug) }), | |
| CollaborationCaret.configure({ | |
| provider, | |
| user: { name: state.me.username, color: colorFor(state.me.username) }, | |
| }), | |
| Highlights, | |
| ], | |
| // a link visitor reads: the socket refuses their writes, and the editor | |
| // must not invite them to make any | |
| editable: !state.viewer, | |
| editorProps: { | |
| handleClick: (view, pos, event) => { | |
| const a = event.target?.closest?.('a') | |
| const href = a?.getAttribute('href') | |
| if (!href) return false | |
| // ctrl/cmd+click follows it, and so does a tap: on touch there is no | |
| // modifier to hold. A plain click on a desktop places the caret (the | |
| // words of a link have to be editable) and raises the link bubble, | |
| // which is what gives a mouse an Open to click. | |
| const touch = window.matchMedia('(hover: none)').matches | |
| if (!(event.ctrlKey || event.metaKey || touch)) return false | |
| followLink(href) | |
| 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 uiToast('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 uiToast(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 = [] | |
| // Suggestion ranges come first because the comment highlight has to know | |
| // whether it is sharing text with one: two filled highlights over the same | |
| // words cancel each other out (see below). | |
| const suggestionRanges = [] | |
| for (const chain of suggestionChains()) { | |
| if (chain.display.status !== 'open' && chain.display.status !== 'superseded') continue | |
| const range = suggestionRange(pmState, chain.display) | |
| if (range) suggestionRanges.push(range) | |
| } | |
| const overlapsSuggestion = range => suggestionRanges.some(r => r.from < range.to && range.from < r.to) | |
| // What you are about to comment on. Focusing the composer moves focus out of | |
| // the editor, which drops the browser's own selection highlight — so without | |
| // this the text you selected is no longer marked anywhere while you type | |
| // about it. | |
| for (const [anchors, boxId] of [ | |
| [state.composerAnchors, 'composer'], | |
| [state.suggestAnchors, 'suggest-composer'], | |
| ]) { | |
| if (!anchors || document.getElementById(boxId)?.classList.contains('hidden') !== false) continue | |
| const range = anchorsToRange(pmState, ystate, anchors.start, anchors.end) | |
| if (!range) continue | |
| // A position anchor has no span to tint, so the paragraph it is beside | |
| // carries the marker instead — otherwise the open box says nothing about | |
| // where the comment is going to land. | |
| if (anchors.kind === 'position') { | |
| const deco = anchorBlockDeco(pmState, range.from, 'comment-anchor pending') | |
| if (deco) decos.push(deco) | |
| continue | |
| } | |
| if (range.from >= range.to) continue | |
| decos.push(Decoration.inline(range.from, range.to, { class: 'pending-hl' })) | |
| } | |
| const activeOrigin = activeOriginThreadId() | |
| 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) return | |
| const active = state.activeItem === id || (activeOrigin && ythread.get('originThreadId') === activeOrigin) | |
| // Position-anchored: nothing in the text is marked at rest — an underline | |
| // would claim the comment is about those words. Only the open card marks its | |
| // paragraph, so clicking a card still shows you where it points. | |
| if (ythread.get('anchorKind') === 'position') { | |
| if (!active) return | |
| const deco = anchorBlockDeco(pmState, range.from, 'comment-anchor active') | |
| if (deco) decos.push(deco) | |
| return | |
| } | |
| if (range.from >= range.to) return | |
| // A comment and a suggestion on the same words: the comment gives up its | |
| // fill and keeps only its underline, so the suggestion's own inserted and | |
| // deleted spans stay readable. Two tints stacked made the change invisible. | |
| const cls = | |
| 'comment-hl' + | |
| (active ? ' active' : '') + | |
| (overlapsSuggestion(range) ? ' with-sugg' : '') | |
| decos.push(Decoration.inline(range.from, range.to, { class: cls, 'data-thread': id })) | |
| // Inline underlines become hard to follow across a whole paragraph. When | |
| // its card is focused, add one block-height locator in the page gutter — | |
| // the same visual grammar as a suggestion, without obscuring its diff. | |
| if (active) { | |
| pmState.doc.forEach((node, offset) => { | |
| if (offset < range.to && range.from < offset + node.nodeSize) { | |
| decos.push(Decoration.node(offset, offset + node.nodeSize, { class: 'comment-range-active' })) | |
| } | |
| }) | |
| } | |
| }) | |
| // 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 related = activeOrigin && suggestionOriginThreadId(s) === activeOrigin | |
| const hlCls = 'suggestion-hl' + (state.activeItem === s.id || related ? ' 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], related) | |
| } catch {} | |
| } | |
| // Search hits, last so a mark inside commented or suggested text is the | |
| // innermost span and its fill is the one you see. A real <mark> rather than a | |
| // styled span: it is what the element is for, and it is what a screen reader | |
| // announces as marked text. | |
| if (sidebarFilter.length >= MIN_TEXT_QUERY) { | |
| for (const range of memoHitRanges(pmState.doc, sidebarFilter)) { | |
| decos.push(Decoration.inline(range.from, range.to, { nodeName: 'mark', class: 'search-hl' })) | |
| } | |
| } | |
| 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, related = false) { | |
| const A = state.activeItem === s.id || related ? ' 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 = pageFragment() | |
| 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 = pageFragment() | |
| const absS = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorStart), ydoc) | |
| const absE = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorEndIncl), ydoc) | |
| if (!absS || !absE || absS.type !== frag || absE.type !== frag) return null | |
| const si = Math.min(absS.index, absE.index) | |
| const ei = Math.max(absS.index, absE.index) | |
| let from = null | |
| let to = null | |
| pmState.doc.forEach((node, offset, i) => { | |
| if (i === si) from = offset | |
| if (i === ei) to = offset + node.nodeSize | |
| }) | |
| if (from == null || to == null || to <= from) return null | |
| return { from, to } | |
| } catch { | |
| return null | |
| } | |
| } | |
| const ystate = ySyncPluginKey.getState(pmState) | |
| if (!ystate?.binding) return null | |
| return anchorsToRange(pmState, ystate, s.anchorStart, s.anchorEnd) | |
| } | |
| function decodeRel(b64) { | |
| const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/')) | |
| const arr = new Uint8Array(bin.length) | |
| for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) | |
| return Y.decodeRelativePosition(arr) | |
| } | |
| function encodeRel(rel) { | |
| const bytes = Y.encodeRelativePosition(rel) | |
| let bin = '' | |
| for (const b of bytes) bin += String.fromCharCode(b) | |
| return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') | |
| } | |
| function poke(editor) { | |
| editor.view.dispatch(editor.view.state.tr.setMeta('collab-refresh', true)) | |
| } | |
| // Hide a composer box AND drop the highlight that marked what it was about. | |
| function hideComposerBox(id) { | |
| document.getElementById(id)?.classList.add('hidden') | |
| const editor = window.__editor | |
| if (editor && !editor.isDestroyed) poke(editor) | |
| } | |
| function setActive(id) { | |
| state.activeItem = id | |
| // revealing a specific item un-hides the comments panel | |
| if (id && document.getElementById('margin-col').classList.contains('hidden')) setMarginHidden(false) | |
| renderMargin(window.__editor) | |
| poke(window.__editor) | |
| // stacked cards (phone sheet / narrow window) are not positioned next to the | |
| // text, so the expanded one has to be scrolled to inside its own container | |
| if (id && isStackedMargin()) { | |
| requestAnimationFrame(() => document.querySelector('#margin-items .card.expanded')?.scrollIntoView({ block: 'nearest' })) | |
| } | |
| } | |
| // Composers are positioned a frame after opening. On desktop they share the | |
| // document's scroll container, so scrollIntoView() would move the selected text | |
| // just to reveal the box beside it. The selection is already visible: leave the | |
| // page alone. In stacked mode, reveal it by scrolling only the comments sheet. | |
| function settleComposerAfterLayout(node) { | |
| requestAnimationFrame(() => | |
| requestAnimationFrame(() => { | |
| try { | |
| const items = document.getElementById('margin-items') | |
| // Stacked composers are the first children of this scrolling list. | |
| // Reset that list directly; never ask the browser to scroll ancestors. | |
| if (isStackedMargin() && items.contains(node)) items.scrollTop = 0 | |
| } catch {} | |
| }) | |
| ) | |
| } | |
| function jumpTo(editor, range) { | |
| if (!range) return | |
| const pos = Math.min(range.from, editor.state.doc.content.size) | |
| editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)).scrollIntoView()) | |
| } | |
| // --- header toolbar ----------------------------------------------------------- | |
| // The toolbar label is the one bit of the UI that has to look like maths | |
| // without KaTeX being loaded yet. | |
| function mathToolLabel() { | |
| const span = el('span', { class: 'math-tool-label' }) | |
| span.textContent = '\u2211' | |
| return span | |
| } | |
| // An empty selection opens a display formula on its own line — that is what the | |
| // button is for. With text selected, the selection is treated as the source, so | |
| // pasted LaTeX can be promoted in place. | |
| function insertMath(editor) { | |
| const { from, to, empty } = editor.state.selection | |
| if (empty) { | |
| editor.chain().focus().insertMathBlock('').run() | |
| return | |
| } | |
| const latex = editor.state.doc.textBetween(from, to, ' ', leafText).trim().replace(/^\$+|\$+$/g, '') | |
| const block = /\n/.test(latex) || latex.length > 60 | |
| editor | |
| .chain() | |
| .focus() | |
| .deleteSelection() | |
| .insertContent(block ? { type: 'mathBlock', attrs: { latex } } : { type: 'mathInline', attrs: { latex } }) | |
| .run() | |
| } | |
| // --- reader text size ------------------------------------------------------- | |
| // The other half of the toolbar's size pair, and the exact complement of zoom: | |
| // zoom magnifies the page whole and the line breaks are frozen, this re-sets the | |
| // type inside a page whose width never moves, so the lines re-break and the | |
| // words-per-line changes. That is a Kindle's A/A, and it is why the two cannot | |
| // be folded into one control however alike they look. | |
| // | |
| // What is stored is the factor, not the resulting size — see the | |
| // --doc-text-scale comment in app.css for why an absolute size loses to the | |
| // document style switch. Module scope rather than a local in buildHeaderTools | |
| // because the toolbar is rebuilt on every page switch and the choice must not | |
| // be: the label is re-hung each time, the value survives. | |
| const TEXT_SCALE_MIN = 0.8 | |
| const TEXT_SCALE_MAX = 1.6 | |
| let textScale = clampTextScale(Number(localStorage.getItem('doc-text-scale')) || 1) | |
| let docSizeLabel = null | |
| function clampTextScale(v) { | |
| // 0.8 and 1.6 are where the 624px column gives out at either end, measured on | |
| // a desktop at 100% zoom. At 0.8 Docs is 11.7px of Arial running 96 characters | |
| // a line — smaller and it is no longer a reading size, wider and it is no | |
| // longer a line. At 1.6 Reading is 25.6px running 42, which is a newspaper | |
| // column's measure with a book's words in it: any further and the rag is | |
| // doing more work than the text. Steps of 0.1, like zoom's, so the two | |
| // controls feel like one pair. | |
| // | |
| // A phone is a known gap in that reasoning, not a case it covers. The 8.5in | |
| // page only fits a handset at around zoom's 40% floor, and 1.6 on top of that | |
| // is 9.4px on the glass — the ceiling is generous in document units and | |
| // stingy in the only units the reader's eye has. Raising it is not the fix | |
| // either: it is the fixed page width the phone is really fighting, so this | |
| // stays measured against the desktop column until that changes. | |
| return Math.round(Math.max(TEXT_SCALE_MIN, Math.min(TEXT_SCALE_MAX, v)) * 100) / 100 | |
| } | |
| // The label reports the size the DOCUMENT is set in, not the size the screen is | |
| // showing: zoom is deliberately not folded in, the same way Docs' font-size box | |
| // keeps saying 11 at 200%. The base is read back out of the stylesheet rather | |
| // than kept here because it belongs to the document style — it is 14.6667px | |
| // under Docs and 16px under Reading, and it changes under us on a style switch. | |
| // | |
| // That analogy is not free, and this is the part of it that costs. Docs' box | |
| // says "11" — a unitless point size, a fact about the document. Ours says | |
| // "15px", and px reads as a promise about the screen that zoom can break: on a | |
| // phone at zoom's 40% floor with the text at 160%, the glass shows 9.4px while | |
| // this reads "23px". Docs' own unit (11pt, which is also what this app's PDF | |
| // export is set in) would have been immune to that, and was passed over for a | |
| // smaller reason: px next to zoom's % is what makes the two readouts legible as | |
| // different questions at a glance, and pt in a browser UI is a unit almost | |
| // nobody has a feel for. Two units for one document is the price. | |
| function paintDocSize() { | |
| if (!docSizeLabel) return | |
| const base = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-size')) || 16 | |
| docSizeLabel.textContent = `${Math.round(base * textScale)}px` | |
| } | |
| function applyTextSize(editor, step = 0) { | |
| textScale = clampTextScale(textScale + step) | |
| document.documentElement.style.setProperty('--doc-text-scale', textScale) | |
| localStorage.setItem('doc-text-scale', String(textScale)) | |
| paintDocSize() | |
| // the readout is the one part of the toolbar whose width changes as you use | |
| // it ("9px" against "26px"), which is enough to tip a header that was just | |
| // fitting — cheap to re-ask, and wrong exactly once if we don't | |
| fitHeaderTools() | |
| // no fitLayout(): the sheet is exactly as wide as it was, so the columns are | |
| // already fitted. Every line moved, though, and the comment cards are pinned | |
| // to lines — those do have to be laid out again. | |
| scheduleLayout(editor) | |
| } | |
| function buildHeaderTools(editor) { | |
| const bar = document.getElementById('header-tools') | |
| bar.replaceChildren() // rebuilt per page switch | |
| const sep = () => el('span', { class: 'sep' }) | |
| const btn = (label, title, run, isOn) => { | |
| const b = el('button', { type: 'button', title }) | |
| if (typeof label === 'string') b.textContent = label | |
| else b.appendChild(label) | |
| b.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| run() | |
| }) | |
| if (isOn) editor.on('transaction', () => b.classList.toggle('on', isOn())) | |
| return b | |
| } | |
| const boldB = btn('B', 'Bold', () => editor.chain().focus().toggleBold().run(), () => editor.isActive('bold')) | |
| boldB.style.fontWeight = '700' | |
| const italicB = btn('I', 'Italic', () => editor.chain().focus().toggleItalic().run(), () => editor.isActive('italic')) | |
| italicB.style.fontStyle = 'italic' | |
| italicB.style.fontFamily = 'var(--doc)' | |
| const strikeB = btn('S', 'Strikethrough', () => editor.chain().focus().toggleStrike().run(), () => editor.isActive('strike')) | |
| strikeB.style.textDecoration = 'line-through' | |
| bar.append( | |
| btn('H1', 'Heading 1', () => editor.chain().focus().toggleHeading({ level: 1 }).run(), () => editor.isActive('heading', { level: 1 })), | |
| btn('H2', 'Heading 2', () => editor.chain().focus().toggleHeading({ level: 2 }).run(), () => editor.isActive('heading', { level: 2 })), | |
| btn('H3', 'Heading 3', () => editor.chain().focus().toggleHeading({ level: 3 }).run(), () => editor.isActive('heading', { level: 3 })), | |
| sep(), | |
| boldB, | |
| italicB, | |
| strikeB, | |
| btn('</>', 'Inline code', () => editor.chain().focus().toggleCode().run(), () => editor.isActive('code')), | |
| sep(), | |
| btn('• List', 'Bullet list', () => editor.chain().focus().toggleBulletList().run(), () => editor.isActive('bulletList')), | |
| btn('1. List', 'Ordered list', () => editor.chain().focus().toggleOrderedList().run(), () => editor.isActive('orderedList')), | |
| btn(icon('quote'), 'Blockquote', () => editor.chain().focus().toggleBlockquote().run(), () => editor.isActive('blockquote')), | |
| btn('{ }', 'Code block', () => editor.chain().focus().toggleCodeBlock().run(), () => editor.isActive('codeBlock')), | |
| btn(icon('todo'), 'Checklist', () => editor.chain().focus().toggleTaskList().run(), () => editor.isActive('taskList')), | |
| btn(icon('table'), 'Insert table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), | |
| btn('—', 'Divider', () => editor.chain().focus().setHorizontalRule().run()), | |
| btn(mathToolLabel(), 'Math — inline $x^2$, or a display formula on its own line', () => insertMath(editor), () => | |
| editor.isActive('mathInline') || editor.isActive('mathBlock') | |
| ), | |
| btn('HTML', 'Embed live HTML (sandboxed)', () => | |
| editHtmlOverlay('', html => { | |
| if (html.trim()) editor.chain().focus().insertContent({ type: 'htmlBlock', attrs: { html } }).run() | |
| }) | |
| ), | |
| sep(), | |
| btn(icon('link'), 'Link', () => toggleLink(editor), () => editor.isActive('link')) | |
| ) | |
| // undo / redo (from the Collaboration extension's shared-editing undo manager) | |
| const undoB = btn(icon('undo'), 'Undo', () => runUndo(editor)) | |
| undoB.id = 'undo-btn' | |
| const redoB = btn(icon('redo'), 'Redo', () => runRedo(editor)) | |
| redoB.id = 'redo-btn' | |
| bar.append(sep(), undoB, redoB) | |
| // zoom | |
| let zoom = Number(localStorage.getItem('doc-zoom')) || 1 | |
| const zoomLabel = el('span', { id: 'zoom-label' }) | |
| const applyZoom = () => { | |
| // Two floors, because zoom means two different things either side of the | |
| // reflow width. Where the page is a page, 0.4 is how you fit 8.5in into a | |
| // narrow window — that is what the floor was dropped from 0.8 for. Where the | |
| // sheet is fluid (see the ≤840px block in app.css) fitting is already done | |
| // and --doc-zoom only reaches the type, so 0.4 would be 6.4px of body text | |
| // and nothing gained: 0.8 is the smallest step that is still reading size. | |
| // This also lifts a 0.5 left in localStorage by someone who once tried to | |
| // squeeze the page into their phone: applyZoom runs on every mount, so the | |
| // first paint after the fix corrects it. | |
| // It re-clamps on a crossing too. The first version of this read the floor | |
| // only when the zoom was applied, on the argument that a live clamp would | |
| // move a reader's setting under them mid-drag. That is a fair description of | |
| // a desktop window drag and a wrong one for the case that actually happens: | |
| // on a handset you cross 840 by ROTATING THE DEVICE, where nobody is | |
| // mid-anything, and the un-clamped result was 6.4px of body text — the | |
| // unreadable state this whole change exists to remove — held until the next | |
| // reload. A drag pays a one-step correction at the boundary; a rotation | |
| // stops being unreadable. See the resize listener near applyBreakpoint. | |
| const floor = isReflowColumn() ? 0.8 : 0.4 | |
| zoom = Math.round(Math.max(floor, Math.min(1.6, zoom)) * 100) / 100 | |
| document.documentElement.style.setProperty('--doc-zoom', zoom) | |
| zoomLabel.textContent = `${Math.round(zoom * 100)}%` | |
| localStorage.setItem('doc-zoom', String(zoom)) | |
| // zooming changes how much room the page takes, so the columns are re-fitted | |
| fitLayout() | |
| scheduleLayout(editor) | |
| } | |
| const zoomOutB = btn(icon('zoomOut'), 'Zoom out', () => { | |
| zoom -= 0.1 | |
| applyZoom() | |
| }) | |
| zoomOutB.id = 'zoom-out' | |
| const zoomInB = btn(icon('zoomIn'), 'Zoom in', () => { | |
| zoom += 0.1 | |
| applyZoom() | |
| }) | |
| zoomInB.id = 'zoom-in' | |
| bar.append(sep(), zoomOutB, zoomLabel, zoomInB) | |
| applyZoom() | |
| // re-apply on a crossing so the floor for the width you are now at is the one | |
| // in force; applyZoom is idempotent, so this costs nothing when already legal | |
| reclampZoomFloor = applyZoom | |
| // reader text size — same shape as zoom (a step down, a readout, a step up) | |
| // so the pair reads as a pair, but a small A and a large A instead of two | |
| // magnifiers, and px instead of %, so nobody has to click one to find out | |
| // which is which. The tooltips say the part the glyphs cannot. | |
| // | |
| // Both buttons need an aria-label, and zoom's pattern does not carry over to | |
| // them: an accessible name comes from the element's text before its title, so | |
| // zoom's icon-only buttons fall through to their titles while these two — an | |
| // "A" each — would both announce as "A, button" and differ in nothing. The | |
| // readout is a live region for the same reason: pressing either button | |
| // changes the whole page, and none of that change is announced otherwise. | |
| docSizeLabel = el('span', { id: 'docsize-label', 'aria-live': 'polite' }) | |
| const smallerB = btn('A', 'Smaller text — the page keeps its width, so the lines re-break', () => { | |
| applyTextSize(editor, -0.1) | |
| }) | |
| smallerB.id = 'text-smaller' | |
| smallerB.setAttribute('aria-label', 'Smaller text') | |
| const largerB = btn('A', 'Larger text — the page keeps its width, so the lines re-break', () => { | |
| applyTextSize(editor, 0.1) | |
| }) | |
| largerB.id = 'text-larger' | |
| largerB.setAttribute('aria-label', 'Larger text') | |
| bar.append(sep(), smallerB, docSizeLabel, largerB) | |
| applyTextSize(editor) | |
| // image upload | |
| const imgInput = el('input', { type: 'file', accept: 'image/*', style: 'display:none' }) | |
| imgInput.addEventListener('change', () => { | |
| if (imgInput.files[0]) uploadAndInsertImage(imgInput.files[0]) | |
| imgInput.value = '' | |
| }) | |
| const imgBtn = btn(icon('image'), 'Insert image', () => imgInput.click()) | |
| imgBtn.id = 'image-btn' | |
| bar.append(imgBtn, imgInput) | |
| // the bar was just rebuilt from nothing, so whether it still fits is an open | |
| // question — ask it before the frame paints rather than at the next resize | |
| fitHeaderTools() | |
| wireLinkPop(editor) | |
| } | |
| function toggleLink(editor) { | |
| if (editor.isActive('link')) { | |
| editor.chain().focus().extendMarkRange('link').unsetLink().run() | |
| return | |
| } | |
| if (editor.state.selection.empty) return | |
| const pop = document.getElementById('link-pop') | |
| const tools = document.getElementById('header-tools').getBoundingClientRect() | |
| pop.style.left = `${Math.max(12, tools.right - 320)}px` | |
| pop.style.top = `${tools.bottom + 8}px` | |
| pop.classList.remove('hidden') | |
| document.getElementById('link-url').focus() | |
| } | |
| function wireLinkPop(editor) { | |
| const pop = document.getElementById('link-pop') | |
| const input = document.getElementById('link-url') | |
| const apply = () => { | |
| let href = input.value.trim() | |
| if (href && !/^https?:\/\//.test(href)) href = 'https://' + href | |
| if (href) editor.chain().focus().extendMarkRange('link').setLink({ href }).run() | |
| input.value = '' | |
| pop.classList.add('hidden') | |
| } | |
| document.getElementById('link-apply').addEventListener('click', apply) | |
| input.addEventListener('keydown', e => { | |
| if (e.key === 'Enter') apply() | |
| if (e.key === 'Escape') pop.classList.add('hidden') | |
| }) | |
| document.addEventListener('mousedown', e => { | |
| if (!e.target.closest('#link-pop') && !pop.classList.contains('hidden') && !e.target.closest('#header-tools')) { | |
| pop.classList.add('hidden') | |
| } | |
| }) | |
| } | |
| // --- editing / suggesting mode -------------------------------------------------- | |
| // Docs keeps this at the top of the window instead of on the selection: in | |
| // Editing mode keystrokes land in the document; in Suggesting mode the document | |
| // is read-only and selecting text opens a proposal for those blocks. | |
| // | |
| // The switch is HIDDEN for now: proposing a change should mean typing in the | |
| // document itself and having the edit captured, not filling in a markdown box. | |
| // Until that exists, the mode is not worth showing — the flow underneath still | |
| // works (agents use it, and the phone flow could) so flipping this back on is | |
| // all it takes. Humans comment; agents propose. | |
| const SUGGEST_MODE_UI = false | |
| let lockedOut = false // the build gate made this tab read-only — mode must not undo that | |
| function buildModeSwitch(editor) { | |
| const wrap = document.getElementById('mode-switch') | |
| wrap.replaceChildren() | |
| if (!SUGGEST_MODE_UI) { | |
| // force Editing, so a mode left over in localStorage cannot strand someone | |
| // in a read-only document with no visible way back | |
| wrap.classList.add('hidden') | |
| setMode('editing', editor) | |
| return | |
| } | |
| wrap.classList.remove('hidden') | |
| const tab = (mode, label, tip) => { | |
| const b = el('button', { type: 'button', title: tip, 'data-mode': mode }, label) | |
| b.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| setMode(mode, editor) | |
| }) | |
| return b | |
| } | |
| wrap.append( | |
| tab('editing', 'Editing', 'Type straight into the document'), | |
| tab('suggesting', 'Suggesting', 'Propose changes — select text and a suggestion opens in the margin') | |
| ) | |
| setMode(localStorage.getItem(`cw-mode:${docId}`) === 'suggesting' ? 'suggesting' : 'editing', editor) | |
| } | |
| function setMode(mode, editor) { | |
| state.mode = mode === 'suggesting' ? 'suggesting' : 'editing' | |
| localStorage.setItem(`cw-mode:${docId}`, state.mode) | |
| for (const b of document.querySelectorAll('#mode-switch button')) { | |
| b.classList.toggle('on', b.dataset.mode === state.mode) | |
| } | |
| document.body.classList.toggle('suggesting', state.mode === 'suggesting') | |
| if (!lockedOut && !state.viewer) { | |
| try { | |
| editor?.setEditable(state.mode === 'editing') | |
| } catch {} | |
| } | |
| closeAutoComposer() | |
| } | |
| // --- selection menu ------------------------------------------------------------- | |
| function wireSelectionMenu() { | |
| const commentBtn = document.getElementById('comment-btn') | |
| const suggestBtn = document.getElementById('suggest-btn') | |
| commentBtn.append(icon('comment'), document.createTextNode('Comment')) | |
| suggestBtn.append(icon('edit'), document.createTextNode('Suggest')) | |
| if (!SUGGEST_MODE_UI) suggestBtn.classList.add('hidden') | |
| commentBtn.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| try { | |
| openComposer(window.__editor) | |
| } catch (err) { | |
| uiAlert('Could not open the comment box: ' + err.message) | |
| } | |
| }) | |
| suggestBtn.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| openSuggestComposer(window.__editor).catch(err => uiAlert('Could not open suggest mode: ' + err.message)) | |
| }) | |
| } | |
| function bindSelectionMenu(editor) { | |
| const menu = document.getElementById('selection-menu') | |
| let settle = null | |
| editor.on('selectionUpdate', () => { | |
| const { from, to, empty } = editor.state.selection | |
| clearTimeout(settle) | |
| // A selection owns the comment flow while it lasts. Dropped here as well as | |
| // on hover, because a selection made with the keyboard never moves the mouse | |
| // and would otherwise leave the button standing next to it. | |
| if (!empty) hideHoverComment() | |
| if (empty || to - from < 1) { | |
| menu.classList.add('hidden') | |
| // A blur is not a dismissal. Selecting an atom (a formula, an image) makes | |
| // a NodeSelection, and that collapses when the editor loses focus — so | |
| // clicking into the comment box closed the very box you just clicked. | |
| // Only a collapse the user made *in the document* dismisses it. | |
| if (editor.isFocused && !document.activeElement?.closest('.composer-box')) closeAutoComposer() | |
| return | |
| } | |
| // Desktop has a margin to put the box in, so the selection opens the real | |
| // composer there and no floating widget is needed. A phone has no margin | |
| // (the panel is a bottom sheet that would cover the selection), so there the | |
| // little Comment / Suggest bubble stays as the way in. | |
| if (!isStackedMargin()) { | |
| menu.classList.add('hidden') | |
| // wait for the drag/shift-arrow run to settle before popping a box open | |
| settle = setTimeout(() => isLive(editor) && openForSelection(editor), 220) | |
| return | |
| } | |
| const coords = editor.view.coordsAtPos(to) | |
| const wrap = document.getElementById('editor-col').getBoundingClientRect() | |
| menu.classList.remove('hidden') | |
| // measure instead of assuming a width — the buttons are bigger on phones, | |
| // where a hardcoded guess pushed the menu off the right edge | |
| const w = menu.offsetWidth || 230 | |
| menu.style.left = `${Math.max(0, Math.min(coords.left - wrap.left, wrap.width - w))}px` | |
| menu.style.top = `${coords.bottom - wrap.top + 8}px` | |
| }) | |
| } | |
| // A settled selection opens the box the current mode calls for, in the margin | |
| // next to the text. Failures here are not worth an alert on every selection — | |
| // they just mean no box this time. | |
| function openForSelection(editor) { | |
| if (state.previewPid || state.composerDirty) return | |
| if (state.mode === 'suggesting') { | |
| openSuggestComposer(editor, { focus: false, auto: true }).catch(() => {}) | |
| return | |
| } | |
| try { | |
| openComposer(editor, { focus: false, auto: true }) | |
| } catch {} | |
| } | |
| // Close a box the selection opened, unless the user has started writing in it. | |
| function closeAutoComposer() { | |
| if (!state.autoComposer || state.composerDirty) return | |
| const isSuggest = state.autoComposer === 'suggest' | |
| hideComposerBox(isSuggest ? 'suggest-composer' : 'composer') | |
| if (!isSuggest) document.getElementById('composer-text').value = '' | |
| state.autoComposer = null | |
| state.suggestRange = null | |
| renderMargin(window.__editor) | |
| } | |
| // ProseMirror's textBetween skips leaf nodes unless told what they say. Without | |
| // this a comment on a formula quoted an empty string — the atom holds no text. | |
| function leafText(node) { | |
| if (node.type.name === 'mathInline' || node.type.name === 'mathBlock') return `$${node.attrs.latex || ''}$` | |
| if (node.type.name === 'image') return '[figure]' | |
| return '' | |
| } | |
| // Handing work to your own agent is the common case for a comment, so the box | |
| // opens with your first agent already mentioned. | |
| // Which agent the box comes pre-mentioned with. Connected first: an agent that | |
| // is polling picks the work up now, where an offline one sits on the queue until | |
| // it comes back. Yours wins the tie, and an offline agent still beats none — | |
| // the mention is delivered whenever it next polls. | |
| function defaultAgentHandle() { | |
| const mine = a => a.owner === state.me?.username | |
| const rank = a => (a.online ? 0 : 2) + (mine(a) ? 0 : 1) | |
| return [...(state.agents || [])].sort((a, b) => rank(a) - rank(b))[0]?.handle || null | |
| } | |
| // --- comment composer ------------------------------------------------------------- | |
| function openComposer(editor, opts = {}) { | |
| state.composerAnchors = opts.anchors || selectionAnchors(editor) | |
| // A position-anchored comment quotes nothing, so the blockquote would be an | |
| // empty bar. It gets the locator line instead — see anchorLocText for why the | |
| // paragraph's opening words and not a paragraph number. | |
| const positional = state.composerAnchors.kind === 'position' | |
| const quote = document.getElementById('composer-quote') | |
| const loc = document.getElementById('composer-loc') | |
| quote.classList.toggle('hidden', positional) | |
| loc.classList.toggle('hidden', !positional) | |
| if (positional) loc.replaceChildren(icon('quote'), el('span', { class: 'words' }, anchorLocText(state.composerAnchors.context))) | |
| else quote.textContent = state.composerAnchors.excerpt | |
| hideComposerBox('suggest-composer') | |
| document.getElementById('composer').classList.remove('hidden') | |
| document.getElementById('selection-menu').classList.add('hidden') | |
| const text = document.getElementById('composer-text') | |
| if (!state.composerDirty) { | |
| const handle = defaultAgentHandle() | |
| text.value = handle ? `@${handle} ` : '' | |
| text.setSelectionRange(text.value.length, text.value.length) | |
| // presence is only fetched on load, so who is connected may be minutes out | |
| // of date by the time you comment. Re-check in the background and correct | |
| // the mention — but only while the box is still exactly as we left it, so | |
| // this can never overwrite something you typed or picked yourself. | |
| const prefilled = text.value | |
| refreshAgents().then(() => { | |
| if (state.composerDirty || text.value !== prefilled) return | |
| if (document.getElementById('composer').classList.contains('hidden')) return | |
| const fresh = defaultAgentHandle() | |
| const next = fresh ? `@${fresh} ` : '' | |
| if (next === text.value) return | |
| text.value = next | |
| if (document.activeElement === text) text.setSelectionRange(next.length, next.length) | |
| }) | |
| } | |
| state.autoComposer = opts.auto ? 'comment' : null | |
| poke(editor) // mark the pending range now that composerAnchors is set | |
| setMarginHidden(false, false) // the composer lives in the comments panel | |
| renderMargin(editor) | |
| // an auto-opened box must not steal the keyboard: selecting text and typing | |
| // over it is ordinary editing, so the caret stays in the document | |
| if (opts.focus !== false) text.focus({ preventScroll: true }) | |
| settleComposerAfterLayout(document.getElementById('composer')) | |
| } | |
| // What a comment on the current selection is attached to: the two ends of the | |
| // selection as Yjs relative positions, plus the words themselves to quote. | |
| function selectionAnchors(editor) { | |
| const { from, to } = editor.state.selection | |
| const ystate = ySyncPluginKey.getState(editor.state) | |
| return { | |
| start: encodeRel(absolutePositionToRelativePosition(from, ystate.type, ystate.binding.mapping)), | |
| end: encodeRel(absolutePositionToRelativePosition(to, ystate.type, ystate.binding.mapping)), | |
| excerpt: editor.state.doc.textBetween(from, to, ' ', leafText).slice(0, 200), | |
| } | |
| } | |
| // --- position anchors: a comment beside a paragraph, on no words at all --------- | |
| // | |
| // What a position-anchored comment stores, and why: | |
| // | |
| // ONE Yjs relative position, at the first position INSIDE the paragraph (or | |
| // heading, list item, cell, code block) the reader was beside — written into | |
| // both anchor fields so every path that already resolves a thread's anchors | |
| // (itemRange, layoutMargin, rangeFromCard) keeps working unchanged and simply | |
| // sees an empty range. A character offset would be wrong under concurrent | |
| // editing: someone typing higher up the page would slide the comment down the | |
| // document. This is the same machinery the span anchors of a normal comment use | |
| // — deliberately, so there is one way that comment positions survive edits. | |
| // | |
| // It is INSIDE the paragraph rather than at the fragment index of the block, | |
| // which is what a suggestion's anchors use. A suggestion needs to name a block it | |
| // will replace; a comment only needs to name a place, and a place inside the | |
| // paragraph rides along with the paragraph when blocks are inserted, split or | |
| // merged around it. | |
| // | |
| // What this does NOT give you is any signal about the BLOCK. Measured on this | |
| // branch, a position anchor whose paragraph stops existing resolves to a bare | |
| // boundary between blocks, and it resolves there for two very different reasons: | |
| // | |
| // delete the paragraph -> boundary where it used to be, words gone | |
| // Backspace at its start -> boundary after the paragraph it merged into, | |
| // every word still on the page | |
| // | |
| // Position alone cannot separate those, and there is no element identity to fall | |
| // back on either: y-prosemirror's updateYFragment can UPDATE a surviving sibling | |
| // element in place rather than delete one and build another (the reuse | |
| // stableAfterIndex already works around), so "is the element I anchored into | |
| // still alive" is not a question the CRDT answers usefully. threadAnchorState | |
| // therefore asks the text instead — see the comment there. | |
| function positionAnchors(editor, pos) { | |
| const ystate = ySyncPluginKey.getState(editor.state) | |
| if (!ystate?.binding) return null | |
| const $pos = editor.state.doc.resolve(pos) | |
| if (!$pos.parent.isTextblock) return null | |
| const rel = encodeRel(absolutePositionToRelativePosition(pos, ystate.type, ystate.binding.mapping)) | |
| return { | |
| start: rel, | |
| end: rel, | |
| kind: 'position', | |
| // Kept at creation time so the card and any @mentioned agent can say WHERE | |
| // the comment is even after the paragraph has been rewritten around it. It is | |
| // also what tells a merge from a deletion later, so it has to be built the | |
| // same way the text it will be compared against is — hence blockText. | |
| context: blockText($pos.parent).slice(0, 140), | |
| } | |
| } | |
| // The text of one block, the way anchorContext records it. Shared so that the | |
| // remembered words and the words we later look for are produced by the same call | |
| // — inline atoms included, since leafText gives a formula or a figure a stable | |
| // stand-in ("$E = mc^2$", "[figure]") where textContent gives nothing. | |
| function blockText(node) { | |
| return node.textBetween(0, node.content.size, ' ', leafText).trim() | |
| } | |
| // What the sidebar (and the open composer) shows in place of a quote. The | |
| // paragraph's opening words, not a line or paragraph NUMBER: a number is | |
| // meaningless to someone scanning a card, and it is also the least stable thing | |
| // on the page — every paragraph typed above it renumbers every comment below. | |
| // The opening words identify the place, stay recognisable while the paragraph is | |
| // edited, and read as a location rather than as something the author quoted. | |
| function anchorLocText(context) { | |
| const words = (context || '').trim() | |
| if (!words) return 'an empty paragraph' | |
| return words.length > 72 ? words.slice(0, 72).replace(/\s+\S*$/, '') + '…' : words | |
| } | |
| // Where a thread points, now. For a position-anchored thread `orphaned` has to | |
| // mean "the text this was left beside is gone", and that is NOT the same question | |
| // as "does the anchor still land inside a block" — which is where the first cut of | |
| // this got it wrong. Joining two paragraphs (Backspace at the start of the | |
| // anchored one, the most ordinary way a paragraph stops existing) leaves the | |
| // anchor on a bare boundary while every word survives one line up, and the card | |
| // then claimed the paragraph had been deleted while pointing straight at it. | |
| // | |
| // So when the anchor is not inside a block, ask the text rather than the | |
| // position: a merge leaves the remembered words in a block touching that | |
| // boundary, a deletion does not. What each case looks like, measured: | |
| // | |
| // Backspace-merge nodeBefore = "P4 delta here.P5 epsilon is a long..." -> live | |
| // delete mid-page nodeBefore = "P4 delta here.", nodeAfter = "P6 ..." -> orphan | |
| // delete last block nodeBefore = "P4 delta here.", nodeAfter = null -> orphan | |
| // | |
| // (Merging FORWARD — Delete at the end of the anchored paragraph — needs none of | |
| // this: the anchor is still inside the merged block, so it never reaches here.) | |
| // | |
| // Comparing text is the wrong tool for deciding whether a LIVE block is still the | |
| // one you meant — ordinary editing would false-positive, which is exactly why the | |
| // anchor is positional. It is the right tool here because it only ever runs once | |
| // the position has already given up, and the two ways it can be wrong are mild: | |
| // words edited away before a merge read as orphaned (what this did in every merge | |
| // before), and a short remembered phrase that happens to appear next to the gap | |
| // reads as live (a missing strikethrough, never a false claim of deletion). | |
| // | |
| // Either way the thread is kept and never closed on anyone's behalf — deleting | |
| // text must not end a conversation about it — and the position is still the place | |
| // the paragraph occupied, so the card holds its row in the panel instead of | |
| // dropping to the bottom. | |
| function threadAnchorState(editor, ythread) { | |
| const range = itemRange(editor, { kind: 'thread', ythread }) | |
| if (ythread.get('anchorKind') !== 'position') { | |
| return { range, positional: false, orphaned: !range || range.from >= range.to } | |
| } | |
| if (!range) return { range: null, positional: true, orphaned: true } | |
| if (insideTextblock(editor.state, range.from)) return { range, positional: true, orphaned: false } | |
| return { range, positional: true, orphaned: !contextSurvivesBeside(editor.state, range.from, ythread.get('anchorContext')) } | |
| } | |
| // Whitespace is not part of the identity of a place: a merge can put a newline | |
| // where a space was, and textBetween's block separator is not the one that built | |
| // the context. | |
| const squash = s => (s || '').replace(/\s+/g, ' ').trim() | |
| // Are the words we remembered still in a block touching this boundary? Only the | |
| // two immediate neighbours are considered: a merge puts them right there, and | |
| // searching further would start finding coincidences. | |
| function contextSurvivesBeside(pmState, pos, context) { | |
| const words = squash(context) | |
| // Nothing was remembered — a comment left beside an empty paragraph. There is | |
| // no text to find, and an empty paragraph that merged away is as gone as one | |
| // that was deleted, so the position's own verdict stands. | |
| if (!words) return false | |
| if (pos < 0 || pos > pmState.doc.content.size) return false | |
| const $pos = pmState.doc.resolve(pos) | |
| return [$pos.nodeBefore, $pos.nodeAfter].some(n => n && squash(blockText(n)).includes(words)) | |
| } | |
| function insideTextblock(pmState, pos) { | |
| if (pos < 0 || pos > pmState.doc.content.size) return false | |
| return pmState.doc.resolve(pos).parent.isTextblock | |
| } | |
| // A whole-block decoration for the paragraph a position anchor sits in. Used for | |
| // the open composer's pending marker and for the active thread; a position | |
| // comment marks nothing at all the rest of the time, which is the point. | |
| function anchorBlockDeco(pmState, pos, cls) { | |
| if (!insideTextblock(pmState, pos)) return null | |
| const $pos = pmState.doc.resolve(pos) | |
| if ($pos.depth < 1) return null | |
| return Decoration.node($pos.before($pos.depth), $pos.after($pos.depth), { class: cls }) | |
| } | |
| // --- the hover affordance ------------------------------------------------------ | |
| // | |
| // Hover anywhere over the right third of the page and a comment button appears | |
| // out in the right margin, level with the paragraph you are beside; click it and | |
| // the composer opens on a comment anchored to that place rather than to a span. | |
| // | |
| // Three things this deliberately does NOT do: | |
| // | |
| // - It adds no gutter. The button is absolutely positioned inside #editor-col | |
| // (like #selection-menu) and lives in the page's own 1in right margin, so | |
| // arming the zone never moves a single line of text. In the squeezed layout | |
| // where the comments panel overlaps the page (--margin-applied-overlap) a | |
| // card can sit over it; it is drawn above them so it stays clickable. | |
| // - It never shows while a selection is live, or while either composer is | |
| // open. The selection flow is the way to comment ON words and this must not | |
| // race it — a drag that ends in the right third would otherwise pop both. | |
| // - It does not exist on touch. There is no hover there to reveal it with, and | |
| // a permanently visible button in the margin is exactly the gutter we are | |
| // avoiding. Selecting text and using the Comment bubble is the whole flow on | |
| // a phone, and it already works. Hidden in CSS under (hover: none) as well | |
| // as never being armed, so a hybrid device cannot leave one behind. | |
| const HOVER_ZONE = 2 / 3 // fraction of the sheet's width you have to be past | |
| let hoverBlockPos = null // anchor position the button currently stands for | |
| function wireHoverComment() { | |
| const btn = document.getElementById('hover-comment') | |
| btn.appendChild(icon('commentadd')) | |
| let raf = null | |
| const onMove = e => { | |
| if (raf) return | |
| raf = requestAnimationFrame(() => { | |
| raf = null | |
| updateHoverComment(e.clientX, e.clientY) | |
| }) | |
| } | |
| const col = document.getElementById('editor-col') | |
| col.addEventListener('mousemove', onMove) | |
| col.addEventListener('mouseleave', () => hideHoverComment()) | |
| // Scrolling separates the button from the pointer that armed it. The button is | |
| // absolutely positioned in #editor-col, so it travels WITH the text and stays | |
| // beside its own paragraph — a wheel scroll of 400px measured the button and | |
| // its anchor still agreeing on paragraph 20 while the pointer had arrived at | |
| // paragraph 44. Self-consistent, but it is now a button offering to comment on | |
| // something nowhere near the cursor, possibly scrolled up under the header. The | |
| // pointer has not moved, so there are no fresh coordinates to re-aim with: | |
| // drop it and let the next mousemove arm it again. Capture, because scroll does | |
| // not bubble and the vertical scroller here is the window while #editor-col | |
| // scrolls horizontally — one listener has to see both. | |
| window.addEventListener('scroll', () => hideHoverComment(), { passive: true, capture: true }) | |
| // mousedown, not click: the same reason the selection menu uses it — the | |
| // default action would move focus out of the editor before we have read it. | |
| btn.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| const editor = window.__editor | |
| if (!isLive(editor) || hoverBlockPos == null) return | |
| const anchors = positionAnchors(editor, hoverBlockPos) | |
| if (!anchors) return | |
| hideHoverComment() | |
| try { | |
| openComposer(editor, { anchors }) | |
| } catch (err) { | |
| uiAlert('Could not open the comment box: ' + err.message) | |
| } | |
| }) | |
| } | |
| function hideHoverComment() { | |
| hoverBlockPos = null | |
| document.getElementById('hover-comment')?.classList.add('hidden') | |
| } | |
| function updateHoverComment(clientX, clientY) { | |
| const btn = document.getElementById('hover-comment') | |
| const editor = window.__editor | |
| const boxOpen = id => document.getElementById(id)?.classList.contains('hidden') === false | |
| if ( | |
| !isLive(editor) || | |
| state.previewPid || | |
| isStackedMargin() || | |
| window.matchMedia('(hover: none)').matches || | |
| !editor.state.selection.empty || | |
| boxOpen('composer') || | |
| boxOpen('suggest-composer') | |
| ) { | |
| return hideHoverComment() | |
| } | |
| const sheet = document.getElementById('editor') | |
| const textCol = sheet.querySelector('.tiptap') | |
| if (!textCol) return hideHoverComment() | |
| const sheetRect = sheet.getBoundingClientRect() | |
| const textRect = textCol.getBoundingClientRect() | |
| if (clientX < sheetRect.left + sheetRect.width * HOVER_ZONE || clientX > sheetRect.right) return hideHoverComment() | |
| // Vertically the zone is the text, not the sheet: a short page leaves inches | |
| // of blank paper below the last line, and there is no paragraph to be beside | |
| // out there. The 1in top and bottom margins are blank for the same reason. | |
| if (clientY < textRect.top || clientY > textRect.bottom) return hideHoverComment() | |
| const hit = textblockAtHeight(editor, clientY) | |
| if (!hit) return hideHoverComment() | |
| hoverBlockPos = hit.pos + 1 | |
| const colRect = document.getElementById('editor-col').getBoundingClientRect() | |
| const scrollLeft = document.getElementById('editor-col').scrollLeft | |
| // Level with the paragraph's FIRST line, which is also where its card and its | |
| // composer will be laid out (layoutMargin uses coordsAtPos of the same | |
| // position) — the button ends up pointing at the row the card will occupy. | |
| // Per-line alignment inside a paragraph was tried and rejected: the button | |
| // then twitches down the page as the pointer drifts, for an anchor that does | |
| // not change. | |
| let mid = hit.rect.top + Math.min(hit.rect.height, 18) / 2 | |
| try { | |
| const coords = editor.view.coordsAtPos(hoverBlockPos) | |
| if (Number.isFinite(coords.top)) mid = (coords.top + coords.bottom) / 2 | |
| } catch {} | |
| btn.classList.remove('hidden') | |
| btn.style.left = `${textRect.right - colRect.left + scrollLeft + 8}px` | |
| btn.style.top = `${mid - colRect.top - btn.offsetHeight / 2}px` | |
| } | |
| // The deepest text block whose rendered box this height falls in. Asking | |
| // posAtCoords for a position at the pointer would not do: the pointer is out in | |
| // the page margin where there is no text to hit, and the answer we want is a | |
| // block, not a character. Walking the rendered boxes also means atoms answer | |
| // nothing — hover beside a bare image, formula or rule and no button appears, | |
| // because those are commented on by clicking them, a flow that already exists | |
| // and already quotes them ("[figure]", "$E = mc^2$"). | |
| // | |
| // Document order breaks the ties, which only matters for a table: every cell in a | |
| // row shares one Y band, so the first one wins and hovering beside a row anchors | |
| // to its LEFTMOST cell whichever cell you were level with. Some cell has to be | |
| // picked, and the leftmost is where the row starts reading; the card's locator | |
| // then shows that cell's words, so what you get is visible rather than silent. | |
| function textblockAtHeight(editor, clientY) { | |
| let hit = null | |
| editor.state.doc.descendants((node, pos) => { | |
| if (hit) return false | |
| if (!node.isTextblock) return true // keep descending: lists, tables, quotes | |
| let dom = null | |
| try { | |
| dom = editor.view.nodeDOM(pos) | |
| } catch {} | |
| if (dom?.nodeType === 1) { | |
| const rect = dom.getBoundingClientRect() | |
| if (clientY >= rect.top && clientY < rect.bottom) hit = { pos, rect } | |
| } | |
| return false // nothing inside a text block is a block | |
| }) | |
| return hit | |
| } | |
| // --- document switcher (top left) -------------------------------------------------- | |
| // There is no separate index page: the document you are in is the whole app, and | |
| // this is how you reach the others. Switching is a normal navigation — the shell | |
| // is cheap and it keeps one document per URL, which is what sharing a link means. | |
| function wireDocsMenu() { | |
| const btn = document.getElementById('docs-btn') | |
| const pop = document.getElementById('docs-pop') | |
| document.getElementById('docs-caret').appendChild(icon('caret')) | |
| const render = async () => { | |
| const res = await api('/api/docs') | |
| const wrap = document.getElementById('docs-list') | |
| if (res.error) return | |
| const docs = [...(res.docs || [])].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) | |
| wrap.className = docs.length ? '' : 'muted' | |
| if (!docs.length) { | |
| wrap.textContent = 'No documents yet — create one below.' | |
| return | |
| } | |
| wrap.replaceChildren(...docs.map(d => { | |
| const row = el('div', { class: 'doc-row' + (d.id === docId ? ' current' : '') }) | |
| const link = el('a', { href: `/d/${d.id}`, class: 'doc-row-main' }) | |
| link.appendChild(el('span', { class: 'title' }, d.title || 'Untitled')) | |
| // how the project itself opens, so the list says what each one is about. | |
| // Nothing written under home's title yet = no second line at all. | |
| if (d.subtitle) link.appendChild(el('span', { class: 'doc-row-sub' }, d.subtitle)) | |
| const who = d.createdBy && d.createdBy !== state.me?.username ? `${d.createdBy} · ` : '' | |
| // timeAgo says 'now' for anything inside a minute, which does not take 'ago' | |
| const when = timeAgo(d.updatedAt) | |
| link.appendChild(el('span', { class: 'meta' }, who + (when === 'now' ? 'just now' : `${when} ago`))) | |
| row.appendChild(link) | |
| // deletion stays creator-only, as it was on the page this replaces | |
| if (!d.createdBy || d.createdBy === state.me?.username) { | |
| const del = el('button', { class: 'iconbtn', title: 'Delete document (creator only)' }) | |
| del.appendChild(icon('trash')) | |
| del.addEventListener('click', async e => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| const ok = await uiConfirm({ | |
| title: 'Delete document', | |
| message: `Delete "${d.title || 'Untitled'}"? All its pages are gone for everyone. This cannot be undone.`, | |
| confirmLabel: 'Delete', | |
| danger: true, | |
| }) | |
| if (!ok) return | |
| const res2 = await api(`/api/docs/${d.id}`, { method: 'DELETE' }) | |
| if (res2.error) return uiAlert(res2.error) | |
| // deleting the one you are reading leaves nowhere to be — go to another | |
| if (d.id === docId) return location.assign('/') | |
| render() | |
| }) | |
| row.appendChild(del) | |
| } | |
| return row | |
| })) | |
| } | |
| btn.addEventListener('click', () => { | |
| pop.classList.toggle('hidden') | |
| if (!pop.classList.contains('hidden')) render() | |
| }) | |
| document.addEventListener('click', e => { | |
| if (!pop.contains(e.target) && !btn.contains(e.target)) pop.classList.add('hidden') | |
| }) | |
| document.getElementById('new-doc').addEventListener('click', async () => { | |
| const title = await uiPrompt({ | |
| title: 'New document', | |
| label: 'Title for the new document', | |
| placeholder: 'Untitled', | |
| confirmLabel: 'Create', | |
| }) | |
| if (title === null) return | |
| const res = await api('/api/docs', { method: 'POST', body: { title } }) | |
| if (res.error) return uiAlert(res.error) | |
| location.assign(`/d/${res.id}`) | |
| }) | |
| document.getElementById('playground-btn').addEventListener('click', async () => { | |
| const res = await api('/api/playground', { method: 'POST' }) | |
| if (res.error) return uiAlert(res.error) | |
| location.assign(`/d/${res.id}`) | |
| }) | |
| } | |
| function wireComposer() { | |
| const text = document.getElementById('composer-text') | |
| text.addEventListener('input', () => { | |
| state.composerDirty = true | |
| }) | |
| document.getElementById('composer-cancel').addEventListener('click', () => { | |
| hideComposerBox('composer') | |
| text.value = '' | |
| state.composerDirty = false | |
| state.autoComposer = null | |
| renderMargin(window.__editor) | |
| }) | |
| // Enter sends, Shift+Enter breaks the line — the messaging convention, and | |
| // Enter did nothing here before. The mention menu owns Enter while it is open | |
| // (it is picking an agent), and it is wired below, i.e. after this handler. | |
| text.addEventListener('keydown', e => { | |
| if (e.key !== 'Enter' || e.shiftKey) return | |
| if (!document.getElementById('composer-mentions').classList.contains('hidden')) return | |
| e.preventDefault() | |
| document.getElementById('composer-send').click() | |
| }) | |
| document.getElementById('composer-send').addEventListener('click', () => { | |
| const value = text.value.trim() | |
| // the box comes pre-mentioned, so a bare "@handle" counts as empty | |
| const body = value.replace(/^(?:@[a-z0-9_.-]+\s+)+/i, '').trim() | |
| if (!value || !body || !state.composerAnchors) return | |
| const id = createThread(state.composerAnchors, value) | |
| text.value = '' | |
| state.composerDirty = false | |
| state.autoComposer = null | |
| hideComposerBox('composer') | |
| setActive(id) | |
| }) | |
| wireMentionMenu(text, document.getElementById('composer-mentions')) | |
| } | |
| function createThread(anchors, text, suggestionId = null) { | |
| const id = randomId() | |
| const messages = new Y.Array() | |
| messages.push([makeMessage(text)]) | |
| const t = new Y.Map() | |
| t.set('id', id) | |
| t.set('anchorStart', anchors.start) | |
| t.set('anchorEnd', anchors.end) | |
| t.set('excerpt', anchors.excerpt || null) | |
| // A position-anchored comment leaves `excerpt` null on purpose: that field | |
| // means "the words this comment is about", and there are none. anchorContext | |
| // is the paragraph it sits beside, which is a location, not a quotation. | |
| if (anchors.kind === 'position') { | |
| t.set('anchorKind', 'position') | |
| t.set('anchorContext', anchors.context || null) | |
| } | |
| t.set('createdBy', state.me.username) | |
| t.set('createdAt', Date.now()) | |
| t.set('resolved', false) | |
| if (suggestionId) t.set('suggestionId', suggestionId) | |
| t.set('messages', messages) | |
| threads.set(id, t) | |
| return id | |
| } | |
| function makeMessage(text) { | |
| return { id: randomId(), author: state.me.username, authorType: 'user', text, ts: Date.now() } | |
| } | |
| function randomId() { | |
| return Math.random().toString(36).slice(2, 12) | |
| } | |
| // --- suggest composer (humans propose block rewrites, like agents do) -------------- | |
| async function openSuggestComposer(editor, opts = {}) { | |
| const { from, to } = editor.state.selection | |
| const blockFrom = editor.state.doc.resolve(from).index(0) | |
| const blockTo = editor.state.doc.resolve(to).index(0) | |
| // while the selection stays inside the same blocks there is nothing to redo — | |
| // re-fetching on every selection change would be a request per drag | |
| const range = `${blockFrom}-${blockTo}` | |
| if (opts.auto && state.autoComposer === 'suggest' && state.suggestRange === range) return | |
| state.suggestRange = range | |
| const fragment = pageFragment() | |
| state.suggestAnchors = { | |
| start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)), | |
| end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)), | |
| } | |
| hideComposerBox('composer') | |
| document.getElementById('selection-menu').classList.add('hidden') | |
| const composer = document.getElementById('suggest-composer') | |
| composer.classList.remove('hidden') | |
| state.autoComposer = opts.auto ? 'suggest' : null | |
| setMarginHidden(false, false) // the composer lives in the comments panel | |
| renderMargin(editor) | |
| document.getElementById('suggest-quote').textContent = | |
| blockTo > blockFrom ? `Rewriting blocks ${blockFrom + 1}–${blockTo + 1}` : 'Rewriting the selected block' | |
| const textEl = document.getElementById('suggest-text') | |
| textEl.value = '…' | |
| const snap = await api(`/api/docs/${docId}${pageQS}`) | |
| if (state.composerDirty) return // the user started writing while the fetch was in flight | |
| textEl.value = (snap.blocks || []) | |
| .slice(blockFrom, blockTo + 1) | |
| .map(b => b.markdown) | |
| .join('\n\n') | |
| if (opts.focus !== false) textEl.focus({ preventScroll: true }) | |
| settleComposerAfterLayout(document.getElementById('suggest-composer')) | |
| scheduleLayout(editor) | |
| } | |
| function wireSuggestComposer() { | |
| for (const id of ['suggest-text', 'suggest-rationale']) { | |
| document.getElementById(id).addEventListener('input', () => { | |
| state.composerDirty = true | |
| }) | |
| } | |
| document.getElementById('suggest-cancel').addEventListener('click', () => { | |
| hideComposerBox('suggest-composer') | |
| state.composerDirty = false | |
| state.autoComposer = null | |
| state.suggestRange = null | |
| renderMargin(window.__editor) | |
| }) | |
| document.getElementById('suggest-send').addEventListener('click', async () => { | |
| if (!state.suggestAnchors) return | |
| const res = await api(`/api/docs/${docId}/suggestions`, { | |
| method: 'POST', | |
| body: { | |
| page: pageSlug, | |
| anchor_start: state.suggestAnchors.start, | |
| anchor_end: state.suggestAnchors.end, | |
| replacement_markdown: document.getElementById('suggest-text').value, | |
| rationale: document.getElementById('suggest-rationale').value.trim() || undefined, | |
| }, | |
| }) | |
| if (res.error) return uiAlert(res.error) | |
| hideComposerBox('suggest-composer') | |
| document.getElementById('suggest-text').value = '' | |
| document.getElementById('suggest-rationale').value = '' | |
| state.composerDirty = false | |
| state.autoComposer = null | |
| state.suggestRange = null | |
| setActive(res.suggestion_id) | |
| }) | |
| } | |
| // --- margin: unified comments + suggestions, positioned next to their text --------- | |
| // A page switch destroys the editor, and a destroyed editor has no view — so | |
| // every deferred callback (timer, rAF, settle) must check it is still the live | |
| // one first. Switching pages is fast enough now that these fire mid-teardown. | |
| function isLive(editor) { | |
| return Boolean(editor) && !editor.isDestroyed && window.__editor === editor | |
| } | |
| let layoutRaf = null | |
| function scheduleLayout(editor) { | |
| if (layoutRaf) return | |
| layoutRaf = requestAnimationFrame(() => { | |
| layoutRaf = null | |
| if (!isLive(editor)) return | |
| refreshAnchorLocators(editor) | |
| layoutMargin(editor) | |
| }) | |
| } | |
| const LOC_TIP = 'Jump to this place in the text' | |
| const LOC_TIP_GONE = 'The paragraph this comment was left beside was deleted' | |
| // Whether a position-anchored comment's paragraph still exists changes with any | |
| // edit, and cards are deliberately NOT rebuilt on every keystroke — that would | |
| // clobber a reply someone is halfway through typing. So the locator's orphaned | |
| // state is refreshed in place, on the same frame the cards are repositioned. | |
| function refreshAnchorLocators(editor) { | |
| for (const loc of document.querySelectorAll('#margin-items .card .anchor-loc')) { | |
| const ythread = threads?.get(loc.closest('.card')?.dataset.item) | |
| if (!ythread) continue | |
| const orphaned = threadAnchorState(editor, ythread).orphaned | |
| loc.classList.toggle('orphaned', orphaned) | |
| loc.title = orphaned ? LOC_TIP_GONE : LOC_TIP | |
| } | |
| } | |
| // --- revision chains --- | |
| // Suggestions linked by `revises` form a chain; only ONE generation renders at | |
| // a time (default: the newest actionable one), with ‹ n/N › navigation. | |
| function suggestionChains() { | |
| const all = suggestionValues() | |
| const byId = new Map(all.map(s => [s.id, s])) | |
| const rootOf = start => { | |
| let cur = start | |
| const seen = new Set([cur.id]) | |
| while (cur.revises && byId.has(cur.revises) && !seen.has(cur.revises)) { | |
| cur = byId.get(cur.revises) | |
| seen.add(cur.id) | |
| } | |
| return cur.id | |
| } | |
| const groups = new Map() | |
| for (const s of all) { | |
| const root = rootOf(s) | |
| if (!groups.has(root)) groups.set(root, []) | |
| groups.get(root).push(s) | |
| } | |
| const actionable = x => x.status === 'open' || x.status === 'superseded' | |
| return [...groups.entries()].map(([root, members]) => { | |
| members.sort((a, b) => (a.ts || 0) - (b.ts || 0)) | |
| let def = members.length - 1 | |
| for (let i = members.length - 1; i >= 0; i--) { | |
| if (actionable(members[i])) { | |
| def = i | |
| break | |
| } | |
| } | |
| let idx = state.chainPos[root] | |
| if (!(Number.isInteger(idx) && idx >= 0 && idx < members.length)) idx = def | |
| return { root, members, idx, display: members[idx] } | |
| }) | |
| } | |
| function chainNav(chain) { | |
| const nav = el('span', { class: 'chain-nav' }) | |
| const go = idx => { | |
| state.chainPos[chain.root] = idx | |
| renderMargin(window.__editor) | |
| poke(window.__editor) | |
| } | |
| const prev = el('button', { class: 'iconbtn', title: 'Earlier revision' }, '‹') | |
| prev.disabled = chain.idx === 0 | |
| prev.addEventListener('click', e => { | |
| e.stopPropagation() | |
| go(chain.idx - 1) | |
| }) | |
| const next = el('button', { class: 'iconbtn', title: 'Later revision' }, '›') | |
| next.disabled = chain.idx === chain.members.length - 1 | |
| next.addEventListener('click', e => { | |
| e.stopPropagation() | |
| go(chain.idx + 1) | |
| }) | |
| nav.append(prev, el('span', { class: 'chain-pos' }, `${chain.idx + 1}/${chain.members.length}`), next) | |
| return nav | |
| } | |
| // accept/reject buttons shared by every standalone suggestion card | |
| function suggestionActionButtons(s, editor) { | |
| const reject = el('button', { class: 'iconbtn reject-btn', title: 'Reject suggestion' }) | |
| reject.appendChild(icon('x')) | |
| reject.addEventListener('click', e => { | |
| e.stopPropagation() | |
| if (!requireLiveConnection('rejecting a suggestion')) return | |
| api(`/api/docs/${docId}/suggestions/${s.id}/reject`, { method: 'POST', body: { page: pageSlug } }) | |
| }) | |
| const accept = el('button', { class: 'iconbtn accept-btn', title: 'Accept suggestion' }) | |
| accept.appendChild(icon('check')) | |
| accept.addEventListener('click', async e => { | |
| e.stopPropagation() | |
| // A local apply only counts if this tab can actually send it. Accepting from | |
| // a disconnected tab used to mark the suggestion accepted (that call is | |
| // plain HTTP and still succeeds) while the content went nowhere. | |
| if (!requireLiveConnection('accepting a suggestion')) return | |
| // Validate before touching the editor. A proposal can still have a live | |
| // Yjs anchor after another proposal split its old target into several | |
| // blocks; applying first would duplicate the newly inserted neighbours. | |
| const validation = await api(`/api/docs/${docId}/suggestions/${s.id}/accept`, { | |
| method: 'POST', | |
| body: { validate_only: true, page: pageSlug }, | |
| }) | |
| if (validation.error) { | |
| uiAlert(validation.error) | |
| return | |
| } | |
| // apply in this editor so it's undoable; server then only flips the status | |
| const appliedLocally = tryClientApply(editor, s) | |
| const res = await api(`/api/docs/${docId}/suggestions/${s.id}/accept`, { | |
| method: 'POST', | |
| body: { mark_only: appliedLocally, page: pageSlug }, | |
| }) | |
| if (res.error) uiAlert(res.error) | |
| }) | |
| return [reject, accept] | |
| } | |
| function renderMargin(editor) { | |
| const wrap = document.getElementById('margin-items') | |
| const items = [] | |
| for (const chain of suggestionChains()) { | |
| const s = chain.display | |
| const visible = s.status === 'open' || s.status === 'superseded' || state.showResolved | |
| if (!visible) continue | |
| let linkedThread = null | |
| threads.forEach((yt, tid) => { | |
| if (yt.get('suggestionId') === s.id) linkedThread = { id: tid, ythread: yt } | |
| }) | |
| items.push({ kind: 'sugg', id: s.id, data: s, linkedThread, chain, originId: suggestionOriginThreadId(s) }) | |
| } | |
| threads.forEach((ythread, id) => { | |
| if (ythread.get('suggestionId')) return | |
| if (ythread.get('resolved') && !state.showResolved) return | |
| items.push({ kind: 'thread', id, ythread, originId: ythread.get('originThreadId') || null }) | |
| }) | |
| // composers live inside the container too — never clobber them | |
| wrap.querySelectorAll('.card, .margin-empty').forEach(n => n.remove()) | |
| if (!items.length) { | |
| const composerOpen = wrap.querySelector('.composer-box:not(.hidden)') | |
| // The hover zone is invisible by design, so this is the only place that says | |
| // it exists. Worth the extra clause: an affordance nobody finds is no | |
| // affordance, and the empty panel is exactly where someone is looking. | |
| // A visitor gets none of that: every way of adding to the panel is closed | |
| // to them, so naming those ways would only be a list of things they cannot do. | |
| if (!composerOpen) { | |
| wrap.appendChild(el('div', { class: 'margin-empty muted' }, state.viewer | |
| ? 'Read-only view. Comments and suggestions on this project appear here.' | |
| : 'Select text to comment or suggest, or hover the right of a paragraph to comment beside it. Mention @an-agent to hand it work.')) | |
| } | |
| } else { | |
| wrap.append(...items.map(item => (item.kind === 'sugg' ? suggestionCard(item, editor) : threadCard(item, editor)))) | |
| } | |
| setCommentCount(items.length) | |
| document.getElementById('margin-head-title').textContent = items.length ? `Comments · ${items.length}` : 'Comments' | |
| clampLongText(wrap) | |
| scheduleLayout(editor) | |
| } | |
| // Agents can write essays into a thread. Cap every comment body (and rationale) | |
| // at a readable height once it is in the DOM and measurable, with a toggle for | |
| // the rest — a long reply must never bury the next card. | |
| function clampLongText(root) { | |
| for (const node of root.querySelectorAll('.msg .text, .rationale')) { | |
| if (node.dataset.clampChecked === 'open') continue | |
| node.classList.add('clamped') | |
| if (node.scrollHeight - node.clientHeight < 12) { | |
| node.classList.remove('clamped') | |
| continue | |
| } | |
| if (node.dataset.clampChecked) continue | |
| node.dataset.clampChecked = '1' | |
| const more = el('button', { class: 'more-btn', type: 'button' }, 'Show more') | |
| more.addEventListener('click', e => { | |
| e.stopPropagation() | |
| const open = node.classList.toggle('clamped') === false | |
| node.dataset.clampChecked = open ? 'open' : '1' | |
| more.textContent = open ? 'Show less' : 'Show more' | |
| scheduleLayout(window.__editor) | |
| }) | |
| node.after(more) | |
| } | |
| } | |
| function itemRange(editor, item) { | |
| const ystate = ySyncPluginKey.getState(editor.state) | |
| if (!ystate?.binding) return null | |
| if (item.kind === 'sugg') return suggestionRange(editor.state, item.data) | |
| return anchorsToRange(editor.state, ystate, item.ythread.get('anchorStart'), item.ythread.get('anchorEnd')) | |
| } | |
| function layoutMargin(editor) { | |
| const container = document.getElementById('margin-items') | |
| const els = [...container.querySelectorAll('.card, .composer-box:not(.hidden)')] | |
| // stacked/sheet layout: the cards flow normally, so drop any inline | |
| // positioning left over from the desktop layout (a stale minHeight would | |
| // leave a screen-tall gap under the last card) | |
| if (isStackedMargin()) { | |
| container.style.minHeight = '' | |
| for (const node of els) node.style.top = '' | |
| return | |
| } | |
| if (!els.length) { | |
| container.style.minHeight = '0px' | |
| return | |
| } | |
| const containerRect = container.getBoundingClientRect() | |
| const ystate = ySyncPluginKey.getState(editor.state) | |
| const entries = els.map(node => { | |
| let range = null | |
| if (node.classList.contains('composer-box')) { | |
| const anchors = node.id === 'composer' ? state.composerAnchors : state.suggestAnchors | |
| if (anchors && ystate?.binding) range = anchorsToRange(editor.state, ystate, anchors.start, anchors.end) | |
| } else { | |
| range = rangeFromCard(editor, node) | |
| } | |
| let desired = Infinity | |
| if (range) { | |
| try { | |
| desired = editor.view.coordsAtPos(Math.min(range.from, editor.state.doc.content.size)).top - containerRect.top | |
| } catch {} | |
| } | |
| if (Number.isFinite(desired) && node.classList.contains('composer-box')) desired -= 1 // wins ties | |
| return { node, desired: Number.isFinite(desired) ? Math.max(0, desired) : Infinity } | |
| }) | |
| entries.sort((a, b) => a.desired - b.desired) | |
| let cursor = 0 | |
| for (const entry of entries) { | |
| const top = entry.desired === Infinity ? cursor : Math.max(entry.desired, cursor) | |
| entry.node.style.top = `${top}px` | |
| cursor = top + entry.node.offsetHeight + 10 | |
| } | |
| container.style.minHeight = `${cursor}px` | |
| } | |
| function rangeFromCard(editor, card) { | |
| const id = card.dataset.item | |
| const suggestion = [...suggestionValues()].find(s => s.id === id) | |
| if (suggestion) return itemRange(editor, { kind: 'sugg', data: suggestion }) | |
| const ythread = threads.get(id) | |
| if (ythread) return itemRange(editor, { kind: 'thread', ythread }) | |
| return null | |
| } | |
| function suggestionValues() { | |
| const out = [] | |
| suggestions.forEach(s => out.push(s)) | |
| return out | |
| } | |
| function activeOriginThreadId() { | |
| const active = threads?.get(state.activeItem) | |
| return active && !active.get('suggestionId') ? state.activeItem : null | |
| } | |
| function suggestionOriginThreadId(s) { | |
| // New items carry an explicit origin. threadId is the legacy attachment | |
| // field, retained only so pre-origin suggestions keep their visual trail. | |
| return s.originThreadId || (s.threadId && threads?.get(s.threadId) && !threads.get(s.threadId).get('suggestionId') ? s.threadId : null) | |
| } | |
| function cardShell(id, kind, expanded, originId = null) { | |
| const related = originId && activeOriginThreadId() === originId | |
| const attrs = { | |
| class: `card ${kind}${expanded ? ' expanded' : ''}${related ? ' origin-related' : ''}`, | |
| 'data-item': id, | |
| } | |
| if (originId) attrs['data-origin'] = originId | |
| const card = el('div', attrs) | |
| card.addEventListener('mousedown', e => { | |
| if (e.target.closest('button, input, textarea, a, .mention-menu')) return | |
| if (state.activeItem !== id) setActive(id) | |
| }) | |
| return card | |
| } | |
| function originLink(originId, editor) { | |
| if (!originId) return null | |
| const origin = threads.get(originId) | |
| const closed = origin?.get('resolved') | |
| const button = el('button', { class: 'origin-link', type: 'button', title: 'Focus the comment that prompted this item' }, closed ? 'from closed comment' : 'from comment') | |
| button.addEventListener('click', e => { | |
| e.stopPropagation() | |
| if (origin?.get('resolved')) state.showResolved = true | |
| setActive(originId) | |
| if (origin) jumpTo(editor, itemRange(editor, { kind: 'thread', ythread: origin })) | |
| }) | |
| return button | |
| } | |
| function cardHead(iconName, who, isAgent, preview, when) { | |
| const head = el('div', { class: 'head' }) | |
| const identity = el('span', { class: 'head-identity' }) | |
| identity.appendChild(icon(iconName)) | |
| identity.appendChild(el('span', { class: `who${isAgent ? ' agent' : ''}` }, who)) | |
| const context = el('span', { class: 'head-context' }) | |
| context.appendChild(el('span', { class: 'preview' }, preview)) | |
| context.appendChild(el('span', { class: 'when' }, timeAgo(when))) | |
| head.append(identity, el('span', { class: 'head-actions' }), context) | |
| return head | |
| } | |
| function messageEl(msg) { | |
| const m = el('div', { class: 'msg' }) | |
| const author = el('div', { class: 'author' }) | |
| author.appendChild(el('span', {}, msg.authorType === 'agent' ? `@${msg.author}` : msg.author)) | |
| if (msg.authorType === 'agent') author.appendChild(el('span', { class: 'agent-tag' }, 'agent')) | |
| author.appendChild(el('span', { class: 'when' }, timeAgo(msg.ts))) | |
| m.appendChild(author) | |
| const textEl = el('div', { class: 'text' }) | |
| textEl.innerHTML = renderMentionText(msg.text) | |
| m.appendChild(textEl) | |
| for (const chip of msg.mentions || []) { | |
| m.appendChild(el('span', { class: `chip ${chip.status}` }, `@${chip.handle} · ${statusLabel(chip.status)}`)) | |
| } | |
| return m | |
| } | |
| function replyRow(placeholder, onSend) { | |
| const row = el('div', { class: 'reply-row' }) | |
| // a textarea, not an input: replies need Shift+Enter to add a line too, and | |
| // it grows with the text instead of scrolling a one-line box | |
| const input = el('textarea', { placeholder, rows: '1' }) | |
| const menu = el('div', { class: 'mention-menu hidden' }) | |
| const send = el('button', { class: 'iconbtn', title: 'Send' }) | |
| send.appendChild(icon('send')) | |
| const autoGrow = () => { | |
| input.style.height = 'auto' | |
| input.style.height = `${Math.min(input.scrollHeight, 140)}px` | |
| } | |
| send.addEventListener('click', () => { | |
| if (!input.value.trim()) return | |
| onSend(input.value.trim()) | |
| input.value = '' | |
| autoGrow() | |
| }) | |
| input.addEventListener('input', autoGrow) | |
| input.addEventListener('keydown', e => { | |
| if (e.key !== 'Enter' || e.shiftKey) return | |
| if (!menu.classList.contains('hidden')) return // the mention menu is picking | |
| e.preventDefault() | |
| send.click() | |
| }) | |
| row.append(input, send) | |
| wireMentionMenu(input, menu) | |
| return { row, menu } | |
| } | |
| // --- thread card --- | |
| function threadCard(item, editor) { | |
| const { id, ythread } = item | |
| const expanded = state.activeItem === id | |
| const card = cardShell(id, 'thread', expanded, item.originId) | |
| const msgs = ythread.get('messages')?.toArray() || [] | |
| const first = msgs[0] || {} | |
| const resolved = ythread.get('resolved') | |
| const head = cardHead('comment', first.authorType === 'agent' ? `@${first.author}` : first.author || '?', first.authorType === 'agent', (resolved ? 'closed · ' : '') + (first.text || ''), first.ts) | |
| const origin = originLink(item.originId, editor) | |
| if (origin) head.querySelector('.head-context').prepend(origin) | |
| if (!resolved) { | |
| // closable without opening the card. A tick reads as "approved" or "done", | |
| // which a comment is not — it is a conversation you are finished with. | |
| const close = el('button', { class: 'btn small ghost close-btn', 'data-tip': 'Close this comment' }, 'Close') | |
| close.addEventListener('click', e => { | |
| e.stopPropagation() | |
| ythread.set('resolved', true) | |
| if (state.activeItem === id) state.activeItem = null | |
| }) | |
| head.querySelector('.head-actions').appendChild(close) | |
| } | |
| card.appendChild(head) | |
| if (!expanded) return card | |
| const body = el('div', { class: 'body' }) | |
| const { range, positional, orphaned } = threadAnchorState(editor, ythread) | |
| if (positional) { | |
| // No quote: this comment is beside the text, not about a phrase in it. The | |
| // locator says where, in the paragraph's own opening words, and clicking it | |
| // puts the caret exactly where the comment was left. Once the paragraph is | |
| // deleted the words are all that is left of the place, so they stay — struck | |
| // through, with the anchor now marking the gap the paragraph left behind. | |
| const loc = el('div', { class: 'anchor-loc' + (orphaned ? ' orphaned' : '') }) | |
| loc.append(icon('quote'), el('span', { class: 'words' }, anchorLocText(ythread.get('anchorContext')))) | |
| loc.title = orphaned ? LOC_TIP_GONE : LOC_TIP | |
| // resolved at click time, not captured: the paragraph may have moved (or | |
| // gone) since the card was built | |
| loc.addEventListener('click', () => jumpTo(editor, threadAnchorState(editor, ythread).range)) | |
| body.appendChild(loc) | |
| } else if (!ythread.get('anchorStart')) { | |
| // A comment on the page as a whole — what an agent opens when its remark is | |
| // about no particular block. It marks no text BY DESIGN, so say so: without | |
| // this it reads as a comment whose highlight has gone missing. Distinct from | |
| // the positional case above, which does have a place, just not a phrase. | |
| body.appendChild(el('div', { class: 'muted page-level' }, 'On this page')) | |
| } else if (ythread.get('excerpt')) { | |
| const quote = el('blockquote', { class: orphaned ? 'orphaned' : '' }, ythread.get('excerpt')) | |
| quote.title = orphaned ? 'The commented text was deleted' : 'Jump to text' | |
| quote.addEventListener('click', () => jumpTo(editor, range)) | |
| body.appendChild(quote) | |
| } | |
| for (const msg of msgs) body.appendChild(messageEl(msg)) | |
| if (!resolved) { | |
| const { row, menu } = replyRow('Reply — @agent to hand off', text => { | |
| ythread.get('messages').push([makeMessage(text)]) | |
| }) | |
| body.appendChild(row) | |
| body.appendChild(menu) | |
| const actions = el('div', { class: 'actions' }) | |
| const close = el('button', { class: 'btn small ghost close-btn' }, 'Close') | |
| close.addEventListener('click', () => { | |
| ythread.set('resolved', true) | |
| state.activeItem = null | |
| }) | |
| actions.appendChild(close) | |
| body.appendChild(actions) | |
| } else { | |
| body.appendChild(el('div', { class: 'status-note' }, 'closed')) | |
| } | |
| card.appendChild(body) | |
| return card | |
| } | |
| // --- suggestion card --- | |
| function suggestionCard(item, editor) { | |
| const s = item.data | |
| const expanded = state.activeItem === s.id | |
| const card = cardShell(s.id, 'suggestion', expanded, item.originId) | |
| // the diff lives in the document itself — the card only carries identity, | |
| // rationale, actions (always visible, even collapsed) and the discussion | |
| const actionable = s.status === 'open' || s.status === 'superseded' | |
| const head = cardHead( | |
| 'edit', | |
| s.authorType === 'agent' ? `@${s.author}` : s.author, | |
| s.authorType === 'agent', | |
| actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`, | |
| s.ts | |
| ) | |
| const origin = originLink(item.originId, editor) | |
| if (origin) head.querySelector('.head-context').prepend(origin) | |
| const actions = head.querySelector('.head-actions') | |
| if (item.chain && item.chain.members.length > 1) actions.appendChild(chainNav(item.chain)) | |
| if (actionable) actions.append(...suggestionActionButtons(s, editor)) | |
| card.appendChild(head) | |
| if (!expanded) return card | |
| const body = el('div', { class: 'body' }) | |
| if (actionable && s.rationale) body.appendChild(el('div', { class: 'rationale' }, s.rationale)) | |
| if (!actionable) body.appendChild(el('div', { class: 'status-note' }, `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`)) | |
| // discussion on the suggestion (e.g. to guide the agent to revise it) | |
| const msgs = item.linkedThread?.ythread.get('messages')?.toArray() || [] | |
| for (const msg of msgs) body.appendChild(messageEl(msg)) | |
| const { row, menu } = replyRow('Discuss — @agent to request changes', text => { | |
| if (item.linkedThread) { | |
| item.linkedThread.ythread.get('messages').push([makeMessage(text)]) | |
| } else { | |
| createThread({ start: s.anchorStart, end: s.anchorEnd, excerpt: null }, text, s.id) | |
| } | |
| }) | |
| body.appendChild(row) | |
| body.appendChild(menu) | |
| card.appendChild(body) | |
| return card | |
| } | |
| // --- accepting suggestions locally (so undo works) -------------------------------- | |
| // Run undo/redo against the Yjs UndoManager directly. Tiptap's wrapped | |
| // undo command intermittently no-ops on large accept transactions (stale | |
| // chained state), while the manager itself is always reliable. | |
| function runUndo(editor) { | |
| yUndoPluginKey.getState(editor.state)?.undoManager?.undo() | |
| return true | |
| } | |
| function runRedo(editor) { | |
| yUndoPluginKey.getState(editor.state)?.undoManager?.redo() | |
| return true | |
| } | |
| // When the accepting user's own editor applies the replacement, the change lands | |
| // in their collaborative undo history. The server then only records the status. | |
| let pendingAcceptSuggestion = null | |
| function wireUndoReopens(editor) { | |
| const undoManager = yUndoPluginKey.getState(editor.state)?.undoManager | |
| if (!undoManager) return | |
| undoManager.on('stack-item-added', event => { | |
| if (pendingAcceptSuggestion) { | |
| event.stackItem.meta.set('acceptedSuggestion', pendingAcceptSuggestion) | |
| pendingAcceptSuggestion = null | |
| } | |
| }) | |
| undoManager.on('stack-item-popped', event => { | |
| if (event.type !== 'undo') return | |
| const sid = event.stackItem.meta.get('acceptedSuggestion') | |
| if (sid) api(`/api/docs/${docId}/suggestions/${sid}/reopen`, { method: 'POST', body: { page: pageSlug } }) | |
| }) | |
| } | |
| function inlineToPmContent(segments) { | |
| return (segments || []) | |
| .filter(seg => seg.text) | |
| .map(seg => | |
| // math is an atom carrying its source, not marked-up text | |
| seg.math != null | |
| ? { type: 'mathInline', attrs: { latex: seg.math } } | |
| : { | |
| type: 'text', | |
| text: seg.text, | |
| marks: Object.entries(seg.attrs || {}).map(([type, attrs]) => ({ type, attrs })), | |
| } | |
| ) | |
| } | |
| function blockToPmJSON(block) { | |
| switch (block.type) { | |
| case 'mathBlock': | |
| return { type: 'mathBlock', attrs: { latex: block.attrs?.latex || '' } } | |
| case 'heading': | |
| return { type: 'heading', attrs: { level: block.attrs?.level || 1 }, content: inlineToPmContent(block.inline) } | |
| case 'codeBlock': | |
| return { type: 'codeBlock', attrs: block.attrs || {}, content: block.text ? [{ type: 'text', text: block.text }] : undefined } | |
| case 'htmlBlock': | |
| return { type: 'htmlBlock', attrs: { html: block.text || '' } } | |
| case 'bulletList': | |
| case 'orderedList': | |
| return { | |
| type: block.type, | |
| content: (block.items || []).map(segments => ({ | |
| type: 'listItem', | |
| content: [{ type: 'paragraph', content: inlineToPmContent(segments) }], | |
| })), | |
| } | |
| case 'taskList': | |
| return { | |
| type: 'taskList', | |
| content: (block.items || []).map(it => ({ | |
| type: 'taskItem', | |
| attrs: { checked: !!it.checked }, | |
| content: [{ type: 'paragraph', content: inlineToPmContent(it.inline) }], | |
| })), | |
| } | |
| case 'table': | |
| return { | |
| type: 'table', | |
| content: (block.rows || []).map((cells, r) => ({ | |
| type: 'tableRow', | |
| content: cells.map(segs => ({ | |
| type: r === 0 ? 'tableHeader' : 'tableCell', | |
| attrs: { colspan: 1, rowspan: 1, colwidth: null }, | |
| content: [{ type: 'paragraph', content: inlineToPmContent(segs) }], | |
| })), | |
| })), | |
| } | |
| case 'blockquote': | |
| return { type: 'blockquote', content: [{ type: 'paragraph', content: inlineToPmContent(block.inline) }] } | |
| case 'horizontalRule': | |
| return { type: 'horizontalRule' } | |
| case 'image': | |
| return { type: 'image', attrs: { src: block.attrs?.src || '', alt: block.attrs?.alt || null } } | |
| default: { | |
| const content = inlineToPmContent(block.inline) | |
| return content.length ? { type: 'paragraph', content } : { type: 'paragraph' } | |
| } | |
| } | |
| } | |
| // Every block of the replacement must be findable in the result. A distinctive | |
| // slice of each block's text is enough — exact node comparison would trip over | |
| // harmless schema normalisation. | |
| function replacementLanded(doc, from, blocks) { | |
| const after = doc.textBetween(Math.max(0, from), doc.content.size, '\n', '\n') | |
| return blocks.every(b => { | |
| const want = blockDescText(b).replace(/\s+/g, ' ').trim() | |
| if (!want) return true | |
| const probe = want.slice(0, 40) | |
| return after.replace(/\s+/g, ' ').includes(probe) | |
| }) | |
| } | |
| function tryClientApply(editor, s) { | |
| try { | |
| const range = suggestionRange(editor.state, s) | |
| if (!range) return false | |
| const blocks = markdownToBlocks(s.replacementMarkdown) | |
| const nodes = blocks.map(b => editor.schema.nodeFromJSON(blockToPmJSON(b))) | |
| // Build the transaction first and check what it would actually produce. | |
| // ProseMirror silently drops whatever does not fit the target position, so a | |
| // range that resolved inside a list item (or any nested block) can apply a | |
| // partial replacement — one live doc ended up with an entry's detail text | |
| // nested under the *previous* person, its heading line gone. Let the server | |
| // do the replacement in that case: it works on whole blocks. | |
| const tr = editor.state.tr.replaceWith(range.from, range.to, nodes) | |
| if (!replacementLanded(tr.doc, range.from, blocks)) { | |
| console.warn('client-side apply would drop content, falling back to server apply') | |
| return false | |
| } | |
| pendingAcceptSuggestion = s.id | |
| editor.view.dispatch(tr) | |
| // stack-item-added fires synchronously during dispatch; if the change was | |
| // merged into an existing stack item instead, don't tag a later one | |
| setTimeout(() => { | |
| pendingAcceptSuggestion = null | |
| }, 0) | |
| return true | |
| } catch (err) { | |
| pendingAcceptSuggestion = null | |
| console.warn('client-side apply failed, falling back to server apply', err) | |
| return false | |
| } | |
| } | |
| // --- agents --------------------------------------------------------------------- | |
| function wireAgentsPanel() { | |
| const btn = document.getElementById('agents-btn') | |
| const pop = document.getElementById('agents-pop') | |
| btn.addEventListener('click', () => pop.classList.toggle('hidden')) | |
| document.getElementById('agent-form').addEventListener('submit', async e => { | |
| e.preventDefault() | |
| const input = document.getElementById('agent-handle') | |
| const handle = input.value.trim().toLowerCase() | |
| if (!handle) return | |
| const res = await api('/api/agents', { method: 'POST', body: { handle } }) | |
| if (res.error) uiToast(res.error) | |
| else { | |
| input.value = '' | |
| refreshAgents() | |
| if (res.key) pop.appendChild(agentKeyBox({ handle: res.handle, key: res.key, docId })) | |
| } | |
| }) | |
| } | |
| // --- download ----------------------------------------------------------------- | |
| // Two independent choices: how much (this page / whole project) and in what | |
| // shape (markdown / pdf). Markdown is a zip built by the server; PDF goes through | |
| // the browser's own print-to-PDF, which is the only way to get the document's | |
| // real typography — the fonts, math and tables are already laid out here, and no | |
| // headless browser exists in the Space image to re-do it server-side. | |
| let downloadChoice = { scope: 'page', format: 'md' } | |
| function wireDownloadPanel() { | |
| const btn = document.getElementById('download-btn') | |
| btn.appendChild(icon('download')) | |
| const pop = document.getElementById('download-pop') | |
| const go = document.getElementById('dl-go') | |
| const hint = document.getElementById('dl-hint') | |
| const options = { | |
| scope: [ | |
| ['page', 'This page'], | |
| ['project', 'Whole project'], | |
| ], | |
| format: [ | |
| ['md', 'Markdown'], | |
| ['pdf', 'PDF'], | |
| ], | |
| } | |
| const render = () => { | |
| for (const [kind, list] of Object.entries(options)) { | |
| const wrap = document.getElementById(`dl-${kind}`) | |
| wrap.replaceChildren() | |
| for (const [value, label] of list) { | |
| const b = el('button', { | |
| class: 'dl-opt' + (downloadChoice[kind] === value ? ' on' : ''), | |
| type: 'button', | |
| role: 'radio', | |
| 'aria-checked': downloadChoice[kind] === value ? 'true' : 'false', | |
| }, label) | |
| b.addEventListener('click', () => { | |
| downloadChoice[kind] = value | |
| render() | |
| }) | |
| wrap.appendChild(b) | |
| } | |
| } | |
| const pages = downloadChoice.scope === 'project' ? 'every page in this project' : 'this page' | |
| hint.textContent = | |
| downloadChoice.format === 'md' | |
| ? `A zip: ${pages} as .md files, plus an assets folder with the images they use.` | |
| : `Opens your print dialog — choose "Save as PDF" to keep ${pages}.` | |
| go.textContent = downloadChoice.format === 'md' ? 'Download zip' : 'Open print dialog' | |
| } | |
| render() | |
| btn.addEventListener('click', () => { | |
| for (const id of ['agents-pop', 'share-pop', 'docs-pop']) document.getElementById(id)?.classList.add('hidden') | |
| pop.classList.toggle('hidden') | |
| }) | |
| document.addEventListener('mousedown', e => { | |
| if (!e.target.closest('#download-pop, #download-btn')) pop.classList.add('hidden') | |
| }) | |
| go.addEventListener('click', async () => { | |
| pop.classList.add('hidden') | |
| if (downloadChoice.format === 'md') return downloadMarkdownZip(downloadChoice.scope) | |
| await printPages(downloadChoice.scope) | |
| }) | |
| } | |
| function downloadMarkdownZip(scope) { | |
| // a plain navigation, so the browser's own download machinery (and the | |
| // Content-Disposition filename) does the work | |
| const url = `/api/docs/${docId}/export?scope=${scope}${scope === 'page' ? `&page=${encodeURIComponent(pageSlug)}` : ''}` | |
| const a = el('a', { href: url, download: '' }) | |
| document.body.appendChild(a) | |
| a.click() | |
| a.remove() | |
| } | |
| // Build a plain-HTML rendition of the pages and hand it to the print dialog. | |
| // Printing the live editor would only ever cover the page you are on, and would | |
| // carry its decorations (comment highlights, suggestion bars) onto the paper. | |
| async function printPages(scope) { | |
| const slugs = [] | |
| if (scope === 'page') slugs.push(pageSlug) | |
| else { | |
| const s = sidebarSnapshot || (await api(`/api/docs/${docId}/structure`)) | |
| const walk = nodes => { | |
| for (const node of nodes || []) { | |
| if (node.group == null && node.slug && node.slug !== '_structure') slugs.push(node.slug) | |
| walk(node.children) | |
| } | |
| } | |
| walk(s.tree) | |
| for (const slug of s.unfiled || []) if (slug !== '_structure') slugs.push(slug) | |
| } | |
| const unique = [...new Set(slugs)] | |
| uiToast(unique.length > 1 ? `Preparing ${unique.length} pages…` : 'Preparing…') | |
| const pages = [] | |
| for (const slug of unique) { | |
| const doc = await api(`/api/docs/${docId}?page=${encodeURIComponent(slug)}`) | |
| if (doc?.error) continue | |
| pages.push({ slug, markdown: doc.markdown || '' }) | |
| } | |
| if (!pages.length) return uiToast('nothing to print') | |
| document.getElementById('print-view')?.remove() | |
| const view = el('div', { id: 'print-view' }) | |
| for (const [i, page] of pages.entries()) { | |
| const sheet = el('section', { class: 'print-page tiptap' + (i ? ' page-break' : '') }) | |
| sheet.innerHTML = markdownToBlocks(page.markdown).map(blockToHtml).join('') | |
| view.appendChild(sheet) | |
| } | |
| document.body.appendChild(view) | |
| // blockToHtml leaves formulas as placeholders carrying their source (it builds | |
| // a string), so they have to be typeset now or the paper shows blanks | |
| await loadKatex() | |
| typesetMathIn(view) | |
| // let layout and any images settle before the dialog freezes the rendering | |
| await new Promise(r => requestAnimationFrame(() => setTimeout(r, 120))) | |
| await Promise.all( | |
| [...view.querySelectorAll('img')].map(img => (img.complete ? null : new Promise(r => { img.onload = img.onerror = r }))) | |
| ) | |
| document.body.classList.add('printing') | |
| const cleanup = () => { | |
| document.body.classList.remove('printing') | |
| document.getElementById('print-view')?.remove() | |
| window.removeEventListener('afterprint', cleanup) | |
| } | |
| window.addEventListener('afterprint', cleanup) | |
| window.print() | |
| // Safari/Firefox fire afterprint reliably; this is the belt for the rest | |
| setTimeout(cleanup, 60000) | |
| } | |
| // --- sharing ------------------------------------------------------------------ | |
| function wireSharePanel() { | |
| const btn = document.getElementById('share-btn') | |
| const pop = document.getElementById('share-pop') | |
| btn.addEventListener('click', () => { | |
| pop.classList.toggle('hidden') | |
| if (!pop.classList.contains('hidden')) renderShareList() | |
| }) | |
| document.getElementById('share-form').addEventListener('submit', async e => { | |
| e.preventDefault() | |
| const input = document.getElementById('share-user') | |
| const username = input.value.trim() | |
| if (!username) return | |
| const res = await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username } }) | |
| if (res.error) return uiToast(res.error) | |
| input.value = '' | |
| renderShareList() | |
| }) | |
| } | |
| // Opening the panel and clicking "Create link" both render this list, so two | |
| // fetches can be in flight at once. Without a sequence check the slower (older) | |
| // answer lands last and paints a link that has just been created away again. | |
| let shareRenderSeq = 0 | |
| async function renderShareList() { | |
| const seq = ++shareRenderSeq | |
| const meta = await api(`/api/docs/${docId}`) | |
| if (seq !== shareRenderSeq) return // a newer render already answered | |
| const wrap = document.getElementById('share-list') | |
| if (meta.error) { | |
| wrap.textContent = meta.error | |
| return | |
| } | |
| const isCreator = !meta.created_by || meta.created_by === state.me.username | |
| document.getElementById('share-form').classList.toggle('hidden', !isCreator) | |
| wrap.className = '' | |
| const rows = [] | |
| if (meta.created_by) { | |
| const owner = el('div', { class: 'share-row' }) | |
| owner.append(el('span', { class: 'handle' }, meta.created_by), el('span', { class: 'muted' }, 'owner')) | |
| rows.push(owner) | |
| } | |
| for (const username of meta.shared_with || []) { | |
| const row = el('div', { class: 'share-row' }) | |
| row.appendChild(el('span', { class: 'handle' }, username)) | |
| if (isCreator) { | |
| const rm = el('button', { class: 'iconbtn', title: 'Remove access' }) | |
| rm.appendChild(icon('x')) | |
| rm.addEventListener('click', async () => { | |
| await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username, remove: true } }) | |
| renderShareList() | |
| }) | |
| row.appendChild(rm) | |
| } | |
| rows.push(row) | |
| } | |
| if (!rows.length) rows.push(el('div', { class: 'muted' }, 'Not shared with anyone yet.')) | |
| wrap.replaceChildren(...rows) | |
| renderLinkShare(isCreator, meta.public_link || null) | |
| } | |
| // "Anyone with the link" — a URL that needs no account. Creator-only, like | |
| // every other sharing decision, and it says plainly what it hands out: the | |
| // whole project, comments included, because that is what the visitor's browser | |
| // receives over the socket. | |
| function renderLinkShare(isCreator, url) { | |
| const box = document.getElementById('link-share') | |
| box.classList.toggle('hidden', !isCreator) | |
| if (!isCreator) return | |
| const hint = document.getElementById('link-hint') | |
| const row = document.getElementById('link-row') | |
| const toggle = document.getElementById('link-toggle') | |
| const field = document.getElementById('link-url') | |
| row.classList.toggle('hidden', !url) | |
| toggle.textContent = url ? 'Turn off' : 'Create link' | |
| hint.textContent = url | |
| ? 'On — anyone with this link can read the project and its comments, without signing in. They cannot edit.' | |
| : 'Off — only the people above can open this.' | |
| // compose the visible link from THIS origin: the server's configured HOST is | |
| // right for the agent prompt's curl examples, but the link a person copies | |
| // has to work from where they are | |
| if (url) field.value = `${location.origin}/p/${url.split('/p/')[1]}` | |
| toggle.onclick = async () => { | |
| const res = url | |
| ? await api(`/api/docs/${docId}/public-link`, { method: 'DELETE' }) | |
| : await api(`/api/docs/${docId}/public-link`, { method: 'POST' }) | |
| if (res.error) return uiToast(res.error) | |
| renderShareList() | |
| } | |
| document.getElementById('link-copy').onclick = async () => { | |
| await navigator.clipboard.writeText(field.value) | |
| const btn = document.getElementById('link-copy') | |
| btn.textContent = 'Copied' | |
| setTimeout(() => (btn.textContent = 'Copy'), 1500) | |
| } | |
| } | |
| async function refreshAgents() { | |
| if (state.viewer) return // no account, no agents — and the endpoint would 401 | |
| const res = await api(`/api/agents?doc=${docId}`) | |
| if (!res.agents) return | |
| state.agents = res.agents | |
| const wrap = document.getElementById('agents-list') | |
| if (!res.agents.length) { | |
| wrap.className = 'muted' | |
| wrap.textContent = 'No agents registered yet.' | |
| return | |
| } | |
| wrap.className = '' | |
| wrap.replaceChildren( | |
| ...res.agents.map(a => { | |
| const row = el('div', { class: 'agent-row' }) | |
| row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''} tip-below`, 'data-tip': a.online ? 'Online — the agent is polling for work' : 'Offline — the agent is not polling right now' })) | |
| const id = el('span', { class: 'agent-id' }) | |
| id.appendChild(el('span', { class: 'handle' }, `@${a.handle}`)) | |
| const detail = a.managed | |
| ? `${a.owner} · built in · ${a.model?.split('/').pop() || 'Kimi K3'} · private` | |
| : a.owner + (a.shared ? '' : ' · private') | |
| id.appendChild(el('span', { class: 'muted owner' }, detail)) | |
| row.appendChild(id) | |
| const actions = el('span', { class: 'actions' }) | |
| if (!a.managed) { | |
| const copy = el('button', { class: 'iconbtn', 'data-tip': 'Copy the agent prompt (paste it into your coding agent)' }) | |
| copy.appendChild(icon('copy')) | |
| copy.addEventListener('click', async () => { | |
| const text = await fetch(`/api/agent-prompt?doc=${docId}&handle=${a.handle}`).then(r => r.text()) | |
| await navigator.clipboard.writeText(text) | |
| copy.replaceChildren(icon('check')) | |
| setTimeout(() => copy.replaceChildren(icon('copy')), 1500) | |
| }) | |
| actions.appendChild(copy) | |
| } | |
| if (a.owner === state.me.username && !a.managed) { | |
| const vis = el('button', { | |
| class: 'iconbtn' + (a.shared ? ' on' : ''), | |
| 'data-tip': a.shared ? 'Shared: collaborators can @mention this agent — click to make it private' : 'Private: only you can @mention it — click to share', | |
| }) | |
| vis.appendChild(icon(a.shared ? 'eye' : 'eyeoff')) | |
| vis.addEventListener('click', async () => { | |
| const res = await api(`/api/agents/${a.handle}/visibility`, { method: 'POST', body: { shared: !a.shared } }) | |
| if (res.error) uiToast(res.error) | |
| else refreshAgents() | |
| }) | |
| actions.appendChild(vis) | |
| const rot = el('button', { class: 'iconbtn', 'data-tip': 'Issue a new key (the old one stops working)' }) | |
| rot.appendChild(icon('key')) | |
| rot.addEventListener('click', async () => { | |
| const res = await api(`/api/agents/${a.handle}/rotate`, { method: 'POST' }) | |
| if (res.error) uiToast(res.error) | |
| else document.getElementById('agents-pop').appendChild(agentKeyBox({ handle: a.handle, key: res.key, docId })) | |
| }) | |
| actions.appendChild(rot) | |
| } | |
| if (!a.managed && (a.owner === state.me.username || state.me.isAdmin)) { | |
| const del = el('button', { class: 'iconbtn', 'data-tip': 'Remove this agent (its key stops working, open tasks are cancelled)' }) | |
| del.appendChild(icon('trash')) | |
| del.addEventListener('click', async () => { | |
| const ok = await uiConfirm({ | |
| title: `Remove @${a.handle}`, | |
| message: `Remove @${a.handle}? Its key stops working and open tasks are cancelled.`, | |
| confirmLabel: 'Remove', | |
| danger: true, | |
| }) | |
| if (!ok) return | |
| const res = await api(`/api/agents/${a.handle}`, { method: 'DELETE' }) | |
| if (res.error) uiToast(res.error) | |
| refreshAgents() | |
| }) | |
| actions.appendChild(del) | |
| } | |
| row.appendChild(actions) | |
| return row | |
| }) | |
| ) | |
| } | |
| // --- mention autocomplete ---------------------------------------------------------- | |
| function wireMentionMenu(input, menu) { | |
| const getQuery = () => { | |
| const pos = input.selectionStart ?? input.value.length | |
| const before = input.value.slice(0, pos) | |
| const m = before.match(/@([a-z0-9_.-]*)$/i) | |
| return m ? { q: m[1].toLowerCase(), start: pos - m[1].length } : null | |
| } | |
| let options = [] | |
| let sel = 0 | |
| const hide = () => { | |
| menu.classList.add('hidden') | |
| options = [] | |
| } | |
| const insert = a => { | |
| const ctx = getQuery() | |
| if (!a || !ctx) return | |
| input.value = input.value.slice(0, ctx.start) + a.handle + ' ' + input.value.slice(input.selectionStart) | |
| const caret = ctx.start + a.handle.length + 1 | |
| input.setSelectionRange(caret, caret) | |
| hide() | |
| input.focus({ preventScroll: true }) | |
| } | |
| const render = () => { | |
| const ctx = getQuery() | |
| if (!ctx) return hide() | |
| // connected agents first, then your own — the preselected row is an agent | |
| // that can act on this now, and the green dot beside it says why it is top | |
| const mine = a => a.owner === state.me?.username | |
| const rank = a => (a.online ? 0 : 2) + (mine(a) ? 0 : 1) | |
| options = state.agents | |
| .filter(a => a.handle.startsWith(ctx.q)) | |
| .sort((a, b) => rank(a) - rank(b)) | |
| .slice(0, 6) | |
| if (!options.length) return hide() | |
| sel = Math.min(sel, options.length - 1) | |
| menu.replaceChildren( | |
| ...options.map((a, i) => { | |
| const opt = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') }) | |
| opt.append(el('span', { class: `dot${a.online ? ' on' : ''}` }), document.createTextNode(`@${a.handle}`)) | |
| opt.addEventListener('mouseenter', () => { | |
| if (sel === i) return | |
| sel = i | |
| render() | |
| }) | |
| opt.addEventListener('mousedown', e => { | |
| e.preventDefault() | |
| insert(a) | |
| }) | |
| return opt | |
| }) | |
| ) | |
| menu.classList.remove('hidden') | |
| } | |
| input.addEventListener('input', () => { | |
| sel = 0 // a new query means a new list — start at your first agent again | |
| render() | |
| }) | |
| // arrows move through the list, Enter/Tab picks, Escape closes it without | |
| // touching the comment box itself | |
| input.addEventListener('keydown', e => { | |
| if (menu.classList.contains('hidden') || !options.length) return | |
| if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| e.preventDefault() | |
| sel = (sel + (e.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length | |
| render() | |
| menu.querySelector('.selected')?.scrollIntoView({ block: 'nearest' }) | |
| return | |
| } | |
| if (e.key === 'Enter' || e.key === 'Tab') { | |
| e.preventDefault() | |
| insert(options[sel]) | |
| return | |
| } | |
| if (e.key === 'Escape') { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| hide() | |
| } | |
| }) | |
| input.addEventListener('blur', () => setTimeout(hide, 150)) | |
| } | |
| // --- presence ----------------------------------------------------------------------- | |
| function renderPresence(provider) { | |
| const wrap = document.getElementById('presence') | |
| const seen = new Set() | |
| const avatars = [] | |
| provider.awareness?.getStates().forEach(s => { | |
| const u = s.user | |
| if (s.page !== pageSlug || !u?.name || seen.has(u.name)) return | |
| seen.add(u.name) | |
| const a = el('div', { class: 'avatar', title: u.name }, u.name.slice(0, 2).toUpperCase()) | |
| a.style.background = u.color || '#999' | |
| avatars.push(a) | |
| }) | |
| wrap.replaceChildren(...avatars) | |
| } | |