Spaces:
Running
Running
References, out of three general primitives
#9
by lvwerra HF Staff - opened
- client/app.css +48 -0
- client/src/doc.js +149 -9
- client/src/fold.js +121 -0
- client/src/outline.js +89 -0
- server/api.js +12 -1
- test/browser.js +126 -0
client/app.css
CHANGED
|
@@ -749,6 +749,54 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
|
|
| 749 |
.card.thread .head .resolve-btn { color: var(--accent); }
|
| 750 |
|
| 751 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
/* --- page-link autocomplete + links --- */
|
| 753 |
#pagelink-menu { position: fixed; z-index: 70; }
|
| 754 |
.tiptap a { cursor: pointer; }
|
|
|
|
| 749 |
.card.thread .head .resolve-btn { color: var(--accent); }
|
| 750 |
|
| 751 |
|
| 752 |
+
/* --- collapsible headings ---------------------------------------------------
|
| 753 |
+
The arrow lives in the sheet's left margin so it never moves the text, and
|
| 754 |
+
stays invisible until the pointer is in the document (or the section is shut,
|
| 755 |
+
which has to be legible at a glance). Folding is a local view state: what you
|
| 756 |
+
collapse is collapsed for you only. */
|
| 757 |
+
.tiptap .cw-heading { position: relative; }
|
| 758 |
+
.fold-arrow {
|
| 759 |
+
position: absolute; left: -1.35em; top: 0.1em; width: 1.05em; height: 1.05em; padding: 0;
|
| 760 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 761 |
+
border: none; background: none; color: var(--doc-text-3); cursor: pointer;
|
| 762 |
+
opacity: 0; transition: opacity .12s, transform .12s; user-select: none;
|
| 763 |
+
touch-action: manipulation;
|
| 764 |
+
}
|
| 765 |
+
.fold-arrow .icon { width: 1em; height: 1em; stroke-width: 2; }
|
| 766 |
+
.tiptap:hover .fold-arrow, .fold-arrow:focus-visible { opacity: 0.55; }
|
| 767 |
+
.fold-arrow:hover { opacity: 1 !important; color: var(--doc-text); }
|
| 768 |
+
.fold-arrow.collapsed { opacity: 0.75; transform: rotate(-90deg); }
|
| 769 |
+
.tiptap .cw-hidden-by-fold { display: none; }
|
| 770 |
+
/* a shut section says so, so a short page and a folded one are not confused */
|
| 771 |
+
.tiptap .cw-heading.cw-folded::after {
|
| 772 |
+
content: '···'; margin-left: 0.5em; font-size: 0.7em; letter-spacing: 0.15em;
|
| 773 |
+
color: var(--doc-text-3); vertical-align: 0.15em;
|
| 774 |
+
}
|
| 775 |
+
/* arriving from a #anchor link: a brief wash, then out of the way */
|
| 776 |
+
@keyframes cw-jump-fade { from { background: var(--hl-active); } to { background: transparent; } }
|
| 777 |
+
.tiptap .cw-jumped { animation: cw-jump-fade 1.4s ease-out; border-radius: 3px; }
|
| 778 |
+
@media (prefers-reduced-motion: reduce) { .tiptap .cw-jumped { animation: none; } }
|
| 779 |
+
/* on a touch screen there is no hover to reveal them by */
|
| 780 |
+
@media (hover: none) { .fold-arrow { opacity: 0.5; } }
|
| 781 |
+
|
| 782 |
+
/* --- link preview ------------------------------------------------------------
|
| 783 |
+
What is on the other end of an internal link: the section's heading, its
|
| 784 |
+
source and the start of its summary. */
|
| 785 |
+
#link-preview {
|
| 786 |
+
position: fixed; z-index: 80; width: min(360px, calc(100vw - 16px));
|
| 787 |
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
| 788 |
+
box-shadow: var(--shadow-md); padding: 11px 13px; pointer-events: none;
|
| 789 |
+
font-family: var(--ui);
|
| 790 |
+
}
|
| 791 |
+
#link-preview.hidden { display: none; }
|
| 792 |
+
#link-preview .lp-title { font-weight: 600; font-size: 13.5px; line-height: 1.35; color: var(--text); }
|
| 793 |
+
#link-preview .lp-url { font-size: 11.5px; color: var(--accent); margin-top: 2px; word-break: break-all; }
|
| 794 |
+
#link-preview .lp-summary { font-size: 12.5px; color: var(--text-2); margin-top: 6px; line-height: 1.45; }
|
| 795 |
+
#link-preview .lp-page {
|
| 796 |
+
font-size: 11px; color: var(--text-3); margin-top: 8px; padding-top: 6px;
|
| 797 |
+
border-top: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.05em;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
/* --- page-link autocomplete + links --- */
|
| 801 |
#pagelink-menu { position: fixed; z-index: 70; }
|
| 802 |
.tiptap a { cursor: pointer; }
|
client/src/doc.js
CHANGED
|
@@ -200,6 +200,8 @@ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
|
| 200 |
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
| 201 |
import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme, wireDocStyle } from './common.js'
|
| 202 |
import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
|
|
|
|
|
|
|
| 203 |
|
| 204 |
const docId = location.pathname.split('/').filter(Boolean)[1]
|
| 205 |
// current page — mutable: switching pages swaps the document client-side
|
|
@@ -293,11 +295,23 @@ async function main() {
|
|
| 293 |
// back/forward navigates between pages client-side
|
| 294 |
window.addEventListener('popstate', () => {
|
| 295 |
const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
|
| 296 |
-
|
|
|
|
|
|
|
| 297 |
})
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
loadStructure() // in flight while the document syncs, not after it
|
| 300 |
await initPage()
|
|
|
|
|
|
|
| 301 |
|
| 302 |
setInterval(loadStructure, 15000)
|
| 303 |
window.addEventListener('focus', () => loadStructure())
|
|
@@ -470,6 +484,7 @@ function teardownPage() {
|
|
| 470 |
state.chainPos = {}
|
| 471 |
state.composerAnchors = null
|
| 472 |
state.suggestAnchors = null
|
|
|
|
| 473 |
document.getElementById('composer').classList.add('hidden')
|
| 474 |
document.getElementById('suggest-composer').classList.add('hidden')
|
| 475 |
document.getElementById('selection-menu').classList.add('hidden')
|
|
@@ -477,22 +492,45 @@ function teardownPage() {
|
|
| 477 |
}
|
| 478 |
|
| 479 |
// switch pages client-side: swap the document, keep header + sidebar
|
| 480 |
-
async function openPage(slug, { push = true } = {}) {
|
| 481 |
closeDrawerOnPhone() // the drawer covers the document it just navigated to
|
|
|
|
| 482 |
if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) {
|
| 483 |
// leaving a proposal preview back to the same page
|
| 484 |
} else if (slug === pageSlug) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
return
|
| 486 |
}
|
| 487 |
exitProposalPreview()
|
| 488 |
teardownPage()
|
| 489 |
setPage(slug)
|
| 490 |
-
if (push) history.pushState({}, '', slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`)
|
| 491 |
showDocSkeleton({ delay: 180 })
|
| 492 |
await initPage()
|
|
|
|
| 493 |
loadStructure()
|
| 494 |
}
|
| 495 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
// Is this tab's collaboration socket actually delivering changes? Everything
|
| 497 |
// typed or accepted while it is down never reaches the server, so actions that
|
| 498 |
// must persist check this first.
|
|
@@ -578,6 +616,11 @@ function pageLinkKey(key) {
|
|
| 578 |
return pageLinkController ? pageLinkController(key) : false
|
| 579 |
}
|
| 580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
function wirePageLinkMenu(editor) {
|
| 582 |
document.getElementById('pagelink-menu')?.remove()
|
| 583 |
const menu = el('div', { class: 'mention-menu hidden', id: 'pagelink-menu' })
|
|
@@ -594,7 +637,8 @@ function wirePageLinkMenu(editor) {
|
|
| 594 |
|
| 595 |
const apply = item => {
|
| 596 |
if (!range) return
|
| 597 |
-
const
|
|
|
|
| 598 |
editor
|
| 599 |
.chain()
|
| 600 |
.focus()
|
|
@@ -612,7 +656,7 @@ function wirePageLinkMenu(editor) {
|
|
| 612 |
...items.map((item, i) => {
|
| 613 |
const row = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') })
|
| 614 |
row.appendChild(el('span', {}, item.title))
|
| 615 |
-
row.appendChild(el('span', { class: 'muted', style: 'margin-left:8px;font-size:11px' }, item.slug))
|
| 616 |
row.addEventListener('mousedown', e => {
|
| 617 |
e.preventDefault()
|
| 618 |
apply(item)
|
|
@@ -626,10 +670,35 @@ function wirePageLinkMenu(editor) {
|
|
| 626 |
const { state: pmState } = editor
|
| 627 |
const { from, empty } = pmState.selection
|
| 628 |
if (!empty) return hide()
|
| 629 |
-
const before = pmState.doc.textBetween(Math.max(0, from -
|
| 630 |
-
const m = before.match(/\[\[([^\[\]]{0,
|
| 631 |
if (!m) return hide()
|
| 632 |
const q = m[1].toLowerCase()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
items = Object.entries(state.pageTitles || {})
|
| 634 |
.filter(([slug]) => slug !== '_structure' && slug !== pageSlug)
|
| 635 |
.map(([slug, title]) => ({ slug, title }))
|
|
@@ -672,6 +741,76 @@ function wirePageLinkMenu(editor) {
|
|
| 672 |
}
|
| 673 |
}
|
| 674 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
// --- table controls -----------------------------------------------------------
|
| 676 |
// A floating bar that appears above the table your cursor is in, with add/remove
|
| 677 |
// row & column and delete-table actions (TipTap has no default table UI).
|
|
@@ -1254,6 +1393,7 @@ function buildEditor(provider) {
|
|
| 1254 |
user: { name: state.me.username, color: colorFor(state.me.username) },
|
| 1255 |
}),
|
| 1256 |
Highlights,
|
|
|
|
| 1257 |
],
|
| 1258 |
editorProps: {
|
| 1259 |
handleClick: (view, pos, event) => {
|
|
@@ -1263,8 +1403,8 @@ function buildEditor(provider) {
|
|
| 1263 |
const touch = window.matchMedia('(hover: none)').matches
|
| 1264 |
if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey || touch)) return false
|
| 1265 |
const url = new URL(a.getAttribute('href'), location.origin)
|
| 1266 |
-
const internal =
|
| 1267 |
-
if (internal) openPage(internal
|
| 1268 |
else if (url.origin === location.origin) location.href = url.pathname + url.search
|
| 1269 |
else window.open(url.href, '_blank', 'noopener')
|
| 1270 |
return true
|
|
|
|
| 200 |
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
| 201 |
import { initAuth, api, el, esc, icon, colorFor, timeAgo, statusLabel, renderMentionText, agentKeyBox, wireTheme, wireDocStyle } from './common.js'
|
| 202 |
import { wordDiff, arrayDiff, styledWordDiff, markKeyOf } from './diff.js'
|
| 203 |
+
import { HeadingFold, outlineOf, resetFolds, unfold } from './fold.js'
|
| 204 |
+
import { markdownSections, parseInternalLink } from './outline.js'
|
| 205 |
|
| 206 |
const docId = location.pathname.split('/').filter(Boolean)[1]
|
| 207 |
// current page — mutable: switching pages swaps the document client-side
|
|
|
|
| 295 |
// back/forward navigates between pages client-side
|
| 296 |
window.addEventListener('popstate', () => {
|
| 297 |
const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
|
| 298 |
+
const anchor = decodeURIComponent(location.hash.replace(/^#/, '')) || null
|
| 299 |
+
if (slug !== pageSlug) openPage(slug, { push: false, anchor })
|
| 300 |
+
else if (anchor) revealSection(anchor)
|
| 301 |
})
|
| 302 |
|
| 303 |
+
// a hash arriving any other way (pasted, edited in the bar, an in-page anchor)
|
| 304 |
+
window.addEventListener('hashchange', () => {
|
| 305 |
+
const anchor = decodeURIComponent(location.hash.replace(/^#/, ''))
|
| 306 |
+
if (anchor) revealSection(anchor)
|
| 307 |
+
})
|
| 308 |
+
|
| 309 |
+
wireLinkPreview()
|
| 310 |
+
|
| 311 |
loadStructure() // in flight while the document syncs, not after it
|
| 312 |
await initPage()
|
| 313 |
+
// a link into a section, pasted or followed from outside, lands on the section
|
| 314 |
+
if (location.hash) revealSection(decodeURIComponent(location.hash.slice(1)))
|
| 315 |
|
| 316 |
setInterval(loadStructure, 15000)
|
| 317 |
window.addEventListener('focus', () => loadStructure())
|
|
|
|
| 484 |
state.chainPos = {}
|
| 485 |
state.composerAnchors = null
|
| 486 |
state.suggestAnchors = null
|
| 487 |
+
resetFolds() // which sections are shut is a fact about the page you were on
|
| 488 |
document.getElementById('composer').classList.add('hidden')
|
| 489 |
document.getElementById('suggest-composer').classList.add('hidden')
|
| 490 |
document.getElementById('selection-menu').classList.add('hidden')
|
|
|
|
| 492 |
}
|
| 493 |
|
| 494 |
// switch pages client-side: swap the document, keep header + sidebar
|
| 495 |
+
async function openPage(slug, { push = true, anchor = null } = {}) {
|
| 496 |
closeDrawerOnPhone() // the drawer covers the document it just navigated to
|
| 497 |
+
const hash = anchor ? `#${anchor}` : ''
|
| 498 |
if (slug === pageSlug && !document.getElementById('proposal-view')?.classList.contains('hidden')) {
|
| 499 |
// leaving a proposal preview back to the same page
|
| 500 |
} else if (slug === pageSlug) {
|
| 501 |
+
// already here: a link into a section of this page is just a jump
|
| 502 |
+
if (anchor) {
|
| 503 |
+
if (push) history.replaceState({}, '', location.pathname + hash)
|
| 504 |
+
revealSection(anchor)
|
| 505 |
+
}
|
| 506 |
return
|
| 507 |
}
|
| 508 |
exitProposalPreview()
|
| 509 |
teardownPage()
|
| 510 |
setPage(slug)
|
| 511 |
+
if (push) history.pushState({}, '', (slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`) + hash)
|
| 512 |
showDocSkeleton({ delay: 180 })
|
| 513 |
await initPage()
|
| 514 |
+
if (anchor) revealSection(anchor)
|
| 515 |
loadStructure()
|
| 516 |
}
|
| 517 |
|
| 518 |
+
// Jump to a heading by its slug — unfolding it first if it is collapsed, since
|
| 519 |
+
// scrolling to something that is hidden is no answer at all.
|
| 520 |
+
function revealSection(anchor, tries = 0) {
|
| 521 |
+
if (!anchor) return
|
| 522 |
+
unfold(anchor)
|
| 523 |
+
const target = document.querySelector(`.tiptap [data-anchor="${CSS.escape(anchor)}"]`)
|
| 524 |
+
if (!target) {
|
| 525 |
+
// the page may still be syncing; a few frames of patience, then give up
|
| 526 |
+
if (tries < 20) setTimeout(() => revealSection(anchor, tries + 1), 100)
|
| 527 |
+
return
|
| 528 |
+
}
|
| 529 |
+
target.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
| 530 |
+
target.classList.add('cw-jumped')
|
| 531 |
+
setTimeout(() => target.classList.remove('cw-jumped'), 1400)
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
// Is this tab's collaboration socket actually delivering changes? Everything
|
| 535 |
// typed or accepted while it is down never reaches the server, so actions that
|
| 536 |
// must persist check this first.
|
|
|
|
| 616 |
return pageLinkController ? pageLinkController(key) : false
|
| 617 |
}
|
| 618 |
|
| 619 |
+
// the headings of the page in front of you, in the shape sectionsFor() returns
|
| 620 |
+
function pageOutline(editor) {
|
| 621 |
+
return outlineOf(editor.state.doc).map(h => ({ slug: h.slug, title: h.node.textContent, summary: '', url: '' }))
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
function wirePageLinkMenu(editor) {
|
| 625 |
document.getElementById('pagelink-menu')?.remove()
|
| 626 |
const menu = el('div', { class: 'mention-menu hidden', id: 'pagelink-menu' })
|
|
|
|
| 637 |
|
| 638 |
const apply = item => {
|
| 639 |
if (!range) return
|
| 640 |
+
const path = item.slug === 'home' ? `/d/${docId}` : `/d/${docId}/${item.slug}`
|
| 641 |
+
const href = item.anchor ? `${path}#${item.anchor}` : path
|
| 642 |
editor
|
| 643 |
.chain()
|
| 644 |
.focus()
|
|
|
|
| 656 |
...items.map((item, i) => {
|
| 657 |
const row = el('div', { class: 'mention-item' + (i === sel ? ' selected' : '') })
|
| 658 |
row.appendChild(el('span', {}, item.title))
|
| 659 |
+
row.appendChild(el('span', { class: 'muted', style: 'margin-left:8px;font-size:11px' }, item.anchor ? `#${item.anchor}` : item.slug))
|
| 660 |
row.addEventListener('mousedown', e => {
|
| 661 |
e.preventDefault()
|
| 662 |
apply(item)
|
|
|
|
| 670 |
const { state: pmState } = editor
|
| 671 |
const { from, empty } = pmState.selection
|
| 672 |
if (!empty) return hide()
|
| 673 |
+
const before = pmState.doc.textBetween(Math.max(0, from - 80), from, '\n', '\n')
|
| 674 |
+
const m = before.match(/\[\[([^\[\]]{0,64})$/)
|
| 675 |
if (!m) return hide()
|
| 676 |
const q = m[1].toLowerCase()
|
| 677 |
+
// "[[page#" switches to that page's headings — how you cite one entry of a
|
| 678 |
+
// references page rather than the whole list
|
| 679 |
+
const deep = q.match(/^(.*?)#(.*)$/)
|
| 680 |
+
if (deep) {
|
| 681 |
+
const [, pageQ, headQ] = deep
|
| 682 |
+
const page = Object.entries(state.pageTitles || {})
|
| 683 |
+
.map(([slug, title]) => ({ slug, title }))
|
| 684 |
+
.find(p => p.slug === pageQ || p.title.toLowerCase() === pageQ)
|
| 685 |
+
if (!page) return hide()
|
| 686 |
+
// the target page's own headings, unless it IS this page (already in hand)
|
| 687 |
+
const load = page.slug === pageSlug ? Promise.resolve(pageOutline(editor)) : sectionsFor(page.slug)
|
| 688 |
+
Promise.resolve(load).then(sections => {
|
| 689 |
+
if (!range) return
|
| 690 |
+
items = sections
|
| 691 |
+
.filter(s => s.slug.includes(headQ) || s.title.toLowerCase().includes(headQ))
|
| 692 |
+
.slice(0, 8)
|
| 693 |
+
.map(s => ({ slug: page.slug, title: s.title, anchor: s.slug }))
|
| 694 |
+
if (!items.length) return hide()
|
| 695 |
+
sel = Math.min(sel, items.length - 1)
|
| 696 |
+
render()
|
| 697 |
+
menu.classList.remove('hidden')
|
| 698 |
+
})
|
| 699 |
+
range = { from: from - m[0].length, to: from }
|
| 700 |
+
return
|
| 701 |
+
}
|
| 702 |
items = Object.entries(state.pageTitles || {})
|
| 703 |
.filter(([slug]) => slug !== '_structure' && slug !== pageSlug)
|
| 704 |
.map(([slug, title]) => ({ slug, title }))
|
|
|
|
| 741 |
}
|
| 742 |
}
|
| 743 |
|
| 744 |
+
// --- link previews --------------------------------------------------------------
|
| 745 |
+
// Hovering a link to another page in this project shows what is on the other
|
| 746 |
+
// end: for "…/references#attention-is-all-you-need" that is the section's
|
| 747 |
+
// heading, its source URL and the start of its summary. Nothing here knows what
|
| 748 |
+
// a reference IS — it previews any internal link, and a references page is just
|
| 749 |
+
// the page where that happens to be most useful.
|
| 750 |
+
|
| 751 |
+
const pageCache = new Map() // slug -> { at, sections: Promise }
|
| 752 |
+
const PREVIEW_TTL = 30000 // a page being edited next door should not stay stale
|
| 753 |
+
|
| 754 |
+
function sectionsFor(slug) {
|
| 755 |
+
const hit = pageCache.get(slug)
|
| 756 |
+
if (hit && Date.now() - hit.at < PREVIEW_TTL) return hit.sections
|
| 757 |
+
const sections = api(`/api/docs/${docId}${slug === 'home' ? '' : `?page=${slug}`}`)
|
| 758 |
+
.then(res => markdownSections(res?.markdown || ''))
|
| 759 |
+
.catch(() => [])
|
| 760 |
+
pageCache.set(slug, { at: Date.now(), sections })
|
| 761 |
+
return sections
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
function wireLinkPreview() {
|
| 765 |
+
const pop = el('div', { id: 'link-preview', class: 'hidden' })
|
| 766 |
+
document.body.appendChild(pop)
|
| 767 |
+
let over = null
|
| 768 |
+
let timer = null
|
| 769 |
+
|
| 770 |
+
const hide = () => {
|
| 771 |
+
clearTimeout(timer)
|
| 772 |
+
over = null
|
| 773 |
+
pop.classList.add('hidden')
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
const show = async (a, target) => {
|
| 777 |
+
const sections = await sectionsFor(target.slug)
|
| 778 |
+
if (over !== a) return // the pointer moved on while we were fetching
|
| 779 |
+
const section = target.anchor ? sections.find(s => s.slug === target.anchor) : sections[0]
|
| 780 |
+
const title = section?.title || state.pageTitles?.[target.slug] || target.slug
|
| 781 |
+
const summary = section?.summary || ''
|
| 782 |
+
if (!title && !summary) return
|
| 783 |
+
pop.replaceChildren(
|
| 784 |
+
el('div', { class: 'lp-title' }, title),
|
| 785 |
+
...(section?.url ? [el('div', { class: 'lp-url' }, section.url.replace(/^https?:\/\//, ''))] : []),
|
| 786 |
+
...(summary ? [el('div', { class: 'lp-summary' }, summary.slice(0, 220) + (summary.length > 220 ? '…' : ''))] : []),
|
| 787 |
+
el('div', { class: 'lp-page' }, state.pageTitles?.[target.slug] || target.slug)
|
| 788 |
+
)
|
| 789 |
+
pop.classList.remove('hidden')
|
| 790 |
+
// place it under the link, kept inside the window
|
| 791 |
+
const r = a.getBoundingClientRect()
|
| 792 |
+
const w = pop.offsetWidth
|
| 793 |
+
pop.style.left = `${Math.max(8, Math.min(window.innerWidth - w - 8, r.left))}px`
|
| 794 |
+
const below = r.bottom + 8
|
| 795 |
+
pop.style.top = `${below + pop.offsetHeight > window.innerHeight - 8 ? Math.max(8, r.top - pop.offsetHeight - 8) : below}px`
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
document.addEventListener('mouseover', e => {
|
| 799 |
+
const a = e.target?.closest?.('.tiptap a, .proposal-content a, #margin-items a')
|
| 800 |
+
if (!a) {
|
| 801 |
+
if (over && !e.target?.closest?.('#link-preview')) hide()
|
| 802 |
+
return
|
| 803 |
+
}
|
| 804 |
+
if (a === over) return
|
| 805 |
+
const target = parseInternalLink(a.getAttribute('href'), docId)
|
| 806 |
+
if (!target) return hide()
|
| 807 |
+
hide()
|
| 808 |
+
over = a
|
| 809 |
+
timer = setTimeout(() => show(a, target), 280) // not on every passing pointer
|
| 810 |
+
})
|
| 811 |
+
document.addEventListener('scroll', hide, true)
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
// --- table controls -----------------------------------------------------------
|
| 815 |
// A floating bar that appears above the table your cursor is in, with add/remove
|
| 816 |
// row & column and delete-table actions (TipTap has no default table UI).
|
|
|
|
| 1393 |
user: { name: state.me.username, color: colorFor(state.me.username) },
|
| 1394 |
}),
|
| 1395 |
Highlights,
|
| 1396 |
+
HeadingFold,
|
| 1397 |
],
|
| 1398 |
editorProps: {
|
| 1399 |
handleClick: (view, pos, event) => {
|
|
|
|
| 1403 |
const touch = window.matchMedia('(hover: none)').matches
|
| 1404 |
if (!a?.getAttribute('href') || !(event.ctrlKey || event.metaKey || touch)) return false
|
| 1405 |
const url = new URL(a.getAttribute('href'), location.origin)
|
| 1406 |
+
const internal = parseInternalLink(a.getAttribute('href'), docId)
|
| 1407 |
+
if (internal) openPage(internal.slug, { anchor: internal.anchor })
|
| 1408 |
else if (url.origin === location.origin) location.href = url.pathname + url.search
|
| 1409 |
else window.open(url.href, '_blank', 'noopener')
|
| 1410 |
return true
|
client/src/fold.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Collapsible headings.
|
| 2 |
+
//
|
| 3 |
+
// A long page — a references list above all — is only navigable if its sections
|
| 4 |
+
// can be shut. Folding is a VIEW state, not document content: it lives in this
|
| 5 |
+
// module, never in the Yjs doc, so collapsing a section on your screen does not
|
| 6 |
+
// collapse it on everyone else's, and nothing about it is persisted or synced.
|
| 7 |
+
//
|
| 8 |
+
// Sections are keyed by heading slug (the same slug a "#anchor" link uses), so
|
| 9 |
+
// the fold survives edits elsewhere in the page and a link can open a folded
|
| 10 |
+
// section by name.
|
| 11 |
+
import { Extension } from '@tiptap/core'
|
| 12 |
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
| 13 |
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
| 14 |
+
import { makeSlugger } from './outline.js'
|
| 15 |
+
|
| 16 |
+
export const foldKey = new PluginKey('headingFold')
|
| 17 |
+
|
| 18 |
+
const folded = new Set()
|
| 19 |
+
let onToggle = () => {}
|
| 20 |
+
|
| 21 |
+
export function resetFolds() {
|
| 22 |
+
folded.clear()
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function isFolded(slug) {
|
| 26 |
+
return folded.has(slug)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function unfold(slug) {
|
| 30 |
+
if (!folded.delete(slug)) return false
|
| 31 |
+
onToggle()
|
| 32 |
+
return true
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// The page's headings in document order, each with the slug everything else
|
| 36 |
+
// addresses it by.
|
| 37 |
+
export function outlineOf(doc) {
|
| 38 |
+
const slugOf = makeSlugger()
|
| 39 |
+
const out = []
|
| 40 |
+
doc.forEach((node, offset) => {
|
| 41 |
+
if (node.type.name !== 'heading') return
|
| 42 |
+
out.push({ slug: slugOf(node.textContent), level: node.attrs.level || 1, pos: offset, node })
|
| 43 |
+
})
|
| 44 |
+
return out
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Walk the top level once: every heading gets its slug and a fold arrow, and
|
| 48 |
+
// everything under a folded heading — up to the next heading of the same or a
|
| 49 |
+
// higher rank — gets hidden.
|
| 50 |
+
function decorate(doc) {
|
| 51 |
+
const decos = []
|
| 52 |
+
const headings = outlineOf(doc)
|
| 53 |
+
const hidden = []
|
| 54 |
+
headings.forEach((h, i) => {
|
| 55 |
+
if (!folded.has(h.slug)) return
|
| 56 |
+
const next = headings.slice(i + 1).find(n => n.level <= h.level)
|
| 57 |
+
hidden.push([h.pos + h.node.nodeSize, next ? next.pos : doc.content.size])
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
let hi = 0
|
| 61 |
+
doc.forEach((node, offset) => {
|
| 62 |
+
const end = offset + node.nodeSize
|
| 63 |
+
const heading = headings[hi]
|
| 64 |
+
if (heading && heading.pos === offset) {
|
| 65 |
+
hi++
|
| 66 |
+
const collapsed = folded.has(heading.slug)
|
| 67 |
+
decos.push(
|
| 68 |
+
Decoration.node(offset, end, {
|
| 69 |
+
class: `cw-heading${collapsed ? ' cw-folded' : ''}`,
|
| 70 |
+
'data-anchor': heading.slug,
|
| 71 |
+
})
|
| 72 |
+
)
|
| 73 |
+
decos.push(
|
| 74 |
+
Decoration.widget(offset + 1, () => foldArrow(heading.slug, collapsed), {
|
| 75 |
+
side: -1,
|
| 76 |
+
key: `fold-${heading.slug}-${collapsed}`,
|
| 77 |
+
})
|
| 78 |
+
)
|
| 79 |
+
return
|
| 80 |
+
}
|
| 81 |
+
if (hidden.some(([from, to]) => offset >= from && end <= to)) {
|
| 82 |
+
decos.push(Decoration.node(offset, end, { class: 'cw-hidden-by-fold' }))
|
| 83 |
+
}
|
| 84 |
+
})
|
| 85 |
+
return DecorationSet.create(doc, decos)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function foldArrow(slug, collapsed) {
|
| 89 |
+
const btn = document.createElement('button')
|
| 90 |
+
btn.className = 'fold-arrow' + (collapsed ? ' collapsed' : '')
|
| 91 |
+
btn.type = 'button'
|
| 92 |
+
btn.contentEditable = 'false'
|
| 93 |
+
btn.setAttribute('aria-expanded', String(!collapsed))
|
| 94 |
+
btn.title = collapsed ? 'Expand section' : 'Collapse section'
|
| 95 |
+
btn.innerHTML = '<svg viewBox="0 0 22 22" class="icon"><path d="M6 9l5 5 5-5"/></svg>'
|
| 96 |
+
// mousedown, not click: ProseMirror would otherwise move the caret into the
|
| 97 |
+
// heading first and scroll the section you are trying to shut
|
| 98 |
+
btn.addEventListener('mousedown', e => {
|
| 99 |
+
e.preventDefault()
|
| 100 |
+
e.stopPropagation()
|
| 101 |
+
if (!folded.delete(slug)) folded.add(slug)
|
| 102 |
+
onToggle()
|
| 103 |
+
})
|
| 104 |
+
return btn
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export const HeadingFold = Extension.create({
|
| 108 |
+
name: 'headingFold',
|
| 109 |
+
addProseMirrorPlugins() {
|
| 110 |
+
const editor = this.editor
|
| 111 |
+
onToggle = () => editor.view.dispatch(editor.state.tr.setMeta('fold', true))
|
| 112 |
+
return [
|
| 113 |
+
new Plugin({
|
| 114 |
+
key: foldKey,
|
| 115 |
+
props: {
|
| 116 |
+
decorations: state => decorate(state.doc),
|
| 117 |
+
},
|
| 118 |
+
}),
|
| 119 |
+
]
|
| 120 |
+
},
|
| 121 |
+
})
|
client/src/outline.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Headings as addressable sections.
|
| 2 |
+
//
|
| 3 |
+
// One slug rule, used in three places that have to agree: the fold state, the
|
| 4 |
+
// "#anchor" at the end of a page link, and the hover preview that reads the
|
| 5 |
+
// target section. Anything that can name a heading names it this way.
|
| 6 |
+
|
| 7 |
+
export function headingSlug(text) {
|
| 8 |
+
return String(text || '')
|
| 9 |
+
.toLowerCase()
|
| 10 |
+
.replace(/[^a-z0-9]+/g, '-')
|
| 11 |
+
.replace(/^-+|-+$/g, '')
|
| 12 |
+
.slice(0, 64)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Slugs are unique within a page: repeats take -2, -3 … in document order, so
|
| 16 |
+
// two sections called "Method" stay separately addressable.
|
| 17 |
+
export function makeSlugger() {
|
| 18 |
+
const seen = new Map()
|
| 19 |
+
return text => {
|
| 20 |
+
const base = headingSlug(text) || 'section'
|
| 21 |
+
const n = (seen.get(base) || 0) + 1
|
| 22 |
+
seen.set(base, n)
|
| 23 |
+
return n === 1 ? base : `${base}-${n}`
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Split page markdown into sections: the heading, its slug, the prose under it
|
| 28 |
+
// and the first link in that prose. For a references page that is exactly the
|
| 29 |
+
// source's title, its URL and the start of its summary — but nothing here knows
|
| 30 |
+
// what a reference is, so it works for any page.
|
| 31 |
+
export function markdownSections(markdown) {
|
| 32 |
+
const slugOf = makeSlugger()
|
| 33 |
+
const sections = []
|
| 34 |
+
let fenced = false
|
| 35 |
+
for (const line of String(markdown || '').split('\n')) {
|
| 36 |
+
if (/^\s*```/.test(line)) fenced = !fenced
|
| 37 |
+
const head = !fenced && line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/)
|
| 38 |
+
if (head) {
|
| 39 |
+
sections.push({ level: head[1].length, title: stripMarkup(head[2]), slug: slugOf(head[2]), body: [] })
|
| 40 |
+
} else if (sections.length) {
|
| 41 |
+
sections[sections.length - 1].body.push(line)
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
return sections.map(s => {
|
| 45 |
+
const text = s.body.join('\n').trim()
|
| 46 |
+
return { ...s, summary: stripMarkup(firstProse(text)), url: firstUrl(text) }
|
| 47 |
+
})
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// The first paragraph that is prose rather than a bare link line — a reference
|
| 51 |
+
// usually leads with its URL, and "https://…" is a poor preview of a source.
|
| 52 |
+
function firstProse(text) {
|
| 53 |
+
for (const para of text.split(/\n\s*\n/)) {
|
| 54 |
+
const p = para.trim()
|
| 55 |
+
if (!p || /^\[?https?:\/\//.test(p) || /^<https?:\/\//.test(p)) continue
|
| 56 |
+
return p.replace(/\s+/g, ' ')
|
| 57 |
+
}
|
| 58 |
+
return ''
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function firstUrl(text) {
|
| 62 |
+
return text.match(/https?:\/\/[^\s)>\]]+/)?.[0] || ''
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Markdown left in a heading or a summary should read as text in a preview
|
| 66 |
+
// card, not as syntax.
|
| 67 |
+
function stripMarkup(s) {
|
| 68 |
+
return String(s || '')
|
| 69 |
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
| 70 |
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
| 71 |
+
.replace(/[*_`]+/g, '')
|
| 72 |
+
.trim()
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// "/d/<doc>/<page>#<anchor>" → the parts, for any link that points inside this
|
| 76 |
+
// project. Returns null for anything else (external links, other documents).
|
| 77 |
+
export function parseInternalLink(href, docId) {
|
| 78 |
+
if (!href) return null
|
| 79 |
+
let url
|
| 80 |
+
try {
|
| 81 |
+
url = new URL(href, location.origin)
|
| 82 |
+
} catch {
|
| 83 |
+
return null
|
| 84 |
+
}
|
| 85 |
+
if (url.origin !== location.origin) return null
|
| 86 |
+
const m = url.pathname.match(new RegExp(`^/d/${docId}(?:/([A-Za-z0-9_-]+))?/?$`))
|
| 87 |
+
if (!m) return null
|
| 88 |
+
return { slug: m[1] || 'home', anchor: decodeURIComponent(url.hash.replace(/^#/, '')) || null }
|
| 89 |
+
}
|
server/api.js
CHANGED
|
@@ -483,7 +483,18 @@ Work loop — repeat until the user tells you to stop:
|
|
| 483 |
the page with your content and adds it to the structure) or rejects it. To reorganize
|
| 484 |
EXISTING pages, suggest an edit to the _structure page's YAML as an ordinary suggestion.
|
| 485 |
Removing a page's line never deletes it — it just moves to "unfiled" in the sidebar.
|
| 486 |
-
Cross-reference pages in any markdown you write as [Title](/d/<doc_id>/<slug>)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
3. Do the task the instruction asks for, using your own tools and judgment.
|
| 489 |
|
|
|
|
| 483 |
the page with your content and adds it to the structure) or rejects it. To reorganize
|
| 484 |
EXISTING pages, suggest an edit to the _structure page's YAML as an ordinary suggestion.
|
| 485 |
Removing a page's line never deletes it — it just moves to "unfiled" in the sidebar.
|
| 486 |
+
Cross-reference pages in any markdown you write as [Title](/d/<doc_id>/<slug>),
|
| 487 |
+
or a single section as [Title](/d/<doc_id>/<slug>#<heading-slug>) — the slug of a
|
| 488 |
+
heading is its text lowercased with every run of non-alphanumerics turned into a
|
| 489 |
+
hyphen ("Attention Is All You Need" -> attention-is-all-you-need). Readers get a
|
| 490 |
+
preview of the section when they hover the link.
|
| 491 |
+
|
| 492 |
+
Sources: when you cite anything, keep a "references" page and cite INTO it.
|
| 493 |
+
Create it if it does not exist (a new-page suggestion titled "References").
|
| 494 |
+
One "### Source title" section per source, and under it the URL on its own line
|
| 495 |
+
followed by a 1-3 sentence summary of what it says. Then cite it from the text as
|
| 496 |
+
[Source title](/d/<doc_id>/references#source-title). Never paste bare URLs into
|
| 497 |
+
the prose, and never repeat a source's summary outside its section.
|
| 498 |
|
| 499 |
3. Do the task the instruction asks for, using your own tools and judgment.
|
| 500 |
|
test/browser.js
CHANGED
|
@@ -1035,6 +1035,132 @@ async function main() {
|
|
| 1035 |
await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
|
| 1036 |
console.log('✓ [[ page cross-references')
|
| 1037 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
// ghost page: visiting a slug that does not exist creates it, titled by slug
|
| 1039 |
const projId = page.url().split('/d/')[1].split('/')[0]
|
| 1040 |
await page.goto(`${BASE}/d/${projId}/road-map`)
|
|
|
|
| 1035 |
await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
|
| 1036 |
console.log('✓ [[ page cross-references')
|
| 1037 |
|
| 1038 |
+
// --- references: sections are addressable, previewable and collapsible ---
|
| 1039 |
+
// No references machinery exists: an agent writes an ordinary page with one
|
| 1040 |
+
// heading per source, and these three primitives make it work as a citation
|
| 1041 |
+
// system. The test therefore builds that page the way an agent would.
|
| 1042 |
+
const refsMd = [
|
| 1043 |
+
'# References',
|
| 1044 |
+
'',
|
| 1045 |
+
'### Attention Is All You Need',
|
| 1046 |
+
'',
|
| 1047 |
+
'https://arxiv.org/abs/1706.03762',
|
| 1048 |
+
'',
|
| 1049 |
+
'Introduces the transformer: attention replaces recurrence entirely.',
|
| 1050 |
+
'',
|
| 1051 |
+
'### Deep Residual Learning',
|
| 1052 |
+
'',
|
| 1053 |
+
'https://arxiv.org/abs/1512.03385',
|
| 1054 |
+
'',
|
| 1055 |
+
'Residual connections let very deep networks train at all.',
|
| 1056 |
+
'',
|
| 1057 |
+
].join('\n')
|
| 1058 |
+
const refsDocId = page.url().split('/d/')[1].split('/')[0]
|
| 1059 |
+
const refsPage = await page.evaluate(
|
| 1060 |
+
async ([id, md]) => {
|
| 1061 |
+
const post = (path, body) =>
|
| 1062 |
+
fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json())
|
| 1063 |
+
const proposed = await post(`/api/docs/${id}/page-suggestions`, { title: 'References', content_markdown: md })
|
| 1064 |
+
return post(`/api/docs/${id}/page-suggestions/${proposed.page_suggestion_id}/accept`, {})
|
| 1065 |
+
},
|
| 1066 |
+
[refsDocId, refsMd]
|
| 1067 |
+
)
|
| 1068 |
+
assert.ok(refsPage.ok, 'references page created: ' + JSON.stringify(refsPage))
|
| 1069 |
+
// the sidebar polls the structure; the autocomplete reads the same list
|
| 1070 |
+
await waitFor(
|
| 1071 |
+
async () => page.evaluate(() => [...document.querySelectorAll('#page-tree a')].some(a => a.textContent.includes('References'))),
|
| 1072 |
+
'the references page reaches the page tree',
|
| 1073 |
+
20000
|
| 1074 |
+
)
|
| 1075 |
+
|
| 1076 |
+
// cite it: "[[references#att" offers the section, and links straight into it
|
| 1077 |
+
await focusDocEnd(page)
|
| 1078 |
+
await page.keyboard.press('Enter')
|
| 1079 |
+
await page.keyboard.type('As shown in [[references#att')
|
| 1080 |
+
await waitFor(
|
| 1081 |
+
async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))),
|
| 1082 |
+
'the heading autocomplete offers sections of the target page'
|
| 1083 |
+
)
|
| 1084 |
+
await page.keyboard.press('Enter')
|
| 1085 |
+
const citationHref = () =>
|
| 1086 |
+
page.evaluate(() =>
|
| 1087 |
+
[...document.querySelectorAll('.tiptap a')].map(a => a.getAttribute('href')).find(h => h.includes('/references#'))
|
| 1088 |
+
)
|
| 1089 |
+
await waitFor(citationHref, 'a citation links into the section')
|
| 1090 |
+
const citation = await citationHref()
|
| 1091 |
+
assert.ok(citation.endsWith('#attention-is-all-you-need'), 'the anchor is the heading slug: ' + citation)
|
| 1092 |
+
|
| 1093 |
+
// hovering it shows what is on the other end
|
| 1094 |
+
await page.hover(`.tiptap a[href$="#attention-is-all-you-need"]`)
|
| 1095 |
+
await waitFor(async () => !(await page.evaluate(() => document.getElementById('link-preview').classList.contains('hidden'))), 'a preview appears')
|
| 1096 |
+
const preview = await page.evaluate(() => ({
|
| 1097 |
+
title: document.querySelector('#link-preview .lp-title')?.textContent,
|
| 1098 |
+
url: document.querySelector('#link-preview .lp-url')?.textContent,
|
| 1099 |
+
summary: document.querySelector('#link-preview .lp-summary')?.textContent,
|
| 1100 |
+
page: document.querySelector('#link-preview .lp-page')?.textContent,
|
| 1101 |
+
}))
|
| 1102 |
+
assert.strictEqual(preview.title, 'Attention Is All You Need', 'the preview names the source')
|
| 1103 |
+
assert.ok(preview.url?.includes('arxiv.org/abs/1706.03762'), 'and carries its link: ' + preview.url)
|
| 1104 |
+
assert.ok(preview.summary?.startsWith('Introduces the transformer'), 'and the start of the summary: ' + preview.summary)
|
| 1105 |
+
assert.strictEqual(preview.page, 'References', 'and says which page it is on')
|
| 1106 |
+
|
| 1107 |
+
// following it lands on that section, not merely on the page
|
| 1108 |
+
await page.click(`.tiptap a[href$="#attention-is-all-you-need"]`, { modifiers: ['Control'] })
|
| 1109 |
+
await waitFor(async () => (await page.evaluate(() => location.hash)) === '#attention-is-all-you-need', 'the link carries the anchor')
|
| 1110 |
+
await waitFor(
|
| 1111 |
+
async () => page.evaluate(() => !!document.querySelector('.tiptap [data-anchor="attention-is-all-you-need"]')),
|
| 1112 |
+
'the references page is open with its sections addressable'
|
| 1113 |
+
)
|
| 1114 |
+
const jumped = await page.evaluate(() => {
|
| 1115 |
+
const h = document.querySelector('.tiptap [data-anchor="attention-is-all-you-need"]')
|
| 1116 |
+
return { onScreen: h.getBoundingClientRect().top < innerHeight, marked: h.classList.contains('cw-jumped') }
|
| 1117 |
+
})
|
| 1118 |
+
assert.ok(jumped.onScreen, 'the section was scrolled into view')
|
| 1119 |
+
|
| 1120 |
+
// and a section can be shut, locally
|
| 1121 |
+
const folding = async () =>
|
| 1122 |
+
page.evaluate(() => ({
|
| 1123 |
+
hidden: document.querySelectorAll('.tiptap .cw-hidden-by-fold').length,
|
| 1124 |
+
folded: [...document.querySelectorAll('.tiptap .cw-folded')].map(n => n.dataset.anchor),
|
| 1125 |
+
bodyVisible: document.querySelector('.tiptap').innerText.includes('attention replaces recurrence'),
|
| 1126 |
+
}))
|
| 1127 |
+
const clickArrow = anchor =>
|
| 1128 |
+
page.evaluate(
|
| 1129 |
+
a =>
|
| 1130 |
+
document
|
| 1131 |
+
.querySelector(`.tiptap [data-anchor="${a}"] .fold-arrow`)
|
| 1132 |
+
.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })),
|
| 1133 |
+
anchor
|
| 1134 |
+
)
|
| 1135 |
+
assert.ok((await folding()).bodyVisible, 'the section reads before folding')
|
| 1136 |
+
await clickArrow('attention-is-all-you-need')
|
| 1137 |
+
await page.waitForTimeout(250)
|
| 1138 |
+
const shut = await folding()
|
| 1139 |
+
assert.deepStrictEqual(shut.folded, ['attention-is-all-you-need'], 'the heading shows as folded')
|
| 1140 |
+
assert.ok(shut.hidden > 0 && !shut.bodyVisible, 'its body is hidden, and only its body')
|
| 1141 |
+
assert.ok(
|
| 1142 |
+
await page.evaluate(() => document.querySelector('.tiptap').innerText.includes('Deep Residual Learning')),
|
| 1143 |
+
'the next section is untouched'
|
| 1144 |
+
)
|
| 1145 |
+
await clickArrow('attention-is-all-you-need')
|
| 1146 |
+
await page.waitForTimeout(250)
|
| 1147 |
+
assert.ok((await folding()).bodyVisible, 'unfolding brings it back')
|
| 1148 |
+
|
| 1149 |
+
// a link INTO a folded section has to open it, or the jump lands on nothing
|
| 1150 |
+
await clickArrow('attention-is-all-you-need')
|
| 1151 |
+
await page.waitForTimeout(250)
|
| 1152 |
+
await page.evaluate(() => {
|
| 1153 |
+
location.hash = ''
|
| 1154 |
+
location.hash = 'attention-is-all-you-need'
|
| 1155 |
+
})
|
| 1156 |
+
await page.waitForTimeout(600)
|
| 1157 |
+
assert.ok((await folding()).bodyVisible, 'following an anchor unfolds the section it points at')
|
| 1158 |
+
console.log('✓ references: #section links, hover previews, collapsible headings')
|
| 1159 |
+
|
| 1160 |
+
await page.goto(`${BASE}/d/${refsDocId}`)
|
| 1161 |
+
await page.waitForSelector('.tiptap', { timeout: 10000 })
|
| 1162 |
+
await page.waitForTimeout(800)
|
| 1163 |
+
|
| 1164 |
// ghost page: visiting a slug that does not exist creates it, titled by slug
|
| 1165 |
const projId = page.url().split('/d/')[1].split('/')[0]
|
| 1166 |
await page.goto(`${BASE}/d/${projId}/road-map`)
|