diff --git a/.changeset/table-selection-dom-patch.md b/.changeset/table-selection-dom-patch.md new file mode 100644 index 0000000000..2ba0dbba07 --- /dev/null +++ b/.changeset/table-selection-dom-patch.md @@ -0,0 +1,5 @@ +--- +'@platejs/table': patch +--- + +- Reduce large-table selection latency by deriving reactive table selection from editor selectors, keeping selected-cell DOM sync at the table root, and avoiding plugin-store writes on every `set_selection`. diff --git a/.claude/docs/table/block-selection-core-migration-design-2026-03-12.md b/.claude/docs/table/block-selection-core-migration-design-2026-03-12.md deleted file mode 100644 index 0432433d1e..0000000000 --- a/.claude/docs/table/block-selection-core-migration-design-2026-03-12.md +++ /dev/null @@ -1,184 +0,0 @@ -# Block Selection Core Migration Design Draft - -## Background - -- The current block selection implementation lives in [BlockSelectionPlugin.tsx](/Users/felixfeng/Desktop/udecode/plate/packages/selection/src/react/BlockSelectionPlugin.tsx). -- The implementation is already on the right track: state is lightweight, the core data is `selectedIds: Set`, and it provides per-id selectors. -- In contrast, table selection takes a heavier path: - - [useSelectedCells.ts](/Users/felixfeng/Desktop/udecode/plate/packages/table/src/react/components/TableElement/useSelectedCells.ts) recomputes the table/cell grid and writes `selectedCells` / `selectedTables` - - [useIsCellSelected.ts](/Users/felixfeng/Desktop/udecode/plate/packages/table/src/react/components/TableCellElement/useIsCellSelected.ts) makes every cell subscribe to the entire selection state -- The long-term direction: selection becomes a core editor capability, and block selection / table selection both build on top of it. -- Phase 1 does not address table selection. It only completes the block selection migration, but the design must leave room for table extension. - -## Problem Definition - -Although the current block selection works, it is still "a complete selection state and behavior set owned by a plugin", which causes several structural issues: - -1. Selection is a feature-level capability, not a core-level capability -2. Other selection modes cannot reuse the same underlying model -3. Table selection will likely grow another parallel state set instead of reusing unified selection infrastructure - -We need to take the first step: move block selection state ownership down to core, without immediately changing the base semantics of `editor.selection`. - -## Goals - -- Make block selection a core-backed capability -- Keep existing block selection interactions and external behavior unchanged -- Do not modify `editor.selection` in Phase 1 -- Introduce a core model that can accommodate future non-text selection -- Keep Phase 1 within a scope that can be landed and verified independently - -## Non-Goals - -- Do not redo table selection in this phase -- Do not change Slate's `editor.selection` to `Range | Range[]` -- Do not require all transforms to understand multi-range discrete ranges in Phase 1 -- Do not detail table merge / border implementation in this document - -## Phase 1 Scope - -Phase 1 does one thing: migrate block selection from "plugin-owned state" to "core-owned state". - -Expected outcome after this phase: - -- Block selection state is registered and managed by core -- Block selection UI can remain in the selection package -- Existing block selection commands continue to work, but delegate to core internally -- External callers no longer treat block plugin options as the ultimate source of truth - -## Proposed Core State Model - -The Phase 1 model should be minimal — do not prematurely include the full table shape. - -```ts -type EditorSelectionState = { - primary: Range | null; - block: { - anchorId: string | null; - selectedIds: Set; - isSelecting: boolean; - isSelectionAreaVisible: boolean; - }; -}; -``` - -Notes: - -- `primary` continues to correspond to the current text selection -- `block` is a new core selection channel, not a plugin-private store -- `selectedIds` continues to use `Set` because it is already the correct data shape: cheap per-id lookups, low-cost membership checks -- Phase 1 does not add a table descriptor, but the state boundary must not be hardcoded to "only block as an extra selection type" - -## API Direction - -Core should expose a thin selection API layer, and block selection adapts on top of it. - -```ts -editor.api.selection.getPrimary() -editor.api.selection.setPrimary(range) - -editor.api.selection.block.get() -editor.api.selection.block.clear() -editor.api.selection.block.set(ids) -editor.api.selection.block.add(ids) -editor.api.selection.block.delete(ids) -editor.api.selection.block.has(id) -editor.api.selection.block.isSelecting() -``` - -Existing block-facing helpers can be retained, but their semantics should change: - -- `editor.getApi(BlockSelectionPlugin).blockSelection.add(...)` -- `editor.getApi(BlockSelectionPlugin).blockSelection.clear()` -- `editor.getApi(BlockSelectionPlugin).blockSelection.getNodes(...)` - -These helpers should become compatibility wrappers rather than continuing to hold their own real state. - -## Rendering Layer Direction - -Phase 1 does not need to rewrite block selection visual interactions — only migrate state ownership. - -- Block selection area UI can remain in the selection package -- [useBlockSelected.ts](/Users/felixfeng/Desktop/udecode/plate/packages/selection/src/react/hooks/useBlockSelected.ts) switches to reading a core-backed selector -- `BlockSelectionPlugin` shrinks to an adapter: event wiring, render integration, and compatibility layer API - -This approach carries significantly lower risk than "rewriting the entire interaction model at once". - -## Migration Steps - -### Step 1: Introduce core block selection state - -- Add block selection state structure in core -- Expose minimal selectors and mutators -- Keep `editor.selection` behavior unchanged - -### Step 2: Redirect block selection API - -- Redirect reads/writes of `selectedIds`, `anchorId`, `isSelecting`, etc. behind the core API -- Continue exposing the existing block selection command surface externally - -### Step 3: Redirect hooks and render - -- Hooks like [useBlockSelected.ts](/Users/felixfeng/Desktop/udecode/plate/packages/selection/src/react/hooks/useBlockSelected.ts) switch to consuming the core-backed selector -- UI behavior remains unchanged - -### Step 4: Reduce plugin state ownership - -- `BlockSelectionPlugin` retains: - - Event wiring - - Adapter APIs - - Rendering integration -- Core becomes the sole state owner - -## Compatibility Strategy - -To keep the blast radius under control, Phase 1 should adhere to these rules: - -- `editor.selection` continues to be `Range | null` -- Not all editing commands are required to understand block selection immediately -- Block-specific operations continue to explicitly read block selection state -- Avoid introducing large-scale type modifications in Phase 1 - -This allows the migration to be incremental rather than affecting the entire Slate / Plate command surface at once. - -## Why This Step First - -The value of this phase: - -- Reclaim selection ownership into core -- Remove a feature-level state owner -- Provide a unified foundation for other future selection modes - -It is also low-risk because block selection's current data model is already relatively healthy — the main issue is "where the state lives", not "what the state looks like". - -## Table Direction as Design Constraint Only - -Table is not in Phase 1 scope, but Phase 1 design must avoid blocking future table work. - -Phase 1 should explicitly avoid: - -- Hardcoding core selection to serve only block ids -- Treating "flat id set" as the only non-text selection shape -- Letting future table selection still depend on materialized node arrays - -Future table selection will likely need: - -- A table-scoped descriptor instead of `selectedCells: TElement[]` -- Keyed selectors instead of each cell subscribing to the entire selection -- Expressive power for non-contiguous / grid-shaped selection semantics - -No need to detail the table design here — just ensure Phase 1 state boundaries and API do not prevent Phase 2 from extending in this direction. - -## Open Questions - -- Should core selection expose a channeled model (`block` / `table` / `primary`) or a more generic descriptor registry? -- After migration, which `BlockSelectionPlugin` APIs are still worth keeping as public interfaces? -- Should block selection render logic stay in `packages/selection` long-term, or continue moving toward core? - -## Phase 1 Acceptance Criteria - -- Block selection state is owned by core -- Existing block selection interaction behavior remains consistent -- `useBlockSelected` and related selectors switch to reading core-backed state -- Existing block selection commands continue to work, delegating to core via compatibility wrappers -- Phase 1 does not require any changes to table selection behavior diff --git a/.claude/docs/table/dev-table-perf-performance-2026-03-10.md b/.claude/docs/table/dev-table-perf-performance-2026-03-10.md deleted file mode 100644 index 5f28210ce5..0000000000 --- a/.claude/docs/table/dev-table-perf-performance-2026-03-10.md +++ /dev/null @@ -1,50 +0,0 @@ -# dev/table-perf 性能快照 - -## 环境 - -- 测试时间:2026-03-10 23:36:30 CST -- 页面地址:`http://localhost:3002/dev/table-perf` -- 应用:`apps/www`,Next.js 16.1.6(Turbopack dev) -- 浏览器:`agent-browser` + Chromium 138.0.7204.15 -- 机器:macOS 15.7.3 -- 页面错误:`agent-browser errors` 未发现报错 - -## 采样方法 - -- 阅读 `apps/www/src/app/dev/table-perf/page.tsx`,确认页面内置了两套测试: - - benchmark:`5` 次 warmup + `20` 次 measured remount - - input latency:`10` 次 warmup + `50` 次 measured inserts -- 使用 `agent-browser` 打开 `/dev/table-perf` -- 读取页面 Metrics 面板和 console 输出 -- 本次记录两组数据: - - 默认 `10 x 10`(100 cells) - - 压力 `60 x 60`(3600 cells) - -## 结果 - -| 配置 | Cells | Initial render | Benchmark mean | Benchmark median | Benchmark p95 | Input mean | Input median | Input p95 | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 10 x 10 | 100 | 209.30 ms | 99.32 ms | 83.80 ms | 147.50 ms | 28.07 ms | 28.40 ms | 30.50 ms | -| 60 x 60 | 3600 | 2663.30 ms | 2499.24 ms | 2453.30 ms | 2848.70 ms | 390.48 ms | 379.40 ms | 443.70 ms | - -## 对比 - -- `60 x 60` 相比 `10 x 10` -- Initial render:`12.72x` -- Benchmark mean:`25.16x` -- Input mean:`13.91x` - -## 结论 - -- `10 x 10` 基线可接受。input latency 均值约 `28 ms`,交互感觉应当是顺的。 -- `60 x 60` 仍可跑完,但已经明显进入高延迟区间: - - 初始挂载约 `2.66 s` - - benchmark 均值约 `2.50 s` - - 单次输入延迟均值约 `390 ms` -- 以当前页面表现看,大表场景下主要问题不是偶发尖峰,而是整体延迟已经稳定抬高到肉眼可感知的程度。 - -## 解读注意点 - -- 切换 preset 后点击 `Generate Table`,页面会刷新当前表格和基础 metrics。 -- `Benchmark Results` 会被清空后重新计算。 -- `Input Latency` 结果不会在 `Generate Table` 时自动清空;如果切到新 preset,必须重新跑一次 input latency 才能读到当前配置的数据。 diff --git a/.claude/docs/table/dev-table-perf-performance-2026-03-12.md b/.claude/docs/table/table-perf-benchmark-input-snapshot.md similarity index 65% rename from .claude/docs/table/dev-table-perf-performance-2026-03-12.md rename to .claude/docs/table/table-perf-benchmark-input-snapshot.md index 80545a8ada..d46a778ca5 100644 --- a/.claude/docs/table/dev-table-perf-performance-2026-03-12.md +++ b/.claude/docs/table/table-perf-benchmark-input-snapshot.md @@ -9,7 +9,7 @@ - Browser: Playwright + Chromium `138.0.7204.15` - Page accessibility check: confirmed `/dev/table-perf` loads via `agent-browser` -## Sampling Method +## Sampling - Read `apps/www/src/app/dev/table-perf/page.tsx`, confirmed the page has two built-in tests: - Benchmark: `5` warmup + `20` measured remount iterations @@ -45,15 +45,3 @@ | Input Latency | P95 | 52.70 ms | | Input Latency | Min | 28.00 ms | | Input Latency | Max | 61.60 ms | - -## Conclusion - -- The `40 x 40` remount benchmark is still in the high-cost range, with a mean of ~`841 ms`. -- The `40 x 40` input latency mean is ~`41 ms`, median ~`39 ms`, well below the `100+ ms` threshold where noticeable lag occurs. -- This snapshot is better suited for tracking large-table input performance; if the focus shifts to resize/hover interactions, a separate drag/hover profiling session is recommended. - -## Interpretation Notes - -- `Initial render`, `Re-render count`, `Last render`, and `Avg render / Median / P95` come from the left-side Metrics panel. -- `Benchmark Results` are the remount benchmark statistics. -- `Input Latency` results do not auto-clear when switching presets or clicking `Generate Table`; re-run after changing configuration. diff --git a/.claude/docs/table/table-selection-latency-snapshot.md b/.claude/docs/table/table-selection-latency-snapshot.md new file mode 100644 index 0000000000..4e65008a53 --- /dev/null +++ b/.claude/docs/table/table-selection-latency-snapshot.md @@ -0,0 +1,44 @@ +# dev/table-perf Performance Snapshot + +## Environment + +- Page route: `/dev/table-perf` +- App: `apps/www` +- Metric group: `Table Selection Latency` +- Table size: `40 x 40` (`1600` cells) +- Selected cells: `9` +- Injected delay: `0 ms` + +## Results + +### Baseline + +| Category | Metric | Value | +| --- | --- | ---: | +| Table Selection Latency | Selected cells | 9 | +| Table Selection Latency | Injected delay | 0.00 ms | +| Table Selection Latency | Mean | 425.00 ms | +| Table Selection Latency | Median | 424.60 ms | +| Table Selection Latency | P95 | 477.90 ms | +| Table Selection Latency | Min | 368.90 ms | +| Table Selection Latency | Max | 489.50 ms | + +### Current + +| Category | Metric | Value | +| --- | --- | ---: | +| Table Selection Latency | Selected cells | 9 | +| Table Selection Latency | Injected delay | 0.00 ms | +| Table Selection Latency | Mean | 174.62 ms | +| Table Selection Latency | Median | 174.00 ms | +| Table Selection Latency | P95 | 187.10 ms | +| Table Selection Latency | Min | 140.20 ms | +| Table Selection Latency | Max | 214.10 ms | + +## Comparison + +- Mean: `425.00 -> 174.62 ms` (`-250.38 ms`, `-58.9%`) +- Median: `424.60 -> 174.00 ms` (`-250.60 ms`, `-59.0%`) +- P95: `477.90 -> 187.10 ms` (`-290.80 ms`, `-60.8%`) +- Min: `368.90 -> 140.20 ms` (`-228.70 ms`, `-62.0%`) +- Max: `489.50 -> 214.10 ms` (`-275.40 ms`, `-56.3%`) diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index c348783678..c827b9b2f8 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -40,6 +40,8 @@ jobs: steps: - name: 📥 Checkout Repo uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: ♻️ Setup Node.js uses: actions/setup-node@v4 @@ -67,6 +69,14 @@ jobs: TEMPLATE_SKIP_VERIFY: 'true' run: pnpm templates:update --local + - name: 📦 Override templates with local workspace packages + env: + TEMPLATE_LOCAL_PACKAGE_BASE_REF: origin/${{ github.base_ref }} + run: | + node tooling/scripts/prepare-local-template-packages.mjs \ + templates/plate-template \ + templates/plate-playground-template + - name: ✅ Run template CI run: | cd templates/plate-template diff --git a/apps/www/public/r/table-node.json b/apps/www/public/r/table-node.json index ed7e25edfa..57d09920dd 100644 --- a/apps/www/public/r/table-node.json +++ b/apps/www/public/r/table-node.json @@ -20,7 +20,7 @@ "files": [ { "path": "src/registry/ui/table-node.tsx", - "content": "'use client';\n\nimport * as React from 'react';\n\nimport { useDraggable, useDropLine } from '@platejs/dnd';\nimport {\n BlockSelectionPlugin,\n useBlockSelected,\n} from '@platejs/selection/react';\nimport { resizeLengthClampStatic } from '@platejs/resizable';\nimport {\n setCellBackground,\n setTableColSize,\n setTableMarginLeft,\n setTableRowSize,\n} from '@platejs/table';\nimport {\n TablePlugin,\n TableProvider,\n roundCellSizeToStep,\n useCellIndices,\n useIsCellSelected,\n useOverrideColSize,\n useOverrideMarginLeft,\n useOverrideRowSize,\n useTableCellBorders,\n useTableBordersDropdownMenuContentState,\n useTableColSizes,\n useTableElement,\n useTableMergeState,\n useTableValue,\n} from '@platejs/table/react';\nimport {\n ArrowDown,\n ArrowLeft,\n ArrowRight,\n ArrowUp,\n CombineIcon,\n EraserIcon,\n Grid2X2Icon,\n GripVertical,\n PaintBucketIcon,\n SquareSplitHorizontalIcon,\n Trash2Icon,\n XIcon,\n} from 'lucide-react';\nimport {\n type TElement,\n type TTableCellElement,\n type TTableElement,\n type TTableRowElement,\n KEYS,\n PathApi,\n} from 'platejs';\nimport {\n type PlateElementProps,\n PlateElement,\n useComposedRef,\n useEditorPlugin,\n useEditorRef,\n useEditorSelector,\n useElement,\n useFocusedLast,\n usePluginOption,\n useReadOnly,\n useRemoveNodeButton,\n useSelected,\n withHOC,\n} from 'platejs/react';\nimport { useElementSelector } from 'platejs/react';\n\nimport { Button } from '@/components/ui/button';\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuPortal,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\n\nimport { blockSelectionVariants } from './block-selection';\nimport {\n ColorDropdownMenuItems,\n DEFAULT_COLORS,\n} from './font-color-toolbar-button';\nimport {\n BorderAllIcon,\n BorderBottomIcon,\n BorderLeftIcon,\n BorderNoneIcon,\n BorderRightIcon,\n BorderTopIcon,\n} from './table-icons';\nimport {\n Toolbar,\n ToolbarButton,\n ToolbarGroup,\n ToolbarMenuGroup,\n} from './toolbar';\n\ntype TableResizeDirection = 'bottom' | 'left' | 'right';\n\ntype TableResizeStartOptions = {\n colIndex: number;\n direction: TableResizeDirection;\n handleKey: string;\n rowIndex: number;\n};\n\ntype TableResizeDragState = {\n colIndex: number;\n direction: TableResizeDirection;\n initialPosition: number;\n initialSize: number;\n marginLeft: number;\n rowIndex: number;\n};\n\ntype TableResizeContextValue = {\n disableMarginLeft: boolean;\n clearResizePreview: (handleKey: string) => void;\n setResizePreview: (\n event: React.PointerEvent,\n options: TableResizeStartOptions\n ) => void;\n startResize: (\n event: React.PointerEvent,\n options: TableResizeStartOptions\n ) => void;\n};\n\nconst TABLE_CONTROL_COLUMN_WIDTH = 8;\nconst TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT = 1200;\n\nconst TableResizeContext = React.createContext(\n null\n);\n\nfunction useTableResizeContext() {\n const context = React.useContext(TableResizeContext);\n\n if (!context) {\n throw new Error('TableResizeContext is missing');\n }\n\n return context;\n}\n\nfunction useTableResizeController({\n deferColumnResize,\n dragIndicatorRef,\n hoverIndicatorRef,\n marginLeft,\n controlColumnWidth,\n tablePath,\n tableRef,\n wrapperRef,\n}: {\n deferColumnResize: boolean;\n dragIndicatorRef: React.RefObject;\n hoverIndicatorRef: React.RefObject;\n marginLeft: number;\n controlColumnWidth: number;\n tablePath: number[];\n tableRef: React.RefObject;\n wrapperRef: React.RefObject;\n}) {\n const { editor, getOptions } = useEditorPlugin(TablePlugin);\n const { disableMarginLeft = false, minColumnWidth = 0 } = getOptions();\n const colSizes = useTableColSizes({ disableOverrides: true });\n const colSizesRef = React.useRef(colSizes);\n const activeHandleKeyRef = React.useRef(null);\n const activeRowElementRef = React.useRef(null);\n const cleanupListenersRef = React.useRef<(() => void) | null>(null);\n const marginLeftRef = React.useRef(marginLeft);\n const dragStateRef = React.useRef(null);\n const previewHandleKeyRef = React.useRef(null);\n const overrideColSize = useOverrideColSize();\n const overrideMarginLeft = useOverrideMarginLeft();\n const overrideRowSize = useOverrideRowSize();\n\n React.useEffect(() => {\n colSizesRef.current = colSizes;\n }, [colSizes]);\n\n React.useEffect(() => {\n marginLeftRef.current = marginLeft;\n }, [marginLeft]);\n\n const hideDeferredResizeIndicator = React.useCallback(() => {\n const indicator = dragIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'none';\n indicator.style.removeProperty('left');\n }, [dragIndicatorRef]);\n\n const showDeferredResizeIndicator = React.useCallback(\n (offset: number) => {\n const indicator = dragIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'block';\n indicator.style.left = `${offset}px`;\n },\n [dragIndicatorRef]\n );\n\n const hideResizeIndicator = React.useCallback(() => {\n const indicator = hoverIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'none';\n indicator.style.removeProperty('left');\n }, [hoverIndicatorRef]);\n\n const showResizeIndicatorAtOffset = React.useCallback(\n (offset: number) => {\n const indicator = hoverIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'block';\n indicator.style.left = `${offset}px`;\n },\n [hoverIndicatorRef]\n );\n\n const showResizeIndicator = React.useCallback(\n ({\n event,\n direction,\n }: Pick & {\n event: React.PointerEvent;\n }) => {\n if (direction === 'bottom') return;\n\n const wrapper = wrapperRef.current;\n\n if (!wrapper) return;\n\n const handleRect = event.currentTarget.getBoundingClientRect();\n const wrapperRect = wrapper.getBoundingClientRect();\n const boundaryOffset =\n handleRect.left - wrapperRect.left + handleRect.width / 2;\n\n showResizeIndicatorAtOffset(boundaryOffset);\n },\n [showResizeIndicatorAtOffset, wrapperRef]\n );\n\n const setResizePreview = React.useCallback(\n (\n event: React.PointerEvent,\n options: TableResizeStartOptions\n ) => {\n if (activeHandleKeyRef.current) return;\n\n previewHandleKeyRef.current = options.handleKey;\n showResizeIndicator({ ...options, event });\n },\n [showResizeIndicator]\n );\n\n const clearResizePreview = React.useCallback(\n (handleKey: string) => {\n if (activeHandleKeyRef.current) return;\n if (previewHandleKeyRef.current !== handleKey) return;\n\n previewHandleKeyRef.current = null;\n hideResizeIndicator();\n },\n [hideResizeIndicator]\n );\n\n const commitColSize = React.useCallback(\n (colIndex: number, width: number) => {\n setTableColSize(editor, { colIndex, width }, { at: tablePath });\n setTimeout(() => overrideColSize(colIndex, null), 0);\n },\n [editor, overrideColSize, tablePath]\n );\n\n const commitRowSize = React.useCallback(\n (rowIndex: number, height: number) => {\n setTableRowSize(editor, { height, rowIndex }, { at: tablePath });\n setTimeout(() => overrideRowSize(rowIndex, null), 0);\n },\n [editor, overrideRowSize, tablePath]\n );\n\n const commitMarginLeft = React.useCallback(\n (nextMarginLeft: number) => {\n setTableMarginLeft(\n editor,\n { marginLeft: nextMarginLeft },\n { at: tablePath }\n );\n setTimeout(() => overrideMarginLeft(null), 0);\n },\n [editor, overrideMarginLeft, tablePath]\n );\n\n const getColumnBoundaryOffset = React.useCallback(\n (colIndex: number, currentWidth: number) =>\n controlColumnWidth +\n colSizesRef.current\n .slice(0, colIndex)\n .reduce((total, colSize) => total + colSize, 0) +\n currentWidth,\n [controlColumnWidth]\n );\n\n const applyResize = React.useCallback(\n (event: PointerEvent, finished: boolean) => {\n const dragState = dragStateRef.current;\n\n if (!dragState) return;\n\n const currentPosition =\n dragState.direction === 'bottom' ? event.clientY : event.clientX;\n const delta = currentPosition - dragState.initialPosition;\n\n if (dragState.direction === 'bottom') {\n const newHeight = roundCellSizeToStep(\n dragState.initialSize + delta,\n undefined\n );\n\n if (finished) {\n commitRowSize(dragState.rowIndex, newHeight);\n } else {\n overrideRowSize(dragState.rowIndex, newHeight);\n }\n\n return;\n }\n\n if (dragState.direction === 'left') {\n const initial =\n colSizesRef.current[dragState.colIndex] ?? dragState.initialSize;\n const complement = (width: number) =>\n initial + dragState.marginLeft - width;\n const nextMarginLeft = roundCellSizeToStep(\n resizeLengthClampStatic(dragState.marginLeft + delta, {\n max: complement(minColumnWidth),\n min: 0,\n }),\n undefined\n );\n const nextWidth = complement(nextMarginLeft);\n\n if (finished) {\n commitMarginLeft(nextMarginLeft);\n commitColSize(dragState.colIndex, nextWidth);\n } else if (deferColumnResize) {\n showDeferredResizeIndicator(\n controlColumnWidth + (nextMarginLeft - dragState.marginLeft)\n );\n } else {\n showResizeIndicatorAtOffset(\n controlColumnWidth + (nextMarginLeft - dragState.marginLeft)\n );\n overrideMarginLeft(nextMarginLeft);\n overrideColSize(dragState.colIndex, nextWidth);\n }\n\n return;\n }\n\n const currentInitial =\n colSizesRef.current[dragState.colIndex] ?? dragState.initialSize;\n const nextInitial = colSizesRef.current[dragState.colIndex + 1];\n const complement = (width: number) =>\n currentInitial + nextInitial - width;\n const currentWidth = roundCellSizeToStep(\n resizeLengthClampStatic(currentInitial + delta, {\n max: nextInitial ? complement(minColumnWidth) : undefined,\n min: minColumnWidth,\n }),\n undefined\n );\n const nextWidth = nextInitial ? complement(currentWidth) : undefined;\n\n if (finished) {\n commitColSize(dragState.colIndex, currentWidth);\n\n if (nextWidth !== undefined) {\n commitColSize(dragState.colIndex + 1, nextWidth);\n }\n } else if (deferColumnResize) {\n showDeferredResizeIndicator(\n getColumnBoundaryOffset(dragState.colIndex, currentWidth)\n );\n } else {\n showResizeIndicatorAtOffset(\n getColumnBoundaryOffset(dragState.colIndex, currentWidth)\n );\n overrideColSize(dragState.colIndex, currentWidth);\n\n if (nextWidth !== undefined) {\n overrideColSize(dragState.colIndex + 1, nextWidth);\n }\n }\n },\n [\n commitColSize,\n commitMarginLeft,\n commitRowSize,\n controlColumnWidth,\n deferColumnResize,\n getColumnBoundaryOffset,\n showDeferredResizeIndicator,\n showResizeIndicatorAtOffset,\n minColumnWidth,\n overrideColSize,\n overrideMarginLeft,\n overrideRowSize,\n ]\n );\n\n const stopResize = React.useCallback(() => {\n cleanupListenersRef.current?.();\n cleanupListenersRef.current = null;\n activeHandleKeyRef.current = null;\n previewHandleKeyRef.current = null;\n dragStateRef.current = null;\n\n if (activeRowElementRef.current) {\n delete activeRowElementRef.current.dataset.tableResizing;\n activeRowElementRef.current = null;\n }\n\n hideDeferredResizeIndicator();\n hideResizeIndicator();\n }, [hideDeferredResizeIndicator, hideResizeIndicator]);\n\n React.useEffect(() => stopResize, [stopResize]);\n\n const startResize = React.useCallback(\n (\n event: React.PointerEvent,\n { colIndex, direction, handleKey, rowIndex }: TableResizeStartOptions\n ) => {\n const rowHeight =\n tableRef.current?.rows.item(rowIndex)?.getBoundingClientRect().height ??\n 0;\n\n dragStateRef.current = {\n colIndex,\n direction,\n initialPosition: direction === 'bottom' ? event.clientY : event.clientX,\n initialSize:\n direction === 'bottom'\n ? rowHeight\n : (colSizesRef.current[colIndex] ?? 0),\n marginLeft: marginLeftRef.current,\n rowIndex,\n };\n activeHandleKeyRef.current = handleKey;\n previewHandleKeyRef.current = null;\n\n const rowElement = tableRef.current?.rows.item(rowIndex) ?? null;\n\n if (\n activeRowElementRef.current &&\n activeRowElementRef.current !== rowElement\n ) {\n delete activeRowElementRef.current.dataset.tableResizing;\n }\n\n activeRowElementRef.current = rowElement;\n\n if (rowElement) {\n rowElement.dataset.tableResizing = 'true';\n }\n\n cleanupListenersRef.current?.();\n\n const handlePointerMove = (pointerEvent: PointerEvent) => {\n applyResize(pointerEvent, false);\n };\n\n const handlePointerEnd = (pointerEvent: PointerEvent) => {\n applyResize(pointerEvent, true);\n stopResize();\n };\n\n window.addEventListener('pointermove', handlePointerMove);\n window.addEventListener('pointerup', handlePointerEnd);\n window.addEventListener('pointercancel', handlePointerEnd);\n\n cleanupListenersRef.current = () => {\n window.removeEventListener('pointermove', handlePointerMove);\n window.removeEventListener('pointerup', handlePointerEnd);\n window.removeEventListener('pointercancel', handlePointerEnd);\n };\n\n if (deferColumnResize && direction !== 'bottom') {\n hideResizeIndicator();\n showDeferredResizeIndicator(\n direction === 'left'\n ? controlColumnWidth\n : getColumnBoundaryOffset(\n colIndex,\n colSizesRef.current[colIndex] ?? 0\n )\n );\n } else {\n showResizeIndicator({ direction, event });\n }\n\n event.preventDefault();\n event.stopPropagation();\n },\n [\n controlColumnWidth,\n deferColumnResize,\n getColumnBoundaryOffset,\n hideResizeIndicator,\n showDeferredResizeIndicator,\n showResizeIndicator,\n stopResize,\n tableRef,\n applyResize,\n ]\n );\n\n return React.useMemo(\n () => ({\n clearResizePreview,\n disableMarginLeft,\n setResizePreview,\n startResize,\n }),\n [clearResizePreview, disableMarginLeft, setResizePreview, startResize]\n );\n}\n\nexport const TableElement = withHOC(\n TableProvider,\n function TableElement({\n children,\n ...props\n }: PlateElementProps) {\n const readOnly = useReadOnly();\n const isSelectionAreaVisible = usePluginOption(\n BlockSelectionPlugin,\n 'isSelectionAreaVisible'\n );\n const hasControls = !readOnly && !isSelectionAreaVisible;\n const {\n isSelectingCell,\n marginLeft,\n props: tableProps,\n } = useTableElement();\n const colSizes = useTableColSizes();\n const controlColumnWidth = hasControls ? TABLE_CONTROL_COLUMN_WIDTH : 0;\n const dragIndicatorRef = React.useRef(null);\n const hoverIndicatorRef = React.useRef(null);\n const deferColumnResize =\n colSizes.length * props.element.children.length >\n TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT;\n const tablePath = useElementSelector(([, path]) => path, [], {\n key: KEYS.table,\n });\n const tableRef = React.useRef(null);\n const wrapperRef = React.useRef(null);\n const resizeController = useTableResizeController({\n controlColumnWidth,\n deferColumnResize,\n dragIndicatorRef,\n hoverIndicatorRef,\n marginLeft,\n tablePath,\n tableRef,\n wrapperRef,\n });\n const tableVariableStyle = React.useMemo(() => {\n if (colSizes.length === 0) {\n return;\n }\n\n return {\n ...Object.fromEntries(\n colSizes.map((colSize, index) => [\n `--table-col-${index}`,\n `${colSize}px`,\n ])\n ),\n } as React.CSSProperties;\n }, [colSizes]);\n const tableStyle = React.useMemo(\n () =>\n ({\n width: `${\n colSizes.reduce((total, colSize) => total + colSize, 0) +\n controlColumnWidth\n }px`,\n }) as React.CSSProperties,\n [colSizes, controlColumnWidth]\n );\n\n const isSelectingTable = useBlockSelected(props.element.id as string);\n\n const content = (\n \n \n \n \n \n );\n\n if (readOnly) {\n return content;\n }\n\n return {content};\n }\n);\n\nfunction TableFloatingToolbar({\n children,\n ...props\n}: React.ComponentProps) {\n const { tf } = useEditorPlugin(TablePlugin);\n const selected = useSelected();\n const element = useElement();\n const { props: buttonProps } = useRemoveNodeButton({ element });\n const collapsedInside = useEditorSelector(\n (editor) => selected && editor.api.isCollapsed(),\n [selected]\n );\n const isFocusedLast = useFocusedLast();\n\n const { canMerge, canSplit } = useTableMergeState();\n\n return (\n \n {children}\n e.preventDefault()}\n contentEditable={false}\n {...props}\n >\n \n \n \n \n \n {canMerge && (\n tf.table.merge()}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Merge cells\"\n >\n \n \n )}\n {canSplit && (\n tf.table.split()}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Split cell\"\n >\n \n \n )}\n\n \n \n \n \n \n \n\n \n \n \n \n\n {collapsedInside && (\n \n \n \n \n \n )}\n \n\n {collapsedInside && (\n \n {\n tf.insert.tableRow({ before: true });\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert row before\"\n >\n \n \n {\n tf.insert.tableRow();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert row after\"\n >\n \n \n {\n tf.remove.tableRow();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Delete row\"\n >\n \n \n \n )}\n\n {collapsedInside && (\n \n {\n tf.insert.tableColumn({ before: true });\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert column before\"\n >\n \n \n {\n tf.insert.tableColumn();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert column after\"\n >\n \n \n {\n tf.remove.tableColumn();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Delete column\"\n >\n \n \n \n )}\n \n \n \n );\n}\n\nfunction TableBordersDropdownMenuContent(\n props: React.ComponentProps\n) {\n const editor = useEditorRef();\n const {\n getOnSelectTableBorder,\n hasBottomBorder,\n hasLeftBorder,\n hasNoBorders,\n hasOuterBorders,\n hasRightBorder,\n hasTopBorder,\n } = useTableBordersDropdownMenuContentState();\n\n return (\n {\n e.preventDefault();\n editor.tf.focus();\n }}\n align=\"start\"\n side=\"right\"\n sideOffset={0}\n {...props}\n >\n \n \n \n
Top Border
\n \n \n \n
Right Border
\n \n \n \n
Bottom Border
\n \n \n \n
Left Border
\n \n
\n\n \n \n \n
No Border
\n \n \n \n
Outside Borders
\n \n
\n \n );\n}\n\nfunction ColorDropdownMenu({\n children,\n tooltip,\n}: {\n children: React.ReactNode;\n tooltip: string;\n}) {\n const [open, setOpen] = React.useState(false);\n\n const editor = useEditorRef();\n const selectedCells = usePluginOption(TablePlugin, 'selectedCells') as\n | TElement[]\n | null;\n\n const onUpdateColor = React.useCallback(\n (color: string) => {\n setOpen(false);\n setCellBackground(editor, { color, selectedCells: selectedCells ?? [] });\n },\n [selectedCells, editor]\n );\n\n const onClearColor = React.useCallback(() => {\n setOpen(false);\n setCellBackground(editor, {\n color: null,\n selectedCells: selectedCells ?? [],\n });\n }, [selectedCells, editor]);\n\n return (\n \n \n {children}\n \n\n \n \n \n \n \n \n \n Clear\n \n \n \n \n );\n}\n\nexport function TableRowElement({\n children,\n ...props\n}: PlateElementProps) {\n const { element } = props;\n const readOnly = useReadOnly();\n const selected = useSelected();\n const editor = useEditorRef();\n const rowIndex = useElementSelector(([, path]) => path.at(-1) as number, [], {\n key: KEYS.tr,\n });\n const rowSize = useElementSelector(\n ([node]) => (node as TTableRowElement).size,\n [],\n {\n key: KEYS.tr,\n }\n );\n const rowSizeOverrides = useTableValue('rowSizeOverrides');\n const rowMinHeight = rowSizeOverrides.get?.(rowIndex) ?? rowSize;\n const isSelectionAreaVisible = usePluginOption(\n BlockSelectionPlugin,\n 'isSelectionAreaVisible'\n );\n const hasControls = !readOnly && !isSelectionAreaVisible;\n\n const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({\n element,\n type: element.type,\n canDropNode: ({ dragEntry, dropEntry }) =>\n PathApi.equals(\n PathApi.parent(dragEntry[1]),\n PathApi.parent(dropEntry[1])\n ),\n onDropHandler: (_, { dragItem }) => {\n const dragElement = (dragItem as { element: TElement }).element;\n\n if (dragElement) {\n editor.tf.select(dragElement);\n }\n },\n });\n\n return (\n \n {hasControls && (\n \n \n \n \n )}\n\n {children}\n \n );\n}\n\nfunction useTableCellPresentation(element: TTableCellElement) {\n const { api, setOption } = useEditorPlugin(TablePlugin);\n const borders = useTableCellBorders({ element });\n const { col, row } = useCellIndices();\n const selected = useIsCellSelected(element);\n const selectedCells = usePluginOption(TablePlugin, 'selectedCells') as\n | TElement[]\n | null;\n\n React.useEffect(() => {\n if (\n selectedCells?.some((cell) => cell.id === element.id && cell !== element)\n ) {\n setOption(\n 'selectedCells',\n selectedCells.map((cell) => (cell.id === element.id ? element : cell))\n );\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [element]);\n\n const colSpan = api.table.getColSpan(element);\n const rowSpan = api.table.getRowSpan(element);\n const width = React.useMemo(() => {\n const terms = Array.from(\n { length: colSpan },\n (_, offset) => `var(--table-col-${col + offset}, 120px)`\n );\n\n return terms.length === 1 ? terms[0]! : `calc(${terms.join(' + ')})`;\n }, [col, colSpan]);\n\n return {\n borders,\n colIndex: col + colSpan - 1,\n colSpan,\n rowIndex: row + rowSpan - 1,\n rowSpan,\n selected,\n width,\n };\n}\n\nfunction RowDragHandle({ dragRef }: { dragRef: React.Ref }) {\n const editor = useEditorRef();\n const element = useElement();\n\n return (\n {\n editor.tf.select(element);\n }}\n >\n \n \n );\n}\n\nfunction RowDropLine() {\n const { dropLine } = useDropLine();\n\n if (!dropLine) return null;\n\n return (\n \n );\n}\n\nexport function TableCellElement({\n isHeader,\n ...props\n}: PlateElementProps & {\n isHeader?: boolean;\n}) {\n const readOnly = useReadOnly();\n const element = props.element;\n\n const tableId = useElementSelector(([node]) => node.id as string, [], {\n key: KEYS.table,\n });\n const rowId = useElementSelector(([node]) => node.id as string, [], {\n key: KEYS.tr,\n });\n const isSelectingTable = useBlockSelected(tableId);\n const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;\n const isSelectionAreaVisible = usePluginOption(\n BlockSelectionPlugin,\n 'isSelectionAreaVisible'\n );\n\n const {\n borders,\n colIndex,\n colSpan,\n rowIndex,\n rowSpan,\n selected: cellSelected,\n width,\n } = useTableCellPresentation(element);\n\n return (\n \n \n {props.children}\n \n\n {!readOnly && !isSelectionAreaVisible && (\n \n )}\n\n {isSelectingRow && (\n
\n )}\n \n );\n}\n\nexport function TableCellHeaderElement(\n props: React.ComponentProps\n) {\n return ;\n}\n\nconst TableCellResizeControls = React.memo(function TableCellResizeControls({\n colIndex,\n rowIndex,\n}: {\n colIndex: number;\n rowIndex: number;\n}) {\n const {\n clearResizePreview,\n disableMarginLeft,\n setResizePreview,\n startResize,\n } = useTableResizeContext();\n const rightHandleKey = `right:${rowIndex}:${colIndex}`;\n const bottomHandleKey = `bottom:${rowIndex}:${colIndex}`;\n const leftHandleKey = `left:${rowIndex}:${colIndex}`;\n const isLeftHandle = colIndex === 0 && !disableMarginLeft;\n\n return (\n \n {\n setResizePreview(event, {\n colIndex,\n direction: 'right',\n handleKey: rightHandleKey,\n rowIndex,\n });\n }}\n onPointerLeave={() => {\n clearResizePreview(rightHandleKey);\n }}\n onPointerDown={(event) => {\n startResize(event, {\n colIndex,\n direction: 'right',\n handleKey: rightHandleKey,\n rowIndex,\n });\n }}\n />\n {\n setResizePreview(event, {\n colIndex,\n direction: 'bottom',\n handleKey: bottomHandleKey,\n rowIndex,\n });\n }}\n onPointerLeave={() => {\n clearResizePreview(bottomHandleKey);\n }}\n onPointerDown={(event) => {\n startResize(event, {\n colIndex,\n direction: 'bottom',\n handleKey: bottomHandleKey,\n rowIndex,\n });\n }}\n />\n {isLeftHandle && (\n {\n setResizePreview(event, {\n colIndex,\n direction: 'left',\n handleKey: leftHandleKey,\n rowIndex,\n });\n }}\n onPointerLeave={() => {\n clearResizePreview(leftHandleKey);\n }}\n onPointerDown={(event) => {\n startResize(event, {\n colIndex,\n direction: 'left',\n handleKey: leftHandleKey,\n rowIndex,\n });\n }}\n />\n )}\n
\n );\n});\n\nTableCellResizeControls.displayName = 'TableCellResizeControls';\n", + "content": "'use client';\n\nimport * as React from 'react';\n\nimport { useDraggable, useDropLine } from '@platejs/dnd';\nimport {\n BlockSelectionPlugin,\n useBlockSelected,\n} from '@platejs/selection/react';\nimport { resizeLengthClampStatic } from '@platejs/resizable';\nimport {\n setCellBackground,\n setTableColSize,\n setTableMarginLeft,\n setTableRowSize,\n} from '@platejs/table';\nimport {\n TablePlugin,\n TableProvider,\n roundCellSizeToStep,\n useCellIndices,\n useOverrideColSize,\n useOverrideMarginLeft,\n useOverrideRowSize,\n useTableCellBorders,\n useTableBordersDropdownMenuContentState,\n useTableColSizes,\n useTableElement,\n useTableMergeState,\n useTableSelectionDom,\n useTableValue,\n} from '@platejs/table/react';\nimport {\n ArrowDown,\n ArrowLeft,\n ArrowRight,\n ArrowUp,\n CombineIcon,\n EraserIcon,\n Grid2X2Icon,\n GripVertical,\n PaintBucketIcon,\n SquareSplitHorizontalIcon,\n Trash2Icon,\n XIcon,\n} from 'lucide-react';\nimport {\n type TElement,\n type TTableCellElement,\n type TTableElement,\n type TTableRowElement,\n KEYS,\n PathApi,\n} from 'platejs';\nimport {\n type PlateElementProps,\n PlateElement,\n useComposedRef,\n useEditorPlugin,\n useEditorRef,\n useEditorSelector,\n useElement,\n useFocusedLast,\n usePluginOption,\n useReadOnly,\n useRemoveNodeButton,\n useSelected,\n withHOC,\n} from 'platejs/react';\nimport { useElementSelector } from 'platejs/react';\n\nimport { Button } from '@/components/ui/button';\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuGroup,\n DropdownMenuItem,\n DropdownMenuPortal,\n DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu';\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\n\nimport { blockSelectionVariants } from './block-selection';\nimport {\n ColorDropdownMenuItems,\n DEFAULT_COLORS,\n} from './font-color-toolbar-button';\nimport {\n BorderAllIcon,\n BorderBottomIcon,\n BorderLeftIcon,\n BorderNoneIcon,\n BorderRightIcon,\n BorderTopIcon,\n} from './table-icons';\nimport {\n Toolbar,\n ToolbarButton,\n ToolbarGroup,\n ToolbarMenuGroup,\n} from './toolbar';\n\ntype TableResizeDirection = 'bottom' | 'left' | 'right';\n\ntype TableResizeStartOptions = {\n colIndex: number;\n direction: TableResizeDirection;\n handleKey: string;\n rowIndex: number;\n};\n\ntype TableResizeDragState = {\n colIndex: number;\n direction: TableResizeDirection;\n initialPosition: number;\n initialSize: number;\n marginLeft: number;\n rowIndex: number;\n};\n\ntype TableResizeContextValue = {\n disableMarginLeft: boolean;\n clearResizePreview: (handleKey: string) => void;\n setResizePreview: (\n event: React.PointerEvent,\n options: TableResizeStartOptions\n ) => void;\n startResize: (\n event: React.PointerEvent,\n options: TableResizeStartOptions\n ) => void;\n};\n\nconst TABLE_CONTROL_COLUMN_WIDTH = 8;\nconst TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT = 1200;\n\nconst TableResizeContext = React.createContext(\n null\n);\n\nfunction useTableResizeContext() {\n const context = React.useContext(TableResizeContext);\n\n if (!context) {\n throw new Error('TableResizeContext is missing');\n }\n\n return context;\n}\n\nfunction useTableResizeController({\n deferColumnResize,\n dragIndicatorRef,\n hoverIndicatorRef,\n marginLeft,\n controlColumnWidth,\n tablePath,\n tableRef,\n wrapperRef,\n}: {\n deferColumnResize: boolean;\n dragIndicatorRef: React.RefObject;\n hoverIndicatorRef: React.RefObject;\n marginLeft: number;\n controlColumnWidth: number;\n tablePath: number[];\n tableRef: React.RefObject;\n wrapperRef: React.RefObject;\n}) {\n const { editor, getOptions } = useEditorPlugin(TablePlugin);\n const { disableMarginLeft = false, minColumnWidth = 0 } = getOptions();\n const colSizes = useTableColSizes({ disableOverrides: true });\n const colSizesRef = React.useRef(colSizes);\n const activeHandleKeyRef = React.useRef(null);\n const activeRowElementRef = React.useRef(null);\n const cleanupListenersRef = React.useRef<(() => void) | null>(null);\n const marginLeftRef = React.useRef(marginLeft);\n const dragStateRef = React.useRef(null);\n const previewHandleKeyRef = React.useRef(null);\n const overrideColSize = useOverrideColSize();\n const overrideMarginLeft = useOverrideMarginLeft();\n const overrideRowSize = useOverrideRowSize();\n\n React.useEffect(() => {\n colSizesRef.current = colSizes;\n }, [colSizes]);\n\n React.useEffect(() => {\n marginLeftRef.current = marginLeft;\n }, [marginLeft]);\n\n const hideDeferredResizeIndicator = React.useCallback(() => {\n const indicator = dragIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'none';\n indicator.style.removeProperty('left');\n }, [dragIndicatorRef]);\n\n const showDeferredResizeIndicator = React.useCallback(\n (offset: number) => {\n const indicator = dragIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'block';\n indicator.style.left = `${offset}px`;\n },\n [dragIndicatorRef]\n );\n\n const hideResizeIndicator = React.useCallback(() => {\n const indicator = hoverIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'none';\n indicator.style.removeProperty('left');\n }, [hoverIndicatorRef]);\n\n const showResizeIndicatorAtOffset = React.useCallback(\n (offset: number) => {\n const indicator = hoverIndicatorRef.current;\n\n if (!indicator) return;\n\n indicator.style.display = 'block';\n indicator.style.left = `${offset}px`;\n },\n [hoverIndicatorRef]\n );\n\n const showResizeIndicator = React.useCallback(\n ({\n event,\n direction,\n }: Pick & {\n event: React.PointerEvent;\n }) => {\n if (direction === 'bottom') return;\n\n const wrapper = wrapperRef.current;\n\n if (!wrapper) return;\n\n const handleRect = event.currentTarget.getBoundingClientRect();\n const wrapperRect = wrapper.getBoundingClientRect();\n const boundaryOffset =\n handleRect.left - wrapperRect.left + handleRect.width / 2;\n\n showResizeIndicatorAtOffset(boundaryOffset);\n },\n [showResizeIndicatorAtOffset, wrapperRef]\n );\n\n const setResizePreview = React.useCallback(\n (\n event: React.PointerEvent,\n options: TableResizeStartOptions\n ) => {\n if (activeHandleKeyRef.current) return;\n\n previewHandleKeyRef.current = options.handleKey;\n showResizeIndicator({ ...options, event });\n },\n [showResizeIndicator]\n );\n\n const clearResizePreview = React.useCallback(\n (handleKey: string) => {\n if (activeHandleKeyRef.current) return;\n if (previewHandleKeyRef.current !== handleKey) return;\n\n previewHandleKeyRef.current = null;\n hideResizeIndicator();\n },\n [hideResizeIndicator]\n );\n\n const commitColSize = React.useCallback(\n (colIndex: number, width: number) => {\n setTableColSize(editor, { colIndex, width }, { at: tablePath });\n setTimeout(() => overrideColSize(colIndex, null), 0);\n },\n [editor, overrideColSize, tablePath]\n );\n\n const commitRowSize = React.useCallback(\n (rowIndex: number, height: number) => {\n setTableRowSize(editor, { height, rowIndex }, { at: tablePath });\n setTimeout(() => overrideRowSize(rowIndex, null), 0);\n },\n [editor, overrideRowSize, tablePath]\n );\n\n const commitMarginLeft = React.useCallback(\n (nextMarginLeft: number) => {\n setTableMarginLeft(\n editor,\n { marginLeft: nextMarginLeft },\n { at: tablePath }\n );\n setTimeout(() => overrideMarginLeft(null), 0);\n },\n [editor, overrideMarginLeft, tablePath]\n );\n\n const getColumnBoundaryOffset = React.useCallback(\n (colIndex: number, currentWidth: number) =>\n controlColumnWidth +\n colSizesRef.current\n .slice(0, colIndex)\n .reduce((total, colSize) => total + colSize, 0) +\n currentWidth,\n [controlColumnWidth]\n );\n\n const applyResize = React.useCallback(\n (event: PointerEvent, finished: boolean) => {\n const dragState = dragStateRef.current;\n\n if (!dragState) return;\n\n const currentPosition =\n dragState.direction === 'bottom' ? event.clientY : event.clientX;\n const delta = currentPosition - dragState.initialPosition;\n\n if (dragState.direction === 'bottom') {\n const newHeight = roundCellSizeToStep(\n dragState.initialSize + delta,\n undefined\n );\n\n if (finished) {\n commitRowSize(dragState.rowIndex, newHeight);\n } else {\n overrideRowSize(dragState.rowIndex, newHeight);\n }\n\n return;\n }\n\n if (dragState.direction === 'left') {\n const initial =\n colSizesRef.current[dragState.colIndex] ?? dragState.initialSize;\n const complement = (width: number) =>\n initial + dragState.marginLeft - width;\n const nextMarginLeft = roundCellSizeToStep(\n resizeLengthClampStatic(dragState.marginLeft + delta, {\n max: complement(minColumnWidth),\n min: 0,\n }),\n undefined\n );\n const nextWidth = complement(nextMarginLeft);\n\n if (finished) {\n commitMarginLeft(nextMarginLeft);\n commitColSize(dragState.colIndex, nextWidth);\n } else if (deferColumnResize) {\n showDeferredResizeIndicator(\n controlColumnWidth + (nextMarginLeft - dragState.marginLeft)\n );\n } else {\n showResizeIndicatorAtOffset(\n controlColumnWidth + (nextMarginLeft - dragState.marginLeft)\n );\n overrideMarginLeft(nextMarginLeft);\n overrideColSize(dragState.colIndex, nextWidth);\n }\n\n return;\n }\n\n const currentInitial =\n colSizesRef.current[dragState.colIndex] ?? dragState.initialSize;\n const nextInitial = colSizesRef.current[dragState.colIndex + 1];\n const complement = (width: number) =>\n currentInitial + nextInitial - width;\n const currentWidth = roundCellSizeToStep(\n resizeLengthClampStatic(currentInitial + delta, {\n max: nextInitial ? complement(minColumnWidth) : undefined,\n min: minColumnWidth,\n }),\n undefined\n );\n const nextWidth = nextInitial ? complement(currentWidth) : undefined;\n\n if (finished) {\n commitColSize(dragState.colIndex, currentWidth);\n\n if (nextWidth !== undefined) {\n commitColSize(dragState.colIndex + 1, nextWidth);\n }\n } else if (deferColumnResize) {\n showDeferredResizeIndicator(\n getColumnBoundaryOffset(dragState.colIndex, currentWidth)\n );\n } else {\n showResizeIndicatorAtOffset(\n getColumnBoundaryOffset(dragState.colIndex, currentWidth)\n );\n overrideColSize(dragState.colIndex, currentWidth);\n\n if (nextWidth !== undefined) {\n overrideColSize(dragState.colIndex + 1, nextWidth);\n }\n }\n },\n [\n commitColSize,\n commitMarginLeft,\n commitRowSize,\n controlColumnWidth,\n deferColumnResize,\n getColumnBoundaryOffset,\n showDeferredResizeIndicator,\n showResizeIndicatorAtOffset,\n minColumnWidth,\n overrideColSize,\n overrideMarginLeft,\n overrideRowSize,\n ]\n );\n\n const stopResize = React.useCallback(() => {\n cleanupListenersRef.current?.();\n cleanupListenersRef.current = null;\n activeHandleKeyRef.current = null;\n previewHandleKeyRef.current = null;\n dragStateRef.current = null;\n\n if (activeRowElementRef.current) {\n delete activeRowElementRef.current.dataset.tableResizing;\n activeRowElementRef.current = null;\n }\n\n hideDeferredResizeIndicator();\n hideResizeIndicator();\n }, [hideDeferredResizeIndicator, hideResizeIndicator]);\n\n React.useEffect(() => stopResize, [stopResize]);\n\n const startResize = React.useCallback(\n (\n event: React.PointerEvent,\n { colIndex, direction, handleKey, rowIndex }: TableResizeStartOptions\n ) => {\n const rowHeight =\n tableRef.current?.rows.item(rowIndex)?.getBoundingClientRect().height ??\n 0;\n\n dragStateRef.current = {\n colIndex,\n direction,\n initialPosition: direction === 'bottom' ? event.clientY : event.clientX,\n initialSize:\n direction === 'bottom'\n ? rowHeight\n : (colSizesRef.current[colIndex] ?? 0),\n marginLeft: marginLeftRef.current,\n rowIndex,\n };\n activeHandleKeyRef.current = handleKey;\n previewHandleKeyRef.current = null;\n\n const rowElement = tableRef.current?.rows.item(rowIndex) ?? null;\n\n if (\n activeRowElementRef.current &&\n activeRowElementRef.current !== rowElement\n ) {\n delete activeRowElementRef.current.dataset.tableResizing;\n }\n\n activeRowElementRef.current = rowElement;\n\n if (rowElement) {\n rowElement.dataset.tableResizing = 'true';\n }\n\n cleanupListenersRef.current?.();\n\n const handlePointerMove = (pointerEvent: PointerEvent) => {\n applyResize(pointerEvent, false);\n };\n\n const handlePointerEnd = (pointerEvent: PointerEvent) => {\n applyResize(pointerEvent, true);\n stopResize();\n };\n\n window.addEventListener('pointermove', handlePointerMove);\n window.addEventListener('pointerup', handlePointerEnd);\n window.addEventListener('pointercancel', handlePointerEnd);\n\n cleanupListenersRef.current = () => {\n window.removeEventListener('pointermove', handlePointerMove);\n window.removeEventListener('pointerup', handlePointerEnd);\n window.removeEventListener('pointercancel', handlePointerEnd);\n };\n\n if (deferColumnResize && direction !== 'bottom') {\n hideResizeIndicator();\n showDeferredResizeIndicator(\n direction === 'left'\n ? controlColumnWidth\n : getColumnBoundaryOffset(\n colIndex,\n colSizesRef.current[colIndex] ?? 0\n )\n );\n } else {\n showResizeIndicator({ direction, event });\n }\n\n event.preventDefault();\n event.stopPropagation();\n },\n [\n controlColumnWidth,\n deferColumnResize,\n getColumnBoundaryOffset,\n hideResizeIndicator,\n showDeferredResizeIndicator,\n showResizeIndicator,\n stopResize,\n tableRef,\n applyResize,\n ]\n );\n\n return React.useMemo(\n () => ({\n clearResizePreview,\n disableMarginLeft,\n setResizePreview,\n startResize,\n }),\n [clearResizePreview, disableMarginLeft, setResizePreview, startResize]\n );\n}\n\nexport const TableElement = withHOC(\n TableProvider,\n function TableElement({\n children,\n ...props\n }: PlateElementProps) {\n const readOnly = useReadOnly();\n const isSelectionAreaVisible = usePluginOption(\n BlockSelectionPlugin,\n 'isSelectionAreaVisible'\n );\n const hasControls = !readOnly && !isSelectionAreaVisible;\n const {\n isSelectingCell,\n marginLeft,\n props: tableProps,\n } = useTableElement();\n const colSizes = useTableColSizes();\n const controlColumnWidth = hasControls ? TABLE_CONTROL_COLUMN_WIDTH : 0;\n const dragIndicatorRef = React.useRef(null);\n const hoverIndicatorRef = React.useRef(null);\n const deferColumnResize =\n colSizes.length * props.element.children.length >\n TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT;\n const tablePath = useElementSelector(([, path]) => path, [], {\n key: KEYS.table,\n });\n const tableRef = React.useRef(null);\n const wrapperRef = React.useRef(null);\n useTableSelectionDom(tableRef);\n const resizeController = useTableResizeController({\n controlColumnWidth,\n deferColumnResize,\n dragIndicatorRef,\n hoverIndicatorRef,\n marginLeft,\n tablePath,\n tableRef,\n wrapperRef,\n });\n const tableVariableStyle = React.useMemo(() => {\n if (colSizes.length === 0) {\n return;\n }\n\n return {\n ...Object.fromEntries(\n colSizes.map((colSize, index) => [\n `--table-col-${index}`,\n `${colSize}px`,\n ])\n ),\n } as React.CSSProperties;\n }, [colSizes]);\n const tableStyle = React.useMemo(\n () =>\n ({\n width: `${\n colSizes.reduce((total, colSize) => total + colSize, 0) +\n controlColumnWidth\n }px`,\n }) as React.CSSProperties,\n [colSizes, controlColumnWidth]\n );\n\n const isSelectingTable = useBlockSelected(props.element.id as string);\n\n const content = (\n \n \n \n \n \n );\n\n if (readOnly) {\n return content;\n }\n\n return {content};\n }\n);\n\nfunction TableFloatingToolbar({\n children,\n ...props\n}: React.ComponentProps) {\n const { tf } = useEditorPlugin(TablePlugin);\n const selected = useSelected();\n const element = useElement();\n const { props: buttonProps } = useRemoveNodeButton({ element });\n const collapsedInside = useEditorSelector(\n (editor) => selected && editor.api.isCollapsed(),\n [selected]\n );\n const isFocusedLast = useFocusedLast();\n\n const { canMerge, canSplit } = useTableMergeState();\n\n return (\n \n {children}\n e.preventDefault()}\n contentEditable={false}\n {...props}\n >\n \n \n \n \n \n {canMerge && (\n tf.table.merge()}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Merge cells\"\n >\n \n \n )}\n {canSplit && (\n tf.table.split()}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Split cell\"\n >\n \n \n )}\n\n \n \n \n \n \n \n\n \n \n \n \n\n {collapsedInside && (\n \n \n \n \n \n )}\n \n\n {collapsedInside && (\n \n {\n tf.insert.tableRow({ before: true });\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert row before\"\n >\n \n \n {\n tf.insert.tableRow();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert row after\"\n >\n \n \n {\n tf.remove.tableRow();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Delete row\"\n >\n \n \n \n )}\n\n {collapsedInside && (\n \n {\n tf.insert.tableColumn({ before: true });\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert column before\"\n >\n \n \n {\n tf.insert.tableColumn();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Insert column after\"\n >\n \n \n {\n tf.remove.tableColumn();\n }}\n onMouseDown={(e) => e.preventDefault()}\n tooltip=\"Delete column\"\n >\n \n \n \n )}\n \n \n \n );\n}\n\nfunction TableBordersDropdownMenuContent(\n props: React.ComponentProps\n) {\n const editor = useEditorRef();\n const {\n getOnSelectTableBorder,\n hasBottomBorder,\n hasLeftBorder,\n hasNoBorders,\n hasOuterBorders,\n hasRightBorder,\n hasTopBorder,\n } = useTableBordersDropdownMenuContentState();\n\n return (\n {\n e.preventDefault();\n editor.tf.focus();\n }}\n align=\"start\"\n side=\"right\"\n sideOffset={0}\n {...props}\n >\n \n \n \n
Top Border
\n \n \n \n
Right Border
\n \n \n \n
Bottom Border
\n \n \n \n
Left Border
\n \n
\n\n \n \n \n
No Border
\n \n \n \n
Outside Borders
\n \n
\n \n );\n}\n\nfunction ColorDropdownMenu({\n children,\n tooltip,\n}: {\n children: React.ReactNode;\n tooltip: string;\n}) {\n const [open, setOpen] = React.useState(false);\n\n const editor = useEditorRef();\n const selectedCells = usePluginOption(TablePlugin, 'selectedCells') as\n | TElement[]\n | null;\n\n const onUpdateColor = React.useCallback(\n (color: string) => {\n setOpen(false);\n setCellBackground(editor, { color, selectedCells: selectedCells ?? [] });\n },\n [selectedCells, editor]\n );\n\n const onClearColor = React.useCallback(() => {\n setOpen(false);\n setCellBackground(editor, {\n color: null,\n selectedCells: selectedCells ?? [],\n });\n }, [selectedCells, editor]);\n\n return (\n \n \n {children}\n \n\n \n \n \n \n \n \n \n Clear\n \n \n \n \n );\n}\n\nexport function TableRowElement({\n children,\n ...props\n}: PlateElementProps) {\n const { element } = props;\n const readOnly = useReadOnly();\n const selected = useSelected();\n const editor = useEditorRef();\n const rowIndex = useElementSelector(([, path]) => path.at(-1) as number, [], {\n key: KEYS.tr,\n });\n const rowSize = useElementSelector(\n ([node]) => (node as TTableRowElement).size,\n [],\n {\n key: KEYS.tr,\n }\n );\n const rowSizeOverrides = useTableValue('rowSizeOverrides');\n const rowMinHeight = rowSizeOverrides.get?.(rowIndex) ?? rowSize;\n const isSelectionAreaVisible = usePluginOption(\n BlockSelectionPlugin,\n 'isSelectionAreaVisible'\n );\n const hasControls = !readOnly && !isSelectionAreaVisible;\n\n const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({\n element,\n type: element.type,\n canDropNode: ({ dragEntry, dropEntry }) =>\n PathApi.equals(\n PathApi.parent(dragEntry[1]),\n PathApi.parent(dropEntry[1])\n ),\n onDropHandler: (_, { dragItem }) => {\n const dragElement = (dragItem as { element: TElement }).element;\n\n if (dragElement) {\n editor.tf.select(dragElement);\n }\n },\n });\n\n return (\n \n {hasControls && (\n \n \n \n \n )}\n\n {children}\n \n );\n}\n\nfunction useTableCellPresentation(element: TTableCellElement) {\n const { api, editor, setOption } = useEditorPlugin(TablePlugin);\n const borders = useTableCellBorders({ element });\n const { col, row } = useCellIndices();\n\n React.useEffect(() => {\n const selectedCells = editor.getOption(TablePlugin, 'selectedCells') as\n | TElement[]\n | null;\n\n if (\n !selectedCells?.some((cell) => cell.id === element.id && cell !== element)\n )\n return;\n\n setOption(\n 'selectedCells',\n selectedCells.map((cell) => (cell.id === element.id ? element : cell))\n );\n }, [editor, element, setOption]);\n\n const colSpan = api.table.getColSpan(element);\n const rowSpan = api.table.getRowSpan(element);\n const width = React.useMemo(() => {\n const terms = Array.from(\n { length: colSpan },\n (_, offset) => `var(--table-col-${col + offset}, 120px)`\n );\n\n return terms.length === 1 ? terms[0]! : `calc(${terms.join(' + ')})`;\n }, [col, colSpan]);\n\n return {\n borders,\n colIndex: col + colSpan - 1,\n colSpan,\n rowIndex: row + rowSpan - 1,\n rowSpan,\n width,\n };\n}\n\nfunction RowDragHandle({ dragRef }: { dragRef: React.Ref }) {\n const editor = useEditorRef();\n const element = useElement();\n\n return (\n {\n editor.tf.select(element);\n }}\n >\n \n \n );\n}\n\nfunction RowDropLine() {\n const { dropLine } = useDropLine();\n\n if (!dropLine) return null;\n\n return (\n \n );\n}\n\nexport function TableCellElement({\n isHeader,\n ...props\n}: PlateElementProps & {\n isHeader?: boolean;\n}) {\n const readOnly = useReadOnly();\n const element = props.element;\n\n const tableId = useElementSelector(([node]) => node.id as string, [], {\n key: KEYS.table,\n });\n const rowId = useElementSelector(([node]) => node.id as string, [], {\n key: KEYS.tr,\n });\n const isSelectingTable = useBlockSelected(tableId);\n const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;\n const isSelectionAreaVisible = usePluginOption(\n BlockSelectionPlugin,\n 'isSelectionAreaVisible'\n );\n\n const { borders, colIndex, colSpan, rowIndex, rowSpan, width } =\n useTableCellPresentation(element);\n\n return (\n \n \n {props.children}\n \n\n {!readOnly && !isSelectionAreaVisible && (\n \n )}\n\n {isSelectingRow && (\n
\n )}\n \n );\n}\n\nexport function TableCellHeaderElement(\n props: React.ComponentProps\n) {\n return ;\n}\n\nconst TableCellResizeControls = React.memo(function TableCellResizeControls({\n colIndex,\n rowIndex,\n}: {\n colIndex: number;\n rowIndex: number;\n}) {\n const {\n clearResizePreview,\n disableMarginLeft,\n setResizePreview,\n startResize,\n } = useTableResizeContext();\n const rightHandleKey = `right:${rowIndex}:${colIndex}`;\n const bottomHandleKey = `bottom:${rowIndex}:${colIndex}`;\n const leftHandleKey = `left:${rowIndex}:${colIndex}`;\n const isLeftHandle = colIndex === 0 && !disableMarginLeft;\n\n return (\n \n {\n setResizePreview(event, {\n colIndex,\n direction: 'right',\n handleKey: rightHandleKey,\n rowIndex,\n });\n }}\n onPointerLeave={() => {\n clearResizePreview(rightHandleKey);\n }}\n onPointerDown={(event) => {\n startResize(event, {\n colIndex,\n direction: 'right',\n handleKey: rightHandleKey,\n rowIndex,\n });\n }}\n />\n {\n setResizePreview(event, {\n colIndex,\n direction: 'bottom',\n handleKey: bottomHandleKey,\n rowIndex,\n });\n }}\n onPointerLeave={() => {\n clearResizePreview(bottomHandleKey);\n }}\n onPointerDown={(event) => {\n startResize(event, {\n colIndex,\n direction: 'bottom',\n handleKey: bottomHandleKey,\n rowIndex,\n });\n }}\n />\n {isLeftHandle && (\n {\n setResizePreview(event, {\n colIndex,\n direction: 'left',\n handleKey: leftHandleKey,\n rowIndex,\n });\n }}\n onPointerLeave={() => {\n clearResizePreview(leftHandleKey);\n }}\n onPointerDown={(event) => {\n startResize(event, {\n colIndex,\n direction: 'left',\n handleKey: leftHandleKey,\n rowIndex,\n });\n }}\n />\n )}\n
\n );\n});\n\nTableCellResizeControls.displayName = 'TableCellResizeControls';\n", "type": "registry:ui" }, { diff --git a/apps/www/src/app/dev/table-perf/page.tsx b/apps/www/src/app/dev/table-perf/page.tsx index bde2eec707..80f9cac9e9 100644 --- a/apps/www/src/app/dev/table-perf/page.tsx +++ b/apps/www/src/app/dev/table-perf/page.tsx @@ -4,6 +4,7 @@ import { Profiler, useCallback, useImperativeHandle, + useLayoutEffect, useRef, useState, } from 'react'; @@ -14,10 +15,11 @@ import type { TTableElement, TTableRowElement, } from 'platejs'; +import { TablePlugin } from '@platejs/table/react'; import type { PlateEditor } from 'platejs/react'; -import { Plate, usePlateEditor } from 'platejs/react'; +import { Plate, useEditorSelector, usePlateEditor } from 'platejs/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -50,7 +52,7 @@ type BenchmarkResult = { stdDev: number; }; -type InputLatencyResult = { +type LatencyResult = { max: number; mean: number; median: number; @@ -59,6 +61,17 @@ type InputLatencyResult = { samples: number[]; }; +type SelectionLatencyResult = LatencyResult & { + selectedCells: number; + simulatedDelayMs: number; +}; + +type SelectionSimulationConfig = { + cols: number; + delayMs: number; + rows: number; +}; + // Presets for O(n²) analysis const PRESETS: { cells: number; cols: number; label: string; rows: number }[] = [ @@ -94,6 +107,68 @@ function generateTableValue(rows: number, cols: number): TTableElement { }; } +function getCellPoint(rowIndex: number, colIndex: number) { + return { + offset: 0, + path: [0, rowIndex, colIndex, 0, 0], + }; +} + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function waitForNextPaint() { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} + +function blockMainThread(durationMs: number) { + const endTime = performance.now() + durationMs; + + while (performance.now() < endTime) { + // Busy loop on purpose. This is a dev-only latency simulator. + } +} + +function calculateLatencyStats(samples: number[]): LatencyResult { + if (samples.length === 0) { + return { max: 0, mean: 0, median: 0, min: 0, p95: 0, samples: [] }; + } + + const sorted = [...samples].sort((a, b) => a - b); + const n = sorted.length; + + return { + max: sorted[n - 1] ?? 0, + mean: samples.reduce((a, b) => a + b, 0) / n, + median: sorted[Math.floor(n / 2)] ?? 0, + min: sorted[0] ?? 0, + p95: sorted[Math.floor(n * 0.95)] ?? sorted[n - 1] ?? 0, + samples, + }; +} + +function hasSameIds( + nextValue: string[] | null | undefined, + prevValue: string[] | null | undefined +) { + if (nextValue === prevValue) return true; + if (!nextValue || !prevValue) return !nextValue && !prevValue; + if (nextValue.length !== prevValue.length) return false; + + for (const [index, nextId] of nextValue.entries()) { + if (nextId !== prevValue[index]) return false; + } + + return true; +} + // Statistics calculator function calculateStats(samples: number[]): BenchmarkResult { if (samples.length === 0) { @@ -138,10 +213,12 @@ function MetricsDisplay({ benchmarkResult, inputLatencyResult, metrics, + selectionLatencyResult, }: { benchmarkResult: BenchmarkResult | null; - inputLatencyResult: InputLatencyResult | null; + inputLatencyResult: LatencyResult | null; metrics: Metrics; + selectionLatencyResult: SelectionLatencyResult | null; }) { const stats = calculateStats(metrics.renderDurations); @@ -256,6 +333,49 @@ function MetricsDisplay({ )} + {selectionLatencyResult && ( + <> +
+
Table Selection Latency:
+
+ Selected cells: + + {selectionLatencyResult.selectedCells} + +
+
+ Injected delay: + + {selectionLatencyResult.simulatedDelayMs.toFixed(0)} ms + +
+
+ Mean: + + {selectionLatencyResult.mean.toFixed(2)} ms + +
+
+ Median: + + {selectionLatencyResult.median.toFixed(2)} ms + +
+
+ P95: + + {selectionLatencyResult.p95.toFixed(2)} ms + +
+
+ Min/Max: + + {selectionLatencyResult.min.toFixed(2)} /{' '} + {selectionLatencyResult.max.toFixed(2)} ms + +
+ + )} ); @@ -275,14 +395,26 @@ export default function TablePerfPage() { useState(null); const [isBenchmarking, setIsBenchmarking] = useState(false); const [inputLatencyResult, setInputLatencyResult] = - useState(null); + useState(null); const [isMeasuringLatency, setIsMeasuringLatency] = useState(false); + const [selectionLatencyResult, setSelectionLatencyResult] = + useState(null); + const [selectionSimulation, setSelectionSimulation] = + useState({ + cols: 3, + delayMs: 0, + rows: 3, + }); + const [isMeasuringSelection, setIsMeasuringSelection] = useState(false); const editorRef = useRef(null); const plateEditorRef = useRef(null); // Generate initial table value const tableValue = generateTableValue(config.rows, config.cols); + const selectedCols = Math.min(selectionSimulation.cols, config.cols); + const selectedRows = Math.min(selectionSimulation.rows, config.rows); + // Use ref to collect profiler data without causing re-renders const profilerDataRef = useRef<{ initialRender: number | null; @@ -340,14 +472,55 @@ export default function TablePerfPage() { renderDurations: [], }); setBenchmarkResult(null); + setInputLatencyResult(null); + setSelectionLatencyResult(null); shouldUpdateMetricsRef.current = true; // Allow metrics update setEditorKey((k) => k + 1); }, []); const handlePreset = useCallback((preset: (typeof PRESETS)[number]) => { setConfig({ cols: preset.cols, rows: preset.rows }); + setSelectionSimulation((current) => ({ + ...current, + cols: Math.min(current.cols, preset.cols), + rows: Math.min(current.rows, preset.rows), + })); + }, []); + + const focusEditor = useCallback(async () => { + const editorElement = editorRef.current?.querySelector('[contenteditable]'); + + if (editorElement) { + (editorElement as HTMLElement).focus(); + } + + await wait(100); + }, []); + + const collapseSelectionToFirstCell = useCallback((editor: PlateEditor) => { + const firstCellPoint = getCellPoint(0, 0); + + editor.tf.select({ + anchor: firstCellPoint, + focus: firstCellPoint, + }); }, []); + const selectCellRange = useCallback( + (editor: PlateEditor, rows: number, cols: number) => { + const endRow = Math.max(1, Math.min(config.rows, rows)) - 1; + const endCol = Math.max(1, Math.min(config.cols, cols)) - 1; + + editor.tf.select({ + anchor: getCellPoint(0, 0), + focus: getCellPoint(endRow, endCol), + }); + + return { cols: endCol + 1, rows: endRow + 1 }; + }, + [config.cols, config.rows] + ); + const runBenchmark = useCallback(async () => { setIsBenchmarking(true); setBenchmarkResult(null); @@ -378,16 +551,10 @@ export default function TablePerfPage() { setEditorKey((k) => k + 1); // Wait for render to complete - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve(); - }); - }); - }); + await waitForNextPaint(); // Small delay to ensure state is updated - await new Promise((resolve) => setTimeout(resolve, 50)); + await wait(50); const initialRender = profilerDataRef.current.initialRender; @@ -401,7 +568,7 @@ export default function TablePerfPage() { } // Cooldown between iterations - await new Promise((resolve) => setTimeout(resolve, COOLDOWN_MS)); + await wait(COOLDOWN_MS); } const result = calculateStats(renderTimes); @@ -429,31 +596,18 @@ export default function TablePerfPage() { return; } - // Focus the editor and select first cell - const editorElement = editorRef.current?.querySelector('[contenteditable]'); - if (editorElement) { - (editorElement as HTMLElement).focus(); - } - - // Wait for focus - await new Promise((resolve) => setTimeout(resolve, 100)); + await focusEditor(); // Select the start of the first cell using Slate API try { - // Find first text node path in the table - // Table structure: table > tr > td > p > text - const firstCellPath = [0, 0, 0, 0, 0]; // table[0]/row[0]/cell[0]/p[0]/text[0] - editor.tf.select({ - anchor: { offset: 0, path: firstCellPath }, - focus: { offset: 0, path: firstCellPath }, - }); + collapseSelectionToFirstCell(editor); } catch (e) { console.error('[Input Latency] Could not select first cell:', e); setIsMeasuringLatency(false); return; } - await new Promise((resolve) => setTimeout(resolve, 100)); + await wait(100); for (let i = 0; i < NUM_SAMPLES + WARMUP_SAMPLES; i++) { const isWarmup = i < WARMUP_SAMPLES; @@ -465,13 +619,7 @@ export default function TablePerfPage() { editor.tf.insertText(char); // Wait for React to process and render the update - await new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve(); - }); - }); - }); + await waitForNextPaint(); const endTime = performance.now(); const latency = endTime - startTime; @@ -486,33 +634,114 @@ export default function TablePerfPage() { } // Small delay between inputs - await new Promise((resolve) => setTimeout(resolve, 50)); + await wait(50); } - // Calculate stats - const sorted = [...samples].sort((a, b) => a - b); - const n = sorted.length; - const mean = samples.reduce((a, b) => a + b, 0) / n; - const median = sorted[Math.floor(n / 2)] ?? 0; - const p95 = sorted[Math.floor(n * 0.95)] ?? sorted[n - 1] ?? 0; - const min = sorted[0] ?? 0; - const max = sorted[n - 1] ?? 0; - - const result: InputLatencyResult = { - max, - mean, - median, - min, - p95, - samples, - }; + const result = calculateLatencyStats(samples); setInputLatencyResult(result); setIsMeasuringLatency(false); console.log('[Input Latency] Complete!'); console.log('[Input Latency] Results:', result); - }, []); + }, [collapseSelectionToFirstCell, focusEditor]); + + const simulateTableSelection = useCallback(async () => { + const editor = plateEditorRef.current; + if (!editor) { + console.error('[Selection] Could not find Plate editor'); + return; + } + + console.log( + `[Selection] Simulating ${selectedRows}x${selectedCols} range with ${selectionSimulation.delayMs}ms injected delay` + ); + + await focusEditor(); + collapseSelectionToFirstCell(editor); + await wait(50); + + shouldUpdateMetricsRef.current = true; + selectCellRange(editor, selectedRows, selectedCols); + + await waitForNextPaint(); + setMetrics({ ...profilerDataRef.current }); + }, [ + collapseSelectionToFirstCell, + focusEditor, + selectedCols, + selectedRows, + selectCellRange, + selectionSimulation.delayMs, + ]); + + const measureTableSelectionLatency = useCallback(async () => { + setIsMeasuringSelection(true); + setSelectionLatencyResult(null); + + const editor = plateEditorRef.current; + if (!editor) { + console.error('[Selection] Could not find Plate editor'); + setIsMeasuringSelection(false); + return; + } + + const samples: number[] = []; + const NUM_SAMPLES = 30; + const WARMUP_SAMPLES = 5; + + console.log('[Selection] Starting latency measurement...'); + console.log( + `[Selection] Range: ${selectedRows}x${selectedCols}, injected delay: ${selectionSimulation.delayMs}ms` + ); + + await focusEditor(); + collapseSelectionToFirstCell(editor); + await wait(100); + + for (let i = 0; i < NUM_SAMPLES + WARMUP_SAMPLES; i++) { + const isWarmup = i < WARMUP_SAMPLES; + + collapseSelectionToFirstCell(editor); + await wait(16); + + const startTime = performance.now(); + selectCellRange(editor, selectedRows, selectedCols); + await waitForNextPaint(); + + const latency = performance.now() - startTime; + + if (!isWarmup) { + samples.push(latency); + if ((i - WARMUP_SAMPLES + 1) % 10 === 0) { + console.log( + `[Selection] Sample ${i - WARMUP_SAMPLES + 1}/${NUM_SAMPLES}: ${latency.toFixed(2)}ms` + ); + } + } + + await wait(50); + } + + const result: SelectionLatencyResult = { + ...calculateLatencyStats(samples), + selectedCells: selectedRows * selectedCols, + simulatedDelayMs: selectionSimulation.delayMs, + }; + + setSelectionLatencyResult(result); + setIsMeasuringSelection(false); + + console.log('[Selection] Complete!'); + console.log('[Selection] Results:', result); + }, [ + collapseSelectionToFirstCell, + focusEditor, + selectedCols, + selectedRows, + selectCellRange, + selectionSimulation.delayMs, + ]); return (
@@ -534,12 +763,18 @@ export default function TablePerfPage() { min={1} type="number" value={config.rows} - onChange={(e) => - setConfig((c) => ({ - ...c, - rows: Math.max(1, Math.min(100, Number(e.target.value) || 1)), - })) - } + onChange={(e) => { + const nextRows = Math.max( + 1, + Math.min(100, Number(e.target.value) || 1) + ); + + setConfig((current) => ({ ...current, rows: nextRows })); + setSelectionSimulation((current) => ({ + ...current, + rows: Math.min(current.rows, nextRows), + })); + }} /> x @@ -554,12 +789,18 @@ export default function TablePerfPage() { min={1} type="number" value={config.cols} - onChange={(e) => - setConfig((c) => ({ - ...c, - cols: Math.max(1, Math.min(100, Number(e.target.value) || 1)), - })) - } + onChange={(e) => { + const nextCols = Math.max( + 1, + Math.min(100, Number(e.target.value) || 1) + ); + + setConfig((current) => ({ ...current, cols: nextCols })); + setSelectionSimulation((current) => ({ + ...current, + cols: Math.min(current.cols, nextCols), + })); + }} /> @@ -585,7 +826,91 @@ export default function TablePerfPage() { ))} -
+
+

Selection Simulation

+ +
+
+ + + setSelectionSimulation((current) => ({ + ...current, + rows: Math.max( + 1, + Math.min(config.rows, Number(e.target.value) || 1) + ), + })) + } + /> +
+ x +
+ + + setSelectionSimulation((current) => ({ + ...current, + cols: Math.max( + 1, + Math.min(config.cols, Number(e.target.value) || 1) + ), + })) + } + /> +
+
+ + + setSelectionSimulation((current) => ({ + ...current, + delayMs: Math.max( + 0, + Math.min(2000, Number(e.target.value) || 0) + ), + })) + } + /> + ms +
+ + = {selectedRows * selectedCols} selected cells + +
+ +

