[feat]: Tiptap table integration (#2008)

* added basic table support

* fixed table position at bottom

* fixed image node deletion logic's regression issue

* added compatible styles

* enabled slash commands

* disabled slash command and bubble menu's node selector for table cells

* added dropcursor support to type below the table/image

* blocked image uploads for handledrop and paste actions
This commit is contained in:
M. Palanikannan 2023-08-31 13:41:41 +05:30 committed by GitHub
parent 320608ea73
commit 38b7f4382f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 306 additions and 8 deletions

View file

@ -77,14 +77,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
{...bubbleMenuProps}
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
<NodeSelector
{!props.editor.isActive("table") && <NodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
/>}
<LinkSelector
editor={props.editor!!}
isOpen={isLinkSelectorOpen}

View file

@ -6,7 +6,7 @@ import isValidHttpUrl from "./utils/link-validator";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen: Dispatch<SetStateAction<boolean>>
}
@ -52,7 +52,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); onLinkSubmit();
e.preventDefault();
onLinkSubmit();
}
}}
>

View file

@ -13,6 +13,7 @@ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { lowlight } from "lowlight/lib/core";
import SlashCommand from "../slash-command";
import { InputRule } from "@tiptap/core";
import Gapcursor from '@tiptap/extension-gapcursor'
import ts from "highlight.js/lib/languages/typescript";
@ -20,6 +21,10 @@ import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
import { Table } from "./table/table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
lowlight.registerLanguage("ts", ts);
@ -55,7 +60,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
@ -86,6 +91,7 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
class: "mb-6 border-t border-custom-border-300",
},
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
@ -104,6 +110,9 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return ""
}
return "Press '/' for commands...";
},
@ -134,4 +143,8 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow
];

View file

@ -0,0 +1,31 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => { isHeader: element.tagName === "TD" },
renderHTML: (attributes) => { tag: attributes.isHeader ? "th" : "td" }
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
[
"span",
{ class: "absolute top-0 right-0" },
],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});

View file

@ -0,0 +1,7 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph"
});
export { TableHeader };

View file

@ -0,0 +1,9 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true
});
export { Table };

View file

@ -5,6 +5,7 @@ import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { useImperativeHandle, useRef, forwardRef } from "react";
import { ImageResizer } from "./extensions/image-resize";
import { TableMenu } from "./table-menu";
export interface ITipTapRichTextEditor {
value: string;
@ -92,6 +93,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => {
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("table") && <TableMenu editor={editor} />}
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>

View file

@ -16,6 +16,7 @@ const TrackImageDeletionPlugin = () =>
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== 'image') return;
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
@ -28,7 +29,6 @@ const TrackImageDeletionPlugin = () =>
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
}

View file

@ -60,8 +60,6 @@ function findPlaceholder(state: EditorState, id: {}) {
export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
return;
}
const id = {};

View file

@ -1,5 +1,6 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
import { findTableAncestor } from "./table-menu";
export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps {
return {
@ -18,6 +19,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu
},
},
handlePaste: (view, event) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (
event.clipboardData &&
event.clipboardData.files &&
@ -32,6 +42,15 @@ export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSu
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (
!moved &&
event.dataTransfer &&

View file

@ -15,6 +15,7 @@ import {
MinusSquare,
CheckSquare,
ImageIcon,
Table,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
@ -46,6 +47,9 @@ const Command = Extension.create({
return [
Suggestion({
editor: this.editor,
allow({ editor }) {
return !editor.isActive("table");
},
...this.options.suggestion,
}),
];
@ -117,6 +121,15 @@ const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitti
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",

View file

@ -0,0 +1,96 @@
import { useState, useEffect } from "react";
import { Rows, Columns, ToggleRight } from "lucide-react";
import { cn } from "../utils";
interface TableMenuItem {
name: string;
command: () => void;
icon: any;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
}
return node as HTMLTableElement;
};
export const TableMenu = ({ editor }: { editor: any }) => {
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
const items: TableMenuItem[] = [
{
name: "Insert Column right",
command: () => editor.chain().focus().addColumnBefore().run(),
icon: Columns,
},
{
name: "Insert Row below",
command: () => editor.chain().focus().addRowAfter().run(),
icon: Rows,
},
{
name: "Delete Column",
command: () => editor.chain().focus().deleteColumn().run(),
icon: Columns,
},
{
name: "Delete Rows",
command: () => editor.chain().focus().deleteRow().run(),
icon: Rows,
},
{
name: "Toggle Header Row",
command: () => editor.chain().focus().toggleHeaderRow().run(),
icon: ToggleRight,
}
];
useEffect(() => {
if (typeof window !== "undefined") {
const handleWindowClick = () => {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer);
if (tableNode) {
const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2;
const menuWidth = 45;
const menuLeft = tableCenter - menuWidth / 2;
const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft });
}
}
}
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}
}, [tableLocation]);
return (
<section
className="fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
style={{ bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`, left: `${tableLocation.left}px` }}
>
{items.map((item, index) => (
<button
key={index}
onClick={item.command}
className="p-2 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-primary-100/10 active:bg-custom-background-100"
title={item.name}
>
<item.icon
className={cn("h-5 w-5 text-lg", {
"text-red-600": item.name.includes("Delete"),
})}
/>
</button>
))}
</section>
);
};