Skip to content

Commit 086eaf7

Browse files
committed
frontend/latex/output: add word count
1 parent 78849df commit 086eaf7

File tree

5 files changed

+145
-11
lines changed

5 files changed

+145
-11
lines changed

src/packages/frontend/frame-editors/latex-editor/actions.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export class Actions extends BaseActions<LatexEditorState> {
114114
private _last_sagetex_hash: string;
115115
private _last_syncstring_hash: number | undefined;
116116
private is_building: boolean = false;
117+
public word_count: (
118+
time: number,
119+
force: boolean,
120+
skipFramePopup?: boolean,
121+
) => Promise<void>;
117122
private is_stopping: boolean = false; // if true, do not continue running any compile jobs
118123
private ext: string = "tex";
119124
private knitr: boolean = false; // true, if we deal with a knitr file
@@ -193,6 +198,7 @@ export class Actions extends BaseActions<LatexEditorState> {
193198
debounce(this.ensureNonempty.bind(this), 1500),
194199
);
195200
}
201+
this.word_count = reuseInFlight(this._word_count.bind(this));
196202
}
197203

198204
// similar to jupyter, where an empty document is really
@@ -1718,9 +1724,15 @@ export class Actions extends BaseActions<LatexEditorState> {
17181724
return force ? Date.now() : time || this.last_save_time();
17191725
}
17201726

