thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
8c82c03
·
1 Parent(s): edcad24

The sidebar box searches page text, not just page titles

Browse files

Filtering the tree by title only finds a page if you already remember what
it is called. Every page of a project is a named fragment of the one Yjs
document the tab already syncs, so the text to search is in memory before
the first keystroke — no endpoint, no per-page fetch, and no permission
logic to re-derive, since the socket handshake already decided access. The
index is built from the live CRDT, so it follows a collaborator's typing.

A match now shows its occurrences as snippets under the page, each labelled
with the heading it sits under, and clicking one opens that page scrolled to
that occurrence and no other. Hits in the open document are marked in amber
— not the accent green, which on this page always means somebody said
something about the text. The query lives in the URL (?q=&n=), so a search
is a link you can send someone.

Titles and slugs still match from the first character; searching text waits
for a second one, or one letter highlights half the prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (4) hide show
  1. client/app.css +54 -0
  2. client/doc.html +2 -2
  3. client/src/doc.js +418 -16
  4. test/browser.js +133 -0
client/app.css CHANGED
@@ -94,6 +94,7 @@
94
  --tint-strong: rgba(0, 0, 0, 0.12);
95
  --hl-weak: rgba(29, 92, 69, 0.06); /* accent washes: nav hover, strips, bars */
96
  --hl-edge: rgba(29, 92, 69, 0.55); /* the rule beside quoted/commented text */
 
97
  --scrim: rgba(18, 18, 24, 0.4);
98
  --danger: #b03030;
99
  --chip-bg: #f0f0f3;
@@ -385,6 +386,19 @@ textarea { resize: vertical; }
385
  .tiptap hr { border: none; border-top: 1px solid var(--doc-rule); margin: 0.75em 0; }
386
  .tiptap img { max-width: 100%; border-radius: 8px; display: block; margin: 0.4em 0; }
387
  .tiptap img.ProseMirror-selectednode { outline: 3px solid var(--accent-soft); outline-offset: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  .tiptap .comment-hl { background: var(--hl); border-bottom: 1.5px solid var(--hl-edge); cursor: pointer; }
389
  .tiptap .comment-hl.active { background: var(--hl-active); }
390
  /* What the open composer is about. The browser drops its own selection
@@ -612,6 +626,9 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
612
  --hl-active: rgba(116, 194, 160, 0.32);
613
  --hl-weak: rgba(116, 194, 160, 0.09);
614
  --hl-edge: rgba(116, 194, 160, 0.6);
 
 
 
615
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
616
  --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.55), 0 1px 3px rgba(0, 0, 0, 0.4);
617
  --page-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.55);
@@ -840,6 +857,43 @@ body.suggesting .tiptap { caret-color: transparent; cursor: default; }
840
  background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
841
  }
842
  #page-tree .unfiled-label { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 10px 0 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  .sidebar-toast {
844
  position: fixed; left: 16px; bottom: 16px; z-index: 90; max-width: min(380px, calc(100vw - 32px));
845
  padding: 8px 12px; border-radius: 999px; background: var(--tooltip-bg); color: #fff;
 
94
  --tint-strong: rgba(0, 0, 0, 0.12);
95
  --hl-weak: rgba(29, 92, 69, 0.06); /* accent washes: nav hover, strips, bars */
96
  --hl-edge: rgba(29, 92, 69, 0.55); /* the rule beside quoted/commented text */
97
+ --find: #ffe08a; /* search hits — amber, not accent: see mark.search-hl */
98
  --scrim: rgba(18, 18, 24, 0.4);
99
  --danger: #b03030;
100
  --chip-bg: #f0f0f3;
 
386
  .tiptap hr { border: none; border-top: 1px solid var(--doc-rule); margin: 0.75em 0; }
387
  .tiptap img { max-width: 100%; border-radius: 8px; display: block; margin: 0.4em 0; }
388
  .tiptap img.ProseMirror-selectednode { outline: 3px solid var(--accent-soft); outline-offset: 2px; }
389
+ /* Search hits inside the open document, live for as long as the sidebar box has
390
+ a query in it. Amber, not the accent green: every other mark on the text means
391
+ somebody said something about it (a comment, a suggestion, a pending
392
+ selection), and a search hit means nothing of the sort — it has to read as a
393
+ different kind of thing at a glance. The document's own ink is left alone, the
394
+ way find-in-page does it, so a highlighted link is still visibly a link.
395
+ box-decoration-break keeps a hit that wraps across a line from losing its
396
+ corners mid-word. */
397
+ .tiptap mark.search-hl {
398
+ background: var(--find); color: inherit;
399
+ border-radius: 2px; padding: 0 1px; margin: 0 -1px;
400
+ -webkit-box-decoration-break: clone; box-decoration-break: clone;
401
+ }
402
  .tiptap .comment-hl { background: var(--hl); border-bottom: 1.5px solid var(--hl-edge); cursor: pointer; }
403
  .tiptap .comment-hl.active { background: var(--hl-active); }
404
  /* What the open composer is about. The browser drops its own selection
 
626
  --hl-active: rgba(116, 194, 160, 0.32);
627
  --hl-weak: rgba(116, 194, 160, 0.09);
628
  --hl-edge: rgba(116, 194, 160, 0.6);
629
+ /* a wash rather than the light theme's solid amber: on the dark page a filled
630
+ block would out-shout the text it is supposed to be pointing at */
631
+ --find: rgba(255, 205, 90, 0.34);
632
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
633
  --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.55), 0 1px 3px rgba(0, 0, 0, 0.4);
634
  --page-shadow: 0 1px 3px 1px rgba(0, 0, 0, 0.55);
 
857
  background: var(--surface); color: var(--text); font: inherit; font-size: 13px;
858
  }
859
  #page-tree .unfiled-label { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 10px 0 2px; }
