| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- import { useEffect, useRef, useState, useMemo, useCallback } from "react";
- import { Popover, Typography } from "antd";
- import {
- TagTwoTone,
- InfoCircleOutlined,
- ApartmentOutlined,
- EditOutlined,
- QuestionCircleOutlined,
- } from "@ant-design/icons";
- import "./wbw.css";
- import WbwDetail from "./WbwDetail";
- import type { IWbw, IWbwAttachment, TWbwDisplayMode } from "./WbwWord";
- import { bookMarkColor } from "./WbwDetailBookMark";
- import WbwVideoButton from "./WbwVideoButton";
- import PaliText from "./PaliText";
- import store from "../../../store";
- import { grammarId, lookup } from "../../../reducers/command";
- import { useAppSelector } from "../../../hooks";
- import { add, relationAddParam } from "../../../reducers/relation-add";
- import type { ArticleMode } from "../../article/Article";
- import { anchor, showWbw } from "../../../reducers/wbw";
- import { ParaLinkCtl } from "../ParaLink";
- import type { IStudio } from "../../auth/Studio";
- import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
- import type { TooltipPlacement } from "antd/es/tooltip"; // antd6: 路径从 lib → es
- import { temp } from "../../../reducers/setting";
- import TagsArea from "../../tag/TagsArea";
- import type { ITagMapData } from "../../../api/Tag";
- export const PopPlacement = "setting.wbw.pop.placement";
- // ─── VideoIcon ────────────────────────────────────────────────────────────────
- interface IVideoIcon {
- attachments?: IWbwAttachment[];
- }
- const VideoIcon = ({ attachments }: IVideoIcon) => {
- const videoList = attachments?.filter((item) =>
- item.content_type?.includes("video")
- );
- if (!videoList?.length) return null;
- return (
- <WbwVideoButton
- video={videoList.map((item) => ({
- videoId: item.id,
- type: item.content_type,
- title: item.title,
- }))}
- />
- );
- };
- // ─── NoteIcon ─────────────────────────────────────────────────────────────────
- interface INoteIcon {
- note?: string;
- }
- const NoteIcon = ({ note }: INoteIcon) => {
- if (!note?.trim()) return null;
- return (
- <Popover content={note} placement="bottom">
- <InfoCircleOutlined style={{ color: "blue" }} />
- </Popover>
- );
- };
- // ─── RelationIcon ─────────────────────────────────────────────────────────────
- interface IRelationIcon {
- hasRelation?: boolean;
- }
- const RelationIcon = ({ hasRelation }: IRelationIcon) =>
- hasRelation ? <ApartmentOutlined style={{ color: "blue" }} /> : null;
- // ─── BookMarkIcon ─────────────────────────────────────────────────────────────
- interface IBookMarkIcon {
- text?: string;
- color: string;
- }
- const BookMarkIcon = ({ text, color }: IBookMarkIcon) => {
- if (!text?.trim()) return null;
- return (
- <Popover
- content={<Typography.Paragraph copyable>{text}</Typography.Paragraph>}
- placement="bottom"
- >
- <TagTwoTone twoToneColor={color} />
- </Popover>
- );
- };
- // ─── Types ────────────────────────────────────────────────────────────────────
- interface IWidget {
- data: IWbw;
- studio?: IStudio;
- channelId: string;
- display?: TWbwDisplayMode;
- mode?: ArticleMode;
- readonly?: boolean;
- onSave?: (e: IWbw, isPublish: boolean, isPublic: boolean) => void;
- }
- // ─── WbwPaliWidget ────────────────────────────────────────────────────────────
- const WbwPaliWidget = ({
- data,
- channelId,
- mode,
- studio,
- readonly = false,
- onSave,
- }: IWidget) => {
- const [popOpen, setPopOpen] = useState(false);
- const [tags, setTags] = useState<ITagMapData[]>();
- const [paliColor, setPaliColor] = useState("unset");
- const divShell = useRef<HTMLDivElement>(null);
- const wbwAnchor = useAppSelector(anchor);
- const addParam = useAppSelector(relationAddParam);
- const wordSn = `${data.book}-${data.para}-${data.sn.join("-")}`;
- const tempSettings = useAppSelector(temp);
- /**
- * 修复1:popOnTop 是纯派生值(来自 tempSettings),无需 state + effect 同步。
- * 直接用 useMemo 在渲染时计算,消除一次 setState-in-effect。
- */
- const popOnTop = useMemo(() => {
- const popSetting = tempSettings?.find((v) => v.key === PopPlacement);
- console.debug("PopPlacement change", popSetting);
- return popSetting?.value === true;
- }, [tempSettings]);
- /**
- * 修复2:popPlacement 用 useState 存储,但只在事件处理器中更新。
- * 在事件处理器(用户点击)里读取 DOM ref 并 setState 完全合法,
- * 不会产生"effect 内 setState"的级联渲染警告。
- */
- const [popPlacement, setPopPlacement] = useState<TooltipPlacement>("bottom");
- const computeAndSetPlacement = useCallback(() => {
- const rightPanel = document.getElementById("article_right_panel");
- const rightPanelWidth = rightPanel?.offsetWidth ?? 0;
- const containerWidth = window.innerWidth - rightPanelWidth;
- const divRight = divShell.current?.getBoundingClientRect().right ?? 0;
- const toDivRight = containerWidth - divRight;
- setPopPlacement(
- popOnTop
- ? toDivRight > 200
- ? "top"
- : "topRight"
- : toDivRight > 200
- ? "bottom"
- : "bottomRight"
- );
- }, [popOnTop]);
- // ── Popover open/close ────────────────────────────────────────────────────
- const popOpenChange = useCallback(
- (open: boolean) => {
- if (open) {
- // 事件处理器中读取 ref + setState,合法,不产生级联渲染
- computeAndSetPlacement();
- setPaliColor("lightblue");
- } else {
- setPaliColor("unset");
- }
- setPopOpen(open);
- },
- [computeAndSetPlacement]
- );
- /**
- * relation 高亮颜色是派生值,用 useMemo 直接计算。
- */
- const relationHighlight = useMemo(() => {
- let grammar = data.case?.value
- ?.replace("v:ind", "v")
- .replace("#", "$")
- .replace(":", "$")
- .replaceAll(".", "")
- .split("$");
- if (data.grammar2?.value) {
- grammar = grammar
- ? [data.grammar2.value, ...grammar]
- : [data.grammar2.value];
- }
- if (!grammar) return false;
- const match = addParam?.relations?.filter((value) => {
- if (!value.to) return false;
- const caseMatch =
- !value.to.case ||
- value.to.case.filter((c) => grammar!.includes(c)).length ===
- value.to.case.length;
- const spellMatch = !value.to.spell || data.real.value === value.to.spell;
- return caseMatch && spellMatch;
- });
- return !!(match && match.length > 0);
- }, [
- addParam?.relations,
- data.case?.value,
- data.grammar2?.value,
- data.real.value,
- ]);
- /**
- * 修复3:popOpen 和 paliColor 的"关闭"逻辑来自 wbwAnchor 的变化。
- * 不能在 effect 里 setState,改为用 useMemo 派生出是否应该强制关闭,
- * 再结合实际的 open state 计算最终值。
- * anchorClosed = true 表示当前 anchor 指向别处,本组件应处于关闭态。
- */
- const anchorClosed = useMemo(() => {
- if (!wbwAnchor) return false;
- return wbwAnchor.id !== wordSn || wbwAnchor.channel !== channelId;
- }, [wbwAnchor, wordSn, channelId]);
- // 最终是否打开:自身 state 为 true 且 anchor 未指向别处
- const resolvedPopOpen = popOpen && !anchorClosed;
- /**
- * 修复5:addParam apply/cancel 时重置 paliColor 并重新打开弹窗。
- * 原来在 effect 里 setState,改为用 useMemo 派生:
- * - forceOpen: 当前 addParam 命令要求重新打开本组件的弹窗
- * - forceColorReset: 当前 addParam 命令要求重置颜色
- */
- const forceOpen = useMemo(() => {
- if (addParam?.command !== "apply" && addParam?.command !== "cancel")
- return false;
- return (
- addParam.src_sn === data.sn.join("-") &&
- addParam.book === data.book &&
- addParam.para === data.para
- );
- }, [addParam, data.book, data.para, data.sn]);
- const forceColorReset = useMemo(() => {
- return addParam?.command === "apply" || addParam?.command === "cancel";
- }, [addParam?.command]);
- // 派生最终 popOpen:forceOpen 优先
- const finalPopOpen = forceOpen ? true : resolvedPopOpen;
- // 当 forceOpen 激活时,触发 dispatch(side effect 仍需 effect,但 setState 已移除)
- useEffect(() => {
- if (forceOpen) {
- store.dispatch(
- add({
- book: data.book,
- para: data.para,
- src_sn: data.sn.join("-"),
- command: "finish",
- })
- );
- }
- // 仅在 forceOpen 变为 true 时触发一次,dispatch 不是 setState,合法
- }, [forceOpen, data.book, data.para, data.sn]);
- // paliColor 合并:交互状态优先,forceColorReset/anchorClosed 时重置,其次 relation 高亮
- const resolvedPaliColor =
- anchorClosed || forceColorReset
- ? relationHighlight
- ? "greenyellow"
- : "unset"
- : paliColor !== "unset"
- ? paliColor
- : relationHighlight
- ? "greenyellow"
- : "unset";
- // ── Sub-components ────────────────────────────────────────────────────────
- const wbwDialog = () => (
- <WbwDetail
- data={data}
- visible={finalPopOpen}
- popIsTop={popOnTop}
- readonly={readonly}
- onClose={() => {
- setPaliColor("unset");
- setPopOpen(false);
- }}
- onSave={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
- onSave?.(e, isPublish, isPublic);
- setPopOpen(false);
- setPaliColor("unset");
- }}
- onAttachmentSelectOpen={(open: boolean) => {
- setPopOpen(!open);
- }}
- onPopTopChange={(value: boolean) => {
- console.debug(PopPlacement, value);
- }}
- onTagCreate={(newTags: ITagMapData[]) => {
- setTags(newTags);
- }}
- />
- );
- // ── Pali class & padding ──────────────────────────────────────────────────
- let classPali = "pali";
- if (data.style?.value === "note") {
- classPali = "wbw_note";
- } else if (data.style?.value === "bld" && !data.word.value.includes("{")) {
- classPali = "pali wbw_bold";
- }
- const padding =
- typeof data.real !== "undefined" && data.real.value !== ""
- ? "4px"
- : "4px 0";
- // ── Pali node ─────────────────────────────────────────────────────────────
- let pali: React.ReactNode;
- if (data.word.value.includes("}")) {
- const paliArray = data.word.value.replace("{", "").split("}");
- pali = (
- <>
- <span style={{ fontWeight: 700 }}>
- <PaliText
- style={{ color: "brown" }}
- text={paliArray[0]}
- termToLocal={false}
- />
- </span>
- <PaliText
- style={{ color: "brown" }}
- text={paliArray[1]}
- termToLocal={false}
- />
- </>
- );
- } else {
- pali = (
- <PaliText
- style={{ color: "brown" }}
- text={data.word.value}
- termToLocal={false}
- />
- );
- }
- const paliWord = (
- <span
- className={classPali}
- style={{ backgroundColor: resolvedPaliColor, padding, borderRadius: 5 }}
- onClick={() => {
- if (typeof data.real?.value === "string") {
- store.dispatch(lookup(data.real.value));
- }
- }}
- >
- {pali}
- </span>
- );
- // ── Render ────────────────────────────────────────────────────────────────
- if (typeof data.real !== "undefined" && data.real.value !== "") {
- // 非标点符号
- return (
- <div className="pali_shell" ref={divShell}>
- <div style={{ position: "absolute", marginTop: -24 }}>
- <TagsArea
- resId={data.uid}
- resType="wbw"
- selectorTitle={data.word.value}
- data={tags}
- max={1}
- />
- </div>
- <span className="pali_shell_spell">
- {data.grammarId ? (
- <span
- onClick={() => store.dispatch(grammarId(data.grammarId))}
- style={{ cursor: "pointer" }}
- >
- <QuestionCircleOutlined style={{ color: "blue" }} />
- </span>
- ) : null}
- <Popover
- content={wbwDialog}
- placement={popPlacement}
- trigger="click"
- open={finalPopOpen}
- >
- <span
- onClick={() => {
- popOpenChange(true);
- store.dispatch(showWbw({ id: wordSn, channel: channelId }));
- }}
- >
- {mode === "wbw" ? (
- paliWord
- ) : (
- <span className="edit_icon">
- <EditOutlined style={{ cursor: "pointer" }} />
- </span>
- )}
- </span>
- </Popover>
- {mode === "edit" ? paliWord : null}
- </span>
- {/*
- antd6: Space 组件内部渲染方式调整,子节点返回 null 可能导致多余间距。
- 改用 inline-flex 容器替代 <Space>,更可预期地处理动态显隐子节点。
- */}
- <span style={{ display: "inline-flex", gap: 4, alignItems: "center" }}>
- <VideoIcon attachments={data.attachments} />
- <NoteIcon note={data.note?.value ?? undefined} />
- <BookMarkIcon
- text={data.bookMarkText?.value ?? undefined}
- color={
- data.bookMarkColor?.value
- ? bookMarkColor[data.bookMarkColor.value]
- : "white"
- }
- />
- <RelationIcon hasRelation={!!data.relation} />
- <WbwPaliDiscussionIcon
- data={data}
- studio={studio}
- channelId={channelId}
- />
- </span>
- </div>
- );
- }
- // 标点符号
- return (
- <div className="pali_shell" style={{ cursor: "unset" }}>
- {data.bookName ? (
- <ParaLinkCtl
- title={data.word.value}
- bookName={data.bookName}
- paragraphs={data.word?.value}
- />
- ) : (
- paliWord
- )}
- </div>
- );
- };
- export default WbwPaliWidget;
|