1721-
async word_count(time: number, force: boolean): Promise<void> {
1722-
// only run word count if at least one such panel exists
1723-
this.show_recently_focused_frame_of_type("word_count");
1727+
private async _word_count(
1728+
time: number,
1729+
force: boolean,
1730+
skipFramePopup: boolean = false,
1731+
): Promise<void> {
1732+
// only run word count if at least one such panel exists or skipFramePopup is true
1733+
if (!skipFramePopup) {
1734+
this.show_recently_focused_frame_of_type("word_count");
1735+
}
17241736

17251737
try {
17261738
const timestamp = this.make_timestamp(time, force);

src/packages/frontend/frame-editors/latex-editor/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ export const KNITR_EXTS: ReadonlyArray<string> = ["rnw", "rtex"];
77

88
// The maximum we let a job run
99
export const TIMEOUT_LATEX_JOB_S = 15 * 60;
10+
11+
// Icon for word count functionality
12+
export const WORD_COUNT_ICON = "file-alt";

src/packages/frontend/frame-editors/latex-editor/count_words.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
* License: MS-RSL – see LICENSE.md for details
44
*/
55

6-
import { exec } from "../generic/client";
6+
// cSpell:ignore texcount htmlcore
7+
8+
import { exec } from "@cocalc/frontend/frame-editors/generic/client";
79
import { path_split } from "@cocalc/util/misc";
810

911
// an enhancement might be to generate html via $ texcount -htmlcore

src/packages/frontend/frame-editors/latex-editor/editor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Spec for editing LaTeX documents.
1010
import { IS_IOS, IS_IPAD } from "@cocalc/frontend/feature";
1111
import { editor, labels } from "@cocalc/frontend/i18n";
1212
import { set } from "@cocalc/util/misc";
13+
import { WORD_COUNT_ICON } from "./constants";
1314
import { CodemirrorEditor } from "../code-editor/codemirror-editor";
1415
import { createEditor } from "../frame-tree/editor";
1516
import { EditorDescription } from "../frame-tree/types";
@@ -164,7 +165,7 @@ const word_count: EditorDescription = {
164165
type: "latex-word_count",
165166
short: labels.word_count,
166167
name: labels.word_count,
167-
icon: "file-alt",
168+
icon: WORD_COUNT_ICON,
168169
commands: set(["word_count"]),
169170
component: LatexWordCount,
170171
} as const;

src/packages/frontend/frame-editors/latex-editor/output.tsx

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ With build controls at the top (build, force build, clean, etc.)
2121
import type { Data } from "@cocalc/frontend/frame-editors/frame-tree/pinch-to-zoom";
2222
import type { TabsProps } from "antd";
2323

24-
import { Avatar, List as AntdList, Button, Spin, Tabs, Tag } from "antd";
24+
import { List as AntdList, Avatar, Button, Spin, Tabs, Tag } from "antd";
2525
import { List } from "immutable";
2626
import { useCallback, useMemo, useState } from "react";
2727
import { useIntl } from "react-intl";
@@ -33,20 +33,21 @@ import {
3333
TableOfContentsEntryList,
3434
} from "@cocalc/frontend/components";
3535
import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";
36+
import { filenameIcon } from "@cocalc/frontend/file-associations";
3637
import { EditorState } from "@cocalc/frontend/frame-editors/frame-tree/types";
3738
import { project_api } from "@cocalc/frontend/frame-editors/generic/client";
38-
import { filenameIcon } from "@cocalc/frontend/file-associations";
3939
import { editor, labels } from "@cocalc/frontend/i18n";
4040
import { path_split, plural } from "@cocalc/util/misc";
4141
import { COLORS } from "@cocalc/util/theme";
4242
import { Actions } from "./actions";
4343
import { Build } from "./build";
44+
import { WORD_COUNT_ICON } from "./constants";
4445
import { ErrorsAndWarnings } from "./errors-and-warnings";
4546
import { use_build_logs } from "./hooks";
4647
import { PDFControls } from "./output-pdf-control";
4748
import { PDFJS } from "./pdfjs";
48-
import { BuildLogs } from "./types";
4949
import { useFileSummaries } from "./summarize-tex";
50+
import { BuildLogs } from "./types";
5051

5152
interface OutputProps {
5253
id: string;
@@ -63,7 +64,7 @@ interface OutputProps {
6364
status: string;
6465
}
6566

66-
type TabType = "pdf" | "contents" | "files" | "build" | "errors";
67+
type TabType = "pdf" | "contents" | "files" | "build" | "errors" | "word_count";
6768

6869
interface FileListItem {
6970
path: string;
@@ -112,7 +113,9 @@ export function Output(props: OutputProps) {
112113
} | null>(null);
113114

114115
// 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+
>([]);
116119

117120
// Callback to clear viewport info after successful sync
118121
const clearViewportInfo = useCallback(() => {
@@ -135,6 +138,29 @@ export function Output(props: OutputProps) {
135138
const { fileSummaries, summariesLoading, refreshSummaries } =
136139
useFileSummaries(switch_to_files, project_id, path, homeDir, reload);
137140

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+
138164
// Fetch home directory once when component mounts or project_id changes
139165
React.useEffect(() => {
140166
const fetchHomeDir = async () => {
@@ -161,6 +187,13 @@ export function Output(props: OutputProps) {
161187
setTimeout(() => actions.updateTableOfContents(true));
162188
}, []);
163189

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+
164197
// Sync state with stored values when they change
165198
React.useEffect(() => {
166199
setActiveTab(storedTab);
@@ -187,7 +220,7 @@ export function Output(props: OutputProps) {
187220
const knitr: boolean = useRedux([name, "knitr"]);
188221

189222
// Get UI font size for output panel interface elements
190-
const uiFontSize =
223+
const uiFontSize: number =
191224
useRedux([name, "local_view_state", id, "font_size"]) ?? font_size;
192225

193226
// Get PDF zoom level (completely separate from UI font size)
@@ -508,6 +541,88 @@ export function Output(props: OutputProps) {
508541
};
509542
}
510543

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+
511626
function renderErrorsTab() {
512627
const { errors, warnings, typesetting } = errorCounts;
513628
const hasAnyIssues = errors > 0 || warnings > 0 || typesetting > 0;
@@ -552,6 +667,7 @@ export function Output(props: OutputProps) {
552667
...(switch_to_files?.size > 1 ? [renderFilesTab()] : []),
553668
renderBuildTab(),
554669
renderErrorsTab(),
670+
renderWordCountTab(),
555671
];
556672

557673
return (

0 commit comments

Comments
 (0)