Feat: add word and sentence counts for selected text

This commit is contained in:
hnd0hng
2026-09-27 18:11:27 +02:00
committed by yu-i-i
parent 79b145a852
commit f0ac2f4051
13 changed files with 1739 additions and 1 deletions
+4
View File
@@ -1057,6 +1057,10 @@ module.exports = {
__dirname,
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx'
),
Path.resolve(
__dirname,
'../modules/selected-word-count/frontend/components/selected-word-count-controller.tsx'
),
],
mainEditorLayoutPanels: [],
pythonRunner: [],
@@ -1974,6 +1974,7 @@
"select_tax_id_type": "",
"select_user": "",
"select_user_role": "",
"selected_text_word_count": "",
"selection_deleted": "",
"send": "",
"send_confirmation_code": "",
@@ -1982,6 +1983,7 @@
"send_request": "",
"sending": "",
"sentence_completion": "",
"sentences": "",
"server_error": "",
"server_pro_license_entitlement_line_1": "",
"server_pro_license_entitlement_line_2": "",
@@ -68,7 +68,17 @@ export const ToolbarMenuBar = () => {
type: 'command',
label: t('word_count_lower'),
disabled: !wordCountEnabled,
handler: () => {
handler: ({ location }) => {
if (location === 'menu-bar') {
const event = new Event('word-count:open-requested', {
cancelable: true,
})
if (!window.dispatchEvent(event)) {
return
}
}
setShowWordCountModal(true)
},
id: 'word_count',
+2
View File
@@ -1501,11 +1501,13 @@
"select_users": "Benutzer auswählen",
"selected": "Ausgewählt",
"selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern",
"selected_text_word_count": "Wortanzahl im ausgewählten Text",
"send": "Absenden",
"send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden",
"send_confirmation_code": "Bestätigungscode senden",
"send_test_email": "Test-Mail senden",
"sending": "Wird gesendet",
"sentences": "Sätze",
"server_error": "Serverfehler",
"services": "Services",
"session_created_at": "Session erzeugt um",
+2
View File
@@ -2570,6 +2570,7 @@
"selected": "Selected",
"selected_by_overleaf_staff": "Selected by Overleaf staff",
"selected_plural": "selected",
"selected_text_word_count": "Selected text word count",
"selection_deleted": "Selection deleted",
"send": "Send",
"send_notification_emails_to_users": "Send notification emails to the current and the new owners",
@@ -2581,6 +2582,7 @@
"sending": "Sending",
"sent": "Sent",
"sentence_completion": "Sentence completion",
"sentences": "Sentences",
"server_error": "Server Error",
"server_pro_license_entitlement_line_1": "<0>__appName__</0> Server Pro license",
"server_pro_license_entitlement_line_2": "You currently have <0>__count__ active users</0>. If you need to increase your license entitlement, please <1>contact Overleaf</1>.",
+2
View File
@@ -655,7 +655,9 @@
"select_projects": "Выбрать проекты",
"select_user": "Выбрать пользователя",
"select_users": "Выбрать пользователей",
"selected_text_word_count": "Количество слов в выбранном тексте",
"send_notification_emails_to_users": "Отправить уведовление текущему и новому владельцам",
"sentences": "Предложения",
"server_error": "Ошибка сервера",
"services": "Сервисы",
"session_created_at": "Сессия создана",
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Text } from '@codemirror/state'
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import { createSegmenters } from '@/features/word-count-modal/utils/segmenters'
import { debugConsole } from '@/utils/debugging'
import { WORD_COUNT_OPEN_REQUESTED_EVENT } from '../selected-word-count-events'
import {
countWordsInSelection,
type SelectedWordCountResult,
} from '../utils/count-words-in-selection'
import { createSentenceSegmenter } from '../utils/sentence-segmenter'
import SelectedWordCountModal from './selected-word-count-modal'
type SelectedWordCountRequest = {
doc: Text
from: number
to: number
}
export default function SelectedWordCountController() {
const { spellCheckLanguage } = useProjectSettingsContext()
const { view } = useEditorViewContext()
const { showVisual } = useEditorPropertiesContext()
const { openEntity, selectedEntityCount } = useFileTreeOpenContext()
const { view: layoutView } = useLayoutContext()
const [open, setOpen] = useState(false)
const [request, setRequest] = useState<SelectedWordCountRequest | null>(null)
const [data, setData] = useState<SelectedWordCountResult | null>(null)
const [error, setError] = useState(false)
const segmenters = useMemo(() => {
return createSegmenters(spellCheckLanguage?.replace(/_/, '-'))
}, [spellCheckLanguage])
const sentenceSegmenter = useMemo(() => {
return createSentenceSegmenter(spellCheckLanguage?.replace(/_/, '-'))
}, [spellCheckLanguage])
const onClose = useCallback(() => {
setOpen(false)
setRequest(null)
}, [])
useEffect(() => {
const handleOpenRequested = (event: Event) => {
if (
!view ||
showVisual ||
layoutView !== 'editor' ||
selectedEntityCount !== 1 ||
openEntity?.type !== 'doc'
) {
return
}
const { doc, selection } = view.state
const { from, to } = selection.main
if (from >= to) {
return
}
event.preventDefault()
setRequest({ doc, from, to })
setData(null)
setError(false)
setOpen(true)
}
window.addEventListener(
WORD_COUNT_OPEN_REQUESTED_EVENT,
handleOpenRequested
)
return () => {
window.removeEventListener(
WORD_COUNT_OPEN_REQUESTED_EVENT,
handleOpenRequested
)
}
}, [layoutView, openEntity, selectedEntityCount, showVisual, view])
useEffect(() => {
if (!open || !request) {
return
}
try {
const nextData = countWordsInSelection(
request.doc.toString(),
{ from: request.from, to: request.to },
segmenters,
sentenceSegmenter
)
setData(nextData)
} catch (error) {
debugConsole.error(error)
setError(true)
}
}, [open, request, segmenters, sentenceSegmenter])
return (
<SelectedWordCountModal
show={open}
onClose={onClose}
data={data}
error={error}
/>
)
}
@@ -0,0 +1,106 @@
import { useTranslation } from 'react-i18next'
import { Col, Container, Row } from 'react-bootstrap'
import { WordCountError } from '@/features/word-count-modal/components/word-count-error'
import LoadingSpinner from '@/shared/components/loading-spinner'
import OLButton from '@/shared/components/ol/ol-button'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import type { SelectedWordCountResult } from '../utils/count-words-in-selection'
const numberFormat = new Intl.NumberFormat()
type SelectedWordCountModalProps = {
show: boolean
onClose: () => void
data: SelectedWordCountResult | null
error: boolean
}
export default function SelectedWordCountModal({
show,
onClose,
data,
error,
}: SelectedWordCountModalProps) {
const { t } = useTranslation()
const rows = data
? [
{
key: 'total',
label: t('total_words'),
value: data.totalWords,
testId: 'selected-word-count-total',
},
{
key: 'sentences',
label: t('sentences'),
value: data.sentences,
testId: 'selected-word-count-sentences',
},
{
key: 'headers',
label: t('headers'),
value: data.headers,
testId: 'selected-word-count-headers',
},
{
key: 'math-inline',
label: t('math_inline'),
value: data.mathInline,
testId: 'selected-word-count-math-inline',
},
{
key: 'math-display',
label: t('math_display'),
value: data.mathDisplay,
testId: 'selected-word-count-math-display',
},
]
: []
return (
<OLModal
animation
show={show}
onHide={onClose}
initialFocus={false}
id="selected-word-count-modal"
data-testid="selected-word-count-modal"
>
<OLModalHeader>
<OLModalTitle>{t('selected_text_word_count')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{data === null && !error && <LoadingSpinner />}
{error && <WordCountError />}
{data !== null && (
<Container fluid>
{rows.map(({ key, label, value, testId }) => (
<Row key={key}>
<Col xs={4}>
<div className="float-end">{label}:</div>
</Col>
<Col xs={6} data-testid={testId}>
{numberFormat.format(value)}
</Col>
</Row>
))}
</Container>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onClose}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
@@ -0,0 +1 @@
export const WORD_COUNT_OPEN_REQUESTED_EVENT = 'word-count:open-requested'
@@ -0,0 +1,759 @@
import { NodeType, type SyntaxNodeRef } from '@lezer/common'
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
import { findPreambleExtent } from '@/features/word-count-modal/utils/find-preamble-extent'
import type { Segmenters } from '@/features/word-count-modal/utils/segmenters'
import type { SentenceSegmenter } from './sentence-segmenter'
type SourceRange = {
from: number
to: number
}
type TextNode = SourceRange & {
text: string
}
type SimpleMacroDefinition = {
from: number
text: string
}
export type SelectedWordCountResult = {
totalWords: number
sentences: number
headers: number
mathInline: number
mathDisplay: number
}
const mergeRanges = (ranges: SourceRange[]): SourceRange[] => {
const merged: SourceRange[] = []
const sorted = [...ranges].sort(
(left, right) => left.from - right.from || left.to - right.to
)
for (const span of sorted) {
const previous = merged[merged.length - 1]
if (previous && span.from <= previous.to) {
previous.to = Math.max(previous.to, span.to)
} else {
merged.push({ ...span })
}
}
return merged
}
const replacementsMap = new Map<string, string>([
['aa', 'å'],
['AA', 'Å'],
['ae', 'æ'],
['AE', 'Æ'],
['oe', 'œ'],
['OE', 'Œ'],
['o', 'ø'],
['O', 'Ø'],
['ss', 'ß'],
['SS', 'SS'],
['l', 'ł'],
['L', 'Ł'],
['dh', 'ð'],
['DH', 'Ð'],
['dj', 'đ'],
['DJ', 'Ð'],
['th', 'þ'],
['TH', 'Þ'],
['ng', 'ŋ'],
['NG', 'Ŋ'],
['i', 'ı'],
['j', 'ȷ'],
['&', '&'],
['$', '$'],
['%', '%'],
['#', '#'],
['_', '_'],
['{', '{'],
['}', '}'],
['H', 'ő'],
['b', 'o'],
['c', 'ç'],
['d', 'o'],
['k', 'ą'],
['r', 'å'],
['t', 'o͡o'],
['u', 'ŏ'],
['v', 'š'],
["'", ''],
['^', ''],
['"', ''],
['=', ''],
['.', ''],
['`', ''],
['~', ''],
['TeX', 'TeX'],
['LaTeX', 'LaTeX'],
['BibTeX', 'BibTeX'],
['textbackslash', '\\'],
])
const hiddenContentCommands = new Set(['phantom', 'hphantom', 'vphantom'])
const nonRenderingCommands = new Set([
'addtocounter',
'addtolength',
'bibliography',
'bibliographystyle',
'cline',
'color',
'hspace',
'includegraphics',
'includesvg',
'label',
'pagestyle',
'rule',
'setcounter',
'setlength',
'thispagestyle',
'vspace',
])
const transparentTextCommands = new Set([
'MakeLowercase',
'MakeUppercase',
'fbox',
'lowercase',
'mbox',
'uppercase',
])
const boundaryTextCommands = new Set([
'IEEEauthorblockA',
'IEEEauthorblockN',
'centerline',
])
const nonRenderingEnvironments = new Set([
'comment',
'filecontents',
'filecontents*',
])
const verbatimEnvironments = new Set([
'Verbatim',
'boxedverbatim',
'codeexample',
'lstlisting',
'minted',
'tcblisting',
'verbatim',
])
const plainMathTextCommands = new Set(['mbox', 'mathrm', 'textnormal'])
const isSimpleMacroText = (text: string) =>
text.length > 0 && !/[\\{}#$%&_~^]/u.test(text)
export const countWordsInSelection = (
content: string,
sourceRange: SourceRange,
segmenters: Segmenters,
sentenceSegmenter: SentenceSegmenter
): SelectedWordCountResult => {
const result: SelectedWordCountResult = {
totalWords: 0,
sentences: 0,
headers: 0,
mathInline: 0,
mathDisplay: 0,
}
const range = {
from: Math.max(0, Math.min(sourceRange.from, content.length)),
to: Math.max(0, Math.min(sourceRange.to, content.length)),
}
if (range.from >= range.to) {
return result
}
const tree = LaTeXLanguage.parser.parse(content)
const textNodes: TextNode[] = []
const transparentRanges: SourceRange[] = []
const collapsedRanges: SourceRange[] = []
const boundaryRanges: SourceRange[] = []
const boundaryPositions: number[] = []
const maketitlePositions: number[] = []
const simpleMacroDefinitions = new Map<string, SimpleMacroDefinition[]>()
const addSimpleMacroDefinition = (
name: string,
definition: SimpleMacroDefinition
) => {
const definitions = simpleMacroDefinitions.get(name) ?? []
definitions.push(definition)
simpleMacroDefinitions.set(name, definitions)
}
tree.iterate({
enter(nodeRef: SyntaxNodeRef) {
if (nodeRef.type.is('Maketitle')) {
maketitlePositions.push(nodeRef.from)
return false
}
const isNewCommand =
nodeRef.type.is('NewCommand') || nodeRef.type.is('RenewCommand')
const isDef = nodeRef.type.is('Def')
if (!isNewCommand && !isDef) {
return
}
if (
(isNewCommand &&
nodeRef.node.getChildren('OptionalArgument').length > 0) ||
(isDef && nodeRef.node.getChildren('MacroParameter').length > 0)
) {
return false
}
const nameNode =
nodeRef.node.getChild('Csname') ??
nodeRef.node.getChild('LiteralArgContent')
const definitionNode = nodeRef.node.getChild('DefinitionArgument')
if (!nameNode || !definitionNode) {
return false
}
const nameMatch = /\\([A-Za-z@]+)/u.exec(
content.substring(nameNode.from, nameNode.to)
)
const definitionText = content.substring(
definitionNode.from + 1,
definitionNode.to - 1
)
if (nameMatch && isSimpleMacroText(definitionText)) {
addSimpleMacroDefinition(nameMatch[1], {
from: nodeRef.to,
text: definitionText,
})
}
return false
},
})
const preambleExtent = findPreambleExtent(tree)
const intersectsRange = (span: SourceRange) =>
span.to > range.from && span.from < range.to
const rangesOverlap = (left: SourceRange, right: SourceRange) =>
left.to > right.from && left.from < right.to
const isContainedInRange = (span: SourceRange) =>
span.from >= range.from && span.to <= range.to
const addBoundaryRange = (span: SourceRange) => {
if (intersectsRange(span)) {
boundaryRanges.push({ from: span.from, to: span.to })
}
}
const addBoundaryPositions = (span: SourceRange) => {
if (intersectsRange(span)) {
boundaryPositions.push(span.from, span.to)
}
}
const addTransparentRange = (span: SourceRange) => {
if (intersectsRange(span)) {
transparentRanges.push({ from: span.from, to: span.to })
}
}
const addCollapsedRange = (span: SourceRange) => {
if (intersectsRange(span)) {
collapsedRanges.push({ from: span.from, to: span.to })
}
}
const addSourceTextSpan = (span: SourceRange) => {
if (!intersectsRange(span)) {
return
}
const from = Math.max(span.from, range.from)
const to = Math.min(span.to, range.to)
textNodes.push({
from,
to,
text: content.substring(from, to),
})
}
const addSourceTextNode = (nodeRef: SyntaxNodeRef) => {
addSourceTextSpan(nodeRef)
}
const addSyntheticTextNode = (
sourceSpan: SourceRange,
text: string,
textNodeSpan: SourceRange = sourceSpan
) => {
if (!isContainedInRange(sourceSpan)) {
return
}
textNodes.push({
from: textNodeSpan.from,
to: textNodeSpan.to,
text,
})
}
const findSimpleMacroReplacement = (name: string, position: number) => {
const definitions = simpleMacroDefinitions.get(name)
if (!definitions) {
return
}
let replacement: string | undefined
for (const definition of definitions) {
if (definition.from > position) {
break
}
replacement = definition.text
}
return replacement
}
const visitBodyNode = (nodeRef: SyntaxNodeRef): boolean | void => {
return bodyMatcher(nodeRef.type)?.(nodeRef)
}
const iterateNode = (nodeRef: SyntaxNodeRef) => {
nodeRef.node.cursor().iterate(childNodeRef => {
if (childNodeRef.node !== nodeRef.node) {
return visitBodyNode(childNodeRef)
}
})
}
const handleComment = (nodeRef: SyntaxNodeRef) => {
addTransparentRange(nodeRef)
addCollapsedRange(nodeRef)
}
const addDelimitedLiteralContent = (nodeRef: SourceRange) => {
let from = nodeRef.from
let to = nodeRef.to
if (content[from] === '*') {
from++
}
if (to - from >= 2) {
from++
to--
}
if (from < to) {
addSourceTextSpan({ from, to })
}
}
const isRenderedByMaketitle = (nodeRef: SyntaxNodeRef) =>
maketitlePositions.some(position => position >= nodeRef.to)
const handleMaketitleText = (
nodeRef: SyntaxNodeRef,
countAsHeader = false
) => {
if (!isRenderedByMaketitle(nodeRef)) {
return false
}
if (countAsHeader && intersectsRange(nodeRef)) {
result.headers++
}
if (nodeRef.type.is('Date')) {
const dateArgument = nodeRef.node.getChild('ShortTextArgument')
if (dateArgument) {
iterateNode(dateArgument)
}
return false
}
iterateNode(nodeRef)
return false
}
const handleMathText = (nodeRef: SyntaxNodeRef) => {
nodeRef.node.cursor().iterate(childNodeRef => {
if (childNodeRef.node === nodeRef.node) {
return
}
if (childNodeRef.type.is('MathTextCommand')) {
const textArgument = childNodeRef.node.getChild('TextArgument')
if (textArgument) {
iterateNode(textArgument)
}
return false
}
if (childNodeRef.type.is('MathUnknownCommand')) {
const macro = childNodeRef.node.getChild('$CtrlSeq')
const argument = childNodeRef.node.getChild('MathArgument')
if (!macro || !argument) {
return false
}
const commandName = content.substring(macro.from + 1, macro.to)
const argumentText = content.substring(
argument.from + 1,
argument.to - 1
)
if (
plainMathTextCommands.has(commandName) &&
isSimpleMacroText(argumentText)
) {
addSourceTextSpan({ from: argument.from + 1, to: argument.to - 1 })
}
return false
}
})
}
const handleEnvironment = (nodeRef: SyntaxNodeRef) => {
const envNameGroup = nodeRef.node
.getChild('BeginEnv')
?.getChild('EnvNameGroup')
if (!envNameGroup) {
return
}
const envName = content
.substring(envNameGroup.from + 1, envNameGroup.to - 1)
.replace(/\*$/, '')
if (nonRenderingEnvironments.has(envName)) {
addBoundaryRange(nodeRef)
return false
}
if (verbatimEnvironments.has(envName)) {
const verbatimContent = nodeRef.node
.getChild('Content')
?.getChild('VerbatimContent')
if (verbatimContent) {
addSourceTextSpan(verbatimContent)
}
return false
}
if (envName !== 'abstract') {
return
}
if (intersectsRange(nodeRef)) {
result.headers++
}
const contentNode = nodeRef.node.getChild('Content')
if (contentNode) {
iterateNode(contentNode)
}
return false
}
const headMatcher = NodeType.match<
(nodeRef: SyntaxNodeRef) => boolean | void
>({
Comment(nodeRef) {
handleComment(nodeRef)
return false
},
Title(nodeRef) {
return handleMaketitleText(nodeRef, true)
},
'Author Affil Affiliation Date'(nodeRef) {
return handleMaketitleText(nodeRef)
},
$Environment(nodeRef) {
return handleEnvironment(nodeRef)
},
})
const bodyMatcher = NodeType.match<
(nodeRef: SyntaxNodeRef) => boolean | void
>({
Comment(nodeRef) {
handleComment(nodeRef)
return false
},
Normal(nodeRef) {
addSourceTextNode(nodeRef)
},
Cite(nodeRef) {
addBoundaryRange(nodeRef)
const optionalArgs = nodeRef.node.getChildren('OptionalArgument')
for (const arg of optionalArgs) {
const child = arg.getChild('ShortOptionalArg')
if (child) {
iterateNode(child)
}
}
return false
},
UnknownCommand(nodeRef) {
const macro =
nodeRef.node.getChild('$CtrlSeq') ?? nodeRef.node.getChild('$CtrlSym')
if (!macro) {
return
}
const commandName = content.substring(macro.from + 1, macro.to)
if (!commandName) {
return
}
if (commandName === 'thanks') {
iterateNode(nodeRef)
return false
}
if (
hiddenContentCommands.has(commandName) ||
nonRenderingCommands.has(commandName)
) {
addBoundaryRange(nodeRef)
return false
}
if (boundaryTextCommands.has(commandName)) {
addBoundaryPositions(nodeRef)
return
}
if (transparentTextCommands.has(commandName)) {
addTransparentRange(nodeRef)
return
}
const simpleMacroReplacement = findSimpleMacroReplacement(
commandName,
macro.from
)
if (simpleMacroReplacement !== undefined) {
addSyntheticTextNode(macro, simpleMacroReplacement)
return false
}
const replacement = replacementsMap.get(commandName)
if (replacement === undefined) {
addBoundaryRange(nodeRef)
return false
}
const commandTail = content.substring(macro.to, nodeRef.to)
addSyntheticTextNode(
macro,
replacement,
commandTail.trim() === '' ? macro : nodeRef
)
return false
},
$Environment(nodeRef) {
return handleEnvironment(nodeRef)
},
'Title Author Affil Affiliation Date'(nodeRef) {
if (nodeRef.to <= preambleExtent.to) {
return false
}
return handleMaketitleText(nodeRef, nodeRef.type.is('Title'))
},
'$ToggleTextFormattingCommand $OtherTextFormattingCommand'(nodeRef) {
addTransparentRange(nodeRef)
},
HrefCommand(nodeRef) {
const label = nodeRef.node.getChild('ShortTextArgument')
if (label) {
iterateNode(label)
}
return false
},
UrlCommand(nodeRef) {
const literalContent = nodeRef.node
.getChild('UrlArgument')
?.getChild('LiteralArgContent')
if (literalContent) {
addSourceTextSpan(literalContent)
}
return false
},
'VerbCommand LstInlineCommand'(nodeRef) {
const literalContent =
nodeRef.node.getChild('VerbContent') ??
nodeRef.node.getChild('LstInlineContent')
if (literalContent) {
addDelimitedLiteralContent(literalContent)
}
return false
},
Item(nodeRef) {
for (const optionalArgument of nodeRef.node.getChildren(
'OptionalArgument'
)) {
const label = optionalArgument.getChild('ShortOptionalArg')
if (label) {
iterateNode(label)
}
}
return false
},
'Def Let NewCommand RenewCommand NewEnvironment RenewEnvironment NewTheoremCommand TheoremStyleCommand'() {
return false
},
'IncludeGraphics IncludeSvg SetLengthCommand'(nodeRef) {
addBoundaryRange(nodeRef)
return false
},
BeginEnv() {
return false
},
Math(nodeRef) {
if (!intersectsRange(nodeRef)) {
return false
}
addBoundaryRange(nodeRef)
handleMathText(nodeRef)
const parent = nodeRef.node.parent
if (parent?.type.is('InlineMath') || parent?.type.is('ParenMath')) {
result.mathInline++
} else {
result.mathDisplay++
}
return false
},
'ShortTextArgument ShortOptionalArg'(nodeRef) {
addBoundaryRange(nodeRef)
return false
},
SectioningArgument(nodeRef) {
if (intersectsRange(nodeRef)) {
result.headers++
}
iterateNode(nodeRef)
return false
},
Caption(nodeRef) {
iterateNode(nodeRef)
return false
},
'FootnoteCommand EndnoteCommand'(nodeRef) {
iterateNode(nodeRef)
return false
},
'IncludeArgument InputArgument SubfileArgument'(nodeRef) {
addBoundaryRange(nodeRef)
return false
},
'BlankLine LineBreak'(nodeRef) {
addSyntheticTextNode(nodeRef, '\n')
},
})
tree.iterate({
from: 0,
to: preambleExtent.to,
enter(nodeRef: SyntaxNodeRef) {
return headMatcher(nodeRef.type)?.(nodeRef)
},
})
tree.iterate({
from: preambleExtent.to,
enter(nodeRef: SyntaxNodeRef) {
if (nodeRef.to <= preambleExtent.to) {
return false
}
return visitBodyNode(nodeRef)
},
})
let text = ''
let position = range.from
const mergedTransparentRanges = mergeRanges(transparentRanges)
const mergedCollapsedRanges = mergeRanges(collapsedRanges)
const gapHasVisibleWhitespace = (gap: SourceRange) => {
let cursor = gap.from
for (const span of mergedCollapsedRanges) {
if (span.to <= cursor) {
continue
}
if (span.from >= gap.to) {
break
}
if (/\s/u.test(content.substring(cursor, Math.max(cursor, span.from)))) {
return true
}
cursor = Math.max(cursor, Math.min(span.to, gap.to))
}
return /\s/u.test(content.substring(cursor, gap.to))
}
for (const textNode of textNodes) {
const gap = {
from: position,
to: textNode.from,
}
const isTransparentGap =
gap.from < gap.to &&
mergedTransparentRanges.some(
span => span.from <= gap.from && span.to >= gap.to
) &&
!gapHasVisibleWhitespace(gap) &&
!boundaryPositions.some(
position => position >= gap.from && position <= gap.to
) &&
!boundaryRanges.some(span => rangesOverlap(span, gap))
if (gap.from < gap.to && !isTransparentGap) {
text += ' '
}
text += textNode.text
position = textNode.to
}
for (const value of segmenters.word.segment(
text.replace(/\w[-_]\w/g, 'aaa')
)) {
if (value.isWordLike) {
result.totalWords++
}
}
for (const sentence of sentenceSegmenter.segment(text)) {
for (const word of segmenters.word.segment(sentence.segment)) {
if (word.isWordLike) {
result.sentences++
break
}
}
}
return result
}
@@ -0,0 +1,37 @@
export type SentenceSegment = {
segment: string
}
export type SentenceSegmenter = {
segment(input: string): {
[Symbol.iterator](): IterableIterator<SentenceSegment>
}
}
const sentenceRe = /[^.!?。!?…]+(?:[.!?。!?…]+|$)/gu
const fallbackSentenceSegmenter: SentenceSegmenter = {
segment(input: string) {
const segments: SentenceSegment[] = []
for (const match of input.matchAll(sentenceRe)) {
segments.push({ segment: match[0] })
}
return segments
},
}
export const createSentenceSegmenter = (locale?: string): SentenceSegmenter => {
if (!Intl.Segmenter) {
return fallbackSentenceSegmenter
}
try {
return new Intl.Segmenter(locale, {
granularity: 'sentence',
})
} catch {
return fallbackSentenceSegmenter
}
}
@@ -0,0 +1,192 @@
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { useEffect, type FC, type PropsWithChildren } from 'react'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import type { FileTreeDocumentFindResult } from '@/features/ide-react/types/file-tree'
import {
EditorProviders,
makeEditorPropertiesProvider,
} from '../../../../../test/frontend/helpers/editor-providers'
import { EditorViewContext } from '../../../../../frontend/js/features/ide-react/context/editor-view-context'
import SelectedWordCountController from '../../../frontend/components/selected-word-count-controller'
import { WORD_COUNT_OPEN_REQUESTED_EVENT } from '../../../frontend/selected-word-count-events'
const ActiveDocument = () => {
const { handleFileTreeSelect } = useFileTreeOpenContext()
useEffect(() => {
const document: FileTreeDocumentFindResult = {
type: 'doc',
entity: { _id: 'test-doc', name: 'main.tex' },
parent: [],
parentFolderId: 'root-folder-id',
path: ['main.tex'],
index: 0,
}
handleFileTreeSelect([document])
}, [handleFileTreeSelect])
return null
}
describe('<SelectedWordCountController />', function () {
let view: EditorView | undefined
const mountController = (
content: string,
from: number,
to: number,
options: { activeDocument?: boolean; showVisual?: boolean } = {}
) => {
view = new EditorView({
state: EditorState.create({
doc: content,
selection: { anchor: from, head: to },
}),
})
const TestEditorViewProvider: FC<PropsWithChildren> = ({ children }) => (
<EditorViewContext.Provider
value={{ view: view ?? null, setView: () => undefined }}
>
{children}
</EditorViewContext.Provider>
)
cy.mount(
<EditorProviders
providers={{
EditorViewProvider: TestEditorViewProvider,
EditorPropertiesProvider: makeEditorPropertiesProvider({
showVisual: options.showVisual,
}),
}}
>
{options.activeDocument !== false && <ActiveDocument />}
<SelectedWordCountController />
</EditorProviders>
)
}
const requestWordCount = () => {
const event = new Event(WORD_COUNT_OPEN_REQUESTED_EVENT, {
cancelable: true,
})
window.dispatchEvent(event)
return event
}
afterEach(function () {
view?.destroy()
view = undefined
})
it('counts the immutable selection snapshot and closes the modal', function () {
const content = 'one two three four'
const from = content.indexOf('two three')
mountController(content, from, from + 'two three'.length)
cy.then(() => {
const event = requestWordCount()
expect(event.defaultPrevented).to.be.true
view?.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: 'changed after opening',
},
})
})
cy.findByRole('dialog').within(() => {
cy.findByText('Selected text word count').should('exist')
cy.findByText('Total Words').should('exist')
cy.findByText('Sentences').should('exist')
cy.findByText('Headers').should('exist')
cy.findByText('Math Inline').should('exist')
cy.findByText('Math Display').should('exist')
cy.findByTestId('selected-word-count-total').should('have.text', '2')
cy.findByTestId('selected-word-count-sentences').should('have.text', '1')
cy.findByTestId('selected-word-count-headers').should('have.text', '0')
cy.findByTestId('selected-word-count-math-inline').should(
'have.text',
'0'
)
cy.findByTestId('selected-word-count-math-display').should(
'have.text',
'0'
)
cy.findByRole('button', { name: 'Close' }).click()
})
cy.findByRole('dialog').should('not.exist')
})
it('shows the selected header and math breakdown', function () {
const content =
'\\section{Heading words}\n' + 'Body text $x+y$ and \\[z=1\\]'
mountController(content, 0, content.length)
cy.then(() => {
expect(requestWordCount().defaultPrevented).to.be.true
})
cy.findByRole('dialog').within(() => {
cy.findByTestId('selected-word-count-total').should('have.text', '5')
cy.findByTestId('selected-word-count-sentences').should('have.text', '1')
cy.findByTestId('selected-word-count-headers').should('have.text', '1')
cy.findByTestId('selected-word-count-math-inline').should(
'have.text',
'1'
)
cy.findByTestId('selected-word-count-math-display').should(
'have.text',
'1'
)
})
})
it('shows zero when the selection has no countable prose', function () {
const content = '\\cite{smith2026}'
mountController(content, 0, content.length)
cy.then(() => {
expect(requestWordCount().defaultPrevented).to.be.true
})
cy.findByTestId('selected-word-count-total').should('have.text', '0')
cy.findByTestId('selected-word-count-sentences').should('have.text', '0')
})
it('does not handle the request when the selection is empty', function () {
mountController('one', 1, 1)
cy.then(() => {
expect(requestWordCount().defaultPrevented).to.be.false
})
cy.findByRole('dialog').should('not.exist')
})
it('does not handle a selection when no document is active', function () {
mountController('one two', 0, 3, { activeDocument: false })
cy.then(() => {
expect(requestWordCount().defaultPrevented).to.be.false
})
cy.findByRole('dialog').should('not.exist')
})
it('does not handle a source selection while the visual editor is active', function () {
mountController('one two', 0, 3, { showVisual: true })
cy.then(() => {
expect(requestWordCount().defaultPrevented).to.be.false
})
cy.findByRole('dialog').should('not.exist')
})
})
@@ -0,0 +1,508 @@
import { expect } from 'chai'
import { createSegmenters } from '@/features/word-count-modal/utils/segmenters'
import { countWordsInSelection } from '../../../frontend/utils/count-words-in-selection'
import { createSentenceSegmenter } from '../../../frontend/utils/sentence-segmenter'
describe('selected-text word count', function () {
const segmenters = createSegmenters('en-US')
const sentenceSegmenter = createSentenceSegmenter('en-US')
const countSelectionResult = (
content: string,
from: number,
to: number,
localeSegmenters = segmenters,
localeSentenceSegmenter = sentenceSegmenter
) =>
countWordsInSelection(
content,
{ from, to },
localeSegmenters,
localeSentenceSegmenter
)
const countSelection = (
content: string,
from: number,
to: number,
localeSegmenters = segmenters
) => countSelectionResult(content, from, to, localeSegmenters).totalWords
const countSelectedText = (content: string, selectedText: string) => {
const from = content.indexOf(selectedText)
expect(from).to.be.greaterThanOrEqual(0)
return countSelection(content, from, from + selectedText.length)
}
it('counts plain prose', function () {
const content = 'Hello world from Overleaf.'
expect(countSelection(content, 0, content.length)).to.equal(4)
})
it('counts common sentence-ending punctuation', function () {
const content = 'Hello world. This works! Is it ready?'
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
3
)
})
it('counts a final sentence without ending punctuation', function () {
const content = 'Hello world'
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
1
)
})
it('does not count punctuation-only segments as sentences', function () {
const content = '... ?!'
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
0
)
})
it('handles decimal numbers and common abbreviations', function () {
const content = 'Use e.g. version 3.14 today. It works.'
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
2
)
})
it('counts sentences from visible text after filtering LaTeX', function () {
const content =
'First \\textbf{visible} sentence. % hidden sentence.\n' +
'Second $x+y$ sentence!'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 5,
sentences: 2,
headers: 0,
mathInline: 1,
mathDisplay: 0,
})
})
it('counts sentences only inside the selected range', function () {
const content = 'First sentence. Second sentence! Third sentence?'
const selectedText = 'Second sentence!'
const from = content.indexOf(selectedText)
expect(
countSelectionResult(content, from, from + selectedText.length).sentences
).to.equal(1)
})
it('uses locale-aware sentence segmentation for English', function () {
const content = 'This is sentence one. This is sentence two!'
expect(
countSelectionResult(
content,
0,
content.length,
createSegmenters('en-US'),
createSentenceSegmenter('en-US')
).sentences
).to.equal(2)
})
it('falls back when the sentence locale is invalid', function () {
const content = 'First sentence. Second sentence!'
expect(
countSelectionResult(
content,
0,
content.length,
segmenters,
createSentenceSegmenter('invalid_locale')
).sentences
).to.equal(2)
})
it('returns word, header, and math counts for a mixed selection', function () {
const content =
'\\section{Heading words}\n' + 'Body text $x+y$ and \\[z=1\\]'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 5,
sentences: 1,
headers: 1,
mathInline: 1,
mathDisplay: 1,
})
})
it('does not count header or math nodes outside the selection', function () {
const content = '\\section{Outside heading}\n' + 'Selected words $x+y$'
const selectedText = 'Selected words'
const from = content.indexOf(selectedText)
expect(
countSelectionResult(content, from, from + selectedText.length)
).to.deep.equal({
totalWords: 2,
sentences: 1,
headers: 0,
mathInline: 0,
mathDisplay: 0,
})
})
it('counts only a partial range', function () {
expect(countSelectedText('one two three four', 'two three')).to.equal(2)
})
it('counts human-readable text inside formatting commands', function () {
expect(
countSelectedText('This is \\textbf{important text}.', 'important text')
).to.equal(2)
})
it('joins word fragments separated only by formatting markup', function () {
const content = 'inter\\textbf{nal}formatting'
expect(countSelection(content, 0, content.length)).to.equal(1)
})
it('joins fragments across adjacent formatting commands', function () {
const content = 'inter\\textbf{n}\\emph{al}formatting'
expect(countSelection(content, 0, content.length)).to.equal(1)
})
it('keeps real whitespace around formatting as a word boundary', function () {
const content = 'one \\textbf{two}'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('keeps excluded math inside formatting as a word boundary', function () {
const content = 'before\\textbf{$x$}after'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 2,
sentences: 1,
headers: 0,
mathInline: 1,
mathDisplay: 0,
})
})
it('keeps an excluded citation inside formatting as a boundary', function () {
const content = 'before\\textbf{\\cite{key}}after'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 2,
sentences: 1,
headers: 0,
mathInline: 0,
mathDisplay: 0,
})
})
it('does not count a citation key selected inside a citation', function () {
expect(
countSelectedText(
'According to \\cite{smith2026}, results improve.',
'smith2026'
)
).to.equal(0)
})
it('counts prose in an optional citation argument', function () {
expect(
countSelectedText(
'According to \\cite[see detailed discussion]{smith2026}.',
'see detailed discussion'
)
).to.equal(3)
})
it('does not count selected math as prose', function () {
const content = 'The result is $E = mc^2$ today.'
const selectedText = 'E = mc^2'
const from = content.indexOf(selectedText)
expect(
countSelectionResult(content, from, from + selectedText.length)
).to.deep.equal({
totalWords: 0,
sentences: 0,
headers: 0,
mathInline: 1,
mathDisplay: 0,
})
})
it('does not count selected comment text', function () {
expect(
countSelectedText(
'Visible text. % hidden comment words\n',
'hidden comment words'
)
).to.equal(0)
})
it('counts rendered text inside TeXcount ignore directives', function () {
expect(
countSelectedText(
'%TC:ignore\nthese words are ignored\n%TC:endignore\nvisible',
'these words are ignored'
)
).to.equal(4)
})
it('counts rendered structures inside TeXcount ignore directives', function () {
const content =
'%TC:ignore\n' +
'\\section{Visible heading}\n' +
'$x+y$\n' +
'\\[z=1\\]\n' +
'%TC:endignore\n' +
'visible'
const selectedText = '\\section{Visible heading}\n$x+y$\n\\[z=1\\]'
const from = content.indexOf(selectedText)
expect(
countSelectionResult(content, from, from + selectedText.length)
).to.deep.equal({
totalWords: 2,
sentences: 1,
headers: 1,
mathInline: 1,
mathDisplay: 1,
})
})
it('collapses comments in the same way as LaTeX', function () {
const content = 'inter% comment removes the newline\nnational'
expect(countSelection(content, 0, content.length)).to.equal(1)
})
it('does not follow input or include files or count their paths', function () {
const content =
'Before.\n\\input{chapter}\nMiddle.\n\\include{appendix}\nAfter.'
expect(countSelection(content, 0, content.length)).to.equal(3)
})
it('counts title, author, affiliation, and date rendered by maketitle', function () {
const content =
'\\title{Visible Title}\n' +
'\\author{Alice Smith}\n' +
'\\affil{Example University}\n' +
'\\date{June 2026}\n' +
'\\begin{document}\\maketitle\\end{document}'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 8,
sentences: 1,
headers: 1,
mathInline: 0,
mathDisplay: 0,
})
})
it('does not count title metadata when maketitle is absent', function () {
const content =
'\\title{Hidden Title}\\author{Hidden Author}\\begin{document}Body\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(1)
})
it('counts IEEE author block contents rendered by maketitle', function () {
const content =
'\\begin{document}' +
'\\author{\\IEEEauthorblockN{Alice Smith}\\IEEEauthorblockA{Example University}}' +
'\\maketitle\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(4)
})
it('does not count layout dimensions or drawing parameters', function () {
const content = 'Before \\vspace{12pt} \\hspace{2em} \\cline{2-4} after.'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('does not count phantom text', function () {
const content = 'Before \\phantom{hidden words} after.'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('counts visible href labels but not target URLs', function () {
const content = '\\href{https://example.test/internal-path}{Visible link}'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('counts URLs that are rendered by the url command', function () {
const content = '\\url{visible-address}'
expect(countSelection(content, 0, content.length)).to.equal(1)
})
it('counts literal contents of verb and lstinline commands', function () {
const content = 'Use \\verb|visible code| and \\lstinline!more code! here.'
expect(countSelection(content, 0, content.length)).to.equal(7)
})
it('counts visible verbatim environment content', function () {
const content =
'Before \\begin{verbatim}literal code words\\end{verbatim} after.'
expect(countSelection(content, 0, content.length)).to.equal(5)
})
it('does not count content in non-rendering environments', function () {
const content = 'Before \\begin{comment}hidden words\\end{comment} after.'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('counts text-producing commands inside math', function () {
const content =
'Before $x + \\text{visible words} + \\textrm{more text}$ after.'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 6,
sentences: 1,
headers: 0,
mathInline: 1,
mathDisplay: 0,
})
})
it('counts supported plain-text commands inside math', function () {
const content = 'Before $\\mathrm{unit} + \\textnormal{normal text}$ after.'
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
totalWords: 5,
sentences: 1,
headers: 0,
mathInline: 1,
mathDisplay: 0,
})
})
it('counts built-in text-producing macros', function () {
const content = 'Made with \\TeX, \\LaTeX, and \\BibTeX.'
expect(countSelection(content, 0, content.length)).to.equal(6)
})
it('expands simple zero-argument text macros after their definition', function () {
const content =
'\\newcommand{\\project}{Visible Project Name}' +
'\\begin{document}The \\project works.\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(5)
})
it('expands simple def macros after their definition', function () {
const content =
'\\def\\project{Visible Project}' +
'\\begin{document}The \\project works.\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(4)
})
it('uses the latest simple renewcommand definition', function () {
const content =
'\\newcommand{\\project}{First Name}' +
'\\renewcommand{\\project}{Second Name}' +
'\\begin{document}\\project\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('skips parameterized custom macros conservatively', function () {
const content =
'\\newcommand{\\greet}[1]{Hello #1}' +
'\\begin{document}Before \\greet{World} after.\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('does not count macro definitions as rendered text', function () {
const content =
'\\newcommand{\\project}{Definition words}' +
'\\begin{document}Visible body\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(2)
})
it('keeps table and bibliography prose in the word count', function () {
const content =
'\\begin{table}\\begin{tabular}{c}Visible table cell\\end{tabular}\\end{table}' +
'\\begin{thebibliography}{1}\\bibitem{key}Visible reference words\\end{thebibliography}'
expect(countSelection(content, 0, content.length)).to.equal(6)
})
it('counts optional item labels because they are rendered', function () {
const content =
'\\begin{description}\\item[Visible label]Body text\\end{description}'
expect(countSelection(content, 0, content.length)).to.equal(4)
})
it('keeps formatted ordinal fragments joined inside IEEE author blocks', function () {
const content =
'\\begin{document}' +
'\\author{' +
'\\IEEEauthorblockN{1\\textsuperscript{st} Author}\\and' +
'\\IEEEauthorblockN{2\\textsuperscript{nd} Author}}' +
'\\maketitle\\end{document}'
expect(countSelection(content, 0, content.length)).to.equal(4)
})
it('counts headings, captions, and footnotes as selected words', function () {
const content =
'\\section{Heading words}\n' +
'\\begin{figure}\\caption{Caption words}\\end{figure}\n' +
'Body\\footnote{Footnote words}'
expect(countSelection(content, 0, content.length)).to.equal(7)
})
it('uses Unicode-aware segmentation for English text', function () {
const content = 'The café résumé is ready.'
expect(
countSelection(content, 0, content.length, createSegmenters('en-US'))
).to.equal(5)
})
it('counts a complete synthetic replacement as one atomic word', function () {
expect(countSelectedText('Made with \\LaTeX today.', '\\LaTeX')).to.equal(1)
})
it('does not count a partial synthetic replacement', function () {
expect(countSelectedText('Made with \\LaTeX today.', 'LaTeX')).to.equal(0)
})
it('clips out-of-bounds ranges to the document', function () {
expect(countSelection('one two', -100, 100)).to.equal(2)
})
it('returns zero for an empty range', function () {
expect(countSelection('one two', 3, 3)).to.equal(0)
expect(countSelectionResult('one two', 3, 3)).to.deep.equal({
totalWords: 0,
sentences: 0,
headers: 0,
mathInline: 0,
mathDisplay: 0,
})
})
})