Spaces:
Sleeping
The sidebar box searches page text, not just page titles
Browse filesFiltering 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>
- client/app.css +54 -0
- client/doc.html +2 -2
- client/src/doc.js +418 -16
- test/browser.js +133 -0
|
@@ -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;
|
|
@@ -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="
|
| 127 |
-
<button id="sidebar-search-key" type="button" aria-label="Focus
|
| 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>
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 1362 |
-
|
| 1363 |
}
|
| 1364 |
const clearFilter = () => {
|
| 1365 |
search.value = ''
|
| 1366 |
-
sidebarFilter = ''
|
| 1367 |
search.blur()
|
| 1368 |
-
|
|
|
|
| 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 |
-
//
|
| 1489 |
-
//
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 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 |
|
|
@@ -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')
|