Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Table row/column extension buttons #1172

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions packages/ariakit/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export const components: Components = {
},
TableHandle: {
Root: TableHandle,
ExtendButton: (props) => (
<button style={{ height: "100%", width: "100%" }} {...props}>
+
</button>
),
},
Generic: {
Form: {
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/api/nodeConversions/blockToNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ export function tableContentToNodes<

for (const row of tableContent.rows) {
const columnNodes: Node[] = [];
for (const cell of row.cells) {
for (let i = 0; i < row.cells.length; i++) {
const cell = row.cells[i];
let pNode: Node;
if (!cell) {
pNode = schema.nodes["tableParagraph"].create({});
Expand All @@ -172,7 +173,19 @@ export function tableContentToNodes<
pNode = schema.nodes["tableParagraph"].create({}, textNodes);
}

const cellNode = schema.nodes["tableCell"].create({}, pNode);
const cellNode = schema.nodes["tableCell"].create(
{
// The colwidth array should have multiple values when the colspan of
// a cell is greater than 1. However, this is not yet implemented so
// we can always assume a length of 1.
colwidth: [
tableContent.columnWidths
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
? tableContent.columnWidths[i] || 100
: 100,
],
},
pNode
);
columnNodes.push(cellNode);
}
const rowNode = schema.nodes["tableRow"].create({}, columnNodes);
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/api/nodeConversions/nodeToBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,24 @@ export function contentNodeToTableContent<
>(contentNode: Node, inlineContentSchema: I, styleSchema: S) {
const ret: TableContent<I, S> = {
type: "tableContent",
columnWidths: [],
rows: [],
};

contentNode.content.forEach((rowNode) => {
contentNode.content.forEach((rowNode, _offset, index) => {
const row: TableContent<I, S>["rows"][0] = {
cells: [],
};

if (index === 0) {
rowNode.content.forEach((cellNode) => {
// The colwidth array should have multiple values when the colspan of a
// cell is greater than 1. However, this is not yet implemented so we
// can always assume a length of 1.
ret.columnWidths.push(cellNode.attrs.colwidth[0] || 100);
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
});
}

rowNode.content.forEach((cellNode) => {
row.cells.push(
contentNodeToInlineContent(
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/blocks/TableBlockContent/TableExtension.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
import { callOrReturn, Extension, getExtensionField } from "@tiptap/core";
import { columnResizing, tableEditing } from "prosemirror-tables";
import { TableView } from "prosemirror-tables";
import { Node as PMNode } from "prosemirror-model";
import { mergeCSSClasses } from "../../util/browser.js";

export const TableExtension = Extension.create({
name: "BlockNoteTableExtension",

addProseMirrorPlugins: () => {
class CustomTableView extends TableView {
YousefED marked this conversation as resolved.
Show resolved Hide resolved
constructor(public node: PMNode, public cellMinWidth: number) {
super(node, cellMinWidth);

const blockContent = document.createElement("div");
blockContent.className = mergeCSSClasses(
"bn-block-content"
// blockContentHTMLAttributes.class
);
blockContent.setAttribute("data-content-type", "table");
// for (const [attribute, value] of Object.entries(blockContentHTMLAttributes)) {
// if (attribute !== "class") {
// blockContent.setAttribute(attribute, value);
// }
// }

const tableWrapper = this.dom;
blockContent.appendChild(tableWrapper);

this.dom = blockContent;
}
}

return [
columnResizing({
cellMinWidth: 100,
View: CustomTableView,
}),
tableEditing(),
];
Expand Down
5 changes: 1 addition & 4 deletions packages/core/src/editor/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ Tippy popups that are appended to document.body directly
/* table related: */
.bn-editor table {
width: auto !important;
margin-bottom: 2em;
}
.bn-editor th,
.bn-editor td {
Expand All @@ -115,10 +116,6 @@ Tippy popups that are appended to document.body directly
padding: 3px 5px;
}

.bn-editor .tableWrapper {
margin: 1em 0;
}

.bn-editor th {
font-weight: bold;
text-align: left;
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export class TableHandlesView<

public tableId: string | undefined;
public tablePos: number | undefined;
public tableElement: HTMLElement | undefined;

public menuFrozen = false;

Expand Down Expand Up @@ -195,6 +196,7 @@ export class TableHandlesView<
if (!blockEl) {
return;
}
this.tableElement = blockEl.node;

let tableBlock: Block<any, any, any> | undefined = undefined;

Expand Down Expand Up @@ -410,6 +412,41 @@ export class TableHandlesView<
}
};

// Updates drag handle positions on table content updates.
update() {
if (!this.state || !this.state.show) {
return;
}

// TODO: Can also just do down the DOM tree until we find it but this seems
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
// cleaner.
const tableBody = this.tableElement!.querySelector("tbody");
if (!tableBody) {
return;
}

if (this.state.rowIndex >= tableBody.children.length) {
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
this.state.rowIndex = tableBody.children.length - 1;
this.emitUpdate();

return;
}
const row = tableBody.children[this.state.rowIndex];

if (this.state.colIndex >= tableBody.children[0].children.length) {
this.state.colIndex = tableBody.children[0].children.length - 1;
this.emitUpdate();

return;
}
const cell = row.children[this.state.colIndex];

// TODO: Check if DOMRects changed first?
this.state.referencePosCell = cell.getBoundingClientRect();
this.state.referencePosTable = tableBody.getBoundingClientRect();
this.emitUpdate();
}

destroy() {
this.pmView.dom.removeEventListener("mousemove", this.mouseMoveHandler);
this.pmView.root.removeEventListener(
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export type TableContent<
S extends StyleSchema = StyleSchema
> = {
type: "tableContent";
columnWidths: number[];
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
rows: {
cells: InlineContent<I, S>[][];
}[];
Expand Down Expand Up @@ -224,6 +225,7 @@ export type PartialTableContent<
S extends StyleSchema = StyleSchema
> = {
type: "tableContent";
columnWidths?: (number | undefined)[];
rows: {
cells: PartialInlineContent<I, S>[];
}[];
Expand Down
5 changes: 5 additions & 0 deletions packages/mantine/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export const components: Components = {
},
TableHandle: {
Root: TableHandle,
ExtendButton: (props) => (
<button style={{ height: "100%", width: "100%" }} {...props}>
+
</button>
),
},
Generic: {
Form: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
DefaultInlineContentSchema,
DefaultStyleSchema,
InlineContentSchema,
PartialTableContent,
StyleSchema,
} from "@blocknote/core";
import { MouseEvent as ReactMouseEvent, useEffect, useState } from "react";

import { useComponentsContext } from "../../../editor/ComponentsContext.js";
import { TableHandleProps } from "../TableHandleProps.js";

const getContentWithAddedRows = <
I extends InlineContentSchema,
S extends StyleSchema
>(
content: PartialTableContent<I, S>,
rowsToAdd = 1
): PartialTableContent<I, S> => {
const newRow: PartialTableContent<I, S>["rows"][number] = {
cells: content.rows[0].cells.map(() => []),
};
const newRows: PartialTableContent<I, S>["rows"] = [];
for (let i = 0; i < rowsToAdd; i++) {
newRows.push(newRow);
}

return {
type: "tableContent",
columnWidths: content.columnWidths
? [...content.columnWidths, ...newRows.map(() => undefined)]
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
: undefined,
rows: [...content.rows, ...newRows],
};
};

const getContentWithAddedCols = <
I extends InlineContentSchema,
S extends StyleSchema
>(
content: PartialTableContent<I, S>,
colsToAdd = 1
): PartialTableContent<I, S> => {
const newCell: PartialTableContent<I, S>["rows"][number]["cells"][number] =
[];
const newCells: PartialTableContent<I, S>["rows"][number]["cells"] = [];
for (let i = 0; i < colsToAdd; i++) {
newCells.push(newCell);
}

return {
type: "tableContent",
columnWidths: content.columnWidths || undefined,
matthewlipski marked this conversation as resolved.
Show resolved Hide resolved
rows: content.rows.map((row) => ({
cells: [...row.cells, ...newCells],
})),
};
};

export const ExtendButton = <
I extends InlineContentSchema = DefaultInlineContentSchema,
S extends StyleSchema = DefaultStyleSchema
>(
props: Pick<
TableHandleProps<I, S>,
"block" | "editor" | "orientation" | "freezeHandles" | "unfreezeHandles"
>
) => {
const Components = useComponentsContext()!;

const [editingState, setEditingState] = useState<{
startPos: number;
numOriginalCells: number;
clickOnly: boolean;
} | null>(null);

const mouseDownHandler = (event: ReactMouseEvent) => {
props.freezeHandles();
setEditingState({
startPos: props.orientation === "row" ? event.clientX : event.clientY,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

confused here by row / cols. when orientation is row, shouldn't we use event.clientY?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it is kind of confusing, you could justify either the bottom or right placement as being the row orientation. Imo it makes most sense if the button on the right is for row, since it extends the rows in the table and the table handle for the row orientation is on the left (so it's consistent with row being left/right and col being top/bottom).

numOriginalCells:
props.orientation === "row"
? props.block.content.rows[0].cells.length
: props.block.content.rows.length,
clickOnly: true,
});
};

// Extends columns/rows on when moving the mouse.
useEffect(() => {
const callback = (event: MouseEvent) => {
if (editingState === null) {
return;
}

const diff =
(props.orientation === "row" ? event.clientX : event.clientY) -
editingState.startPos;

const numCells =
editingState.numOriginalCells +
Math.floor(diff / (props.orientation === "row" ? 100 : 31));
const block = props.editor.getBlock(props.block)!;
const numCurrentCells =
props.orientation === "row"
? block.content.rows[0].cells.length
: block.content.rows.length;

if (
editingState.numOriginalCells <= numCells &&
numCells !== numCurrentCells
) {
props.editor.updateBlock(props.block, {
type: "table",
content:
props.orientation === "row"
? getContentWithAddedCols(
props.block.content,
numCells - editingState.numOriginalCells
)
: getContentWithAddedRows(
props.block.content,
numCells - editingState.numOriginalCells
),
});
// Edge case for updating block content as `updateBlock` causes the
// selection to move into the next block, so we have to set it back.
if (block.content) {
props.editor.setTextCursorPosition(props.block);
}
setEditingState({ ...editingState, clickOnly: false });
}
};

document.body.addEventListener("mousemove", callback);

return () => {
document.body.removeEventListener("mousemove", callback);
};
}, [editingState, props.block, props.editor, props.orientation]);

// Stops mouse movements from extending columns/rows when the mouse is
// released. Also extends columns/rows by 1 if the mouse wasn't moved enough
// to add any, imitating a click.
useEffect(() => {
const callback = () => {
if (editingState?.clickOnly) {
props.editor.updateBlock(props.block, {
type: "table",
content:
props.orientation === "row"
? getContentWithAddedCols(props.block.content)
: getContentWithAddedRows(props.block.content),
});
}

setEditingState(null);
props.unfreezeHandles();
};

document.body.addEventListener("mouseup", callback);

return () => {
document.body.removeEventListener("mouseup", callback);
};
}, [
editingState?.clickOnly,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a) it looks like we set something on the state to then trigger a useEffect, that's not a very clear pattern I think
b) can't we just use the onClick handler?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we have a choice here because we need the mousemove and mouseup handlers to be attached to the document body, because the mouse cursor is not always above the extend button while dragging. This is also why we can't use a click event, as those only fire when the same element is hovered on both the mouse down and mouse up.

getContentWithAddedCols,
getContentWithAddedRows,
props,
]);

return (
<Components.TableHandle.ExtendButton
onDragStart={(event) => {
event.preventDefault();
}}
onMouseDown={mouseDownHandler}
/>
);
};
Loading
Loading