860
+
861
+ /* --- what a search looks like in the tree ----------------------------------
862
+ Only ever present while the box has a query in it, which is why none of this
863
+ touches the row rules above: the unfiltered tree renders exactly as before.
864
+
865
+ The title line becomes a flex line when it carries a hit count, so the count
866
+ keeps its width and the title is the part that ellipses. */
867
+ #page-tree a:has(.tree-hit-count) { display: flex; align-items: baseline; gap: 5px; }
868
+ #page-tree .tree-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
869
+ /* the matched substring, in a title and in a snippet alike */
870
+ #page-tree a b { color: var(--accent); font-weight: 600; }
871
+ /* The page's true number of hits, not the number of snippets shown below it. */
872
+ #page-tree .tree-hit-count {
873
+ flex: 0 0 auto; padding: 0 5px; border-radius: 8px; background: var(--accent-soft);
874
+ color: var(--accent); font-size: 9.5px; line-height: 15px; font-weight: 600;
875
+ font-family: var(--mono, ui-monospace, monospace);
876
+ }
877
+ /* One row per occurrence, indented under its page and ruled on the left so a
878
+ long list still reads as belonging to the page above it. */
879
+ #page-tree .tree-hits { margin: 1px 0 4px 25px; }
880
+ #page-tree .tree-hit {
881
+ display: block; padding: 2px 6px; border-left: 2px solid var(--line); border-radius: 0 4px 4px 0;
882
+ color: var(--text-3); font-size: 11px; line-height: 1.4; text-decoration: none;
883
+ white-space: normal; overflow: visible; text-overflow: clip;
884
+ transition: background .12s, border-color .12s, color .12s;
885
+ }
886
+ #page-tree .tree-hit:hover { background: var(--hl-weak); border-left-color: var(--accent); color: var(--text-2); }
887
+ /* The heading the hit sits under. rl-wiki put this in the URL as an anchor;
888
+ cowrite opens an editor, which has no heading anchors, so the heading earns
889
+ its keep as the visible answer to "where in the page is this?". */
890
+ #page-tree .tree-hit-sec {
891
+ display: block; color: var(--text-3); font-size: 9.5px; text-transform: uppercase;
892
+ letter-spacing: 0.05em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
893
+ }
894
+ #page-tree .tree-hit.more { font-style: italic; color: var(--text-3); border-left-color: transparent; }
895
+ #page-tree .tree-hit.more:hover { background: none; }
896
+ #page-tree .tree-empty { color: var(--text-3); font-size: 11.5px; font-style: italic; padding: 8px 2px; }
897
  .sidebar-toast {
898
  position: fixed; left: 16px; bottom: 16px; z-index: 90; max-width: min(380px, calc(100vw - 32px));
899
  padding: 8px 12px; border-radius: 999px; background: var(--tooltip-bg); color: #fff;
client/doc.html CHANGED
@@ -123,8 +123,8 @@
123
  <div id="sidebar-sticky-head">
124
  <div id="sidebar-head">
125
  <div id="sidebar-search-wrap">
126
- <input id="sidebar-search" type="text" placeholder="Filter pages…" autocomplete="off" aria-label="Filter pages by title" />
127
- <button id="sidebar-search-key" type="button" aria-label="Focus page filter">⌘K</button>
128
  </div>
129
  <div class="sidebar-actions">
130
  <button class="iconbtn" id="page-add" title="New page" aria-label="New page"></button>
 
123
  <div id="sidebar-sticky-head">
124
  <div id="sidebar-head">
125
  <div id="sidebar-search-wrap">
126
+ <input id="sidebar-search" type="text" placeholder="Search pages…" autocomplete="off" aria-label="Search page titles and text" />
127
+ <button id="sidebar-search-key" type="button" aria-label="Focus search">⌘K</button>
128
  </div>
129
  <div class="sidebar-actions">
130
  <button class="iconbtn" id="page-add" title="New page" aria-label="New page"></button>
client/src/doc.js CHANGED
@@ -195,7 +195,7 @@ const HtmlBlock = Node.create({
195
  // IMPORTANT: use tiptap's y-prosemirror fork — the Collaboration extension registers
196
  // its sync plugin from @tiptap/y-tiptap, so y-prosemirror's ySyncPluginKey is a
197
  // *different* PluginKey instance and getState() would return undefined.
198
- import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, relativePositionToAbsolutePosition } from '@tiptap/y-tiptap'
199
  import { markdownToBlocks } from '../../server/md.js'
200
  import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
201
  import { Decoration, DecorationSet } from '@tiptap/pm/view'
@@ -325,10 +325,16 @@ async function main() {
325
  document.getElementById('m-more').classList.remove('on')
326
  })
327
 
328
- // back/forward navigates between pages client-side
 
329
  window.addEventListener('popstate', () => {
330
  const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
331
- if (slug !== pageSlug) openPage(slug, { push: false })
 
 
 
 
 
332
  })
333
 
334
  // in flight while the document syncs, not after it. Every refresh of it also
@@ -502,6 +508,14 @@ async function initPage() {
502
  }
503
 
504
  renderMargin(editor)
 
 
 
 
 
 
 
 
505
  setTimeout(() => isLive(editor) && updateTitle(editor), 600)
506
 
507
  // a ghost page appears in the tree as soon as its record lands
@@ -545,7 +559,7 @@ async function openPage(slug, { push = true } = {}) {
545
  // the tab's page part until the new editor reports its own first line; the
546
  // sidebar already knows it, and keeping the previous page's name would be wrong
547
  paintTitles(state.pageTitles?.[slug] || '')
548
- if (push) history.pushState({}, '', slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`)
549
  await initPage()
550
  loadStructure().finally(() => paintTitles())
551
  }
@@ -1239,6 +1253,339 @@ let sidebarFilter = ''
1239
  let pendingSidebarRename = null
1240
  let sidebarToastTimer = null
1241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1242
  function showSidebarToast(message) {
1243
  let toast = document.querySelector('.sidebar-toast')
1244
  if (!toast) {
@@ -1357,15 +1704,30 @@ function wireSidebar() {
1357
  })
1358
  const search = document.getElementById('sidebar-search')
1359
  const searchKey = document.getElementById('sidebar-search-key')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1360
  const paintFilter = () => {
1361
- sidebarFilter = search.value.trim().toLocaleLowerCase()
1362
- loadStructure()
1363
  }
1364
  const clearFilter = () => {
1365
  search.value = ''
1366
- sidebarFilter = ''
1367
  search.blur()
1368
- loadStructure()
 
1369
  }
1370
  search.addEventListener('input', paintFilter)
1371
  search.addEventListener('keydown', e => {
@@ -1377,6 +1739,10 @@ function wireSidebar() {
1377
  if (search.value) clearFilter()
1378
  else search.focus()
1379
  })
 
 
 
 
1380
  document.addEventListener('keydown', e => {
1381
  if ((e.metaKey || e.ctrlKey) && e.key.toLocaleLowerCase() === 'k') {
1382
  e.preventDefault()
@@ -1419,12 +1785,17 @@ async function loadStructure() {
1419
  const s = await api(`/api/docs/${docId}/structure`)
1420
  if (s?.error) return showSidebarToast(s.error)
1421
  sidebarSnapshot = s
 
 
 
 
 
 
1422
  state.pageTitles = s.titles || {}
1423
  renderSlugLegend(s)
1424
  const container = document.getElementById('page-tree')
1425
  const known = new Set(Object.keys(s.titles || {}))
1426
  known.add(pageSlug) // visiting auto-creates, so the current page is never a ghost
1427
- const pageHref = slug => (slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`)
1428
  const deletablePage = slug => slug !== 'home' && slug !== '_structure' && known.has(slug)
1429
  const pageDeleteBtn = slug => {
1430
  const title = s.titles?.[slug] || slug
@@ -1485,8 +1856,9 @@ async function loadStructure() {
1485
  for (const key of sidebarSelection) if (!everyKey.has(key)) sidebarSelection.delete(key)
1486
  if (sidebarSelectionAnchor && !everyKey.has(sidebarSelectionAnchor)) sidebarSelectionAnchor = null
1487
 
1488
- // Filtering is title-only. Non-matching ancestors remain so the location of
1489
- // each result is legible, but unrelated descendants do not.
 
1490
  const filterNodes = (nodes, path = []) => {
1491
  if (!sidebarFilter) return nodes || []
1492
  const kept = []
@@ -1497,7 +1869,11 @@ async function loadStructure() {
1497
  : node.group != null
1498
  ? node.group
1499
  : s.titles?.[node.slug] || node.slug
1500
- if (label.toLocaleLowerCase().includes(sidebarFilter) || children.length) kept.push({ ...node, children })
 
 
 
 
1501
  }
1502
  return kept
1503
  }
@@ -1635,6 +2011,7 @@ async function loadStructure() {
1635
  const hasChildren = Boolean(node.children?.length)
1636
  const isCollapsed = !sidebarFilter && hasChildren && collapsed.has(key) && !forcedOpen.has(key)
1637
  const li = el('li', { class: group ? 'nav-cat' : '' })
 
1638
  if (node.proposal) {
1639
  // not a line in the structure yaml yet, so it takes no drag attributes:
1640
  // there is nothing to move until it is accepted
@@ -1694,7 +2071,9 @@ async function loadStructure() {
1694
  const a = el(
1695
  'a',
1696
  {
1697
- href: pageHref(node.slug),
 
 
1698
  class: (node.slug === pageSlug ? 'current' : '') + (known.has(node.slug) ? '' : ' ghost-page'),
1699
  title: known.has(node.slug) ? '' : `no page "${node.slug}" yet — create it or fix the structure yaml`,
1700
  },
@@ -1733,8 +2112,14 @@ async function loadStructure() {
1733
  row.appendChild(star)
1734
  }
1735
  if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
 
 
1736
  }
1737
  li.appendChild(row)
 
 
 
 
1738
  if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1))
1739
  ul.appendChild(li)
1740
  })
@@ -1743,7 +2128,7 @@ async function loadStructure() {
1743
  container.innerHTML = ''
1744
  container.appendChild(renderNodes(visibleTree))
1745
  const visibleUnfiled = sidebarFilter
1746
- ? (s.unfiled || []).filter(slug => (s.titles?.[slug] || slug).toLocaleLowerCase().includes(sidebarFilter))
1747
  : (s.unfiled || [])
1748
  if (visibleUnfiled.length) {
1749
  container.appendChild(el('div', { class: 'unfiled-label' }, 'unfiled'))
@@ -1757,7 +2142,7 @@ async function loadStructure() {
1757
  row.className = 'tree-row page-row unfiled-row'
1758
  row.appendChild(el('span', { class: 'tree-caret-spacer', 'aria-hidden': 'true' }))
1759
  const pageTitle = s.titles?.[slug] || slug
1760
- const a = el('a', { href: pageHref(slug), class: slug === pageSlug ? 'current' : '' }, pageTitle)
1761
  let clickTimer = null
1762
  a.addEventListener('click', e => {
1763
  if (e.button !== 0) return
@@ -1779,12 +2164,19 @@ async function loadStructure() {
1779
  })
1780
  row.appendChild(a)
1781
  if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug))
 
 
1782
  li.appendChild(row)
 
1783
  ul.appendChild(li)
1784
  }
1785
  if (ul.children.length) container.appendChild(ul)
1786
  }
1787
-
 
 
 
 
1788
  }
1789
 
1790
  // --- new-page proposal preview ------------------------------------------------
@@ -2088,6 +2480,16 @@ function buildDecorations(pmState) {
2088
  } catch {}
2089
  }
2090
 
 
 
 
 
 
 
 
 
 
 
2091
  return DecorationSet.create(pmState.doc, decos)
2092
  }
