@quadrats/react 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useDragLayer } from 'react-dnd';
3
+ import { useTableDragContext } from '../contexts/TableDragContext.js';
4
+
5
+ const TableDragLayer = ({ scrollRef }) => {
6
+ const { dragState } = useTableDragContext();
7
+ const [columnWidths, setColumnWidths] = useState([]);
8
+ const [rowHeights, setRowHeights] = useState([]);
9
+ const { isDragging, currentOffset } = useDragLayer((monitor) => ({
10
+ isDragging: monitor.isDragging(),
11
+ currentOffset: monitor.getClientOffset(),
12
+ }));
13
+ // 計算所有 column 的寬度和 row 的高度
14
+ useEffect(() => {
15
+ if (!scrollRef.current || !isDragging)
16
+ return;
17
+ const tableContainer = scrollRef.current;
18
+ const cells = tableContainer.querySelectorAll('.qdr-table__cell');
19
+ if ((dragState === null || dragState === void 0 ? void 0 : dragState.type) === 'column') {
20
+ // 計算每個 column 的寬度
21
+ const widths = [];
22
+ const columnCells = Array.from(cells).filter((cell) => {
23
+ const cellElement = cell;
24
+ return cellElement.dataset.columnIndex !== undefined;
25
+ });
26
+ const columnCount = Math.max(...columnCells.map((cell) => {
27
+ const cellElement = cell;
28
+ return parseInt(cellElement.dataset.columnIndex || '0', 10);
29
+ })) + 1;
30
+ for (let i = 0; i < columnCount; i++) {
31
+ const cellsInColumn = columnCells.filter((cell) => {
32
+ const cellElement = cell;
33
+ return parseInt(cellElement.dataset.columnIndex || '0', 10) === i;
34
+ });
35
+ if (cellsInColumn.length > 0) {
36
+ const firstCell = cellsInColumn[0];
37
+ widths[i] = firstCell.getBoundingClientRect().width;
38
+ }
39
+ }
40
+ setColumnWidths(widths);
41
+ }
42
+ else if ((dragState === null || dragState === void 0 ? void 0 : dragState.type) === 'row') {
43
+ // 計算每個 row 的高度
44
+ const heights = [];
45
+ const rowCells = Array.from(cells).filter((cell) => {
46
+ const cellElement = cell;
47
+ return cellElement.dataset.rowIndex !== undefined;
48
+ });
49
+ const rowCount = Math.max(...rowCells.map((cell) => {
50
+ const cellElement = cell;
51
+ return parseInt(cellElement.dataset.rowIndex || '0', 10);
52
+ })) + 1;
53
+ for (let i = 0; i < rowCount; i++) {
54
+ const cellsInRow = rowCells.filter((cell) => {
55
+ const cellElement = cell;
56
+ return parseInt(cellElement.dataset.rowIndex || '0', 10) === i;
57
+ });
58
+ if (cellsInRow.length > 0) {
59
+ const firstCell = cellsInRow[0];
60
+ heights[i] = firstCell.getBoundingClientRect().height;
61
+ }
62
+ }
63
+ setRowHeights(heights);
64
+ }
65
+ }, [isDragging, dragState, scrollRef]);
66
+ if (!isDragging || !dragState || !currentOffset || !scrollRef.current) {
67
+ return null;
68
+ }
69
+ const tableContainer = scrollRef.current;
70
+ const tableRect = tableContainer.getBoundingClientRect();
71
+ if (dragState.type) {
72
+ const sourceIndex = dragState.type === 'column' ? dragState.columnIndex : dragState.rowIndex;
73
+ const rowHeight = dragState.type === 'column' ? tableRect.height : rowHeights[sourceIndex];
74
+ const columnWidth = dragState.type === 'column' ? columnWidths[sourceIndex] : tableRect.width;
75
+ return (React.createElement("div", { className: "qdr-table__drag-overlay", style: {
76
+ left: tableRect.left,
77
+ top: tableRect.top,
78
+ width: columnWidth,
79
+ height: rowHeight,
80
+ transform: dragState.type === 'column'
81
+ ? `translateX(${currentOffset.x - tableRect.left - columnWidth / 2}px)`
82
+ : `translateY(${currentOffset.y - tableRect.top - rowHeight / 2}px)`,
83
+ } },
84
+ React.createElement("div", { className: "qdr-table__drag-overlay-content" })));
85
+ }
86
+ return null;
87
+ };
88
+
89
+ export { TableDragLayer };
@@ -2,16 +2,18 @@ import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react'
2
2
  import clsx from 'clsx';
