web: add character, spaces, paragraphs count in status bar popup (#8311)

* web: add character, spaces, paragraphs count in status bar popup
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: refactor selected text stats logic
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: use PopupPresenter for note statistics popup
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: improve gettng text for counting characters
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
This commit is contained in:
01zulfi
2025-07-07 11:54:01 +05:00
committed by GitHub
parent 29c9476c16
commit 3ca0089ee8
8 changed files with 241 additions and 34 deletions

View File

@@ -28,23 +28,21 @@ import {
Plus,
Minus,
EditorNormalWidth,
TableOfContents,
ExitFullscreen,
Fullscreen,
EditorFullWidth,
NormalMode
NormalMode,
Cross
} from "../icons";
import {
useEditorConfig,
useNoteStatistics,
useEditorManager
} from "./manager";
import { useEditorConfig, useNoteStatistics } from "./manager";
import { getFormattedDate } from "@notesnook/common";
import { MAX_AUTO_SAVEABLE_WORDS } from "./types";
import { MAX_AUTO_SAVEABLE_WORDS, NoteStatistics } from "./types";
import { strings } from "@notesnook/intl";
import { EDITOR_ZOOM } from "./common";
import { useWindowControls } from "../../hooks/use-window-controls";
import { exitFullscreen } from "../../utils/fullscreen";
import { useRef, useState } from "react";
import { PopupPresenter } from "@notesnook/ui";
const SAVE_STATE_ICON_MAP = {
"-1": NotSaved,
@@ -54,11 +52,13 @@ const SAVE_STATE_ICON_MAP = {
function EditorFooter() {
const { isFullscreen } = useWindowControls();
const { words } = useNoteStatistics();
const statistics = useNoteStatistics();
const session = useEditorStore((store) => store.getActiveSession());
const { editorConfig, setEditorConfig } = useEditorConfig();
const editorMargins = useEditorStore((store) => store.editorMargins);
const isFocusMode = useAppStore((store) => store.isFocusMode);
const [isStatisticsPopupOpen, setIsStatisticsPopupOpen] = useState(false);
const statisticsRef = useRef<HTMLButtonElement | null>(null);
if (!session) return null;
@@ -169,7 +169,7 @@ function EditorFooter() {
<Plus size={13} />
</Button>
</Flex>
{words.total > MAX_AUTO_SAVEABLE_WORDS ? (
{statistics.words.total > MAX_AUTO_SAVEABLE_WORDS ? (
<Text
className="selectable"
variant="subBody"
@@ -178,15 +178,76 @@ function EditorFooter() {
{strings.autoSaveOff()}
</Text>
) : null}
<Text
<Button
className="selectable"
data-test-id="editor-word-count"
variant="subBody"
sx={{ color: "paragraph" }}
variant="statusitem"
ref={statisticsRef}
onClick={() => setIsStatisticsPopupOpen(true)}
>
{strings.totalWords(words.total)}
{words.selected ? ` (${strings.selectedWords(words.selected)})` : ""}
<Text variant="subBody" sx={{ color: "paragraph" }}>
{strings.totalWords(statistics.words.total)}
{statistics.words.selected
? ` (${strings.selectedWords(statistics.words.selected)})`
: ""}
</Text>
</Button>
<PopupPresenter
isOpen={isStatisticsPopupOpen}
onClose={() => setIsStatisticsPopupOpen(false)}
position={{
isTargetAbsolute: true,
target: statisticsRef.current,
location: "top",
align: "center",
yOffset: 5
}}
>
<Flex
sx={{
width: ["100%", "fit-content"],
border: "1px solid",
borderColor: "border",
borderRadius: "dialog",
boxShadow: "0px 0px 15px 0px #00000011",
bg: "background-secondary",
flexDirection: "column",
gap: 2,
pt: 2,
justifyContent: "center"
}}
>
{Object.entries(statistics).map(([key, value]) => (
<Flex key={key} sx={{ justifyContent: "space-between", px: 4 }}>
<Text variant="body" sx={{ width: 100 }}>
{strings[key as keyof NoteStatistics]()}
</Text>
<Text
variant="body"
sx={{ color: "paragraph", fontWeight: "bold" }}
>
{value.total}
{value.selected ? (
<span style={{ fontWeight: "normal" }}>
{" "}
({value.selected} selected)
</span>
) : (
""
)}
</Text>
</Flex>
))}
<Button
data-test-id="dialog-no"
onClick={() => setIsStatisticsPopupOpen(false)}
color="paragraph"
variant="icon"
>
<Cross size={12} />
</Button>
</Flex>
</PopupPresenter>
{dateEdited > 0 ? (
<Text
className="selectable"

View File

@@ -119,7 +119,10 @@ export function useNoteStatistics(): NoteStatistics {
(store) =>
(store.activeEditorId &&
store.editors[store.activeEditorId]?.statistics) || {
words: { total: 0 }
words: { total: 0 },
characters: { total: 0 },
paragraphs: { total: 0 },
spaces: { total: 0 }
}
);
}

View File

@@ -37,7 +37,8 @@ import {
Attachment,
getTableOfContents,
getChangedNodes,
LinkAttributes
LinkAttributes,
type Selection
} from "@notesnook/editor";
import { Box, Flex } from "@theme-ui/components";
import {
@@ -107,19 +108,51 @@ type TipTapProps = {
fontLigatures: boolean;
};
function updateWordCount(id: string, content: () => Fragment) {
function countCharacters(text: string) {
return text.length;
}
function countParagraphs(fragment: Fragment) {
let count = 0;
fragment.nodesBetween(0, fragment.size, (node) => {
if (node.type.name === "paragraph") {
count++;
}
return true;
});
return count;
}
function countSpaces(text: string) {
return (text.match(/ /g) || []).length;
}
function updateNoteStatistics(id: string, content: () => Fragment) {
const fragment = content();
const documentText = fragment.textBetween(0, fragment.size, "\n", " ");
useEditorManager.getState().updateEditor(id, {
statistics: {
words: {
total: countWords(fragment.textBetween(0, fragment.size, "\n", " ")),
total: countWords(documentText),
selected: 0
},
characters: {
total: countCharacters(removeNewlineCharacters(documentText)),
selected: 0
},
paragraphs: {
total: countParagraphs(fragment),
selected: 0
},
spaces: {
total: countSpaces(documentText),
selected: 0
}
}
});
}
const deferredUpdateWordCount = debounce(updateWordCount, 1000);
const deferredUpdateNoteStatistics = debounce(updateNoteStatistics, 1000);
function TipTap(props: TipTapProps) {
const {
@@ -222,6 +255,18 @@ function TipTap(props: TipTapProps) {
words: {
total: totalWords,
selected: 0
},
characters: {
total: countCharacters(editor.state.doc.textContent),
selected: 0
},
paragraphs: {
total: countParagraphs(editor.state.doc.content),
selected: 0
},
spaces: {
total: countSpaces(editor.state.doc.textContent),
selected: 0
}
},
tableOfContents: getTableOfContents(editor.view.dom)
@@ -240,7 +285,7 @@ function TipTap(props: TipTapProps) {
onContentChange?.();
deferredUpdateWordCount(id, () => editor.state.doc.content);
deferredUpdateNoteStatistics(id, () => editor.state.doc.content);
const preventSave = transaction?.getMeta("preventSave") as boolean;
const ignoreEdit = transaction.getMeta("ignoreEdit") as boolean;
@@ -274,22 +319,63 @@ function TipTap(props: TipTapProps) {
useEditorManager.getState().updateEditor(id, (old) => {
const oldSelected = old.statistics?.words?.selected;
const oldWords = old.statistics?.words.total || 0;
if (isEmptySelection)
const oldParagraphs = old.statistics?.paragraphs.total || 0;
const oldSpaces = old.statistics?.spaces.total || 0;
const oldCharacters = old.statistics?.characters.total || 0;
if (isEmptySelection) {
return oldSelected
? {
statistics: { words: { total: oldWords, selected: 0 } }
statistics: {
words: { total: oldWords, selected: 0 },
characters: {
total: oldCharacters,
selected: 0
},
paragraphs: {
total: oldParagraphs,
selected: 0
},
spaces: {
total: oldSpaces,
selected: 0
}
}
}
: old;
}
const selectedWords = getSelectedWords(
const selectedText = editor.state.doc.textBetween(
transaction.selection.from,
transaction.selection.to,
"\n",
" "
);
const selectedWords = countWords(selectedText);
const selectedSpaces = countSpaces(selectedText);
const selectedParagraphs = getSelectedParagraphs(
editor as Editor,
transaction.selection
);
const selectedCharacters = countCharacters(
removeNewlineCharacters(selectedText)
);
return {
statistics: {
words: {
total: oldWords,
selected: selectedWords
},
characters: {
total: oldCharacters,
selected: selectedCharacters
},
paragraphs: {
total: oldParagraphs,
selected: selectedParagraphs
},
spaces: {
total: oldSpaces,
selected: selectedSpaces
}
}
};
@@ -620,12 +706,17 @@ function toIEditor(editor: Editor): IEditor {
};
}
function getSelectedWords(
editor: Editor,
selection: { from: number; to: number; empty: boolean }
): number {
const selectedText = selection.empty
? ""
: editor.state.doc.textBetween(selection.from, selection.to, "\n", " ");
return countWords(selectedText);
function getSelectedParagraphs(editor: Editor, selection: Selection): number {
let count = 0;
editor.state.doc.nodesBetween(selection.from, selection.to, (node) => {
if (node.type.name === "paragraph") {
count++;
}
return true;
});
return count;
}
function removeNewlineCharacters(text: string) {
return text.replaceAll(/\n/g, "");
}

View File

@@ -26,6 +26,18 @@ export type NoteStatistics = {
total: number;
selected?: number;
};
characters: {
total: number;
selected?: number;
};
paragraphs: {
total: number;
selected?: number;
};
spaces: {
total: number;
selected?: number;
};
};
export interface IEditor {

View File

@@ -438,3 +438,4 @@ export {
};
export { replaceDateTime } from "./extensions/date-time/index.js";
export type * from "./extension-imports.js";
export { type Selection } from "@tiptap/pm/state";

View File

@@ -6,6 +6,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/strings.ts:2410
msgid " \"Notebook > Notes\""
@@ -1340,6 +1346,10 @@ msgstr "Changes from other devices won't be updated in the editor in real-time."
msgid "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection."
msgstr "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection."
#: src/strings.ts:2490
msgid "Characters"
msgstr "Characters"
#: src/strings.ts:1295
msgid "Check for new version of Notesnook"
msgstr "Check for new version of Notesnook"
@@ -4256,6 +4266,10 @@ msgstr "Outline list"
msgid "Paragraph"
msgstr "Paragraph"
#: src/strings.ts:2491
msgid "Paragraphs"
msgstr "Paragraphs"
#: src/strings.ts:2100
msgid "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection."
msgstr "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection."
@@ -6837,6 +6851,10 @@ msgstr "What went wrong?"
msgid "Width"
msgstr "Width"
#: src/strings.ts:2489
msgid "Words"
msgstr "Words"
#: src/strings.ts:2412
msgid "Work & Office"
msgstr "Work & Office"

View File

@@ -6,6 +6,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: pseudo-LOCALE\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/strings.ts:2410
msgid " \"Notebook > Notes\""
@@ -1340,6 +1346,10 @@ msgstr ""
msgid "Changing password is an irreversible process. You will be logged out from all your devices. Please make sure you do not close the app while your password is changing and have good internet connection."
msgstr ""
#: src/strings.ts:2490
msgid "Characters"
msgstr ""
#: src/strings.ts:1295
msgid "Check for new version of Notesnook"
msgstr ""
@@ -4230,6 +4240,10 @@ msgstr ""
msgid "Paragraph"
msgstr ""
#: src/strings.ts:2491
msgid "Paragraphs"
msgstr ""
#: src/strings.ts:2100
msgid "Partial backups contain all your data except attachments. They are created from data already available on your device and do not require an Internet connection."
msgstr ""
@@ -6788,6 +6802,10 @@ msgstr ""
msgid "Width"
msgstr ""
#: src/strings.ts:2489
msgid "Words"
msgstr ""
#: src/strings.ts:2412
msgid "Work & Office"
msgstr ""

View File

@@ -2485,5 +2485,8 @@ Use this if changes from other devices are not appearing on this device. This wi
yourArchiveIsEmpty: () => t`Your archive is empty`,
unarchive: () => t`Unarchive`,
moveNotebookDesc: () =>
t`Select a notebook to move this notebook into, or unselect to move it to the root level.`
t`Select a notebook to move this notebook into, or unselect to move it to the root level.`,
words: () => t`Words`,
characters: () => t`Characters`,
paragraphs: () => t`Paragraphs`
};