| import * as Y from 'yjs' |
| import { HocuspocusProvider } from '@hocuspocus/provider' |
| import { Editor, Extension } from '@tiptap/core' |
| import StarterKit from '@tiptap/starter-kit' |
| import Collaboration from '@tiptap/extension-collaboration' |
| import CollaborationCaret from '@tiptap/extension-collaboration-caret' |
| import Image from '@tiptap/extension-image' |
| import { TaskList, TaskItem } from '@tiptap/extension-list' |
| import { InputRule } from '@tiptap/core' |
|
|
| |
| |
| |
| 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') { |
| |
| chain().deleteRange(range).toggleList('taskList', 'taskItem').updateAttributes('taskItem', { checked }).run() |
| } else if (parent === 'taskItem') { |
| |
| chain().deleteRange(range).updateAttributes('taskItem', { checked }).run() |
| } else { |
| return null |
| } |
| }, |
| }), |
| ] |
| }, |
| }) |
| import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table' |
|
|
| |
| |
| 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'), |
| }, |
| } |
| }, |
| }) |
| |
| |
| |
| import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, relativePositionToAbsolutePosition } from '@tiptap/y-tiptap' |
| import { markdownToBlocks } from '../../server/md.js' |
| import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' |
| import { Decoration, DecorationSet } from '@tiptap/pm/view' |
| import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox } from './common.js' |
| import { wordDiff, arrayDiff } from './diff.js' |
|
|
| const docId = location.pathname.split('/').filter(Boolean)[1] |
| |
| |
| let pageSlug = '' |
| let docName = '' |
| let pageQS = '' |
|
|
| function setPage(slug) { |
| pageSlug = slug |
| docName = slug === 'home' ? docId : `${docId}::${slug}` |
| pageQS = slug === 'home' ? '' : `?page=${slug}` |
| } |
| setPage(location.pathname.split('/').filter(Boolean)[2] || 'home') |
|
|
| const state = { |
| me: null, |
| agents: [], |
| activeItem: null, |
| showResolved: false, |
| composerAnchors: null, |
| suggestAnchors: null, |
| chainPos: {}, |
| previewPid: null, |
| } |
|
|
| let ydoc = null |
| let threads = null |
| let suggestions = null |
| let provider = null |
|
|
| main() |
|
|
| async function main() { |
| state.me = await initAuth(`/d/${docId}`) |
| if (!state.me) return |
|
|
| |
| document.getElementById('back-link').appendChild(icon('back')) |
| wireSelectionMenu() |
| wireMarginToggle() |
| wireComposer() |
| wireSuggestComposer() |
| wireAgentsPanel() |
| wireSharePanel() |
| wireSidebar() |
|
|
| document.getElementById('show-resolved').addEventListener('change', e => { |
| state.showResolved = e.target.checked |
| renderMargin(window.__editor) |
| }) |
| window.addEventListener('resize', () => scheduleLayout(window.__editor)) |
| new ResizeObserver(() => scheduleLayout(window.__editor)).observe(document.getElementById('editor')) |
|
|
| |
| document.addEventListener('mousedown', e => { |
| if (e.target.closest('.card, .composer-box, #selection-menu, .mention-menu, #agents-pop, #agents-btn, #share-pop, #share-btn, #link-pop')) return |
| if (state.activeItem) { |
| state.activeItem = null |
| renderMargin(window.__editor) |
| } |
| for (const id of ['agents-pop', 'share-pop']) { |
| document.getElementById(id).classList.add('hidden') |
| } |
| }) |
|
|
| |
| window.addEventListener('popstate', () => { |
| const slug = location.pathname.split('/').filter(Boolean)[2] || 'home' |
| if (slug !== pageSlug) openPage(slug, { push: false }) |
| }) |
|
|
| await initPage() |
|
|
| loadStructure() |
| setInterval(loadStructure, 15000) |
| window.addEventListener('focus', () => loadStructure()) |
| refreshAgents() |
| setInterval(refreshAgents, 20000) |
|
|
| |
| const meta = await api(`/api/docs/${docId}`) |
| if (meta?.playground && meta.created_by === state.me.username) { |
| const reset = el('button', { class: 'btn ghost small', id: 'reset-btn', title: 'Restore the playground to its seeded state' }, 'Reset') |
| reset.addEventListener('click', async () => { |
| if (!confirm('Reset the playground to its original seeded state? All changes here are lost.')) return |
| reset.disabled = true |
| const res = await api(`/api/docs/${docId}/reset`, { method: 'POST' }) |
| if (res.error) { |
| alert(res.error) |
| reset.disabled = false |
| return |
| } |
| location.href = `/d/${docId}` |
| }) |
| document.getElementById('share-btn').before(reset) |
| } |
| } |
|
|
| |
|
|
| async function initPage() { |
| |
| |
| if (pageSlug !== 'home' && pageSlug !== '_structure') { |
| const s = await api(`/api/docs/${docId}/structure`) |
| if (s?.titles && !(pageSlug in s.titles)) { |
| const pretty = pageSlug.replace(/-/g, ' ') |
| await api(`/api/docs/${docId}/pages`, { |
| method: 'POST', |
| body: { title: pretty.charAt(0).toUpperCase() + pretty.slice(1), slug: pageSlug }, |
| }) |
| } |
| } |
|
|
| exitProposalPreview() |
| ydoc = new Y.Doc() |
| threads = ydoc.getMap('threads') |
| suggestions = ydoc.getMap('suggestions') |
|
|
| const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/collab` |
| provider = new HocuspocusProvider({ |
| url: wsUrl, |
| name: docName, |
| document: ydoc, |
| |
| token: 'cookie:' + (state.me.buildId || ''), |
| onStatus: ({ status }) => { |
| document.getElementById('conn').className = status === 'connected' ? 'on' : '' |
| if (status === 'connected') sessionStorage.removeItem('cw-reloaded') |
| }, |
| onAuthenticationFailed: handleAuthFailed, |
| }) |
|
|
| const editor = buildEditor(provider) |
| window.__editor = editor |
| window.__ydoc = ydoc |
| window.__Y = Y |
|
|
| buildHeaderTools(editor) |
| bindSelectionMenu(editor) |
| |
| |
| threads.observeDeep(() => { |
| renderMargin(editor) |
| poke(editor) |
| }) |
| suggestions.observeDeep(() => { |
| renderMargin(editor) |
| poke(editor) |
| }) |
| editor.on('update', () => { |
| updateTitle(editor) |
| scheduleLayout(editor) |
| }) |
| provider.on('awarenessUpdate', () => renderPresence(provider)) |
| wireUndoReopens(editor) |
| wireImageBubble(editor) |
| wireTableControls(editor) |
| wirePageLinkMenu(editor) |
| if (pageSlug === '_structure') { |
| |
| let t = null |
| editor.on('update', () => { |
| clearTimeout(t) |
| t = setTimeout(loadStructure, 500) |
| }) |
| } |
|
|
| document.getElementById('layout').classList.toggle('structure-mode', pageSlug === '_structure') |
| document.getElementById('structure-hint')?.remove() |
| if (pageSlug === '_structure') { |
| const hint = el( |
| 'div', |
| { id: 'structure-hint' }, |
| 'This YAML is the page tree: one "- slug" line per page, indent two spaces to nest. Pages missing here show under "unfiled" — nothing is ever deleted by editing this. Changes can also be proposed as suggestions.' |
| ) |
| document.getElementById('editor').before(hint) |
| } |
|
|
| renderMargin(editor) |
| setTimeout(() => updateTitle(editor), 600) |
| } |
|
|
| function teardownPage() { |
| try { |
| provider?.destroy() |
| } catch {} |
| try { |
| window.__editor?.destroy() |
| } catch {} |
| try { |
| ydoc?.destroy() |
| } catch {} |
| provider = null |
| state.activeItem = null |
| state.chainPos = {} |
| state.composerAnchors = null |
| state.suggestAnchors = null |
| document.getElementById('composer').classList.add('hidden') |
| document.getElementById('suggest-composer').classList.add('hidden') |
| document.getElementById('selection-menu').classList.add('hidden') |
| document.querySelectorAll('#margin-items .card, #margin-items .margin-empty').forEach(n => n.remove()) |
| } |
|
|
| |
| async function openPage(slug, { push = true } = {}) { |
| if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) { |
| |
| } else if (slug === pageSlug) { |
| return |
| } |
| exitProposalPreview() |
| teardownPage() |
| setPage(slug) |
| if (push) history.pushState({}, '', slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`) |
| await initPage() |
| loadStructure() |
| } |
|
|
| function handleAuthFailed({ reason }) { |
| if (!String(reason || '').includes('outdated')) return |
| try { |
| provider?.destroy() |
| } catch {} |
| if (!sessionStorage.getItem('cw-reloaded')) { |
| sessionStorage.setItem('cw-reloaded', '1') |
| location.reload() |
| return |
| } |
| const bar = el('div', { id: 'update-banner' }, 'A new version of cowrite is available — reload the page to keep editing. ') |
| const a = el('a', { href: location.pathname }, 'Reload now') |
| bar.appendChild(a) |
| document.body.appendChild(bar) |
| } |
|
|
| |
|
|
| |
| |
| 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 |
| } |
| } |
|
|
| |
| |
| |
| function wireTableControls(editor) { |
| document.getElementById('table-controls')?.remove() |
| const bar = el('div', { id: 'table-controls', class: 'hidden' }) |
| const add = (label, tip, cmd) => { |
| const b = el('button', { class: 'iconbtn', 'data-tip': tip }, label) |
| b.addEventListener('mousedown', e => { |
| e.preventDefault() |
| cmd() |
| }) |
| bar.appendChild(b) |
| return b |
| } |
| add('+ Row', 'Add a row below', () => editor.chain().focus().addRowAfter().run()) |
| add('− Row', 'Delete this row', () => editor.chain().focus().deleteRow().run()) |
| bar.appendChild(el('span', { class: 'sep' })) |
| add('+ Col', 'Add a column to the right', () => editor.chain().focus().addColumnAfter().run()) |
| add('− Col', 'Delete this column', () => editor.chain().focus().deleteColumn().run()) |
| bar.appendChild(el('span', { class: 'sep' })) |
| const del = add('⌫', 'Delete the whole table', () => { |
| if (confirm('Delete this table?')) editor.chain().focus().deleteTable().run() |
| }) |
| del.classList.add('danger') |
| document.body.appendChild(bar) |
|
|
| const update = () => { |
| if (editor.isDestroyed || !editor.isActive('table')) { |
| bar.classList.add('hidden') |
| return |
| } |
| let dom = editor.view.domAtPos(editor.state.selection.from)?.node |
| if (dom?.nodeType === 3) dom = dom.parentElement |
| const table = dom?.closest?.('table') |
| if (!table) { |
| bar.classList.add('hidden') |
| return |
| } |
| const r = table.getBoundingClientRect() |
| bar.classList.remove('hidden') |
| bar.style.left = `${Math.max(8, r.left)}px` |
| bar.style.top = `${Math.max(8, r.top - bar.offsetHeight - 6)}px` |
| } |
| editor.on('selectionUpdate', update) |
| editor.on('transaction', update) |
| window.addEventListener('scroll', update, true) |
| } |
|
|
| |
|
|
| 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) |
| } |
|
|
| |
|
|
| function setMarginHidden(hidden, persist = true) { |
| document.getElementById('margin-col').classList.toggle('hidden', hidden) |
| document.getElementById('margin-toggle').textContent = hidden ? '◂' : '▸' |
| if (persist) localStorage.setItem('margin-hidden', hidden ? '1' : '0') |
| scheduleLayout(window.__editor) |
| } |
|
|
| function wireMarginToggle() { |
| const stored = localStorage.getItem('margin-hidden') |
| |
| const hidden = stored == null ? window.matchMedia('(max-width: 1150px)').matches : stored === '1' |
| setMarginHidden(hidden, false) |
| document.getElementById('margin-toggle').addEventListener('click', () => { |
| setMarginHidden(!document.getElementById('margin-col').classList.contains('hidden')) |
| }) |
| } |
|
|
| |
| |
| 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) |
| } |
|
|
| |
|
|
| function wireSidebar() { |
| const sidebar = document.getElementById('sidebar') |
| const toggle = document.getElementById('sidebar-toggle') |
| const stored = localStorage.getItem('sidebar-hidden') |
| |
| const hidden = stored == null ? window.matchMedia('(max-width: 980px)').matches : stored === '1' |
| sidebar.classList.toggle('hidden', hidden) |
| toggle.textContent = hidden ? '▸' : '◂' |
| toggle.addEventListener('click', () => { |
| const nowHidden = !sidebar.classList.contains('hidden') |
| sidebar.classList.toggle('hidden', nowHidden) |
| toggle.textContent = nowHidden ? '▸' : '◂' |
| localStorage.setItem('sidebar-hidden', nowHidden ? '1' : '0') |
| scheduleLayout(window.__editor) |
| }) |
| document.getElementById('page-add').addEventListener('click', async () => { |
| const title = prompt('Page title') |
| if (!title?.trim()) return |
| const res = await api(`/api/docs/${docId}/pages`, { method: 'POST', body: { title: title.trim() } }) |
| if (res.error) return alert(res.error) |
| openPage(res.slug) |
| }) |
| const structureLink = document.getElementById('structure-link') |
| structureLink.href = `/d/${docId}/_structure` |
| structureLink.replaceChildren(icon('tree'), document.createTextNode(' Structure')) |
| structureLink.addEventListener('click', e => { |
| if (e.metaKey || e.ctrlKey || e.button !== 0) return |
| e.preventDefault() |
| openPage('_structure') |
| }) |
| if (pageSlug === '_structure') structureLink.classList.add('current') |
| } |
|
|
| async function loadStructure() { |
| const s = await api(`/api/docs/${docId}/structure`) |
| if (s?.error) return |
| state.pageTitles = s.titles || {} |
| document.getElementById('structure-link')?.classList.toggle('current', pageSlug === '_structure') |
| renderSlugLegend(s) |
| const container = document.getElementById('page-tree') |
| const known = new Set(Object.keys(s.titles || {})) |
| known.add(pageSlug) |
| const pageHref = slug => (slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`) |
| const deletablePage = slug => slug !== 'home' && slug !== '_structure' && known.has(slug) |
| const pageDeleteBtn = slug => { |
| const b = el('button', { class: 'iconbtn page-del', 'data-tip': `Delete page "${s.titles?.[slug] || slug}" for everyone` }) |
| b.appendChild(icon('trash')) |
| b.addEventListener('click', async e => { |
| e.preventDefault() |
| e.stopPropagation() |
| if (!confirm(`Delete the page "${s.titles?.[slug] || slug}" for everyone? This cannot be undone.`)) return |
| const res = await api(`/api/docs/${docId}/pages/${slug}`, { method: 'DELETE' }) |
| if (res.error) return alert(res.error) |
| if (slug === pageSlug) openPage('home') |
| else loadStructure() |
| }) |
| return b |
| } |
| const renderNodes = nodes => { |
| const ul = el('ul', {}) |
| for (const node of nodes) { |
| const li = el('li', {}) |
| const a = el( |
| 'a', |
| { |
| href: pageHref(node.slug), |
| class: (node.slug === pageSlug ? 'current' : '') + (known.has(node.slug) ? '' : ' ghost-page'), |
| title: known.has(node.slug) ? '' : `no page "${node.slug}" yet — create it or fix the structure yaml`, |
| }, |
| s.titles?.[node.slug] || node.slug |
| ) |
| a.addEventListener('click', e => { |
| if (e.metaKey || e.ctrlKey || e.button !== 0) return |
| e.preventDefault() |
| openPage(node.slug) |
| }) |
| if (deletablePage(node.slug)) a.appendChild(pageDeleteBtn(node.slug)) |
| li.appendChild(a) |
| if (node.children?.length) li.appendChild(renderNodes(node.children)) |
| ul.appendChild(li) |
| } |
| return ul |
| } |
| container.innerHTML = '' |
| container.appendChild(renderNodes(s.tree || [])) |
| if (s.unfiled?.length) { |
| container.appendChild(el('div', { class: 'unfiled-label' }, 'unfiled')) |
| const ul = el('ul', {}) |
| for (const slug of s.unfiled) { |
| if (slug === 'home') continue |
| const li = el('li', {}) |
| const a = el('a', { href: pageHref(slug), class: slug === pageSlug ? 'current' : '' }, s.titles?.[slug] || slug) |
| a.addEventListener('click', e => { |
| if (e.metaKey || e.ctrlKey || e.button !== 0) return |
| e.preventDefault() |
| openPage(slug) |
| }) |
| if (deletablePage(slug)) a.appendChild(pageDeleteBtn(slug)) |
| li.appendChild(a) |
| ul.appendChild(li) |
| } |
| if (ul.children.length) container.appendChild(ul) |
| } |
|
|
| |
| if (s.pending?.length) { |
| container.appendChild(el('div', { class: 'unfiled-label' }, 'proposed')) |
| const ul = el('ul', {}) |
| for (const p of s.pending) { |
| const li = el('li', {}) |
| const a = el( |
| 'a', |
| { |
| href: '#', |
| class: 'pending-page' + (state.previewPid === p.id ? ' current' : ''), |
| title: `Proposed by ${p.author_type === 'agent' ? '@' + p.author : p.author}${p.rationale ? ' — ' + p.rationale : ''}`, |
| }, |
| p.title || p.slug |
| ) |
| a.appendChild(el('span', { class: 'pending-badge' }, p.author_type === 'agent' ? '@' + p.author : 'proposed')) |
| a.addEventListener('click', e => { |
| e.preventDefault() |
| openProposalPreview(p.id) |
| }) |
| li.appendChild(a) |
| ul.appendChild(li) |
| } |
| container.appendChild(ul) |
| } |
| } |
|
|
| |
|
|
| function exitProposalPreview() { |
| const view = document.getElementById('proposal-view') |
| if (!view) return |
| view.classList.add('hidden') |
| view.replaceChildren() |
| document.getElementById('editor').style.display = '' |
| state.previewPid = null |
| } |
|
|
| async function openProposalPreview(pid) { |
| exitProposalPreview() |
| teardownPage() |
| state.previewPid = pid |
| document.getElementById('editor').style.display = 'none' |
| const view = document.getElementById('proposal-view') |
| view.classList.remove('hidden') |
| loadStructure() |
| const p = await api(`/api/docs/${docId}/page-suggestions/${pid}`) |
| if (p.error) { |
| view.textContent = p.error |
| return |
| } |
| document.getElementById('doc-title').textContent = p.title + ' — proposed' |
| document.title = p.title + ' — proposed' |
| const bar = el('div', { class: 'proposal-bar' }) |
| bar.appendChild( |
| el('div', { class: 'proposal-meta' }, `Proposed new page by ${p.author_type === 'agent' ? '@' + p.author : p.author}${p.rationale ? ' — ' + p.rationale : ''}`) |
| ) |
| const actions = el('div', { class: 'proposal-actions' }) |
| const reject = el('button', { class: 'btn small ghost' }, 'Reject') |
| reject.addEventListener('click', async () => { |
| if (!confirm(`Reject the proposed page "${p.title}"?`)) return |
| const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/reject`, { method: 'POST' }) |
| if (r.error) return alert(r.error) |
| exitProposalPreview() |
| openPage('home') |
| }) |
| const accept = el('button', { class: 'btn small primary' }, 'Accept & create page') |
| accept.addEventListener('click', async () => { |
| const r = await api(`/api/docs/${docId}/page-suggestions/${pid}/accept`, { method: 'POST' }) |
| if (r.error) return alert(r.error) |
| exitProposalPreview() |
| openPage(r.slug) |
| }) |
| actions.append(reject, accept) |
| bar.appendChild(actions) |
| const content = el('div', { class: 'proposal-content tiptap' }) |
| content.innerHTML = markdownToBlocks(p.content_markdown).map(blockToHtml).join('') |
| view.replaceChildren(bar, content) |
| } |
|
|
| function updateTitle(editor) { |
| if (pageSlug === '_structure') { |
| document.getElementById('doc-title').textContent = 'Structure' |
| document.title = 'Structure' |
| return |
| } |
| let title = 'Untitled' |
| editor.state.doc.descendants(node => { |
| if (title === 'Untitled' && node.isTextblock && node.textContent.trim()) { |
| title = node.textContent.trim().slice(0, 80) |
| return false |
| } |
| return title === 'Untitled' |
| }) |
| document.getElementById('doc-title').textContent = title |
| document.title = title |
| const cur = document.querySelector('#page-tree a.current') |
| if (cur && title !== 'Untitled' && cur.textContent !== title) cur.textContent = title |
| } |
|
|
| |
|
|
| const highlightKey = new PluginKey('collab-highlights') |
|
|
| function buildEditor(provider) { |
| const Highlights = Extension.create({ |
| name: 'collabHighlights', |
| priority: 10000, |
| addKeyboardShortcuts() { |
| return { |
| 'Mod-z': () => runUndo(this.editor), |
| 'Mod-Z': () => runRedo(this.editor), |
| 'Mod-Shift-z': () => runRedo(this.editor), |
| 'Mod-y': () => runRedo(this.editor), |
| Enter: () => pageLinkKey('Enter'), |
| ArrowDown: () => pageLinkKey('Down'), |
| ArrowUp: () => pageLinkKey('Up'), |
| Escape: () => pageLinkKey('Esc'), |
| Tab: () => pageLinkKey('Enter'), |
| } |
| }, |
| addProseMirrorPlugins() { |
| return [ |
| new Plugin({ |
| key: highlightKey, |
| props: { |
| decorations: buildDecorations, |
| handleClickOn: (view, pos, node, nodePos, event) => { |
| const target = event.target?.closest?.('.comment-hl, [data-sugg]') |
| if (target?.dataset.thread) { |
| setActive(target.dataset.thread) |
| return false |
| } |
| if (target?.dataset.sugg) { |
| setActive(target.dataset.sugg) |
| return false |
| } |
| }, |
| }, |
| }), |
| ] |
| }, |
| }) |
|
|
| return new Editor({ |
| element: document.getElementById('editor'), |
| extensions: [ |
| StarterKit.configure({ undoRedo: false, link: { openOnClick: false } }), |
| LayoutImage, |
| CheckboxInList, |
| TaskItem.configure({ nested: false }), |
| Table.configure({ resizable: false }), |
| TableRow, |
| TableHeader, |
| TableCell, |
| Collaboration.configure({ document: ydoc }), |
| CollaborationCaret.configure({ |
| provider, |
| user: { name: state.me.username, color: colorFor(state.me.username) }, |
| }), |
| Highlights, |
| ], |
| editorProps: { |
| handleClick: (view, pos, event) => { |
| const a = event.target?.closest?.('a') |
| if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey)) return false |
| const url = new URL(a.getAttribute('href'), location.origin) |
| const internal = url.origin === location.origin && url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?$`)) |
| if (internal) openPage(internal[1] || 'home') |
| else if (url.origin === location.origin) location.href = url.pathname + url.search |
| else window.open(url.href, '_blank', 'noopener') |
| return true |
| }, |
| handlePaste: (view, event) => { |
| const file = [...(event.clipboardData?.files || [])].find(f => f.type.startsWith('image/')) |
| if (!file) return false |
| uploadAndInsertImage(file) |
| return true |
| }, |
| handleDrop: (view, event) => { |
| const file = [...(event.dataTransfer?.files || [])].find(f => f.type.startsWith('image/')) |
| if (!file) return false |
| event.preventDefault() |
| uploadAndInsertImage(file) |
| return true |
| }, |
| }, |
| }) |
| } |
|
|
| async function uploadAndInsertImage(file) { |
| if (file.size > 10 * 1024 * 1024) return alert('Image too large (max 10 MB)') |
| const res = await fetch(`/api/docs/${docId}/upload`, { |
| method: 'POST', |
| headers: { 'content-type': file.type }, |
| body: file, |
| }).then(r => r.json()) |
| if (res.error) return alert(res.error) |
| window.__editor.chain().focus().setImage({ src: res.url }).run() |
| } |
|
|
| function buildDecorations(pmState) { |
| const ystate = ySyncPluginKey.getState(pmState) |
| if (!ystate?.binding) return DecorationSet.empty |
| const decos = [] |
|
|
| threads.forEach((ythread, id) => { |
| if (ythread.get('resolved') || ythread.get('suggestionId')) return |
| const range = anchorsToRange(pmState, ystate, ythread.get('anchorStart'), ythread.get('anchorEnd')) |
| if (!range || range.from >= range.to) return |
| const cls = 'comment-hl' + (state.activeItem === id ? ' active' : '') |
| decos.push(Decoration.inline(range.from, range.to, { class: cls, 'data-thread': id })) |
| }) |
|
|
| |
| for (const chain of suggestionChains()) { |
| const s = chain.display |
| if (s.status !== 'open' && s.status !== 'superseded') continue |
| const range = suggestionRange(pmState, s) |
| if (!range) continue |
| const hlCls = 'suggestion-hl' + (state.activeItem === s.id ? ' active' : '') |
| let firstInRange = true |
| pmState.doc.forEach((node, offset) => { |
| if (offset >= range.from && offset < Math.max(range.to, range.from + 1)) { |
| decos.push(Decoration.node(offset, offset + node.nodeSize, { class: hlCls + (firstInRange ? '' : ' hl-join'), 'data-sugg': s.id })) |
| firstInRange = false |
| } |
| }) |
| try { |
| suggestionDiffDecos(pmState, s, range, decos, chain.members[chain.members.length - 1]) |
| } catch {} |
| } |
|
|
| return DecorationSet.create(pmState.doc, decos) |
| } |
|
|
| |
| |
| |
| function suggestionDiffDecos(pmState, s, range, decos, chainLatest = null) { |
| const A = state.activeItem === s.id ? ' active' : '' |
|
|
| |
| const oldBlocks = [] |
| pmState.doc.forEach((node, offset) => { |
| if (offset >= range.from && offset + node.nodeSize <= Math.max(range.to, range.from + 1)) { |
| oldBlocks.push({ node, pos: offset, ...blockCharMap(node, offset) }) |
| } |
| }) |
| const newBlocks = markdownToBlocks(s.replacementMarkdown).map(block => ({ block, text: blockDescText(block) })) |
| const ops = arrayDiff(oldBlocks, newBlocks, (a, b) => a.text === b.text) |
|
|
| let widgetIdx = 0 |
| let k = 0 |
| let cursorEnd = range.from |
| 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++ |
| } |
| |
| |
| |
| |
| |
| 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) |
| } |
| } |
| |
| |
| 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 |
| |
| |
| |
| |
| 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 |
| } |
| |
| |
| |
| 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 |
| } |
|
|
| |
| function afterAnchorPos(pmState, s) { |
| if (!s.anchorAfter) return null |
| try { |
| const frag = ydoc.getXmlFragment('default') |
| const abs = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorAfter), ydoc) |
| if (!abs || abs.type !== frag) return null |
| if (abs.index >= pmState.doc.childCount) return pmState.doc.content.size |
| let pos = null |
| pmState.doc.forEach((node, offset, i) => { |
| if (i === abs.index) pos = offset |
| }) |
| return pos |
| } catch { |
| return null |
| } |
| } |
|
|
| |
| function inlineBlockDiff(oldB, newB, suggId, A, decos, widgetIdx) { |
| const ops = wordDiff(oldB.text, newB.text) |
| const charToPm = off => (off >= oldB.map.length ? oldB.pos + oldB.node.nodeSize - 1 : oldB.map[off]) |
| const listType = ['bulletList', 'orderedList'].includes(oldB.node.type.name) ? oldB.node.type.name : null |
| for (const op of ops) { |
| if (op.type === 'del' && op.text.trim()) { |
| const from = charToPm(op.oldStart) |
| const to = charToPm(op.oldEnd - 1) + 1 |
| if (to > from) decos.push(Decoration.inline(from, to, { class: 'sugg-del' + A, 'data-sugg': suggId })) |
| } else if (op.type === 'ins' && op.text.trim()) { |
| const pos = charToPm(op.oldStart) |
| const text = op.text |
| decos.push(Decoration.widget(pos, () => insWidget(suggId, text, A, listType), { key: `${suggId}:${widgetIdx}${A}`, marks: [] })) |
| widgetIdx++ |
| } |
| } |
| return widgetIdx |
| } |
|
|
| function insWidget(suggId, text, A, listType = null) { |
| const span = document.createElement('span') |
| span.className = 'sugg-ins' + A |
| span.dataset.sugg = suggId |
| span.addEventListener('click', () => setActive(suggId)) |
| |
| |
| |
| |
| if (listType && text.includes('\n')) { |
| span.className = 'sugg-ins sugg-ins-multi' + A |
| const segs = text.split('\n') |
| if (segs[0].trim()) { |
| const seg = document.createElement('span') |
| seg.className = 'sugg-ins-seg' |
| seg.textContent = segs[0] |
| span.appendChild(seg) |
| } |
| for (const segText of segs.slice(1)) { |
| if (!segText.trim()) continue |
| const li = document.createElement('span') |
| li.className = 'sugg-ins-li' + (listType === 'orderedList' ? ' ordered' : '') |
| li.textContent = segText |
| span.appendChild(li) |
| } |
| } else { |
| span.textContent = text |
| } |
| return span |
| } |
|
|
| |
| |
| function codeBlockDiff(oldB, newB, suggId, A, decos, widgetIdx) { |
| const oldLines = oldB.text.split('\n') |
| const newLines = String(newB.text ?? '').split('\n') |
| const lineStart = [] |
| let off = 0 |
| for (const l of oldLines) { |
| lineStart.push(off) |
| off += l.length + 1 |
| } |
| const charToPm = c => (c >= oldB.map.length ? oldB.pos + oldB.node.nodeSize - 1 : oldB.map[c]) |
| const ops = arrayDiff(oldLines, newLines, (a, b) => a === b) |
| let k = 0 |
| while (k < ops.length) { |
| if (ops[k].type === 'eq') { |
| k++ |
| continue |
| } |
| const delIdx = [] |
| const insTxt = [] |
| while (k < ops.length && ops[k].type !== 'eq') { |
| if (ops[k].type === 'del') delIdx.push(ops[k].aIdx) |
| else insTxt.push(newLines[ops[k].bIdx]) |
| k++ |
| } |
| for (const li of delIdx) { |
| if (!oldLines[li].length) continue |
| const from = charToPm(lineStart[li]) |
| const to = charToPm(lineStart[li] + oldLines[li].length - 1) + 1 |
| if (to > from) decos.push(Decoration.inline(from, to, { class: 'sugg-del' + A, 'data-sugg': suggId })) |
| } |
| if (insTxt.length) { |
| let posChar |
| if (delIdx.length) { |
| const last = delIdx[delIdx.length - 1] |
| posChar = lineStart[last] + oldLines[last].length |
| } else if (k < ops.length && ops[k].type === 'eq') { |
| posChar = lineStart[ops[k].aIdx] |
| } else { |
| posChar = oldB.map.length |
| } |
| const lines = insTxt |
| decos.push( |
| Decoration.widget(charToPm(Math.min(posChar, oldB.map.length)), () => { |
| const span = document.createElement('span') |
| span.className = 'sugg-ins sugg-ins-multi' + A |
| span.dataset.sugg = suggId |
| for (const line of lines) { |
| const div = document.createElement('span') |
| div.className = 'sugg-ins-codeline' |
| div.textContent = line || ' ' |
| span.appendChild(div) |
| } |
| span.addEventListener('click', () => setActive(suggId)) |
| return span |
| }, { key: `${suggId}:code${widgetIdx}${A}`, marks: [] }) |
| ) |
| widgetIdx++ |
| } |
| } |
| return widgetIdx |
| } |
|
|
| function blockInsWidget(suggId, blocks, A) { |
| const div = document.createElement('div') |
| div.className = 'sugg-ins-block' + A |
| div.dataset.sugg = suggId |
| div.innerHTML = blocks.map(blockToHtml).join('') |
| div.addEventListener('click', () => setActive(suggId)) |
| return div |
| } |
|
|
| |
| function blockCharMap(node, pos) { |
| let text = '' |
| const map = [] |
| if (node.type.name === 'image') { |
| for (const ch of '[figure]') { |
| text += ch |
| map.push(pos) |
| } |
| return { text, map } |
| } |
| node.descendants((child, childPos) => { |
| if (child.isText) { |
| if (text && !text.endsWith('\n') && map.length && pos + 1 + childPos > map[map.length - 1] + 1) { |
| |
| } |
| const base = pos + 1 + childPos |
| for (let c = 0; c < child.text.length; c++) { |
| text += child.text[c] |
| map.push(base + c) |
| } |
| } else if (child.type.name === 'image') { |
| for (const ch of '[figure]') { |
| text += ch |
| map.push(pos + 1 + childPos) |
| } |
| } else if (child.isBlock && text && !text.endsWith('\n')) { |
| text += '\n' |
| map.push(pos + 1 + childPos) |
| } |
| return true |
| }) |
| return { text, map } |
| } |
|
|
| |
| function blockDescText(b) { |
| const seg = ss => (ss || []).map(x => x.text).join('') |
| switch (b.type) { |
| case 'bulletList': |
| case 'orderedList': |
| return (b.items || []).map(seg).join('\n') |
| case 'taskList': |
| return (b.items || []).map(it => `[${it.checked ? 'x' : ' '}] ` + seg(it.inline)).join('\n') |
| case 'table': |
| return (b.rows || []).map(r => r.map(seg).join(' | ')).join('\n') |
| case 'codeBlock': |
| return b.text || '' |
| case 'image': |
| return '[figure]' |
| case 'horizontalRule': |
| return '' |
| default: |
| return seg(b.inline) |
| } |
| } |
|
|
| |
| function inlineHtml(segments) { |
| return (segments || []) |
| .map(s => { |
| 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('') |
| } |
|
|
| 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 'blockquote': |
| return `<blockquote><p>${inlineHtml(b.inline)}</p></blockquote>` |
| 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 |
| } |
| } |
|
|
| |
| |
| |
| function suggestionRange(pmState, s) { |
| if (s.anchorEndIncl) { |
| try { |
| const frag = ydoc.getXmlFragment('default') |
| const absS = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorStart), ydoc) |
| const absE = Y.createAbsolutePositionFromRelativePosition(decodeRel(s.anchorEndIncl), ydoc) |
| if (!absS || !absE || absS.type !== frag || absE.type !== frag) return null |
| const si = Math.min(absS.index, absE.index) |
| const ei = Math.max(absS.index, absE.index) |
| let from = null |
| let to = null |
| pmState.doc.forEach((node, offset, i) => { |
| if (i === si) from = offset |
| if (i === ei) to = offset + node.nodeSize |
| }) |
| if (from == null || to == null || to <= from) return null |
| return { from, to } |
| } catch { |
| return null |
| } |
| } |
| const ystate = ySyncPluginKey.getState(pmState) |
| if (!ystate?.binding) return null |
| return anchorsToRange(pmState, ystate, s.anchorStart, s.anchorEnd) |
| } |
|
|
| function decodeRel(b64) { |
| const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/')) |
| const arr = new Uint8Array(bin.length) |
| for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) |
| return Y.decodeRelativePosition(arr) |
| } |
|
|
| function encodeRel(rel) { |
| const bytes = Y.encodeRelativePosition(rel) |
| let bin = '' |
| for (const b of bytes) bin += String.fromCharCode(b) |
| return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') |
| } |
|
|
| function poke(editor) { |
| editor.view.dispatch(editor.view.state.tr.setMeta('collab-refresh', true)) |
| } |
|
|
| function setActive(id) { |
| state.activeItem = id |
| |
| if (id && document.getElementById('margin-col').classList.contains('hidden')) setMarginHidden(false) |
| renderMargin(window.__editor) |
| poke(window.__editor) |
| } |
|
|
| |
| |
| |
| function revealAfterLayout(node) { |
| requestAnimationFrame(() => |
| requestAnimationFrame(() => { |
| try { |
| node.scrollIntoView({ block: 'nearest' }) |
| } catch {} |
| }) |
| ) |
| } |
|
|
| function jumpTo(editor, range) { |
| if (!range) return |
| const pos = Math.min(range.from, editor.state.doc.content.size) |
| editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)).scrollIntoView()) |
| } |
|
|
| |
|
|
| function buildHeaderTools(editor) { |
| const bar = document.getElementById('header-tools') |
| bar.replaceChildren() |
| const sep = () => el('span', { class: 'sep' }) |
| const btn = (label, title, run, isOn) => { |
| const b = el('button', { type: 'button', title }) |
| if (typeof label === 'string') b.textContent = label |
| else b.appendChild(label) |
| b.addEventListener('mousedown', e => { |
| e.preventDefault() |
| run() |
| }) |
| if (isOn) editor.on('transaction', () => b.classList.toggle('on', isOn())) |
| return b |
| } |
|
|
| const boldB = btn('B', 'Bold', () => editor.chain().focus().toggleBold().run(), () => editor.isActive('bold')) |
| boldB.style.fontWeight = '700' |
| const italicB = btn('I', 'Italic', () => editor.chain().focus().toggleItalic().run(), () => editor.isActive('italic')) |
| italicB.style.fontStyle = 'italic' |
| italicB.style.fontFamily = 'var(--serif)' |
| const strikeB = btn('S', 'Strikethrough', () => editor.chain().focus().toggleStrike().run(), () => editor.isActive('strike')) |
| strikeB.style.textDecoration = 'line-through' |
|
|
| bar.append( |
| btn('H1', 'Heading 1', () => editor.chain().focus().toggleHeading({ level: 1 }).run(), () => editor.isActive('heading', { level: 1 })), |
| btn('H2', 'Heading 2', () => editor.chain().focus().toggleHeading({ level: 2 }).run(), () => editor.isActive('heading', { level: 2 })), |
| btn('H3', 'Heading 3', () => editor.chain().focus().toggleHeading({ level: 3 }).run(), () => editor.isActive('heading', { level: 3 })), |
| sep(), |
| boldB, |
| italicB, |
| strikeB, |
| btn('</>', 'Inline code', () => editor.chain().focus().toggleCode().run(), () => editor.isActive('code')), |
| sep(), |
| btn('• List', 'Bullet list', () => editor.chain().focus().toggleBulletList().run(), () => editor.isActive('bulletList')), |
| btn('1. List', 'Ordered list', () => editor.chain().focus().toggleOrderedList().run(), () => editor.isActive('orderedList')), |
| btn(icon('quote'), 'Blockquote', () => editor.chain().focus().toggleBlockquote().run(), () => editor.isActive('blockquote')), |
| btn('{ }', 'Code block', () => editor.chain().focus().toggleCodeBlock().run(), () => editor.isActive('codeBlock')), |
| btn(icon('todo'), 'Checklist', () => editor.chain().focus().toggleTaskList().run(), () => editor.isActive('taskList')), |
| btn(icon('table'), 'Insert table', () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), |
| btn('—', 'Divider', () => editor.chain().focus().setHorizontalRule().run()), |
| sep(), |
| btn(icon('link'), 'Link', () => toggleLink(editor), () => editor.isActive('link')) |
| ) |
|
|
| |
| 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) |
|
|
| |
| let zoom = Number(localStorage.getItem('doc-zoom')) || 1 |
| const zoomLabel = el('span', { id: 'zoom-label' }) |
| const applyZoom = () => { |
| zoom = Math.round(Math.max(0.8, Math.min(1.6, zoom)) * 100) / 100 |
| document.documentElement.style.setProperty('--doc-zoom', zoom) |
| zoomLabel.textContent = `${Math.round(zoom * 100)}%` |
| localStorage.setItem('doc-zoom', String(zoom)) |
| scheduleLayout(editor) |
| } |
| const zoomOutB = btn(icon('zoomOut'), 'Zoom out', () => { |
| zoom -= 0.1 |
| applyZoom() |
| }) |
| zoomOutB.id = 'zoom-out' |
| const zoomInB = btn(icon('zoomIn'), 'Zoom in', () => { |
| zoom += 0.1 |
| applyZoom() |
| }) |
| zoomInB.id = 'zoom-in' |
| bar.append(sep(), zoomOutB, zoomLabel, zoomInB) |
| applyZoom() |
|
|
| |
| const imgInput = el('input', { type: 'file', accept: 'image/*', style: 'display:none' }) |
| imgInput.addEventListener('change', () => { |
| if (imgInput.files[0]) uploadAndInsertImage(imgInput.files[0]) |
| imgInput.value = '' |
| }) |
| const imgBtn = btn(icon('image'), 'Insert image', () => imgInput.click()) |
| imgBtn.id = 'image-btn' |
| bar.append(imgBtn, imgInput) |
|
|
| wireLinkPop(editor) |
| } |
|
|
| function toggleLink(editor) { |
| if (editor.isActive('link')) { |
| editor.chain().focus().extendMarkRange('link').unsetLink().run() |
| return |
| } |
| if (editor.state.selection.empty) return |
| const pop = document.getElementById('link-pop') |
| const tools = document.getElementById('header-tools').getBoundingClientRect() |
| pop.style.left = `${Math.max(12, tools.right - 320)}px` |
| pop.style.top = `${tools.bottom + 8}px` |
| pop.classList.remove('hidden') |
| document.getElementById('link-url').focus() |
| } |
|
|
| function wireLinkPop(editor) { |
| const pop = document.getElementById('link-pop') |
| const input = document.getElementById('link-url') |
| const apply = () => { |
| let href = input.value.trim() |
| if (href && !/^https?:\/\//.test(href)) href = 'https://' + href |
| if (href) editor.chain().focus().extendMarkRange('link').setLink({ href }).run() |
| input.value = '' |
| pop.classList.add('hidden') |
| } |
| document.getElementById('link-apply').addEventListener('click', apply) |
| input.addEventListener('keydown', e => { |
| if (e.key === 'Enter') apply() |
| if (e.key === 'Escape') pop.classList.add('hidden') |
| }) |
| document.addEventListener('mousedown', e => { |
| if (!e.target.closest('#link-pop') && !pop.classList.contains('hidden') && !e.target.closest('#header-tools')) { |
| pop.classList.add('hidden') |
| } |
| }) |
| } |
|
|
| |
|
|
| function wireSelectionMenu() { |
| const commentBtn = document.getElementById('comment-btn') |
| const suggestBtn = document.getElementById('suggest-btn') |
| commentBtn.append(icon('comment'), document.createTextNode('Comment')) |
| suggestBtn.append(icon('edit'), document.createTextNode('Suggest')) |
| commentBtn.addEventListener('mousedown', e => { |
| e.preventDefault() |
| try { |
| openComposer(window.__editor) |
| } catch (err) { |
| alert('Could not open the comment box: ' + err.message) |
| } |
| }) |
| suggestBtn.addEventListener('mousedown', e => { |
| e.preventDefault() |
| openSuggestComposer(window.__editor).catch(err => alert('Could not open suggest mode: ' + err.message)) |
| }) |
| } |
|
|
| function bindSelectionMenu(editor) { |
| const menu = document.getElementById('selection-menu') |
| editor.on('selectionUpdate', () => { |
| const { from, to, empty } = editor.state.selection |
| if (empty || to - from < 1) { |
| menu.classList.add('hidden') |
| return |
| } |
| const coords = editor.view.coordsAtPos(to) |
| const wrap = document.getElementById('editor-col').getBoundingClientRect() |
| menu.style.left = `${Math.max(0, Math.min(coords.left - wrap.left, wrap.width - 230))}px` |
| menu.style.top = `${coords.bottom - wrap.top + 8}px` |
| menu.classList.remove('hidden') |
| }) |
| } |
|
|
| |
|
|
| function openComposer(editor) { |
| const { from, to } = editor.state.selection |
| const ystate = ySyncPluginKey.getState(editor.state) |
| state.composerAnchors = { |
| start: encodeRel(absolutePositionToRelativePosition(from, ystate.type, ystate.binding.mapping)), |
| end: encodeRel(absolutePositionToRelativePosition(to, ystate.type, ystate.binding.mapping)), |
| excerpt: editor.state.doc.textBetween(from, to, ' ').slice(0, 200), |
| } |
| document.getElementById('composer-quote').textContent = state.composerAnchors.excerpt |
| document.getElementById('suggest-composer').classList.add('hidden') |
| document.getElementById('composer').classList.remove('hidden') |
| document.getElementById('selection-menu').classList.add('hidden') |
| renderMargin(editor) |
| document.getElementById('composer-text').focus({ preventScroll: true }) |
| revealAfterLayout(document.getElementById('composer')) |
| } |
|
|
| function wireComposer() { |
| const text = document.getElementById('composer-text') |
| document.getElementById('composer-cancel').addEventListener('click', () => { |
| document.getElementById('composer').classList.add('hidden') |
| text.value = '' |
| renderMargin(window.__editor) |
| }) |
| document.getElementById('composer-send').addEventListener('click', () => { |
| const value = text.value.trim() |
| if (!value || !state.composerAnchors) return |
| const id = createThread(state.composerAnchors, value) |
| text.value = '' |
| document.getElementById('composer').classList.add('hidden') |
| setActive(id) |
| }) |
| wireMentionMenu(text, document.getElementById('composer-mentions')) |
| } |
|
|
| function createThread(anchors, text, suggestionId = null) { |
| const id = randomId() |
| const messages = new Y.Array() |
| messages.push([makeMessage(text)]) |
| const t = new Y.Map() |
| t.set('id', id) |
| t.set('anchorStart', anchors.start) |
| t.set('anchorEnd', anchors.end) |
| t.set('excerpt', anchors.excerpt || null) |
| t.set('createdBy', state.me.username) |
| t.set('createdAt', Date.now()) |
| t.set('resolved', false) |
| if (suggestionId) t.set('suggestionId', suggestionId) |
| t.set('messages', messages) |
| threads.set(id, t) |
| return id |
| } |
|
|
| function makeMessage(text) { |
| return { id: randomId(), author: state.me.username, authorType: 'user', text, ts: Date.now() } |
| } |
|
|
| function randomId() { |
| return Math.random().toString(36).slice(2, 12) |
| } |
|
|
| |
|
|
| async function openSuggestComposer(editor) { |
| const { from, to } = editor.state.selection |
| const blockFrom = editor.state.doc.resolve(from).index(0) |
| const blockTo = editor.state.doc.resolve(to).index(0) |
| const fragment = ydoc.getXmlFragment('default') |
| state.suggestAnchors = { |
| start: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockFrom)), |
| end: encodeRel(Y.createRelativePositionFromTypeIndex(fragment, blockTo + 1)), |
| } |
| document.getElementById('composer').classList.add('hidden') |
| document.getElementById('selection-menu').classList.add('hidden') |
| const composer = document.getElementById('suggest-composer') |
| composer.classList.remove('hidden') |
| renderMargin(editor) |
| document.getElementById('suggest-quote').textContent = |
| blockTo > blockFrom ? `Rewriting blocks ${blockFrom + 1}–${blockTo + 1}` : 'Rewriting the selected block' |
| const textEl = document.getElementById('suggest-text') |
| textEl.value = '…' |
| const snap = await api(`/api/docs/${docId}${pageQS}`) |
| textEl.value = (snap.blocks || []) |
| .slice(blockFrom, blockTo + 1) |
| .map(b => b.markdown) |
| .join('\n\n') |
| textEl.focus({ preventScroll: true }) |
| revealAfterLayout(document.getElementById('suggest-composer')) |
| scheduleLayout(editor) |
| } |
|
|
| function wireSuggestComposer() { |
| document.getElementById('suggest-cancel').addEventListener('click', () => { |
| document.getElementById('suggest-composer').classList.add('hidden') |
| renderMargin(window.__editor) |
| }) |
| document.getElementById('suggest-send').addEventListener('click', async () => { |
| if (!state.suggestAnchors) return |
| const res = await api(`/api/docs/${docId}/suggestions`, { |
| method: 'POST', |
| body: { |
| page: pageSlug, |
| anchor_start: state.suggestAnchors.start, |
| anchor_end: state.suggestAnchors.end, |
| replacement_markdown: document.getElementById('suggest-text').value, |
| rationale: document.getElementById('suggest-rationale').value.trim() || undefined, |
| }, |
| }) |
| if (res.error) return alert(res.error) |
| document.getElementById('suggest-composer').classList.add('hidden') |
| document.getElementById('suggest-text').value = '' |
| document.getElementById('suggest-rationale').value = '' |
| setActive(res.suggestion_id) |
| }) |
| } |
|
|
| |
|
|
| let layoutRaf = null |
| function scheduleLayout(editor) { |
| if (layoutRaf) return |
| layoutRaf = requestAnimationFrame(() => { |
| layoutRaf = null |
| layoutMargin(editor) |
| }) |
| } |
|
|
| |
| |
| |
| 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 |
| } |
|
|
| |
| function suggestionActionButtons(s, editor) { |
| const reject = el('button', { class: 'iconbtn reject-btn', title: 'Reject suggestion' }) |
| reject.appendChild(icon('x')) |
| reject.addEventListener('click', e => { |
| e.stopPropagation() |
| api(`/api/docs/${docId}/suggestions/${s.id}/reject`, { method: 'POST', body: { page: pageSlug } }) |
| }) |
| const accept = el('button', { class: 'iconbtn accept-btn', title: 'Accept suggestion' }) |
| accept.appendChild(icon('check')) |
| accept.addEventListener('click', async e => { |
| e.stopPropagation() |
| |
| const appliedLocally = tryClientApply(editor, s) |
| const res = await api(`/api/docs/${docId}/suggestions/${s.id}/accept`, { |
| method: 'POST', |
| body: { mark_only: appliedLocally, page: pageSlug }, |
| }) |
| if (res.error) alert(res.error) |
| }) |
| return [reject, accept] |
| } |
|
|
| function renderMargin(editor) { |
| const wrap = document.getElementById('margin-items') |
| const items = [] |
|
|
| const attachedByThread = new Map() |
| 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 } |
| }) |
| |
| |
| const hostId = s.threadId && threads.get(s.threadId) && !threads.get(s.threadId).get('suggestionId') ? s.threadId : null |
| const hostVisible = hostId && (!threads.get(hostId).get('resolved') || state.showResolved) |
| if (hostId && hostVisible) { |
| if (!attachedByThread.has(hostId)) attachedByThread.set(hostId, []) |
| attachedByThread.get(hostId).push({ chain, linkedThread }) |
| continue |
| } |
| items.push({ kind: 'sugg', id: s.id, data: s, linkedThread, chain }) |
| } |
| threads.forEach((ythread, id) => { |
| if (ythread.get('suggestionId')) return |
| if (ythread.get('resolved') && !state.showResolved) return |
| items.push({ kind: 'thread', id, ythread, attached: attachedByThread.get(id) || [] }) |
| }) |
|
|
| |
| wrap.querySelectorAll('.card, .margin-empty').forEach(n => n.remove()) |
| if (!items.length) { |
| const composerOpen = wrap.querySelector('.composer-box:not(.hidden)') |
| if (!composerOpen) wrap.appendChild(el('div', { class: 'margin-empty muted' }, 'Select text to comment or suggest. Mention @an-agent to hand it work.')) |
| } else { |
| wrap.append(...items.map(item => (item.kind === 'sugg' ? suggestionCard(item, editor) : threadCard(item, editor)))) |
| } |
| scheduleLayout(editor) |
| } |
|
|
| function itemRange(editor, item) { |
| const ystate = ySyncPluginKey.getState(editor.state) |
| if (!ystate?.binding) return null |
| if (item.kind === 'sugg') return suggestionRange(editor.state, item.data) |
| return anchorsToRange(editor.state, ystate, item.ythread.get('anchorStart'), item.ythread.get('anchorEnd')) |
| } |
|
|
| function layoutMargin(editor) { |
| const container = document.getElementById('margin-items') |
| const els = [...container.querySelectorAll('.card, .composer-box:not(.hidden)')] |
| if (!els.length) { |
| container.style.minHeight = '0px' |
| return |
| } |
| const containerRect = container.getBoundingClientRect() |
| const ystate = ySyncPluginKey.getState(editor.state) |
|
|
| const entries = els.map(node => { |
| let range = null |
| if (node.classList.contains('composer-box')) { |
| const anchors = node.id === 'composer' ? state.composerAnchors : state.suggestAnchors |
| if (anchors && ystate?.binding) range = anchorsToRange(editor.state, ystate, anchors.start, anchors.end) |
| } else { |
| range = rangeFromCard(editor, node) |
| } |
| let desired = Infinity |
| if (range) { |
| try { |
| desired = editor.view.coordsAtPos(Math.min(range.from, editor.state.doc.content.size)).top - containerRect.top |
| } catch {} |
| } |
| if (Number.isFinite(desired) && node.classList.contains('composer-box')) desired -= 1 |
| return { node, desired: Number.isFinite(desired) ? Math.max(0, desired) : Infinity } |
| }) |
| entries.sort((a, b) => a.desired - b.desired) |
|
|
| let cursor = 0 |
| for (const entry of entries) { |
| const top = entry.desired === Infinity ? cursor : Math.max(entry.desired, cursor) |
| entry.node.style.top = `${top}px` |
| cursor = top + entry.node.offsetHeight + 10 |
| } |
| container.style.minHeight = `${cursor}px` |
| } |
|
|
| function rangeFromCard(editor, card) { |
| const id = card.dataset.item |
| const suggestion = [...suggestionValues()].find(s => s.id === id) |
| if (suggestion) return itemRange(editor, { kind: 'sugg', data: suggestion }) |
| const ythread = threads.get(id) |
| if (ythread) return itemRange(editor, { kind: 'thread', ythread }) |
| return null |
| } |
|
|
| function suggestionValues() { |
| const out = [] |
| suggestions.forEach(s => out.push(s)) |
| return out |
| } |
|
|
| function cardShell(id, kind, expanded) { |
| const card = el('div', { class: `card ${kind}${expanded ? ' expanded' : ''}`, 'data-item': id }) |
| card.addEventListener('mousedown', e => { |
| if (e.target.closest('button, input, textarea, a, .mention-menu')) return |
| if (state.activeItem !== id) setActive(id) |
| }) |
| return card |
| } |
|
|
| function cardHead(iconName, who, isAgent, preview, when) { |
| const head = el('div', { class: 'head' }) |
| head.appendChild(icon(iconName)) |
| head.appendChild(el('span', { class: `who${isAgent ? ' agent' : ''}` }, who)) |
| head.appendChild(el('span', { class: 'preview' }, preview)) |
| head.appendChild(el('span', { class: 'when' }, timeAgo(when))) |
| return head |
| } |
|
|
| function messageEl(msg) { |
| const m = el('div', { class: 'msg' }) |
| const author = el('div', { class: 'author' }) |
| author.appendChild(el('span', {}, msg.authorType === 'agent' ? `@${msg.author}` : msg.author)) |
| if (msg.authorType === 'agent') author.appendChild(el('span', { class: 'agent-tag' }, 'agent')) |
| author.appendChild(el('span', { class: 'when' }, timeAgo(msg.ts))) |
| m.appendChild(author) |
| const textEl = el('div', { class: 'text' }) |
| textEl.innerHTML = renderMentionText(msg.text) |
| m.appendChild(textEl) |
| for (const chip of msg.mentions || []) { |
| m.appendChild(el('span', { class: `chip ${chip.status}` }, `@${chip.handle} · ${statusLabel(chip.status)}`)) |
| } |
| return m |
| } |
|
|
| function replyRow(placeholder, onSend) { |
| const row = el('div', { class: 'reply-row' }) |
| const input = el('input', { placeholder }) |
| const menu = el('div', { class: 'mention-menu hidden' }) |
| const send = el('button', { class: 'iconbtn', title: 'Send' }) |
| send.appendChild(icon('send')) |
| send.addEventListener('click', () => { |
| if (!input.value.trim()) return |
| onSend(input.value.trim()) |
| input.value = '' |
| }) |
| input.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && menu.classList.contains('hidden')) send.click() |
| }) |
| row.append(input, send) |
| wireMentionMenu(input, menu) |
| return { row, menu } |
| } |
|
|
| |
|
|
| function threadCard(item, editor) { |
| const { id, ythread } = item |
| const expanded = state.activeItem === id |
| const card = cardShell(id, 'thread', expanded) |
| const msgs = ythread.get('messages')?.toArray() || [] |
| const first = msgs[0] || {} |
| const resolved = ythread.get('resolved') |
|
|
| const head = cardHead('comment', first.authorType === 'agent' ? `@${first.author}` : first.author || '?', first.authorType === 'agent', (resolved ? 'resolved · ' : '') + (first.text || ''), first.ts) |
| if (!resolved) { |
| |
| const resolve = el('button', { class: 'iconbtn resolve-btn', title: 'Resolve comment' }) |
| resolve.appendChild(icon('check')) |
| resolve.addEventListener('click', e => { |
| e.stopPropagation() |
| ythread.set('resolved', true) |
| if (state.activeItem === id) state.activeItem = null |
| }) |
| head.appendChild(resolve) |
| } |
| card.appendChild(head) |
|
|
| |
| for (const att of item.attached || []) { |
| card.appendChild(suggestionStrip(att.chain, editor)) |
| } |
|
|
| if (!expanded) return card |
|
|
| const body = el('div', { class: 'body' }) |
| const range = itemRange(editor, item) |
| const orphaned = !range || range.from >= range.to |
| if (ythread.get('excerpt')) { |
| const quote = el('blockquote', { class: orphaned ? 'orphaned' : '' }, ythread.get('excerpt')) |
| quote.title = orphaned ? 'The commented text was deleted' : 'Jump to text' |
| quote.addEventListener('click', () => jumpTo(editor, range)) |
| body.appendChild(quote) |
| } |
| for (const msg of msgs) body.appendChild(messageEl(msg)) |
|
|
| if (!resolved) { |
| const { row, menu } = replyRow('Reply — @agent to hand off', text => { |
| ythread.get('messages').push([makeMessage(text)]) |
| }) |
| body.appendChild(row) |
| body.appendChild(menu) |
| const actions = el('div', { class: 'actions' }) |
| const resolve = el('button', { class: 'btn small ghost' }) |
| resolve.append(icon('resolve'), document.createTextNode('Resolve')) |
| resolve.addEventListener('click', () => { |
| ythread.set('resolved', true) |
| state.activeItem = null |
| }) |
| actions.appendChild(resolve) |
| body.appendChild(actions) |
| } else { |
| body.appendChild(el('div', { class: 'status-note' }, 'resolved')) |
| } |
| card.appendChild(body) |
| return card |
| } |
|
|
| |
|
|
| |
| function suggestionStrip(chain, editor) { |
| const s = chain.display |
| const actionable = s.status === 'open' || s.status === 'superseded' |
| const strip = el('div', { class: 'sugg-strip' + (state.activeItem === s.id ? ' active' : ''), 'data-sugg-strip': s.id }) |
| strip.appendChild(icon('edit')) |
| strip.appendChild(el('span', { class: `who${s.authorType === 'agent' ? ' agent' : ''}` }, s.authorType === 'agent' ? `@${s.author}` : s.author)) |
| strip.appendChild(el('span', { class: 'preview' }, actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`)) |
| if (chain.members.length > 1) strip.appendChild(chainNav(chain)) |
| if (actionable) strip.append(...suggestionActionButtons(s, editor)) |
| strip.addEventListener('mousedown', e => { |
| if (e.target.closest('button')) return |
| setActive(s.id) |
| }) |
| return strip |
| } |
|
|
| function suggestionCard(item, editor) { |
| const s = item.data |
| const expanded = state.activeItem === s.id |
| const card = cardShell(s.id, 'suggestion', expanded) |
|
|
| |
| |
| const actionable = s.status === 'open' || s.status === 'superseded' |
| const head = cardHead( |
| 'edit', |
| s.authorType === 'agent' ? `@${s.author}` : s.author, |
| s.authorType === 'agent', |
| actionable ? s.rationale || 'suggested a change' : `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`, |
| s.ts |
| ) |
| if (item.chain && item.chain.members.length > 1) head.appendChild(chainNav(item.chain)) |
| if (actionable) head.append(...suggestionActionButtons(s, editor)) |
| card.appendChild(head) |
|
|
| if (!expanded) return card |
|
|
| const body = el('div', { class: 'body' }) |
| if (actionable && s.rationale) body.appendChild(el('div', { class: 'rationale' }, s.rationale)) |
| if (!actionable) body.appendChild(el('div', { class: 'status-note' }, `${s.status}${s.resolvedBy ? ' by ' + s.resolvedBy : ''}`)) |
|
|
| |
| 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 |
| } |
|
|
| |
|
|
| |
| |
| |
| function runUndo(editor) { |
| yUndoPluginKey.getState(editor.state)?.undoManager?.undo() |
| return true |
| } |
|
|
| function runRedo(editor) { |
| yUndoPluginKey.getState(editor.state)?.undoManager?.redo() |
| return true |
| } |
|
|
| |
| |
| let pendingAcceptSuggestion = null |
|
|
| function wireUndoReopens(editor) { |
| const undoManager = yUndoPluginKey.getState(editor.state)?.undoManager |
| if (!undoManager) return |
| undoManager.on('stack-item-added', event => { |
| if (pendingAcceptSuggestion) { |
| event.stackItem.meta.set('acceptedSuggestion', pendingAcceptSuggestion) |
| pendingAcceptSuggestion = null |
| } |
| }) |
| undoManager.on('stack-item-popped', event => { |
| if (event.type !== 'undo') return |
| const sid = event.stackItem.meta.get('acceptedSuggestion') |
| if (sid) api(`/api/docs/${docId}/suggestions/${sid}/reopen`, { method: 'POST', body: { page: pageSlug } }) |
| }) |
| } |
|
|
| function inlineToPmContent(segments) { |
| return (segments || []) |
| .filter(seg => seg.text) |
| .map(seg => ({ |
| type: 'text', |
| text: seg.text, |
| marks: Object.entries(seg.attrs || {}).map(([type, attrs]) => ({ type, attrs })), |
| })) |
| } |
|
|
| function blockToPmJSON(block) { |
| switch (block.type) { |
| case 'heading': |
| return { type: 'heading', attrs: { level: block.attrs?.level || 1 }, content: inlineToPmContent(block.inline) } |
| case 'codeBlock': |
| return { type: 'codeBlock', attrs: block.attrs || {}, content: block.text ? [{ type: 'text', text: block.text }] : undefined } |
| case 'bulletList': |
| case 'orderedList': |
| return { |
| type: block.type, |
| content: (block.items || []).map(segments => ({ |
| type: 'listItem', |
| content: [{ type: 'paragraph', content: inlineToPmContent(segments) }], |
| })), |
| } |
| case 'taskList': |
| return { |
| type: 'taskList', |
| content: (block.items || []).map(it => ({ |
| type: 'taskItem', |
| attrs: { checked: !!it.checked }, |
| content: [{ type: 'paragraph', content: inlineToPmContent(it.inline) }], |
| })), |
| } |
| case 'table': |
| return { |
| type: 'table', |
| content: (block.rows || []).map((cells, r) => ({ |
| type: 'tableRow', |
| content: cells.map(segs => ({ |
| type: r === 0 ? 'tableHeader' : 'tableCell', |
| attrs: { colspan: 1, rowspan: 1, colwidth: null }, |
| content: [{ type: 'paragraph', content: inlineToPmContent(segs) }], |
| })), |
| })), |
| } |
| case 'blockquote': |
| return { type: 'blockquote', content: [{ type: 'paragraph', content: inlineToPmContent(block.inline) }] } |
| case 'horizontalRule': |
| return { type: 'horizontalRule' } |
| case 'image': |
| return { type: 'image', attrs: { src: block.attrs?.src || '', alt: block.attrs?.alt || null } } |
| default: { |
| const content = inlineToPmContent(block.inline) |
| return content.length ? { type: 'paragraph', content } : { type: 'paragraph' } |
| } |
| } |
| } |
|
|
| function tryClientApply(editor, s) { |
| try { |
| const range = suggestionRange(editor.state, s) |
| if (!range) return false |
| const nodes = markdownToBlocks(s.replacementMarkdown).map(b => editor.schema.nodeFromJSON(blockToPmJSON(b))) |
| pendingAcceptSuggestion = s.id |
| editor.view.dispatch(editor.state.tr.replaceWith(range.from, range.to, nodes)) |
| |
| |
| setTimeout(() => { |
| pendingAcceptSuggestion = null |
| }, 0) |
| return true |
| } catch (err) { |
| pendingAcceptSuggestion = null |
| console.warn('client-side apply failed, falling back to server apply', err) |
| return false |
| } |
| } |
|
|
| |
|
|
| function wireAgentsPanel() { |
| const btn = document.getElementById('agents-btn') |
| const pop = document.getElementById('agents-pop') |
| btn.addEventListener('click', () => pop.classList.toggle('hidden')) |
|
|
| document.getElementById('agent-form').addEventListener('submit', async e => { |
| e.preventDefault() |
| const input = document.getElementById('agent-handle') |
| const handle = input.value.trim().toLowerCase() |
| if (!handle) return |
| const res = await api('/api/agents', { method: 'POST', body: { handle } }) |
| if (res.error) alert(res.error) |
| else { |
| input.value = '' |
| refreshAgents() |
| if (res.key) pop.appendChild(agentKeyBox({ handle: res.handle, key: res.key, docId })) |
| } |
| }) |
| } |
|
|
| |
|
|
| function wireSharePanel() { |
| const btn = document.getElementById('share-btn') |
| const pop = document.getElementById('share-pop') |
| btn.addEventListener('click', () => { |
| pop.classList.toggle('hidden') |
| if (!pop.classList.contains('hidden')) renderShareList() |
| }) |
| document.getElementById('share-form').addEventListener('submit', async e => { |
| e.preventDefault() |
| const input = document.getElementById('share-user') |
| const username = input.value.trim() |
| if (!username) return |
| const res = await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username } }) |
| if (res.error) return alert(res.error) |
| input.value = '' |
| renderShareList() |
| }) |
| } |
|
|
| async function renderShareList() { |
| const meta = await api(`/api/docs/${docId}`) |
| const wrap = document.getElementById('share-list') |
| if (meta.error) { |
| wrap.textContent = meta.error |
| return |
| } |
| const isCreator = !meta.created_by || meta.created_by === state.me.username |
| document.getElementById('share-form').classList.toggle('hidden', !isCreator) |
| wrap.className = '' |
| const rows = [] |
| if (meta.created_by) { |
| const owner = el('div', { class: 'share-row' }) |
| owner.append(el('span', { class: 'handle' }, meta.created_by), el('span', { class: 'muted' }, 'owner')) |
| rows.push(owner) |
| } |
| for (const username of meta.shared_with || []) { |
| const row = el('div', { class: 'share-row' }) |
| row.appendChild(el('span', { class: 'handle' }, username)) |
| if (isCreator) { |
| const rm = el('button', { class: 'iconbtn', title: 'Remove access' }) |
| rm.appendChild(icon('x')) |
| rm.addEventListener('click', async () => { |
| await api(`/api/docs/${docId}/share`, { method: 'POST', body: { username, remove: true } }) |
| renderShareList() |
| }) |
| row.appendChild(rm) |
| } |
| rows.push(row) |
| } |
| if (!rows.length) rows.push(el('div', { class: 'muted' }, 'Not shared with anyone yet.')) |
| wrap.replaceChildren(...rows) |
| } |
|
|
| async function refreshAgents() { |
| const res = await api(`/api/agents?doc=${docId}`) |
| if (!res.agents) return |
| state.agents = res.agents |
| const wrap = document.getElementById('agents-list') |
| if (!res.agents.length) { |
| wrap.className = 'muted' |
| wrap.textContent = 'No agents registered yet.' |
| return |
| } |
| wrap.className = '' |
| wrap.replaceChildren( |
| ...res.agents.map(a => { |
| const row = el('div', { class: 'agent-row' }) |
| row.appendChild(el('span', { class: `dot${a.online ? ' on' : ''} tip-below`, 'data-tip': a.online ? 'Online — the agent is polling for work' : 'Offline — the agent is not polling right now' })) |
| const id = el('span', { class: 'agent-id' }) |
| id.appendChild(el('span', { class: 'handle' }, `@${a.handle}`)) |
| id.appendChild(el('span', { class: 'muted owner' }, a.owner + (a.shared === false ? ' · private' : ''))) |
| row.appendChild(id) |
| const actions = el('span', { class: 'actions' }) |
| const copy = el('button', { class: 'iconbtn', 'data-tip': 'Copy the agent prompt (paste it into your coding agent)' }) |
| copy.appendChild(icon('copy')) |
| copy.addEventListener('click', async () => { |
| const text = await fetch(`/api/agent-prompt?doc=${docId}&handle=${a.handle}`).then(r => r.text()) |
| await navigator.clipboard.writeText(text) |
| copy.replaceChildren(icon('check')) |
| setTimeout(() => copy.replaceChildren(icon('copy')), 1500) |
| }) |
| actions.appendChild(copy) |
| if (a.owner === state.me.username) { |
| const vis = el('button', { |
| class: 'iconbtn' + (a.shared ? ' on' : ''), |
| 'data-tip': a.shared ? 'Shared: collaborators can @mention this agent — click to make it private' : 'Private: only you can @mention it — click to share', |
| }) |
| vis.appendChild(icon(a.shared ? 'eye' : 'eyeoff')) |
| vis.addEventListener('click', async () => { |
| const res = await api(`/api/agents/${a.handle}/visibility`, { method: 'POST', body: { shared: !a.shared } }) |
| if (res.error) alert(res.error) |
| else refreshAgents() |
| }) |
| actions.appendChild(vis) |
| const rot = el('button', { class: 'iconbtn', 'data-tip': 'Issue a new key (the old one stops working)' }) |
| rot.appendChild(icon('key')) |
| rot.addEventListener('click', async () => { |
| const res = await api(`/api/agents/${a.handle}/rotate`, { method: 'POST' }) |
| if (res.error) alert(res.error) |
| else document.getElementById('agents-pop').appendChild(agentKeyBox({ handle: a.handle, key: res.key, docId })) |
| }) |
| actions.appendChild(rot) |
| } |
| if (a.owner === state.me.username || state.me.isAdmin) { |
| const del = el('button', { class: 'iconbtn', 'data-tip': 'Remove this agent (its key stops working, open tasks are cancelled)' }) |
| del.appendChild(icon('trash')) |
| del.addEventListener('click', async () => { |
| if (!confirm(`Remove @${a.handle}? Its key stops working and open tasks are cancelled.`)) return |
| const res = await api(`/api/agents/${a.handle}`, { method: 'DELETE' }) |
| if (res.error) alert(res.error) |
| refreshAgents() |
| }) |
| actions.appendChild(del) |
| } |
| row.appendChild(actions) |
| return row |
| }) |
| ) |
| } |
|
|
| |
|
|
| function wireMentionMenu(input, menu) { |
| const getQuery = () => { |
| const pos = input.selectionStart ?? input.value.length |
| const before = input.value.slice(0, pos) |
| const m = before.match(/@([a-z0-9_.-]*)$/i) |
| return m ? { q: m[1].toLowerCase(), start: pos - m[1].length } : null |
| } |
| const render = () => { |
| const ctx = getQuery() |
| if (!ctx) return menu.classList.add('hidden') |
| const options = state.agents.filter(a => a.handle.startsWith(ctx.q)).slice(0, 6) |
| if (!options.length) return menu.classList.add('hidden') |
| menu.replaceChildren( |
| ...options.map(a => { |
| const opt = el('div') |
| opt.append(el('span', { class: `dot${a.online ? ' on' : ''}` }), document.createTextNode(`@${a.handle}`)) |
| opt.addEventListener('mousedown', e => { |
| e.preventDefault() |
| input.value = input.value.slice(0, ctx.start) + a.handle + ' ' + input.value.slice(input.selectionStart) |
| menu.classList.add('hidden') |
| input.focus({ preventScroll: true }) |
| }) |
| return opt |
| }) |
| ) |
| menu.classList.remove('hidden') |
| } |
| input.addEventListener('input', render) |
| input.addEventListener('blur', () => setTimeout(() => menu.classList.add('hidden'), 150)) |
| } |
|
|
| |
|
|
| function renderPresence(provider) { |
| const wrap = document.getElementById('presence') |
| const seen = new Set() |
| const avatars = [] |
| provider.awareness?.getStates().forEach(s => { |
| const u = s.user |
| if (!u?.name || seen.has(u.name)) return |
| seen.add(u.name) |
| const a = el('div', { class: 'avatar', title: u.name }, u.name.slice(0, 2).toUpperCase()) |
| a.style.background = u.color || '#999' |
| avatars.push(a) |
| }) |
| wrap.replaceChildren(...avatars) |
| } |
|
|