返回 presentation-ai
1 import type React from "react";
2 import { useCallback, useEffect, useRef } from "react";
3
4 import { GridHeader } from "./grid-header";
5 import { GridRow } from "./grid-row";
6 import {
7 type ChartDataField,
8 type ChartDataRow,
9 type ChartEditorSchema,
10 } from "./schemas";
11 import { type ChartDataType, type SeriesChartType } from "./types";
12
13 const EMPTY_SERIES_CHART_TYPES: Record<string, SeriesChartType> = {};
14
15 interface GridProps {
16 data: ChartDataType;
17 fields: ChartDataField[];
18 schema: ChartEditorSchema;
19 seriesNames: string[];
20 focusedCell: { row: number; col: number } | null;
21 onUpdateCell: (rowIndex: number, field: string, value: string) => void;
22 onRemoveRow: (rowIndex: number) => void;
23 onRenameSeries: (oldName: string, newName: string) => void;
24 onRemoveSeries: (name: string) => void;
25 onAddRow: () => void;
26 onFocusCell: (row: number, col: number) => void;
27 setFocusedCell: (cell: { row: number; col: number } | null) => void;
28 seriesChartTypes?: Record<string, SeriesChartType>;
29 onSeriesChartTypeChange?: (
30 seriesName: string,
31 chartType: SeriesChartType,
32 ) => void;
33 isComposedChart?: boolean;
34 labelKey?: string;
35 }
36
37 function toRows(data: ChartDataType): ChartDataRow[] {
38 return Array.isArray(data) ? (data as ChartDataRow[]) : [];
39 }
40
41 export function Grid({
42 data,
43 fields,
44 schema,
45 seriesNames,
46 focusedCell,
47 onUpdateCell,
48 onRemoveRow,
49 onRenameSeries,
50 onRemoveSeries,
51 onAddRow,
52 onFocusCell,
53 setFocusedCell,
54 seriesChartTypes = EMPTY_SERIES_CHART_TYPES,
55 onSeriesChartTypeChange,
56 isComposedChart = false,
57 }: GridProps) {
58 const cellRefs = useRef<Map<string, HTMLInputElement> | null>(null);
59 if (!cellRefs.current) {
60 cellRefs.current = new Map();
61 }
62 const cellRefsCurrent = cellRefs.current;
63 const rows = toRows(data);
64
65 const registerCell = useCallback(
66 (row: number, col: number, el: HTMLInputElement | null) => {
67 const key = `${row}-${col}`;
68 if (el) {
69 cellRefsCurrent.set(key, el);
70 } else {
71 cellRefsCurrent.delete(key);
72 }
73 },
74 [],
75 );
76
77 const focusCellElement = useCallback((row: number, col: number) => {
78 const key = `${row}-${col}`;
79 const cell = cellRefsCurrent.get(key);
80 if (cell) {
81 cell.focus();
82 cell.select();
83 }
84 }, []);
85
86 useEffect(() => {
87 if (focusedCell) {
88 focusCellElement(focusedCell.row, focusedCell.col);
89 }
90 }, [focusedCell, focusCellElement]);
91
92 const handleKeyDown = useCallback(
93 (e: React.KeyboardEvent<HTMLInputElement>, row: number, col: number) => {
94 const totalCols = fields.length;
95 const totalRows = rows.length;
96
97 switch (e.key) {
98 case "Tab":
99 e.preventDefault();
100 if (e.shiftKey) {
101 if (col > 0) {
102 onFocusCell(row, col - 1);
103 } else if (row > 0) {
104 onFocusCell(row - 1, totalCols - 1);
105 }
106 } else if (col < totalCols - 1) {
107 onFocusCell(row, col + 1);
108 } else if (row < totalRows - 1) {
109 onFocusCell(row + 1, 0);
110 } else {
111 onAddRow();
112 setTimeout(() => onFocusCell(totalRows, 0), 50);
113 }
114 break;
115
116 case "Enter":
117 e.preventDefault();
118 if (row < totalRows - 1) {
119 onFocusCell(row + 1, col);
120 } else {
121 onAddRow();
122 setTimeout(() => onFocusCell(totalRows, col), 50);
123 }
124 break;
125
126 case "ArrowUp":
127 e.preventDefault();
128 if (row > 0) onFocusCell(row - 1, col);
129 break;
130
131 case "ArrowDown":
132 e.preventDefault();
133 if (row < totalRows - 1) onFocusCell(row + 1, col);
134 break;
135
136 case "ArrowLeft":
137 if (e.currentTarget.selectionStart === 0) {
138 e.preventDefault();
139 if (col > 0) onFocusCell(row, col - 1);
140 }
141 break;
142
143 case "ArrowRight":
144 if (e.currentTarget.selectionStart === e.currentTarget.value.length) {
145 e.preventDefault();
146 if (col < totalCols - 1) onFocusCell(row, col + 1);
147 }
148 break;
149 }
150 },
151 [fields.length, rows.length, onFocusCell, onAddRow],
152 );
153
154 return (
155 <div className="overflow-hidden rounded-md border border-border bg-background">
156 <div className="overflow-x-auto">
157 <table className="w-full border-collapse">
158 <GridHeader
159 fields={fields}
160 schema={schema}
161 seriesNames={seriesNames}
162 onRenameSeries={onRenameSeries}
163 onRemoveSeries={onRemoveSeries}
164 seriesChartTypes={seriesChartTypes}
165 onSeriesChartTypeChange={onSeriesChartTypeChange}
166 isComposedChart={isComposedChart}
167 />
168 <tbody>
169 {rows.map((row, rowIndex) => (
170 <GridRow
171 key={rowIndex}
172 row={row}
173 fields={fields}
174 rowIndex={rowIndex}
175 focusedCol={
176 focusedCell?.row === rowIndex ? focusedCell.col : null
177 }
178 canDelete={rows.length > 1}
179 onUpdateCell={(field, value) =>
180 onUpdateCell(rowIndex, field, value)
181 }
182 onRemoveRow={() => onRemoveRow(rowIndex)}
183 onKeyDown={(e, col) => handleKeyDown(e, rowIndex, col)}
184 onFocus={(col) => setFocusedCell({ row: rowIndex, col })}
185 registerCell={(col, el) => registerCell(rowIndex, col, el)}
186 />
187 ))}
188 </tbody>
189 </table>
190 </div>
191 </div>
192 );
193 }
194
194 lines Plain Text