diff --git a/packages/koenig-lexical/demo/DemoApp.jsx b/packages/koenig-lexical/demo/DemoApp.jsx
index eb86e4502b..816631c151 100644
--- a/packages/koenig-lexical/demo/DemoApp.jsx
+++ b/packages/koenig-lexical/demo/DemoApp.jsx
@@ -17,7 +17,7 @@ import {$getRoot, $isDecoratorNode} from 'lexical';
import {
BASIC_NODES, BASIC_TRANSFORMERS, EmailEditor,
KoenigComposableEditor, KoenigComposer, KoenigEditor, MINIMAL_NODES,
- MINIMAL_TRANSFORMERS, RestrictContentPlugin, TKCountPlugin, WordCountPlugin
+ MINIMAL_TRANSFORMERS, RestrictContentPlugin, SelectionWordCountPlugin, TKCountPlugin, WordCountPlugin
} from '../src';
import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig';
import {fetchEmbed} from './utils/fetchEmbed';
@@ -131,7 +131,7 @@ function getAllowedNodes({editorType}) {
return undefined;
}
-function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setWordCount, setTKCount}) {
+function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setWordCount, setSelectionWordCount, setTKCount}) {
if (editorType === 'basic') {
return (
+
);
} else if (editorType === 'minimal') {
@@ -152,6 +153,7 @@ function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setW
>
+
);
}
@@ -163,12 +165,13 @@ function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setW
registerAPI={registerAPI}
>
+
);
}
-function DemoComposer({editorType, isMultiplayer, setWordCount, setTKCount}) {
+function DemoComposer({editorType, isMultiplayer, setWordCount, setSelectionWordCount, setTKCount}) {
const [searchParams, setSearchParams] = useSearchParams();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [sidebarView, setSidebarView] = useState('json');
@@ -405,6 +408,7 @@ function DemoComposer({editorType, isMultiplayer, setWordCount, setTKCount}) {
darkMode={darkMode}
editorType={editorType}
registerAPI={setEditorAPI}
+ setSelectionWordCount={setSelectionWordCount}
setTKCount={setTKCount}
setWordCount={setWordCount}
/>
@@ -418,6 +422,7 @@ const MemoizedDemoComposer = React.memo(DemoComposer);
function DemoApp({editorType, isMultiplayer}) {
const [wordCount, setWordCount] = useState(0);
+ const [selectionWordCount, setSelectionWordCount] = useState(null);
const [tkCount, setTKCount] = useState(0);
// used to force a re-initialization of the editor when URL changes, otherwise
@@ -430,11 +435,12 @@ function DemoApp({editorType, isMultiplayer}) {
className={`koenig-lexical top`}
>
{/* outside of DemoComposer to avoid re-renders and flaky tests when word count changes */}
-
+
diff --git a/packages/koenig-lexical/demo/components/WordCount.jsx b/packages/koenig-lexical/demo/components/WordCount.jsx
index 2ad85f1476..44d3865816 100644
--- a/packages/koenig-lexical/demo/components/WordCount.jsx
+++ b/packages/koenig-lexical/demo/components/WordCount.jsx
@@ -1,6 +1,12 @@
-const WordCount = ({wordCount, tkCount}) => {
+const WordCount = ({wordCount, selectionWordCount, tkCount}) => {
return (
+ {selectionWordCount !== null && (
+ <>
+ {selectionWordCount}
+ {' of '}
+ >
+ )}
{wordCount} words
{tkCount > 0 && (
<>
diff --git a/packages/koenig-lexical/src/components/KoenigComposer.jsx b/packages/koenig-lexical/src/components/KoenigComposer.jsx
index 1174d63d15..d8dd809aae 100644
--- a/packages/koenig-lexical/src/components/KoenigComposer.jsx
+++ b/packages/koenig-lexical/src/components/KoenigComposer.jsx
@@ -70,6 +70,11 @@ const KoenigComposer = ({
const editorContainerRef = React.useRef(null);
const onWordCountChangeRef = React.useRef(null);
+ const onSelectionWordCountChangeRef = React.useRef(null);
+ const selectionWordCountsRef = React.useRef(new Map());
+ // starts as undefined (not null) so SelectionWordCountPlugin's first
+ // emission always fires, including the initial no-selection null
+ const lastEmittedSelectionWordCountRef = React.useRef(undefined);
if (!fileUploader.useFileUpload) {
fileUploader.useFileUpload = function () {
@@ -118,7 +123,10 @@ const KoenigComposer = ({
multiplayerDocId,
multiplayerUsername,
createWebsocketProvider,
- onWordCountChangeRef
+ onWordCountChangeRef,
+ onSelectionWordCountChangeRef,
+ selectionWordCountsRef,
+ lastEmittedSelectionWordCountRef
}}>
diff --git a/packages/koenig-lexical/src/components/KoenigNestedComposer.jsx b/packages/koenig-lexical/src/components/KoenigNestedComposer.jsx
index 193cde91ff..00d8e445ce 100644
--- a/packages/koenig-lexical/src/components/KoenigNestedComposer.jsx
+++ b/packages/koenig-lexical/src/components/KoenigNestedComposer.jsx
@@ -1,5 +1,6 @@
import KoenigComposerContext from '../context/KoenigComposerContext';
import React from 'react';
+import SelectionWordCountPlugin from '../plugins/SelectionWordCountPlugin';
import WordCountPlugin from '../plugins/WordCountPlugin';
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer';
@@ -7,7 +8,7 @@ import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContex
const KoenigNestedComposer = ({initialEditor, initialEditorState, initialNodes, initialTheme, skipCollabChecks, children} = {}) => {
const {isCollabActive} = useCollaborationContext();
- const {createWebsocketProvider, onWordCountChangeRef} = React.useContext(KoenigComposerContext);
+ const {createWebsocketProvider, onWordCountChangeRef, onSelectionWordCountChangeRef} = React.useContext(KoenigComposerContext);
return (
) : null}
+ {onSelectionWordCountChangeRef?.current ? (
+
+ ) : null}
{children}
);
diff --git a/packages/koenig-lexical/src/index.js b/packages/koenig-lexical/src/index.js
index 798dbd61ff..f30f1f05da 100644
--- a/packages/koenig-lexical/src/index.js
+++ b/packages/koenig-lexical/src/index.js
@@ -39,6 +39,7 @@ import PlusCardMenuPlugin from './plugins/PlusCardMenuPlugin';
import ProductPlugin from './plugins/ProductPlugin';
import ReplacementStringsPlugin from './plugins/ReplacementStringsPlugin';
import RestrictContentPlugin from './plugins/RestrictContentPlugin';
+import SelectionWordCountPlugin from './plugins/SelectionWordCountPlugin';
import SignupPlugin from './plugins/SignupPlugin';
import SlashCardMenuPlugin from './plugins/SlashCardMenuPlugin';
import TKCountPlugin from './plugins/TKCountPlugin';
@@ -112,6 +113,7 @@ export {
ProductPlugin,
ReplacementStringsPlugin,
RestrictContentPlugin,
+ SelectionWordCountPlugin,
SignupPlugin,
SlashCardMenuPlugin,
TKCountPlugin,
diff --git a/packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx b/packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx
new file mode 100644
index 0000000000..8d179104ec
--- /dev/null
+++ b/packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx
@@ -0,0 +1,92 @@
+import KoenigComposerContext from '../context/KoenigComposerContext';
+import React from 'react';
+import throttle from 'lodash/throttle';
+import {$getSelection, $isRangeSelection} from 'lexical';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {utils} from '@tryghost/helpers';
+
+const {countWords} = utils;
+
+// Reports the selection's word count: an integer (0 allowed) while a
+// non-collapsed range selection exists, null otherwise.
+export const SelectionWordCountPlugin = ({onChange} = {}) => {
+ const [editor] = useLexicalComposerContext();
+ const {onSelectionWordCountChangeRef, selectionWordCountsRef, lastEmittedSelectionWordCountRef} = React.useContext(KoenigComposerContext);
+
+ React.useLayoutEffect(() => {
+ if (!onChange) {
+ return;
+ }
+
+ // store onChange in context so that KoenigNestedComposer can render
+ // a nested without passing onChange down
+ if (!editor._parentEditor) {
+ onSelectionWordCountChangeRef.current = onChange;
+ }
+
+ // a selection lives in exactly one editor at a time but a plugin
+ // instance exists per editor (main + nested caption editors). Each
+ // instance writes its own editor's selection count into the shared
+ // map and emits the combined value so that the emission order
+ // between editor instances doesn't matter
+ const counts = selectionWordCountsRef.current;
+
+ const emitCombined = () => {
+ let combined = null;
+
+ for (const count of counts.values()) {
+ if (count !== null) {
+ combined = count;
+ break;
+ }
+ }
+
+ // dedup lives on the composer-level ref rather than in this
+ // closure so that no instance re-emits a value another
+ // instance has already delivered
+ if (combined !== lastEmittedSelectionWordCountRef.current) {
+ lastEmittedSelectionWordCountRef.current = combined;
+ onChange(combined);
+ }
+ };
+
+ const updateSelectionCount = () => {
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection) && !selection.isCollapsed()) {
+ counts.set(editor.getKey(), countWords(selection.getTextContent()));
+ } else {
+ counts.set(editor.getKey(), null);
+ }
+ });
+
+ emitCombined();
+ };
+
+ updateSelectionCount();
+
+ const throttledUpdate = throttle(updateSelectionCount, 200);
+
+ // update listeners fire after every commit, including selection-only
+ // changes, so a SELECTION_CHANGE_COMMAND handler isn't needed
+ const cleanupRegister = editor.registerUpdateListener(() => {
+ throttledUpdate();
+ });
+
+ return () => {
+ throttledUpdate.cancel();
+ cleanupRegister();
+ counts.delete(editor.getKey());
+ // an unmounting editor may hold the active selection (e.g. a
+ // card deleted mid-edit) so re-emit to drop its count
+ emitCombined();
+
+ if (!editor._parentEditor) {
+ onSelectionWordCountChangeRef.current = null;
+ }
+ };
+ }, [editor, onChange, onSelectionWordCountChangeRef, selectionWordCountsRef, lastEmittedSelectionWordCountRef]);
+};
+
+export default SelectionWordCountPlugin;
diff --git a/packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js
new file mode 100644
index 0000000000..0008de00ef
--- /dev/null
+++ b/packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js
@@ -0,0 +1,118 @@
+import {ctrlOrCmd, focusEditor, initialize, insertCard, selectBackwards} from '../../utils/e2e';
+import {expect, test} from '@playwright/test';
+
+test.describe('Selection Word Count Plugin', async function () {
+ let page;
+
+ test.beforeAll(async ({browser}) => {
+ page = await browser.newPage();
+ });
+
+ test.beforeEach(async () => {
+ await initialize({page});
+ });
+
+ test.afterAll(async () => {
+ await page.close();
+ });
+
+ test('shows no selection count without a selection', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello beautiful world');
+ await expect(page.getByTestId('word-count')).toHaveText('3');
+ await expect(page.getByTestId('selection-word-count')).not.toBeVisible();
+ });
+
+ test('shows selection count for selected text', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello beautiful world');
+ await selectBackwards(page, 5); // selects "world"
+ await expect(page.getByTestId('selection-word-count')).toHaveText('1');
+ // total stays visible alongside the selection count
+ await expect(page.getByTestId('word-count')).toHaveText('3');
+
+ // extend across a word boundary; selects "beautiful world"
+ await selectBackwards(page, 10);
+ await expect(page.getByTestId('selection-word-count')).toHaveText('2');
+ });
+
+ test('shows 0 for a whitespace-only selection', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello world');
+ for (let i = 0; i < 5; i++) {
+ await page.keyboard.press('ArrowLeft');
+ }
+
+ // Chrome for Testing occasionally drops the programmatic selection;
+ // retry until the whitespace-only selection registers and gets counted
+ await expect(async () => {
+ if (!(await page.evaluate(() => window.getSelection().isCollapsed))) {
+ // a previous attempt selected; collapse back to just before "world"
+ await page.keyboard.press('ArrowRight');
+ }
+ await selectBackwards(page, 1); // selects the space between the words
+ await expect(page.getByTestId('selection-word-count')).toHaveText('0', {timeout: 1000});
+ }).toPass();
+ });
+
+ test('counts partial word fragments as words', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello world');
+ await selectBackwards(page, 3); // selects "rld"
+ await expect(page.getByTestId('selection-word-count')).toHaveText('1');
+ });
+
+ test('reverts to total-only display when selection collapses', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello world');
+ await selectBackwards(page, 5);
+ await expect(page.getByTestId('selection-word-count')).toHaveText('1');
+ await page.keyboard.press('ArrowRight'); // collapse the selection
+ await expect(page.getByTestId('selection-word-count')).not.toBeVisible();
+ await expect(page.getByTestId('word-count')).toHaveText('2');
+ });
+
+ test('counts selection inside a nested editor', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello world');
+ await page.keyboard.press('Enter');
+ await insertCard(page, {cardName: 'callout'});
+ await page.keyboard.type('Nested content here');
+ await expect(page.getByTestId('word-count')).toHaveText('5');
+ await selectBackwards(page, 12); // selects "content here"
+ await expect(page.getByTestId('selection-word-count')).toHaveText('2');
+ });
+
+ test('moving selection from nested editor back to main editor reports main selection', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello world');
+ await page.keyboard.press('Enter');
+ await insertCard(page, {cardName: 'callout'});
+ await page.keyboard.type('Nested content here');
+ await selectBackwards(page, 4); // selects "here" in the nested editor
+ await expect(page.getByTestId('selection-word-count')).toHaveText('1');
+
+ // click into the first paragraph and select "Hello world" there
+ await page.locator('[data-lexical-editor] > p').first().click();
+ await page.keyboard.press('End');
+ await selectBackwards(page, 11);
+ await expect(page.getByTestId('selection-word-count')).toHaveText('2');
+
+ // collapse — counter must revert to total-only, with no stale nested count
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByTestId('selection-word-count')).not.toBeVisible();
+ });
+
+ test('select-all across a card matches the document total', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('Hello world');
+ await page.keyboard.press('Enter');
+ await insertCard(page, {cardName: 'callout'});
+ await page.keyboard.type('Nested content here');
+ await page.keyboard.press('Escape'); // exit nested editing, card selected
+ await page.keyboard.press('ArrowDown'); // cursor into main editor below card
+ await page.keyboard.press(`${ctrlOrCmd(page)}+KeyA`);
+ await expect(page.getByTestId('word-count')).toHaveText('5');
+ await expect(page.getByTestId('selection-word-count')).toHaveText('5');
+ });
+});