diff --git a/packages/@intlayer/design-system/src/components/TextArea/AutocompleteTextArea.tsx b/packages/@intlayer/design-system/src/components/TextArea/AutocompleteTextArea.tsx index 717f3d4c6..b7f7421fa 100644 --- a/packages/@intlayer/design-system/src/components/TextArea/AutocompleteTextArea.tsx +++ b/packages/@intlayer/design-system/src/components/TextArea/AutocompleteTextArea.tsx @@ -1,36 +1,12 @@ 'use client'; -import { useAutocomplete } from '@hooks/reactQuery'; -import type { AutocompleteResponse } from '@intlayer/backend'; -import { useConfiguration } from '@intlayer/editor-react'; import { type FC, useEffect, useRef, useState } from 'react'; +import type { AutoSizedTextAreaProps } from './AutoSizeTextArea'; import { - AutoSizedTextArea, - type AutoSizedTextAreaProps, -} from './AutoSizeTextArea'; + ContentEditableTextArea, + type ContentEditableTextAreaHandle, +} from './ContentEditableTextArea'; -/** - * Custom hook for debouncing values to prevent excessive API calls. - * - * Delays updating the returned value until the input value has stopped changing - * for the specified delay period. - * - * @param value - The value to debounce - * @param delay - Delay in milliseconds before updating the debounced value - * @returns The debounced value that only updates after the delay period - * - * @example - * ```tsx - * const [searchTerm, setSearchTerm] = useState(''); - * const debouncedSearchTerm = useDebounce(searchTerm, 300); - * - * useEffect(() => { - * if (debouncedSearchTerm) { - * performSearch(debouncedSearchTerm); - * } - * }, [debouncedSearchTerm]); - * ``` - */ export const useDebounce = (value: T, delay: number): T => { const [debouncedValue, setDebouncedValue] = useState(value); @@ -39,329 +15,97 @@ export const useDebounce = (value: T, delay: number): T => { setDebouncedValue(value); }, delay); - // Cleanup the timer if value changes before 'delay' ms return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; }; -/** - * Props for the AutocompleteTextArea component. - * - * Extends AutoSizedTextAreaProps with AI-powered autocomplete functionality. - * - * @example - * ```tsx - * // AI-powered autocomplete textarea - * - * - * // Manual suggestion mode - * - * - * // Disabled autocomplete for sensitive content - * - * ``` - */ export type AutocompleteTextAreaProps = AutoSizedTextAreaProps & { - /** Whether AI autocomplete is active and should fetch suggestions */ isActive?: boolean; - /** Manual suggestion text to display (overrides AI suggestions) */ suggestion?: string; }; -/** - * AutoCompleteTextarea Component - * - * An intelligent textarea that provides AI-powered autocomplete suggestions as users type, - * combining auto-sizing functionality with contextual text completion. - * - * ## Features - * - **AI-Powered Suggestions**: Context-aware autocomplete using configured AI models - * - **Debounced API Calls**: Efficient suggestion fetching with 200ms debounce - * - **Visual Suggestions**: Inline preview of suggested completions - * - **Keyboard Navigation**: Tab key to accept suggestions - * - **Context Analysis**: Uses surrounding text for better suggestions - * - **Auto-Sizing**: Inherits all AutoSizedTextArea capabilities - * - **Performance Optimized**: Smart caching and minimal re-renders - * - * ## Technical Implementation - * - **Debounce Strategy**: 200ms delay before fetching suggestions - * - **Context Window**: 5 lines before/after cursor for context - * - **Minimum Trigger**: Requires 3+ characters before suggesting - * - **Position Tracking**: Ghost layer for accurate suggestion positioning - * - **Cursor Management**: Tracks cursor position during suggestion fetch - * - * ## AI Integration - * - Uses configured AI model (OpenAI, Anthropic, etc.) - * - Sends context-aware prompts for relevant suggestions - * - Respects temperature and model settings from configuration - * - Handles API errors gracefully without interrupting user flow - * - * ## Use Cases - * - **Content Creation**: Blog posts, articles, documentation - * - **Code Comments**: Intelligent code documentation assistance - * - **Email Composition**: Professional email writing assistance - * - **Creative Writing**: Story and narrative completion - * - **Technical Documentation**: API docs, README files - * - **Social Media**: Post creation with engagement optimization - * - * @example - * ```tsx - * // Blog writing assistant - * const [blogPost, setBlogPost] = useState(''); - * const [isAiEnabled, setIsAiEnabled] = useState(true); - * - *
- *
- * - * - *
- * - * setBlogPost(e.target.value)} - * placeholder="Start writing your blog post..." - * isActive={isAiEnabled} - * autoSize={true} - * maxRows={15} - * className="min-h-[200px] font-serif text-lg leading-relaxed" - * /> - *
- * - * // Code documentation assistant - * - * - * // Email composition with templates - * - * ``` - * - * ## Accessibility - * - Ghost layer is properly hidden from screen readers - * - Maintains focus management during suggestion acceptance - * - Preserves keyboard navigation patterns - * - Respects reduced motion preferences - */ export const AutoCompleteTextarea: FC = ({ isActive = true, suggestion: suggestionProp, ...props }) => { const defaultValue = String(props.value ?? props.defaultValue ?? ''); - const { mutate: autocomplete } = useAutocomplete(); - const configuration = useConfiguration(); - const [isTyped, setIsTyped] = useState(false); const [text, setText] = useState(defaultValue); const [suggestion, setSuggestion] = useState(''); - const textareaRef = useRef(null); - const placeholderRef = useRef(null); - const ghostLayerRef = useRef(null); - const [suggestionPosition, setSuggestionPosition] = useState<{ - left: number; - top: number; - } | null>(null); - const [cursorAtFetch, setCursorAtFetch] = useState(-1); - - // Only update this “debouncedText” after the user stops typing for 200ms - const debouncedText = useDebounce(text, 200); + const editorRef = useRef(null); useEffect(() => { if (typeof props.value === 'undefined') return; - setText(defaultValue); + setText(String(props.value ?? props.defaultValue ?? '')); }, [props.value, props.defaultValue]); - useEffect(() => { - if (!isActive) return; - if (!isTyped) return; - - const fetchSuggestion = async () => { - try { - const cursor = - textareaRef.current?.selectionStart ?? debouncedText.length; - const before = debouncedText.slice(0, cursor); - const after = debouncedText.slice(cursor); - const numLines = 5; - const beforeLines = before.split('\n'); - const contextBeforeLines = beforeLines.slice( - Math.max(0, beforeLines.length - numLines - 1), - -1 - ); - const contextBefore = contextBeforeLines.join('\n'); - const currentLine = beforeLines[beforeLines.length - 1] ?? ''; - const afterLines = after.split('\n'); - const contextAfter = afterLines.slice(1, numLines + 1).join('\n'); - - autocomplete( - { - text: before, - contextBefore, - currentLine, - contextAfter, - aiOptions: { - apiKey: configuration.ai?.apiKey, - model: configuration.ai?.model, - temperature: configuration.ai?.temperature, - }, - }, - { - onSuccess: (data: AutocompleteResponse) => { - setSuggestion(data.data?.autocompletion ?? ''); - setCursorAtFetch(cursor); - }, - } - ); - } catch (err) { - console.error('Autocomplete error:', err); - } - }; - - if (debouncedText.length > 3) { - // Only fetch if user typed more than 3 chars and has paused - setSuggestion(''); - // TODO: Uncomment this when the autocomplete works well enough - // fetchSuggestion(); - } else { - // If typed less than threshold, clear the suggestion - setSuggestion(''); - } - }, [debouncedText, isActive, autocomplete, configuration]); - - useEffect(() => { - if ( - !suggestion || - cursorAtFetch === -1 || - !placeholderRef.current || - !ghostLayerRef.current - ) { - setSuggestionPosition(null); - return; - } - - const rect = placeholderRef.current.getBoundingClientRect(); - const parentRect = ghostLayerRef.current.getBoundingClientRect(); - setSuggestionPosition({ - left: rect.left - parentRect.left, - top: rect.top - parentRect.top, - }); - }, [suggestion, cursorAtFetch, text]); - const acceptSuggestion = () => { - const currentCursor = textareaRef.current?.selectionStart ?? cursorAtFetch; - if (currentCursor !== cursorAtFetch) return; - const newText = - text.slice(0, currentCursor) + suggestion + text.slice(currentCursor); - setText(newText); + const active = suggestionProp ?? suggestion; + if (!active) return; + + const cursor = editorRef.current?.getCursorOffset() ?? text.length; + const next = text.slice(0, cursor) + active + text.slice(cursor); + setText(next); setSuggestion(''); - setCursorAtFetch(-1); + setTimeout(() => { - textareaRef.current?.focus(); - const newCursorPos = currentCursor + suggestion.length; - textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); + editorRef.current?.focus(); + editorRef.current?.setCursorAtOffset(cursor + active.length); }, 0); }; + const activeGhost = isActive + ? (suggestionProp ?? (suggestion || undefined)) + : undefined; + const textLines = text.split('\n'); + const activeLine = suggestionProp ? textLines.length - 1 : undefined; + const activeOffset = suggestionProp + ? (textLines[textLines.length - 1]?.length ?? 0) + : undefined; + return ( -
- - {suggestion && suggestionPosition && ( -
- {suggestion} -
- )} - { - setIsTyped(true); - setText(e.target.value); - setSuggestion(''); - props.onChange?.(e); - }} - onKeyDown={(e) => { - if (e.key === 'Tab' && suggestion) { - e.preventDefault(); - acceptSuggestion(); - } - props.onKeyDown?.(e); - }} - onSelect={(e) => { - if ( - suggestion && - (e.target as HTMLTextAreaElement).selectionStart !== cursorAtFetch - ) { - setSuggestion(''); - setCursorAtFetch(-1); - } - props.onSelect?.(e); - }} - /> -
+ { + setText(val); + setSuggestion(''); + + if (props.onChange) { + const evt = { + target: { value: val }, + currentTarget: { value: val }, + } as React.ChangeEvent; + props.onChange(evt); + } + }} + onKeyDown={(e) => { + if (e.key === 'Tab' && (suggestionProp ?? suggestion)) { + e.preventDefault(); + acceptSuggestion(); + } + props.onKeyDown?.( + e as unknown as React.KeyboardEvent + ); + }} + ghostText={activeGhost} + ghostLine={activeLine} + ghostOffset={activeOffset} + placeholder={props.placeholder} + disabled={props.disabled} + autoSize={props.autoSize} + maxRows={props.maxRows} + minRows={props.rows} + variant={props.variant} + validationStyleEnabled={props.validationStyleEnabled} + className={props.className} + dir={props.dir as 'ltr' | 'rtl' | 'auto'} + aria-label={props['aria-label']} + aria-invalid={props['aria-invalid']} + aria-describedby={props['aria-describedby']} + data-testid={props['data-testid']} + /> ); }; diff --git a/packages/@intlayer/design-system/src/components/TextArea/ContentEditableTextArea.tsx b/packages/@intlayer/design-system/src/components/TextArea/ContentEditableTextArea.tsx new file mode 100644 index 000000000..09898b79c --- /dev/null +++ b/packages/@intlayer/design-system/src/components/TextArea/ContentEditableTextArea.tsx @@ -0,0 +1,735 @@ +'use client'; + +import { cn } from '@utils/cn'; +import type { VariantProps } from 'class-variance-authority'; +import { + type FC, + type HTMLAttributes, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { type InputVariant, inputVariants } from '../Input'; + +type CaretPosition = { + line: number; + offset: number; +}; + +type UseContentEditableOptions = { + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + disabled?: boolean; +}; + +const ZERO_WIDTH_SPACE = '\u200B'; + +const getTextFromContainer = (container: HTMLDivElement): string => { + const lineEls = container.querySelectorAll('[data-line]'); + if (lineEls.length === 0) { + return (container.textContent ?? '').split(ZERO_WIDTH_SPACE).join(''); + } + + return Array.from(lineEls) + .map((el) => { + const editable = el.querySelector('[data-editable]'); + const raw = editable?.textContent ?? el.textContent ?? ''; + return raw === ZERO_WIDTH_SPACE + ? '' + : raw.split(ZERO_WIDTH_SPACE).join(''); + }) + .join('\n'); +}; + +const splitLines = (text: string): string[] => { + const lines = text.split('\n'); + return lines.length === 0 ? [''] : lines; +}; + +// Cached Intl.Segmenter for grapheme-aware deletion (emoji, CJK, etc.) +const graphemeSegmenter = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : null; + +/** + * Find the previous grapheme cluster boundary for safe deletion. + * Falls back to code-point-aware deletion if Intl.Segmenter is unavailable. + */ +const prevGraphemeBoundary = (text: string, offset: number): number => { + if (offset <= 0) return 0; + + if (graphemeSegmenter) { + const segments = [...graphemeSegmenter.segment(text.slice(0, offset))]; + const last = segments[segments.length - 1]; + return last ? offset - last.segment.length : offset - 1; + } + + // Fallback: handle surrogate pairs + const code = text.charCodeAt(offset - 1); + if (code >= 0xdc00 && code <= 0xdfff && offset >= 2) { + return offset - 2; + } + return offset - 1; +}; + +/** + * Find the next grapheme cluster boundary for safe forward deletion. + */ +const nextGraphemeBoundary = (text: string, offset: number): number => { + if (offset >= text.length) return text.length; + + if (graphemeSegmenter) { + const segments = [...graphemeSegmenter.segment(text.slice(offset))]; + const first = segments[0]; + return first ? offset + first.segment.length : offset + 1; + } + + // Fallback: handle surrogate pairs + const code = text.charCodeAt(offset); + if (code >= 0xd800 && code <= 0xdbff && offset + 1 < text.length) { + return offset + 2; + } + return offset + 1; +}; + +/** + * Find the previous word boundary for Option+Backspace. + */ +const prevWordBoundary = (text: string, offset: number): number => { + if (offset <= 0) return 0; + let i = offset - 1; + // Skip whitespace + while (i > 0 && /\s/.test(text[i - 1])) i--; + // Skip word characters + while (i > 0 && /\S/.test(text[i - 1])) i--; + return i; +}; + +/** + * Find the next word boundary for Option+Delete. + */ +const nextWordBoundary = (text: string, offset: number): number => { + if (offset >= text.length) return text.length; + let i = offset; + // Skip word characters + while (i < text.length && /\S/.test(text[i])) i++; + // Skip whitespace + while (i < text.length && /\s/.test(text[i])) i++; + return i; +}; + +/** + * Find the start of the current line (for Cmd+Backspace). + */ +const lineStart = (text: string, offset: number): number => { + const before = text.slice(0, offset); + const lastNewline = before.lastIndexOf('\n'); + return lastNewline + 1; +}; + +/** + * Find the end of the current line (for Cmd+Delete). + */ +const lineEnd = (text: string, offset: number): number => { + const nextNewline = text.indexOf('\n', offset); + return nextNewline === -1 ? text.length : nextNewline; +}; + +export const useContentEditable = ({ + value, + defaultValue, + onChange, + disabled = false, +}: UseContentEditableOptions) => { + const initialValue = value ?? defaultValue ?? ''; + const [lines, setLines] = useState(() => splitLines(initialValue)); + const containerRef = useRef(null); + const pendingCaretRef = useRef(null); + const isControlled = value !== undefined; + + // Keep a ref to the latest lines to avoid stale closures in rapid typing + const linesRef = useRef(lines); + linesRef.current = lines; + + useEffect(() => { + if (isControlled && value !== undefined) { + setLines(splitLines(value)); + } + }, [value, isControlled]); + + const getText = () => linesRef.current.join('\n'); + + const getCaretPosition = (): CaretPosition | null => { + const sel = window.getSelection(); + if (!sel?.rangeCount || !containerRef.current) return null; + + const range = sel.getRangeAt(0); + const lineEls = containerRef.current.querySelectorAll('[data-line]'); + + for (let i = 0; i < lineEls.length; i++) { + if (lineEls[i].contains(range.startContainer)) { + return { line: i, offset: range.startOffset }; + } + } + return null; + }; + + const getSelectionOffsets = (): { + start: number; + end: number; + hasSelection: boolean; + } | null => { + const sel = window.getSelection(); + if (!sel?.rangeCount || !containerRef.current) return null; + + const range = sel.getRangeAt(0); + const lineEls = containerRef.current.querySelectorAll('[data-line]'); + const currentLines = linesRef.current; + + const findOffset = (node: Node, nodeOffset: number): number => { + for (let i = 0; i < lineEls.length; i++) { + if (lineEls[i].contains(node)) { + let flat = 0; + for (let j = 0; j < i; j++) { + flat += currentLines[j].length + 1; + } + return flat + Math.min(nodeOffset, currentLines[i]?.length ?? 0); + } + } + // Selection is on the root container (e.g. select-all) + if (node === containerRef.current) { + if (nodeOffset === 0) return 0; + return currentLines.join('\n').length; + } + return 0; + }; + + const start = findOffset(range.startContainer, range.startOffset); + const end = findOffset(range.endContainer, range.endOffset); + + return { + start: Math.min(start, end), + end: Math.max(start, end), + hasSelection: !range.collapsed, + }; + }; + + const setCaretPosition = (pos: CaretPosition) => { + if (!containerRef.current) return; + + const lineEls = containerRef.current.querySelectorAll('[data-line]'); + const lineEl = lineEls[pos.line]; + if (!lineEl) return; + + const editable = lineEl.querySelector('[data-editable]'); + const node = + editable?.firstChild ?? editable ?? lineEl.firstChild ?? lineEl; + + const sel = window.getSelection(); + if (!sel) return; + + const range = document.createRange(); + const maxOff = Math.min(pos.offset, node.textContent?.length ?? 0); + + try { + range.setStart(node, maxOff); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } catch { + range.selectNodeContents(node); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } + }; + + useEffect(() => { + if (pendingCaretRef.current && containerRef.current) { + setCaretPosition(pendingCaretRef.current); + pendingCaretRef.current = null; + } + }); + + const flatOffsetFromCaret = (pos: CaretPosition): number => { + const currentLines = linesRef.current; + let offset = 0; + for (let i = 0; i < pos.line; i++) { + offset += currentLines[i].length + 1; + } + return offset + pos.offset; + }; + + const caretFromFlatOffset = ( + flat: number, + targetLines: string[] + ): CaretPosition => { + let rem = flat; + for (let i = 0; i < targetLines.length; i++) { + if (rem <= targetLines[i].length) { + return { line: i, offset: rem }; + } + rem -= targetLines[i].length + 1; + } + return { + line: targetLines.length - 1, + offset: targetLines[targetLines.length - 1]?.length ?? 0, + }; + }; + + const getCursorOffset = (): number => { + const pos = getCaretPosition(); + if (!pos) return 0; + return flatOffsetFromCaret(pos); + }; + + /** + * Applies a text mutation: computes new lines, sets pending caret, updates state. + */ + const applyTextChange = (newText: string, caretOffset: number) => { + const newLines = splitLines(newText); + pendingCaretRef.current = caretFromFlatOffset(caretOffset, newLines); + setLines(newLines); + onChange?.(newText); + }; + + const handleInput = () => { + if (pendingCaretRef.current !== null) return; + if (disabled || !containerRef.current) return; + + const caretPos = getCaretPosition(); + const newText = getTextFromContainer(containerRef.current); + const newLines = splitLines(newText); + + pendingCaretRef.current = caretPos; + setLines(newLines); + onChange?.(newText); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (disabled) { + e.preventDefault(); + return; + } + + // Don't intercept during IME composition (CJK input) + if (e.nativeEvent.isComposing) return; + + // Block undo/redo — browser would mutate DOM out of sync with React + if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + e.preventDefault(); + return; + } + + const selInfo = getSelectionOffsets(); + if (!selInfo) return; + + const currentText = linesRef.current.join('\n'); + + if (e.key === 'Enter') { + e.preventDefault(); + const newText = + currentText.slice(0, selInfo.start) + + '\n' + + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start + 1); + return; + } + + if (e.key === 'Backspace') { + e.preventDefault(); + + if (selInfo.hasSelection) { + const newText = + currentText.slice(0, selInfo.start) + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start); + } else { + if (selInfo.start === 0) return; + + let deleteFrom: number; + if (e.metaKey) { + // Cmd+Backspace: delete to start of line + deleteFrom = lineStart(currentText, selInfo.start); + } else if (e.altKey) { + // Option+Backspace: delete previous word + deleteFrom = prevWordBoundary(currentText, selInfo.start); + } else { + // Regular backspace: delete one grapheme + deleteFrom = prevGraphemeBoundary(currentText, selInfo.start); + } + + const newText = + currentText.slice(0, deleteFrom) + currentText.slice(selInfo.start); + applyTextChange(newText, deleteFrom); + } + return; + } + + if (e.key === 'Delete') { + e.preventDefault(); + + if (selInfo.hasSelection) { + const newText = + currentText.slice(0, selInfo.start) + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start); + } else { + if (selInfo.start >= currentText.length) return; + + let deleteTo: number; + if (e.metaKey) { + // Cmd+Delete: delete to end of line + deleteTo = lineEnd(currentText, selInfo.start); + } else if (e.altKey) { + // Option+Delete: delete next word + deleteTo = nextWordBoundary(currentText, selInfo.start); + } else { + // Regular delete: delete one grapheme + deleteTo = nextGraphemeBoundary(currentText, selInfo.start); + } + + const newText = + currentText.slice(0, selInfo.start) + currentText.slice(deleteTo); + applyTextChange(newText, selInfo.start); + } + return; + } + }; + + const handleCut = (e: React.ClipboardEvent) => { + if (disabled) { + e.preventDefault(); + return; + } + + e.preventDefault(); + const selInfo = getSelectionOffsets(); + if (!selInfo || !selInfo.hasSelection) return; + + const currentText = linesRef.current.join('\n'); + const selectedText = currentText.slice(selInfo.start, selInfo.end); + + // Write selected text to clipboard + e.clipboardData.setData('text/plain', selectedText); + + // Delete the selected text + const newText = + currentText.slice(0, selInfo.start) + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + if (disabled) { + e.preventDefault(); + return; + } + + e.preventDefault(); + const pastedText = e.clipboardData.getData('text/plain'); + if (!pastedText) return; + + const selInfo = getSelectionOffsets(); + if (!selInfo) return; + + const currentText = linesRef.current.join('\n'); + const newText = + currentText.slice(0, selInfo.start) + + pastedText + + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start + pastedText.length); + }; + + const handleBeforeInput = (e: React.FormEvent) => { + if (disabled) return; + + const inputEvent = e.nativeEvent as InputEvent; + + // Don't intercept during IME composition (CJK input) + if (inputEvent.isComposing) return; + + const inputType = inputEvent.inputType; + + // Skip types handled by handleKeyDown (when keydown fires) + if (inputType === 'insertParagraph' || inputType === 'insertLineBreak') { + return; + } + + // Handle deletions as fallback for mobile keyboards that don't fire keydown + if ( + inputType === 'deleteContentBackward' || + inputType === 'deleteContentForward' + ) { + e.preventDefault(); + const selInfo = getSelectionOffsets(); + if (!selInfo) return; + + const currentText = linesRef.current.join('\n'); + + if (selInfo.hasSelection) { + const newText = + currentText.slice(0, selInfo.start) + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start); + } else if (inputType === 'deleteContentBackward') { + if (selInfo.start === 0) return; + const deleteFrom = prevGraphemeBoundary(currentText, selInfo.start); + const newText = + currentText.slice(0, deleteFrom) + currentText.slice(selInfo.start); + applyTextChange(newText, deleteFrom); + } else { + if (selInfo.start >= currentText.length) return; + const deleteTo = nextGraphemeBoundary(currentText, selInfo.start); + const newText = + currentText.slice(0, selInfo.start) + currentText.slice(deleteTo); + applyTextChange(newText, selInfo.start); + } + return; + } + + // Handle spell-check replacements + if (inputType === 'insertReplacementText') { + e.preventDefault(); + const selInfo = getSelectionOffsets(); + if (!selInfo) return; + + const currentText = linesRef.current.join('\n'); + const replacement = + inputEvent.data ?? inputEvent.dataTransfer?.getData('text/plain') ?? ''; + const newText = + currentText.slice(0, selInfo.start) + + replacement + + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start + replacement.length); + return; + } + + if (inputType === 'insertText' && inputEvent.data) { + e.preventDefault(); + + const selInfo = getSelectionOffsets(); + if (!selInfo) return; + + const currentText = linesRef.current.join('\n'); + const inserted = inputEvent.data; + const newText = + currentText.slice(0, selInfo.start) + + inserted + + currentText.slice(selInfo.end); + applyTextChange(newText, selInfo.start + inserted.length); + } + }; + + const handleDrop = (e: React.DragEvent) => { + // Block drag-and-drop to prevent uncontrolled DOM mutations + e.preventDefault(); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + return { + lines, + containerRef, + getText, + handleInput, + handleBeforeInput, + handleKeyDown, + handleCut, + handlePaste, + handleDrop, + handleDragOver, + getCaretPosition, + setCaretPosition, + getCursorOffset, + caretFromFlatOffset, + }; +}; + +type LineProps = { + index: number; + text: string; + isLast: boolean; + ghostText?: string; +}; + +const Line: FC = ({ index, text, isLast, ghostText }) => ( + + {text || '\u200B'} + {ghostText && ( + + )} + {!isLast &&
} +
+); + +export type ContentEditableTextAreaHandle = { + getContainer: () => HTMLDivElement | null; + getText: () => string; + focus: () => void; + getCursorOffset: () => number; + setCursorAtOffset: (offset: number) => void; +}; + +export type ContentEditableTextAreaProps = Omit< + HTMLAttributes, + 'onChange' | 'defaultValue' +> & { + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; + minRows?: number; + maxRows?: number; + autoSize?: boolean; + validationStyleEnabled?: boolean; + variant?: InputVariant | `${InputVariant}`; + ghostText?: string; + ghostLine?: number; + ghostOffset?: number; + ref?: React.Ref; + dir?: 'ltr' | 'rtl' | 'auto'; +} & Omit< + VariantProps, + 'validationStyleEnabled' | 'variant' + >; + +const LINE_HEIGHT = 24; +const LINE_PADDING = 12; + +export const ContentEditableTextArea: FC = ({ + value, + defaultValue, + onChange, + placeholder, + disabled = false, + minRows = 1, + maxRows = 999, + autoSize = true, + validationStyleEnabled = false, + variant, + ghostText, + ghostLine, + ghostOffset, + onClick, + className, + dir = 'auto', + ref, + ...rest +}) => { + const { + lines, + containerRef, + getText, + handleInput, + handleBeforeInput, + handleKeyDown, + handleCut, + handlePaste, + handleDrop, + handleDragOver, + getCursorOffset, + setCaretPosition, + caretFromFlatOffset, + } = useContentEditable({ value, defaultValue, onChange, disabled }); + + const elRef = useRef(null); + + const setRef = (el: HTMLDivElement | null) => { + elRef.current = el; + (containerRef as React.MutableRefObject).current = + el; + }; + + useImperativeHandle(ref, () => ({ + getContainer: () => elRef.current, + getText, + focus: () => elRef.current?.focus(), + getCursorOffset, + setCursorAtOffset: (offset: number) => { + setCaretPosition(caretFromFlatOffset(offset, lines)); + }, + })); + + useEffect(() => { + if (!autoSize || !elRef.current) return; + + const el = elRef.current; + const max = LINE_HEIGHT * maxRows + LINE_PADDING; + const min = LINE_HEIGHT * minRows + LINE_PADDING; + + el.style.height = 'auto'; + const sh = el.scrollHeight; + el.style.height = `${Math.max(Math.min(sh, max), min)}px`; + el.style.overflowY = sh > max ? 'auto' : 'hidden'; + }, [lines, autoSize, maxRows, minRows]); + + const isEmpty = lines.length === 1 && lines[0] === ''; + const hasGhost = + ghostText && ghostLine !== undefined && ghostOffset !== undefined; + + return ( +
+ {isEmpty && placeholder && ( + + )} + +
+ {lines.map((text, i) => ( + + ))} +
+
+ ); +}; diff --git a/packages/@intlayer/design-system/src/components/TextArea/contentEditable.test.tsx b/packages/@intlayer/design-system/src/components/TextArea/contentEditable.test.tsx new file mode 100644 index 000000000..86dcfe2e3 --- /dev/null +++ b/packages/@intlayer/design-system/src/components/TextArea/contentEditable.test.tsx @@ -0,0 +1,184 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { + ContentEditableTextArea, + useContentEditable, +} from './ContentEditableTextArea'; + +describe('useContentEditable', () => { + test('splits value into lines', () => { + const { result } = renderHook(() => + useContentEditable({ value: 'line1\nline2\nline3' }) + ); + expect(result.current.lines).toEqual(['line1', 'line2', 'line3']); + }); + + test('empty string gives single empty line', () => { + const { result } = renderHook(() => useContentEditable({ value: '' })); + expect(result.current.lines).toEqual(['']); + }); + + test('getText joins lines back', () => { + const { result } = renderHook(() => + useContentEditable({ value: 'hello\nworld' }) + ); + expect(result.current.getText()).toBe('hello\nworld'); + }); + + test('reacts to value changes', () => { + const { result, rerender } = renderHook( + ({ value }) => useContentEditable({ value }), + { initialProps: { value: 'initial' } } + ); + rerender({ value: 'updated\ntext' }); + expect(result.current.lines).toEqual(['updated', 'text']); + }); +}); + +describe('ContentEditableTextArea', () => { + test('renders as role=textbox with aria-multiline', () => { + render(); + const el = screen.getByRole('textbox'); + expect(el).toBeDefined(); + expect(el.getAttribute('aria-multiline')).toBe('true'); + }); + + test('renders value as line spans', () => { + render(); + const el = screen.getByRole('textbox'); + expect(el.querySelectorAll('[data-line]').length).toBe(2); + }); + + test('shows placeholder when empty', () => { + render(); + expect(screen.getByText('Type here...')).toBeDefined(); + }); + + test('disabled sets contenteditable=false', () => { + render(); + expect(screen.getByRole('textbox').getAttribute('contenteditable')).toBe( + 'false' + ); + }); + + test('passes className through', () => { + render(); + expect(screen.getByTestId('ce').className).toContain('my-class'); + }); + + test('renders ghost text on specified line', () => { + render( + + ); + expect(screen.getByText('world')).toBeDefined(); + }); +}); + +describe('ContentEditableTextArea input handling', () => { + test('controlled value updates reflected in DOM', () => { + const { rerender } = render(); + const el = screen.getByRole('textbox'); + expect(el.textContent).toContain('initial'); + rerender(); + expect(el.textContent).toContain('updated'); + }); + + test('multiline controlled value renders correct number of lines', () => { + render(); + const el = screen.getByRole('textbox'); + expect(el.querySelectorAll('[data-line]').length).toBe(3); + }); + + test('empty value shows placeholder', () => { + render(); + expect(screen.getByText('Enter text...')).toBeDefined(); + }); + + test('non-empty value hides placeholder', () => { + render( + + ); + expect(screen.queryByText('Enter text...')).toBeNull(); + }); + + test('disabled prevents contentEditable', () => { + render(); + const el = screen.getByRole('textbox'); + expect(el.getAttribute('contenteditable')).toBe('false'); + expect(el.getAttribute('tabindex')).toBe('-1'); + }); + + test('aria-disabled is set when disabled', () => { + render(); + expect(screen.getByRole('textbox').getAttribute('aria-disabled')).toBe( + 'true' + ); + }); + + test('ghost text sets aria-autocomplete=inline', () => { + render( + + ); + expect(screen.getByRole('textbox').getAttribute('aria-autocomplete')).toBe( + 'inline' + ); + }); + + test('no ghost text means no aria-autocomplete', () => { + render(); + expect( + screen.getByRole('textbox').getAttribute('aria-autocomplete') + ).toBeNull(); + }); + + test('dir prop is passed to element', () => { + render(); + expect(screen.getByTestId('ce').getAttribute('dir')).toBe('rtl'); + }); +}); + +describe('useContentEditable utilities', () => { + test('single line text gives correct lines', () => { + const { result } = renderHook(() => + useContentEditable({ value: 'no newlines here' }) + ); + expect(result.current.lines).toEqual(['no newlines here']); + }); + + test('trailing newline creates empty last line', () => { + const { result } = renderHook(() => + useContentEditable({ value: 'hello\n' }) + ); + expect(result.current.lines).toEqual(['hello', '']); + }); + + test('multiple consecutive newlines preserved', () => { + const { result } = renderHook(() => + useContentEditable({ value: 'a\n\n\nb' }) + ); + expect(result.current.lines).toEqual(['a', '', '', 'b']); + }); + + test('defaultValue is used when value is undefined', () => { + const { result } = renderHook(() => + useContentEditable({ defaultValue: 'fallback' }) + ); + expect(result.current.getText()).toBe('fallback'); + }); + + test('empty options gives single empty line', () => { + const { result } = renderHook(() => useContentEditable({})); + expect(result.current.lines).toEqual(['']); + expect(result.current.getText()).toBe(''); + }); +}); diff --git a/packages/@intlayer/design-system/src/components/TextArea/contentEditableTextarea.stories.tsx b/packages/@intlayer/design-system/src/components/TextArea/contentEditableTextarea.stories.tsx new file mode 100644 index 000000000..8d4594a2d --- /dev/null +++ b/packages/@intlayer/design-system/src/components/TextArea/contentEditableTextarea.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { ContentEditableTextArea } from './ContentEditableTextArea'; + +const meta: Meta = { + title: 'Components/TextArea/ContentEditableTextArea', + component: ContentEditableTextArea, + tags: ['autodocs'], + argTypes: { + dir: { control: 'select', options: ['ltr', 'rtl', 'auto'] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: 'Start typing...', + minRows: 3, + maxRows: 10, + }, +}; + +export const WithGhostText: Story = { + render: () => ( + + ), +}; + +export const MultilineGhost: Story = { + render: () => ( + + ), +}; + +export const RTL: Story = { + args: { + dir: 'rtl', + placeholder: 'اكتب هنا...', + defaultValue: 'مرحبا بالعالم', + minRows: 3, + }, +}; + +export const Disabled: Story = { + args: { + value: 'This content cannot be edited', + disabled: true, + }, +}; + +export const Controlled: Story = { + render: () => { + const [value, setValue] = useState('Edit me!'); + return ( +
+ +

{value.length} characters

+
+ ); + }, +}; diff --git a/packages/@intlayer/design-system/src/components/TextArea/index.tsx b/packages/@intlayer/design-system/src/components/TextArea/index.tsx index 04ec9f222..6f4936269 100644 --- a/packages/@intlayer/design-system/src/components/TextArea/index.tsx +++ b/packages/@intlayer/design-system/src/components/TextArea/index.tsx @@ -1,3 +1,4 @@ export * from './AutocompleteTextArea'; export * from './AutoSizeTextArea'; +export * from './ContentEditableTextArea'; export * from './TextArea';