Featuring Design System
Selection & Editing

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 매트릭스

키 리맵은 ExtendingonCellKeyDown + preventGridDefault veto 패턴과 함께 씁니다. preventGridDefault()로 내장 처리를 끄고, 위 헬퍼로 같은 규칙을 자기 핸들러에서 재현합니다.

붙여넣기 통째로 가로채기 — onPaste

붙여넣기를 셀 단위 onCellEdit이 아니라 매트릭스 통째로 받고 싶을 때 useDataGridEditingonPaste를 줍니다. 수만 셀을 단일 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

DataGridFillArgsgetFillValue 인자

Prop

Type

DataGridColumnFillArgsmeta.fill 함수형 인자

Prop

Type

DataGridFillContextdoubleClickAction 함수형 인자

Prop

Type

다음 단계

  • Editing — 편집 라이프사이클·record-on-commit/confirm·undo/redo·equals.
  • Selection — anchor/range 다중 직사각형 모델·active range.
  • ExtendingonCellKeyDown + preventGridDefault veto로 키 리맵·커스텀 클립보드.
  • 라이브 스토리 — 복사/붙여넣기·잘라내기 이동·Fill Handle을 Storybook에서 직접 실행.