3
3
  import { useModal, useSlateStatic, ReactEditor } from '@quadrats/react';
4
4
  import { Icon } from '@quadrats/react/components';
5
- import { Copy, Trash, AlignLeft, AlignCenter, AlignRight, Plus } from '@quadrats/icons';
5
+ import { AlignLeft, AlignRight, AlignCenter, Copy, Trash, Plus } from '@quadrats/icons';
6
6
  import { useTableActionsContext } from '../hooks/useTableActionsContext.js';
7
7
  import { useTableMetadata } from '../hooks/useTableMetadata.js';
8
8
  import { useTableStateContext } from '../hooks/useTableStateContext.js';
9
+ import { useTableDragContext } from '../contexts/TableDragContext.js';
9
10
  import { InlineToolbar, ToolbarGroupIcon, ToolbarIcon } from '@quadrats/react/toolbar';
10
11
  import { Transforms } from 'slate';
11
12
  import { calculateTableMinWidth, columnWidthToCSS } from '@quadrats/common/table';
12
13
  import { TableScrollContext } from '../contexts/TableScrollContext.js';
13
14
  import { useTableCellAlign, useTableCellAlignStatus } from '../hooks/useTableCell.js';
14
15
  import { getTableElements, getColumnWidths } from '../utils/helper.js';
16
+ import { TableDragLayer } from './TableDragLayer.js';
15
17
 
16
18
  function TableMain(props) {
17
19
  const { attributes, children } = props;
@@ -20,6 +22,7 @@ function TableMain(props) {
20
22
  const { addColumn, addRow, addColumnAndRow } = useTableActionsContext();
21
23
  const { isReachMaximumColumns, isReachMaximumRows, tableElement } = useTableMetadata();
22
24
  const { tableSelectedOn, setTableSelectedOn } = useTableStateContext();
25
+ const { dragState } = useTableDragContext();
23
26
  // Table align functions
24
27
  const setAlign = useTableCellAlign(tableElement, editor);
25
28
  const getAlign = useTableCellAlignStatus(tableElement, editor);
@@ -63,10 +66,14 @@ function TableMain(props) {
63
66
  const handleScroll = () => {
64
67
  setScrollTop(scrollContainer.scrollTop);
65
68
  setScrollLeft(scrollContainer.scrollLeft);
66
- // 如果正在程式化更新滾動位置,不要觸發 Slate 更新
69
+ // 如果正在更新滾動位置,不要觸發 Slate 更新
67
70
  if (isUpdatingScrollRef.current) {
68
71
  return;
69
72
  }
73
+ // 如果正在拖曳,不要觸發 Slate 更新(避免 transform 導致事件遺失)
74
+ if (dragState) {
75
+ return;
76
+ }
70
77
  // 使用 debounce 來減少 Slate 更新頻率
71
78
  if (scrollUpdateTimerRef.current) {
72
79
  clearTimeout(scrollUpdateTimerRef.current);
@@ -89,7 +96,7 @@ function TableMain(props) {
89
96
  clearTimeout(scrollUpdateTimerRef.current);
90
97
  }
91
98
  };
92
- }, [editor, tableElement]);
99
+ }, [editor, tableElement, dragState]);
93
100
  // 只在 columnWidths 改變時恢復滾動位置
