diff --git a/packages/editor/src/core/extensions/side-menu.ts b/packages/editor/src/core/extensions/side-menu.ts index 34e3c45e5f2..ca139f72311 100644 --- a/packages/editor/src/core/extensions/side-menu.ts +++ b/packages/editor/src/core/extensions/side-menu.ts @@ -131,7 +131,7 @@ const SideMenu = (options: SideMenuPluginProps) => { } } - if (node.matches(".table-wrapper")) { + if (node.matches("table")) { rect.top += 8; rect.left -= 8; } diff --git a/packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts b/packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts new file mode 100644 index 00000000000..0e88d8c7797 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/table-selection-outline/plugin.ts @@ -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 => + new Plugin({ + 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; + }, + }, + }); diff --git a/packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts b/packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts new file mode 100644 index 00000000000..f4c43e77ee6 --- /dev/null +++ b/packages/editor/src/core/extensions/table/plugins/table-selection-outline/utils.ts @@ -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; +}; diff --git a/packages/editor/src/core/extensions/table/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell.ts index 2ba06845a6c..988fcbb14d2 100644 --- a/packages/editor/src/core/extensions/table/table-cell.ts +++ b/packages/editor/src/core/extensions/table/table-cell.ts @@ -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; } @@ -25,7 +29,7 @@ export const TableCell = Node.create({ 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({ isolating: true, + addProseMirrorPlugins() { + return [TableCellSelectionOutlinePlugin(this.editor)]; + }, + parseHTML() { return [{ tag: "td" }]; }, diff --git a/packages/editor/src/core/extensions/table/table-header.ts b/packages/editor/src/core/extensions/table/table-header.ts index 491889eefae..315ada5ec90 100644 --- a/packages/editor/src/core/extensions/table/table-header.ts +++ b/packages/editor/src/core/extensions/table/table-header.ts @@ -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; } @@ -25,7 +28,7 @@ export const TableHeader = Node.create({ default: 1, }, colwidth: { - default: null, + default: [DEFAULT_COLUMN_WIDTH], parseHTML: (element) => { const colwidth = element.getAttribute("colwidth"); const value = colwidth ? [parseInt(colwidth, 10)] : null; diff --git a/packages/editor/src/core/extensions/table/table/index.ts b/packages/editor/src/core/extensions/table/table/index.ts index 8efc4312099..328422b73d4 100644 --- a/packages/editor/src/core/extensions/table/table/index.ts +++ b/packages/editor/src/core/extensions/table/table/index.ts @@ -1 +1,3 @@ export { Table } from "./table"; + +export const DEFAULT_COLUMN_WIDTH = 150; diff --git a/packages/editor/src/core/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx index f78d964ed49..c3466ba59a1 100644 --- a/packages/editor/src/core/extensions/table/table/table-view.tsx +++ b/packages/editor/src/core/extensions/table/table/table-view.tsx @@ -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 diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index 4810706b395..aa2c79aaf3d 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -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; @@ -42,12 +43,7 @@ export interface TableOptions { declare module "@tiptap/core" { interface Commands { [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({ 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; diff --git a/packages/editor/src/core/extensions/table/table/utilities/create-table.ts b/packages/editor/src/core/extensions/table/table/utilities/create-table.ts index 5e9c4ba2451..0ba2757db7b 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/create-table.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/create-table.ts @@ -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, - columnWidth: number = 100 -): ProsemirrorNode { +type Props = { + schema: Schema; + rowsCount: number; + colsCount: number; + withHeaderRow: boolean; + cellContent?: Fragment | ProsemirrorNode | Array; + 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); -} +}; diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 415a42bb3b4..43543d57539 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -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 = ({ diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index 4a534bc4cd1..ae3bced4c3f 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -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; } diff --git a/packages/editor/src/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css index 7db6ed87554..7b9577cc40e 100644 --- a/packages/editor/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -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 { diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index b4a1ab82071..8c0b1786af7 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -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; - } -} + td, + th { + 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; -.table-wrapper table { - th { - font-weight: 500; - text-align: left; - } + > * { + 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)); + } + } + } - tr[background="none"], - tr:not([background]) { th { - background-color: rgba(var(--color-background-90)); + 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, diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css index 6d6e2d9b642..8d4e41a3c49 100644 --- a/packages/editor/src/styles/variables.css +++ b/packages/editor/src/styles/variables.css @@ -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); + } } }