2093
 
 
195
  // IMPORTANT: use tiptap's y-prosemirror fork — the Collaboration extension registers
196
  // its sync plugin from @tiptap/y-tiptap, so y-prosemirror's ySyncPluginKey is a
197
  // *different* PluginKey instance and getState() would return undefined.
198
+ import { ySyncPluginKey, yUndoPluginKey, absolutePositionToRelativePosition, relativePositionToAbsolutePosition, yXmlFragmentToProsemirrorJSON } from '@tiptap/y-tiptap'
199
  import { markdownToBlocks } from '../../server/md.js'
200
  import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
201
  import { Decoration, DecorationSet } from '@tiptap/pm/view'
 
325
  document.getElementById('m-more').classList.remove('on')
326
  })
327
 
328
+ // back/forward navigates between pages client-side, and carries the search
329
+ // that page was found through (?q=) back with it
330
  window.addEventListener('popstate', () => {
331
  const slug = location.pathname.split('/').filter(Boolean)[2] || 'home'
332
+ const searchChanged = adoptSearchFromUrl()
333
+ if (slug !== pageSlug) return openPage(slug, { push: false })
334
+ if (!searchChanged) return
335
+ if (sidebarSnapshot) renderStructure(sidebarSnapshot)
336
+ refreshDocHighlights()
337
+ settleDeepLinkQuery()
338
  })
339
 
340
  // in flight while the document syncs, not after it. Every refresh of it also
 
508
  }
509
 
510
  renderMargin(editor)
511
+ // A search survives a page switch, and a ?q= deep link starts one before this
512
+ // editor existed: repaint the hit list against this page's text, mark it, and
513
+ // — for a deep link only — scroll to the hit it named.
514
+ if (sidebarFilter) {
515
+ if (sidebarSnapshot) renderStructure(sidebarSnapshot)
516
+ refreshDocHighlights()
517
+ settleDeepLinkQuery()
518
+ }
519
  setTimeout(() => isLive(editor) && updateTitle(editor), 600)
520
 
521
  // a ghost page appears in the tree as soon as its record lands
 
559
  // the tab's page part until the new editor reports its own first line; the
560
  // sidebar already knows it, and keeping the previous page's name would be wrong
561
  paintTitles(state.pageTitles?.[slug] || '')
562
+ if (push) history.pushState({}, '', searchHref(slug))
563
  await initPage()
564
  loadStructure().finally(() => paintTitles())
565
  }
 
1253
  let pendingSidebarRename = null
1254
  let sidebarToastTimer = null
1255
 
