Skip to content

Count only content lines and show the selection word count (#199) - #204

Merged
Renakoni merged 3 commits into
mainfrom
feat/toolbar-stats-199
Aug 8, 2026
Merged

Count only content lines and show the selection word count (#199)#204
Renakoni merged 3 commits into
mainfrom
feat/toolbar-stats-199

Conversation

@Renakoni

@Renakoni Renakoni commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #199 — both halves: the line-count bug and the selection word count port, surfaced on the toolbar stats line per the owner's decision (not the ⋮ menu).

Bug: line count

getDocumentStats counted every newline-delimited segment of the serialized markdown — Muya's blank separator lines between blocks and the trailing-newline phantom segment included — which is how a handful of visible lines reported as "10 lines" on device. It now counts non-blank lines only: # Title\n\npara one\n\npara two\n is 3 lines, a single-line document is 1 (previously 2). The same stats feed the draft-log diagnostics, which become meaningful too.

Port: selection word count (upstream marktext#4457, reshaped)

While a selection lives inside the editor host, the expanded-toolbar stats line appends its word count — 4 words · 23 chars · 1 lines · 2 selected — and drops it when the selection collapses.

The port deliberately reshapes the upstream implementation rather than copying it:

  • One update source instead of two. Upstream feeds a store from both Muya's selection-change event and a document selectionchange listener, reconciled through a subtle lastSelectedText/store-consistency dedupe. Here a single document-level selectionchange listener feeds component-local state in MobileEditorToolbar — the component already owns such a listener for command restore-ranges, and the count never leaves the component that displays it. No store, no dedupe, no race.
  • rAF-coalesced so dragging a selection handle pays at most one count per frame (upstream parity).
  • Math/ruby preview exclusion kept (upstream parity): rendered previews duplicate their source text in the DOM, and a selection spanning one would count the formula twice. Only ranges that actually contain a preview pay for fragment cloning; plain selections keep the toString() fast path. The vendored engine uses the same mu-math-render/mu-ruby-render class names, so the selector ports verbatim.
  • Containment guard: anchor and focus must both sit inside the editor host, so selections elsewhere in the app never count.
  • Cleared on collapse, host swap, and editor teardown (the host watcher re-derives; a null host yields null).
  • Not ported, deliberately: the table rectangular-selection counting (mouse-drag cell selection has no mobile counterpart; native selections across table cells still count through toString()), and the source-mode leg — the toolbar, the stats surface the owner chose, does not render in source mode. If a source-mode surface is wanted later, that is a new issue.

New locale key toolbar.statsSelection across all ten languages, phrased to each locale's existing counter style (选中 {words} 字, {words} selected, …).

Codex review round 1 (at d5d7db9) — both P2s addressed in 6683395

  • Fence-aware line counting. The blanket blank-line filter mis-counted fenced code: interior blank lines are real, visible content, and the fence markers are syntax. countContentLines now tracks fence state — every interior line counts (blanks included), fences don't, and closing requires the same marker at least as long as the opener (tildes stay content inside a backtick fence, a longer run of the same marker closes, per CommonMark). Codex's example (```js / alpha / blank / beta / ```) now counts 3. Regression cases cover interior blanks, lone-blank interiors, marker mixing, longer closers, and composition with the separator filtering.
  • Genuinely allocation-free fast path. The previous code cloned every range before deciding whether previews were present — proportional copying and GC on every frame of a handle drag. The read moved to selectionStats.ts: an ancestor closest() plus scoped intersectsNode probe decides without cloning, so plain selections stay on toString(). A selection entirely inside a rendered preview now counts as nothing (render output is not document text — and its clone would lack the wrapper the strip keys on, a gap the upstream implementation shares). Contract tests pin the cost model: the fake range throws on cloneContents, so the fast-path tests passing is the proof no clone happens; strip and containment behavior covered alongside.

Codex review round 2 (at 6683395) — addressed in f2c5218

  • Indentation rule (real gap, fixed): fence delimiters now allow at most three columns of indentation — a leading tab already disqualifies — so four-space-indented backticks are indented-code content (codex's example counts 3), and an over-indented marker run cannot close a real fence.
  • Delimiter syntax checks (the close-side example was already handled; the open side had the real gap): the shipped closing check required a pure marker run, so ```not-a-closing-fence already stayed inside the block — a regression case now pins it. The genuine hole was on the open side: a backtick fence's info string may not contain backticks, so a paragraph starting with inline code (```foo``` bar) no longer opens a phantom fence; tilde info strings stay unrestricted. Both delimiters now have explicit, separate syntax checks as suggested.
  • Honest probe cost (wording corrected + probe improved): the claim is now clone-free, not allocation-free. The probe runs a querySelector existence test first — engine early-exit, no NodeList — so the common no-preview path pays exactly one native scan of the range-ancestor subtree; querySelectorAll only materializes when a preview actually exists there. A new contract test pins that querySelectorAll is never called on the no-preview path (alongside the existing throws-on-clone proof). The deeper options codex sketched (preview index via MutationObserver) trade ongoing mutation-tracking cost for probe cost; at realistic preview counts the existence test is the better bargain, and the comment now documents the model honestly.

Verification

  • Web: 693 tests green — unit tests pin the line semantics (empty / single line / Muya-style serialization / CRLF / blank-only / five fenced-code cases / seven CommonMark delimiter-rule cases) and the selection-read contract (fast path never clones, never materializes a NodeList without previews, strip, inside-preview, containment, collapsed); vue-tsc -b + Vite build green, focused lint clean, locale contract satisfied.
  • e2e mobile-editor-toolbar 37/37 — new test types a heading + paragraph, asserts 5 words and 2 lines (the old code would say 4), selects two words via a real DOM range and asserts 2 selected, then collapses and asserts the suffix is gone.
  • Emulator (API 35, debug build): typed alpha beta gamma delta, expanded the toolbar → 4 words · 23 chars · 1 lines (single content line, previously 2); drove a beta gamma selection over CDP → 4 words · 23 chars · 1 lines · 2 selected; cleared the selection → suffix dropped. Long-press caret placement (no selection) correctly shows no suffix.

- getDocumentStats now counts non-blank lines: Muya serializes blank
  separator lines between blocks and a trailing newline adds a phantom
  segment, so short documents reported inflated line counts ("10 lines"
  for a handful of visible ones).
- The expanded-toolbar stats line appends the selected-text word count
  while a selection lives inside the editor host — a port of the
  maintainer''s unmerged upstream marktext#4457, reshaped for this app:
  one document-level selectionchange source feeding component-local
  state (no store), rAF-coalesced so handle drags pay at most one count
  per frame, with the upstream math/ruby preview exclusion kept (same
  mu- class names in the vendored engine). Cleared on collapse, host
  swap, and editor teardown. The source-mode leg is deliberately out:
  the toolbar — the stats surface the owner chose — does not render in
  source mode.
- New locale key toolbar.statsSelection across all ten languages; unit
  tests for the line semantics and an e2e that asserts content-line
  counting, the appended selection count, and its clearing.
- countContentLines understands fenced code: every interior line is
  visible content, blank ones included, while the fence markers are
  syntax and do not count. Closing requires the same marker at least as
  long as the opener, so tildes stay content inside a backtick fence.
  Regression cases cover interior blanks, lone-blank interiors, marker
  mixing, longer closers, and composition with separator filtering.
- The selection text read moves to selectionStats.ts with a genuinely
  allocation-free probe: ancestor closest() plus scoped intersectsNode
  decide before anything is cloned, so plain selections - every frame of
  a handle drag - stay on the toString() fast path. A selection entirely
  inside a rendered preview now counts as nothing (render output is not
  document text; its clone would lack the wrapper the strip keys on).
  Contract tests prove the fast path never clones (the fake range throws
  on cloneContents) alongside the strip and containment behavior.
- Fence detection gains explicit per-delimiter syntax checks: delimiters
  allow at most three columns of indentation (a leading tab already
  disqualifies), so four-space-indented backticks are indented-code
  content; a backtick fence info string may not contain backticks, so a
  paragraph opening with inline code no longer opens a phantom fence;
  tilde info strings stay unrestricted. The closing check (a pure marker
  run, at least opener length) already rejected trailing text - it now
  also refuses over-indented closers. Seven new delimiter-rule cases.
- The preview probe now runs a querySelector existence test first: it
  early-exits on a match and materializes no NodeList, so the common
  no-preview path pays one native subtree scan and nothing else; the
  full querySelectorAll walk only happens when a preview actually exists
  under the range ancestor. Comments and the PR text now state the
  honest cost model - clone-free, not allocation-free - and a contract
  test pins that querySelectorAll is never called on the no-preview path.
@Renakoni
Renakoni merged commit 206c64a into main Aug 8, 2026
6 checks passed
@Renakoni
Renakoni deleted the feat/toolbar-stats-199 branch August 8, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Toolbar statistics: line count overstates lines; port selection word count (upstream marktext#4457)

1 participant