| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- "use client";
- import { Fragment, useMemo, useState } from "react";
- import type {
- ScriptDescriptionPart,
- ScriptDimension,
- ScriptElementRow,
- ScriptFacet,
- ScriptParagraphRow,
- } from "@/lib/types";
- const FACETS = [
- ["theme", "主题", "theme", "主题描述"],
- ["form", "形式", "form", "形式描述"],
- ["function", "作用", "action", "作用描述"],
- ["feeling", "感受", "feeling", "感受描述"],
- ] as const;
- const PARA_COL_WIDTH = 100;
- const ATOM_TYPE_COL_WIDTH = 72;
- const ATOM_DIM_COL_WIDTH = 108;
- const ATOM_VAL_COL_WIDTH = 108;
- const OUTLINE_DESC_WIDTH = 155;
- const FIXED_COLS_WIDTH = 150 + (OUTLINE_DESC_WIDTH + ATOM_TYPE_COL_WIDTH + ATOM_DIM_COL_WIDTH + ATOM_VAL_COL_WIDTH) * 4 + 400;
- interface Props {
- paragraphs: ScriptParagraphRow[];
- elements: ScriptElementRow[];
- onOpenElement: (id: number | string) => void;
- }
- export function ScriptTable({ paragraphs, elements, onOpenElement }: Props) {
- const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
- const maxDepth = Math.max(1, ...paragraphs.map((row) => Number(row.depth || 0) + 1));
- const elementLookup = useMemo(() => buildElementLookup(elements), [elements]);
- const hiddenIds = useMemo(() => descendantIds(paragraphs, collapsed), [paragraphs, collapsed]);
- const rowsWithChildren = useMemo(() => {
- const parents = new Set(paragraphs.map((row) => row.parentId).filter((value) => value != null).map(String));
- return new Set(paragraphs.filter((row) => parents.has(String(row.id)) || parents.has(String(row.rowId))).map((row) => String(row.id)));
- }, [paragraphs]);
- const toggle = (id: number | string) => setCollapsed((current) => {
- const next = new Set(current);
- const key = String(id);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- return next;
- });
- return <section className="originalScriptTable" aria-label="完整脚本创作表">
- <div className="scriptTableTitle">
- <strong>创作脚本</strong>
- <span className="script-legend"><span className="legend-item"><i className="legend-swatch" />脚本元素</span></span>
- </div>
- <div className="script-table-wrapper" tabIndex={0} aria-label="可横向和纵向滚动的脚本创作表">
- <table className="script-table" style={{ width: maxDepth * PARA_COL_WIDTH + FIXED_COLS_WIDTH }}>
- <caption className="srOnly">完整脚本创作表</caption>
- <colgroup>
- {Array.from({ length: maxDepth }, (_, index) => <col key={`paragraph-${index}`} style={{ width: PARA_COL_WIDTH }} />)}
- <col style={{ width: 150 }} />
- {FACETS.flatMap(([key]) => [
- <col key={`${key}-description`} style={{ width: OUTLINE_DESC_WIDTH }} />,
- <col key={`${key}-type`} style={{ width: ATOM_TYPE_COL_WIDTH }} />,
- <col key={`${key}-name`} style={{ width: ATOM_DIM_COL_WIDTH }} />,
- <col key={`${key}-value`} style={{ width: ATOM_VAL_COL_WIDTH }} />,
- ])}
- <col style={{ width: 400 }} />
- </colgroup>
- <TableHeader maxDepth={maxDepth} />
- <tbody>{paragraphs.map((paragraph) => hiddenIds.has(String(paragraph.id)) ? null : <tr key={String(paragraph.rowId)} className={`script-row-l${Number(paragraph.depth || 0) + 1}`}>
- {Array.from({ length: maxDepth }, (_, columnIndex) => {
- const isOwnLevel = columnIndex === Number(paragraph.depth || 0);
- const isLast = columnIndex === maxDepth - 1;
- return <td key={columnIndex} className={`col-paragraph${isLast ? " col-paragraph-last" : ""}`} style={{ left: columnIndex * PARA_COL_WIDTH }}>
- {isOwnLevel ? <>
- {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}
- {paragraph.name || "未命名"}
- </> : null}
- </td>;
- })}
- <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>
- {FACETS.map(([key, , section]) => <FacetCells key={key} section={section} facet={paragraph.sections[key]} elementLookup={elementLookup} onOpenElement={onOpenElement} />)}
- <td className="col-full-desc"><ExpandableDescription parts={paragraph.fullDescriptionParts} fallback={paragraph.fullDescription} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
- </tr>)}</tbody>
- </table>
- </div>
- </section>;
- }
- function TableHeader({ maxDepth }: { maxDepth: number }) {
- return <thead>
- <tr className="st-header-group">
- <th colSpan={maxDepth} className="col-paragraph" style={{ left: 0 }}>段落</th>
- <th rowSpan={3} className="col-range">段落范围</th>
- <th colSpan={16} className="col-outline">创作目录大纲(段本身)</th>
- <th rowSpan={3} className="col-full-desc">完整描述</th>
- </tr>
- <tr className="st-header-subgroup">
- {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>)}
- {FACETS.map(([, label, section]) => <th key={section} colSpan={4} className={`col-outline col-outline-${section}`}>{label}</th>)}
- </tr>
- <tr className="st-header-col">
- {FACETS.map(([, , section, description]) => <Fragment key={section}>
- <th className={`col-outline col-outline-${section}`}>{description}</th>
- <th className={`col-outline col-outline-${section} col-atom-sub col-atom-type`}>维度类型</th>
- <th className={`col-outline col-outline-${section} col-atom-sub col-atom-dim`}>维度名</th>
- <th className={`col-outline col-outline-${section} col-atom-sub col-atom-val`}>维度值</th>
- </Fragment>)}
- </tr>
- </thead>;
- }
- function FacetCells({ section, facet, elementLookup, onOpenElement }: { section: string; facet: ScriptFacet; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
- const dimensions = sortDimensions(facet.dimensions, section === "feeling");
- return <>
- <td className={`col-outline col-outline-${section}`}><ElementText parts={facet.descriptionParts} fallback={facet.description} elementLookup={elementLookup} onOpenElement={onOpenElement} /></td>
- {dimensions.length ? <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`}>
- <table className="atom-rows-table">
- <colgroup><col style={{ width: ATOM_TYPE_COL_WIDTH }} /><col style={{ width: ATOM_DIM_COL_WIDTH }} /><col style={{ width: ATOM_VAL_COL_WIDTH }} /></colgroup>
- <tbody>{dimensions.map((dimension, index) => {
- const dimClass = dimensionClass(dimension.type, section === "feeling");
- return <tr key={`${dimension.id ?? "dimension"}-${index}`}>
- <td className={`col-atom-type${dimClass ? ` ${dimClass}` : ""}`}>{section === "feeling" ? "—" : dimensionTypeLabel(dimension.type)}</td>
- <td className={`col-atom-dim${dimClass ? ` ${dimClass}` : ""}`}>{dimension.name || "—"}</td>
- <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>
- </tr>;
- })}</tbody>
- </table>
- </td> : <td colSpan={3} className={`col-outline col-outline-${section} col-atom-group col-atom-group-${section}`} />}
- </>;
- }
- function ExpandableDescription({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
- const [expanded, setExpanded] = useState(false);
- return <div className={`cell-expandable${expanded ? " expanded" : ""}`} role="button" tabIndex={0} aria-expanded={expanded} onClick={() => setExpanded((value) => !value)} onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- setExpanded((value) => !value);
- }
- }}><ElementText parts={parts} fallback={fallback} elementLookup={elementLookup} onOpenElement={onOpenElement} /></div>;
- }
- function ElementText({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) {
- if (!parts.length) return <>{highlightPlainText(fallback || "", elementLookup.names)}</>;
- return <>{parts.map((part, index) => part.type === "text"
- ? <Fragment key={index}>{highlightPlainText(part.text, elementLookup.names)}</Fragment>
- : part.resolved
- ? <button key={index} type="button" className="elem-hl-extended" title="在脚本元素中查看" onClick={(event) => { event.stopPropagation(); onOpenElement(part.id); }}>{part.name}</button>
- : <span key={index}>{part.text}</span>)}</>;
- }
- type ElementLookup = { names: string[] };
- function buildElementLookup(elements: ScriptElementRow[]): ElementLookup {
- return { names: [...new Set(elements.map((element) => String(element.name || "").trim()).filter(Boolean))].sort((a, b) => b.length - a.length) };
- }
- function highlightPlainText(text: string, names: string[]) {
- if (!text || !names.length) return text || "";
- const pattern = names.map(escapeRegExp).join("|");
- if (!pattern) return text;
- const matcher = new RegExp(`(${pattern})`, "g");
- return text.split(matcher).map((part, index) => names.includes(part) ? <span key={index} className="elem-hl-extended">{part}</span> : <Fragment key={index}>{part}</Fragment>);
- }
- function escapeRegExp(value: string) {
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- }
- function sortDimensions(dimensions: ScriptDimension[], isFeeling: boolean) {
- if (isFeeling) return [...dimensions];
- const rank: Record<string, number> = { "主维度": 0, "从维度": 1, "子元素": 2 };
- return [...dimensions].sort((left, right) => {
- const typeDiff = (rank[String(left.type || "")] ?? 99) - (rank[String(right.type || "")] ?? 99);
- if (typeDiff) return typeDiff;
- const nameDiff = String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
- return nameDiff || String(left.value || "").localeCompare(String(right.value || ""), "zh-CN");
- });
- }
- function dimensionTypeLabel(value?: string | null) {
- if (value === "主维度") return "主";
- if (value === "从维度") return "从";
- return value || "—";
- }
- function dimensionClass(value: string | null | undefined, isFeeling: boolean) {
- if (isFeeling) return "";
- if (value === "主维度") return "atom-dim-main";
- if (value === "从维度") return "atom-dim-sub";
- return "";
- }
- function descendantIds(paragraphs: ScriptParagraphRow[], collapsed: Set<string>) {
- const hidden = new Set<string>();
- let changed = true;
- while (changed) {
- changed = false;
- for (const row of paragraphs) {
- const parent = row.parentId == null ? null : String(row.parentId);
- if (!parent || (!collapsed.has(parent) && !hidden.has(parent))) continue;
- const key = String(row.id);
- if (!hidden.has(key)) {
- hidden.add(key);
- changed = true;
- }
- }
- }
- return hidden;
- }
|