1256
+ /* ============================================================
1257
+ SEARCH — the sidebar box searches page titles AND page text.
1258
+ ============================================================
1259
+ Where the index comes from. Every page of a project is a named fragment of
1260
+ the ONE Yjs document this tab already syncs (see initProjectCollaboration),
1261
+ so the text of every page the reader is allowed to see is sitting in memory
1262
+ before the first keystroke. That makes a client-side index the cheap option
1263
+ here rather than the expensive one: no endpoint, no per-page fetch, no
1264
+ permission logic to re-derive (the socket handshake already decided access),
1265
+ and the index follows a collaborator's typing because it is built from the
1266
+ live CRDT, not from a snapshot the server took.
1267
+
1268
+ Two walks read that content, and they MUST enumerate matchable text in the
1269
+ same order, because the sidebar identifies a hit by its ordinal within the
1270
+ page and the jump then reveals the Nth <mark> in the document:
1271
+ - indexBlocks() walks the Yjs->ProseMirror JSON of any page, current or not;
1272
+ - docHitRanges() walks the live ProseMirror doc of the open page.
1273
+ The invariant that keeps them in step: only text nodes contribute characters,
1274
+ every other inline node contributes exactly one character that no query can
1275
+ match, and text blocks are separated by a newline that no (trimmed,
1276
+ single-line) query can span. */
1277
+
1278
+ // One character per non-text inline node. Same width as the node's ProseMirror
1279
+ // position, so an offset in the text we index is also an offset in the
1280
+ // document; U+FFFC is the object-replacement character, which the search box
1281
+ // strips from the query (see paintFilter) and so can never be part of a hit.
1282
+ const INLINE_ATOM = '\uFFFC'
1283
+ // Below this, the box is the title filter it has always been: one letter matches
1284
+ // half the prose, which buries the tree in snippets and marks that point at
1285
+ // nothing in particular. A page called "R" is still worth finding by name, so
1286
+ // titles and slugs keep matching from the first character — it is searching the
1287
+ // *text* that waits for a second one, and it waits in all three places at once
1288
+ // (which pages match, which snippets show, what the document marks).
1289
+ const MIN_TEXT_QUERY = 2
1290
+ // Snippets per page. rl-wiki showed 20; this sidebar is 220px wide, where a
1291
+ // snippet is two or three wrapped lines, so 20 of them bury the rest of the tree.
1292
+ const SNIPPET_CAP = 8
1293
+
1294
+ // Blocks of a page as { text, heading }: the plain text of every text-bearing
1295
+ // block, tagged with the nearest heading at or above it. In rl-wiki this had to
1296
+ // re-implement the renderer's slugify to guess an anchor id from markdown
1297
+ // source; here the heading is simply the heading node we last walked past, and
1298
+ // it is shown next to the hit rather than encoded into a URL — cowrite renders
1299
+ // an editor, which has positions but no heading anchors.
1300
+ function indexBlocks(json) {
1301
+ const blocks = []
1302
+ let heading = ''
1303
+ const visit = node => {
1304
+ const content = node.content
1305
+ if (!Array.isArray(content) || !content.length) return
1306
+ // A ProseMirror node's children are either all blocks or all inline, so one
1307
+ // text child means this node is a text block and recursion stops here.
1308
+ if (!content.some(c => c.type === 'text')) {
1309
+ for (const child of content) visit(child)
1310
+ return
1311
+ }
1312
+ let text = ''
1313
+ for (const child of content) {
1314
+ if (child.type === 'text') text += child.text || ''
1315
+ else text += child.type === 'hardBreak' ? ' ' : INLINE_ATOM
1316
+ }
1317
+ if (node.type === 'heading') heading = text
1318
+ blocks.push({ text, heading })
1319
+ }
1320
+ visit(json)
1321
+ return blocks
1322
+ }
1323
+
1324
+ // slug -> { lc, blocks } for every page of the project, built lazily and dropped
1325
+ // again when that page's fragment changes. `lc` is the whole page lowercased,
1326
+ // blocks joined by a newline, so "does this page match at all?" is one
1327
+ // String.includes before anything walks the blocks. Kept across page switches:
1328
+ // the sidebar it feeds is part of the shell, not of the open document.
1329
+ const searchIndex = new Map()
1330
+ const searchWatched = new Set()
1331
+
1332
+ function pageSearchEntry(slug) {
1333
+ const cached = searchIndex.get(slug)
1334
+ if (cached) return cached
1335
+ if (!ydoc) return null
1336
+ const fragment = ydoc.getXmlFragment(pageField(slug))
1337
+ if (!searchWatched.has(slug)) {
1338
+ searchWatched.add(slug)
1339
+ // Precise invalidation, one observer per page: a keystroke anywhere in the
1340
+ // project would otherwise rebuild every page's text.
1341
+ fragment.observeDeep(() => {
1342
+ searchIndex.delete(slug)
1343
+ if (sidebarFilter) scheduleSearchRefresh()
1344
+ })
1345
+ }
1346
+ let blocks = []
1347
+ try {
1348
+ blocks = indexBlocks(yXmlFragmentToProsemirrorJSON(fragment))
1349
+ } catch {
1350
+ blocks = [] // an unreadable page must not take the search down with it
1351
+ }
1352
+ const entry = { lc: blocks.map(b => b.text).join('\n').toLocaleLowerCase(), blocks }
1353
+ searchIndex.set(slug, entry)
1354
+ return entry
1355
+ }
1356
+
1357
+ // Every occurrence of q in a page: a snippet with its surrounding words, the
1358
+ // heading it sits under, and its ordinal so the document can be scrolled to
1359
+ // this hit and no other. `total` is the true count even when the list is capped.
1360
+ function pageHits(slug, q) {
1361
+ if (q.length < MIN_TEXT_QUERY) return { hits: [], total: 0 }
1362
+ const entry = pageSearchEntry(slug)
1363
+ if (!entry || !entry.lc.includes(q)) return { hits: [], total: 0 }
1364
+ const hits = []
1365
+ let total = 0
1366
+ for (const block of entry.blocks) {
1367
+ const lc = block.text.toLocaleLowerCase()
1368
+ for (let i = lc.indexOf(q); i >= 0; i = lc.indexOf(q, i + q.length)) {
1369
+ if (hits.length < SNIPPET_CAP) {
1370
+ // 30 before / 40 after: enough of the sentence to tell two hits apart,
1371
+ // and about two wrapped lines in a 220px sidebar. The text is already
1372
+ // plain — no markdown syntax to strip out of it, unlike rl-wiki, which
1373
+ // indexed the source.
1374
+ //
1375
+ // Cut in three pieces around THIS occurrence instead of excerpting and
1376
+ // then searching the excerpt again: two hits in one sentence produce
1377
+ // near-identical excerpts, and re-finding the query in one of those
1378
+ // bolds whichever occurrence comes first — so the second hit would point
1379
+ // at the first hit's words.
1380
+ const from = Math.max(0, i - 30)
1381
+ const to = i + q.length + 40
1382
+ const flat = s => s.replace(/\s+/g, ' ')
1383
+ hits.push({
1384
+ before: (from > 0 ? '…' : '') + flat(block.text.slice(from, i)).trimStart(),
1385
+ match: flat(block.text.slice(i, i + q.length)),
1386
+ after: flat(block.text.slice(i + q.length, to)).trimEnd() + (to < block.text.length ? '…' : ''),
1387
+ heading: block.heading,
1388
+ ordinal: total,
1389
+ })
1390
+ }
1391
+ total++
1392
+ }
1393
+ }
1394
+ return { hits, total }
1395
+ }
1396
+
1397
+ // A page matches on its title, its slug, or anywhere in its text.
1398
+ function pageMatches(slug, title, q) {
1399
+ if ((title || '').toLocaleLowerCase().includes(q) || slug.toLocaleLowerCase().includes(q)) return true
1400
+ if (q.length < MIN_TEXT_QUERY) return false
1401
+ const entry = pageSearchEntry(slug)
1402
+ return !!entry && entry.lc.includes(q)
1403
+ }
1404
+
1405
+ // Bold the matched substring of a page title. Returns HTML, so everything around
1406
+ // the <b> is escaped. (Snippets already know where their match is — see pageHits.)
1407
+ function highlightMatch(text, q) {
1408
+ const i = q ? text.toLocaleLowerCase().indexOf(q) : -1
1409
+ if (i < 0) return esc(text)
1410
+ return esc(text.slice(0, i)) + '<b>' + esc(text.slice(i, i + q.length)) + '</b>' + esc(text.slice(i + q.length))
1411
+ }
1412
+
1413
+ // Ranges of the active query in a live ProseMirror doc, in document order — the
1414
+ // same order pageHits() counts in, so hit N here is hit N there.
1415
+ function docHitRanges(doc, q) {
1416
+ const ranges = []
1417
+ if (!q) return ranges
1418
+ doc.descendants((node, pos) => {
1419
+ if (!node.isTextblock) return true
1420
+ let text = ''
1421
+ const spans = [] // where each child's characters start, in text and in the doc
1422
+ node.forEach((child, offset) => {
1423
+ spans.push({ at: text.length, from: pos + 1 + offset })
1424
+ if (child.isText) text += child.text || ''
1425
+ else text += child.type.name === 'hardBreak' ? ' ' : INLINE_ATOM
1426
+ })
1427
+ const lc = text.toLocaleLowerCase()
1428
+ const docPos = off => {
1429
+ let span = spans[0]
1430
+ for (const s of spans) if (s.at <= off) span = s
1431
+ return span ? span.from + (off - span.at) : pos + 1
1432
+ }
1433
+ for (let i = lc.indexOf(q); i >= 0; i = lc.indexOf(q, i + q.length)) {
1434
+ ranges.push({ from: docPos(i), to: docPos(i + q.length) })
1435
+ }
1436
+ return false
1437
+ })
1438
+ return ranges
1439
+ }
1440
+
1441
+ // Highlighting the open document is the expensive half of the search: it runs
1442
+ // on every editor transaction, including every keystroke. Memoised on the doc
1443
+ // and the query, both of which are immutable, so an unrelated transaction (a
1444
+ // selection move, a decoration poke) costs one comparison.
1445
+ let hitRangeMemo = { doc: null, q: '', ranges: [] }
1446
+ function memoHitRanges(doc, q) {
1447
+ if (hitRangeMemo.doc !== doc || hitRangeMemo.q !== q) {
1448
+ hitRangeMemo = { doc, q, ranges: docHitRanges(doc, q) }
1449
+ }
1450
+ return hitRangeMemo.ranges
1451
+ }
1452
+
1453
+ // Poke the open editor so buildDecorations runs again: the query changed but the
1454
+ // document did not, and nothing else would ask for fresh decorations.
1455
+ function refreshDocHighlights() {
1456
+ const editor = window.__editor
1457
+ if (editor && !editor.isDestroyed) poke(editor)
1458
+ }
1459
+
1460
+ // Scroll the nth mark of the open document into view. Waits for it: a page
1461
+ // switch remounts the editor and a deep link lands before the socket has
1462
+ // synced, so the mark is not in the DOM on the next line. Bounded in frames
1463
+ // rather than milliseconds, which also means a background tab spends none of
1464
+ // its budget — rAF does not fire there, and there is nothing to scroll anyway.
1465
+ async function scrollToMark(ordinal, frames) {
1466
+ for (let frame = 0; frame < frames; frame++) {
1467
+ const marks = document.querySelectorAll('#editor mark.search-hl')
1468
+ if (marks.length > ordinal) {
1469
+ marks[ordinal].scrollIntoView({ block: 'center', behavior: 'smooth' })
1470
+ return true
1471
+ }
1472
+ await new Promise(done => requestAnimationFrame(done))
1473
+ }
1474
+ return false // the text went away between the render and the click
1475
+ }
1476
+
1477
+ // Reveal one hit: the page it is on, scrolled to that hit and no other. The
1478
+ // document's marks are in the same order pageHits() counted in, so the ordinal
1479
+ // picks one out.
1480
+ async function revealHit(slug, ordinal) {
1481
+ if (slug !== pageSlug) await openPage(slug)
1482
+ reflectSearchInUrl(ordinal)
1483
+ await scrollToMark(ordinal, 30)
1484
+ }
1485
+
1486
+ // The hit a deep link arrived at, spent once. The page it names scrolls to that
1487
+ // hit as soon as the text has synced; every later page switch keeps the search
1488
+ // running but must not yank the reader to a mark they did not ask for.
1489
+ let pendingDeepLinkHit = -1
1490
+ function settleDeepLinkQuery() {
1491
+ if (pendingDeepLinkHit < 0) return
1492
+ const ordinal = pendingDeepLinkHit
1493
+ pendingDeepLinkHit = -1
1494
+ scrollToMark(ordinal, 600) // ~10s of frames: a cold load has to sync first
1495
+ }
1496
+
1497
+ // Take the query from the URL — on load, and again when back/forward lands on a
1498
+ // history entry with a different one.
1499
+ function adoptSearchFromUrl() {
1500
+ const { q, hit } = urlSearchQuery()
1501
+ if (q === sidebarFilter) return false
1502
+ sidebarFilter = q
1503
+ const box = document.getElementById('sidebar-search')
1504
+ if (box) box.value = q
1505
+ pendingDeepLinkHit = q ? hit : -1
1506
+ return true
1507
+ }
1508
+
1509
+ // Throttled tree re-render, for when the index is invalidated under an active
1510
+ // search — a collaborator typing on another page changes what matches.
1511
+ let searchRefreshTimer = null
1512
+ function scheduleSearchRefresh() {
1513
+ if (searchRefreshTimer) return
1514
+ searchRefreshTimer = setTimeout(() => {
1515
+ searchRefreshTimer = null
1516
+ if (!sidebarFilter || !sidebarSnapshot) return
1517
+ renderStructure(sidebarSnapshot)
1518
+ }, 200)
1519
+ }
1520
+
1521
+ // The active query lives in the URL so that what you are looking at is what you
1522
+ // can send someone: /d/<id>/<page>?q=<term>[&n=<hit>] opens the page with the
1523
+ // search already running and jumps to that hit.
1524
+ function searchHref(slug, ordinal = 0) {
1525
+ const base = slug === 'home' ? `/d/${docId}` : `/d/${docId}/${slug}`
1526
+ if (!sidebarFilter) return base
1527
+ return base + `?q=${encodeURIComponent(sidebarFilter)}` + (ordinal ? `&n=${ordinal}` : '')
1528
+ }
1529
+
1530
+ // replaceState, not pushState — a query is typed one character at a time, and
1531
+ // back should leave the page rather than walk backwards through the letters.
1532
+ function reflectSearchInUrl(ordinal = 0) {
1533
+ const want = searchHref(pageSlug, ordinal)
1534
+ if (location.pathname + location.search !== want) history.replaceState({}, '', want)
1535
+ }
1536
+
1537
+ function urlSearchQuery() {
1538
+ try {
1539
+ const params = new URLSearchParams(location.search)
1540
+ const q = (params.get('q') || '').trim().replaceAll(INLINE_ATOM, '').toLocaleLowerCase()
1541
+ const n = Number.parseInt(params.get('n') || '0', 10)
1542
+ return { q, hit: q && Number.isInteger(n) && n > 0 ? n : 0 }
1543
+ } catch {
1544
+ return { q: '', hit: 0 }
1545
+ }
1546
+ }
1547
+
1548
+ // --- what a search looks like in the sidebar ---------------------------------
1549
+
1550
+ // The query bolded inside a page's title, plus the page's true hit count. Only
1551
+ // under an active search: without one the row keeps the plain text label it has
1552
+ // always had, so nothing about the unfiltered tree changes.
1553
+ function paintPageLabel(a, title, total) {
1554
+ if (!sidebarFilter) return
1555
+ const label = el('span', { class: 'tree-title' })
1556
+ label.innerHTML = highlightMatch(title, sidebarFilter)
1557
+ a.replaceChildren(label)
1558
+ if (total > 1) a.appendChild(el('span', { class: 'tree-hit-count' }, String(total)))
1559
+ }
1560
+
1561
+ // The hit list under a page row: one clickable snippet per occurrence in the
1562
+ // page, each labelled with the heading it sits under. A page that matched on its
1563
+ // title alone gets no list — that is how a title match stays quiet.
1564
+ function pageHitList(slug) {
1565
+ const { hits, total } = pageHits(slug, sidebarFilter)
1566
+ if (!hits.length) return { list: null, total }
1567
+ const list = el('div', { class: 'tree-hits' })
1568
+ for (const hit of hits) {
1569
+ // A real link, so a middle-click or ⌘-click opens the hit in its own tab and
1570
+ // the context menu can copy it. n= is the hit's ordinal, dropped for the
1571
+ // first one: ?q= alone already lands there.
1572
+ const row = el('a', { class: 'tree-hit', href: searchHref(slug, hit.ordinal) })
1573
+ if (hit.heading) row.appendChild(el('span', { class: 'tree-hit-sec' }, hit.heading))
1574
+ const text = el('span', { class: 'tree-hit-text' })
1575
+ text.append(hit.before, el('b', {}, hit.match), hit.after)
1576
+ row.appendChild(text)
1577
+ row.addEventListener('click', e => {
1578
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey) return
1579
+ e.preventDefault()
1580
+ revealHit(slug, hit.ordinal)
1581
+ })
1582
+ list.appendChild(row)
1583
+ }
1584
+ const rest = total - hits.length
1585
+ if (rest > 0) list.appendChild(el('div', { class: 'tree-hit more' }, `+${rest} more match${rest === 1 ? '' : 'es'}`))
1586
+ return { list, total }
1587
+ }
1588
+
1589
  function showSidebarToast(message) {
1590
  let toast = document.querySelector('.sidebar-toast')
1591
  if (!toast) {
 
1704
  })
