File size: 5,790 Bytes
68722e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | import { useLayoutEffect, useRef } from 'react';
import type { ProblemFramingPlan, ProblemFramingStep } from '../../../shared/types/api';
import { useI18n } from '../../../shared/i18n';
const DESIGN_FIELDS = ['audience', 'backgroundRules', 'selectedRoute', 'alternativeRoutes', 'outOfScope', 'specialCases', 'visualMotif', 'designerHint'] as const;
// 稿纸语言:字段不带自己的框,静息时只是一行字;悬停与聚焦由底色承接。
// 独立块自带底色反馈,行内字段把反馈让给整行。
const blockField = 'w-full overflow-hidden rounded-lg bg-transparent px-2 py-1.5 text-sm leading-6 text-text-primary transition-colors duration-200 hover:bg-bg-tertiary/15 focus:bg-bg-tertiary/20 focus:outline-none disabled:opacity-60';
const rowField = 'w-full overflow-hidden bg-transparent text-text-primary focus:outline-none disabled:opacity-60';
/** 稿纸里没有固定行高的框:文本有几行,这一行就多高。 */
function GrowingTextarea({ label, value, disabled, className, onChange }: {
label: string;
value: string;
disabled: boolean;
className: string;
onChange: (value: string) => void;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const fit = () => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
};
fit();
// 手写字体加载完成后行高会变,重算一次。
void document.fonts?.ready.then(fit);
}, [value, disabled]);
return <textarea ref={ref} aria-label={label} rows={1} value={value} disabled={disabled} className={className} onChange={e => onChange(e.target.value)} />;
}
/** A linear, editable argument. Group headings never reorder the author's steps. */
export function ProblemPlanEditor({ plan, onChange, disabled = false }: {
plan: ProblemFramingPlan;
onChange: (plan: ProblemFramingPlan) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
const editStep = (index: number, patch: Partial<ProblemFramingStep>) => {
onChange({ ...plan, steps: plan.steps.map((step, i) => i === index ? { ...step, ...patch } : step) });
};
return <div className="mx-auto max-w-3xl space-y-6 pb-4">
<header className="space-y-3">
<h2 className="text-xl text-text-primary">{plan.headline}</h2>
<p className="text-sm leading-6 text-text-secondary">{plan.summary}</p>
{(['question', 'answer'] as const).map(field => plan[field] !== undefined && <label key={field} className="block">
<span className="text-xs tracking-[0.14em] text-text-tertiary">{t(`problem.field.${field}`)}</span>
<GrowingTextarea label={t(`problem.field.${field}`)} className={blockField} value={plan[field]} disabled={disabled} onChange={value => onChange({ ...plan, [field]: value })} />
</label>)}
</header>
{/* 时间轴:一条 rail 串起全部步骤,节点圆点落在 rail 上,步骤是一次论证,不是十二个盒子。
颜色必须是裸字面量:类名一旦靠变量拼接,Tailwind 与运行时各拿一份,圆点会静默变透明。 */}
<ol className="space-y-5">
{plan.steps.map((step, index) => {
const startsPhase = index === 0 || step.phase !== plan.steps[index - 1].phase;
return <li key={index} className="group relative pl-9">
{/* 线先渲染、圆点后渲染:节点压在 rail 上,而不是被 rail 划开 */}
<span aria-hidden="true" className={`absolute left-1 w-px bg-text-tertiary/25 ${index === 0 ? 'top-[40px]' : 'top-0'} ${index === plan.steps.length - 1 ? 'hidden' : 'bottom-[-20px]'}`} />
<span aria-hidden="true" className={`absolute left-0 h-[9px] w-[9px] rounded-full bg-text-tertiary/55 transition-colors duration-200 group-focus-within:bg-accent ${startsPhase ? 'top-[39.5px]' : 'top-[15.5px]'}`} />
{startsPhase && <h3 className={`flex items-center gap-3 text-xs tracking-[0.18em] text-text-tertiary ${index === 0 ? 'mb-2' : 'mb-2 mt-8'}`}>
{t(`problem.phase.${step.phase || 'steps'}`)}
<span aria-hidden="true" className="h-px flex-1 bg-text-tertiary/15" />
</h3>}
<div className="rounded-lg px-2 py-1.5 transition-colors duration-200 group-focus-within:bg-bg-tertiary/20 group-hover:bg-bg-tertiary/10">
<div className="flex items-center gap-2">
<span aria-hidden="true" className="w-6 shrink-0 text-xs tabular-nums text-text-tertiary">{String(index + 1).padStart(2, '0')}</span>
<input aria-label={`${t('problem.step')} ${index + 1} ${t('problem.stepTitle')}`} className={`${rowField} text-base leading-7`} value={step.title} disabled={disabled} onChange={e => editStep(index, { title: e.target.value })} />
</div>
<GrowingTextarea label={`${t('problem.step')} ${index + 1} ${t('problem.stepContent')}`} className={`${rowField} pl-8 text-sm leading-6`} value={step.content} disabled={disabled} onChange={value => editStep(index, { content: value })} />
</div>
</li>;
})}
</ol>
<details className="border-t border-text-tertiary/15 pt-4">
<summary className="cursor-pointer text-sm text-text-secondary transition-colors hover:text-text-primary">{t('problem.designBasis')}</summary>
<div className="mt-4 space-y-4">{DESIGN_FIELDS.map(field => plan[field] !== undefined && <label key={field} className="block">
<span className="text-xs tracking-[0.14em] text-text-tertiary">{t(`problem.field.${field}`)}</span>
<GrowingTextarea label={t(`problem.field.${field}`)} className={blockField} value={plan[field]} disabled={disabled} onChange={value => onChange({ ...plan, [field]: value })} />
</label>)}</div>
</details>
</div>;
}
|