{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-data-grid-undo-redo",
  "title": "Use Data Grid Undo Redo",
  "description": "An undo/redo hook for the data grid with keyboard shortcuts and history management",
  "dependencies": [
    "sonner"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/hooks/use-data-grid-undo-redo.ts",
      "content": "import * as React from \"react\";\nimport { toast } from \"sonner\";\n\nimport { useAsRef } from \"@/hooks/use-as-ref\";\nimport { useLazyRef } from \"@/hooks/use-lazy-ref\";\nimport { getIsInPopover } from \"@/lib/data-grid\";\n\nconst DEFAULT_MAX_HISTORY = 100;\nconst BATCH_TIMEOUT = 300;\n\ninterface HistoryEntry<TData> {\n  variant: \"cells_update\" | \"rows_add\" | \"rows_delete\";\n  count: number;\n  timestamp: number;\n  undo: (currentData: TData[]) => TData[];\n  redo: (currentData: TData[]) => TData[];\n}\n\ninterface UndoRedoCellUpdate {\n  rowId: string;\n  columnId: string;\n  previousValue: unknown;\n  newValue: unknown;\n}\n\ninterface StoreState<TData> {\n  undoStack: HistoryEntry<TData>[];\n  redoStack: HistoryEntry<TData>[];\n  hasPendingChanges: boolean;\n}\n\ninterface Store<TData> {\n  subscribe: (callback: () => void) => () => void;\n  getState: () => StoreState<TData>;\n  push: (entry: HistoryEntry<TData>) => void;\n  undo: () => HistoryEntry<TData> | null;\n  redo: () => HistoryEntry<TData> | null;\n  clear: () => void;\n  setPendingChanges: (value: boolean) => void;\n  notify: () => void;\n}\n\nfunction useStore<T>(\n  store: Store<T>,\n  selector: (state: StoreState<T>) => boolean,\n): boolean {\n  const getSnapshot = React.useCallback(\n    () => selector(store.getState()),\n    [store, selector],\n  );\n\n  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\nfunction buildIndexById<TData>(\n  data: TData[],\n  getRowId: (row: TData) => string,\n): Map<string, number> {\n  const map = new Map<string, number>();\n  for (let i = 0; i < data.length; i++) {\n    const row = data[i];\n    if (row) {\n      map.set(getRowId(row), i);\n    }\n  }\n  return map;\n}\n\nfunction getPendingKey(rowId: string, columnId: string): string {\n  return `${rowId}\\0${columnId}`;\n}\n\ninterface UseDataGridUndoRedoProps<TData> {\n  data: TData[];\n  onDataChange: (data: TData[]) => void;\n  getRowId: (row: TData) => string;\n  maxHistory?: number;\n  enabled?: boolean;\n}\n\ninterface UseDataGridUndoRedoReturn<TData> {\n  canUndo: boolean;\n  canRedo: boolean;\n  onUndo: () => void;\n  onRedo: () => void;\n  onClear: () => void;\n  trackCellsUpdate: (updates: UndoRedoCellUpdate[]) => void;\n  trackRowsAdd: (rows: TData[]) => void;\n  trackRowsDelete: (rows: TData[]) => void;\n}\n\nfunction useDataGridUndoRedo<TData>({\n  data,\n  onDataChange,\n  getRowId,\n  maxHistory = DEFAULT_MAX_HISTORY,\n  enabled = true,\n}: UseDataGridUndoRedoProps<TData>): UseDataGridUndoRedoReturn<TData> {\n  const propsRef = useAsRef({\n    data,\n    onDataChange,\n    getRowId,\n    maxHistory,\n    enabled,\n  });\n\n  const listenersRef = useLazyRef(() => new Set<() => void>());\n\n  const stateRef = useLazyRef<StoreState<TData>>(() => ({\n    undoStack: [],\n    redoStack: [],\n    hasPendingChanges: false,\n  }));\n\n  const pendingBatchRef = React.useRef<{\n    byKey: Map<string, UndoRedoCellUpdate>;\n    timeoutId: ReturnType<typeof setTimeout> | null;\n  }>({\n    byKey: new Map(),\n    timeoutId: null,\n  });\n\n  const pendingNotifyRef = React.useRef(false);\n\n  const store = React.useMemo<Store<TData>>(() => {\n    return {\n      subscribe: (callback) => {\n        listenersRef.current.add(callback);\n        return () => listenersRef.current.delete(callback);\n      },\n      getState: () => stateRef.current,\n      push: (entry) => {\n        const state = stateRef.current;\n        const newUndoStack = [...state.undoStack, entry];\n\n        if (newUndoStack.length > propsRef.current.maxHistory) {\n          newUndoStack.shift();\n        }\n\n        stateRef.current = {\n          undoStack: newUndoStack,\n          redoStack: [],\n          hasPendingChanges: false,\n        };\n        store.notify();\n      },\n      undo: () => {\n        const state = stateRef.current;\n        if (state.undoStack.length === 0) return null;\n\n        const entry = state.undoStack[state.undoStack.length - 1];\n        if (!entry) return null;\n\n        stateRef.current = {\n          undoStack: state.undoStack.slice(0, -1),\n          redoStack: [...state.redoStack, entry],\n          hasPendingChanges: false,\n        };\n        store.notify();\n        return entry;\n      },\n      redo: () => {\n        const state = stateRef.current;\n        if (state.redoStack.length === 0) return null;\n\n        const entry = state.redoStack[state.redoStack.length - 1];\n        if (!entry) return null;\n\n        stateRef.current = {\n          undoStack: [...state.undoStack, entry],\n          redoStack: state.redoStack.slice(0, -1),\n          hasPendingChanges: false,\n        };\n        store.notify();\n        return entry;\n      },\n      clear: () => {\n        stateRef.current = {\n          undoStack: [],\n          redoStack: [],\n          hasPendingChanges: false,\n        };\n        store.notify();\n      },\n      setPendingChanges: (value) => {\n        if (stateRef.current.hasPendingChanges === value) return;\n        stateRef.current = {\n          ...stateRef.current,\n          hasPendingChanges: value,\n        };\n\n        if (!pendingNotifyRef.current) {\n          pendingNotifyRef.current = true;\n          queueMicrotask(() => {\n            pendingNotifyRef.current = false;\n            store.notify();\n          });\n        }\n      },\n      notify: () => {\n        for (const listener of listenersRef.current) {\n          listener();\n        }\n      },\n    };\n  }, [listenersRef, stateRef, propsRef]);\n\n  const canUndo = useStore(\n    store,\n    (state) => state.undoStack.length > 0 || state.hasPendingChanges,\n  );\n  const canRedo = useStore(store, (state) => state.redoStack.length > 0);\n\n  const onCommit = React.useCallback(() => {\n    const pending = pendingBatchRef.current;\n    if (pending.byKey.size === 0) return;\n\n    if (pending.timeoutId) {\n      clearTimeout(pending.timeoutId);\n      pending.timeoutId = null;\n    }\n\n    const updates = Array.from(pending.byKey.values());\n    pending.byKey.clear();\n\n    const { getRowId } = propsRef.current;\n\n    const entry: HistoryEntry<TData> = {\n      variant: \"cells_update\",\n      count: updates.length,\n      timestamp: Date.now(),\n      undo: (currentData) => {\n        const newData = [...currentData];\n        const indexById = buildIndexById(newData, getRowId);\n\n        for (const update of updates) {\n          const index = indexById.get(update.rowId);\n          if (index !== undefined) {\n            const row = newData[index];\n            if (row) {\n              newData[index] = {\n                ...row,\n                [update.columnId]: update.previousValue,\n              };\n            }\n          }\n        }\n        return newData;\n      },\n      redo: (currentData) => {\n        const newData = [...currentData];\n        const indexById = buildIndexById(newData, getRowId);\n\n        for (const update of updates) {\n          const index = indexById.get(update.rowId);\n          if (index !== undefined) {\n            const row = newData[index];\n            if (row) {\n              newData[index] = { ...row, [update.columnId]: update.newValue };\n            }\n          }\n        }\n        return newData;\n      },\n    };\n\n    store.push(entry);\n  }, [store, propsRef]);\n\n  const onUndo = React.useCallback(() => {\n    if (!propsRef.current.enabled) return;\n\n    onCommit();\n\n    const entry = store.undo();\n    if (!entry) {\n      toast.info(\"No actions to undo\");\n      return;\n    }\n\n    const newData = entry.undo(propsRef.current.data);\n    propsRef.current.onDataChange(newData);\n\n    toast.success(\n      `${entry.count} action${entry.count !== 1 ? \"s\" : \"\"} undone`,\n    );\n  }, [store, propsRef, onCommit]);\n\n  const onRedo = React.useCallback(() => {\n    if (!propsRef.current.enabled) return;\n\n    onCommit();\n\n    const entry = store.redo();\n    if (!entry) {\n      toast.info(\"No actions to redo\");\n      return;\n    }\n\n    const newData = entry.redo(propsRef.current.data);\n    propsRef.current.onDataChange(newData);\n\n    toast.success(\n      `${entry.count} action${entry.count !== 1 ? \"s\" : \"\"} redone`,\n    );\n  }, [store, propsRef, onCommit]);\n\n  const onClear = React.useCallback(() => {\n    const pending = pendingBatchRef.current;\n    if (pending.timeoutId) {\n      clearTimeout(pending.timeoutId);\n      pending.timeoutId = null;\n    }\n    pending.byKey.clear();\n\n    store.clear();\n  }, [store]);\n\n  const trackCellsUpdate = React.useCallback(\n    (updates: UndoRedoCellUpdate[]) => {\n      if (!propsRef.current.enabled || updates.length === 0) return;\n\n      const filteredUpdates = updates.filter(\n        (u) => !Object.is(u.previousValue, u.newValue),\n      );\n      if (filteredUpdates.length === 0) return;\n\n      const pending = pendingBatchRef.current;\n\n      for (const update of filteredUpdates) {\n        const key = getPendingKey(update.rowId, update.columnId);\n        const existing = pending.byKey.get(key);\n\n        if (existing) {\n          pending.byKey.set(key, { ...existing, newValue: update.newValue });\n        } else {\n          pending.byKey.set(key, update);\n        }\n      }\n\n      store.setPendingChanges(true);\n\n      if (pending.timeoutId) {\n        clearTimeout(pending.timeoutId);\n      }\n      pending.timeoutId = setTimeout(onCommit, BATCH_TIMEOUT);\n    },\n    [store, propsRef, onCommit],\n  );\n\n  const trackRowsAdd = React.useCallback(\n    (rows: TData[]) => {\n      if (!propsRef.current.enabled || rows.length === 0) return;\n\n      onCommit();\n\n      const { getRowId } = propsRef.current;\n\n      const rowIds = new Set(rows.map((row) => getRowId(row)));\n      const rowsCopy = rows.map((row) => ({ ...row }));\n\n      const entry: HistoryEntry<TData> = {\n        variant: \"rows_add\",\n        count: rows.length,\n        timestamp: Date.now(),\n        undo: (currentData) => {\n          return currentData.filter((row) => !rowIds.has(getRowId(row)));\n        },\n        redo: (currentData) => {\n          return [...currentData, ...rowsCopy.map((row) => ({ ...row }))];\n        },\n      };\n\n      store.push(entry);\n    },\n    [store, propsRef, onCommit],\n  );\n\n  const trackRowsDelete = React.useCallback(\n    (rows: TData[]) => {\n      if (!propsRef.current.enabled || rows.length === 0) return;\n\n      onCommit();\n\n      const { getRowId, data: currentData } = propsRef.current;\n\n      const indexById = buildIndexById(currentData, getRowId);\n\n      const rowsWithPositions: Array<{ index: number; row: TData }> = [];\n      for (const row of rows) {\n        const rowId = getRowId(row);\n        const currentIndex = indexById.get(rowId);\n        if (currentIndex !== undefined) {\n          rowsWithPositions.push({\n            index: currentIndex,\n            row: { ...row },\n          });\n        }\n      }\n\n      rowsWithPositions.sort((a, b) => a.index - b.index);\n\n      const rowIds = new Set(rows.map((row) => getRowId(row)));\n\n      const entry: HistoryEntry<TData> = {\n        variant: \"rows_delete\",\n        count: rows.length,\n        timestamp: Date.now(),\n        undo: (currentData) => {\n          const newData = [...currentData];\n          for (const { index, row } of rowsWithPositions) {\n            const insertIndex = Math.min(index, newData.length);\n            newData.splice(insertIndex, 0, { ...row });\n          }\n          return newData;\n        },\n        redo: (currentData) => {\n          return currentData.filter((row) => !rowIds.has(getRowId(row)));\n        },\n      };\n\n      store.push(entry);\n    },\n    [store, propsRef, onCommit],\n  );\n\n  React.useEffect(() => {\n    const pending = pendingBatchRef.current;\n    return () => {\n      if (pending.timeoutId) {\n        clearTimeout(pending.timeoutId);\n      }\n    };\n  }, []);\n\n  React.useEffect(() => {\n    if (!enabled) return;\n\n    function onKeyDown(event: KeyboardEvent) {\n      const isCtrlOrCmd = event.ctrlKey || event.metaKey;\n      const key = event.key.toLowerCase();\n\n      if (!isCtrlOrCmd || (key !== \"z\" && key !== \"y\")) return;\n\n      const activeElement = document.activeElement;\n      if (activeElement) {\n        const isInput =\n          activeElement.tagName === \"INPUT\" ||\n          activeElement.tagName === \"TEXTAREA\";\n        const isContentEditable =\n          activeElement.getAttribute(\"contenteditable\") === \"true\";\n        const isInPopover = getIsInPopover(activeElement);\n\n        if (isInput || isContentEditable || isInPopover) return;\n      }\n\n      if (key === \"z\" && !event.shiftKey) {\n        event.preventDefault();\n        onUndo();\n        return;\n      }\n\n      if ((key === \"z\" && event.shiftKey) || key === \"y\") {\n        event.preventDefault();\n        onRedo();\n      }\n    }\n\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [enabled, onUndo, onRedo]);\n\n  return React.useMemo(\n    () => ({\n      canUndo,\n      canRedo,\n      onUndo,\n      onRedo,\n      onClear,\n      trackCellsUpdate,\n      trackRowsAdd,\n      trackRowsDelete,\n    }),\n    [\n      canUndo,\n      canRedo,\n      onUndo,\n      onRedo,\n      onClear,\n      trackCellsUpdate,\n      trackRowsAdd,\n      trackRowsDelete,\n    ],\n  );\n}\n\nexport {\n  //\n  type UndoRedoCellUpdate,\n  useDataGridUndoRedo,\n};\n",
      "type": "registry:hook"
    },
    {
      "path": "src/hooks/use-as-ref.ts",
      "content": "import * as React from \"react\";\n\nimport { useIsomorphicLayoutEffect } from \"@/hooks/use-isomorphic-layout-effect\";\n\nfunction useAsRef<T>(props: T) {\n  const ref = React.useRef<T>(props);\n\n  useIsomorphicLayoutEffect(() => {\n    ref.current = props;\n  });\n\n  return ref;\n}\n\nexport { useAsRef };\n",
      "type": "registry:hook"
    },
    {
      "path": "src/hooks/use-lazy-ref.ts",
      "content": "import * as React from \"react\";\n\nfunction useLazyRef<T>(fn: () => T): React.RefObject<T> {\n  const ref = React.useRef<T | null>(null);\n  if (ref.current === null) {\n    ref.current = fn();\n  }\n  return ref as React.RefObject<T>;\n}\n\nexport { useLazyRef };\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}