1705
  const search = document.getElementById('sidebar-search')
1706
  const searchKey = document.getElementById('sidebar-search-key')
1707
+ // A keystroke now repaints the tree AND the document's marks, but it is not
1708
+ // news about the structure: it renders from the snapshot we already hold
1709
+ // rather than asking the server again, which is what the box used to do on
1710
+ // every character.
1711
+ const applyFilter = () => {
1712
+ sidebarFilter = search.value.trim().replaceAll(INLINE_ATOM, '').toLocaleLowerCase()
1713
+ if (sidebarSnapshot) renderStructure(sidebarSnapshot)
1714
+ else loadStructure()
1715
+ reflectSearchInUrl()
1716
+ refreshDocHighlights()
1717
+ }
1718
+ // 120ms, as rl-wiki used: typing "suggestion" would otherwise rebuild the tree
1719
+ // and re-scan the open document ten times on the way to the word. The box
1720
+ // itself does not wait on this — its ✕ badge is CSS — so it still feels live.
1721
+ let filterPaintTimer = null
1722
  const paintFilter = () => {
1723
+ clearTimeout(filterPaintTimer)
1724
+ filterPaintTimer = setTimeout(applyFilter, 120)
1725
  }
1726
  const clearFilter = () => {
1727
  search.value = ''
 
1728
  search.blur()
1729
+ clearTimeout(filterPaintTimer)
1730
+ applyFilter()
1731
  }
