[WIKI-491] [WIKI-496] [WIKI-499] refactor: tables width and selection UI (#7274)
* refactor: tables width and selection UI * fix: drag handle position * refactor: selection decorator logic * refactor: adjacent cells logic * refactor: folder structure * chore: default column width for new columns * refactor: plugin location
This commit is contained in:
parent
0b159c4963
commit
1fcffad7dd
14 changed files with 306 additions and 118 deletions
|
|
@ -131,7 +131,7 @@ const SideMenu = (options: SideMenuPluginProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (node.matches(".table-wrapper")) {
|
||||
if (node.matches("table")) {
|
||||
rect.top += 8;
|
||||
rect.left -= 8;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { findParentNode, type Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
// local imports
|
||||
import { getCellBorderClasses } from "./utils";
|
||||
|
||||
type TableCellSelectionOutlinePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
};
|
||||
|
||||
const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey("table-cell-selection-outline");
|
||||
|
||||
export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>
|
||||
new Plugin<TableCellSelectionOutlinePluginState>({
|
||||
key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY,
|
||||
state: {
|
||||
init: () => ({}),
|
||||
apply(tr, prev, oldState, newState) {
|
||||
if (!editor.isEditable) return {};
|
||||
const table = findParentNode((node) => node.type.spec.tableRole === "table")(newState.selection);
|
||||
const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection);
|
||||
if (!table || !hasDocChanged) {
|
||||
return table === undefined ? {} : prev;
|
||||
}
|
||||
|
||||
const { selection } = newState;
|
||||
if (!(selection instanceof CellSelection)) return {};
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
const tableMap = TableMap.get(table.node);
|
||||
const selectedCells: number[] = [];
|
||||
|
||||
// First, collect all selected cell positions
|
||||
selection.forEachCell((_node, pos) => {
|
||||
const start = pos - table.pos - 1;
|
||||
selectedCells.push(start);
|
||||
});
|
||||
|
||||
// Then, add decorations with appropriate border classes
|
||||
selection.forEachCell((node, pos) => {
|
||||
const start = pos - table.pos - 1;
|
||||
const classes = getCellBorderClasses(start, selectedCells, tableMap);
|
||||
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(" ") }));
|
||||
});
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { TableMap } from "@tiptap/pm/tables";
|
||||
|
||||
/**
|
||||
* Calculates the positions of cells adjacent to a given cell in a table
|
||||
* @param cellStart - The start position of the current cell in the document
|
||||
* @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions
|
||||
* @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge)
|
||||
*/
|
||||
const getAdjacentCellPositions = (
|
||||
cellStart: number,
|
||||
tableMap: TableMap
|
||||
): { top?: number; bottom?: number; left?: number; right?: number } => {
|
||||
// Extract table dimensions
|
||||
// width -> number of columns in the table
|
||||
// height -> number of rows in the table
|
||||
const { width, height } = tableMap;
|
||||
|
||||
// Find the index of our cell in the flat tableMap.map array
|
||||
// tableMap.map contains start positions of all cells in row-by-row order
|
||||
const cellIndex = tableMap.map.indexOf(cellStart);
|
||||
|
||||
// Safety check: if cell position not found in table map, return empty object
|
||||
if (cellIndex === -1) return {};
|
||||
|
||||
// Convert flat array index to 2D grid coordinates
|
||||
// row = which row the cell is in (0-based from top)
|
||||
// col = which column the cell is in (0-based from left)
|
||||
const row = Math.floor(cellIndex / width); // Integer division gives row number
|
||||
const col = cellIndex % width; // Remainder gives column number
|
||||
|
||||
return {
|
||||
// Top cell: same column, one row up
|
||||
// Check if we're not in the first row (row > 0) before calculating
|
||||
top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined,
|
||||
|
||||
// Bottom cell: same column, one row down
|
||||
// Check if we're not in the last row (row < height - 1) before calculating
|
||||
bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined,
|
||||
|
||||
// Left cell: same row, one column left
|
||||
// Check if we're not in the first column (col > 0) before calculating
|
||||
left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined,
|
||||
|
||||
// Right cell: same row, one column right
|
||||
// Check if we're not in the last column (col < width - 1) before calculating
|
||||
right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => {
|
||||
const adjacent = getAdjacentCellPositions(cellStart, tableMap);
|
||||
const classes: string[] = [];
|
||||
|
||||
// Add border-right if right cell is not selected or doesn't exist
|
||||
if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) {
|
||||
classes.push("selectedCell-border-right");
|
||||
}
|
||||
|
||||
// Add border-left if left cell is not selected or doesn't exist
|
||||
if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) {
|
||||
classes.push("selectedCell-border-left");
|
||||
}
|
||||
|
||||
// Add border-top if top cell is not selected or doesn't exist
|
||||
if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) {
|
||||
classes.push("selectedCell-border-top");
|
||||
}
|
||||
|
||||
// Add border-bottom if bottom cell is not selected or doesn't exist
|
||||
if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) {
|
||||
classes.push("selectedCell-border-bottom");
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { TableCellSelectionOutlinePlugin } from "./plugins/table-selection-outline/plugin";
|
||||
import { DEFAULT_COLUMN_WIDTH } from "./table";
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
|
@ -25,7 +29,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
default: [DEFAULT_COLUMN_WIDTH],
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
|
@ -46,6 +50,10 @@ export const TableCell = Node.create<TableCellOptions>({
|
|||
|
||||
isolating: true,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [TableCellSelectionOutlinePlugin(this.editor)];
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "td" }];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { DEFAULT_COLUMN_WIDTH } from "./table";
|
||||
|
||||
export interface TableHeaderOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
|
@ -25,7 +28,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
|||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
default: [DEFAULT_COLUMN_WIDTH],
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export { Table } from "./table";
|
||||
|
||||
export const DEFAULT_COLUMN_WIDTH = 150;
|
||||
|
|
|
|||
|
|
@ -387,7 +387,7 @@ export class TableView implements NodeView {
|
|||
this.root = h(
|
||||
"div",
|
||||
{
|
||||
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
|
||||
className: "table-wrapper editor-full-width-block horizontal-scrollbar scrollbar-sm controls--disabled",
|
||||
},
|
||||
this.controls,
|
||||
this.table
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { createTable } from "./utilities/create-table";
|
|||
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected";
|
||||
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||
import { DEFAULT_COLUMN_WIDTH } from ".";
|
||||
|
||||
export interface TableOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
|
|
@ -42,12 +43,7 @@ export interface TableOptions {
|
|||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.TABLE]: {
|
||||
insertTable: (options?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
withHeaderRow?: boolean;
|
||||
columnWidth?: number;
|
||||
}) => ReturnType;
|
||||
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType;
|
||||
addColumnBefore: () => ReturnType;
|
||||
addColumnAfter: () => ReturnType;
|
||||
deleteColumn: () => ReturnType;
|
||||
|
|
@ -81,7 +77,7 @@ declare module "@tiptap/core" {
|
|||
}
|
||||
}
|
||||
|
||||
export const Table = Node.create({
|
||||
export const Table = Node.create<TableOptions>({
|
||||
name: CORE_EXTENSIONS.TABLE,
|
||||
|
||||
addOptions() {
|
||||
|
|
@ -116,9 +112,15 @@ export const Table = Node.create({
|
|||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) =>
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);
|
||||
const node = createTable({
|
||||
schema: editor.schema,
|
||||
rowsCount: rows,
|
||||
colsCount: cols,
|
||||
withHeaderRow,
|
||||
columnWidth: DEFAULT_COLUMN_WIDTH,
|
||||
});
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
|
|||
import { createCell } from "@/extensions/table/table/utilities/create-cell";
|
||||
import { getTableNodeTypes } from "@/extensions/table/table/utilities/get-table-node-types";
|
||||
|
||||
export function createTable(
|
||||
schema: Schema,
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
columnWidth: number = 100
|
||||
): ProsemirrorNode {
|
||||
type Props = {
|
||||
schema: Schema;
|
||||
rowsCount: number;
|
||||
colsCount: number;
|
||||
withHeaderRow: boolean;
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>;
|
||||
columnWidth: number;
|
||||
};
|
||||
|
||||
export const createTable = (props: Props): ProsemirrorNode => {
|
||||
const { schema, rowsCount, colsCount, withHeaderRow, cellContent, columnWidth } = props;
|
||||
|
||||
const types = getTableNodeTypes(schema);
|
||||
const headerCells: ProsemirrorNode[] = [];
|
||||
const cells: ProsemirrorNode[] = [];
|
||||
|
|
@ -38,4 +42,4 @@ export function createTable(
|
|||
}
|
||||
|
||||
return types.table.createChecked(null, rows);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,9 +109,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (range)
|
||||
editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const insertImage = ({
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const generalSelectors = [
|
|||
"blockquote",
|
||||
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
|
||||
"[data-type=horizontalRule]",
|
||||
".table-wrapper",
|
||||
"table",
|
||||
".issue-embed",
|
||||
".image-component",
|
||||
".image-upload-component",
|
||||
|
|
@ -90,7 +90,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
|||
|
||||
for (const elem of elements) {
|
||||
// Check for table wrapper first
|
||||
if (elem.matches(".table-wrapper")) {
|
||||
if (elem.matches("table")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
|||
}
|
||||
|
||||
// Skip table cells
|
||||
if (elem.closest(".table-wrapper")) {
|
||||
if (elem.closest("table")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue