| 1 | // fixtures/wordcount.mjs — words, characters, paragraphs. |
| 2 | // |
| 3 | // Counted over ProseMirror's document rather than over the rendered |
| 4 | // text, so what is counted is what is IN the document: a page gap is a |
| 5 | // widget decoration and contributes nothing, and neither does anything |
| 6 | // else this file does not walk. |
| 7 | // |
| 8 | // WORD SEGMENTATION IS Intl.Segmenter WHERE THERE IS ONE, and splitting |
| 9 | // on whitespace only as a fallback. Whitespace is wrong for most of the |
| 10 | // twelve locales this app declares — it does not find a word boundary |
| 11 | // in Japanese or Chinese at all, and it miscounts languages that use |
| 12 | // no space before punctuation. A word count is exactly the kind of |
| 13 | // number people quote to each other, so being quietly wrong in eight |
| 14 | // locales is worse than being a little slower in all twelve. |
| 15 | // |
| 16 | // The dialog is a native <dialog>: the browser gives modality, Escape, |
| 17 | // focus trapping and the backdrop, all of which are easy to get wrong |
| 18 | // by hand and none of which is this feature's interesting part. Its |
| 19 | // markup and every string are server-rendered (views/show.html, from |
| 20 | // the catalog); this module only fills in numbers. |
| 21 | |
| 22 | // segmenter is built once and only if the platform has one. Building it |
| 23 | // per call is measurably slow on a long document, and Intl.Segmenter's |
| 24 | // constructor is the expensive half. |
| 25 | const segmenter = |
| 26 | typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' |
| 27 | ? new Intl.Segmenter(undefined, { granularity: 'word' }) |
| 28 | : null |
| 29 | |
| 30 | // countWords is the one piece of real logic here, and it is exported so |
| 31 | // wordcount.test.mjs can check it without a DOM. |
| 32 | export function countWords(text) { |
| 33 | if (!text) return 0 |
| 34 | if (segmenter) { |
| 35 | let n = 0 |
| 36 | for (const s of segmenter.segment(text)) if (s.isWordLike) n++ |
| 37 | return n |
| 38 | } |
| 39 | // The fallback. Deliberately simple: anything cleverer would be a |
| 40 | // second, worse segmenter, and this path only runs where the platform |
| 41 | // has none. |
| 42 | const parts = text.trim().split(/\s+/) |
| 43 | return parts.length === 1 && parts[0] === '' ? 0 : parts.length |
| 44 | } |
| 45 | |
| 46 | // textOf walks a slice of the document and joins its text, putting a |
| 47 | // space between blocks so the last word of one paragraph and the first |
| 48 | // of the next are not counted as one. |
| 49 | function textOf(node) { |
| 50 | const parts = [] |
| 51 | node.descendants((child) => { |
| 52 | if (child.isText) parts.push(child.text) |
| 53 | else if (child.isBlock && parts.length) parts.push(' ') |
| 54 | }) |
| 55 | return parts.join('') |
| 56 | } |
| 57 | |
| 58 | // counts reports on the whole document, and on the selection when there |
| 59 | // is one — the distinction people actually want, since the reason to |
| 60 | // open this is usually "how long is this section". |
| 61 | export function counts(state) { |
| 62 | const whole = textOf(state.doc) |
| 63 | const { from, to, empty } = state.selection |
| 64 | |
| 65 | let paragraphs = 0 |
| 66 | state.doc.forEach((node) => { |
| 67 | // Top-level blocks that hold text. A list counts as one block |
| 68 | // rather than one per item, which is what "paragraphs" means to a |
| 69 | // person looking at the page. |
| 70 | if (node.isTextblock || node.type.name.endsWith('_list') || node.type.name === 'blockquote') { |
| 71 | paragraphs++ |
| 72 | } |
| 73 | }) |
| 74 | |
| 75 | const selected = empty ? null : textOf(state.doc.cut(from, to)) |
| 76 | |
| 77 | return { |
| 78 | words: countWords(whole), |
| 79 | characters: whole.length, |
| 80 | charactersNoSpaces: whole.replace(/\s/g, '').length, |
| 81 | paragraphs, |
| 82 | selection: selected === null ? null : { words: countWords(selected), characters: selected.length }, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // mountWordCount wires the dialog. Returns the command the menu runs, |
| 87 | // or null when the template does not render one. |
| 88 | export function mountWordCount(dialog, { getView }) { |
| 89 | if (!dialog || typeof dialog.showModal !== 'function') return null |
| 90 | |
| 91 | const slots = { |
| 92 | words: dialog.querySelector('[docs-count-words]'), |
| 93 | characters: dialog.querySelector('[docs-count-characters]'), |
| 94 | charactersNoSpaces: dialog.querySelector('[docs-count-characters-nospaces]'), |
| 95 | paragraphs: dialog.querySelector('[docs-count-paragraphs]'), |
| 96 | selection: dialog.querySelector('[docs-count-selection]'), |
| 97 | selectionWords: dialog.querySelector('[docs-count-selection-words]'), |
| 98 | } |
| 99 | |
| 100 | return () => { |
| 101 | const view = getView() |
| 102 | if (!view) return false |
| 103 | const c = counts(view.state) |
| 104 | |
| 105 | if (slots.words) slots.words.textContent = String(c.words) |
| 106 | if (slots.characters) slots.characters.textContent = String(c.characters) |
| 107 | if (slots.charactersNoSpaces) slots.charactersNoSpaces.textContent = String(c.charactersNoSpaces) |
| 108 | if (slots.paragraphs) slots.paragraphs.textContent = String(c.paragraphs) |
| 109 | // The selection row is hidden rather than showing zeros when |
| 110 | // nothing is selected: a row reading "0 words selected" invites the |
| 111 | // reader to work out whether that is a count or an absence. |
| 112 | if (slots.selection) slots.selection.hidden = c.selection === null |
| 113 | if (slots.selectionWords && c.selection) slots.selectionWords.textContent = String(c.selection.words) |
| 114 | |
| 115 | dialog.showModal() |
| 116 | return true |
| 117 | } |
| 118 | } |
| 119 | |