From 227f962a2f6ded0aaa45c4dec5f49ca4b9de45fc Mon Sep 17 00:00:00 2001 From: Kevin Feng Date: Wed, 10 Jun 2026 19:02:52 -0400 Subject: [PATCH 1/2] Added SelectionWordCountPlugin for selection-aware word counts --- packages/koenig-lexical/demo/DemoApp.jsx | 14 ++- .../demo/components/WordCount.jsx | 8 +- .../src/components/KoenigComposer.jsx | 6 +- packages/koenig-lexical/src/index.js | 2 + .../src/plugins/SelectionWordCountPlugin.jsx | 92 +++++++++++++++++++ .../plugins/SelectionWordCountPlugin.test.js | 72 +++++++++++++++ 6 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx create mode 100644 packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js 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..ad34f09b49 100644 --- a/packages/koenig-lexical/src/components/KoenigComposer.jsx +++ b/packages/koenig-lexical/src/components/KoenigComposer.jsx @@ -70,6 +70,8 @@ const KoenigComposer = ({ const editorContainerRef = React.useRef(null); const onWordCountChangeRef = React.useRef(null); + const onSelectionWordCountChangeRef = React.useRef(null); + const selectionWordCountsRef = React.useRef(new Map()); if (!fileUploader.useFileUpload) { fileUploader.useFileUpload = function () { @@ -118,7 +120,9 @@ const KoenigComposer = ({ multiplayerDocId, multiplayerUsername, createWebsocketProvider, - onWordCountChangeRef + onWordCountChangeRef, + onSelectionWordCountChangeRef, + selectionWordCountsRef }}> 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..39cf9eb85d --- /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, COMMAND_PRIORITY_LOW, SELECTION_CHANGE_COMMAND} from 'lexical'; +import {mergeRegister} from '@lexical/utils'; +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} = 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; + let lastEmitted; + + const emitCombined = () => { + let combined = null; + + for (const count of counts.values()) { + if (count !== null) { + combined = count; + break; + } + } + + if (combined !== lastEmitted) { + lastEmitted = 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); + + const cleanupRegister = mergeRegister( + editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { + throttledUpdate(); + return false; + }, COMMAND_PRIORITY_LOW), + editor.registerUpdateListener(() => { + throttledUpdate(); + }) + ); + + return () => { + throttledUpdate.cancel(); + cleanupRegister(); + counts.delete(editor.getKey()); + + if (!editor._parentEditor) { + onSelectionWordCountChangeRef.current = null; + } + }; + }, [editor, onChange, onSelectionWordCountChangeRef, selectionWordCountsRef]); +}; + +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..dc18647be8 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js @@ -0,0 +1,72 @@ +import {expect, test} from '@playwright/test'; +import {focusEditor, initialize, selectBackwards} from '../../utils/e2e'; + +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'); + }); + + 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 DOM reports a non-collapsed selection + 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 + expect(await page.evaluate(() => window.getSelection().isCollapsed)).toBe(false); + }).toPass(); + + await expect(page.getByTestId('selection-word-count')).toHaveText('0'); + }); + + 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'); + }); +}); From 017f74f01b6cb7c663025c17d49bbf1690fbb4d9 Mon Sep 17 00:00:00 2001 From: Kevin Feng Date: Thu, 11 Jun 2026 06:16:19 -0400 Subject: [PATCH 2/2] Added nested editor support to SelectionWordCountPlugin --- .../src/components/KoenigComposer.jsx | 6 +- .../src/components/KoenigNestedComposer.jsx | 6 +- .../src/plugins/SelectionWordCountPlugin.jsx | 32 +++++------ .../plugins/SelectionWordCountPlugin.test.js | 56 +++++++++++++++++-- 4 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/koenig-lexical/src/components/KoenigComposer.jsx b/packages/koenig-lexical/src/components/KoenigComposer.jsx index ad34f09b49..d8dd809aae 100644 --- a/packages/koenig-lexical/src/components/KoenigComposer.jsx +++ b/packages/koenig-lexical/src/components/KoenigComposer.jsx @@ -72,6 +72,9 @@ const KoenigComposer = ({ 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 () { @@ -122,7 +125,8 @@ const KoenigComposer = ({ createWebsocketProvider, onWordCountChangeRef, onSelectionWordCountChangeRef, - selectionWordCountsRef + 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/plugins/SelectionWordCountPlugin.jsx b/packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx index 39cf9eb85d..8d179104ec 100644 --- a/packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx +++ b/packages/koenig-lexical/src/plugins/SelectionWordCountPlugin.jsx @@ -1,8 +1,7 @@ import KoenigComposerContext from '../context/KoenigComposerContext'; import React from 'react'; import throttle from 'lodash/throttle'; -import {$getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, SELECTION_CHANGE_COMMAND} from 'lexical'; -import {mergeRegister} from '@lexical/utils'; +import {$getSelection, $isRangeSelection} from 'lexical'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {utils} from '@tryghost/helpers'; @@ -12,7 +11,7 @@ const {countWords} = utils; // non-collapsed range selection exists, null otherwise. export const SelectionWordCountPlugin = ({onChange} = {}) => { const [editor] = useLexicalComposerContext(); - const {onSelectionWordCountChangeRef, selectionWordCountsRef} = React.useContext(KoenigComposerContext); + const {onSelectionWordCountChangeRef, selectionWordCountsRef, lastEmittedSelectionWordCountRef} = React.useContext(KoenigComposerContext); React.useLayoutEffect(() => { if (!onChange) { @@ -31,7 +30,6 @@ export const SelectionWordCountPlugin = ({onChange} = {}) => { // map and emits the combined value so that the emission order // between editor instances doesn't matter const counts = selectionWordCountsRef.current; - let lastEmitted; const emitCombined = () => { let combined = null; @@ -43,8 +41,11 @@ export const SelectionWordCountPlugin = ({onChange} = {}) => { } } - if (combined !== lastEmitted) { - lastEmitted = combined; + // 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); } }; @@ -67,26 +68,25 @@ export const SelectionWordCountPlugin = ({onChange} = {}) => { const throttledUpdate = throttle(updateSelectionCount, 200); - const cleanupRegister = mergeRegister( - editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { - throttledUpdate(); - return false; - }, COMMAND_PRIORITY_LOW), - editor.registerUpdateListener(() => { - throttledUpdate(); - }) - ); + // 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]); + }, [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 index dc18647be8..0008de00ef 100644 --- a/packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js +++ b/packages/koenig-lexical/test/e2e/plugins/SelectionWordCountPlugin.test.js @@ -1,5 +1,5 @@ +import {ctrlOrCmd, focusEditor, initialize, insertCard, selectBackwards} from '../../utils/e2e'; import {expect, test} from '@playwright/test'; -import {focusEditor, initialize, selectBackwards} from '../../utils/e2e'; test.describe('Selection Word Count Plugin', async function () { let page; @@ -30,6 +30,10 @@ test.describe('Selection Word Count Plugin', async function () { 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 () { @@ -40,17 +44,15 @@ test.describe('Selection Word Count Plugin', async function () { } // Chrome for Testing occasionally drops the programmatic selection; - // retry until the DOM reports a non-collapsed 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 - expect(await page.evaluate(() => window.getSelection().isCollapsed)).toBe(false); + await expect(page.getByTestId('selection-word-count')).toHaveText('0', {timeout: 1000}); }).toPass(); - - await expect(page.getByTestId('selection-word-count')).toHaveText('0'); }); test('counts partial word fragments as words', async function () { @@ -69,4 +71,48 @@ test.describe('Selection Word Count Plugin', async function () { 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'); + }); });