Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/components/editor/MarkdownSourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const syncingRef = useRef(false);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;

useEffect(() => {
if (!hostRef.current) return;
Expand All @@ -91,13 +100,36 @@ 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(),
});
}
}
}),
],
}),
});
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;
Expand Down
80 changes: 68 additions & 12 deletions src/components/editor/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -74,6 +75,7 @@ export const NoteEditor = memo(function NoteEditor({
const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null);
const [linkPicker, setLinkPicker] = useState<LinkPickerState | null>(null);
const [words, setWords] = useState(0);
const [selectedWords, setSelectedWords] = useState(0);
const [missing, setMissing] = useState(false);
const [properties, setProperties] = useState<Property[]>([]);
const [propertyAddRequest, setPropertyAddRequest] = useState(0);
Expand All @@ -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("");
Expand Down Expand Up @@ -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);
},
},
Expand All @@ -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],
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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).
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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));
}}
/>
</Suspense>
) : (
Expand Down Expand Up @@ -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"}`}
</div>
)}
{active && publishSnapshot && (
Expand All @@ -841,8 +902,3 @@ export const NoteEditor = memo(function NoteEditor({
});

type EditorInstance = ReturnType<typeof useEditor>;

function countWords(markdown: string) {
const text = markdown.trim();
return text ? text.split(/\s+/).filter(Boolean).length : 0;
}
75 changes: 75 additions & 0 deletions src/lib/wordCount.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading