ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
296 lines
7.3 KiB
TypeScript
296 lines
7.3 KiB
TypeScript
/**
|
|
* WhiteboardCanvas — SVG-based whiteboard for classroom scene rendering.
|
|
*
|
|
* Supports incremental drawing operations:
|
|
* - Text (positioned labels)
|
|
* - Shapes (rectangles, circles, arrows)
|
|
* - Charts (bar/line/pie via simple SVG)
|
|
* - LaTeX (rendered as styled text blocks)
|
|
*/
|
|
|
|
import { useCallback } from 'react';
|
|
import type { SceneAction } from '../../types/classroom';
|
|
|
|
interface WhiteboardCanvasProps {
|
|
items: WhiteboardItem[];
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export interface WhiteboardItem {
|
|
type: string;
|
|
data: SceneAction;
|
|
}
|
|
|
|
export function WhiteboardCanvas({
|
|
items,
|
|
width = 800,
|
|
height = 600,
|
|
}: WhiteboardCanvasProps) {
|
|
const renderItem = useCallback((item: WhiteboardItem, index: number) => {
|
|
switch (item.type) {
|
|
case 'whiteboard_draw_text':
|
|
return <TextItem key={index} data={item.data as TextDrawData} />;
|
|
case 'whiteboard_draw_shape':
|
|
return <ShapeItem key={index} data={item.data as ShapeDrawData} />;
|
|
case 'whiteboard_draw_chart':
|
|
return <ChartItem key={index} data={item.data as ChartDrawData} />;
|
|
case 'whiteboard_draw_latex':
|
|
return <LatexItem key={index} data={item.data as LatexDrawData} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-auto">
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className="w-full h-full"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
{/* Grid background */}
|
|
<defs>
|
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f0f0f0" strokeWidth="0.5" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width={width} height={height} fill="url(#grid)" />
|
|
|
|
{/* Rendered items */}
|
|
{items.map((item, i) => renderItem(item, i))}
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TextDrawData {
|
|
type: 'whiteboard_draw_text';
|
|
x: number;
|
|
y: number;
|
|
text: string;
|
|
fontSize?: number;
|
|
color?: string;
|
|
}
|
|
|
|
function TextItem({ data }: { data: TextDrawData }) {
|
|
return (
|
|
<text
|
|
x={data.x}
|
|
y={data.y}
|
|
fontSize={data.fontSize ?? 16}
|
|
fill={data.color ?? '#333333'}
|
|
fontFamily="system-ui, sans-serif"
|
|
>
|
|
{data.text}
|
|
</text>
|
|
);
|
|
}
|
|
|
|
interface ShapeDrawData {
|
|
type: 'whiteboard_draw_shape';
|
|
shape: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
fill?: string;
|
|
}
|
|
|
|
function ShapeItem({ data }: { data: ShapeDrawData }) {
|
|
switch (data.shape) {
|
|
case 'circle':
|
|
return (
|
|
<ellipse
|
|
cx={data.x + data.width / 2}
|
|
cy={data.y + data.height / 2}
|
|
rx={data.width / 2}
|
|
ry={data.height / 2}
|
|
fill={data.fill ?? '#e5e7eb'}
|
|
stroke="#9ca3af"
|
|
strokeWidth={1}
|
|
/>
|
|
);
|
|
case 'arrow':
|
|
return (
|
|
<g>
|
|
<line
|
|
x1={data.x}
|
|
y1={data.y + data.height / 2}
|
|
x2={data.x + data.width}
|
|
y2={data.y + data.height / 2}
|
|
stroke={data.fill ?? '#6b7280'}
|
|
strokeWidth={2}
|
|
markerEnd="url(#arrowhead)"
|
|
/>
|
|
<defs>
|
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
|
<polygon points="0 0, 10 3.5, 0 7" fill={data.fill ?? '#6b7280'} />
|
|
</marker>
|
|
</defs>
|
|
</g>
|
|
);
|
|
default: // rectangle
|
|
return (
|
|
<rect
|
|
x={data.x}
|
|
y={data.y}
|
|
width={data.width}
|
|
height={data.height}
|
|
fill={data.fill ?? '#e5e7eb'}
|
|
stroke="#9ca3af"
|
|
strokeWidth={1}
|
|
rx={4}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
interface ChartDrawData {
|
|
type: 'whiteboard_draw_chart';
|
|
chartType: string;
|
|
data: Record<string, unknown>;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
function ChartItem({ data }: { data: ChartDrawData }) {
|
|
const chartData = data.data;
|
|
const labels = (chartData?.labels as string[]) ?? [];
|
|
const values = (chartData?.values as number[]) ?? [];
|
|
|
|
if (labels.length === 0 || values.length === 0) return null;
|
|
|
|
switch (data.chartType) {
|
|
case 'bar':
|
|
return <BarChart data={data} labels={labels} values={values} />;
|
|
case 'line':
|
|
return <LineChart data={data} labels={labels} values={values} />;
|
|
default:
|
|
return <BarChart data={data} labels={labels} values={values} />;
|
|
}
|
|
}
|
|
|
|
function BarChart({ data, labels, values }: {
|
|
data: ChartDrawData;
|
|
labels: string[];
|
|
values: number[];
|
|
}) {
|
|
const maxVal = Math.max(...values, 1);
|
|
const barWidth = data.width / (labels.length * 2);
|
|
const chartHeight = data.height - 30;
|
|
|
|
return (
|
|
<g transform={`translate(${data.x}, ${data.y})`}>
|
|
{values.map((val, i) => {
|
|
const barHeight = (val / maxVal) * chartHeight;
|
|
return (
|
|
<g key={i}>
|
|
<rect
|
|
x={i * (barWidth * 2) + barWidth / 2}
|
|
y={chartHeight - barHeight}
|
|
width={barWidth}
|
|
height={barHeight}
|
|
fill="#6366f1"
|
|
rx={2}
|
|
/>
|
|
<text
|
|
x={i * (barWidth * 2) + barWidth}
|
|
y={data.height - 5}
|
|
textAnchor="middle"
|
|
fontSize={10}
|
|
fill="#666"
|
|
>
|
|
{labels[i]}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
}
|
|
|
|
function LineChart({ data, labels, values }: {
|
|
data: ChartDrawData;
|
|
labels: string[];
|
|
values: number[];
|
|
}) {
|
|
const maxVal = Math.max(...values, 1);
|
|
const chartHeight = data.height - 30;
|
|
const stepX = data.width / Math.max(labels.length - 1, 1);
|
|
|
|
const points = values.map((val, i) => {
|
|
const x = i * stepX;
|
|
const y = chartHeight - (val / maxVal) * chartHeight;
|
|
return `${x},${y}`;
|
|
}).join(' ');
|
|
|
|
return (
|
|
<g transform={`translate(${data.x}, ${data.y})`}>
|
|
<polyline
|
|
points={points}
|
|
fill="none"
|
|
stroke="#6366f1"
|
|
strokeWidth={2}
|
|
/>
|
|
{values.map((val, i) => {
|
|
const x = i * stepX;
|
|
const y = chartHeight - (val / maxVal) * chartHeight;
|
|
return (
|
|
<g key={i}>
|
|
<circle cx={x} cy={y} r={3} fill="#6366f1" />
|
|
<text
|
|
x={x}
|
|
y={data.height - 5}
|
|
textAnchor="middle"
|
|
fontSize={10}
|
|
fill="#666"
|
|
>
|
|
{labels[i]}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
}
|
|
|
|
interface LatexDrawData {
|
|
type: 'whiteboard_draw_latex';
|
|
latex: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
function LatexItem({ data }: { data: LatexDrawData }) {
|
|
return (
|
|
<g transform={`translate(${data.x}, ${data.y})`}>
|
|
<rect
|
|
x={-4}
|
|
y={-20}
|
|
width={data.latex.length * 10 + 8}
|
|
height={28}
|
|
fill="#fef3c7"
|
|
stroke="#f59e0b"
|
|
strokeWidth={1}
|
|
rx={4}
|
|
/>
|
|
<text
|
|
x={0}
|
|
y={0}
|
|
fontSize={14}
|
|
fill="#92400e"
|
|
fontFamily="'Courier New', monospace"
|
|
>
|
|
{data.latex}
|
|
</text>
|
|
</g>
|
|
);
|
|
}
|