"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>(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
创作脚本 脚本元素
{Array.from({ length: maxDepth }, (_, index) => )} {FACETS.flatMap(([key]) => [ , , , , ])} {paragraphs.map((paragraph) => hiddenIds.has(String(paragraph.id)) ? null : {Array.from({ length: maxDepth }, (_, columnIndex) => { const isOwnLevel = columnIndex === Number(paragraph.depth || 0); const isLast = columnIndex === maxDepth - 1; return ; })} {FACETS.map(([key, , section]) => )} )}
完整脚本创作表
{isOwnLevel ? <> {rowsWithChildren.has(String(paragraph.id)) ? : null} {paragraph.name || "未命名"} : null}
{paragraph.contentRange.map((tag, index) => {tag})}
; } function TableHeader({ maxDepth }: { maxDepth: number }) { return 段落 段落范围 创作目录大纲(段本身) 完整描述 {Array.from({ length: maxDepth }, (_, index) => L{index + 1})} {FACETS.map(([, label, section]) => {label})} {FACETS.map(([, , section, description]) => {description} 维度类型 维度名 维度值 )} ; } function FacetCells({ section, facet, elementLookup, onOpenElement }: { section: string; facet: ScriptFacet; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) { const dimensions = sortDimensions(facet.dimensions, section === "feeling"); return <> {dimensions.length ? {dimensions.map((dimension, index) => { const dimClass = dimensionClass(dimension.type, section === "feeling"); return ; })}
{section === "feeling" ? "—" : dimensionTypeLabel(dimension.type)} {dimension.name || "—"} {dimension.value || "—"}
: } ; } function ExpandableDescription({ parts, fallback, elementLookup, onOpenElement }: { parts: ScriptDescriptionPart[]; fallback?: string | null; elementLookup: ElementLookup; onOpenElement: Props["onOpenElement"] }) { const [expanded, setExpanded] = useState(false); return
setExpanded((value) => !value)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setExpanded((value) => !value); } }}>
; } 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" ? {highlightPlainText(part.text, elementLookup.names)} : part.resolved ? : {part.text})}; } 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) ? {part} : {part}); } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function sortDimensions(dimensions: ScriptDimension[], isFeeling: boolean) { if (isFeeling) return [...dimensions]; const rank: Record = { "主维度": 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) { const hidden = new Set(); 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; }