1732
  search.addEventListener('input', paintFilter)
1733
  search.addEventListener('keydown', e => {
 
1739
  if (search.value) clearFilter()
1740
  else search.focus()
1741
  })
1742
+ // A ?q= deep link arrives with the shell, before the socket has synced — the
1743
+ // hit list and the marks can only be painted once the text is here, so seed
1744
+ // the box now and let initPage do the rest once it is.
1745
+ adoptSearchFromUrl()
1746
  document.addEventListener('keydown', e => {
1747
  if ((e.metaKey || e.ctrlKey) && e.key.toLocaleLowerCase() === 'k') {
1748
  e.preventDefault()
 
1785
  const s = await api(`/api/docs/${docId}/structure`)
1786
  if (s?.error) return showSidebarToast(s.error)
1787
  sidebarSnapshot = s
1788
+ renderStructure(s)
1789
+ }
1790
+
1791
+ // Painting the tree is split from fetching it because the search box repaints on
1792
+ // every keystroke, and the structure has not changed just because someone typed.
1793
+ function renderStructure(s) {
1794
  state.pageTitles = s.titles || {}
1795
  renderSlugLegend(s)
1796
  const container = document.getElementById('page-tree')
1797
  const known = new Set(Object.keys(s.titles || {}))
1798
  known.add(pageSlug) // visiting auto-creates, so the current page is never a ghost
 
1799
  const deletablePage = slug => slug !== 'home' && slug !== '_structure' && known.has(slug)
1800
  const pageDeleteBtn = slug => {
1801
  const title = s.titles?.[slug] || slug
 
1856
  for (const key of sidebarSelection) if (!everyKey.has(key)) sidebarSelection.delete(key)
1857
  if (sidebarSelectionAnchor && !everyKey.has(sidebarSelectionAnchor)) sidebarSelectionAnchor = null
1858
 
1859
+ // A page is kept if the query is in its title, its slug, or its text; a group
1860
+ // or a proposed page only has a label to go on. Non-matching ancestors remain
1861
+ // so the location of each result is legible, but unrelated descendants do not.
1862
  const filterNodes = (nodes, path = []) => {
1863
  if (!sidebarFilter) return nodes || []
1864
  const kept = []
 
1869
  : node.group != null
1870
  ? node.group
1871
  : s.titles?.[node.slug] || node.slug
1872
+ const self =
1873
+ node.proposal || node.group != null
1874
+ ? label.toLocaleLowerCase().includes(sidebarFilter)
1875
+ : pageMatches(node.slug, label, sidebarFilter)
1876
+ if (self || children.length) kept.push({ ...node, children })
1877
  }
1878
  return kept
1879
  }
 
2011
  const hasChildren = Boolean(node.children?.length)
2012
  const isCollapsed = !sidebarFilter && hasChildren && collapsed.has(key) && !forcedOpen.has(key)
2013
  const li = el('li', { class: group ? 'nav-cat' : '' })
2014
+ let hits = null
2015
  if (node.proposal) {
2016
  // not a line in the structure yaml yet, so it takes no drag attributes:
2017
  // there is nothing to move until it is accepted
 
2071
  const a = el(
2072
  'a',
2073
  {
2074
+ // searchHref, so opening a row in its own tab arrives with the
2075
+ // search that found it
2076
+ href: searchHref(node.slug),
2077
  class: (node.slug === pageSlug ? 'current' : '') + (known.has(node.slug) ? '' : ' ghost-page'),
2078
  title: known.has(node.slug) ? '' : `no page "${node.slug}" yet — create it or fix the structure yaml`,
2079
  },
 
2112
  row.appendChild(star)
2113
  }
2114
  if (deletablePage(node.slug)) row.appendChild(pageDeleteBtn(node.slug))
2115
+ hits = sidebarFilter ? pageHitList(node.slug) : null
2116
+ paintPageLabel(a, pageTitle, hits?.total || 0)
2117
  }
2118
  li.appendChild(row)
2119
+ // The hit list hangs off the <li>, not the row: the row is a flex line
2120
+ // with an absolutely-positioned trash can, and snippets belong under the
2121
+ // whole thing, above any child pages.
2122
+ if (hits?.list) li.appendChild(hits.list)
2123
  if (hasChildren && !isCollapsed) li.appendChild(renderNodes(node.children, refPath, key, depth + 1))
2124
  ul.appendChild(li)
2125
  })
 
2128
  container.innerHTML = ''
2129
  container.appendChild(renderNodes(visibleTree))
2130
  const visibleUnfiled = sidebarFilter
2131
+ ? (s.unfiled || []).filter(slug => pageMatches(slug, s.titles?.[slug] || slug, sidebarFilter))
2132
  : (s.unfiled || [])
2133
  if (visibleUnfiled.length) {
2134
  container.appendChild(el('div', { class: 'unfiled-label' }, 'unfiled'))
 
2142
  row.className = 'tree-row page-row unfiled-row'
2143
  row.appendChild(el('span', { class: 'tree-caret-spacer', 'aria-hidden': 'true' }))
2144
  const pageTitle = s.titles?.[slug] || slug
2145
+ const a = el('a', { href: searchHref(slug), class: slug === pageSlug ? 'current' : '' }, pageTitle)
2146
  let clickTimer = null
2147
  a.addEventListener('click', e => {
2148
  if (e.button !== 0) return
 
2164
  })
2165
  row.appendChild(a)
2166
  if (deletablePage(slug)) row.appendChild(pageDeleteBtn(slug))
2167
+ const hits = sidebarFilter ? pageHitList(slug) : null
2168
+ paintPageLabel(a, pageTitle, hits?.total || 0)
2169
  li.appendChild(row)
2170
+ if (hits?.list) li.appendChild(hits.list)
2171
  ul.appendChild(li)
2172
  }
2173
  if (ul.children.length) container.appendChild(ul)
2174
  }
