@@ -21,7 +21,7 @@ With build controls at the top (build, force build, clean, etc.)
21
21
import type { Data } from "@cocalc/frontend/frame-editors/frame-tree/pinch-to-zoom" ;
22
22
import type { TabsProps } from "antd" ;
23
23
24
- import { Avatar , List as AntdList , Button , Spin , Tabs , Tag } from "antd" ;
24
+ import { List as AntdList , Avatar , Button , Spin , Tabs , Tag } from "antd" ;
25
25
import { List } from "immutable" ;
26
26
import { useCallback , useMemo , useState } from "react" ;
27
27
import { useIntl } from "react-intl" ;
@@ -33,20 +33,21 @@ import {
33
33
TableOfContentsEntryList ,
34
34
} from "@cocalc/frontend/components" ;
35
35
import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown" ;
36
+ import { filenameIcon } from "@cocalc/frontend/file-associations" ;
36
37
import { EditorState } from "@cocalc/frontend/frame-editors/frame-tree/types" ;
37
38
import { project_api } from "@cocalc/frontend/frame-editors/generic/client" ;
38
- import { filenameIcon } from "@cocalc/frontend/file-associations" ;
39
39
import { editor , labels } from "@cocalc/frontend/i18n" ;
40
40
import { path_split , plural } from "@cocalc/util/misc" ;
41
41
import { COLORS } from "@cocalc/util/theme" ;
42
42
import { Actions } from "./actions" ;
43
43
import { Build } from "./build" ;
44
+ import { WORD_COUNT_ICON } from "./constants" ;
44
45
import { ErrorsAndWarnings } from "./errors-and-warnings" ;
45
46
import { use_build_logs } from "./hooks" ;
46
47
import { PDFControls } from "./output-pdf-control" ;
47
48
import { PDFJS } from "./pdfjs" ;
48
- import { BuildLogs } from "./types" ;
49
49
import { useFileSummaries } from "./summarize-tex" ;
50
+ import { BuildLogs } from "./types" ;
50
51
51
52
interface OutputProps {
52
53
id : string ;
@@ -63,7 +64,7 @@ interface OutputProps {
63
64
status : string ;
64
65
}
65
66
66
- type TabType = "pdf" | "contents" | "files" | "build" | "errors" ;
67
+ type TabType = "pdf" | "contents" | "files" | "build" | "errors" | "word_count" ;
67
68
68
69
interface FileListItem {
69
70
path : string ;
@@ -112,7 +113,9 @@ export function Output(props: OutputProps) {
112
113
} | null > ( null ) ;
113
114
114
115
// Track page dimensions for manual sync
115
- const [ pageDimensions , setPageDimensions ] = useState < { width : number ; height : number } [ ] > ( [ ] ) ;
116
+ const [ pageDimensions , setPageDimensions ] = useState <
117
+ { width : number ; height : number } [ ]
118
+ > ( [ ] ) ;
116
119
117
120
// Callback to clear viewport info after successful sync
118
121
const clearViewportInfo = useCallback ( ( ) => {
@@ -135,6 +138,29 @@ export function Output(props: OutputProps) {
135
138
const { fileSummaries, summariesLoading, refreshSummaries } =
136
139
useFileSummaries ( switch_to_files , project_id , path , homeDir , reload ) ;
137
140
141
+ // Word count state
142
+ const [ wordCountLoading , setWordCountLoading ] = useState < boolean > ( false ) ;
143
+
144
+ // Get word count from redux store
145
+ const wordCount : string = useRedux ( [ name , "word_count" ] ) ?? "" ;
146
+
147
+ // Word count refresh function (debounce/reuseInFlight handled in actions)
148
+ const refreshWordCount = useCallback (
149
+ async ( force : boolean = false ) => {
150
+ if ( activeTab !== "word_count" ) return ;
151
+ setWordCountLoading ( true ) ;
152
+ try {
153
+ const timestamp = force ? Date . now ( ) : actions . last_save_time ( ) ;
154
+ await actions . word_count ( timestamp , force , true ) ; // skipFramePopup = true
155
+ } catch ( error ) {
156
+ console . warn ( "Word count failed:" , error ) ;
157
+ } finally {
158
+ setWordCountLoading ( false ) ;
159
+ }
160
+ } ,
161
+ [ actions , activeTab ] ,
162
+ ) ;
163
+
138
164
// Fetch home directory once when component mounts or project_id changes
139
165
React . useEffect ( ( ) => {
140
166
const fetchHomeDir = async ( ) => {
@@ -161,6 +187,13 @@ export function Output(props: OutputProps) {
161
187
setTimeout ( ( ) => actions . updateTableOfContents ( true ) ) ;
162
188
} , [ ] ) ;
163
189
190
+ // Refresh word count when tab is opened or document changes
191
+ useEffect ( ( ) => {
192
+ if ( activeTab === "word_count" ) {
193
+ refreshWordCount ( false ) ;
194
+ }
195
+ } , [ activeTab , reload , refreshWordCount ] ) ;
196
+
164
197
// Sync state with stored values when they change
165
198
React . useEffect ( ( ) => {
166
199
setActiveTab ( storedTab ) ;
@@ -187,7 +220,7 @@ export function Output(props: OutputProps) {
187
220
const knitr : boolean = useRedux ( [ name , "knitr" ] ) ;
188
221
189
222
// Get UI font size for output panel interface elements
190
- const uiFontSize =
223
+ const uiFontSize : number =
191
224
useRedux ( [ name , "local_view_state" , id , "font_size" ] ) ?? font_size ;
192
225
193
226
// Get PDF zoom level (completely separate from UI font size)
@@ -508,6 +541,88 @@ export function Output(props: OutputProps) {
508
541
} ;
509
542
}
510
543
544
+ function renderWordCountTab ( ) {
545
+ return {
546
+ key : "word_count" ,
547
+ label : (
548
+ < span style = { { display : "flex" , alignItems : "center" , gap : "2px" } } >
549
+ < Icon name = { WORD_COUNT_ICON } />
550
+ Word Count
551
+ { wordCountLoading && < Spin size = "small" /> }
552
+ </ span >
553
+ ) ,
554
+ children : (
555
+ < div
556
+ className = "smc-vfill"
557
+ style = { {
558
+ display : "flex" ,
559
+ flexDirection : "column" ,
560
+ height : "100%" ,
561
+ } }
562
+ >
563
+ { /* Fixed header with refresh button */ }
564
+ < div
565
+ style = { {
566
+ display : "flex" ,
567
+ justifyContent : "space-between" ,
568
+ alignItems : "center" ,
569
+ padding : "10px" ,
570
+ borderBottom : "1px solid #d9d9d9" ,
571
+ backgroundColor : "white" ,
572
+ flexShrink : 0 ,
573
+ } }
574
+ >
575
+ < span
576
+ style = { {
577
+ color : COLORS . GRAY_M ,
578
+ fontSize : uiFontSize - 1 ,
579
+ display : "flex" ,
580
+ alignItems : "center" ,
581
+ gap : "4px" ,
582
+ } }
583
+ >
584
+ < Icon name = { WORD_COUNT_ICON } />
585
+ Word Count Statistics
586
+ </ span >
587
+
588
+ < Button
589
+ size = "small"
590
+ icon = { < Icon name = "refresh" /> }
591
+ onClick = { ( ) => refreshWordCount ( true ) }
592
+ loading = { wordCountLoading }
593
+ disabled = { wordCountLoading }
594
+ >
595
+ { intl . formatMessage ( labels . refresh ) }
596
+ </ Button >
597
+ </ div >
598
+
599
+ { /* Scrollable content */ }
600
+ < div
601
+ style = { {
602
+ flex : 1 ,
603
+ overflowY : "auto" ,
604
+ padding : "10px" ,
605
+ } }
606
+ >
607
+ < pre
608
+ style = { {
609
+ fontSize : `${ uiFontSize } px` ,
610
+ fontFamily : "monospace" ,
611
+ whiteSpace : "pre-wrap" ,
612
+ wordWrap : "break-word" ,
613
+ margin : 0 ,
614
+ color : COLORS . GRAY_D ,
615
+ } }
616
+ >
617
+ { wordCount ||
618
+ "Click refresh to generate word count statistics..." }
619
+ </ pre >
620
+ </ div >
621
+ </ div >
622
+ ) ,
623
+ } ;
624
+ }
625
+
511
626
function renderErrorsTab ( ) {
512
627
const { errors, warnings, typesetting } = errorCounts ;
513
628
const hasAnyIssues = errors > 0 || warnings > 0 || typesetting > 0 ;
@@ -552,6 +667,7 @@ export function Output(props: OutputProps) {
552
667
...( switch_to_files ?. size > 1 ? [ renderFilesTab ( ) ] : [ ] ) ,
553
668
renderBuildTab ( ) ,
554
669
renderErrorsTab ( ) ,
670
+ renderWordCountTab ( ) ,
555
671
] ;
556
672
557
673
return (
0 commit comments