mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
import type { EditorState } from "prosemirror-state";
|
|
import type { TableRect } from "prosemirror-tables";
|
|
import { CellSelection, isInTable, selectedRect } from "prosemirror-tables";
|
|
import { ColumnSelection } from "../selection/ColumnSelection";
|
|
import { RowSelection } from "../selection/RowSelection";
|
|
import type { EditorView } from "prosemirror-view";
|
|
|
|
/**
|
|
* Checks if the current selection is a column selection.
|
|
* @param state The editor state.
|
|
* @returns True if the selection is a column selection, false otherwise.
|
|
*/
|
|
export function isColSelection(state: EditorState): boolean {
|
|
if (state.selection instanceof ColumnSelection) {
|
|
return state.selection.isColSelection();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if the current selection is a row selection.
|
|
* @param state The editor state.
|
|
* @returns True if the selection is a row selection, false otherwise.
|
|
*/
|
|
export function isRowSelection(state: EditorState): boolean {
|
|
if (state.selection instanceof RowSelection) {
|
|
return state.selection.isRowSelection();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function getColumnIndex(state: EditorState): number | undefined {
|
|
if (state.selection instanceof ColumnSelection) {
|
|
if (state.selection.isColSelection()) {
|
|
const rect = selectedRect(state);
|
|
return rect.left;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
export function getRowIndex(state: EditorState): number | undefined {
|
|
if (state.selection instanceof RowSelection) {
|
|
if (state.selection.isRowSelection()) {
|
|
const rect = selectedRect(state);
|
|
return rect.top;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Get the actual row index in the table map for a given visual row index
|
|
* when merged cells are present.
|
|
*
|
|
* @param visualRowIndex The visual row index (0-based)
|
|
* @param state The editor state
|
|
* @returns The actual row index in the table map, or -1 if not found
|
|
*/
|
|
export function getRowIndexInMap(
|
|
visualRowIndex: number,
|
|
state: EditorState
|
|
): number {
|
|
if (!isInTable(state)) {
|
|
return -1;
|
|
}
|
|
const rect = selectedRect(state);
|
|
const cells = getCellsInColumn(0)(state);
|
|
|
|
if (visualRowIndex >= 0 && visualRowIndex < cells.length) {
|
|
const cellPos = cells[visualRowIndex] - rect.tableStart;
|
|
|
|
// Find the row index in the table map for this cell position
|
|
for (let row = 0; row < rect.map.height; row++) {
|
|
const rowStart = row * rect.map.width;
|
|
for (let col = 0; col < rect.map.width; col++) {
|
|
if (rect.map.map[rowStart + col] === cellPos) {
|
|
return row;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Get the actual row positions in the table map, accounting for merged cells.
|
|
*
|
|
* Iterates through each visual row and returns the position of the first unique cell in that row.
|
|
* This ensures correct row identification even when cells span multiple rows (rowspan > 1).
|
|
*
|
|
* @param state The editor state
|
|
* @returns Array of cell positions representing the first unique cell in each row
|
|
*/
|
|
export function getRowsInTable(state: EditorState): number[] {
|
|
if (!isInTable(state)) {
|
|
return [];
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const rows: number[] = [];
|
|
const seenCells = new Set<number>();
|
|
|
|
for (let row = 0; row < rect.map.height; row++) {
|
|
// Find the leftmost cell in this row
|
|
for (let col = 0; col < rect.map.width; col++) {
|
|
const cellPos =
|
|
rect.tableStart + rect.map.map[row * rect.map.width + col];
|
|
if (!seenCells.has(cellPos)) {
|
|
rows.push(cellPos);
|
|
seenCells.add(cellPos);
|
|
break; // Only add the first unique cell per row
|
|
}
|
|
}
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Get the actual column positions in the table map, accounting for merged cells.
|
|
*
|
|
* @param state The editor state
|
|
* @returns Array of cell positions representing the first unique cell in each column
|
|
*/
|
|
export function getColumnsInTable(state: EditorState): number[] {
|
|
if (!isInTable(state)) {
|
|
return [];
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const columns: number[] = [];
|
|
const seenCells = new Set<number>();
|
|
|
|
for (let col = 0; col < rect.map.width; col++) {
|
|
// Find the topmost cell in this column
|
|
for (let row = 0; row < rect.map.height; row++) {
|
|
const cellPos =
|
|
rect.tableStart + rect.map.map[row * rect.map.width + col];
|
|
if (!seenCells.has(cellPos)) {
|
|
columns.push(cellPos);
|
|
seenCells.add(cellPos);
|
|
break; // Only add the first unique cell per column
|
|
}
|
|
}
|
|
}
|
|
|
|
return columns;
|
|
}
|
|
|
|
export function getCellsInColumn(index: number) {
|
|
return (state: EditorState): number[] => {
|
|
if (!isInTable(state)) {
|
|
return [];
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const cells = [];
|
|
|
|
let previous;
|
|
for (let i = index; i < rect.map.map.length; i += rect.map.width) {
|
|
const cell = rect.tableStart + rect.map.map[i];
|
|
|
|
// Ensure we don't add the same cell multiple times, this can happen
|
|
// if the column is selected and the table row has merged cells.
|
|
if (previous === cell) {
|
|
continue;
|
|
}
|
|
previous = cell;
|
|
|
|
cells.push(cell);
|
|
}
|
|
return cells;
|
|
};
|
|
}
|
|
|
|
export function getCellsInRow(index: number) {
|
|
return (state: EditorState): number[] => {
|
|
if (!isInTable(state)) {
|
|
return [];
|
|
}
|
|
|
|
const rect = selectedRect(state);
|
|
const cells = [];
|
|
|
|
let previous;
|
|
for (let i = 0; i < rect.map.width; i += 1) {
|
|
const cell = rect.tableStart + rect.map.map[index * rect.map.width + i];
|
|
cells.push(cell);
|
|
|
|
// Ensure we don't add the same cell multiple times, this can happen
|
|
// if the row is selected and the table column has merged cells.
|
|
if (previous === cell) {
|
|
continue;
|
|
}
|
|
previous = cell;
|
|
}
|
|
|
|
return cells;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a specific column is selected in the editor.
|
|
*
|
|
* @param state The editor state
|
|
* @param index The index of the column to check
|
|
* @returns Boolean indicating if the column is selected
|
|
*/
|
|
export function isColumnSelected(index: number) {
|
|
return (state: EditorState): boolean => {
|
|
if (isColSelection(state)) {
|
|
const rect = selectedRect(state);
|
|
return rect.left <= index && rect.right > index;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if the header is enabled for the given type and table rect
|
|
*
|
|
* @param state The editor state
|
|
* @param type The type of header to check
|
|
* @param rect The table rect
|
|
* @returns Boolean indicating if the header is enabled
|
|
*/
|
|
export function isHeaderEnabled(
|
|
state: EditorState,
|
|
type: "row" | "column",
|
|
rect: TableRect
|
|
): boolean {
|
|
// Get cell positions for first row or first column
|
|
const cellPositions = rect.map.cellsInRect({
|
|
left: 0,
|
|
top: 0,
|
|
right: type === "row" ? rect.map.width : 1,
|
|
bottom: type === "column" ? rect.map.height : 1,
|
|
});
|
|
|
|
for (let i = 0; i < cellPositions.length; i++) {
|
|
const cell = rect.table.nodeAt(cellPositions[i]);
|
|
if (cell && cell.type !== state.schema.nodes.th) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if a specific row is selected in the editor.
|
|
*
|
|
* @param state The editor state
|
|
* @param index The index of the row to check
|
|
* @returns Boolean indicating if the row is selected
|
|
*/
|
|
export function isRowSelected(index: number) {
|
|
return (state: EditorState): boolean =>
|
|
state.selection instanceof RowSelection && state.selection.isRowSelection()
|
|
? state.selection.$index === index
|
|
: false;
|
|
}
|
|
|
|
/**
|
|
* Check if an entire table is selected in the editor.
|
|
*
|
|
* @param state The editor state
|
|
* @returns Boolean indicating if the table is selected
|
|
*/
|
|
export function isTableSelected(state: EditorState): boolean {
|
|
if (state.selection instanceof CellSelection) {
|
|
const rect = selectedRect(state);
|
|
|
|
return (
|
|
rect.top === 0 &&
|
|
rect.left === 0 &&
|
|
rect.bottom === rect.map.height &&
|
|
rect.right === rect.map.width &&
|
|
!state.selection.empty
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if multiple cells are selected in the editor.
|
|
*
|
|
* @param state The editor state
|
|
* @returns Boolean indicating if multiple cells are selected
|
|
*/
|
|
export function isMultipleCellSelection(state: EditorState): boolean {
|
|
const { selection } = state;
|
|
|
|
return (
|
|
selection instanceof CellSelection &&
|
|
(selection.isColSelection() ||
|
|
selection.isRowSelection() ||
|
|
selection.$anchorCell.pos !== selection.$headCell.pos)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if the selection spans multiple merged cells.
|
|
*
|
|
* @param state The editor state
|
|
* @returns Boolean indicating if a merged cell is selected
|
|
*/
|
|
export function isMergedCellSelection(state: EditorState): boolean {
|
|
const { selection } = state;
|
|
if (selection instanceof CellSelection) {
|
|
// Check if any cell in the selection has a colspan or rowspan > 1
|
|
let hasMergedCells = false;
|
|
selection.forEachCell((cell) => {
|
|
if (cell.attrs.colspan > 1 || cell.attrs.rowspan > 1) {
|
|
hasMergedCells = true;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
return hasMergedCells;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function getAllSelectedColumns(state: EditorState): number[] {
|
|
const rect = selectedRect(state);
|
|
|
|
const selectedColumns: number[] = [];
|
|
for (let col = rect.left; col < rect.right; col++) {
|
|
selectedColumns.push(col);
|
|
}
|
|
|
|
return selectedColumns;
|
|
}
|
|
|
|
/**
|
|
* Get the total width of selected columns by measuring DOM elements.
|
|
* Uses getBoundingClientRect to get precise rendered widths including decimals.
|
|
*
|
|
* @param view The editor view
|
|
* @param rect The table rect
|
|
* @param selectedColumns Array of column indices to measure
|
|
* @returns The total width in px, or 0 if measurement fails
|
|
*/
|
|
export function getWidthFromDom({
|
|
view,
|
|
rect,
|
|
selectedColumns,
|
|
}: {
|
|
view?: EditorView;
|
|
rect: TableRect;
|
|
selectedColumns: number[];
|
|
}): number {
|
|
if (!view) {
|
|
return 0;
|
|
}
|
|
|
|
const tableDOM = view.domAtPos(rect.tableStart).node as HTMLElement;
|
|
const firstRow = tableDOM.closest("table")?.querySelector("tr");
|
|
if (!firstRow) {
|
|
return 0;
|
|
}
|
|
|
|
const cells = firstRow.querySelectorAll("td, th");
|
|
return selectedColumns.reduce((total, colIndex) => {
|
|
const cell = cells[colIndex] as HTMLElement | undefined;
|
|
return total + (cell?.getBoundingClientRect().width ?? 0);
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Get the total width of selected columns from node attributes.
|
|
* Sums the colwidth values stored in the document state.
|
|
*
|
|
* @param state The editor state
|
|
* @param selectedColumns Array of column indices to measure
|
|
* @returns The total width in px from colwidth attributes
|
|
*/
|
|
export function getWidthFromNodes({
|
|
state,
|
|
selectedColumns,
|
|
}: {
|
|
state: EditorState;
|
|
selectedColumns: number[];
|
|
}): number {
|
|
const firstRowCells = getCellsInRow(0)(state);
|
|
if (!firstRowCells) {
|
|
return 0;
|
|
}
|
|
|
|
return selectedColumns.reduce((total, colIndex) => {
|
|
const cell =
|
|
firstRowCells[colIndex] !== undefined
|
|
? state.doc.nodeAt(firstRowCells[colIndex])
|
|
: null;
|
|
const colwidth = cell?.attrs.colwidth;
|
|
return total + (colwidth?.[0] ?? 0);
|
|
}, 0);
|
|
}
|