-
Notifications
You must be signed in to change notification settings - Fork 348
Description
This project seems to be active again! First, thank you to the GDG team for open-sourcing such an excellent table component. I have been using it extensively in my own projects and have developed some solutions that might be useful to other developers as well.
Storkbook provides an API for explanations, but it is not very convenient for sharing code.
I understand that the GDG team is focused on implementing core canvas features, with most extensions centered around cells. However, there are actually some common interactive extensions that could be reused, providing capabilities beyond just the canvas.
Since I also use lexical, I've noticed that its extension ecosystem is much richer for highly interactive document projects. I believe adding a lexical-style extension mechanism would make GDG more popular and easier to use. I have already implemented this in a forked branch. By exposing the GDG ref API in a context-style, we can achieve this effect.
Example:
// add-row-plugin.tsx
import * as React from "react";
import { useGlideDataGridContext } from "@glideapps/glide-data-grid";
export function AddRowPlugin() {
const [editor] = useGlideDataGridContext();
return (
<button onClick={() => editor?.appendRow(0)} style={{ position: "absolute", top: 8, right: 8, zIndex: 10 }}>
add row
</button>
);
}
Usage:
// my-grid.tsx
import * as React from "react";
import { DataEditorAll as DataEditor } from "@glideapps/glide-data-grid";
import { AddRowPlugin } from "./add-row-plugin";
export const PluginDemo = () => {
// ...
return (
<div style={{ position: "relative", height: 400 }}>
<DataEditor ref={editorRef} columns={columns} rows={rows} getCellContent={getCellContent}>
<AddRowPlugin />
</DataEditor>
</div>
);
};
A real-world example is a "freeze line" plugin
Screen.Recording.2025-06-18.at.17.07.09.mov
We don't need to actually publish every plugin as a package—other developers can simply copy and paste the plugin code into their own projects. This is very friendly in the AI era.
Here is the code. Simply copy and paste it into your project to use it.
import type { GridColumn } from "@glideapps/glide-data-grid";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useGlideDataGridContext } from "@glideapps/glide-data-grid";
const DEFAULT_ROW_NUMBER_COL_WIDTH = 0; // Default width for row numbers
interface FreezeLinePluginProps {
columns: readonly GridColumn[];
freezeColumns: number;
onFreezeColumnsChange: (count: number) => void;
hoverTargetWidth?: number;
freezeLineWidth?: number;
rowNumberColWidth?: number;
}
export const FreezeLinePlugin: React.FC<FreezeLinePluginProps> = ({
columns,
freezeColumns,
onFreezeColumnsChange,
hoverTargetWidth = 4,
freezeLineWidth = 2,
rowNumberColWidth = DEFAULT_ROW_NUMBER_COL_WIDTH,
}) => {
const [editor] = useGlideDataGridContext();
const freezeHandleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const dragStartX = useRef<number>(0);
const currentDragX = useRef<number | null>(null);
const [previewFreezeColumns, setPreviewFreezeColumns] = useState<number | null>(null);
// Calculate column end positions (right edge relative to start)
const columnEndPositions: number[] = useMemo(() => {
if (!columns) return [];
const positions = [rowNumberColWidth];
let currentLeft = rowNumberColWidth;
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
const colWidth =
typeof (column as any)?.width === "number" && (column as any).width > 0 ? (column as any).width : 300;
currentLeft += colWidth;
positions.push(currentLeft);
}
return positions;
}, [columns, rowNumberColWidth]);
// Calculate the exact position where the *actual* freeze line should be visually
const freezeLinePosition = useMemo(() => {
if (freezeColumns <= 0 || freezeColumns > columns.length) {
return rowNumberColWidth + (hoverTargetWidth / 2 - 1);
}
return (columnEndPositions[freezeColumns] ?? 0) + (hoverTargetWidth / 2 - 1);
}, [columnEndPositions, freezeColumns, hoverTargetWidth, rowNumberColWidth]);
// Calculate the exact position where the *preview* freeze line should snap to
const previewLinePosition = useMemo(() => {
if (
previewFreezeColumns === null ||
previewFreezeColumns < 0 ||
previewFreezeColumns > columns.length ||
columnEndPositions.length === 0
) {
return null;
}
return columnEndPositions[previewFreezeColumns] ?? 0;
}, [columnEndPositions, previewFreezeColumns]);
// Adjust the visual left position of the handle element to center the hover/drag target
const freezeHandleVisualLeft = useMemo(() => {
return Math.max(0, freezeLinePosition - hoverTargetWidth / 2);
}, [freezeLinePosition, hoverTargetWidth]);
const handleMouseDown = useCallback(
(event: React.MouseEvent) => {
const gridRect = editor?.getBounds ? editor.getBounds(0, 0) : undefined;
const rect = gridRect ?? document.body.getBoundingClientRect();
const relativeX = event.clientX - rect.x;
setIsDragging(true);
setPreviewFreezeColumns(freezeColumns);
dragStartX.current = relativeX;
currentDragX.current = relativeX;
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
event.preventDefault();
},
[freezeColumns, editor]
);
const handleMouseMove = useCallback(
(event: MouseEvent) => {
if (!isDragging || !columns || !columnEndPositions || columnEndPositions.length <= 1) return;
const gridRect = editor?.getBounds ? editor.getBounds(0, 0) : undefined;
const rect = gridRect ?? document.body.getBoundingClientRect();
const currentRelativeX = event.clientX - rect.x;
currentDragX.current = currentRelativeX;
const targetLinePosition = currentRelativeX;
let newPreviewFreezeColumns = 0;
let found = false;
for (let k = 0; k < columns.length; k++) {
const colStartPos = columnEndPositions[k];
const colEndPos = columnEndPositions[k + 1];
const midPoint = colStartPos + Math.max(0, colEndPos - colStartPos) / 2;
if (targetLinePosition < midPoint) {
newPreviewFreezeColumns = k;
found = true;
break;
}
}
if (!found) {
newPreviewFreezeColumns = columns.length;
}
newPreviewFreezeColumns = Math.max(0, Math.min(newPreviewFreezeColumns, columns.length));
if (newPreviewFreezeColumns !== previewFreezeColumns) {
setPreviewFreezeColumns(newPreviewFreezeColumns);
}
},
[isDragging, columns, columnEndPositions, previewFreezeColumns, editor]
);
const handleMouseUp = useCallback(() => {
if (previewFreezeColumns !== null && previewFreezeColumns !== freezeColumns) {
onFreezeColumnsChange(previewFreezeColumns);
}
setIsDragging(false);
setPreviewFreezeColumns(null);
dragStartX.current = 0;
currentDragX.current = null;
document.body.style.userSelect = "";
document.body.style.cursor = "";
}, [previewFreezeColumns, freezeColumns, onFreezeColumnsChange]);
useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
} else {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
if (isDragging) {
if (document.body.style.userSelect === "none") {
document.body.style.userSelect = "";
}
if (document.body.style.cursor === "col-resize") {
document.body.style.cursor = "";
}
}
};
}, [isDragging, handleMouseMove, handleMouseUp]);
const handleMouseEnter = useCallback(() => {
setIsHovering(true);
}, []);
const handleMouseLeave = useCallback(() => {
if (!isDragging) {
setIsHovering(false);
}
}, [isDragging]);
return (
<>
{columns && columns.length > 0 && (
<div
ref={freezeHandleRef}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: freezeHandleVisualLeft,
width: hoverTargetWidth,
zIndex: 10,
cursor: "col-resize",
pointerEvents: "auto",
background: isHovering || isDragging ? "rgba(0,120,212,0.08)" : "transparent",
transition: "background 0.2s",
}}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
{(isHovering || isDragging) && (
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
left: "50%",
transform: "translateX(-50%)",
width: freezeLineWidth,
background: "#0078d4",
opacity: 0.7,
pointerEvents: "none",
}}
/>
)}
</div>
)}
{isDragging && previewLinePosition !== null && (
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
left: previewLinePosition,
width: freezeLineWidth,
zIndex: 10,
background: "#0078d4",
opacity: 0.4,
pointerEvents: "none",
}}
/>
)}
</>
);
};
These extensions could also be registered as shadcn blocks, allowing other developers to add them with a single CLI command. This would make it easier to distribute both interaction and cell extensions, lowering the barrier for developers.