2
0

WbwPali.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { useEffect, useRef, useState, useMemo, useCallback } from "react";
  2. import { Popover, Typography } from "antd";
  3. import {
  4. TagTwoTone,
  5. InfoCircleOutlined,
  6. ApartmentOutlined,
  7. EditOutlined,
  8. QuestionCircleOutlined,
  9. } from "@ant-design/icons";
  10. import "./wbw.css";
  11. import WbwDetail from "./WbwDetail";
  12. import type { IWbw, IWbwAttachment, TWbwDisplayMode } from "./WbwWord";
  13. import { bookMarkColor } from "./WbwDetailBookMark";
  14. import WbwVideoButton from "./WbwVideoButton";
  15. import PaliText from "./PaliText";
  16. import store from "../../../store";
  17. import { grammarId, lookup } from "../../../reducers/command";
  18. import { useAppSelector } from "../../../hooks";
  19. import { add, relationAddParam } from "../../../reducers/relation-add";
  20. import type { ArticleMode } from "../../article/Article";
  21. import { anchor, showWbw } from "../../../reducers/wbw";
  22. import { ParaLinkCtl } from "../ParaLink";
  23. import type { IStudio } from "../../auth/Studio";
  24. import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
  25. import type { TooltipPlacement } from "antd/es/tooltip"; // antd6: 路径从 lib → es
  26. import { temp } from "../../../reducers/setting";
  27. import TagsArea from "../../tag/TagsArea";
  28. import type { ITagMapData } from "../../../api/Tag";
  29. export const PopPlacement = "setting.wbw.pop.placement";
  30. // ─── VideoIcon ────────────────────────────────────────────────────────────────
  31. interface IVideoIcon {
  32. attachments?: IWbwAttachment[];
  33. }
  34. const VideoIcon = ({ attachments }: IVideoIcon) => {
  35. const videoList = attachments?.filter((item) =>
  36. item.content_type?.includes("video")
  37. );
  38. if (!videoList?.length) return null;
  39. return (
  40. <WbwVideoButton
  41. video={videoList.map((item) => ({
  42. videoId: item.id,
  43. type: item.content_type,
  44. title: item.title,
  45. }))}
  46. />
  47. );
  48. };
  49. // ─── NoteIcon ─────────────────────────────────────────────────────────────────
  50. interface INoteIcon {
  51. note?: string;
  52. }
  53. const NoteIcon = ({ note }: INoteIcon) => {
  54. if (!note?.trim()) return null;
  55. return (
  56. <Popover content={note} placement="bottom">
  57. <InfoCircleOutlined style={{ color: "blue" }} />
  58. </Popover>
  59. );
  60. };
  61. // ─── RelationIcon ─────────────────────────────────────────────────────────────
  62. interface IRelationIcon {
  63. hasRelation?: boolean;
  64. }
  65. const RelationIcon = ({ hasRelation }: IRelationIcon) =>
  66. hasRelation ? <ApartmentOutlined style={{ color: "blue" }} /> : null;
  67. // ─── BookMarkIcon ─────────────────────────────────────────────────────────────
  68. interface IBookMarkIcon {
  69. text?: string;
  70. color: string;
  71. }
  72. const BookMarkIcon = ({ text, color }: IBookMarkIcon) => {
  73. if (!text?.trim()) return null;
  74. return (
  75. <Popover
  76. content={<Typography.Paragraph copyable>{text}</Typography.Paragraph>}
  77. placement="bottom"
  78. >
  79. <TagTwoTone twoToneColor={color} />
  80. </Popover>
  81. );
  82. };
  83. // ─── Types ────────────────────────────────────────────────────────────────────
  84. interface IWidget {
  85. data: IWbw;
  86. studio?: IStudio;
  87. channelId: string;
  88. display?: TWbwDisplayMode;
  89. mode?: ArticleMode;
  90. readonly?: boolean;
  91. onSave?: (e: IWbw, isPublish: boolean, isPublic: boolean) => void;
  92. }
  93. // ─── WbwPaliWidget ────────────────────────────────────────────────────────────
  94. const WbwPaliWidget = ({
  95. data,
  96. channelId,
  97. mode,
  98. studio,
  99. readonly = false,
  100. onSave,
  101. }: IWidget) => {
  102. const [popOpen, setPopOpen] = useState(false);
  103. const [tags, setTags] = useState<ITagMapData[]>();
  104. const [paliColor, setPaliColor] = useState("unset");
  105. const divShell = useRef<HTMLDivElement>(null);
  106. const wbwAnchor = useAppSelector(anchor);
  107. const addParam = useAppSelector(relationAddParam);
  108. const wordSn = `${data.book}-${data.para}-${data.sn.join("-")}`;
  109. const tempSettings = useAppSelector(temp);
  110. /**
  111. * 修复1:popOnTop 是纯派生值(来自 tempSettings),无需 state + effect 同步。
  112. * 直接用 useMemo 在渲染时计算,消除一次 setState-in-effect。
  113. */
  114. const popOnTop = useMemo(() => {
  115. const popSetting = tempSettings?.find((v) => v.key === PopPlacement);
  116. console.debug("PopPlacement change", popSetting);
  117. return popSetting?.value === true;
  118. }, [tempSettings]);
  119. /**
  120. * 修复2:popPlacement 用 useState 存储,但只在事件处理器中更新。
  121. * 在事件处理器(用户点击)里读取 DOM ref 并 setState 完全合法,
  122. * 不会产生"effect 内 setState"的级联渲染警告。
  123. */
  124. const [popPlacement, setPopPlacement] = useState<TooltipPlacement>("bottom");
  125. const computeAndSetPlacement = useCallback(() => {
  126. const rightPanel = document.getElementById("article_right_panel");
  127. const rightPanelWidth = rightPanel?.offsetWidth ?? 0;
  128. const containerWidth = window.innerWidth - rightPanelWidth;
  129. const divRight = divShell.current?.getBoundingClientRect().right ?? 0;
  130. const toDivRight = containerWidth - divRight;
  131. setPopPlacement(
  132. popOnTop
  133. ? toDivRight > 200
  134. ? "top"
  135. : "topRight"
  136. : toDivRight > 200
  137. ? "bottom"
  138. : "bottomRight"
  139. );
  140. }, [popOnTop]);
  141. // ── Popover open/close ────────────────────────────────────────────────────
  142. const popOpenChange = useCallback(
  143. (open: boolean) => {
  144. if (open) {
  145. // 事件处理器中读取 ref + setState,合法,不产生级联渲染
  146. computeAndSetPlacement();
  147. setPaliColor("lightblue");
  148. } else {
  149. setPaliColor("unset");
  150. }
  151. setPopOpen(open);
  152. },
  153. [computeAndSetPlacement]
  154. );
  155. /**
  156. * relation 高亮颜色是派生值,用 useMemo 直接计算。
  157. */
  158. const relationHighlight = useMemo(() => {
  159. let grammar = data.case?.value
  160. ?.replace("v:ind", "v")
  161. .replace("#", "$")
  162. .replace(":", "$")
  163. .replaceAll(".", "")
  164. .split("$");
  165. if (data.grammar2?.value) {
  166. grammar = grammar
  167. ? [data.grammar2.value, ...grammar]
  168. : [data.grammar2.value];
  169. }
  170. if (!grammar) return false;
  171. const match = addParam?.relations?.filter((value) => {
  172. if (!value.to) return false;
  173. const caseMatch =
  174. !value.to.case ||
  175. value.to.case.filter((c) => grammar!.includes(c)).length ===
  176. value.to.case.length;
  177. const spellMatch = !value.to.spell || data.real.value === value.to.spell;
  178. return caseMatch && spellMatch;
  179. });
  180. return !!(match && match.length > 0);
  181. }, [
  182. addParam?.relations,
  183. data.case?.value,
  184. data.grammar2?.value,
  185. data.real.value,
  186. ]);
  187. /**
  188. * 修复3:popOpen 和 paliColor 的"关闭"逻辑来自 wbwAnchor 的变化。
  189. * 不能在 effect 里 setState,改为用 useMemo 派生出是否应该强制关闭,
  190. * 再结合实际的 open state 计算最终值。
  191. * anchorClosed = true 表示当前 anchor 指向别处,本组件应处于关闭态。
  192. */
  193. const anchorClosed = useMemo(() => {
  194. if (!wbwAnchor) return false;
  195. return wbwAnchor.id !== wordSn || wbwAnchor.channel !== channelId;
  196. }, [wbwAnchor, wordSn, channelId]);
  197. // 最终是否打开:自身 state 为 true 且 anchor 未指向别处
  198. const resolvedPopOpen = popOpen && !anchorClosed;
  199. /**
  200. * 修复5:addParam apply/cancel 时重置 paliColor 并重新打开弹窗。
  201. * 原来在 effect 里 setState,改为用 useMemo 派生:
  202. * - forceOpen: 当前 addParam 命令要求重新打开本组件的弹窗
  203. * - forceColorReset: 当前 addParam 命令要求重置颜色
  204. */
  205. const forceOpen = useMemo(() => {
  206. if (addParam?.command !== "apply" && addParam?.command !== "cancel")
  207. return false;
  208. return (
  209. addParam.src_sn === data.sn.join("-") &&
  210. addParam.book === data.book &&
  211. addParam.para === data.para
  212. );
  213. }, [addParam, data.book, data.para, data.sn]);
  214. const forceColorReset = useMemo(() => {
  215. return addParam?.command === "apply" || addParam?.command === "cancel";
  216. }, [addParam?.command]);
  217. // 派生最终 popOpen:forceOpen 优先
  218. const finalPopOpen = forceOpen ? true : resolvedPopOpen;
  219. // 当 forceOpen 激活时,触发 dispatch(side effect 仍需 effect,但 setState 已移除)
  220. useEffect(() => {
  221. if (forceOpen) {
  222. store.dispatch(
  223. add({
  224. book: data.book,
  225. para: data.para,
  226. src_sn: data.sn.join("-"),
  227. command: "finish",
  228. })
  229. );
  230. }
  231. // 仅在 forceOpen 变为 true 时触发一次,dispatch 不是 setState,合法
  232. }, [forceOpen, data.book, data.para, data.sn]);
  233. // paliColor 合并:交互状态优先,forceColorReset/anchorClosed 时重置,其次 relation 高亮
  234. const resolvedPaliColor =
  235. anchorClosed || forceColorReset
  236. ? relationHighlight
  237. ? "greenyellow"
  238. : "unset"
  239. : paliColor !== "unset"
  240. ? paliColor
  241. : relationHighlight
  242. ? "greenyellow"
  243. : "unset";
  244. // ── Sub-components ────────────────────────────────────────────────────────
  245. const wbwDialog = () => (
  246. <WbwDetail
  247. data={data}
  248. visible={finalPopOpen}
  249. popIsTop={popOnTop}
  250. readonly={readonly}
  251. onClose={() => {
  252. setPaliColor("unset");
  253. setPopOpen(false);
  254. }}
  255. onSave={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
  256. onSave?.(e, isPublish, isPublic);
  257. setPopOpen(false);
  258. setPaliColor("unset");
  259. }}
  260. onAttachmentSelectOpen={(open: boolean) => {
  261. setPopOpen(!open);
  262. }}
  263. onPopTopChange={(value: boolean) => {
  264. console.debug(PopPlacement, value);
  265. }}
  266. onTagCreate={(newTags: ITagMapData[]) => {
  267. setTags(newTags);
  268. }}
  269. />
  270. );
  271. // ── Pali class & padding ──────────────────────────────────────────────────
  272. let classPali = "pali";
  273. if (data.style?.value === "note") {
  274. classPali = "wbw_note";
  275. } else if (data.style?.value === "bld" && !data.word.value.includes("{")) {
  276. classPali = "pali wbw_bold";
  277. }
  278. const padding =
  279. typeof data.real !== "undefined" && data.real.value !== ""
  280. ? "4px"
  281. : "4px 0";
  282. // ── Pali node ─────────────────────────────────────────────────────────────
  283. let pali: React.ReactNode;
  284. if (data.word.value.includes("}")) {
  285. const paliArray = data.word.value.replace("{", "").split("}");
  286. pali = (
  287. <>
  288. <span style={{ fontWeight: 700 }}>
  289. <PaliText
  290. style={{ color: "brown" }}
  291. text={paliArray[0]}
  292. termToLocal={false}
  293. />
  294. </span>
  295. <PaliText
  296. style={{ color: "brown" }}
  297. text={paliArray[1]}
  298. termToLocal={false}
  299. />
  300. </>
  301. );
  302. } else {
  303. pali = (
  304. <PaliText
  305. style={{ color: "brown" }}
  306. text={data.word.value}
  307. termToLocal={false}
  308. />
  309. );
  310. }
  311. const paliWord = (
  312. <span
  313. className={classPali}
  314. style={{ backgroundColor: resolvedPaliColor, padding, borderRadius: 5 }}
  315. onClick={() => {
  316. if (typeof data.real?.value === "string") {
  317. store.dispatch(lookup(data.real.value));
  318. }
  319. }}
  320. >
  321. {pali}
  322. </span>
  323. );
  324. // ── Render ────────────────────────────────────────────────────────────────
  325. if (typeof data.real !== "undefined" && data.real.value !== "") {
  326. // 非标点符号
  327. return (
  328. <div className="pali_shell" ref={divShell}>
  329. <div style={{ position: "absolute", marginTop: -24 }}>
  330. <TagsArea
  331. resId={data.uid}
  332. resType="wbw"
  333. selectorTitle={data.word.value}
  334. data={tags}
  335. max={1}
  336. />
  337. </div>
  338. <span className="pali_shell_spell">
  339. {data.grammarId ? (
  340. <span
  341. onClick={() => store.dispatch(grammarId(data.grammarId))}
  342. style={{ cursor: "pointer" }}
  343. >
  344. <QuestionCircleOutlined style={{ color: "blue" }} />
  345. </span>
  346. ) : null}
  347. <Popover
  348. content={wbwDialog}
  349. placement={popPlacement}
  350. trigger="click"
  351. open={finalPopOpen}
  352. >
  353. <span
  354. onClick={() => {
  355. popOpenChange(true);
  356. store.dispatch(showWbw({ id: wordSn, channel: channelId }));
  357. }}
  358. >
  359. {mode === "wbw" ? (
  360. paliWord
  361. ) : (
  362. <span className="edit_icon">
  363. <EditOutlined style={{ cursor: "pointer" }} />
  364. </span>
  365. )}
  366. </span>
  367. </Popover>
  368. {mode === "edit" ? paliWord : null}
  369. </span>
  370. {/*
  371. antd6: Space 组件内部渲染方式调整,子节点返回 null 可能导致多余间距。
  372. 改用 inline-flex 容器替代 <Space>,更可预期地处理动态显隐子节点。
  373. */}
  374. <span style={{ display: "inline-flex", gap: 4, alignItems: "center" }}>
  375. <VideoIcon attachments={data.attachments} />
  376. <NoteIcon note={data.note?.value ?? undefined} />
  377. <BookMarkIcon
  378. text={data.bookMarkText?.value ?? undefined}
  379. color={
  380. data.bookMarkColor?.value
  381. ? bookMarkColor[data.bookMarkColor.value]
  382. : "white"
  383. }
  384. />
  385. <RelationIcon hasRelation={!!data.relation} />
  386. <WbwPaliDiscussionIcon
  387. data={data}
  388. studio={studio}
  389. channelId={channelId}
  390. />
  391. </span>
  392. </div>
  393. );
  394. }
  395. // 标点符号
  396. return (
  397. <div className="pali_shell" style={{ cursor: "unset" }}>
  398. {data.bookName ? (
  399. <ParaLinkCtl
  400. title={data.word.value}
  401. bookName={data.bookName}
  402. paragraphs={data.word?.value}
  403. />
  404. ) : (
  405. paliWord
  406. )}
  407. </div>
  408. );
  409. };
  410. export default WbwPaliWidget;