Clipboard & Fill
TSV 직렬화·잘라내기 이동·자동 채우기 — 스프레드시트 인터랙션을 헤드리스로 얹는 두 레이어.
두 레이어가 공유하는 한 가지 모델
클립보드와 자동 채우기는 표면상 다른 기능이지만, 둘 다 하나의 활성 직사각형(active range)을 소스로 삼아 다른 직사각형에 값을 쏟아붓는다는 같은 모델 위에 있습니다.
복사는 그 직사각형을 TSV로 직렬화하고, 채우기는 그 직사각형을 이웃으로 연장합니다. 그래서 두 기능은 같은 좌표 공간·같은 편집 경로(onCellEdit)·같은 undo 그룹을 공유합니다.
이 그리드는 클립보드 직렬화 규칙과 채우기 기하만 소유하고, "셀 값을 어떤 문자열로 복사할지", "어떤 값으로 채울지"는 Editing 모델과 동일하게 전부 소비자에게 돌려줍니다.
클립보드 — TSV 왕복
복사·잘라내기·붙여넣기는 모두 TSV(tab-separated values) 로 오갑니다. Excel·Google Sheets와 호환되는 표준 포맷이라, 그리드에서 복사해 시트에 붙이고 시트에서 복사해 그리드에 붙이는 왕복이 그대로 됩니다.
- 셀 구분 = tab(
\t), 행 구분 = newline(\n) - 값에 tab/newline/
"가 들어 있으면"..."로 감싸고 내부"는""로 이스케이프(RFC 4180) meta.columnRole === 'control'컬럼(체크박스·행번호)은 직렬화에서 제외- 병합(colSpan/rowSpan)으로 가려진 셀은 빈 칸(
'')으로 출력 — 값은 anchor에만 (Excel 병합 TSV 호환)
값 변환 — getCopyValue / parseValue
기본 직렬화는 String(value ?? '')이고 붙여넣기는 raw string을 그대로 씁니다. 셀 값이 number·Date·객체라면 컬럼 meta에서 변환 규칙을 줍니다.
getCopyValue— 복사/잘라내기에서 TanStack cell value를 문자열로 바꿉니다(예:1234567→"1,234,567").parseValue— 붙여넣기에서 raw string을TValue로 바꿉니다(예:"1,234,567"→1234567). 이 변환은 붙여넣기뿐 아니라 Delete/Backspace로 비울 때(parseValue(''))와 잘라내기 이동의 원본 비우기에도 적용됩니다.
import type { ColumnDef } from '@featuring-corp/data-grid';
const followersColumn: ColumnDef<Row> = {
accessorKey: 'followers',
meta: {
editable: true,
// 복사: 1234567 → "1,234,567"
getCopyValue: (info) => info.getValue<number>().toLocaleString('en-US'),
// 붙여넣기: "1,234,567" → 1234567
parseValue: (raw) => Number(raw.replace(/,/g, '')),
},
};getCopyValue·parseValue가 소비자 코드라 throw할 수 있다는 점은 그리드가 알아서 방어합니다. 복사 중 getCopyValue가 throw하면 그 셀만 ''로 강등해 직사각형을 유지합니다(복사는 최핫 경로라 조용히 강등). parseValue가 throw하면 그 셀만 건너뛰고(미파싱 raw로 타입 계약을 깨지 않기 위함), 이때만 dev에서 1회 경고합니다.
잘라내기 = 이동 (single undo group)
잘라내기는 Google Sheets와 동일하게 즉시 비우지 않습니다. Ctrl/Cmd+X는 TSV를 클립보드에 쓰고 소스에 점선(marquee)만 띄웁니다. 실제로 원본이 비워지는 건 붙여넣는 순간입니다.
이때 핵심은 "원본 비우기 + 타겟 채우기"가 같은 undo 그룹으로 묶인다는 것입니다. Ctrl+Z 한 번이 이동 전체를 되돌립니다 — 절반만 되돌아가는 일이 없습니다. 내부적으로 원본 비우기를 붙여넣기와 같은 recording 그룹에 먼저 기록하므로, 겹치는 셀은 붙여넣기 값이 덮고 겹치지 않는 원본 셀만 빈 값으로 남습니다.
marquee — DataGridClipboardSource
복사·잘라내기한 영역은 점선(marquee)으로 표시됩니다. 이 상태는 selection과 별개입니다 — 복사 후 선택을 옮겨도 점선은 남습니다(Sheets 동작). 그리드가 Root 상태로 들고 context로 전파합니다.
Prop
Type
점선은 구조가 바뀌면(정렬·필터·페이지 이동) 좌표가 stale이라 자동으로 사라지고, 셀 편집을 시작해도 사라집니다. Escape로도 점선만 따로 끌 수 있습니다(선택은 유지).
커스텀 클립보드 — 공개 헬퍼 재사용
키를 리맵하거나(onCellKeyDown veto) 자체 내보내기를 만들 때, 그리드 내장과 동일한 규칙을 재사용할 수 있도록 세 헬퍼를 export합니다. 직접 좌표를 순회하지 말고 이들을 쓰면 control 컬럼 제외·병합 처리·이스케이프가 공짜로 따라옵니다.
import {
getActiveRange,
serializeRangeToTsv,
parseTsv,
} from '@featuring-corp/data-grid';
// 비연속 다중선택에서 "어느 직사각형이 대상인가" — 마지막(active) range.
const range = getActiveRange(selection);
// 활성 range를 내장 copy(Ctrl+C)와 동일 규칙으로 TSV 직렬화.
// meta.getCopyValue 존중, control 컬럼 제외, 병합 covered 셀은 ''. 좌표가 현재 model에 없으면 null.
if (range) {
const tsv = serializeRangeToTsv(table, range);
// → 자체 export(CSV 변환 등)나 커스텀 클립보드 쓰기에 활용
}
// 붙여넣을 TSV/CSV 문자열을 2D 배열로 — escapeTsv의 정확한 역연산(RFC 4180 quoted 포함).
const matrix = parseTsv('a\tb\nc\td'); // → [['a','b'], ['c','d']]| 헬퍼 | 시그니처 | 용도 |
|---|---|---|
getActiveRange | (selection) => DataGridCellRange | undefined | 다중선택 중 직렬화/채우기 대상 직사각형 |
serializeRangeToTsv | (table, range) => string | null | 내장과 동일 규칙으로 range → TSV |
parseTsv | (text) => string[][] | TSV/CSV 문자열 → 2D 매트릭스 |
키 리맵은 Extending의
onCellKeyDown+preventGridDefaultveto 패턴과 함께 씁니다.preventGridDefault()로 내장 처리를 끄고, 위 헬퍼로 같은 규칙을 자기 핸들러에서 재현합니다.
붙여넣기 통째로 가로채기 — onPaste
붙여넣기를 셀 단위 onCellEdit이 아니라 매트릭스 통째로 받고 싶을 때 useDataGridEditing에 onPaste를 줍니다. 수만 셀을 단일 setState로 처리하거나, 자체 bulk 업데이트 파이프라인에 흘릴 때 유용합니다.
const editing = useDataGridEditing(grid, {
selection,
onCellEdit: ({ coord, newValue }) => {
/* 셀 단위 편집 */
},
// 정의하면 paste는 onCellEdit를 거치지 않고 매트릭스로 한 번에 들어온다.
onPaste: (startCoord, matrix) => {
// matrix: string[][] (parseValue 미적용 raw). startCoord = 좌상단 anchor.
applyMatrixToData(startCoord, matrix);
},
});onPaste를 정의하면 그 경로는 그리드 내장 처리(타일링·잘라내기 이동·parseValue·undo 기록)를 전부 우회합니다. 따라서 undo 히스토리에 남지 않으므로 필요하면 소비자가 자체 history를 가져야 합니다. onPaste도 소비자 콜백이라 동기 throw는 격리되어 dev 경고로 강등됩니다.
Fill — 자동 채우기
자동 채우기는 선택 직사각형(소스)을 이웃으로 연장해 값을 쏟아붓습니다. 세 가지 트리거가 모두 같은 내부 채우기 엔진을 거쳐 단일 undo 그룹으로 적용됩니다.
- 드래그 — 핸들을 끌어 한 축으로 확장(Body가 핸들 DOM·드래그·미리보기·edge-autoscroll을 자동 처리).
- 키보드 —
Ctrl/Cmd+D(아래) ·Ctrl/Cmd+R(오른쪽). 핸들 없이 현재 단일 선택 직사각형 자체가 소스+타겟이 됩니다. 채울 게 없으면 그리드가 키를 소비하지 않고 브라우저 단축키(북마크/새로고침)에 양보합니다. - 더블클릭 — 정책 seam(
doubleClickAction, 아래).
핸들은 단일 range 선택 + selection·editing 활성 + 편집 중 아님일 때만 보입니다. 다중 range(2개 이상)면 소스가 모호해 숨깁니다.
값 계산 우선순위
채울 값을 결정하는 정책은 두 층으로 나뉩니다. 우선순위는 셀별로 다음과 같습니다.
컬럼 meta.fill (세로 전용)
└─ 'copy' | 'series' | 함수
▲ 우선 (이 컬럼 셀은 getFillValue 무시)
getFillValue (양축 — 가로/교차의 유일한 길)
└─ meta.fill 없는 셀에만 적용
copy / tile fallback
└─ 소스 블록을 modulo로 반복 (위 둘이 undefined를 남긴 셀)- 컬럼
meta.fill— 선언적, 세로(down/up) 전용.'series'는 숫자 등차(소스 2개↑ 유한 숫자면 마지막 두 값 차로 연장), 함수는 이 컬럼 소스 값 + 타겟 오프셋으로 완전 제어. getFillValue— 명령형, 양축. 소스·타겟·방향을 받아 값 배열을 반환. 컬럼meta.fill이 있는 셀은 그쪽이 우선이라, 실질적으로 가로/교차 fill의 유일한 길이자meta.fill이 없는 세로 컬럼의 탈출구입니다.
왜 meta.fill은 세로 전용인가
자동 채우기에는 비대칭이 있습니다. 세로 채우기는 소스 열 = 타겟 열이라, "이 컬럼은 등차로 채운다" 같은 규칙을 컬럼 스키마에 박을 수 있습니다. 하지만 가로 채우기는 소스 열 ≠ 타겟 열(컬럼을 횡단)이고, 행에는 컬럼 같은 스키마가 없습니다. 2024 → 2025 → 2026을 가로로 채우려 해도 "어느 컬럼의 규칙을 따를지"가 정의되지 않습니다. 그래서 가로/교차 fill은 per-column으로 지배할 수 없고, 행 전역을 보는 getFillValue(양축)로만 풀립니다.
import type { ColumnDef, DataGridFillArgs } from '@featuring-corp/data-grid';
// (1) 선언적 — 세로 등차. score 컬럼을 아래로 끌면 10, 20, 30 …
const scoreColumn: ColumnDef<Row> = {
accessorKey: 'score',
meta: { editable: true, fill: 'series' },
};
// (2) 명령형 — 가로/교차 fill. 연도 헤더를 오른쪽으로 연장.
const getFillValue = (args: DataGridFillArgs) =>
args.target.map((t) => {
const base = args.source[0]?.[0]?.value;
return typeof base === 'number' ? base + t.colOffset : undefined; // undefined → copy fallback
});
const fillHandle = useDataGridFillHandle(grid, { selection, editing, getFillValue });함수형 meta.fill·getFillValue 모두 소비자 코드라 특정 셀을 undefined로 두면 그 셀만 copy/tile fallback이고, throw하면 그 컬럼/전체가 copy/tile로 강등(+dev 경고)됩니다.
더블클릭 정책 — doubleClickAction
핸들 더블클릭은 정책 seam입니다.
'autoFillDown'(default) — 엑셀 시그니처. 옆 컬럼(소스 왼쪽, 없으면 오른쪽)의 연속 데이터가 끝나는 행까지 아래로 자동 채우고, 그 영역으로 선택을 확장합니다.false— 더블클릭 무동작.(ctx) => void— 소비자 정의.ctx.applyFill(source, target)로 채우면getFillValue/series/copy우선순위·단일 undo 그룹이 드래그 fill과 동일 경로로 적용됩니다. 선택 확장은 소비자가 따로 합니다.
const fillHandle = useDataGridFillHandle(grid, {
selection,
editing,
// 더블클릭하면 그리드 끝까지 채우기
doubleClickAction: ({ sourceRange, table, applyFill }) => {
const rows = table.getRowModel().rows;
const lastRowId = rows[rows.length - 1]?.id;
if (!lastRowId) return;
applyFill(sourceRange, {
start: sourceRange.start,
end: { rowId: lastRowId, columnId: sourceRange.end.columnId },
});
},
});배선
채우기는 selection·editing을 잇는 브릿지라, 둘을 명시 주입해야 동작합니다(타입이 호출 순서를 강제). 반환값을 <DataGrid.Root fillHandle={...}>에 넘기지 않으면 핸들은 조용히 비활성되고 dev에서 1회 경고합니다.
아래 그리드에서 점수 컬럼의 셀 두 개(예: 10, 20)를 드래그로 선택한 뒤, 선택 영역 우하단 모서리의 핸들을 아래로 끌면
meta.fill: 'series' 규칙대로 등차(30, 40, 50…)로 채워집니다. 복사(Ctrl/Cmd+C)·붙여넣기(Ctrl/Cmd+V)·Ctrl/Cmd+D(아래로 채우기)도 동작합니다.
const initialData = [ { id: '1', name: '김민준', score: 10 }, { id: '2', name: '이서연', score: 20 }, { id: '3', name: '박도윤', score: 0 }, { id: '4', name: '최하은', score: 0 }, { id: '5', name: '정시우', score: 0 }, { id: '6', name: '강지우', score: 0 }, ]; const columns = [ { accessorKey: 'name', meta: { editable: true }, header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => ( <DataGrid.Cell cell={info.cell}> <DataGrid.CellDisplay>{String(info.getValue() ?? '')}</DataGrid.CellDisplay> <DataGrid.CellEditor> <DataGrid.TextEditor /> </DataGrid.CellEditor> </DataGrid.Cell> ), }, { accessorKey: 'score', meta: { editable: true, fill: 'series' }, header: ({ header }) => <DataGrid.HeaderCell header={header}>점수 (series)</DataGrid.HeaderCell>, cell: (info) => ( <DataGrid.Cell cell={info.cell}> <DataGrid.CellDisplay>{String(info.getValue() ?? '')}</DataGrid.CellDisplay> <DataGrid.CellEditor> <DataGrid.TextEditor parse={(raw) => Number(raw) || 0} /> </DataGrid.CellEditor> </DataGrid.Cell> ), }, ]; function FillGrid() { const [data, setData] = useState(initialData); const grid = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }); const selection = useDataGridSelection(grid); const editing = useDataGridEditing(grid, { selection, onCellEdit: ({ coord, newValue }) => setData((rows) => rows.map((row) => (row.id === coord.rowId ? { ...row, [coord.columnId]: newValue } : row))), }); const fillHandle = useDataGridFillHandle(grid, { selection, editing, doubleClickAction: 'autoFillDown', }); return ( <DataGrid.Root table={grid} selection={selection} editing={editing} fillHandle={fillHandle} size="md"> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<FillGrid />);
Prop
Type
DataGridFillArgs — getFillValue 인자
Prop
Type
DataGridColumnFillArgs — meta.fill 함수형 인자
Prop
Type
DataGridFillContext — doubleClickAction 함수형 인자
Prop
Type