Table improvements (#6958)

* Header toggling, resizable columns

* Allow all blocks in table cells, disable column resizing in read-only

* Fixed dynamic scroll shadows

* Refactor, scroll styling

* fix scrolling, tweaks

* fix: Table layout lost on sort

* fix: Caching of grip decorators

* refactor

* stash

* fix first render shadows

* stash

* First add column grip, styles

* Just add column/row click handlers left

* fix: isTableSelected for single cell table

* Refactor mousedown handlers

* fix: 'Add row before' command missing on first row

* fix overflow on rhs

* fix: Error clicking column grip when menu is open

* Hide table controls when printing

* Restore table header background

* fix: Header behavior when adding columns and rows at the edges

* Tweak header styling

* fix: Serialize and parsing of column attributes when copy/pasting
fix: Column width is lost when changing column alignment
This commit is contained in:
Tom Moor
2024-05-31 17:52:39 -04:00
committed by GitHub
parent 1db46f4aac
commit da19054555
27 changed files with 1020 additions and 351 deletions

View File

@@ -1,28 +1,35 @@
import { chainCommands } from "prosemirror-commands";
import { NodeSpec, Node as ProsemirrorNode } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
tableEditing,
toggleHeaderCell,
toggleHeaderColumn,
toggleHeaderRow,
toggleHeader,
} from "prosemirror-tables";
import { Decoration, DecorationSet } from "prosemirror-view";
import {
addRowBefore,
addColumnBefore,
addRowAndMoveSelection,
setColumnAttr,
createTable,
sortTable,
setTableAttr,
} from "../commands/table";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import tablesRule from "../rules/tables";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { TableLayout } from "../types";
import Node from "./Node";
import { TableView } from "./TableView";
export type TableAttrs = {
layout: TableLayout | null;
};
export default class Table extends Node {
get name() {
@@ -36,15 +43,17 @@ export default class Table extends Node {
isolating: true,
group: "block",
parseDOM: [{ tag: "table" }],
attrs: {
layout: {
default: null,
},
},
toDOM() {
// Note: This is overridden by TableView
return [
"div",
{ class: "scrollable-wrapper table-wrapper" },
[
"div",
{ class: "scrollable" },
["table", { class: "rme-table" }, ["tbody", 0]],
],
{ class: EditorStyleHelper.table },
["table", {}, ["tbody", 0]],
];
},
};
@@ -58,16 +67,17 @@ export default class Table extends Node {
return {
createTable,
setColumnAttr,
setTableAttr,
sortTable,
addColumnBefore: () => addColumnBefore,
addColumnBefore,
addColumnAfter: () => addColumnAfter,
deleteColumn: () => deleteColumn,
addRowAfter: addRowAndMoveSelection,
addRowBefore,
addRowAfter: () => addRowAfter,
deleteRow: () => deleteRow,
deleteTable: () => deleteTable,
toggleHeaderColumn: () => toggleHeaderColumn,
toggleHeaderRow: () => toggleHeaderRow,
toggleHeaderCell: () => toggleHeaderCell,
toggleHeaderColumn: () => toggleHeader("column"),
toggleHeaderRow: () => toggleHeader("row"),
};
}
@@ -90,52 +100,12 @@ export default class Table extends Node {
get plugins() {
return [
tableEditing(),
new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
let index = 0;
doc.descendants((node, pos) => {
if (node.type.name !== this.name) {
return;
}
const elements = document.getElementsByClassName("rme-table");
const table = elements[index];
if (!table) {
return;
}
const element = table.parentElement;
const shadowRight = !!(
element && element.scrollWidth > element.clientWidth
);
if (shadowRight) {
decorations.push(
Decoration.widget(
pos + 1,
() => {
const shadow = document.createElement("div");
shadow.className = "scrollable-shadow right";
return shadow;
},
{
key: "table-shadow-right",
}
)
);
}
index++;
});
return DecorationSet.create(doc, decorations);
},
},
// Note: Important to register columnResizing before tableEditing
columnResizing({
View: TableView,
lastColumnResizable: false,
}),
tableEditing(),
];
}
}

View File