94
101
  useEffect(() => {
95
102
  const { current: scrollContainer } = scrollRef;
@@ -133,7 +140,7 @@ function TableMain(props) {
133
140
  // 獲取當前 table 的 align 狀態
134
141
  const currentTableAlign = getAlign('table');
135
142
  // 根據當前 table align 狀態選擇對應的 icon
136
- const getCurrentTableAlignIcon = () => {
143
+ const getCurrentTableAlignIcon = useCallback(() => {
137
144
  switch (currentTableAlign) {
138
145
  case 'left':
139
146
  return AlignLeft;
@@ -144,7 +151,7 @@ function TableMain(props) {
144
151
  default:
145
152
  return AlignLeft;
146
153
  }
147
- };
154
+ }, [currentTableAlign]);
148
155
  return (React.createElement("div", { className: clsx('qdr-table__mainWrapper', {
149
156
  'qdr-table__mainWrapper--selected': (tableSelectedOn === null || tableSelectedOn === void 0 ? void 0 : tableSelectedOn.region) === 'table',
150
157
  }) },
@@ -206,7 +213,8 @@ function TableMain(props) {
206
213
  width: columnWidthToCSS(width),
207
214
  minWidth: columnWidthToCSS(width),
208
215
  } })))),
209
- children))),
216
+ children)),
217
+ React.createElement(TableDragLayer, { scrollRef: scrollRef })),
210
218
  React.createElement("div", { className: "qdr-table__size-indicators" }, firstRowCells === null || firstRowCells === void 0 ? void 0 : firstRowCells.map((cell, colIndex) => (React.createElement("div", { key: colIndex, className: "qdr-table__size-indicator", style: {
211
219
  width: columnWidthToCSS(columnWidths[colIndex]),
212
220
  minWidth: columnWidthToCSS(columnWidths[colIndex]),
@@ -1,3 +1,3 @@
1
1
  import { TableContextType } from '../typings';
2
- export type TableActionsContextType = Pick<TableContextType, 'addColumn' | 'addRow' | 'addColumnAndRow' | 'deleteRow' | 'deleteColumn' | 'moveRowToBody' | 'moveRowToHeader' | 'unsetColumnAsTitle' | 'setColumnAsTitle' | 'pinColumn' | 'unpinColumn' | 'pinRow' | 'unpinRow'>;
2
+ export type TableActionsContextType = Pick<TableContextType, 'addColumn' | 'addRow' | 'addColumnAndRow' | 'deleteRow' | 'deleteColumn' | 'moveRowToBody' | 'moveRowToHeader' | 'unsetColumnAsTitle' | 'setColumnAsTitle' | 'pinColumn' | 'unpinColumn' | 'pinRow' | 'unpinRow' | 'moveOrSwapRow' | 'moveOrSwapColumn'>;
3
3
  export declare const TableActionsContext: import("react").Context<TableActionsContextType | undefined>;
@@ -0,0 +1,26 @@
1
+ import React, { Dispatch, SetStateAction } from 'react';
2
+ interface RowDragState {
3
+ type: 'row';
4
+ rowIndex: number;
5
+ isInHeader: boolean;
6
+ }
7
+ interface ColumnDragState {
8
+ type: 'column';
9
+ columnIndex: number;
10
+ isTitle: boolean;
11
+ }
12
+ type DragState = RowDragState | ColumnDragState | null;
13
+ type DragDirection = 'up' | 'down' | 'left' | 'right' | null;
14
+ interface TableDragContextValue {
15
+ dragState: DragState;
16
+ setDragState: Dispatch<SetStateAction<DragState>>;
17
+ dropTargetIndex: number | null;
18
+ setDropTargetIndex: Dispatch<SetStateAction<number | null>>;
19
+ dragDirection: DragDirection;
20
+ setDragDirection: Dispatch<SetStateAction<DragDirection>>;
21
+ }
22
+ export declare const TableDragProvider: React.FC<{
23
+ children: React.ReactNode;
24
+ }>;
25
+ export declare const useTableDragContext: () => TableDragContextValue;
26
+ export {};
@@ -0,0 +1,26 @@
1
+ import React, { createContext, useContext, useState, useMemo } from 'react';
2
+
3
+ const TableDragContext = createContext(null);
4
+ const TableDragProvider = ({ children }) => {
5
+ const [dragState, setDragState] = useState(null);
6
+ const [dropTargetIndex, setDropTargetIndex] = useState(null);
7
+ const [dragDirection, setDragDirection] = useState(null);
8
+ const value = useMemo(() => ({
9
+ dragState,
10
+ setDragState,
11
+ dropTargetIndex,
12
+ setDropTargetIndex,
13
+ dragDirection,
14
+ setDragDirection,
15
+ }), [dragState, dropTargetIndex, dragDirection]);
16
+ return React.createElement(TableDragContext.Provider, { value: value }, children);
17
+ };
18
+ const useTableDragContext = () => {
19
+ const context = useContext(TableDragContext);
20
+ if (!context) {
21
+ throw new Error('useTableDragContext must be used within TableDragProvider');
22
+ }
23
+ return context;
24
+ };
25
+
26
+ export { TableDragProvider, useTableDragContext };
@@ -80,6 +80,35 @@ function useColumnResize({ tableElement, columnIndex, cellRef }) {
80
80
  }
81
81
  });
82
82
  }
83
+ // 如果有 pinned columns,重新計算所有 pinned cells 的 left 位置
84
+ if (pinnedColumnIndices.length > 0) {
85
+ const allRows = tableDOMElement.querySelectorAll('tr');
86
+ const scrollContainer = tableDOMElement.closest('.qdr-table__scrollContainer');
87
+ const containerRect = scrollContainer === null || scrollContainer === void 0 ? void 0 : scrollContainer.getBoundingClientRect();
88
+ const containerWidth = (containerRect === null || containerRect === void 0 ? void 0 : containerRect.width) || tableWidth;
89
+ // 為每個 pinned column 計算應該的絕對位置
90
+ pinnedColumnIndices.forEach((pinnedIndex) => {
91
+ // 計算此 column 之前所有 pinned columns 的累積寬度
92
+ let accumulatedLeft = 0;
93
+ for (let i = 0; i < pinnedIndex; i++) {
94
+ if (pinnedColumnIndices.includes(i)) {
95
+ const width = newWidths[i];
96
+ if (width.type === 'percentage') {
97
+ const pixelWidth = (containerWidth * width.value) / 100;
98
+ accumulatedLeft += pixelWidth;
99
+ }
100
+ }
101
+ }
102
+ // 更新該 column 所有 cells 的 left 位置
103
+ allRows.forEach((row) => {
104
+ const cells = row.querySelectorAll('td, th');
105
+ const targetCell = cells[pinnedIndex];
106
+ if (targetCell && targetCell.classList.contains('qdr-table__cell--pinned')) {
107
+ targetCell.style.left = `${accumulatedLeft}px`;
108
+ }
109
+ });
110
+ });
111
+ }
83
112
  // 更新 size indicators
84
113
  const sizeIndicatorsContainer = mainWrapper === null || mainWrapper === void 0 ? void 0 : mainWrapper.querySelector('.qdr-table__size-indicators');
85
114
  if (sizeIndicatorsContainer) {
@@ -22,4 +22,6 @@ export declare function useTableActions(element: RenderTableElementProps['elemen
22
22
  unpinRow: () => void;
23
23
  isColumnPinned: (columnIndex: number) => boolean;
24
24
  isRowPinned: (rowIndex: number) => boolean;
25
+ moveOrSwapRow: (sourceRowIndex: number, targetRowIndex: number, mode?: "swap" | "move") => void;
26
+ moveOrSwapColumn: (sourceColumnIndex: number, targetColumnIndex: number, mode?: "swap" | "move") => void;
25
27
  };
@@ -3,7 +3,7 @@ import { Element, Editor, Transforms } from '@quadrats/core';
3
3
  import { ReactEditor } from 'slate-react';
4
4
  import { TABLE_ROW_TYPE, TABLE_CELL_TYPE, TABLE_DEFAULT_MAX_COLUMNS, TABLE_HEADER_TYPE } from '@quadrats/common/table';
5
5
  import { useQuadrats } from '@quadrats/react';
6
- import { getTableStructure, createTableCell, getColumnWidths, calculateColumnWidthsAfterAdd, setColumnWidths, getReferenceRowFromHeaderOrBody, calculateColumnWidthsAfterDelete, hasAnyPinnedRows, moveColumnWidth, getPinnedColumnsInfo, convertToMixedWidthMode, hasAnyPinnedColumns } from '../utils/helper.js';
6
+ import { getTableStructure, createTableCell, getColumnWidths, getPinnedColumnsInfo, calculateColumnWidthsAfterAdd, setColumnWidths, getReferenceRowFromHeaderOrBody, calculateColumnWidthsAfterDelete, hasAnyPinnedRows, moveOrSwapColumnWidth, convertToMixedWidthMode, hasAnyPinnedColumns, convertToPercentageMode } from '../utils/helper.js';
7
7
 
8
8
  function useTableActions(element) {
9
9
  const editor = useQuadrats();
@@ -120,7 +120,9 @@ function useTableActions(element) {
120
120
  // 調整欄位寬度
121
121
  const currentWidths = getColumnWidths(element);
122
122
  if (currentWidths.length > 0) {
123
- const newWidths = calculateColumnWidthsAfterAdd(currentWidths, insertIndex);
123
+ // 獲取當前的 pinned columns 資訊
124
+ const { pinnedColumnIndices } = getPinnedColumnsInfo(element);
125
+ const newWidths = calculateColumnWidthsAfterAdd(currentWidths, insertIndex, pinnedColumnIndices, columnIndex);
124
126
  setColumnWidths(editor, element, newWidths);
125
127
  }
126
128
  });
@@ -256,7 +258,9 @@ function useTableActions(element) {
256
258
  if (currentWidths.length > 0) {
257
259
  // 新欄位插入在最後(columnCount 位置)
258
260
  const insertIndex = columnCount;
259
- const newWidths = calculateColumnWidthsAfterAdd(currentWidths, insertIndex);
261
+ // 獲取當前的 pinned columns 資訊
262
+ const { pinnedColumnIndices } = getPinnedColumnsInfo(element);
263
+ const newWidths = calculateColumnWidthsAfterAdd(currentWidths, insertIndex, pinnedColumnIndices);
260
264
  setColumnWidths(editor, element, newWidths);
261
265
  }
262
266
  });
@@ -521,7 +525,7 @@ function useTableActions(element) {
521
525
  // 調整 columnWidths:將 columnIndex 的寬度移動到 actualTargetIndex
522
526
  const currentWidths = getColumnWidths(element);
523
527
  if (currentWidths.length > 0) {
524
- const movedWidths = moveColumnWidth(currentWidths, columnIndex, actualTargetIndex);
528
+ const movedWidths = moveOrSwapColumnWidth(currentWidths, columnIndex, actualTargetIndex, 'move');
525
529
  // 檢查移動後是否還有 pinned columns
526
530
  const { pinnedColumnIndices } = getPinnedColumnsInfo(element);
527
531
  // 更新釘選欄位索引(移除當前欄位,並調整其他欄位的索引)
@@ -579,7 +583,7 @@ function useTableActions(element) {
579
583
  if (!tableStructure)
580
584
  return;
581
585
  const { tableHeaderElement, tableBodyElement, tableMainElement } = tableStructure;
582
- // 檢查是否已有 pinned columns(一致性規則檢查)
586
+ // 檢查是否已有 pinned columns
583
587
  const hasExistingPinnedColumns = hasAnyPinnedColumns(tableStructure);
584
588
  // 如果有現有的 pinned columns 且沒有提供自定義屬性,自動設置 pinned 以保持一致性
585
589
  const finalProps = customProps || (hasExistingPinnedColumns ? { pinned: true } : undefined);
@@ -626,10 +630,9 @@ function useTableActions(element) {
626
630
  });
627
631
  }
628
632
  });
629
- // 如果目標位置並不需要移動,則直接返回
630
- if (columnIndex < targetColumnIndex)
631
- return;
632
- if (columnIndex !== targetColumnIndex) {
633
+ // 檢查是否需要移動位置
634
+ const needsMove = columnIndex >= targetColumnIndex && columnIndex !== targetColumnIndex;
635
+ if (needsMove) {
633
636
  for (let rowIndex = containerElement.children.length - 1; rowIndex >= 0; rowIndex--) {
634
637
  const row = containerElement.children[rowIndex];
635
638
  if (Element.isElement(row) && row.type.includes(TABLE_ROW_TYPE)) {
@@ -644,7 +647,7 @@ function useTableActions(element) {
644
647
  // 調整 columnWidths:將 columnIndex 的寬度移動到 targetColumnIndex
645
648
  const currentWidths = getColumnWidths(element);
646
649
  if (currentWidths.length > 0) {
647
- const movedWidths = moveColumnWidth(currentWidths, columnIndex, targetColumnIndex);
650
+ const movedWidths = moveOrSwapColumnWidth(currentWidths, columnIndex, targetColumnIndex, 'move');
648
651
  // 如果設定了 pinned,需要轉換為混合模式
649
652
  if ((finalProps === null || finalProps === void 0 ? void 0 : finalProps.pinned) && tableWidth > 0) {
650
653
  const { pinnedColumnIndices } = getPinnedColumnsInfo(element);
@@ -673,7 +676,23 @@ function useTableActions(element) {
673
676
  const currentWidths = getColumnWidths(element);
674
677
  if (currentWidths.length > 0) {
675
678
  const { pinnedColumnIndices } = getPinnedColumnsInfo(element);
676
- const updatedPinnedIndices = [...new Set([...pinnedColumnIndices, columnIndex])].sort((a, b) => a - b);
679
+ // 找出所有已經是 title columns
680
+ const titleColumnIndices = new Set();
681
+ const firstRow = containerElement.children[0];
682
+ if (Element.isElement(firstRow) && firstRow.type.includes(TABLE_ROW_TYPE)) {
683
+ firstRow.children.forEach((cell, colIndex) => {
684
+ if (Element.isElement(cell) &&
685
+ cell.type.includes(TABLE_CELL_TYPE) &&
686
+ cell.treatAsTitle) {
687
+ titleColumnIndices.add(colIndex);
688
+ }
689
+ });
690
+ }
691
+ // 將當前 column 加入 title columns
692
+ titleColumnIndices.add(columnIndex);
693
+ // 合併所有 pinned columns 和 title columns
694
+ const allPinnedIndices = new Set([...pinnedColumnIndices, ...Array.from(titleColumnIndices)]);
695
+ const updatedPinnedIndices = Array.from(allPinnedIndices).sort((a, b) => a - b);
677
696
  const mixedWidths = convertToMixedWidthMode(currentWidths, updatedPinnedIndices, tableWidth);
678
697
  setColumnWidths(editor, element, mixedWidths);
679
698
  }
@@ -753,6 +772,10 @@ function useTableActions(element) {
753
772
  if (tableBodyElement) {
754
773
  processContainer(tableBodyElement);
755
774
  }
775
+ // 轉換回純百分比模式
776
+ const currentWidths = getColumnWidths(element);
777
+ const percentageWidths = convertToPercentageMode(currentWidths);
778
+ setColumnWidths(editor, element, percentageWidths);
756
779
  }
757
780
  catch (error) {
758
781
  console.warn('Failed to unpin column:', error);
@@ -864,6 +887,187 @@ function useTableActions(element) {
864
887
  console.warn('Failed to unpin row:', error);
865
888
  }
866
889
  }, [editor, element]);
890
+ /**
891
+ * 內部函數:移動或交換列的位置
892
+ * @param mode 'swap' 為交換相鄰位置(toolbar 按鈕),'move' 為移動到任意位置(拖曳)
893
+ */
894
+ const moveOrSwapRow = useCallback((sourceRowIndex, targetRowIndex, mode = 'move') => {
895
+ try {
896
+ const tableStructure = getTableStructure(editor, element);
897
+ if (!tableStructure)
898
+ return;
899
+ const { tableHeaderElement, tableBodyElement, tableHeaderPath, tableBodyPath, headerRowCount } = tableStructure;
900
+ // 確定當前列和目標列所屬的容器
901
+ const sourceInHeader = sourceRowIndex < headerRowCount;
902
+ const targetInHeader = targetRowIndex < headerRowCount;
903
+ // 標題列只能與標題列互換/移動,一般列只能與一般列互換/移動
904
+ if (sourceInHeader !== targetInHeader) {
905
+ console.warn(`Cannot ${mode} row between header and body`);
906
+ return;
907
+ }
908
+ // 檢查邊界
909
+ if (sourceRowIndex === targetRowIndex) {
910
+ return;
911
+ }
912
+ let containerPath;
913
+ let sourceLocalIndex;
914
+ let targetLocalIndex;
915
+ if (sourceInHeader) {
916
+ // 在 header 中
917
+ if (!tableHeaderElement || !tableHeaderPath)
918
+ return;
919
+ containerPath = tableHeaderPath;
920
+ sourceLocalIndex = sourceRowIndex;
921
+ targetLocalIndex = targetRowIndex;
922
+ }
923
+ else {
924
+ // 在 body 中
925
+ if (!tableBodyElement)
926
+ return;
927
+ containerPath = tableBodyPath;
928
+ sourceLocalIndex = sourceRowIndex - headerRowCount;
929
+ targetLocalIndex = targetRowIndex - headerRowCount;
930
+ }
931
+ Editor.withoutNormalizing(editor, () => {
932
+ if (mode === 'swap') {
933
+ // swap 邏輯:交換兩個相鄰位置
934
+ if (sourceRowIndex < targetRowIndex) {
935
+ // 向下移動:先將源列移到目標位置之後
936
+ const sourcePath = [...containerPath, sourceLocalIndex];
937
+ const afterTargetPath = [...containerPath, targetLocalIndex];
938
+ Transforms.moveNodes(editor, {
939
+ at: sourcePath,
940
+ to: afterTargetPath,
941
+ });
942
+ }
943
+ else {
944
+ // 向上移動:先將目標列移到源位置之後
945
+ const targetPath = [...containerPath, targetLocalIndex];
946
+ const afterSourcePath = [...containerPath, sourceLocalIndex];
947
+ Transforms.moveNodes(editor, {
948
+ at: targetPath,
949
+ to: afterSourcePath,
950
+ });
951
+ }
952
+ }
953
+ else {
954
+ // move 邏輯:直接移動到目標位置
955
+ const sourcePath = [...containerPath, sourceLocalIndex];
956
+ const targetPath = [...containerPath, targetLocalIndex];
957
+ Transforms.moveNodes(editor, {
958
+ at: sourcePath,
959
+ to: targetPath,
960
+ });
961
+ }
962
+ });
963
+ }
964
+ catch (error) {
965
+ console.warn(`Failed to ${mode} row:`, error);
966
+ }
967
+ }, [editor, element]);
968
+ /**
969
+ * 內部函數:移動或交換行的位置
970
+ * @param mode 'swap' 為交換相鄰位置(toolbar 按鈕),'move' 為移動到任意位置(拖曳)
971
+ */
972
+ const moveOrSwapColumn = useCallback((sourceColumnIndex, targetColumnIndex, mode = 'move') => {
973
+ try {
974
+ const tableStructure = getTableStructure(editor, element);
975
+ if (!tableStructure)
976
+ return;
977
+ const { tableHeaderElement, tableBodyElement, columnCount } = tableStructure;
978
+ // 檢查邊界
979
+ if (targetColumnIndex < 0 || targetColumnIndex >= columnCount) {
980
+ console.warn('Target column index out of bounds');
981
+ return;
982
+ }
983
+ // 檢查是否為同一行
984
+ if (sourceColumnIndex === targetColumnIndex) {
985
+ return;
986
+ }
987
+ // 檢查當前行和目標行是否都是標題行或都是一般行
988
+ // 透過檢查第一個 cell 的 treatAsTitle 屬性來判斷
989
+ const checkIsTitleColumn = (container, colIndex) => {
990
+ if (!Element.isElement(container))
991
+ return false;
992
+ for (const row of container.children) {
993
+ if (Element.isElement(row) && row.type.includes(TABLE_ROW_TYPE)) {
994
+ const cell = row.children[colIndex];
995
+ if (Element.isElement(cell) && cell.type.includes(TABLE_CELL_TYPE)) {
996
+ return !!cell.treatAsTitle;
997
+ }
998
+ }
999
+ }
1000
+ return false;
1001
+ };
1002
+ // 檢查兩個 container 中的第一列來確定是否為標題行
1003
+ let sourceIsTitle = false;
1004
+ let targetIsTitle = false;
1005
+ if (tableHeaderElement) {
1006
+ sourceIsTitle = sourceIsTitle || checkIsTitleColumn(tableHeaderElement, sourceColumnIndex);
1007
+ targetIsTitle = targetIsTitle || checkIsTitleColumn(tableHeaderElement, targetColumnIndex);
1008
+ }
1009
+ if (tableBodyElement) {
1010
+ sourceIsTitle = sourceIsTitle || checkIsTitleColumn(tableBodyElement, sourceColumnIndex);
1011
+ targetIsTitle = targetIsTitle || checkIsTitleColumn(tableBodyElement, targetColumnIndex);
1012
+ }
1013
+ // 標題行只能與標題行互換/移動,一般行只能與一般行互換/移動
1014
+ if (sourceIsTitle !== targetIsTitle) {
1015
+ console.warn(`Cannot ${mode} column between title and normal columns`);
1016
+ return;
1017
+ }
1018
+ // 根據模式選擇不同的 columnWidths 處理方式
1019
+ const currentWidths = getColumnWidths(element);
1020
+ const newWidths = moveOrSwapColumnWidth(currentWidths, sourceColumnIndex, targetColumnIndex, mode);
1021
+ setColumnWidths(editor, element, newWidths);
1022
+ // 對 header 和 body 中的所有列進行操作
1023
+ Editor.withoutNormalizing(editor, () => {
1024
+ const containers = [tableHeaderElement, tableBodyElement].filter((c) => c && Element.isElement(c));
1025
+ for (const container of containers) {
1026
+ // 對每一列進行操作
1027
+ for (let rowIndex = 0; rowIndex < container.children.length; rowIndex++) {
1028
+ const row = container.children[rowIndex];
1029
+ if (!Element.isElement(row) || !row.type.includes(TABLE_ROW_TYPE))
1030
+ continue;
1031
+ const containerPath = ReactEditor.findPath(editor, container);
1032
+ const rowPath = [...containerPath, rowIndex];
1033
+ if (mode === 'swap') {
1034
+ // swap 邏輯:交換兩個相鄰位置
1035
+ if (sourceColumnIndex < targetColumnIndex) {
1036
+ // 向右移動:將源 cell 移到目標位置之後
1037
+ const sourceCellPath = [...rowPath, sourceColumnIndex];
1038
+ const afterTargetCellPath = [...rowPath, targetColumnIndex];
1039
+ Transforms.moveNodes(editor, {
1040
+ at: sourceCellPath,
1041
+ to: afterTargetCellPath,
1042
+ });
1043
+ }
1044
+ else {
1045
+ // 向左移動:將目標 cell 移到源位置之後
1046
+ const targetCellPath = [...rowPath, targetColumnIndex];
1047
+ const afterSourceCellPath = [...rowPath, sourceColumnIndex];
1048
+ Transforms.moveNodes(editor, {
1049
+ at: targetCellPath,
1050
+ to: afterSourceCellPath,
1051
+ });
1052
+ }
1053
+ }
1054
+ else {
1055
+ // move 邏輯:直接移動到目標位置
1056
+ const sourceCellPath = [...rowPath, sourceColumnIndex];
1057
+ const targetCellPath = [...rowPath, targetColumnIndex];
1058
+ Transforms.moveNodes(editor, {
1059
+ at: sourceCellPath,
1060
+ to: targetCellPath,
1061
+ });
1062
+ }
1063
+ }
1064
+ }
1065
+ });
1066
+ }
1067
+ catch (error) {
1068
+ console.warn(`Failed to ${mode} column:`, error);
1069
+ }
1070
+ }, [editor, element]);
867
1071
  return {
868
1072
  addColumn,
869
1073
  addRow,
@@ -880,6 +1084,8 @@ function useTableActions(element) {
880
1084
  unpinRow,
881
1085
  isColumnPinned,
882
1086
  isRowPinned,
1087
+ moveOrSwapRow,
1088
+ moveOrSwapColumn,
883
1089
  };
884
1090
  }
885
1091