Edit: find and replace
A panel, not a modal, and that is the first decision. The two Tools dialogs use showModal() because they are things you read and dismiss. This is a thing you work with — you type in it while watching the document change underneath, and you press Replace while a match is selected — and showModal() makes everything outside inert, taking the document away at the moment it matters. So it is a <dialog> opened with show(): still the top layer, so no z-index argument with the sticky chrome, but the document stays live. show() does not give Escape, which is modal-only, so find.mjs binds it. The hard part is neither the UI nor the search. It is that a match has to become a SELECTION, so every match needs document positions rather than string offsets, and the two do not correspond: a textblock's positions include its own open token, marks split one visible run into several text nodes, and an inline atom occupies a position while contributing no character. buildIndex therefore produces a string and a position for every character in it, per textblock, and matches map back through that array. Per textblock is also what stops a query matching across a paragraph boundary. "end start" is not in a document that says "…the end" and then "start of…", by any reading a person would accept. Case folding is per character rather than a toLowerCase over the whole haystack, because some characters change LENGTH when lowercased — 'İ' is the usual example — and a length change silently shifts every position after it. A character whose lowercase form is not the same length is left alone, so those few match case-sensitively. That is a small stated inaccuracy instead of positions that point at the wrong words. Replace all rewrites every match in one transaction applied from the end of the document backwards: backwards because each replacement shifts every position after it, one transaction because it is one action and a person who presses undo expects one undo. Replace re-derives the match from plugin state rather than trusting the selection, because you can click into the document between pressing Next and pressing Replace — and replacing whatever happens to be selected then would delete a paragraph somebody had merely highlighted. Control-F takes the key from the browser's own find bar. Deliberately: the browser's find searches the rendered page, so in paged view it cannot see past what is laid out, it finds text in the menu bar as readily as in the document, and it has no idea what a replacement is. The binding is shown on the Edit menu item and in the shortcuts sheet rather than being folklore, the browser's find is still in its own menu, and it is a modified key so WCAG 2.1.4 does not apply. Twenty tests against a real ProseMirror document rather than a fake DOM, because a fake would only restate my own arithmetic. Changing one position in buildIndex — blockPos + offset instead of blockPos + 1 + offset — turns nine of them red. One behaviour was wrong on the first run and the test found it: with the caret INSIDE a match, "first match at or after the caret" skips that match and wraps to the top. It is now "first match that ends after the caret", because the word under the cursor is the one a person means. The function changed, not the expectation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DoVTH3jZqj9svgt2UD9t1p
12 files changed,
+952
−17
.amadan/ci.d/10-gate+3 −0fixtures/editor-entry.js+43 −0fixtures/find.mjs+429 −0fixtures/find.test.mjs+232 −0internal/docs/handlers.go+28 −0internal/docs/menu.go+6 −2internal/docs/render.go+24 −0internal/docs/static/editor.5dc0309cb2a5ef6b.js+0 −14internal/docs/static/editor.d2f04e050b943ff1.js+15 −0internal/docs/static/jumpto.a29c74fa2544d5f5.js+1 −0internal/docs/static/jumpto.a37448e7fcd21855.js+0 −1internal/docs/views/show.html+171 −0
Intent
.amadan/ci.d/10-gateLoading diff…
fixtures/editor-entry.jsLoading diff…
fixtures/find.mjsLoading diff…
fixtures/find.test.mjsLoading diff…
internal/docs/handlers.goLoading diff…
internal/docs/menu.goLoading diff…
internal/docs/render.goLoading diff…
internal/docs/static/editor.5dc0309cb2a5ef6b.jsLoading diff…
internal/docs/static/editor.d2f04e050b943ff1.jsLoading diff…
internal/docs/static/jumpto.a29c74fa2544d5f5.jsLoading diff…
internal/docs/static/jumpto.a37448e7fcd21855.jsLoading diff…
internal/docs/views/show.htmlLoading diff…