Browse Source

fix(term): 修复术语选择器重复条目与弹出菜单溢出

- 候选列表按 word 去重,消除句子单词与术语表、以及术语表内同名
  多条记录造成的重复项(同时修复 React 重复 key)
- 菜单改为 max-height + 滚动,限制最大宽度并允许长词换行
- 新增边界钳制:右侧超出容器时左移,底部超出视口时翻到光标上方
- 上下键范围改用实际可见项数,搜索词变化时焦点重置为第一项

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WV4saUFTm1NCDjy8J6zMe
visuddhinanda 1 week ago
parent
commit
bbb105c5a7

+ 54 - 10
dashboard-v6/src/components/general/TermTextArea.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
 import "./style.css";
 import "./style.css";
 import TermTextAreaMenu from "./TermTextAreaMenu";
 import TermTextAreaMenu from "./TermTextAreaMenu";
 
 
@@ -21,18 +21,18 @@ const TermTextAreaWidget = ({
   const [shadowHeight, setShadowHeight] = useState<number>();
   const [shadowHeight, setShadowHeight] = useState<number>();
   const [menuFocusIndex, setMenuFocusIndex] = useState(0);
   const [menuFocusIndex, setMenuFocusIndex] = useState(0);
   const [menuDisplay, setMenuDisplay] = useState("none");
   const [menuDisplay, setMenuDisplay] = useState("none");
-  const [menuTop, setMenuTop] = useState(0);
-  const [menuLeft, setMenuLeft] = useState(0);
+  /** 光标位置,菜单实际坐标由它再做边界钳制 */
+  const [cursorPos, setCursorPos] = useState({ top: 0, left: 0 });
+  const [menuItemCount, setMenuItemCount] = useState(0);
   const [menuSelected, setMenuSelected] = useState<string>();
   const [menuSelected, setMenuSelected] = useState<string>();
 
 
   const [textAreaValue, setTextAreaValue] = useState(value);
   const [textAreaValue, setTextAreaValue] = useState(value);
   const [textAreaHeight, setTextAreaHeight] = useState(100);
   const [textAreaHeight, setTextAreaHeight] = useState(100);
   const [termSearch, setTermSearch] = useState<string>();
   const [termSearch, setTermSearch] = useState<string>();
 
 
-  const _term_max_menu = 10;
-
   const refTextArea = useRef<HTMLTextAreaElement>(null);
   const refTextArea = useRef<HTMLTextAreaElement>(null);
   const refShadow = useRef<HTMLDivElement>(null);
   const refShadow = useRef<HTMLDivElement>(null);
+  const refMenu = useRef<HTMLDivElement>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!refTextArea.current) return;
     if (!refTextArea.current) return;
@@ -48,6 +48,41 @@ const TermTextAreaWidget = ({
     return () => observer.disconnect();
     return () => observer.disconnect();
   }, []);
   }, []);
 
 
