Skip to content

Add Plugin/Extension Mechanism Inspired by Lexical for Common Interactions #1044

@mayneyao

Description

@mayneyao

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions