ScriptTable.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. "use client";
  2. import { Fragment, useMemo, useState } from "react";
  3. import type {
  4. ScriptDescriptionPart,
  5. ScriptDimension,
  6. ScriptElementRow,
  7. ScriptFacet,
  8. ScriptParagraphRow,
  9. } from "@/lib/types";
  10. const FACETS = [
  11. ["theme", "主题", "theme", "主题描述"],
  12. ["form", "形式", "form", "形式描述"],
  13. ["function", "作用", "action", "作用描述"],
  14. ["feeling", "感受", "feeling", "感受描述"],
  15. ] as const;
  16. const PARA_COL_WIDTH = 100;
  17. const ATOM_TYPE_COL_WIDTH = 72;
  18. const ATOM_DIM_COL_WIDTH = 108;
  19. const ATOM_VAL_COL_WIDTH = 108;
  20. const OUTLINE_DESC_WIDTH = 155;
  21. const FIXED_COLS_WIDTH = 150 + (OUTLINE_DESC_WIDTH + ATOM_TYPE_COL_WIDTH + ATOM_DIM_COL_WIDTH + ATOM_VAL_COL_WIDTH) * 4 + 400;
  22. interface Props {
  23. paragraphs: ScriptParagraphRow[];
  24. elements: ScriptElementRow[];
  25. onOpenElement: (id: number | string) => void;
  26. }
  27. export function ScriptTable({ paragraphs, elements, onOpenElement }: Props) {
  28. const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
  29. const maxDepth = Math.max(1, ...paragraphs.map((row) => Number(row.depth || 0) + 1));
  30. const elementLookup = useMemo(() => buildElementLookup(elements), [elements]);
  31. const hiddenIds = useMemo(() => descendantIds(paragraphs, collapsed), [paragraphs, collapsed]);
  32. const rowsWithChildren = useMemo(() => {
  33. const parents = new Set(paragraphs.map((row) => row.parentId).filter((value) => value != null).map(String));
  34. return new Set(paragraphs.filter((row) => parents.has(String(row.id)) || parents.has(String(row.rowId))).map((row) => String(row.id)));
  35. }, [paragraphs]);
  36. const toggle = (id: number | string) => setCollapsed((current) => {
  37. const next = new Set(current);
  38. const key = String(id);
  39. if (next.has(key)) next.delete(key);
  40. else next.add(key);
  41. return next;
  42. });
  43. return <section className="originalScriptTable" aria-label="完整脚本创作表">
  44. <div className="scriptTableTitle">
  45. <strong>创作脚本</strong>
  46. <span className="script-legend"><span className="legend-item"><i className="legend-swatch" />脚本元素</span></span>
  47. </div>
  48. <div className="script-table-wrapper" tabIndex={0} aria-label="可横向和纵向滚动的脚本创作表">
  49. <table className="script-table" style={{ width: maxDepth * PARA_COL_WIDTH + FIXED_COLS_WIDTH }}>
  50. <caption className="srOnly">完整脚本创作表</caption>
  51. <colgroup>
  52. {Array.from({ length: maxDepth }, (_, index) => <col key={`paragraph-${index}`} style={{ width: PARA_COL_WIDTH }} />)}
  53. <col style={{ width: 150 }} />
  54. {FACETS.flatMap(([key]) => [
  55. <col key={`${key}-description`} style={{ width: OUTLINE_DESC_WIDTH }} />,
  56. <col key={`${key}-type`} style={{ width: ATOM_TYPE_COL_WIDTH }} />,
  57. <col key={`${key}-name`} style={{ width: ATOM_DIM_COL_WIDTH }} />,
  58. <col key={`${key}-value`} style={{ width: ATOM_VAL_COL_WIDTH }} />,
  59. ])}
  60. <col style={{ width: 400 }} />
  61. </colgroup>
  62. <TableHeader maxDepth={maxDepth} />
  63. <tbody>{paragraphs.map((paragraph) => hiddenIds.has(String(paragraph.id)) ? null : <tr key={String(paragraph.rowId)} className={`script-row-l${Number(paragraph.depth || 0) + 1}`}>
  64. {Array.from({ length: maxDepth }, (_, columnIndex) => {
  65. const isOwnLevel = columnIndex === Number(paragraph.depth || 0);
  66. const isLast = columnIndex === maxDepth - 1;
  67. return <td key={columnIndex} className={`col-paragraph${isLast ? " col-paragraph-last" : ""}`} style={{ left: columnIndex * PARA_COL_WIDTH }}>
  68. {isOwnLevel ? <>
  69. {rowsWithChildren.has(String(paragraph.id)) ? <button type="button" className={`collapse-toggle${collapsed.has(String(paragraph.id)) ? " collapsed" : ""}`} aria-label={`${collapsed.has(String(paragraph.id)) ? "展开" : "折叠"}${paragraph.name}`} aria-expanded={!collapsed.has(String(paragraph.id))} onClick={() => toggle(paragraph.id)}>{collapsed.has(String(paragraph.id)) ? "▶" : "▼"}</button> : null}
  70. {paragraph.name || "未命名"}
  71. </> : null}
  72. </td>;
  73. })}
  74. <td className="col-range"><div className="content-range-tags">{paragraph.contentRange.map((tag, index) => <span className="cr-tag" key={`${tag}-${index}`}>{tag}</span>)}</div></td>
  75. {FACETS.map(([key, , section]) => <FacetCells key={key} section={section} facet={paragraph.sections[key]} elementLookup={elementLookup} onOpenElement={onOpenElement} />)}
  76. <td className="col-full-desc"><ExpandableDescription parts={paragraph.fullDescriptionParts} fallback={paragraph.fullDescription} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
  77. </tr>)}</tbody>
  78. </table>
  79. </div>
  80. </section>;
  81. }
  82. function TableHeader({ maxDepth }: { maxDepth: number }) {
  83. return <thead>
  84. <tr className="st-header-group">
  85. <th colSpan={maxDepth} className="col-paragraph" style={{ left: 0 }}>段落</th>
  86. <th rowSpan={3} className="col-range">段落范围</th>
  87. <th colSpan={16} className="col-outline">创作目录大纲(段本身)</th>
  88. <th rowSpan={3} className="col-full-desc">完整描述</th>
  89. </tr>
  90. <tr className="st-header-subgroup">
  91. {Array.from({ length: maxDepth }, (_, index) => <th key={index} rowSpan={2} className={`col-paragraph${index === maxDepth - 1 ? " col-paragraph-last" : ""}`} style={{ left: index * PARA_COL_WIDTH }}>L{index + 1}</th>)}
  92. {FACETS.map(([, label, section]) => <th key={section} colSpan={4} className={`col-outline col-outline-${section}`}>{label}</th>)}
  93. </tr>
  94. <tr className="st-header-col">
  95. {FACETS.map(([, , section, description]) => <Fragment key={section}>
  96. <th className={`col-outline col-outline-${section}`}>{description}</th>
  97. <th className={`col-outline col-outline-${section} col-atom-sub col-atom-type`}>维度类型</th>
  98. <th className={`col-outline col-outline-${section} col-atom-sub col-atom-dim`}>维度名</th>
  99. <th className={`col-outline col-outline-${section} col-atom-sub col-atom-val`}>维度值</th>
  100. </Fragment>)}
  101. </tr>
  102. </thead>;
  103. }
  104. function FacetCells({ section, facet, elementLookup, onOpenElement }: { section: string; facet: ScriptFacet; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
  105. const dimensions = sortDimensions(facet.dimensions, section === "feeling");
  106. return <>
  107. <td className={`col-outline col-outline-${section}`}><ElementText parts={facet.descriptionParts} fallback={facet.description} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
  108. {dimensions.length ? <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`}>
  109. <table className="atom-rows-table">
  110. <colgroup><col style={{ width: ATOM_TYPE_COL_WIDTH }} /><col style={{ width: ATOM_DIM_COL_WIDTH }} /><col style={{ width: ATOM_VAL_COL_WIDTH }} /></colgroup>
  111. <tbody>{dimensions.map((dimension, index) => {
  112. const dimClass = dimensionClass(dimension.type, section === "feeling");
  113. return <tr key={`${dimension.id ?? "dimension"}-${index}`}>
  114. <td className={`col-atom-type${dimClass ? ` ${dimClass}` : ""}`}>{section === "feeling" ? "—" : dimensionTypeLabel(dimension.type)}</td>
  115. <td className={`col-atom-dim${dimClass ? ` ${dimClass}` : ""}`}>{dimension.name || "—"}</td>
  116. <td className="col-atom-val"><span className={dimension.id != null ? "atom-val-with-id" : undefined} title={dimension.id != null ? `id: ${dimension.id}` : undefined}>{dimension.value || "—"}</span></td>
  117. </tr>;
  118. })}</tbody>
  119. </table>
  120. </td> : <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`} />}
  121. </>;
  122. }
  123. function ExpandableDescription({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
  124. const [expanded, setExpanded] = useState(false);
  125. return <div className={`cell-expandable${expanded ? " expanded" : ""}`} role="button" tabIndex={0} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)} onKeyDown={(event) => {
  126. if (event.key === "Enter" || event.key === " ") {
  127. event.preventDefault();
  128. setExpanded((value) => !value);
  129. }
  130. }}><ElementText parts={parts} fallback={fallback} elementLookup={elementLookup} onOpenElement={onOpenElement} /></div>;
  131. }
  132. function ElementText({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
  133. if (!parts.length) return <>{highlightPlainText(fallback || "", elementLookup.names)}</>;
  134. return <>{parts.map((part, index) => part.type === "text"
  135. ? <Fragment key={index}>{highlightPlainText(part.text, elementLookup.names)}</Fragment>
  136. : part.resolved
  137. ? <button key={index} type="button" className="elem-hl-extended" title="在脚本元素中查看" onClick={(event) => { event.stopPropagation(); onOpenElement(part.id); }}>{part.name}</button>
  138. : <span key={index}>{part.text}</span>)}</>;
  139. }
  140. type ElementLookup = { names: string[] };
  141. function buildElementLookup(elements: ScriptElementRow[]): ElementLookup {
  142. return { names: [...new Set(elements.map((element) => String(element.name || "").trim()).filter(Boolean))].sort((a, b) => b.length - a.length) };
  143. }
  144. function highlightPlainText(text: string, names: string[]) {
  145. if (!text || !names.length) return text || "";
  146. const pattern = names.map(escapeRegExp).join("|");
  147. if (!pattern) return text;
  148. const matcher = new RegExp(`(${pattern})`, "g");
  149. return text.split(matcher).map((part, index) => names.includes(part) ? <span key={index} className="elem-hl-extended">{part}</span> : <Fragment key={index}>{part}</Fragment>);
  150. }
  151. function escapeRegExp(value: string) {
  152. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  153. }
  154. function sortDimensions(dimensions: ScriptDimension[], isFeeling: boolean) {
  155. if (isFeeling) return [...dimensions];
  156. const rank: Record<string, number> = { "主维度": 0, "从维度": 1, "子元素": 2 };
  157. return [...dimensions].sort((left, right) => {
  158. const typeDiff = (rank[String(left.type || "")] ?? 99) - (rank[String(right.type || "")] ?? 99);
  159. if (typeDiff) return typeDiff;
  160. const nameDiff = String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
  161. return nameDiff || String(left.value || "").localeCompare(String(right.value || ""), "zh-CN");
  162. });
  163. }
  164. function dimensionTypeLabel(value?: string | null) {
  165. if (value === "主维度") return "主";
  166. if (value === "从维度") return "从";
  167. return value || "—";
  168. }
  169. function dimensionClass(value: string | null | undefined, isFeeling: boolean) {
  170. if (isFeeling) return "";
  171. if (value === "主维度") return "atom-dim-main";
  172. if (value === "从维度") return "atom-dim-sub";
  173. return "";
  174. }
  175. function descendantIds(paragraphs: ScriptParagraphRow[], collapsed: Set<string>) {
  176. const hidden = new Set<string>();
  177. let changed = true;
  178. while (changed) {
  179. changed = false;
  180. for (const row of paragraphs) {
  181. const parent = row.parentId == null ? null : String(row.parentId);
  182. if (!parent || (!collapsed.has(parent) && !hidden.has(parent))) continue;
  183. const key = String(row.id);
  184. if (!hidden.has(key)) {
  185. hidden.add(key);
  186. changed = true;
  187. }
  188. }
  189. }
  190. return hidden;
  191. }