// fixtures/wordcount.mjs — words, characters, paragraphs. // // Counted over ProseMirror's document rather than over the rendered // text, so what is counted is what is IN the document: a page gap is a // widget decoration and contributes nothing, and neither does anything // else this file does not walk. // // WORD SEGMENTATION IS Intl.Segmenter WHERE THERE IS ONE, and splitting // on whitespace only as a fallback. Whitespace is wrong for most of the // twelve locales this app declares — it does not find a word boundary // in Japanese or Chinese at all, and it miscounts languages that use // no space before punctuation. A word count is exactly the kind of // number people quote to each other, so being quietly wrong in eight // locales is worse than being a little slower in all twelve. // // The dialog is a native : the browser gives modality, Escape, // focus trapping and the backdrop, all of which are easy to get wrong // by hand and none of which is this feature's interesting part. Its // markup and every string are server-rendered (views/show.html, from // the catalog); this module only fills in numbers. // segmenter is built once and only if the platform has one. Building it // per call is measurably slow on a long document, and Intl.Segmenter's // constructor is the expensive half. const segmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'word' }) : null // countWords is the one piece of real logic here, and it is exported so // wordcount.test.mjs can check it without a DOM. export function countWords(text) { if (!text) return 0 if (segmenter) { let n = 0 for (const s of segmenter.segment(text)) if (s.isWordLike) n++ return n } // The fallback. Deliberately simple: anything cleverer would be a // second, worse segmenter, and this path only runs where the platform // has none. const parts = text.trim().split(/\s+/) return parts.length === 1 && parts[0] === '' ? 0 : parts.length } // textOf walks a slice of the document and joins its text, putting a // space between blocks so the last word of one paragraph and the first // of the next are not counted as one. function textOf(node) { const parts = [] node.descendants((child) => { if (child.isText) parts.push(child.text) else if (child.isBlock && parts.length) parts.push(' ') }) return parts.join('') } // counts reports on the whole document, and on the selection when there // is one — the distinction people actually want, since the reason to // open this is usually "how long is this section". export function counts(state) { const whole = textOf(state.doc) const { from, to, empty } = state.selection let paragraphs = 0 state.doc.forEach((node) => { // Top-level blocks that hold text. A list counts as one block // rather than one per item, which is what "paragraphs" means to a // person looking at the page. if (node.isTextblock || node.type.name.endsWith('_list') || node.type.name === 'blockquote') { paragraphs++ } }) const selected = empty ? null : textOf(state.doc.cut(from, to)) return { words: countWords(whole), characters: whole.length, charactersNoSpaces: whole.replace(/\s/g, '').length, paragraphs, selection: selected === null ? null : { words: countWords(selected), characters: selected.length }, } } // mountWordCount wires the dialog. Returns the command the menu runs, // or null when the template does not render one. export function mountWordCount(dialog, { getView }) { if (!dialog || typeof dialog.showModal !== 'function') return null const slots = { words: dialog.querySelector('[docs-count-words]'), characters: dialog.querySelector('[docs-count-characters]'), charactersNoSpaces: dialog.querySelector('[docs-count-characters-nospaces]'), paragraphs: dialog.querySelector('[docs-count-paragraphs]'), selection: dialog.querySelector('[docs-count-selection]'), selectionWords: dialog.querySelector('[docs-count-selection-words]'), } return () => { const view = getView() if (!view) return false const c = counts(view.state) if (slots.words) slots.words.textContent = String(c.words) if (slots.characters) slots.characters.textContent = String(c.characters) if (slots.charactersNoSpaces) slots.charactersNoSpaces.textContent = String(c.charactersNoSpaces) if (slots.paragraphs) slots.paragraphs.textContent = String(c.paragraphs) // The selection row is hidden rather than showing zeros when // nothing is selected: a row reading "0 words selected" invites the // reader to work out whether that is a count or an absence. if (slots.selection) slots.selection.hidden = c.selection === null if (slots.selectionWords && c.selection) slots.selectionWords.textContent = String(c.selection.words) dialog.showModal() return true } }