/* * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ import { Box, CircularProgress, Input, styled } from "@mui/material"; import randomSeed from "random-seed"; import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { INlpDatasetKeywordEntity } from "../../types/nlp-sample.types"; const SelectableBox = styled(Box)({ position: "relative", height: "30px", marginBottom: "1rem", "& .highlight, & .editable": { position: "absolute", top: 0, display: "block", width: "100%", padding: "4px", }, "& .editable": { backgroundColor: "transparent", padding: "0px 4px", color: "#000", }, }); const COLORS = [ { name: "blue", bg: "#108AA8" }, { name: "navy", bg: "#216372" }, { name: "lime", bg: "#c6e01a" }, { name: "teal", bg: "#4BA49C" }, { name: "olive", bg: "#A8BA33" }, { name: "fuchsia", bg: "#A80551" }, { name: "purple", bg: "#570063" }, { name: "orange", bg: "#E6A23C" }, ]; const UNKNOWN_COLOR = { name: "grey", bg: "#aaaaaa" }; const TODAY = new Date().toDateString(); const getColor = (no: number) => { const rand = randomSeed.create(TODAY); const startIndex = rand(COLORS.length); const color = no < 0 ? UNKNOWN_COLOR : COLORS[(startIndex + no) % COLORS.length]; return { backgroundColor: color.bg, opacity: 0.3, }; }; type SelectableProps = { defaultValue?: string; entities?: INlpDatasetKeywordEntity[]; placeholder?: string; onSelect: (str: string, start: number, end: number) => void; onChange: (sample: { text: string; entities: INlpDatasetKeywordEntity[]; }) => void; loading?: boolean; }; const Selectable: FC = ({ defaultValue, entities = [], placeholder = "", onChange, onSelect, loading = false, }) => { const [text, setText] = useState(defaultValue || ""); const editableRef = useRef(null); const selectableRef = useRef(null); const selectedEntities = useMemo( () => entities?.map((e, index) => { const start = e.start ? e.start : text.indexOf(e.value); const end = e.end ? e.end : start + e.value.length; return { start: text.substring(0, start), value: text.substring(start, end), end: text.substring(end), style: getColor(e.entity ? index : -1), }; }), [entities, text], ); useEffect(() => { setText(defaultValue || ""); }, [defaultValue]); useEffect(() => { const handleSelectionChange = () => { const selection = window.getSelection(); if ( selection && selection.anchorNode && selection.anchorNode === editableRef.current ) { const inputContainer = editableRef.current; let substring: string = ""; let input: HTMLInputElement | null = null; if (inputContainer) { input = inputContainer.querySelector("input"); if ( input && input.selectionStart !== null && input.selectionStart >= 0 && input.selectionEnd && input.selectionStart !== input.selectionEnd ) { substring = text.substring( input.selectionStart, input.selectionEnd, ); } } onSelect( substring, input?.selectionStart ?? 0, input?.selectionEnd ?? 0, ); } }; document.addEventListener("selectionchange", handleSelectionChange); return () => { document.removeEventListener("selectionchange", handleSelectionChange); }; }, [text, onSelect]); const handleTextChange = useCallback( (newText: string) => { const oldText = text; const oldEntities = [...entities]; const newEntities: INlpDatasetKeywordEntity[] = []; const findCharDiff = (oldStr: string, newStr: string): number => { const minLength = Math.min(oldStr.length, newStr.length); const diffIndex = [...Array(minLength)].findIndex( (_, i) => oldStr[i] !== newStr[i], ); return diffIndex === -1 ? minLength : diffIndex; }; const changePoint = findCharDiff(oldText, newText); const textLengthDifference = newText.length - oldText.length; oldEntities.forEach((oldEntity) => { let newStart = oldEntity.start; let newEnd = oldEntity.end; if (changePoint <= oldEntity.start) { newStart += textLengthDifference; newEnd += textLengthDifference; } if (newStart >= 0 && newEnd <= newText.length) { const oldEntityText = oldText.substring( oldEntity.start, oldEntity.end, ); const newEntityText = newText.substring(newStart, newEnd); if (oldEntityText === newEntityText) { newEntities.push({ ...oldEntity, start: newStart, end: newEnd, }); } } }); // Update parent component or manage state locally setText(newText); onChange({ text: newText, entities: newEntities }); }, [text, onChange, entities], ); return ( {selectedEntities?.map((e, idx) => (
{e.start} {e.value} {e.end}
))} handleTextChange(e.target.value)} placeholder={placeholder} endAdornment={ loading ? ( ) : null } />
); }; Selectable.displayName = "Selectable"; export default Selectable;