+  /**
+   * 菜单显示后按容器和视口做边界钳制,避免超出可视区域
+   */
+  useLayoutEffect(() => {
+    if (menuDisplay !== "block" || !refMenu.current || !refTextArea.current) {
+      return;
+    }
+    const menu = refMenu.current;
+    const container = refTextArea.current;
+    const menuWidth = menu.offsetWidth;
+    const menuHeight = menu.offsetHeight;
+
+    let left = cursorPos.left;
+    const maxLeft = container.clientWidth - menuWidth;
+    if (left > maxLeft) {
+      left = maxLeft;
+    }
+    if (left < 0) {
+      left = 0;
+    }
+
+    let top = cursorPos.top + 20;
+    // 菜单底部若超出视口,改为显示在光标上方
+    const containerTop = container.getBoundingClientRect().top;
+    if (containerTop + top + menuHeight > window.innerHeight) {
+      const above = cursorPos.top - menuHeight;
+      if (containerTop + above > 0) {
+        top = above;
+      }
+    }
+
+    menu.style.top = `${top}px`;
+    menu.style.left = `${left}px`;
+  }, [menuDisplay, cursorPos, termSearch]);
+
   function term_at_menu_hide() {
   function term_at_menu_hide() {
     setMenuDisplay("none");
     setMenuDisplay("none");
     setTermSearch("");
     setTermSearch("");
@@ -83,8 +118,13 @@ const TermTextAreaWidget = ({
   return (
   return (
     <div className="text_input">
     <div className="text_input">
       <div
       <div
+        ref={refMenu}
         className="menu"
         className="menu"
-        style={{ display: menuDisplay, top: menuTop, left: menuLeft }}
+        style={{
+          display: menuDisplay,
+          top: cursorPos.top + 20,
+          left: cursorPos.left,
+        }}
       >
       >
         <TermTextAreaMenu
         <TermTextAreaMenu
           currIndex={menuFocusIndex}
           currIndex={menuFocusIndex}
@@ -97,6 +137,7 @@ const TermTextAreaWidget = ({
           onChange={(value: string) => {
           onChange={(value: string) => {
             setMenuSelected(value);
             setMenuSelected(value);
           }}
           }}
+          onCount={setMenuItemCount}
         />
         />
       </div>
       </div>
       <div
       <div
@@ -120,7 +161,7 @@ const TermTextAreaWidget = ({
           switch (event.key) {
           switch (event.key) {
             case "ArrowDown":
             case "ArrowDown":
               if (menuDisplay === "block") {
               if (menuDisplay === "block") {
-                if (menuFocusIndex < _term_max_menu) {
+                if (menuFocusIndex < menuItemCount - 1) {
                   setMenuFocusIndex((value) => ++value);
                   setMenuFocusIndex((value) => ++value);
                 }
                 }
                 event.preventDefault();
                 event.preventDefault();
@@ -201,8 +242,7 @@ const TermTextAreaWidget = ({
             if (menuDisplay !== "block") {
             if (menuDisplay !== "block") {
               setMenuFocusIndex(0);
               setMenuFocusIndex(0);
               setMenuDisplay("block");
               setMenuDisplay("block");
-              setMenuTop(cursor.offsetTop + 20);
-              setMenuLeft(cursor.offsetLeft);
+              setCursorPos({ top: cursor.offsetTop, left: cursor.offsetLeft });
               //menu.innerHTML = TermAtRenderMenu({ focus: 0 });
               //menu.innerHTML = TermAtRenderMenu({ focus: 0 });
               //term_at_menu_show(cursor);
               //term_at_menu_show(cursor);
             }
             }
@@ -228,7 +268,11 @@ const TermTextAreaWidget = ({
               if (pos2 === -1 || pos2 < pos1) {
               if (pos2 === -1 || pos2 < pos1) {
                 //光标
                 //光标
                 const term_input = str1.slice(str1.lastIndexOf("[[") + 2);
                 const term_input = str1.slice(str1.lastIndexOf("[[") + 2);
-                setTermSearch(term_input);
+                if (term_input !== termSearch) {
+                  //候选列表变了,焦点回到第一项
+                  setMenuFocusIndex(0);
+                  setTermSearch(term_input);
+                }
               }
               }
             }
             }
           }
           }

+ 23 - 4
dashboard-v6/src/components/general/TermTextAreaMenu.tsx

@@ -25,6 +25,8 @@ interface IWidget {
   currIndex?: number;
   currIndex?: number;
   onChange?: (word: string) => void;
   onChange?: (word: string) => void;
   onSelect?: (word: string) => void;
   onSelect?: (word: string) => void;
+  /** 当前可见候选项数量,供父组件限制上下键范围 */
+  onCount?: (count: number) => void;
 }
 }
 
 
 const TermTextAreaMenuWidget = ({
 const TermTextAreaMenuWidget = ({
@@ -35,6 +37,7 @@ const TermTextAreaMenuWidget = ({
   currIndex = 0,
   currIndex = 0,
   onChange,
   onChange,
   onSelect,
   onSelect,
+  onCount,
 }: IWidget) => {
 }: IWidget) => {
   const sysTerms = useAppSelector(getTerm);
   const sysTerms = useAppSelector(getTerm);
 
 
@@ -81,7 +84,17 @@ const TermTextAreaMenuWidget = ({
         isTerm: true,
         isTerm: true,
       }));
       }));
 
 
-    return [...parentTerm, ...mWords, ...sysTerm];
+    // 术语表可能有同一个 word 的多条记录(不同 tag/meaning),
+    // 句子单词也可能同时出现在术语表里,这里按 word 统一去重,
+    // 保留优先级 parentTerm > mWords > sysTerm。
+    const unique = new Map<string, IWordWithEn>();
+    [...parentTerm, ...mWords, ...sysTerm].forEach((item) => {
+      if (!unique.has(item.word)) {
+        unique.set(item.word, item);
+      }
+    });
+
+    return Array.from(unique.values());
   }, [items, sysTerms]);
   }, [items, sysTerms]);
 
 
   /**
   /**
@@ -98,13 +111,19 @@ const TermTextAreaMenuWidget = ({
   /**
   /**
    * ✅ 只有真正副作用才用 useEffect
    * ✅ 只有真正副作用才用 useEffect
    */
    */
+  const visibleCount = Math.min(filtered.length, maxItem);
+
+  useEffect(() => {
+    onCount?.(visibleCount);
+  }, [visibleCount, onCount]);
+
   useEffect(() => {
   useEffect(() => {
-    if (!filtered.length || !onChange) return;
+    if (!visibleCount || !onChange) return;
 
 
-    const index = currIndex < filtered.length ? currIndex : filtered.length - 1;
+    const index = currIndex < visibleCount ? currIndex : visibleCount - 1;
 
 
     onChange(filtered[index].word);
     onChange(filtered[index].word);
-  }, [currIndex, filtered, onChange]);
+  }, [currIndex, filtered, visibleCount, onChange]);
 
 
   if (!visible) return null;
   if (!visible) return null;
 
 

+ 6 - 3
dashboard-v6/src/components/general/style.css

@@ -30,9 +30,11 @@
 }
 }
 .text_input > .menu {
 .text_input > .menu {
   background-color: #b2b2b2;
   background-color: #b2b2b2;
-  width: 200px;
-  height: 300px;
-  box-shadow: #000;
+  min-width: 200px;
+  max-width: 320px;
+  max-height: 300px;
+  overflow-y: auto;
+  overflow-x: hidden;
   position: absolute;
   position: absolute;
   display: none;
   display: none;
   z-index: 100;
   z-index: 100;
@@ -47,6 +49,7 @@
   cursor: pointer;
   cursor: pointer;
   padding: 0;
   padding: 0;
   margin: 5px;
   margin: 5px;
+  word-break: break-word;
 }
 }
 .text_input > .menu ul li:hover {
 .text_input > .menu ul li:hover {
   background: linear-gradient(90deg, #40a9ff, transparent);
   background: linear-gradient(90deg, #40a9ff, transparent);