'use client'; import { forwardRef, useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'; import type { PPTTableElement, TableCell, TableCellBorder } from '@openmaic/dsl'; import { getTableSubThemeColor } from '@openmaic/renderer'; import { getTextStyle } from '@openmaic/renderer/elements'; import { RendererTextEditor } from '../text/RendererTextEditor'; import type { TextEditorController, TextFormatState } from '../text/types'; import type { TableCellChange } from '../types'; function cellBorderCss(border?: TableCellBorder): string | undefined { if (!border || border.width <= 0) return undefined; const style = border.style === 'dashed' || border.style === 'dotted' ? border.style : 'solid'; return `${border.width}px ${style} ${border.color}`; } export interface RendererTableEditorProps { readonly element: PPTTableElement; readonly initialFocusPoint?: { readonly left: number; readonly top: number }; readonly onChange: (change: TableCellChange) => void; readonly onTextEditorChange?: (controller: TextEditorController | null) => void; readonly onTextFormatChange?: (elementId: string, state: TextFormatState) => void; readonly onTextFocusChange?: (focused: boolean) => void; readonly onExit?: () => void; } export interface RendererTableEditorController { flush: () => void; discard: () => void; } /** Visual affordance for a selected table before its cells enter edit mode. */ export function TableEditMask({ children, label, }: { readonly children: ReactNode; readonly label: string; }) { return (
{children}
{label}
); } /** A PPTist-style table editor where only one cell is contenteditable. */ export const RendererTableEditor = forwardRef< RendererTableEditorController, RendererTableEditorProps >(function RendererTableEditor( { element, initialFocusPoint, onChange, onTextEditorChange, onTextFormatChange, onTextFocusChange, onExit, }, ref, ) { const [activeCellId, setActiveCellId] = useState(null); const [cellFocusPoint, setCellFocusPoint] = useState<{ readonly cellId: string; readonly left: number; readonly top: number; } | null>(null); const activeCellControllerRef = useRef(null); const cellRefs = useRef(new Map()); const focusedInitialPointRef = useRef(false); const [subThemeDark, subThemeLight] = useMemo(() => { if (!element.theme) return ['', '']; return getTableSubThemeColor(element.theme.color); }, [element.theme]); const borderStyle = useMemo(() => { const outline = element.outline; if (!outline) return 'none'; return `${outline.width ?? 1}px ${outline.style === 'dashed' ? 'dashed' : 'solid'} ${outline.color ?? '#000'}`; }, [element.outline]); const commitActiveCell = useCallback(() => { activeCellControllerRef.current?.flush(); }, []); const activateCell = useCallback( (cellId: string, focusPoint?: { readonly left: number; readonly top: number }) => { if (cellId === activeCellId) return; commitActiveCell(); if (focusPoint) setCellFocusPoint({ cellId, ...focusPoint }); setActiveCellId(cellId); }, [activeCellId, commitActiveCell], ); const discardActiveCell = useCallback(() => { activeCellControllerRef.current?.discard(); }, []); useImperativeHandle(ref, () => ({ flush: commitActiveCell, discard: discardActiveCell }), [ commitActiveCell, discardActiveCell, ]); useLayoutEffect(() => { if (!initialFocusPoint || focusedInitialPointRef.current) return; const target = [...cellRefs.current.entries()].find(([, cell]) => { const rect = cell.getBoundingClientRect(); return ( initialFocusPoint.left >= rect.left && initialFocusPoint.left <= rect.right && initialFocusPoint.top >= rect.top && initialFocusPoint.top <= rect.bottom ); }); if (!target) return; focusedInitialPointRef.current = true; activateCell(target[0], initialFocusPoint); }, [activateCell, initialFocusPoint]); const handleCellContentChange = useCallback( (cell: TableCell, content: string, history: TableCellChange['history']) => { if (content === cell.text) return; onChange({ intent: { type: 'table.updateCell', id: element.id, cellId: cell.id, text: content }, history, }); }, [element.id, onChange], ); const handleCellControllerChange = useCallback( (controller: TextEditorController | null) => { activeCellControllerRef.current = controller; onTextEditorChange?.( controller ? { ...controller, elementId: element.id, kind: 'table-cell', } : null, ); }, [element.id, onTextEditorChange], ); const getCellBackground = (rowIndex: number, columnIndex: number, backcolor?: string) => { if (backcolor) return backcolor; const theme = element.theme; if (!theme) return undefined; if (theme.rowHeader && rowIndex === 0) return theme.color; if (theme.rowFooter && rowIndex === element.data.length - 1) return theme.color; if (theme.colHeader && columnIndex === 0) return subThemeDark; if (theme.colFooter && columnIndex === (element.data[0]?.length ?? 0) - 1) return subThemeDark; const effectiveRow = theme.rowHeader ? rowIndex - 1 : rowIndex; return effectiveRow >= 0 && effectiveRow % 2 === 0 ? subThemeLight : undefined; }; const getHeaderTextColor = (rowIndex: number) => { const theme = element.theme; if (!theme) return undefined; if (theme.rowHeader && rowIndex === 0) return '#fff'; if (theme.rowFooter && rowIndex === element.data.length - 1) return '#fff'; return undefined; }; return ( {element.colWidths.map((width, index) => ( ))} {element.data.map((row, rowIndex) => ( {row.map((cell, columnIndex) => { const headerColor = getHeaderTextColor(rowIndex); const textStyle = getTextStyle(cell.style); if (headerColor && !cell.style?.color) textStyle.color = headerColor; const borders = cell.borders; const borderCss: CSSProperties = borders && (borders.top || borders.bottom || borders.left || borders.right) ? { borderTop: cellBorderCss(borders.top) ?? 'none', borderBottom: cellBorderCss(borders.bottom) ?? 'none', borderLeft: cellBorderCss(borders.left) ?? 'none', borderRight: cellBorderCss(borders.right) ?? 'none', } : { border: borderStyle }; const rowHeight = element.rowHeights?.[rowIndex] ?? element.cellMinHeight; const cellTextStyle: CSSProperties = { minHeight: `${rowHeight - 4}px`, padding: cell.padding, display: 'flex', flexDirection: 'column', lineHeight: 1, justifyContent: cell.vAlign === 'top' ? 'flex-start' : cell.vAlign === 'bottom' ? 'flex-end' : 'center', ...textStyle, }; const isActive = activeCellId === cell.id; const activeCellFocusPoint = cellFocusPoint?.cellId === cell.id ? { left: cellFocusPoint.left, top: cellFocusPoint.top } : undefined; return ( ); })} ))}
{ if (node) cellRefs.current.set(cell.id, node); else cellRefs.current.delete(cell.id); }} data-table-cell-id={cell.id} colSpan={cell.colspan > 1 ? cell.colspan : undefined} rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined} style={{ ...borderCss, backgroundColor: getCellBackground( rowIndex, columnIndex, cell.style?.backcolor, ), }} onPointerDown={(event: ReactPointerEvent) => { event.stopPropagation(); if (activeCellId !== cell.id) event.preventDefault(); activateCell(cell.id, { left: event.clientX, top: event.clientY }); }} > {isActive ? (
handleCellContentChange(cell, change.intent.content, change.history) } onFormatChange={onTextFormatChange} onControllerChange={handleCellControllerChange} onFocusChange={onTextFocusChange} onEscape={() => { discardActiveCell(); onExit?.(); }} />
) : (
)}
); });