oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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.
25const 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.
32export 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.
49function 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".
61export 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.
88export 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