2175
+ // Searching text can come up empty where filtering titles rarely did, and an
2176
+ // empty tree on its own reads as "the sidebar broke", not as "no matches".
2177
+ if (sidebarFilter && !container.querySelector('.tree-row')) {
2178
+ container.appendChild(el('div', { class: 'tree-empty' }, `Nothing matches “${sidebarFilter}”`))
2179
+ }
2180
  }
2181
 
2182
  // --- new-page proposal preview ------------------------------------------------
 
2480
  } catch {}
2481
  }
2482
 
2483
+ // Search hits, last so a mark inside commented or suggested text is the
2484
+ // innermost span and its fill is the one you see. A real <mark> rather than a
2485
+ // styled span: it is what the element is for, and it is what a screen reader
2486
+ // announces as marked text.
2487
+ if (sidebarFilter.length >= MIN_TEXT_QUERY) {
2488
+ for (const range of memoHitRanges(pmState.doc, sidebarFilter)) {
2489
+ decos.push(Decoration.inline(range.from, range.to, { nodeName: 'mark', class: 'search-hl' }))
2490
+ }
2491
+ }
2492
+
2493
  return DecorationSet.create(pmState.doc, decos)
2494
  }
2495
 
test/browser.js CHANGED
@@ -1299,6 +1299,139 @@ async function main() {
1299
  'returning home keeps the same project collaboration connection')
1300
  console.log('✓ page switches reuse one synced project document')
1301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1302
  // revision chain: ‹ 2/3 shows generation two inline
1303
  assert.ok(await page.$('.chain-nav'), 'chain nav present')
1304
  assert.equal(await page.textContent('.chain-pos'), '3/3', 'chain defaults to latest')
 
1299
  'returning home keeps the same project collaboration connection')
1300
  console.log('✓ page switches reuse one synced project document')
1301
 