@@ -2,12 +2,15 @@ import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { DecorationSet, Decoration } from "prosemirror-view";
import { selectRow, selectTable } from "../commands/table";
import { addRowBefore, selectRow, selectTable } from "../commands/table";
import { getCellAttrs, setCellAttrs } from "../lib/table";
import {
getCellsInColumn,
isRowSelected,
isTableSelected,
} from "../queries/table";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { cn } from "../styles/utils";
import Node from "./Node";
export default class TableCell extends Node {
@@ -17,23 +20,18 @@ export default class TableCell extends Node {
get schema(): NodeSpec {
return {
content: "(paragraph | embed)+",
content: "block+",
tableRole: "cell",
isolating: true,
parseDOM: [{ tag: "td" }],
parseDOM: [{ tag: "td", getAttrs: getCellAttrs }],
toDOM(node) {
return [
"td",
node.attrs.alignment
? { style: `text-align: ${node.attrs.alignment}` }
: {},
0,
];
return ["td", setCellAttrs(node), 0];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
colwidth: { default: null },
},
};
}
@@ -50,61 +48,129 @@ export default class TableCell extends Node {
}
get plugins() {
function buildAddRowDecoration(pos: number, index: number) {
const className = cn(EditorStyleHelper.tableAddRow, {
first: index === 0,
});
return Decoration.widget(
pos + 1,
() => {
const plus = document.createElement("a");
plus.role = "button";
plus.className = className;
plus.dataset.index = index.toString();
return plus;
},
{
key: cn(className, index),
}
);
}
return [
new Plugin({
props: {
handleDOMEvents: {
mousedown: (view, event) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const targetAddRow = event.target.closest(
`.${EditorStyleHelper.tableAddRow}`
);
if (targetAddRow) {
event.preventDefault();
event.stopImmediatePropagation();
const index = Number(targetAddRow.getAttribute("data-index"));
addRowBefore({ index })(view.state, view.dispatch);
return true;
}
const targetGrip = event.target.closest(
`.${EditorStyleHelper.tableGrip}`
);
if (targetGrip) {
event.preventDefault();
event.stopImmediatePropagation();
selectTable()(view.state, view.dispatch);
return true;
}
const targetGripRow = event.target.closest(
`.${EditorStyleHelper.tableGripRow}`
);
if (targetGripRow) {
event.preventDefault();
event.stopImmediatePropagation();
selectRow(
Number(targetGripRow.getAttribute("data-index")),
event.metaKey || event.shiftKey
)(view.state, view.dispatch);
return true;
}
return false;
},
},
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(state);
const rows = getCellsInColumn(0)(state);
if (cells) {
cells.forEach((pos, index) => {
if (rows) {
rows.forEach((pos, index) => {
if (index === 0) {
const className = cn(EditorStyleHelper.tableGrip, {
selected: isTableSelected(state),
});
decorations.push(
Decoration.widget(pos + 1, () => {
let className = "grip-table";
const selected = isTableSelected(state);
if (selected) {
className += " selected";
Decoration.widget(
pos + 1,
() => {
const grip = document.createElement("a");
grip.role = "button";
grip.className = className;
return grip;
},
{
key: className,
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectTable(state));
});
return grip;
})
)
);
}
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(state);
let className = "grip-row";
if (rowSelected) {
className += " selected";
const className = cn(EditorStyleHelper.tableGripRow, {
selected: isRowSelected(index)(state),
first: index === 0,
last: index === rows.length - 1,
});
decorations.push(
Decoration.widget(
pos + 1,
() => {
const grip = document.createElement("a");
grip.role = "button";
grip.className = className;
grip.dataset.index = index.toString();
return grip;
},
{
key: cn(className, index),
}
if (index === 0) {
className += " first";
}
if (index === cells.length - 1) {
className += " last";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(
selectRow(index, event.metaKey || event.shiftKey)(state)
);
});
return grip;
})
)
);
if (index === 0) {
decorations.push(buildAddRowDecoration(pos, index));
}
decorations.push(buildAddRowDecoration(pos, index + 1));
});
}

View File

@@ -1,96 +0,0 @@
import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { DecorationSet, Decoration } from "prosemirror-view";
import { selectColumn } from "../commands/table";
import { getCellsInRow, isColumnSelected } from "../queries/table";
import Node from "./Node";
export default class TableHeadCell extends Node {
get name() {
return "th";
}
get schema(): NodeSpec {
return {
content: "(paragraph | embed)+",
tableRole: "header_cell",
isolating: true,
parseDOM: [{ tag: "th" }],
toDOM(node) {
return [
"th",
node.attrs.alignment
? { style: `text-align: ${node.attrs.alignment}` }
: {},
0,
];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
},
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return {
block: "th",
getAttrs: (tok: Token) => ({ alignment: tok.info }),
};
}
get plugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(state);
if (cells) {
cells.forEach((pos, index) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(state);
let className = "grip-column";
if (colSelected) {
className += " selected";
}
if (index === 0) {
className += " first";
} else if (index === cells.length - 1) {
className += " last";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(
selectColumn(
index,
event.metaKey || event.shiftKey
)(state)
);
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}

View File

@@ -0,0 +1,149 @@
import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { DecorationSet, Decoration, EditorView } from "prosemirror-view";
import { addColumnBefore, selectColumn } from "../commands/table";
import { getCellAttrs, setCellAttrs } from "../lib/table";
import { getCellsInRow, isColumnSelected } from "../queries/table";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { cn } from "../styles/utils";
import Node from "./Node";
export default class TableHeader extends Node {
get name() {
return "th";
}
get schema(): NodeSpec {
return {
content: "block+",
tableRole: "header_cell",
isolating: true,
parseDOM: [{ tag: "th", getAttrs: getCellAttrs }],
toDOM(node) {
return ["th", setCellAttrs(node), 0];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
colwidth: { default: null },
},
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return {
block: "th",
getAttrs: (tok: Token) => ({ alignment: tok.info }),
};
}
get plugins() {
function buildAddColumnDecoration(pos: number, index: number) {
const className = cn(EditorStyleHelper.tableAddColumn, {
first: index === 0,
});
return Decoration.widget(
pos + 1,
() => {
const plus = document.createElement("a");
plus.role = "button";
plus.className = className;
plus.dataset.index = index.toString();
return plus;
},
{
key: cn(className, index),
}
);
}
return [
new Plugin({
props: {
handleDOMEvents: {
mousedown: (view: EditorView, event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const targetAddColumn = event.target.closest(
`.${EditorStyleHelper.tableAddColumn}`
);
if (targetAddColumn) {
event.preventDefault();
event.stopImmediatePropagation();
const index = Number(
targetAddColumn.getAttribute("data-index")
);
addColumnBefore({ index })(view.state, view.dispatch);
return true;
}
const targetGripColumn = event.target.closest(
`.${EditorStyleHelper.tableGripColumn}`
);
if (targetGripColumn) {
event.preventDefault();
event.stopImmediatePropagation();
selectColumn(
Number(targetGripColumn.getAttribute("data-index")),
event.metaKey || event.shiftKey
)(view.state, view.dispatch);
return true;
}
return false;
},
},
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
const cols = getCellsInRow(0)(state);
if (cols) {
cols.forEach((pos, index) => {
const className = cn(EditorStyleHelper.tableGripColumn, {
selected: isColumnSelected(index)(state),
first: index === 0,
last: index === cols.length - 1,
});
decorations.push(
Decoration.widget(
pos + 1,
() => {
const grip = document.createElement("a");
grip.role = "button";
grip.className = className;
grip.dataset.index = index.toString();
return grip;
},
{
key: cn(className, index),
}
)
);
if (index === 0) {
decorations.push(buildAddColumnDecoration(pos, index));
}
decorations.push(buildAddColumnDecoration(pos, index + 1));
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}

View File

@@ -0,0 +1,91 @@
import { Node } from "prosemirror-model";
import { TableView as ProsemirrorTableView } from "prosemirror-tables";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { TableLayout } from "../types";
export class TableView extends ProsemirrorTableView {
public constructor(public node: Node, public cellMinWidth: number) {
super(node, cellMinWidth);
this.dom.removeChild(this.table);
this.dom.classList.add(EditorStyleHelper.table);
// Add an extra wrapper to enable scrolling
this.scrollable = this.dom.appendChild(document.createElement("div"));
this.scrollable.appendChild(this.table);
this.scrollable.classList.add(EditorStyleHelper.tableScrollable);
this.scrollable.addEventListener(
"scroll",
() => {
this.updateClassList(this.node);
},
{
passive: true,
}
);
this.updateClassList(node);
// We need to wait for the next tick to ensure dom is rendered and scroll shadows are correct.
setTimeout(() => {
if (this.dom) {
this.updateClassList(node);
}
}, 0);
}
public override update(node: Node) {
this.updateClassList(node);
return super.update(node);
}
public override ignoreMutation(record: MutationRecord): boolean {
if (
record.type === "attributes" &&
record.target === this.dom &&
(record.attributeName === "class" || record.attributeName === "style")
) {
return true;
}
return (
record.type === "attributes" &&
(record.target === this.table || this.colgroup.contains(record.target))
);
}
private updateClassList(node: Node) {
this.dom.classList.toggle(
EditorStyleHelper.tableFullWidth,
node.attrs.layout === TableLayout.fullWidth
);
const shadowLeft = !!(this.scrollable && this.scrollable.scrollLeft > 0);
const shadowRight = !!(
this.scrollable &&
this.scrollable.scrollWidth > this.scrollable.clientWidth &&
this.scrollable.scrollLeft + this.scrollable.clientWidth <
this.scrollable.scrollWidth - 1
);
this.dom.classList.toggle(EditorStyleHelper.tableShadowLeft, shadowLeft);
this.dom.classList.toggle(EditorStyleHelper.tableShadowRight, shadowRight);
if (this.scrollable) {
this.dom.style.setProperty(
"--table-height",
`${this.scrollable?.clientHeight}px`
);
this.dom.style.setProperty(
"--table-width",
`${this.scrollable?.clientWidth}px`
);
} else {
this.dom.style.removeProperty("--table-height");
this.dom.style.removeProperty("--table-width");
}
}
private scrollable: HTMLDivElement | null = null;
}

View File

@@ -39,7 +39,7 @@ import Paragraph from "./Paragraph";
import SimpleImage from "./SimpleImage";
import Table from "./Table";
import TableCell from "./TableCell";
import TableHeadCell from "./TableHeadCell";
import TableHeader from "./TableHeader";
import TableRow from "./TableRow";
import Text from "./Text";
import Video from "./Video";
@@ -77,12 +77,7 @@ export const listExtensions: Nodes = [
ListItem,
];
export const tableExtensions: Nodes = [
Table,
TableCell,
TableHeadCell,
TableRow,
];
export const tableExtensions: Nodes = [Table, TableCell, TableHeader, TableRow];
/**
* The full set of nodes that are used in the editor. This is used for rich