Skip to content

Commit 9ecea15

Browse files
[WIKI-498] [WIKI-567] feat: ability to rearrange columns and rows in table (#7624)
* feat: ability to rearrange columns and rows * chore: update delete icon * refactor: table utilities and plugins * chore: handle edge cases * chore: safe pseudo element inserts --------- Co-authored-by: Sriram Veeraghanta <veeraghanta.sriram@gmail.com>
1 parent 4ad88c9 commit 9ecea15

File tree

22 files changed

+1858
-150
lines changed

22 files changed

+1858
-150
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export enum CORE_EDITOR_META {
22
SKIP_FILE_DELETION = "skipFileDeletion",
3+
INTENTIONAL_DELETION = "intentionalDeletion",
4+
ADD_TO_HISTORY = "addToHistory",
35
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { Editor } from "@tiptap/core";
2+
import { Fragment, type Node, type Node as ProseMirrorNode } from "@tiptap/pm/model";
3+
import type { Transaction } from "@tiptap/pm/state";
4+
import { type CellSelection, TableMap } from "@tiptap/pm/tables";
5+
// extensions
6+
import { TableNodeLocation } from "@/extensions/table/table/utilities/helpers";
7+
8+
type TableRow = (ProseMirrorNode | null)[];
9+
type TableRows = TableRow[];
10+
11+
/**
12+
* Move the selected columns to the specified index.
13+
* @param {Editor} editor - The editor instance.
14+
* @param {TableNodeLocation} table - The table node location.
15+
* @param {CellSelection} selection - The cell selection.
16+
* @param {number} to - The index to move the columns to.
17+
* @param {Transaction} tr - The transaction.
18+
* @returns {Transaction} The updated transaction.
19+
*/
20+
export const moveSelectedColumns = (
21+
editor: Editor,
22+
table: TableNodeLocation,
23+
selection: CellSelection,
24+
to: number,
25+
tr: Transaction
26+
): Transaction => {
27+
const tableMap = TableMap.get(table.node);
28+
29+
let columnStart = -1;
30+
let columnEnd = -1;
31+
32+
selection.forEachCell((_node, pos) => {
33+
const cell = tableMap.findCell(pos - table.pos - 1);
34+
for (let i = cell.left; i < cell.right; i++) {
35+
columnStart = columnStart >= 0 ? Math.min(cell.left, columnStart) : cell.left;
36+
columnEnd = columnEnd >= 0 ? Math.max(cell.right, columnEnd) : cell.right;
37+
}
38+
});
39+
40+
if (columnStart === -1 || columnEnd === -1) {
41+
console.warn("Invalid column selection");
42+
return tr;
43+
}
44+
45+
if (to < 0 || to > tableMap.width || (to >= columnStart && to < columnEnd)) return tr;
46+
47+
const rows = tableToCells(table);
48+
for (const row of rows) {
49+
const range = row.splice(columnStart, columnEnd - columnStart);
50+
const offset = to > columnStart ? to - (columnEnd - columnStart - 1) : to;
51+
row.splice(offset, 0, ...range);
52+
}
53+
54+
tableFromCells(editor, table, rows, tr);
55+
return tr;
56+
};
57+
58+
/**
59+
* Move the selected rows to the specified index.
60+
* @param {Editor} editor - The editor instance.
61+
* @param {TableNodeLocation} table - The table node location.
62+
* @param {CellSelection} selection - The cell selection.
63+
* @param {number} to - The index to move the rows to.
64+
* @param {Transaction} tr - The transaction.
65+
* @returns {Transaction} The updated transaction.
66+
*/
67+
export const moveSelectedRows = (
68+
editor: Editor,
69+
table: TableNodeLocation,
70+
selection: CellSelection,
71+
to: number,
72+
tr: Transaction
73+
): Transaction => {
74+
const tableMap = TableMap.get(table.node);
75+
76+
let rowStart = -1;
77+
let rowEnd = -1;
78+
79+
selection.forEachCell((_node, pos) => {
80+
const cell = tableMap.findCell(pos - table.pos - 1);
81+
for (let i = cell.top; i < cell.bottom; i++) {
82+
rowStart = rowStart >= 0 ? Math.min(cell.top, rowStart) : cell.top;
83+
rowEnd = rowEnd >= 0 ? Math.max(cell.bottom, rowEnd) : cell.bottom;
84+
}
85+
});
86+
87+
if (rowStart === -1 || rowEnd === -1) {
88+
console.warn("Invalid row selection");
89+
return tr;
90+
}
91+
92+
if (to < 0 || to > tableMap.height || (to >= rowStart && to < rowEnd)) return tr;
93+
94+
const rows = tableToCells(table);
95+
const range = rows.splice(rowStart, rowEnd - rowStart);
96+
const offset = to > rowStart ? to - (rowEnd - rowStart - 1) : to;
97+
rows.splice(offset, 0, ...range);
98+
99+
tableFromCells(editor, table, rows, tr);
100+
return tr;
101+
};
102+
103+
/**
104+
* @description Duplicate the selected rows.
105+
* @param {TableNodeLocation} table - The table node location.
106+
* @param {number[]} rowIndices - The indices of the rows to duplicate.
107+
* @param {Transaction} tr - The transaction.
108+
* @returns {Transaction} The updated transaction.
109+
*/
110+
export const duplicateRows = (table: TableNodeLocation, rowIndices: number[], tr: Transaction): Transaction => {
111+
const rows = tableToCells(table);
112+
113+
const { map, width } = TableMap.get(table.node);
114+
115+
// Validate row indices
116+
const maxRow = rows.length - 1;
117+
if (rowIndices.some((idx) => idx < 0 || idx > maxRow)) {
118+
console.warn("Invalid row indices for duplication");
119+
return tr;
120+
}
121+
122+
const mapStart = tr.mapping.maps.length;
123+
124+
const lastRowPos = map[rowIndices[rowIndices.length - 1] * width + width - 1];
125+
const nextRowStart = lastRowPos + (table.node.nodeAt(lastRowPos)?.nodeSize ?? 0) + 1;
126+
const insertPos = tr.mapping.slice(mapStart).map(table.start + nextRowStart);
127+
128+
for (let i = rowIndices.length - 1; i >= 0; i--) {
129+
tr.insert(
130+
insertPos,
131+
rows[rowIndices[i]].filter((r) => r !== null)
132+
);
133+
}
134+
135+
return tr;
136+
};
137+
138+
/**
139+
* @description Duplicate the selected columns.
140+
* @param {TableNodeLocation} table - The table node location.
141+
* @param {number[]} columnIndices - The indices of the columns to duplicate.
142+
* @param {Transaction} tr - The transaction.
143+
* @returns {Transaction} The updated transaction.
144+
*/
145+
export const duplicateColumns = (table: TableNodeLocation, columnIndices: number[], tr: Transaction): Transaction => {
146+
const rows = tableToCells(table);
147+
148+
const { map, width, height } = TableMap.get(table.node);
149+
150+
// Validate column indices
151+
if (columnIndices.some((idx) => idx < 0 || idx >= width)) {
152+
console.warn("Invalid column indices for duplication");
153+
return tr;
154+
}
155+
156+
const mapStart = tr.mapping.maps.length;
157+
158+
for (let row = 0; row < height; row++) {
159+
const lastColumnPos = map[row * width + columnIndices[columnIndices.length - 1]];
160+
const nextColumnStart = lastColumnPos + (table.node.nodeAt(lastColumnPos)?.nodeSize ?? 0);
161+
const insertPos = tr.mapping.slice(mapStart).map(table.start + nextColumnStart);
162+
163+
for (let i = columnIndices.length - 1; i >= 0; i--) {
164+
const copiedNode = rows[row][columnIndices[i]];
165+
if (copiedNode !== null) {
166+
tr.insert(insertPos, copiedNode);
167+
}
168+
}
169+
}
170+
171+
return tr;
172+
};
173+
174+
/**
175+
* @description Convert the table to cells.
176+
* @param {TableNodeLocation} table - The table node location.
177+
* @returns {TableRows} The table rows.
178+
*/
179+
const tableToCells = (table: TableNodeLocation): TableRows => {
180+
const { map, width, height } = TableMap.get(table.node);
181+
182+
const visitedCells = new Set<number>();
183+
const rows: TableRows = [];
184+
for (let row = 0; row < height; row++) {
185+
const cells: (ProseMirrorNode | null)[] = [];
186+
for (let col = 0; col < width; col++) {
187+
const pos = map[row * width + col];
188+
cells.push(!visitedCells.has(pos) ? table.node.nodeAt(pos) : null);
189+
visitedCells.add(pos);
190+
}
191+
rows.push(cells);
192+
}
193+
194+
return rows;
195+
};
196+
197+
/**
198+
* @description Convert the cells to a table.
199+
* @param {Editor} editor - The editor instance.
200+
* @param {TableNodeLocation} table - The table node location.
201+
* @param {TableRows} rows - The table rows.
202+
* @param {Transaction} tr - The transaction.
203+
*/
204+
const tableFromCells = (editor: Editor, table: TableNodeLocation, rows: TableRows, tr: Transaction): void => {
205+
const schema = editor.schema.nodes;
206+
const newRowNodes = rows.map((row) =>
207+
schema.tableRow.create(null, row.filter((cell) => cell !== null) as readonly Node[])
208+
);
209+
const newTableNode = table.node.copy(Fragment.from(newRowNodes));
210+
tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTableNode);
211+
};
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Disclosure } from "@headlessui/react";
2+
import type { Editor } from "@tiptap/core";
3+
import { Ban, ChevronRight, Palette } from "lucide-react";
4+
// plane imports
5+
import { cn } from "@plane/utils";
6+
// constants
7+
import { COLORS_LIST } from "@/constants/common";
8+
import { CORE_EXTENSIONS } from "@/constants/extension";
9+
10+
// TODO: implement text color selector
11+
12+
type Props = {
13+
editor: Editor;
14+
onSelect: (color: string | null) => void;
15+
};
16+
17+
const handleBackgroundColorChange = (editor: Editor, color: string | null) => {
18+
editor
19+
.chain()
20+
.focus()
21+
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
22+
background: color,
23+
})
24+
.run();
25+
};
26+
27+
// const handleTextColorChange = (editor: Editor, color: string | null) => {
28+
// editor
29+
// .chain()
30+
// .focus()
31+
// .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
32+
// textColor: color,
33+
// })
34+
// .run();
35+
// };
36+
37+
export const TableDragHandleDropdownColorSelector: React.FC<Props> = (props) => {
38+
const { editor, onSelect } = props;
39+
40+
return (
41+
<Disclosure defaultOpen>
42+
<Disclosure.Button
43+
as="button"
44+
type="button"
45+
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
46+
>
47+
{({ open }) => (
48+
<>
49+
<span className="flex items-center gap-2">
50+
<Palette className="shrink-0 size-3" />
51+
Color
52+
</span>
53+
<ChevronRight
54+
className={cn("shrink-0 size-3 transition-transform duration-200", {
55+
"rotate-90": open,
56+
})}
57+
/>
58+
</>
59+
)}
60+
</Disclosure.Button>
61+
<Disclosure.Panel className="p-1 space-y-2 mb-1.5">
62+
{/* <div className="space-y-1.5">
63+
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
64+
<div className="flex items-center flex-wrap gap-2">
65+
{COLORS_LIST.map((color) => (
66+
<button
67+
key={color.key}
68+
type="button"
69+
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
70+
style={{
71+
backgroundColor: color.textColor,
72+
}}
73+
onClick={() => handleTextColorChange(editor, color.textColor)}
74+
/>
75+
))}
76+
<button
77+
type="button"
78+
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
79+
onClick={() => handleTextColorChange(editor, null)}
80+
>
81+
<Ban className="size-4" />
82+
</button>
83+
</div>
84+
</div> */}
85+
<div className="space-y-1">
86+
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
87+
<div className="flex items-center flex-wrap gap-2">
88+
{COLORS_LIST.map((color) => (
89+
<button
90+
key={color.key}
91+
type="button"
92+
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
93+
style={{
94+
backgroundColor: color.backgroundColor,
95+
}}
96+
onClick={() => {
97+
handleBackgroundColorChange(editor, color.backgroundColor);
98+
onSelect(color.backgroundColor);
99+
}}
100+
/>
101+
))}
102+
<button
103+
type="button"
104+
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
105+
onClick={() => {
106+
handleBackgroundColorChange(editor, null);
107+
onSelect(null);
108+
}}
109+
>
110+
<Ban className="size-4" />
111+
</button>
112+
</div>
113+
</div>
114+
</Disclosure.Panel>
115+
</Disclosure>
116+
);
117+
};

0 commit comments

Comments
 (0)