1302
+ // --- search: page text, not just titles -------------------------------------
1303
+ // The sidebar box indexes every page of the project out of the one synced Yjs
1304
+ // document, so "suggestion" (which is in the prose, not in any title) has to
1305
+ // find home, list each occurrence with the heading it sits under, and mark
1306
+ // them in the open document.
1307
+ const playgroundHome = page.url()
1308
+ // hits under one page's row — by default the open one, whose marks we can also
1309
+ // count in the document; '*' for every page, since more than one of them
1310
+ // mentions "suggestion"
1311
+ const hitRows = (scope = 'li:has(> .page-row > a.current)') =>
1312
+ page.$$eval(`#page-tree ${scope} > .tree-hits .tree-hit:not(.more)`, rs => rs.map(r => ({
1313
+ heading: r.querySelector('.tree-hit-sec')?.textContent || '',
1314
+ snippet: r.querySelector('.tree-hit-text')?.textContent || '',
1315
+ href: r.getAttribute('href'),
1316
+ })))
1317
+ await page.fill('#sidebar-search', 'suggestion')
1318
+ await waitFor(async () => (await hitRows()).length > 0, 'typing text finds hits inside a page body')
1319
+ const suggestionHits = await hitRows()
1320
+ const marked = await page.$$eval('#editor mark.search-hl', ms => ms.map(m => m.textContent))
1321
+ assert.ok(marked.length >= 5, `every occurrence is marked in the document (${marked.length}): ` + JSON.stringify(marked))
1322
+ assert.ok(marked.every(t => t.toLowerCase() === 'suggestion'), 'marks cover the query and nothing else: ' + JSON.stringify(marked))
1323
+ // the pill is the page's TRUE count, which is more than the snippets listed
1324
+ const trueCount = Number(await page.$eval('#page-tree .page-row:has(a.current) .tree-hit-count', n => n.textContent))
1325
+ assert.strictEqual(trueCount, marked.length, `the count on the row is the real number of hits (${trueCount} vs ${marked.length} marks)`)
1326
+ assert.ok(suggestionHits.every(h => /suggestion/i.test(h.snippet)), 'each snippet quotes the match in context: ' + JSON.stringify(suggestionHits))
1327
+ assert.ok(suggestionHits.some(h => h.heading === 'Checklist') && suggestionHits.some(h => h.heading === 'Table'),
1328
+ 'each hit is tagged with the heading it sits under: ' + JSON.stringify(suggestionHits.map(h => h.heading)))
1329
+ // hits are links: /d/<id>/<page>?q=<term>&n=<hit>, shareable and openable in a tab
1330
+ assert.match(suggestionHits[0].href, /\?q=suggestion$/, 'the first hit links to the plain query: ' + suggestionHits[0].href)
1331
+ assert.match(suggestionHits[1].href, /\?q=suggestion&n=1$/, 'later hits carry their ordinal: ' + suggestionHits[1].href)
1332
+ // a common word overruns the per-page snippet list, and the ones it did not
1333
+ // print are still counted — the pill and the "+n more" row have to add up
1334
+ await page.fill('#sidebar-search', 'the')
1335
+ await waitFor(async () => !!(await page.$('#page-tree .tree-hit.more')), 'a common word overruns the snippet list')
1336
+ const capped = await page.evaluate(() => {
1337
+ const li = document.querySelector('#page-tree li:has(> .page-row > a.current)')
1338
+ return {
1339
+ count: Number(li.querySelector('.tree-hit-count').textContent),
1340
+ listed: li.querySelectorAll(':scope > .tree-hits .tree-hit:not(.more)').length,
1341
+ more: li.querySelector(':scope > .tree-hits .tree-hit.more').textContent,
1342
+ marks: document.querySelectorAll('#editor mark.search-hl').length,
1343
+ }
1344
+ })
1345
+ assert.ok(capped.listed < capped.count, `the snippet list is capped (${capped.listed}) where the count is not (${capped.count})`)
1346
+ assert.strictEqual(capped.count, capped.marks, 'the count still matches what is marked in the document: ' + JSON.stringify(capped))
1347
+ assert.strictEqual(capped.more, `+${capped.count - capped.listed} more matches`, 'the unlisted hits are accounted for: ' + JSON.stringify(capped))
1348
+ // Two occurrences in one sentence produce the same excerpt, and each must bold
1349
+ // its OWN one — re-finding the query inside the excerpt would bold the first
1350
+ // occurrence twice and send the second hit to the first hit's words.
1351
+ const bolded = await page.$$eval('#page-tree .tree-hit-text', ts => ts.map(t => ({
1352
+ text: t.textContent,
1353
+ at: t.firstChild.textContent.length, // the excerpt is [before, <b>, after]
1354
+ })))
1355
+ const twins = bolded.filter(a => bolded.some(b => b !== a && b.text === a.text))
1356
+ assert.ok(twins.length >= 2, 'the fixture has two hits sharing one excerpt: ' + JSON.stringify(bolded.map(b => b.text)))
1357
+ assert.ok(new Set(twins.map(t => t.at)).size >= 2, 'they bold their own occurrence, not the same one twice: ' + JSON.stringify(twins))
1358
+ console.log('✓ search: body hits with per-hit headings, true counts, in-document marks')
1359
+
1360
+ // a title match bolds the matched part of the title and stays quiet otherwise:
1361
+ // there is no body hit to list for it
1362
+ await page.fill('#sidebar-search', 'note')
1363
+ await waitFor(async () => !!(await page.$('#page-tree .page-row a b')), 'a title query bolds what it matched')
1364
+ const titleMatches = await page.$$eval('#page-tree .page-row a:has(b)', as => as.map(a => ({
1365
+ title: a.querySelector('.tree-title')?.textContent,
1366
+ bold: a.querySelector('b')?.textContent,
1367
+ })))
1368
+ assert.ok(titleMatches.some(m => m.title === 'Notes' && m.bold === 'Note'),
1369
+ 'the matched part of the title is bolded: ' + JSON.stringify(titleMatches))
1370
+
1371
+ // a query that only exists on another page pulls that page up, and clicking the
1372
+ // hit navigates there and scrolls to it
1373
+ await page.fill('#sidebar-search', 'defines the tree')
1374
+ await waitFor(async () => (await hitRows('*')).length === 1, 'a phrase only on the Notes page finds only that page')
1375
+ assert.deepStrictEqual(
1376
+ await page.$$eval('#page-tree .page-row a .tree-title', ts => ts.map(t => t.textContent.trim())),
1377
+ ['Notes'],
1378
+ 'the tree is filtered down to the page that contains the phrase'
1379
+ )
1380
+ await page.click('#page-tree .tree-hit')
1381
+ await page.waitForURL(u => /\/notes\?q=/.test(u.toString()), { timeout: 15000 })
1382
+ await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'the hit is marked on the page it navigated to')
1383
+ const onScreen = await page.evaluate(() => {
1384
+ const m = document.querySelector('#editor mark.search-hl')
1385
+ const r = m.getBoundingClientRect()
1386
+ return r.top > 0 && r.bottom < window.innerHeight
1387
+ })
1388
+ assert.ok(onScreen, 'the clicked hit is scrolled into view')
1389
+ console.log('✓ search: clicking a hit on another page navigates and lands on it')
1390
+
1391
+ // ?q= is a deep link: it starts the search, marks the document and jumps to the
1392
+ // named hit — this is what the hrefs above resolve to for someone you send one to
1393
+ await page.goto(`${playgroundHome}?q=greet`, { waitUntil: 'domcontentloaded' })
1394
+ await page.waitForSelector('.tiptap')
1395
+ await waitFor(async () => (await page.inputValue('#sidebar-search')) === 'greet', 'a ?q= link arrives with the search box filled in')
1396
+ await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a ?q= link marks the document once it has synced')
1397
+ // the hit list can only fill in once the socket has synced the text, which on a
1398
+ // cold load lands after the shell has already painted the tree
1399
+ await waitFor(async () => JSON.stringify((await hitRows()).map(h => h.heading)) === '["Code"]',
1400
+ 'the deep-linked hit is listed under its heading')
1401
+ await waitFor(
1402
+ async () => page.evaluate(() => {
1403
+ const r = document.querySelector('#editor mark.search-hl').getBoundingClientRect()
1404
+ return r.top > 0 && r.bottom < window.innerHeight
1405
+ }),
1406
+ 'a ?q= link scrolls its first hit into view'
1407
+ )
1408
+
1409
+ // The index is the live CRDT, not a snapshot the server took: text typed now is
1410
+ // searchable now. Done on Notes, which carries none of home's seeded
1411
+ // suggestions — typing into a block one of those is anchored to is a different
1412
+ // test, and not this one's business.
1413
+ await page.goto(`${playgroundHome}/notes`, { waitUntil: 'domcontentloaded' })
1414
+ await page.waitForSelector('.tiptap')
1415
+ await page.fill('#sidebar-search', 'quokka')
1416
+ await waitFor(async () => !!(await page.$('#page-tree .tree-empty')), 'a query that matches nothing says so instead of emptying the sidebar')
1417
+ await page.evaluate(() => window.__editor.chain().focus('end').insertContent(' quokka ').run())
1418
+ await waitFor(async () => (await hitRows()).length === 1, 'text typed while the search is running becomes a hit')
1419
+ await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'and is marked as it is typed')
1420
+ await page.evaluate(() => window.__editor.commands.undo())
1421
+ await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'undoing the text takes its hit away again')
1422
+
1423
+ // clearing puts everything back, including drag-and-drop
1424
+ await page.click('#sidebar-search-key')
1425
+ await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'clearing the box drops the marks')
1426
+ assert.strictEqual(await page.inputValue('#sidebar-search'), '', 'the ✕ badge empties the box')
1427
+ assert.ok(!/\?q=/.test(page.url()), 'and takes ?q= out of the URL: ' + page.url())
1428
+ assert.ok(await page.evaluate(() => document.querySelectorAll('#page-tree [data-td-row]').length > 0), 'rows are draggable again')
1429
+ console.log('✓ search: ?q= deep links, live index, empty state, clear restores the tree')
1430
+
1431
+ // back where the rest of the playground sub-tests expect to be
1432
+ await page.goto(playgroundHome, { waitUntil: 'domcontentloaded' })
1433
+ await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on the playground home page')
1434
+
1435
  // revision chain: ‹ 2/3 shows generation two inline
1436
  assert.ok(await page.$('.chain-nav'), 'chain nav present')
1437
  assert.equal(await page.textContent('.chain-pos'), '3/3', 'chain defaults to latest')