diff --git a/src/components/editor/MarkdownSourceEditor.tsx b/src/components/editor/MarkdownSourceEditor.tsx index 8f9fbfc..d5bf640 100644 --- a/src/components/editor/MarkdownSourceEditor.tsx +++ b/src/components/editor/MarkdownSourceEditor.tsx @@ -60,16 +60,25 @@ export function MarkdownSourceEditor({ value, onChange, selection, + onSelectionChange, }: { value: string; onChange: (value: string) => void; selection?: { from: number; to: number; nonce: number } | null; + onSelectionChange?: (range: { + from: number; + to: number; + text: string; + docText: string; + }) => void; }) { const hostRef = useRef(null); const viewRef = useRef(null); const syncingRef = useRef(false); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; useEffect(() => { if (!hostRef.current) return; @@ -91,6 +100,20 @@ export function MarkdownSourceEditor({ if (update.docChanged && !syncingRef.current) { onChangeRef.current(update.state.doc.toString()); } + if (update.selectionSet || update.docChanged) { + if (onSelectionChangeRef.current) { + const sel = update.state.selection.main; + const text = sel.empty + ? "" + : update.state.sliceDoc(sel.from, sel.to); + onSelectionChangeRef.current({ + from: sel.from, + to: sel.to, + text, + docText: update.state.doc.toString(), + }); + } + } }), ], }), @@ -98,6 +121,15 @@ export function MarkdownSourceEditor({ viewRef.current = view; view.focus(); + // Report initial selection on mount (empty selection) so stale selection counts from other modes are cleared. + const initialSel = view.state.selection.main; + onSelectionChangeRef.current?.({ + from: initialSel.from, + to: initialSel.to, + text: initialSel.empty ? "" : view.state.sliceDoc(initialSel.from, initialSel.to), + docText: view.state.doc.toString(), + }); + return () => { view.destroy(); viewRef.current = null; diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index 8986e55..492f004 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -24,6 +24,7 @@ import { } from "@/lib/markdownPaste"; import { flattenNotes } from "@/lib/tree"; import { cx, debounce, noteTitle } from "@/lib/utils"; +import { countWords, countMarkdownWords, extractBodySelection } from "@/lib/wordCount"; import { useTabs } from "@/stores/tabs"; import { useUi } from "@/stores/ui"; import { useVault } from "@/stores/vault"; @@ -74,6 +75,7 @@ export const NoteEditor = memo(function NoteEditor({ const [slashMenu, setSlashMenu] = useState(null); const [linkPicker, setLinkPicker] = useState(null); const [words, setWords] = useState(0); + const [selectedWords, setSelectedWords] = useState(0); const [missing, setMissing] = useState(false); const [properties, setProperties] = useState([]); const [propertyAddRequest, setPropertyAddRequest] = useState(0); @@ -86,6 +88,8 @@ export const NoteEditor = memo(function NoteEditor({ } | null>(null); const relRef = useRef(rel); + const markdownSourceRef = useRef(markdownSource); + markdownSourceRef.current = markdownSource; // Frontmatter of the loaded note, kept out of the editor and re-attached on // save so the file's metadata survives round-trips. const frontmatter = useRef(""); @@ -346,11 +350,31 @@ export const NoteEditor = memo(function NoteEditor({ pending.current = markdown; setSaveState("saving"); debouncedPersist(markdown); - setWords(countWords(markdown)); + const fullText = editor.state.doc.textBetween( + 0, + editor.state.doc.content.size, + " ", + " ", + ); + setWords(countWords(fullText)); + const sel = editor.state.selection; + if (sel.empty) { + setSelectedWords(0); + } else { + const selectedText = editor.state.doc.textBetween(sel.from, sel.to, " ", " "); + setSelectedWords(countWords(selectedText)); + } setContentVersion((value) => value + 1); updateSlashMenu(editor); }, onSelectionUpdate({ editor }) { + const sel = editor.state.selection; + if (sel.empty) { + setSelectedWords(0); + } else { + const selectedText = editor.state.doc.textBetween(sel.from, sel.to, " ", " "); + setSelectedWords(countWords(selectedText)); + } updateSlashMenu(editor); }, }, @@ -367,7 +391,7 @@ export const NoteEditor = memo(function NoteEditor({ pending.current = body; setSaveState("saving"); debouncedPersist(body); - setWords(countWords(body)); + setWords(countMarkdownWords(body)); setContentVersion((value) => value + 1); }, [debouncedPersist, setSaveState], @@ -453,7 +477,19 @@ export const NoteEditor = memo(function NoteEditor({ }); swapping.current = false; setLoadNonce((value) => value + 1); - setWords(countWords(body)); + setWords( + markdownSourceRef.current + ? countMarkdownWords(body) + : countWords( + editor.state.doc.textBetween( + 0, + editor.state.doc.content.size, + " ", + " ", + ), + ), + ); + setSelectedWords(0); // A freshly loaded note starts at the top. savedScroll.current = 0; if (scrollRef.current) scrollRef.current.scrollTop = 0; @@ -479,9 +515,12 @@ export const NoteEditor = memo(function NoteEditor({ useEffect(() => { if (!editor || previousSource.current === markdownSource) return; previousSource.current = markdownSource; + setSelectedWords(0); if (markdownSource) { - setRawText(joinFrontmatter(frontmatter.current, editor.getMarkdown())); + const fullMarkdown = joinFrontmatter(frontmatter.current, editor.getMarkdown()); + setRawText(fullMarkdown); + setWords(countMarkdownWords(splitFrontmatter(fullMarkdown).body)); return; } @@ -494,6 +533,11 @@ export const NoteEditor = memo(function NoteEditor({ contentType: "markdown", }); swapping.current = false; + setWords( + countWords( + editor.state.doc.textBetween(0, editor.state.doc.content.size, " ", " "), + ), + ); }, [editor, markdownSource, rawText]); // Flush any unsaved edit when the editor unmounts (tab closed / view gone). @@ -526,12 +570,23 @@ export const NoteEditor = memo(function NoteEditor({ }); swapping.current = false; setLoadNonce((value) => value + 1); - setWords(countWords(body)); + setWords( + markdownSourceRef.current + ? countMarkdownWords(body) + : countWords( + editor.state.doc.textBetween( + 0, + editor.state.doc.content.size, + " ", + " ", + ), + ), + ); } } catch { // note may have been deleted externally; tree refresh handles it } - }, [editor]); + }, [editor, markdownSource]); useEffect(() => { const onFocus = () => { @@ -774,6 +829,10 @@ export const NoteEditor = memo(function NoteEditor({ value={rawText} onChange={applyRawTextChange} selection={noteFind.sourceSelection} + onSelectionChange={({ from, to, docText }) => { + const selectedText = extractBodySelection(docText, from, to); + setSelectedWords(countMarkdownWords(selectedText)); + }} /> ) : ( @@ -825,7 +884,9 @@ export const NoteEditor = memo(function NoteEditor({ (words === 0 || missing) && "opacity-0", )} > - {words} {words === 1 ? "word" : "words"} + {selectedWords > 0 + ? `${selectedWords} of ${words} ${words === 1 ? "word" : "words"}` + : `${words} ${words === 1 ? "word" : "words"}`} )} {active && publishSnapshot && ( @@ -841,8 +902,3 @@ export const NoteEditor = memo(function NoteEditor({ }); type EditorInstance = ReturnType; - -function countWords(markdown: string) { - const text = markdown.trim(); - return text ? text.split(/\s+/).filter(Boolean).length : 0; -} diff --git a/src/lib/wordCount.ts b/src/lib/wordCount.ts new file mode 100644 index 0000000..da74ecb --- /dev/null +++ b/src/lib/wordCount.ts @@ -0,0 +1,75 @@ +import { splitFrontmatter } from "./frontmatter"; + +/** + * Strips common Markdown formatting syntax so words inside prose are counted + * without syntax tokens (like headings `#`, bullets `-`, bold/italic `**`, etc.) + * inflating the word count. + */ +export function stripMarkdownSyntax(markdown: string): string { + return ( + markdown + // Code fence markers + .replace(/^```[a-zA-Z0-9_-]*\s*$/gm, "") + // Headings (including those nested inside blockquotes) + .replace(/^(?:(?:\s*>\s*)+)?#{1,6}\s+/gm, "") + // Blockquotes (including nested e.g. >>, > >) + .replace(/^(?:\s*>\s*)+/gm, "") + // Unordered and task lists + .replace(/^(\s*[-*+]\s*(\[[ xX]\]\s*)?)/gm, "") + // Ordered lists + .replace(/^(\s*\d+\.\s*)/gm, "") + // Wiki links [[note|alias]] -> alias, [[note]] -> note (must precede table pipe replacement) + .replace(/\[\[(?:[^|\]]*\|)?([^\]]+)\]\]/g, "$1") + // Images ![alt](url) -> alt + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + // Markdown links [text](url) -> text + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + // Table delimiter rows (e.g. | --- | :---: | ---: |) + .replace(/^[\s|:-]+$/gm, "") + // Table pipe delimiters -> replace with space so column boundaries separate words + .replace(/\|/g, " ") + // Horizontal rules + .replace(/^[-*_]{3,}\s*$/gm, "") + // Inline code `code` -> code + .replace(/`([^`]+)`/g, "$1") + // Bold, italic, strikethrough: **text**, *text*, __text__, _text_, ~~text~~ + .replace(/(\*\*|__|\*|_|~~)(.*?)\1/g, "$2") + ); +} + +/** + * Counts words in a plain or rendered text string by whitespace. + */ +export function countWords(text: string): number { + const trimmed = text.trim(); + return trimmed ? trimmed.split(/\s+/).filter(Boolean).length : 0; +} + +/** + * Counts prose words in a raw markdown string, ignoring Markdown syntax formatting. + */ +export function countMarkdownWords(markdown: string): number { + return countWords(stripMarkdownSyntax(markdown)); +} + +/** + * Given the entire raw markdown note and a selection range [from, to], + * extracts only the selected text portion that falls within the note's body, + * excluding any frontmatter block. + */ +export function extractBodySelection( + rawText: string, + from: number, + to: number, +): string { + if (from >= to) return ""; + const { frontmatter } = splitFrontmatter(rawText); + const bodyStart = frontmatter.length; + + // Selection is entirely within frontmatter + if (to <= bodyStart) return ""; + + // Selection starts inside frontmatter but extends into body + const effectiveFrom = Math.max(from, bodyStart); + return rawText.slice(effectiveFrom, to); +} diff --git a/tests/wordCount.test.ts b/tests/wordCount.test.ts new file mode 100644 index 0000000..2957805 --- /dev/null +++ b/tests/wordCount.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import { + countMarkdownWords, + countWords, + extractBodySelection, + stripMarkdownSyntax, +} from "../src/lib/wordCount"; + +describe("word counting", () => { + test("counts simple prose words", () => { + expect(countWords("Hello world")).toBe(2); + expect(countWords("One two three four five")).toBe(5); + expect(countWords("")).toBe(0); + expect(countWords(" \n\t ")).toBe(0); + }); + + test("counts rendered prose words preserving literal symbols", () => { + // In rich-text mode, `# hello` from inline code is rendered text + expect(countWords("# hello")).toBe(2); + expect(countWords("> quote")).toBe(2); + }); + + test("excludes markdown headings from raw markdown word count", () => { + expect(countMarkdownWords("# Hello world")).toBe(2); + expect(countMarkdownWords("## Subtitle")).toBe(1); + expect(countMarkdownWords("###### Deep heading with multiple words")).toBe(5); + expect(countMarkdownWords("> # Hello world")).toBe(2); + expect(countMarkdownWords(">> # Hello world")).toBe(2); + }); + + test("excludes list markers and blockquotes in raw markdown", () => { + expect(countMarkdownWords("- Item one\n- Item two")).toBe(4); + expect(countMarkdownWords("* Star item")).toBe(2); + expect(countMarkdownWords("- [ ] Unchecked todo\n- [x] Done todo")).toBe(4); + expect(countMarkdownWords("1. First item\n2. Second item")).toBe(4); + expect(countMarkdownWords("> Blockquote line")).toBe(2); + expect(countMarkdownWords(">> Nested blockquote")).toBe(2); + expect(countMarkdownWords("> > Nested blockquote with spaces")).toBe(4); + expect(countMarkdownWords(">>> Triple nested blockquote")).toBe(3); + }); + + test("excludes table syntax and delimiter rows from raw markdown word count", () => { + const tableWithBorders = [ + "| Name | Value |", + "| --- | --- |", + "| Alice | Ready |", + ].join("\n"); + expect(countMarkdownWords(tableWithBorders)).toBe(4); + + const tableWithoutBorders = [ + "Name | Value", + "--- | ---", + "Alice | Ready", + ].join("\n"); + expect(countMarkdownWords(tableWithoutBorders)).toBe(4); + }); + + test("handles links, inline code, and formatting markers in raw markdown", () => { + expect(countMarkdownWords("[Markd app](https://markd.app)")).toBe(2); + expect(countMarkdownWords("![alt text](https://example.com/image.png)")).toBe(2); + expect(countMarkdownWords("[[Daily Note|Today's Note]]")).toBe(2); + expect(countMarkdownWords("[[SimpleNote]]")).toBe(1); + expect(countMarkdownWords("**bold** *italic* ~~strike~~ `code`")).toBe(4); + expect(countMarkdownWords("`# hello`")).toBe(2); + }); +}); + +describe("extractBodySelection", () => { + const doc = [ + "---", + 'title: "Example"', + "status: done", + "---", + "", + "Hello world", + "Second line", + ].join("\n"); + + test("returns empty string when selection is entirely within frontmatter", () => { + // Select "title: "Example"" + const from = doc.indexOf("title"); + const to = from + 16; + expect(extractBodySelection(doc, from, to)).toBe(""); + expect(countWords(extractBodySelection(doc, from, to))).toBe(0); + }); + + test("returns only body portion when selection spans across frontmatter and body", () => { + // Select from status to "Hello" + const from = doc.indexOf("status"); + const to = doc.indexOf("Hello") + 5; + const extracted = extractBodySelection(doc, from, to); + expect(extracted).toBe("\nHello"); + expect(countWords(extracted)).toBe(1); + }); + + test("returns selected body text when selection is entirely in body", () => { + const from = doc.indexOf("Hello world"); + const to = from + "Hello world".length; + const extracted = extractBodySelection(doc, from, to); + expect(extracted).toBe("Hello world"); + expect(countWords(extracted)).toBe(2); + }); + + test("handles document without frontmatter", () => { + const noFm = "Simple body text without frontmatter"; + const from = noFm.indexOf("body"); + const to = from + 4; + expect(extractBodySelection(noFm, from, to)).toBe("body"); + expect(countWords(extractBodySelection(noFm, from, to))).toBe(1); + }); + + test("correctly extracts selection after document indentation (e.g. Tab in source mode)", () => { + // Initial document before indentation: "a b" + // After indenting with 2 spaces: " a b" + // Range [2, 5] in the indented document corresponds to "a b" + const indentedDoc = " a b"; + const from = 2; + const to = 5; + const extracted = extractBodySelection(indentedDoc, from, to); + expect(extracted).toBe("a b"); + expect(countWords(extracted)).toBe(2); + }); +}); + +describe("mode-dependent note loading and reloading", () => { + // A note containing an image: in rich-text mode the rendered image produces no prose words, + // whereas in source mode the markdown tokens (![alt text](url)) contribute the alt text words. + const noteBody = "Hello ![alt text](https://example.com/image.png)"; + const renderedProseText = "Hello"; + + test("calculates total words according to active editor mode on initial load", () => { + // When opened directly in source mode + const sourceTotal = countMarkdownWords(noteBody); + expect(sourceTotal).toBe(3); // "Hello", "alt", "text" + + // When opened in rich-text mode + const richTotal = countWords(renderedProseText); + expect(richTotal).toBe(1); // "Hello" + + // Verify source mode selection matches source total when selecting all + const allSelectedSource = extractBodySelection(noteBody, 0, noteBody.length); + expect(countMarkdownWords(allSelectedSource)).toBe(sourceTotal); + }); + + test("reloads external edits using the active mode rules after mode switch", () => { + // Simulate reloading an external edit after toggling between modes: + const reloadedBody = "Updated note with ![photo label](https://example.com/pic.png)"; + const reloadedRenderedText = "Updated note with"; + + // If active mode is source mode: + const sourceReloadWords = countMarkdownWords(reloadedBody); + expect(sourceReloadWords).toBe(5); // "Updated", "note", "with", "photo", "label" + + // If active mode is rich-text mode: + const richReloadWords = countWords(reloadedRenderedText); + expect(richReloadWords).toBe(3); // "Updated", "note", "with" + }); +}); + +