diff --git a/packages/koenig-lexical/src/plugins/TKPlugin.jsx b/packages/koenig-lexical/src/plugins/TKPlugin.jsx index 320ad7109f..e69b15072a 100644 --- a/packages/koenig-lexical/src/plugins/TKPlugin.jsx +++ b/packages/koenig-lexical/src/plugins/TKPlugin.jsx @@ -1,6 +1,7 @@ import CardContext from '../context/CardContext'; import {$createTKNode, $isTKNode, ExtendedTextNode, TKNode} from '@tryghost/kg-default-nodes'; import {$getNodeByKey, $getSelection, $isDecoratorNode, $isRangeSelection, TextNode} from 'lexical'; +import {$isListItemNode, $isListNode} from '@lexical/list'; import {SELECT_CARD_COMMAND} from './KoenigBehaviourPlugin'; import {createPortal} from 'react-dom'; import {useCallback, useContext, useEffect, useState} from 'react'; @@ -11,27 +12,70 @@ import {useTKContext} from '../context/TKContext'; const REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK|Tk|tk)+[^\p{L}\p{N}\s]*)(.)?/u); const WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u); +// TK Indicator positioning constants +const INDICATOR_OFFSET_RIGHT = -56; +const INDICATOR_OFFSET_TOP = 4; + +// Node type constants +const NODE_TYPES = { + LIST_ITEM: 'LI' +}; + +// Positioning helper functions +function getPositioningElement(containingElement) { + return containingElement.nodeName === NODE_TYPES.LIST_ITEM + ? containingElement + : containingElement.querySelector('[data-kg-card]') || containingElement; +} + +function calculateRightOffset(elementRect, rootRect) { + let right = INDICATOR_OFFSET_RIGHT; + if (elementRect.right > rootRect.right) { + right = right - (elementRect.right - rootRect.right); + } + return right; +} + +// Helper function to get effective top-level element, treating list items as containers +function getEffectiveTopLevelElement(node) { + const topLevel = node.getTopLevelElement(); + + if (!topLevel) { + return null; + } + + // If the top-level element is not a list, return it directly + if (!$isListNode(topLevel)) { + return topLevel; + } + + // Find the containing list item for list nodes + let currentNode = node; + while (currentNode?.getParent()) { + if ($isListItemNode(currentNode)) { + return currentNode; + } + currentNode = currentNode.getParent(); + } + + // Fallback to top-level element if no list item found + return topLevel; +} + function TKIndicator({editor, rootElement, parentKey, nodeKeys}) { const tkClasses = editor._config.theme.tk?.split(' ') || []; const tkHighlightClasses = editor._config.theme.tkHighlighted?.split(' ') || []; const containingElement = editor.getElementByKey(parentKey); - // position element relative to the TK Node containing element + // Calculate indicator position relative to the root element const calculatePosition = useCallback(() => { - let top = 0; - let right = -56; - - const rootElementRect = rootElement.getBoundingClientRect(); + const rootRect = rootElement.getBoundingClientRect(); + const positioningElement = getPositioningElement(containingElement); + const elementRect = positioningElement.getBoundingClientRect(); - const positioningElement = containingElement.querySelector('[data-kg-card]') || containingElement; - const positioningElementRect = positioningElement.getBoundingClientRect(); - - top = positioningElementRect.top - rootElementRect.top + 4; - - if (positioningElementRect.right > rootElementRect.right) { - right = right - (positioningElementRect.right - rootElementRect.right); - } + const top = elementRect.top - rootRect.top + INDICATOR_OFFSET_TOP; + const right = calculateRightOffset(elementRect, rootRect); return {top, right}; }, [rootElement, containingElement]); @@ -83,12 +127,17 @@ function TKIndicator({editor, rootElement, parentKey, nodeKeys}) { } nodeKeys.forEach((key) => { + const element = editor.getElementByKey(key); + if (!element) { + return; + } + if (isHighlighted) { - editor.getElementByKey(key).classList.remove(...tkClasses); - editor.getElementByKey(key).classList.add(...tkHighlightClasses); + element.classList.remove(...tkClasses); + element.classList.add(...tkHighlightClasses); } else { - editor.getElementByKey(key).classList.add(...tkClasses); - editor.getElementByKey(key).classList.remove(...tkHighlightClasses); + element.classList.add(...tkClasses); + element.classList.remove(...tkHighlightClasses); } }); }; @@ -115,6 +164,11 @@ function TKIndicator({editor, rootElement, parentKey, nodeKeys}) { }; }, [rootElement, containingElement, calculatePosition]); + // Early return if containing element is not available (after all hooks) + if (!containingElement) { + return null; + } + const style = { top: `${position.top}px`, right: `${position.right}px` @@ -157,7 +211,8 @@ export default function TKPlugin() { if (mutation === 'destroyed') { removeEditorTkNode(editor.getKey(), tkNodeKey); } else { - const parentNodeKey = $getNodeByKey(tkNodeKey).getTopLevelElement()?.getKey(); + const effectiveTopLevel = getEffectiveTopLevelElement($getNodeByKey(tkNodeKey)); + const parentNodeKey = effectiveTopLevel?.getKey(); const topLevelNodeKey = parentEditorNodeKey || parentNodeKey; addEditorTkNode(editor.getKey(), topLevelNodeKey, tkNodeKey); } @@ -247,7 +302,7 @@ export default function TKPlugin() { const parentContainer = editor.getElementByKey(parentKey); if (!parentContainer) { - return false; + return null; } return ( diff --git a/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js b/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js index 134c6efa24..2a28e9e412 100644 --- a/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js +++ b/packages/koenig-lexical/test/e2e/plugins/TKPlugin.test.js @@ -115,6 +115,42 @@ test.describe('TK Plugin', async function () { await expect(page.getByTestId('tk-indicator')).toHaveCount(3); }); + test('creates separate TK indicators for each list item containing TK', async function () { + await focusEditor(page); + + await page.keyboard.type('- First item with TK'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Second item with TK'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Third item without placeholder'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Fourth item with TK'); + + // Should have 3 separate TK indicators, one for each list item containing TK + await expect(page.getByTestId('tk-indicator')).toHaveCount(3); + }); + + test('positions TK indicators correctly for individual list items', async function () { + await focusEditor(page); + + await page.keyboard.type('- First TK item'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Second TK item'); + + const indicators = page.getByTestId('tk-indicator'); + await expect(indicators).toHaveCount(2); + + // Get positions to verify they're different (one per list item) + const firstIndicator = indicators.first(); + const secondIndicator = indicators.last(); + + const firstBox = await firstIndicator.boundingBox(); + const secondBox = await secondIndicator.boundingBox(); + + // The indicators should have different vertical positions + expect(Math.abs(firstBox.y - secondBox.y)).toBeGreaterThan(10); + }); + test('clicking the indicator selects the first TK node in the parent', async function () { await focusEditor(page); await page.keyboard.type('TK and TK and TK');