[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:
Aaryan Khandelwal 2025-07-02 15:27:48 +05:30 committed by GitHub
parent 0b159c4963
commit 1fcffad7dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 306 additions and 118 deletions

View file

@ -131,7 +131,7 @@ const SideMenu = (options: SideMenuPluginProps) => {
}
}
if (node.matches(".table-wrapper")) {
if (node.matches("table")) {
rect.top += 8;
rect.left -= 8;
}

View file

@ -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;
},
},
});

View file

@ -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;
};

View file

@ -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" }];
},

View file

@ -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;

View file

@ -1 +1,3 @@
export { Table } from "./table";
export const DEFAULT_COLUMN_WIDTH = 150;

View file

@ -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

View file

@ -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;

View file

@ -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);
}
};

View file

@ -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 = ({

View file

@ -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;
}

View file

@ -35,7 +35,7 @@
}
/* end ai handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image):not(.table-wrapper) {
position: relative;
cursor: grab;
outline: none !important;
@ -61,7 +61,8 @@
}
&.node-imageComponent,
&.node-image {
&.node-image,
&.table-wrapper {
--horizontal-offset: 0px;
&::after {

View file

@ -1,57 +1,84 @@
.table-wrapper {
overflow-x: auto;
width: fit-content;
max-width: 100%;
}
.table-wrapper table {
border-collapse: collapse;
table-layout: fixed;
margin: 0.5rem 0 1rem 0;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
}
table {
border-collapse: collapse;
table-layout: fixed;
margin: 0.5rem 0 1rem 0;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
.table-wrapper table td,
.table-wrapper table th {
min-width: 1em;
border: 1px solid rgba(var(--color-border-200));
padding: 7px 10px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
.table-wrapper table {
th {
font-weight: 500;
text-align: left;
}
tr[background="none"],
tr:not([background]) {
td,
th {
background-color: rgba(var(--color-background-90));
min-width: 1em;
border: 1px solid rgba(var(--color-border-300));
padding: 7px 10px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
&.selectedCell {
user-select: none;
&::after {
position: absolute;
content: "";
top: -1px;
left: -1px;
height: calc(100% + 2px);
width: calc(100% + 2px);
}
&.selectedCell-border-top::after {
border-top: 2px solid rgba(var(--color-primary-100));
}
&.selectedCell-border-left::after {
border-left: 2px solid rgba(var(--color-primary-100));
}
&.selectedCell-border-bottom::after {
border-bottom: 2px solid rgba(var(--color-primary-100));
}
&.selectedCell-border-right::after {
border-right: 2px solid rgba(var(--color-primary-100));
}
}
}
th {
font-weight: 500;
text-align: left;
}
tr[background="none"],
tr:not([background]) {
th {
background-color: rgba(var(--color-background-90));
}
}
}
}
.table-wrapper table .selectedCell {
outline: 0.5px solid rgba(var(--color-primary-100));
&.ProseMirror-selectednode {
table {
background-color: rgba(var(--color-primary-100), 0.2);
}
}
}
/* table dropdown */
.table-wrapper table .column-resize-handle {
position: absolute;
right: 0;
top: 0;
right: -1px;
top: -1px;
width: 2px;
height: 100%;
height: calc(100% + 2px);
z-index: 5;
background-color: rgba(var(--color-primary-100));
pointer-events: none;
@ -59,57 +86,57 @@
.table-wrapper .table-controls {
position: absolute;
}
.table-wrapper .table-controls .columns-control,
.table-wrapper .table-controls .rows-control {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
}
.columns-control,
.rows-control {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
}
.table-wrapper .table-controls .columns-control {
height: 20px;
transform: translateY(-50%);
}
.columns-control {
height: 20px;
transform: translateY(-50%);
.table-wrapper .table-controls .columns-control .columns-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
.columns-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
}
.table-wrapper .table-controls .rows-control {
width: 20px;
transform: translateX(-50%);
left: -8px;
}
.rows-control {
width: 20px;
transform: translateX(-50%);
left: -8px;
.table-wrapper .table-controls .rows-control .rows-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.rows-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
}
.table-wrapper .table-controls .rows-control-div,
.table-wrapper .table-controls .columns-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
cursor: pointer;
.columns-control-div,
.rows-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
cursor: pointer;
}
}
.resize-cursor .table-wrapper .table-controls .rows-control,

View file

@ -179,9 +179,18 @@
}
.ProseMirror {
max-width: var(--editor-content-width);
margin: 0 auto;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
& > *:not(.editor-full-width-block) {
max-width: var(--editor-content-width);
margin-left: auto !important;
margin-right: auto !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
& > .editor-full-width-block {
max-width: 100%;
padding-inline-start: calc((100% - var(--editor-content-width)) / 2);
padding-inline-end: var(--wide-content-margin);
}
}
}