oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev ef4461c02373e55288057d54ce92c616ea303faa parent 4d3420e
12 files changed, +952 −17
  • .amadan/ci.d/10-gate +3 −0
  • fixtures/editor-entry.js +43 −0
  • fixtures/find.mjs +429 −0
  • fixtures/find.test.mjs +232 −0
  • internal/docs/handlers.go +28 −0
  • internal/docs/menu.go +6 −2
  • internal/docs/render.go +24 −0
  • internal/docs/static/editor.5dc0309cb2a5ef6b.js +0 −14
  • internal/docs/static/editor.d2f04e050b943ff1.js +15 −0
  • internal/docs/static/jumpto.a29c74fa2544d5f5.js +1 −0
  • internal/docs/static/jumpto.a37448e7fcd21855.js +0 −1
  • internal/docs/views/show.html +171 −0
.amadan/ci.d/10-gate

Loading diff…

fixtures/editor-entry.js

Loading diff…

fixtures/find.mjs

Loading diff…

fixtures/find.test.mjs

Loading diff…

internal/docs/handlers.go

Loading diff…

internal/docs/menu.go

Loading diff…

internal/docs/render.go

Loading diff…

internal/docs/static/editor.5dc0309cb2a5ef6b.js

Loading diff…

internal/docs/static/editor.d2f04e050b943ff1.js

Loading diff…

internal/docs/static/jumpto.a29c74fa2544d5f5.js

Loading diff…

internal/docs/static/jumpto.a37448e7fcd21855.js

Loading diff…

internal/docs/views/show.html

Loading diff…