+ Delay is injected only on this page, right before paint, when the + table's multi-cell selection changes. +

+
+ +
+
@@ -621,6 +962,7 @@ export default function TablePerfPage() { benchmarkResult={benchmarkResult} inputLatencyResult={inputLatencyResult} metrics={metrics} + selectionLatencyResult={selectionLatencyResult} /> {/* Editor */} @@ -632,6 +974,7 @@ export default function TablePerfPage() { > @@ -644,9 +987,11 @@ export default function TablePerfPage() { // Editor component (separated for profiling) function TablePerfEditor({ editorRef, + selectionSimulationDelayMs, tableValue, }: { editorRef?: RefObject; + selectionSimulationDelayMs: number; tableValue: TTableElement; }) { const editor = usePlateEditor({ @@ -658,9 +1003,28 @@ function TablePerfEditor({ return ( + ); } + +function SelectionCostSimulator({ delayMs }: { delayMs: number }) { + const selectedCellIds = useEditorSelector( + (editor) => editor.getApi(TablePlugin).table.getSelectedCellIds(), + [], + { + equalityFn: hasSameIds, + } + ); + + useLayoutEffect(() => { + if (!selectedCellIds?.length || delayMs <= 0) return; + + blockMainThread(delayMs); + }, [delayMs, selectedCellIds]); + + return null; +} diff --git a/apps/www/src/registry/ui/font-color-toolbar-button.tsx b/apps/www/src/registry/ui/font-color-toolbar-button.tsx index ac3ad10ad4..b589677df2 100644 --- a/apps/www/src/registry/ui/font-color-toolbar-button.tsx +++ b/apps/www/src/registry/ui/font-color-toolbar-button.tsx @@ -530,7 +530,9 @@ export function ColorDropdownMenuItems({ key={name ?? value} value={value} isBrightColor={isBrightColor} - isSelected={!!color && normalizeColor(color) === normalizeColor(value)} + isSelected={ + !!color && normalizeColor(color) === normalizeColor(value) + } updateColor={updateColor} /> ))} diff --git a/apps/www/src/registry/ui/table-node.tsx b/apps/www/src/registry/ui/table-node.tsx index f958418f4b..524a5f085d 100644 --- a/apps/www/src/registry/ui/table-node.tsx +++ b/apps/www/src/registry/ui/table-node.tsx @@ -9,6 +9,7 @@ import { } from '@platejs/selection/react'; import { resizeLengthClampStatic } from '@platejs/resizable'; import { + getTableColumnCount, setCellBackground, setTableColSize, setTableMarginLeft, @@ -19,7 +20,6 @@ import { TableProvider, roundCellSizeToStep, useCellIndices, - useIsCellSelected, useOverrideColSize, useOverrideMarginLeft, useOverrideRowSize, @@ -28,6 +28,7 @@ import { useTableColSizes, useTableElement, useTableMergeState, + useTableSelectionDom, useTableValue, } from '@platejs/table/react'; import { @@ -138,7 +139,9 @@ type TableResizeContextValue = { }; const TABLE_CONTROL_COLUMN_WIDTH = 8; +const TABLE_DEFAULT_COLUMN_WIDTH = 120; const TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT = 1200; +const TABLE_MULTI_SELECTION_TOOLBAR_DELAY_MS = 150; const TableResizeContext = React.createContext( null @@ -175,21 +178,28 @@ function useTableResizeController({ }) { const { editor, getOptions } = useEditorPlugin(TablePlugin); const { disableMarginLeft = false, minColumnWidth = 0 } = getOptions(); - const colSizes = useTableColSizes({ disableOverrides: true }); - const colSizesRef = React.useRef(colSizes); + const colSizes = useTableColSizes({ + disableOverrides: true, + }); + const effectiveColSizes = React.useMemo( + () => colSizes.map((colSize) => colSize || TABLE_DEFAULT_COLUMN_WIDTH), + [colSizes] + ); + const effectiveColSizesRef = React.useRef(effectiveColSizes); const activeHandleKeyRef = React.useRef(null); const activeRowElementRef = React.useRef(null); const cleanupListenersRef = React.useRef<(() => void) | null>(null); const marginLeftRef = React.useRef(marginLeft); const dragStateRef = React.useRef(null); + const frozenRowIndicesRef = React.useRef(null); const previewHandleKeyRef = React.useRef(null); const overrideColSize = useOverrideColSize(); const overrideMarginLeft = useOverrideMarginLeft(); const overrideRowSize = useOverrideRowSize(); React.useEffect(() => { - colSizesRef.current = colSizes; - }, [colSizes]); + effectiveColSizesRef.current = effectiveColSizes; + }, [effectiveColSizes]); React.useEffect(() => { marginLeftRef.current = marginLeft; @@ -225,6 +235,39 @@ function useTableResizeController({ indicator.style.removeProperty('left'); }, [hoverIndicatorRef]); + const clearFrozenRowHeights = React.useCallback(() => { + const frozenRowIndices = frozenRowIndicesRef.current; + + if (!frozenRowIndices) return; + + frozenRowIndicesRef.current = null; + + frozenRowIndices.forEach((rowIndex) => { + overrideRowSize(rowIndex, null); + }); + }, [overrideRowSize]); + + const freezeRowHeights = React.useCallback(() => { + const table = tableRef.current; + + if (!table || deferColumnResize) return; + + clearFrozenRowHeights(); + + const frozenRowIndices: number[] = []; + + Array.from(table.rows).forEach((row, rowIndex) => { + const height = row.getBoundingClientRect().height; + + if (!height) return; + + overrideRowSize(rowIndex, height); + frozenRowIndices.push(rowIndex); + }); + + frozenRowIndicesRef.current = frozenRowIndices; + }, [clearFrozenRowHeights, deferColumnResize, overrideRowSize, tableRef]); + const showResizeIndicatorAtOffset = React.useCallback( (offset: number) => { const indicator = hoverIndicatorRef.current; @@ -315,7 +358,7 @@ function useTableResizeController({ const getColumnBoundaryOffset = React.useCallback( (colIndex: number, currentWidth: number) => controlColumnWidth + - colSizesRef.current + effectiveColSizesRef.current .slice(0, colIndex) .reduce((total, colSize) => total + colSize, 0) + currentWidth, @@ -349,7 +392,8 @@ function useTableResizeController({ if (dragState.direction === 'left') { const initial = - colSizesRef.current[dragState.colIndex] ?? dragState.initialSize; + effectiveColSizesRef.current[dragState.colIndex] ?? + dragState.initialSize; const complement = (width: number) => initial + dragState.marginLeft - width; const nextMarginLeft = roundCellSizeToStep( @@ -380,8 +424,9 @@ function useTableResizeController({ } const currentInitial = - colSizesRef.current[dragState.colIndex] ?? dragState.initialSize; - const nextInitial = colSizesRef.current[dragState.colIndex + 1]; + effectiveColSizesRef.current[dragState.colIndex] ?? + dragState.initialSize; + const nextInitial = effectiveColSizesRef.current[dragState.colIndex + 1]; const complement = (width: number) => currentInitial + nextInitial - width; const currentWidth = roundCellSizeToStep( @@ -444,7 +489,8 @@ function useTableResizeController({ hideDeferredResizeIndicator(); hideResizeIndicator(); - }, [hideDeferredResizeIndicator, hideResizeIndicator]); + clearFrozenRowHeights(); + }, [clearFrozenRowHeights, hideDeferredResizeIndicator, hideResizeIndicator]); React.useEffect(() => stopResize, [stopResize]); @@ -464,7 +510,8 @@ function useTableResizeController({ initialSize: direction === 'bottom' ? rowHeight - : (colSizesRef.current[colIndex] ?? 0), + : (effectiveColSizesRef.current[colIndex] ?? + TABLE_DEFAULT_COLUMN_WIDTH), marginLeft: marginLeftRef.current, rowIndex, }; @@ -488,6 +535,10 @@ function useTableResizeController({ cleanupListenersRef.current?.(); + if (direction !== 'bottom') { + freezeRowHeights(); + } + const handlePointerMove = (pointerEvent: PointerEvent) => { applyResize(pointerEvent, false); }; @@ -514,7 +565,8 @@ function useTableResizeController({ ? controlColumnWidth : getColumnBoundaryOffset( colIndex, - colSizesRef.current[colIndex] ?? 0 + effectiveColSizesRef.current[colIndex] ?? + TABLE_DEFAULT_COLUMN_WIDTH ) ); } else { @@ -534,6 +586,7 @@ function useTableResizeController({ stopResize, tableRef, applyResize, + freezeRowHeights, ] ); @@ -560,11 +613,7 @@ export const TableElement = withHOC( 'isSelectionAreaVisible' ); const hasControls = !readOnly && !isSelectionAreaVisible; - const { - isSelectingCell, - marginLeft, - props: tableProps, - } = useTableElement(); + const { marginLeft, props: tableProps } = useTableElement(); const colSizes = useTableColSizes(); const controlColumnWidth = hasControls ? TABLE_CONTROL_COLUMN_WIDTH : 0; const dragIndicatorRef = React.useRef(null); @@ -577,6 +626,7 @@ export const TableElement = withHOC( }); const tableRef = React.useRef(null); const wrapperRef = React.useRef(null); + useTableSelectionDom(tableRef); const resizeController = useTableResizeController({ controlColumnWidth, deferColumnResize, @@ -587,29 +637,39 @@ export const TableElement = withHOC( tableRef, wrapperRef, }); + const resolvedColSizes = React.useMemo(() => { + if (colSizes.length > 0) { + return colSizes.map((colSize) => colSize || TABLE_DEFAULT_COLUMN_WIDTH); + } + + return Array.from( + { length: getTableColumnCount(props.element) }, + () => TABLE_DEFAULT_COLUMN_WIDTH + ); + }, [colSizes, props.element]); const tableVariableStyle = React.useMemo(() => { - if (colSizes.length === 0) { + if (resolvedColSizes.length === 0) { return; } return { ...Object.fromEntries( - colSizes.map((colSize, index) => [ + resolvedColSizes.map((colSize, index) => [ `--table-col-${index}`, `${colSize}px`, ]) ), } as React.CSSProperties; - }, [colSizes]); + }, [resolvedColSizes]); const tableStyle = React.useMemo( () => ({ width: `${ - colSizes.reduce((total, colSize) => total + colSize, 0) + + resolvedColSizes.reduce((total, colSize) => total + colSize, 0) + controlColumnWidth }px`, }) as React.CSSProperties, - [colSizes, controlColumnWidth] + [controlColumnWidth, resolvedColSizes] ); const isSelectingTable = useBlockSelected(props.element.id as string); @@ -643,12 +703,16 @@ export const TableElement = withHOC( ref={tableRef} className={cn( 'mr-0 ml-px table h-px table-fixed border-collapse', - isSelectingCell && 'selection:bg-transparent' + 'data-[table-selecting=true]:[&_*::selection]:!bg-transparent', + 'data-[table-selecting=true]:[&_*::selection]:!text-inherit', + 'data-[table-selecting=true]:[&_*::-moz-selection]:!bg-transparent', + 'data-[table-selecting=true]:[&_*::-moz-selection]:!text-inherit', + 'data-[table-selecting=true]:[&_*]:!caret-transparent' )} style={tableStyle} {...tableProps} > - {colSizes.length > 0 && ( + {resolvedColSizes.length > 0 && ( {hasControls && ( )} - {colSizes.map((colSize, index) => ( + {resolvedColSizes.map((colSize, index) => ( ))} @@ -701,144 +761,249 @@ function TableFloatingToolbar({ children, ...props }: React.ComponentProps) { - const { tf } = useEditorPlugin(TablePlugin); + const selectedCellCount = useEditorSelector( + (editor) => + editor.getApi(TablePlugin).table.getSelectedCellIds()?.length ?? 0, + [] + ); const selected = useSelected(); - const element = useElement(); - const { props: buttonProps } = useRemoveNodeButton({ element }); const collapsedInside = useEditorSelector( (editor) => selected && editor.api.isCollapsed(), [selected] ); const isFocusedLast = useFocusedLast(); + const [isExpandedSelectionToolbarReady, setIsExpandedSelectionToolbarReady] = + React.useState(false); + const isCollapsedToolbarOpen = isFocusedLast && collapsedInside; + const isExpandedSelectionPending = + isFocusedLast && !collapsedInside && selectedCellCount > 1; + + React.useEffect(() => { + if (!isExpandedSelectionPending) { + setIsExpandedSelectionToolbarReady(false); + + return; + } + + const timeoutId = window.setTimeout(() => { + setIsExpandedSelectionToolbarReady(true); + }, TABLE_MULTI_SELECTION_TOOLBAR_DELAY_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isExpandedSelectionPending]); + + const shouldRenderExpandedSelectionToolbar = + isExpandedSelectionToolbarReady && isExpandedSelectionPending; + const isToolbarOpen = + isCollapsedToolbarOpen || shouldRenderExpandedSelectionToolbar; + + return ( + + {children} + {isCollapsedToolbarOpen && ( + + )} + {shouldRenderExpandedSelectionToolbar && ( + + )} + + ); +} +function ExpandedSelectionTableFloatingToolbarContent( + props: React.ComponentProps +) { + const { tf } = useEditorPlugin(TablePlugin); const { canMerge, canSplit } = useTableMergeState(); + if (!canMerge && !canSplit) return null; + + return ( + tf.table.merge()} + onSplit={() => tf.table.split()} + {...props} + /> + ); +} + +function CollapsedTableFloatingToolbarContent( + props: React.ComponentProps +) { + const { tf } = useEditorPlugin(TablePlugin); + const element = useElement(); + const { props: buttonProps } = useRemoveNodeButton({ element }); + const { canSplit } = useTableMergeState(); + + return ( + { + tf.remove.tableColumn(); + }} + onDeleteRow={() => { + tf.remove.tableRow(); + }} + onInsertColumnAfter={() => { + tf.insert.tableColumn(); + }} + onInsertColumnBefore={() => { + tf.insert.tableColumn({ before: true }); + }} + onInsertRowAfter={() => { + tf.insert.tableRow(); + }} + onInsertRowBefore={() => { + tf.insert.tableRow({ before: true }); + }} + onSplit={() => tf.table.split()} + {...props} + /> + ); +} + +function TableFloatingToolbarContent({ + buttonProps, + canMerge = false, + canSplit = false, + collapsedInside = false, + onDeleteColumn, + onDeleteRow, + onInsertColumnAfter, + onInsertColumnBefore, + onInsertRowAfter, + onInsertRowBefore, + onMerge, + onSplit, + ...props +}: React.ComponentProps & { + buttonProps?: React.ComponentProps; + canMerge?: boolean; + canSplit?: boolean; + collapsedInside?: boolean; + onDeleteColumn?: () => void; + onDeleteRow?: () => void; + onInsertColumnAfter?: () => void; + onInsertColumnBefore?: () => void; + onInsertRowAfter?: () => void; + onInsertRowBefore?: () => void; + onMerge?: () => void; + onSplit?: () => void; +}) { return ( - e.preventDefault()} + contentEditable={false} + {...props} > - {children} - e.preventDefault()} + - - - - - - {canMerge && ( - tf.table.merge()} - onMouseDown={(e) => e.preventDefault()} - tooltip="Merge cells" - > - - - )} - {canSplit && ( - tf.table.split()} - onMouseDown={(e) => e.preventDefault()} - tooltip="Split cell" - > - + + + + + {canMerge && onMerge && ( + e.preventDefault()} + tooltip="Merge cells" + > + + + )} + {canSplit && onSplit && ( + e.preventDefault()} + tooltip="Split cell" + > + + + )} + + + + + - )} + - - - - - - - - - - - - - {collapsedInside && ( - - - - - - )} - + + + + {collapsedInside && ( - { - tf.insert.tableRow({ before: true }); - }} - onMouseDown={(e) => e.preventDefault()} - tooltip="Insert row before" - > - - - { - tf.insert.tableRow(); - }} - onMouseDown={(e) => e.preventDefault()} - tooltip="Insert row after" - > - - - { - tf.remove.tableRow(); - }} - onMouseDown={(e) => e.preventDefault()} - tooltip="Delete row" - > - + + )} + - {collapsedInside && ( - - { - tf.insert.tableColumn({ before: true }); - }} - onMouseDown={(e) => e.preventDefault()} - tooltip="Insert column before" - > - - - { - tf.insert.tableColumn(); - }} - onMouseDown={(e) => e.preventDefault()} - tooltip="Insert column after" - > - - - { - tf.remove.tableColumn(); - }} - onMouseDown={(e) => e.preventDefault()} - tooltip="Delete column" - > - - - - )} - - - + {collapsedInside && ( + + e.preventDefault()} + tooltip="Insert row before" + > + + + e.preventDefault()} + tooltip="Insert row after" + > + + + e.preventDefault()} + tooltip="Delete row" + > + + + + )} + + {collapsedInside && ( + + e.preventDefault()} + tooltip="Insert column before" + > + + + e.preventDefault()} + tooltip="Insert column after" + > + + + e.preventDefault()} + tooltip="Delete column" + > + + + + )} + + ); } @@ -929,25 +1094,26 @@ function ColorDropdownMenu({ const [open, setOpen] = React.useState(false); const editor = useEditorRef(); - const selectedCells = usePluginOption(TablePlugin, 'selectedCells') as - | TElement[] - | null; const onUpdateColor = React.useCallback( (color: string) => { setOpen(false); - setCellBackground(editor, { color, selectedCells: selectedCells ?? [] }); + setCellBackground(editor, { + color, + selectedCells: + editor.getApi(TablePlugin).table.getSelectedCells() ?? [], + }); }, - [selectedCells, editor] + [editor] ); const onClearColor = React.useCallback(() => { setOpen(false); setCellBackground(editor, { color: null, - selectedCells: selectedCells ?? [], + selectedCells: editor.getApi(TablePlugin).table.getSelectedCells() ?? [], }); - }, [selectedCells, editor]); + }, [editor]); return ( @@ -980,7 +1146,6 @@ export function TableRowElement({ }: PlateElementProps) { const { element } = props; const readOnly = useReadOnly(); - const selected = useSelected(); const editor = useEditorRef(); const rowIndex = useElementSelector(([, path]) => path.at(-1) as number, [], { key: KEYS.tr, @@ -1029,10 +1194,6 @@ export function TableRowElement({ '--tableRowMinHeight': rowMinHeight ? `${rowMinHeight}px` : undefined, } as React.CSSProperties } - attributes={{ - ...props.attributes, - 'data-selected': selected ? 'true' : undefined, - }} > {hasControls && ( { - if ( - selectedCells?.some((cell) => cell.id === element.id && cell !== element) - ) { - setOption( - 'selectedCells', - selectedCells.map((cell) => (cell.id === element.id ? element : cell)) - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]); const colSpan = api.table.getColSpan(element); const rowSpan = api.table.getRowSpan(element); @@ -1087,7 +1232,6 @@ function useTableCellPresentation(element: TTableCellElement) { colSpan, rowIndex: row + rowSpan - 1, rowSpan, - selected, width, }; } @@ -1151,15 +1295,8 @@ export function TableCellElement({ 'isSelectionAreaVisible' ); - const { - borders, - colIndex, - colSpan, - rowIndex, - rowSpan, - selected: cellSelected, - width, - } = useTableCellPresentation(element); + const { borders, colIndex, colSpan, rowIndex, rowSpan, width } = + useTableCellPresentation(element); return ( diff --git a/packages/table/src/lib/BaseTablePlugin.ts b/packages/table/src/lib/BaseTablePlugin.ts index e1abdce1e3..8dc3704efc 100644 --- a/packages/table/src/lib/BaseTablePlugin.ts +++ b/packages/table/src/lib/BaseTablePlugin.ts @@ -18,9 +18,16 @@ import { mergeTableCells, splitTableCell } from './merge'; import { normalizeInitialValueTable } from './normalizeInitialValueTable'; import { getColSpan, + getSelectedCell, + getSelectedCellIds, + getSelectedCells, + getSelectedTableIds, + getSelectedTables, getRowSpan, getTableCellBorders, getTableCellSize, + isCellSelected, + isSelectingCell, } from './queries'; import { deleteColumn, @@ -114,9 +121,15 @@ export type TableConfig = PluginConfig< { /** @private Keeps Track of cell indices by id. */ _cellIndices: Record; - /** The currently selected cells. */ + /** @private Keeps track of selected cell ids for cheap membership checks. */ + _selectedCellIds: string[] | null | undefined; + /** @private Keeps track of selected table ids for cheap table checks. */ + _selectedTableIds: string[] | null | undefined; + /** @private Forces selection-derived selectors to refresh. */ + _selectionVersion: number; + /** Legacy selector key. Selected cells are derived from editor selection. */ selectedCells: TElement[] | null; - /** The currently selected tables. */ + /** Legacy selector key. Selected tables are derived from editor selection. */ selectedTables: TElement[] | null; /** Disable expanding the table when inserting cells. */ disableExpandOnInsert?: boolean; @@ -156,9 +169,16 @@ export type TableConfig = PluginConfig< table: { getCellBorders: OmitFirst; getCellSize: OmitFirst; + getSelectedCell: OmitFirst; + getSelectedCellIds: OmitFirst; + getSelectedCells: OmitFirst; + getSelectedTableIds: OmitFirst; + getSelectedTables: OmitFirst; getColSpan: typeof getColSpan; getRowSpan: typeof getRowSpan; getCellChildren: (cell: TTableCellElement) => Descendant[]; + isCellSelected: OmitFirst; + isSelectingCell: OmitFirst; }; }, { @@ -179,6 +199,13 @@ export type TableConfig = PluginConfig< }, { cellIndices?: (id: string) => CellIndices; + isCellSelected?: (id?: string | null) => boolean; + isSelectingCell?: () => boolean; + selectedCell?: (id?: string | null) => TElement | null; + selectedCellIds?: () => string[] | null; + selectedCells?: () => TElement[] | null; + selectedTableIds?: () => string[] | null; + selectedTables?: () => TElement[] | null; } >; @@ -192,6 +219,9 @@ export const BaseTablePlugin = createTSlatePlugin({ normalizeInitialValue: normalizeInitialValueTable, options: { _cellIndices: {}, + _selectedCellIds: undefined as string[] | null | undefined, + _selectedTableIds: undefined as string[] | null | undefined, + _selectionVersion: 0, disableMerge: false, minColumnWidth: 48, selectedCells: null as TElement[] | null, @@ -206,8 +236,59 @@ export const BaseTablePlugin = createTSlatePlugin({ }, plugins: [BaseTableRowPlugin, BaseTableCellPlugin, BaseTableCellHeaderPlugin], }) - .extendSelectors(({ getOptions }) => ({ + .extendSelectors(({ editor, getOptions }) => ({ cellIndices: (id) => getOptions()._cellIndices[id], + isCellSelected: (id) => { + const selectedCellIds = getOptions()._selectedCellIds; + + if (selectedCellIds !== undefined) { + return !!id && (selectedCellIds?.includes(id) ?? false); + } + + return isCellSelected(editor, id); + }, + isSelectingCell: () => { + const selectedCellIds = getOptions()._selectedCellIds; + + if (selectedCellIds !== undefined) { + return !!selectedCellIds; + } + + return isSelectingCell(editor); + }, + selectedCell: (id) => { + void getOptions()._selectionVersion; + + return getSelectedCell(editor, id); + }, + selectedCellIds: () => { + const selectedCellIds = getOptions()._selectedCellIds; + + if (selectedCellIds !== undefined) { + return selectedCellIds; + } + + return getSelectedCellIds(editor); + }, + selectedCells: () => { + void getOptions()._selectionVersion; + + return getSelectedCells(editor); + }, + selectedTableIds: () => { + const selectedTableIds = getOptions()._selectedTableIds; + + if (selectedTableIds !== undefined) { + return selectedTableIds; + } + + return getSelectedTableIds(editor); + }, + selectedTables: () => { + void getOptions()._selectionVersion; + + return getSelectedTables(editor); + }, })) .extendEditorApi(({ editor }) => ({ create: { @@ -218,9 +299,16 @@ export const BaseTablePlugin = createTSlatePlugin({ table: { getCellBorders: bindFirst(getTableCellBorders, editor), getCellSize: bindFirst(getTableCellSize, editor), + getSelectedCell: bindFirst(getSelectedCell, editor), + getSelectedCellIds: bindFirst(getSelectedCellIds, editor), + getSelectedCells: bindFirst(getSelectedCells, editor), + getSelectedTableIds: bindFirst(getSelectedTableIds, editor), + getSelectedTables: bindFirst(getSelectedTables, editor), getColSpan, getRowSpan, getCellChildren: (cell) => cell.children, + isCellSelected: bindFirst(isCellSelected, editor), + isSelectingCell: bindFirst(isSelectingCell, editor), }, })) .extendEditorTransforms(({ editor }) => ({ diff --git a/packages/table/src/lib/queries/getSelectedCells.ts b/packages/table/src/lib/queries/getSelectedCells.ts new file mode 100644 index 0000000000..5af19a60b7 --- /dev/null +++ b/packages/table/src/lib/queries/getSelectedCells.ts @@ -0,0 +1,157 @@ +import type { ElementEntry, SlateEditor, TElement } from 'platejs'; + +import { getTableGridAbove } from './getTableGridAbove'; + +type SelectionQueryCache = { + cellEntries?: ElementEntry[]; + children: SlateEditor['children']; + selection: SlateEditor['selection']; + selectedCellIds?: string[] | null; + selectedCells?: TElement[] | null; + selectedTableIds?: string[] | null; + selectedTables?: TElement[] | null; +}; + +const selectionQueryCache = new WeakMap(); + +const getSelectionQueryCache = (editor: SlateEditor) => { + const { selection } = editor; + const { children } = editor; + const cachedValue = selectionQueryCache.get(editor); + + if ( + cachedValue && + cachedValue.children === children && + cachedValue.selection === selection + ) { + return cachedValue; + } + + const nextValue: SelectionQueryCache = { + children, + selection, + }; + + selectionQueryCache.set(editor, nextValue); + + return nextValue; +}; + +export const getSelectedCellEntries = (editor: SlateEditor): ElementEntry[] => { + const cache = getSelectionQueryCache(editor); + + if ('cellEntries' in cache) { + return cache.cellEntries ?? []; + } + + const cellEntries = getTableGridAbove(editor, { format: 'cell' }); + const nextValue = cellEntries.length > 1 ? cellEntries : []; + + cache.cellEntries = nextValue; + + return nextValue; +}; + +export const getSelectedCells = (editor: SlateEditor): TElement[] | null => { + const cache = getSelectionQueryCache(editor); + + if ('selectedCells' in cache) { + return cache.selectedCells ?? null; + } + + const cellEntries = getSelectedCellEntries(editor); + + if (cellEntries.length === 0) { + cache.selectedCells = null; + + return null; + } + + const nextValue = cellEntries.map(([cell]) => cell); + + cache.selectedCells = nextValue; + + return nextValue; +}; + +export const getSelectedCellIds = (editor: SlateEditor): string[] | null => { + const cache = getSelectionQueryCache(editor); + + if ('selectedCellIds' in cache) { + return cache.selectedCellIds ?? null; + } + + const selectedCellIds = getSelectedCellEntries(editor) + .map(([cell]) => cell.id) + .filter((id): id is string => !!id); + + const nextValue = selectedCellIds.length > 0 ? selectedCellIds : null; + + cache.selectedCellIds = nextValue; + + return nextValue; +}; + +export const getSelectedTableIds = (editor: SlateEditor): string[] | null => { + const cache = getSelectionQueryCache(editor); + + if ('selectedTableIds' in cache) { + return cache.selectedTableIds ?? null; + } + + const selectedTables = getSelectedTables(editor); + + if (!selectedTables) { + cache.selectedTableIds = null; + + return null; + } + + const selectedTableIds = selectedTables + .map((table) => table.id) + .filter((id): id is string => !!id); + + const nextValue = selectedTableIds.length > 0 ? selectedTableIds : null; + + cache.selectedTableIds = nextValue; + + return nextValue; +}; + +export const getSelectedCell = (editor: SlateEditor, id?: string | null) => { + if (!id) return null; + + return ( + getSelectedCellEntries(editor).find(([cell]) => cell.id === id)?.[0] ?? null + ); +}; + +export const getSelectedTables = (editor: SlateEditor): TElement[] | null => { + const cache = getSelectionQueryCache(editor); + + if ('selectedTables' in cache) { + return cache.selectedTables ?? null; + } + + const selectedCellEntries = getSelectedCellEntries(editor); + + if (selectedCellEntries.length === 0) { + cache.selectedTables = null; + + return null; + } + + const nextValue = getTableGridAbove(editor, { format: 'table' }).map( + ([table]) => table + ); + + cache.selectedTables = nextValue; + + return nextValue; +}; + +export const isCellSelected = (editor: SlateEditor, id?: string | null) => + !!getSelectedCell(editor, id); + +export const isSelectingCell = (editor: SlateEditor) => + getSelectedCellEntries(editor).length > 0; diff --git a/packages/table/src/lib/queries/index.ts b/packages/table/src/lib/queries/index.ts index 667da98bbf..62f9abf6a9 100644 --- a/packages/table/src/lib/queries/index.ts +++ b/packages/table/src/lib/queries/index.ts @@ -9,6 +9,7 @@ export * from './getLeftTableCell'; export * from './getNextTableCell'; export * from './getPreviousTableCell'; export * from './getRowSpan'; +export * from './getSelectedCells'; export * from './getSelectedCellsBorders'; export * from './getSelectedCellsBoundingBox'; export * from './getTableAbove'; diff --git a/packages/table/src/lib/withTableCellSelection.spec.tsx b/packages/table/src/lib/withTableCellSelection.spec.tsx index 67a9631297..32f9298d7a 100644 --- a/packages/table/src/lib/withTableCellSelection.spec.tsx +++ b/packages/table/src/lib/withTableCellSelection.spec.tsx @@ -1,6 +1,6 @@ /** @jsx jsxt */ -import { type SlateEditor, createSlateEditor } from 'platejs'; +import { type SlateEditor, type TElement, createSlateEditor } from 'platejs'; import { jsxt } from '@platejs/test-utils'; @@ -17,9 +17,12 @@ const getTestTablePlugins = (options?: Partial) => [ }), ]; -const createTableEditor = (input: SlateEditor) => +const createTableEditor = ( + input: SlateEditor, + options?: Partial +) => createSlateEditor({ - plugins: getTestTablePlugins(), + plugins: getTestTablePlugins(options), selection: input.selection, value: input.children, }); @@ -403,4 +406,213 @@ describe('withTableCellSelection', () => { ); }); }); + + describe('selection selectors', () => { + it('derives multi-cell selection queries from the editor selection', () => { + const input = ( + + + + + + + cell11 + + + + cell12 + + + + + cell21 + + + + cell22 + + + + + + + ) as any as SlateEditor; + + const editor = createTableEditor(input); + + expect( + editor.getOption(BaseTablePlugin, 'selectedCellIds') + ).toStrictEqual(['c11', 'c12', 'c21', 'c22']); + expect( + editor + .getOption(BaseTablePlugin, 'selectedCells') + ?.map((cell: TElement) => cell.id) + ).toStrictEqual(['c11', 'c12', 'c21', 'c22']); + expect( + editor + .getOption(BaseTablePlugin, 'selectedTables') + ?.map((table: TElement) => table.type) + ).toStrictEqual(['table']); + expect(editor.getOption(BaseTablePlugin, 'isSelectingCell')).toBe(true); + expect(editor.getOption(BaseTablePlugin, 'isCellSelected', 'c12')).toBe( + true + ); + expect(editor.getOption(BaseTablePlugin, 'selectedCell', 'c21')?.id).toBe( + 'c21' + ); + }); + + it('returns empty multi-cell queries when the selection stays inside one cell', () => { + const input = ( + + + + + + + ce + + + + ll + + 11 + + + + + cell12 + + + + + ) as any as SlateEditor; + + const editor = createTableEditor(input); + + expect(editor.getOption(BaseTablePlugin, 'selectedCellIds')).toBeNull(); + expect(editor.getOption(BaseTablePlugin, 'selectedCells')).toBeNull(); + expect(editor.getOption(BaseTablePlugin, 'selectedTables')).toBeNull(); + expect(editor.getOption(BaseTablePlugin, 'isSelectingCell')).toBe(false); + expect(editor.getOption(BaseTablePlugin, 'isCellSelected', 'c11')).toBe( + false + ); + expect( + editor.getOption(BaseTablePlugin, 'selectedCell', 'c11') + ).toBeNull(); + }); + + it('reads the latest selected cell nodes after the table changes', () => { + const input = ( + + + + + + + cell11 + + + + + cell12 + + + + + + + ) as any as SlateEditor; + + const editor = createTableEditor(input); + + editor.tf.setNodes({ background: 'red' }, { at: [0, 0, 0] }); + + expect( + editor.getOption(BaseTablePlugin, 'selectedCell', 'c11') + ).toMatchObject({ + background: 'red', + id: 'c11', + }); + expect( + editor + .getOption(BaseTablePlugin, 'selectedCells') + ?.map((cell: TElement) => cell.id) + ).toStrictEqual(['c11', 'c12']); + }); + + it('updates selected cell ids when the Slate selection changes', () => { + const input = ( + + + + + + + cell11 + + + + cell12 + + + + + ) as any as SlateEditor; + + const editor = createTableEditor(input); + + editor.tf.select({ + anchor: editor.api.start([0, 0, 0])!, + focus: editor.api.end([0, 0, 1])!, + }); + + expect( + editor.getOption(BaseTablePlugin, 'selectedCellIds') + ).toStrictEqual(['c11', 'c12']); + + editor.tf.select(editor.api.start([0, 0, 0])!); + + expect(editor.getOption(BaseTablePlugin, 'selectedCellIds')).toBeNull(); + }); + + it('updates selected cell ids for unmerged tables when merge is enabled', () => { + const input = ( + + + + + + + cell11 + + + + cell12 + + + + + cell21 + + + cell22 + + + + + ) as any as SlateEditor; + + const editor = createTableEditor(input, { disableMerge: false }); + + editor.tf.select({ + anchor: editor.api.start([0, 0, 0])!, + focus: editor.api.end([0, 1, 1])!, + }); + + expect( + editor.getOption(BaseTablePlugin, 'selectedCellIds') + ).toStrictEqual(['c11', 'c12', 'c21', 'c22']); + }); + }); }); diff --git a/packages/table/src/react/components/TableCellElement/getOnSelectTableBorderFactory.ts b/packages/table/src/react/components/TableCellElement/getOnSelectTableBorderFactory.ts index 2bba172391..f0b7bdd51c 100644 --- a/packages/table/src/react/components/TableCellElement/getOnSelectTableBorderFactory.ts +++ b/packages/table/src/react/components/TableCellElement/getOnSelectTableBorderFactory.ts @@ -13,6 +13,7 @@ import { isSelectedCellBorder, setBorderSize, } from '../../../lib'; +import { TablePlugin } from '../../TablePlugin'; /** Helper: sets one cell's specific border(s) to `size`. */ function setCellBorderSize( @@ -193,10 +194,12 @@ export function setSelectedCellsBorder( * rectangle, then decide which edges to flip on/off. */ export const getOnSelectTableBorderFactory = - (editor: SlateEditor, selectedCells: TElement[] | null) => + (editor: SlateEditor) => (border: BorderDirection | 'none' | 'outer') => () => { - let cells = selectedCells; + let cells = editor.getApi(TablePlugin).table.getSelectedCells() as + | TElement[] + | null; if (!cells || cells.length === 0) { const cell = editor.api.block({ match: { type: getCellTypes(editor) } }); diff --git a/packages/table/src/react/components/TableCellElement/useIsCellSelected.ts b/packages/table/src/react/components/TableCellElement/useIsCellSelected.ts index b0e6ee4be8..2c0afefd0e 100644 --- a/packages/table/src/react/components/TableCellElement/useIsCellSelected.ts +++ b/packages/table/src/react/components/TableCellElement/useIsCellSelected.ts @@ -1,11 +1,14 @@ import type { TElement } from 'platejs'; -import { usePluginOption } from 'platejs/react'; +import { useEditorSelector } from 'platejs/react'; import { TablePlugin } from '../../TablePlugin'; -export const useIsCellSelected = (element: TElement) => { - const selectedCells = usePluginOption(TablePlugin, 'selectedCells'); - - return !!selectedCells?.includes(element); -}; +export const useIsCellSelected = (element: TElement) => + useEditorSelector( + (editor) => + editor + .getApi(TablePlugin) + .table.isCellSelected(element.id as string | null | undefined), + [element.id] + ); diff --git a/packages/table/src/react/components/TableCellElement/useTableBordersDropdownMenuContentState.ts b/packages/table/src/react/components/TableCellElement/useTableBordersDropdownMenuContentState.ts index e80ebb8a32..0424d448f2 100644 --- a/packages/table/src/react/components/TableCellElement/useTableBordersDropdownMenuContentState.ts +++ b/packages/table/src/react/components/TableCellElement/useTableBordersDropdownMenuContentState.ts @@ -1,11 +1,6 @@ import type { TTableElement } from 'platejs'; -import { - useEditorPlugin, - useEditorSelector, - useElement, - usePluginOption, -} from 'platejs/react'; +import { useEditorPlugin, useEditorSelector, useElement } from 'platejs/react'; import { type TableBorderStates, @@ -21,17 +16,13 @@ export const useTableBordersDropdownMenuContentState = ({ } = {}) => { const { editor } = useEditorPlugin(TablePlugin); const element = useElement() ?? el; - const selectedCells = usePluginOption(TablePlugin, 'selectedCells'); const borderStates = useEditorSelector( - (editor) => getSelectedCellsBorders(editor, selectedCells), - [selectedCells, element] + (editor) => getSelectedCellsBorders(editor), + [element] ); return { - getOnSelectTableBorder: getOnSelectTableBorderFactory( - editor, - selectedCells - ), + getOnSelectTableBorder: getOnSelectTableBorderFactory(editor), hasBottomBorder: borderStates.bottom, hasLeftBorder: borderStates.left, hasNoBorders: borderStates.none, diff --git a/packages/table/src/react/components/TableCellElement/useTableCellElement.ts b/packages/table/src/react/components/TableCellElement/useTableCellElement.ts index 3909d711e5..c80fb0faa6 100644 --- a/packages/table/src/react/components/TableCellElement/useTableCellElement.ts +++ b/packages/table/src/react/components/TableCellElement/useTableCellElement.ts @@ -1,8 +1,6 @@ -import React from 'react'; - import type { TTableCellElement } from 'platejs'; -import { useEditorPlugin, useElement, usePluginOption } from 'platejs/react'; +import { useEditorPlugin, useEditorSelector, useElement } from 'platejs/react'; import type { BorderStylesDefault } from '../../../lib'; @@ -25,21 +23,13 @@ export type TableCellElementState = { }; export const useTableCellElement = (): TableCellElementState => { - const { api, setOption } = useEditorPlugin(TablePlugin); + const { api } = useEditorPlugin(TablePlugin); const element = useElement(); const isCellSelected = useIsCellSelected(element); - const selectedCells = usePluginOption(TablePlugin, 'selectedCells'); - - // Sync element transforms with selected cells - React.useEffect(() => { - if (selectedCells?.some((v) => v.id === element.id && element !== v)) { - setOption( - 'selectedCells', - selectedCells.map((v) => (v.id === element.id ? element : v)) - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]); + const isSelectingCell = useEditorSelector( + (editor) => editor.getApi(TablePlugin).table.isSelectingCell(), + [] + ); const rowSizeOverrides = useTableValue('rowSizeOverrides'); const { minHeight, width } = useTableCellSize({ element }); @@ -59,7 +49,7 @@ export const useTableCellElement = (): TableCellElementState => { borders, colIndex: endingColIndex, colSpan, - isSelectingCell: !!selectedCells, + isSelectingCell, minHeight: rowSizeOverrides.get?.(endingRowIndex) ?? minHeight, rowIndex: endingRowIndex, selected: isCellSelected, diff --git a/packages/table/src/react/components/TableElement/index.ts b/packages/table/src/react/components/TableElement/index.ts index ccab74aaa4..c3a6250d18 100644 --- a/packages/table/src/react/components/TableElement/index.ts +++ b/packages/table/src/react/components/TableElement/index.ts @@ -5,3 +5,4 @@ export * from './useSelectedCells'; export * from './useTableColSizes'; export * from './useTableElement'; +export * from './useTableSelectionDom'; diff --git a/packages/table/src/react/components/TableElement/useSelectedCells.ts b/packages/table/src/react/components/TableElement/useSelectedCells.ts index a462ae67bf..a0fbe853e0 100644 --- a/packages/table/src/react/components/TableElement/useSelectedCells.ts +++ b/packages/table/src/react/components/TableElement/useSelectedCells.ts @@ -1,54 +1,70 @@ import React from 'react'; -import { - useEditorPlugin, - useEditorRef, - useEditorSelection, - usePluginOption, - useReadOnly, - useSelected, -} from 'platejs/react'; - -import { getTableGridAbove } from '../../../lib'; -import { TablePlugin } from '../../TablePlugin'; - -/** - * Many grid cells above and diff -> set No many grid cells above and diff -> - * unset No selection -> unset - */ -export const useSelectedCells = () => { - const readOnly = useReadOnly(); - const selected = useSelected(); - const editor = useEditorRef(); - const selection = useEditorSelection(); +import { useEditorPlugin, useEditorSelector, useReadOnly } from 'platejs/react'; + +import { getSelectedCellIds } from '../../../lib'; +import { BaseTablePlugin } from '../../../lib/BaseTablePlugin'; + +const hasSameIds = ( + nextValue: string[] | null | undefined, + prevValue: string[] | null | undefined +) => { + if (nextValue === prevValue) return true; + if (!nextValue || !prevValue) return !nextValue && !prevValue; + if (nextValue.length !== prevValue.length) return false; + + for (const [index, nextId] of nextValue.entries()) { + if (nextId !== prevValue[index]) return false; + } + + return true; +}; - const { setOption } = useEditorPlugin(TablePlugin); - const selectedCells = usePluginOption(TablePlugin, 'selectedCells'); +const hasSameSelectionState = ( + nextValue: { + selectedCellIds: string[] | null; + selectedContent: unknown; + }, + prevValue: { + selectedCellIds: string[] | null; + selectedContent: unknown; + } +) => + nextValue.selectedContent === prevValue.selectedContent && + hasSameIds(nextValue.selectedCellIds, prevValue.selectedCellIds); - React.useEffect(() => { - if (!selected || readOnly) { - setOption('selectedCells', null); - setOption('selectedTables', null); - } - }, [selected, editor, readOnly, setOption]); +export const useSelectedCells = () => { + const readOnly = useReadOnly(); + const { setOptions } = useEditorPlugin(BaseTablePlugin); + const selectionState = useEditorSelector( + (editor) => { + if (readOnly) { + return { selectedCellIds: null, selectedContent: null }; + } - React.useEffect(() => { - if (readOnly) return; + const selectedCellIds = getSelectedCellIds(editor); - const tableEntries = getTableGridAbove(editor, { format: 'table' }); - const cellEntries = getTableGridAbove(editor, { format: 'cell' }); + return { + selectedCellIds, + selectedContent: selectedCellIds ? editor.children : null, + }; + }, + [readOnly], + { equalityFn: hasSameSelectionState } + ); - if (cellEntries?.length > 1) { - const cells = cellEntries.map((entry) => entry[0]); - const tables = tableEntries.map((entry) => entry[0]); + React.useLayoutEffect(() => { + const nextSelectedCellIds = selectionState.selectedCellIds; - if (JSON.stringify(cells) !== JSON.stringify(selectedCells)) { - setOption('selectedCells', cells); - setOption('selectedTables', tables); + setOptions((draft) => { + if (!hasSameIds(draft._selectedCellIds, nextSelectedCellIds)) { + draft._selectedCellIds = nextSelectedCellIds; + } + if (draft._selectedTableIds !== undefined) { + draft._selectedTableIds = undefined; } - } else if (selectedCells) { - setOption('selectedCells', null); - setOption('selectedTables', null); - } - }, [editor, selection, readOnly, selectedCells, setOption]); + + draft._selectionVersion = (draft._selectionVersion ?? 0) + 1; + }); + }, [selectionState, setOptions]); }; diff --git a/packages/table/src/react/components/TableElement/useTableElement.ts b/packages/table/src/react/components/TableElement/useTableElement.ts index 477af4aab2..76404dd1fb 100644 --- a/packages/table/src/react/components/TableElement/useTableElement.ts +++ b/packages/table/src/react/components/TableElement/useTableElement.ts @@ -1,10 +1,9 @@ import type { TTableElement } from 'platejs'; -import { useEditorPlugin, useElement, usePluginOption } from 'platejs/react'; +import { useEditorPlugin, useElement } from 'platejs/react'; import { useTableValue } from '../../stores'; import { TablePlugin } from '../../TablePlugin'; -import { useSelectedCells } from './useSelectedCells'; export const useTableElement = () => { const { editor, getOptions } = useEditorPlugin(TablePlugin); @@ -12,22 +11,18 @@ export const useTableElement = () => { const { disableMarginLeft } = getOptions(); const element = useElement(); - const selectedCells = usePluginOption(TablePlugin, 'selectedCells'); const marginLeftOverride = useTableValue('marginLeftOverride'); const marginLeft = disableMarginLeft ? 0 : (marginLeftOverride ?? element.marginLeft ?? 0); - useSelectedCells(); - return { - isSelectingCell: !!selectedCells, marginLeft, props: { onMouseDown: () => { // until cell dnd is supported, we collapse the selection on mouse down - if (selectedCells) { + if (editor.getOption(TablePlugin, 'isSelectingCell')) { editor.tf.collapse(); } }, diff --git a/packages/table/src/react/components/TableElement/useTableSelectionDom.ts b/packages/table/src/react/components/TableElement/useTableSelectionDom.ts new file mode 100644 index 0000000000..e2af972650 --- /dev/null +++ b/packages/table/src/react/components/TableElement/useTableSelectionDom.ts @@ -0,0 +1,189 @@ +import React from 'react'; + +import { useEditorSelector } from 'platejs/react'; + +import { getSelectedCellIds } from '../../../lib'; + +const hasSameIds = ( + nextValue: string[] | null | undefined, + prevValue: string[] | null | undefined +) => { + if (nextValue === prevValue) return true; + if (!nextValue || !prevValue) return !nextValue && !prevValue; + if (nextValue.length !== prevValue.length) return false; + + for (const [index, nextId] of nextValue.entries()) { + if (nextId !== prevValue[index]) return false; + } + + return true; +}; + +const TABLE_CELL_SELECTED_ATTRIBUTE = 'data-table-cell-selected'; +const TABLE_SELECTING_ATTRIBUTE = 'data-table-selecting'; +const TABLE_CELL_SELECTOR = '[data-table-cell-id]'; + +const setTableSelectingAttribute = ( + table: HTMLTableElement, + isSelecting: boolean +) => { + if (isSelecting) { + table.setAttribute(TABLE_SELECTING_ATTRIBUTE, 'true'); + + return; + } + + table.removeAttribute(TABLE_SELECTING_ATTRIBUTE); +}; + +const escapeForAttributeSelector = (value: string) => + globalThis.CSS?.escape + ? globalThis.CSS.escape(value) + : value.replaceAll('"', '\\"'); + +const createTableCellElementsById = (table: HTMLTableElement) => { + const tableCellElementsById = new Map(); + + table + .querySelectorAll(TABLE_CELL_SELECTOR) + .forEach((element) => { + const cellId = element.getAttribute('data-table-cell-id'); + + if (cellId) { + tableCellElementsById.set(cellId, element); + } + }); + + return tableCellElementsById; +}; + +const getSelectedCellElement = ( + table: HTMLTableElement, + cellId: string, + tableCellElementsById: Map +) => { + const cachedElement = tableCellElementsById.get(cellId); + + if (cachedElement?.isConnected && table.contains(cachedElement)) { + return cachedElement; + } + + const element = table.querySelector( + `[data-table-cell-id="${escapeForAttributeSelector(cellId)}"]` + ); + + if (element) { + tableCellElementsById.set(cellId, element); + } else { + tableCellElementsById.delete(cellId); + } + + return element; +}; + +export const useTableSelectionDom = ( + tableRef: React.RefObject +) => { + const previousTableRef = React.useRef(null); + const previousSelectedCellIdsRef = React.useRef(null); + const tableCellElementsByIdRef = React.useRef | null>(null); + const selectedCellIds = useEditorSelector( + (editor) => getSelectedCellIds(editor), + [], + { + equalityFn: hasSameIds, + } + ); + + React.useLayoutEffect(() => { + const table = tableRef.current; + + if (!table) return; + + const tableChanged = previousTableRef.current !== table; + const previousSelectedCellIdsRefValue = previousSelectedCellIdsRef.current; + + if ( + !tableChanged && + hasSameIds(selectedCellIds, previousSelectedCellIdsRefValue) + ) { + return; + } + + const previousSelectedCellIds: string[] = tableChanged + ? [] + : (previousSelectedCellIdsRefValue ?? []); + const nextSelectedCellIds: string[] = selectedCellIds ?? []; + const tableCellElementsById = + tableChanged || !tableCellElementsByIdRef.current + ? createTableCellElementsById(table) + : tableCellElementsByIdRef.current; + + tableCellElementsByIdRef.current = tableCellElementsById; + + if (previousSelectedCellIds.length === 0) { + setTableSelectingAttribute(table, nextSelectedCellIds.length > 0); + + nextSelectedCellIds.forEach((cellId) => { + getSelectedCellElement( + table, + cellId, + tableCellElementsById + )?.setAttribute(TABLE_CELL_SELECTED_ATTRIBUTE, 'true'); + }); + + previousTableRef.current = table; + previousSelectedCellIdsRef.current = nextSelectedCellIds; + + return; + } + + if (nextSelectedCellIds.length === 0) { + setTableSelectingAttribute(table, false); + + previousSelectedCellIds.forEach((cellId) => { + getSelectedCellElement( + table, + cellId, + tableCellElementsById + )?.removeAttribute(TABLE_CELL_SELECTED_ATTRIBUTE); + }); + + previousTableRef.current = table; + previousSelectedCellIdsRef.current = nextSelectedCellIds; + + return; + } + + const nextSelectedCellIdsSet = new Set(nextSelectedCellIds); + const previousSelectedCellIdsSet = new Set(previousSelectedCellIds); + + setTableSelectingAttribute(table, true); + + previousSelectedCellIds.forEach((cellId) => { + if (nextSelectedCellIdsSet.has(cellId)) return; + + getSelectedCellElement( + table, + cellId, + tableCellElementsById + )?.removeAttribute(TABLE_CELL_SELECTED_ATTRIBUTE); + }); + + nextSelectedCellIds.forEach((cellId) => { + if (previousSelectedCellIdsSet.has(cellId)) return; + + getSelectedCellElement( + table, + cellId, + tableCellElementsById + )?.setAttribute(TABLE_CELL_SELECTED_ATTRIBUTE, 'true'); + }); + + previousTableRef.current = table; + previousSelectedCellIdsRef.current = nextSelectedCellIds; + }); +}; diff --git a/packages/table/src/react/hooks/useTableMergeState.ts b/packages/table/src/react/hooks/useTableMergeState.ts index ded91507a4..a1d0d02d83 100644 --- a/packages/table/src/react/hooks/useTableMergeState.ts +++ b/packages/table/src/react/hooks/useTableMergeState.ts @@ -1,19 +1,16 @@ /* eslint-disable react-hooks/rules-of-hooks */ +import React from 'react'; + import type { TTableCellElement } from 'platejs'; import { KEYS } from 'platejs'; -import { - useEditorPlugin, - useEditorSelector, - usePluginOption, - useReadOnly, -} from 'platejs/react'; - -import { getTableGridAbove, isTableRectangular } from '../../lib'; +import { useEditorPlugin, useEditorSelector, useReadOnly } from 'platejs/react'; + +import { getSelectedCellEntries, getSelectedCellsBoundingBox } from '../../lib'; import { TablePlugin } from '../TablePlugin'; export const useTableMergeState = () => { - const { api, getOptions } = useEditorPlugin(TablePlugin); + const { api, editor, getOptions } = useEditorPlugin(TablePlugin); const { disableMerge } = getOptions(); @@ -30,16 +27,29 @@ export const useTableMergeState = () => { ); const collapsed = !readOnly && someTable && !selectionExpanded; - const selectedTables = usePluginOption(TablePlugin, 'selectedTables'); - const selectedTable = selectedTables?.[0]; const selectedCellEntries = useEditorSelector( - (editor) => - getTableGridAbove(editor, { - format: 'cell', - }), + (editor) => getSelectedCellEntries(editor), [] ); + const isRectangularSelection = React.useMemo(() => { + if (selectedCellEntries.length <= 1) return false; + + const selectedCells = selectedCellEntries.map( + ([cell]) => cell as TTableCellElement + ); + const { maxCol, maxRow, minCol, minRow } = getSelectedCellsBoundingBox( + editor, + selectedCells + ); + const selectedArea = selectedCells.reduce( + (total, cell) => + total + api.table.getColSpan(cell) * api.table.getRowSpan(cell), + 0 + ); + + return selectedArea === (maxCol - minCol + 1) * (maxRow - minRow + 1); + }, [api.table, editor, selectedCellEntries]); if (!selectedCellEntries) return { canMerge: false, canSplit: false }; @@ -48,7 +58,7 @@ export const useTableMergeState = () => { someTable && selectionExpanded && selectedCellEntries.length > 1 && - isTableRectangular(selectedTable); + isRectangularSelection; const canSplit = collapsed && diff --git a/tooling/scripts/prepare-local-template-packages.mjs b/tooling/scripts/prepare-local-template-packages.mjs new file mode 100644 index 0000000000..01ecefed57 --- /dev/null +++ b/tooling/scripts/prepare-local-template-packages.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..', '..'); +const baseRef = process.env.TEMPLATE_LOCAL_PACKAGE_BASE_REF?.trim(); +const templateDirArgs = process.argv.slice(2); + +if (templateDirArgs.includes('-h') || templateDirArgs.includes('--help')) { + console.log( + 'Usage: node tooling/scripts/prepare-local-template-packages.mjs [template-dir...]' + ); + process.exit(0); +} + +if (templateDirArgs.length === 0) { + console.error( + 'Usage: node tooling/scripts/prepare-local-template-packages.mjs [template-dir...]' + ); + process.exit(1); +} + +const templateDirs = templateDirArgs.map((templateDir) => + path.resolve(repoRoot, templateDir) +); +const localPackageOutputDir = path.join( + repoRoot, + 'node_modules', + '.cache', + 'template-local-packages' +); +const workspacePackages = await getWorkspacePackages(); +const templateConfigs = await Promise.all( + templateDirs.map(async (templateDir) => { + const packageJsonPath = path.join(templateDir, 'package.json'); + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + const localDependencies = getLocalDependencies(packageJson); + + return { + localDependencies, + packageJson, + packageJsonPath, + templateDir, + }; + }) +); + +for (const templateConfig of templateConfigs) { + templateConfig.relevantPackageNames = getReachableWorkspacePackageNames( + templateConfig.localDependencies, + workspacePackages + ); +} + +const packagesToPrepare = getPackagesToPrepare({ + baseRef, + templateConfigs, + workspacePackages, +}); + +if (packagesToPrepare.size === 0) { + if (baseRef) { + console.log( + `No affected local workspace packages referenced by templates for ${baseRef}...HEAD.` + ); + } else { + console.log('No local workspace packages referenced by templates.'); + } + process.exit(0); +} + +await mkdir(localPackageOutputDir, { recursive: true }); + +buildWorkspacePackages([...packagesToPrepare.values()]); + +const tarballsByPackageName = new Map(); + +for (const [packageName, workspacePackage] of packagesToPrepare) { + const tarballPath = packWorkspacePackage(workspacePackage.directory); + + tarballsByPackageName.set(packageName, tarballPath); +} + +await Promise.all( + templateConfigs.map((templateConfig) => + rewriteTemplatePackageJson(templateConfig, tarballsByPackageName) + ) +); + +function buildWorkspacePackages(workspacePackagesToBuild) { + const filters = workspacePackagesToBuild + .map((workspacePackage) => { + const relativeDirectory = path.relative( + repoRoot, + workspacePackage.directory + ); + + return `--filter=./${toPosixPath(relativeDirectory)}`; + }) + .sort(); + const result = spawnSync('pnpm', ['turbo', 'build', ...filters], { + cwd: repoRoot, + stdio: 'inherit', + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function getPackagesToPrepare({ baseRef, templateConfigs, workspacePackages }) { + const packagesToPrepare = new Map(); + const dependencyNames = new Set( + templateConfigs.flatMap( + (templateConfig) => templateConfig.localDependencies + ) + ); + + if (!baseRef) { + for (const dependencyName of dependencyNames) { + const workspacePackage = workspacePackages.get(dependencyName); + + if (!workspacePackage) continue; + + packagesToPrepare.set(dependencyName, workspacePackage); + } + + return packagesToPrepare; + } + + const changedPackageNames = getChangedWorkspacePackageNames( + baseRef, + workspacePackages + ); + + if (changedPackageNames.size === 0) { + return packagesToPrepare; + } + + const relevantPackageNames = new Set( + templateConfigs.flatMap((templateConfig) => [ + ...templateConfig.relevantPackageNames, + ]) + ); + const selectedPackageNames = [...changedPackageNames].filter((packageName) => + relevantPackageNames.has(packageName) + ); + + if (selectedPackageNames.length === 0) { + return packagesToPrepare; + } + + console.log( + `Changed workspace packages for ${baseRef}...HEAD: ${[ + ...changedPackageNames, + ] + .sort() + .join(', ')}` + ); + console.log( + `Selected workspace packages for templates: ${selectedPackageNames + .toSorted() + .join(', ')}` + ); + + for (const packageName of selectedPackageNames) { + const workspacePackage = workspacePackages.get(packageName); + + if (!workspacePackage) continue; + + packagesToPrepare.set(packageName, workspacePackage); + } + + return packagesToPrepare; +} + +function getLocalDependencies(packageJson) { + return getDependencyNames(packageJson).filter((dependencyName) => + workspacePackages.has(dependencyName) + ); +} + +async function getWorkspacePackages() { + const workspacePackageDirectories = [ + path.join(repoRoot, 'packages'), + path.join(repoRoot, 'packages', 'udecode'), + ]; + const workspacePackagesByName = new Map(); + + for (const workspacePackageDirectory of workspacePackageDirectories) { + for (const directoryEntry of await listDirectories( + workspacePackageDirectory + )) { + const packageJsonPath = path.join(directoryEntry, 'package.json'); + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + + workspacePackagesByName.set(packageJson.name, { + directory: directoryEntry, + packageJson, + relativeDirectory: toPosixPath(path.relative(repoRoot, directoryEntry)), + }); + } + } + + for (const workspacePackage of workspacePackagesByName.values()) { + workspacePackage.localDependencyNames = getDependencyNames( + workspacePackage.packageJson + ).filter((dependencyName) => workspacePackagesByName.has(dependencyName)); + } + + return workspacePackagesByName; +} + +async function listDirectories(parentDirectory) { + const directoryEntries = await readdir(parentDirectory, { + withFileTypes: true, + }); + const directories = []; + + for (const directoryEntry of directoryEntries) { + if (!directoryEntry.isDirectory()) continue; + + const directoryPath = path.join(parentDirectory, directoryEntry.name); + + try { + await access(path.join(directoryPath, 'package.json')); + directories.push(directoryPath); + } catch {} + } + + return directories; +} + +function getChangedWorkspacePackageNames(baseRef, workspacePackages) { + const result = spawnSync( + 'git', + ['diff', '--name-only', `${baseRef}...HEAD`], + { + cwd: repoRoot, + encoding: 'utf8', + } + ); + + if (result.status !== 0) { + console.error( + `Failed to determine changed workspace packages for ${baseRef}...HEAD` + ); + process.exit(result.status ?? 1); + } + + const changedFiles = result.stdout + .split('\n') + .map((filePath) => filePath.trim()) + .filter(Boolean); + const changedPackageNames = new Set(); + + for (const changedFile of changedFiles) { + for (const [packageName, workspacePackage] of workspacePackages) { + const packagePrefix = `${workspacePackage.relativeDirectory}/`; + + if (changedFile.startsWith(packagePrefix)) { + changedPackageNames.add(packageName); + } + } + } + + return changedPackageNames; +} + +function getReachableWorkspacePackageNames( + initialPackageNames, + workspacePackages +) { + const relevantPackageNames = new Set(); + const pendingPackageNames = [...initialPackageNames]; + + while (pendingPackageNames.length > 0) { + const packageName = pendingPackageNames.shift(); + + if (!packageName || relevantPackageNames.has(packageName)) continue; + + relevantPackageNames.add(packageName); + + const workspacePackage = workspacePackages.get(packageName); + + if (!workspacePackage) continue; + + pendingPackageNames.push(...workspacePackage.localDependencyNames); + } + + return relevantPackageNames; +} + +function packWorkspacePackage(packageDirectory) { + const result = spawnSync( + 'pnpm', + ['pack', '--pack-destination', localPackageOutputDir], + { + cwd: packageDirectory, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + } + ); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + + const tarballPath = result.stdout.trim().split('\n').at(-1)?.trim(); + + if (!tarballPath) { + console.error(`Failed to determine tarball path for ${packageDirectory}`); + process.exit(1); + } + + return tarballPath; +} + +function getDependencyNames(packageJson) { + const dependencyNames = new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.devDependencies ?? {}), + ...Object.keys(packageJson.optionalDependencies ?? {}), + ...Object.keys(packageJson.peerDependencies ?? {}), + ]); + + return [...dependencyNames]; +} + +function rewriteTemplatePackageJson(templateConfig, tarballsByPackageName) { + const { packageJson, packageJsonPath, relevantPackageNames, templateDir } = + templateConfig; + const dependencySections = ['dependencies', 'devDependencies']; + const rewrittenPackageNames = new Set(); + + for (const section of dependencySections) { + const dependencies = packageJson[section]; + + if (!dependencies) continue; + + for (const dependencyName of Object.keys(dependencies)) { + const tarballPath = tarballsByPackageName.get(dependencyName); + + if (!tarballPath) continue; + + let relativeTarballPath = path.relative(templateDir, tarballPath); + + if (!relativeTarballPath.startsWith('.')) { + relativeTarballPath = `./${relativeTarballPath}`; + } + + dependencies[dependencyName] = `file:${toPosixPath(relativeTarballPath)}`; + rewrittenPackageNames.add(dependencyName); + } + } + + const missingDependencyNames = [...tarballsByPackageName.keys()].filter( + (dependencyName) => + relevantPackageNames.has(dependencyName) && + !rewrittenPackageNames.has(dependencyName) + ); + + if (missingDependencyNames.length > 0) { + packageJson.devDependencies ??= {}; + + for (const dependencyName of missingDependencyNames.toSorted()) { + const tarballPath = tarballsByPackageName.get(dependencyName); + + if (!tarballPath) continue; + + let relativeTarballPath = path.relative(templateDir, tarballPath); + + if (!relativeTarballPath.startsWith('.')) { + relativeTarballPath = `./${relativeTarballPath}`; + } + + packageJson.devDependencies[dependencyName] = + `file:${toPosixPath(relativeTarballPath)}`; + } + } + + return writeFile( + packageJsonPath, + `${JSON.stringify(packageJson, null, 2)}\n` + ); +} + +function toPosixPath(filePath) { + return filePath.split(path.sep).join('/'); +}