Skip to content

Commit

Permalink
[feat]: Tiptap table integration (#2008)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Palanikannan1437 committed Aug 31, 2023
1 parent 320608e commit 38b7f43
Show file tree
Hide file tree
Showing 15 changed files with 306 additions and 8 deletions.
4 changes: 2 additions & 2 deletions apps/app/components/tiptap/bubble-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
5 changes: 3 additions & 2 deletions apps/app/components/tiptap/bubble-menu/link-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import isValidHttpUrl from "./utils/link-validator";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen: Dispatch<SetStateAction<boolean>>
}


Expand Down Expand Up @@ -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();
}
}}
>
Expand Down
15 changes: 14 additions & 1 deletion apps/app/components/tiptap/extensions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ 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";

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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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...";
},
Expand Down Expand Up @@ -134,4 +143,8 @@ export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSub
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
CustomTableCell,
TableRow
];
31 changes: 31 additions & 0 deletions apps/app/components/tiptap/extensions/table/table-cell.ts
Original file line number Diff line number Diff line change
@@ -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];
},
});
7 changes: 7 additions & 0 deletions apps/app/components/tiptap/extensions/table/table-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";

const TableHeader = BaseTableHeader.extend({
content: "paragraph"
});

export { TableHeader };
9 changes: 9 additions & 0 deletions apps/app/components/tiptap/extensions/table/table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Table as BaseTable } from "@tiptap/extension-table";

const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true
});

export { Table };
2 changes: 2 additions & 0 deletions apps/app/components/tiptap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/components/tiptap/plugins/delete-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -28,7 +29,6 @@ const TrackImageDeletionPlugin = () =>
nodeExists = true;
}
});

if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
}
Expand Down
2 changes: 0 additions & 2 deletions apps/app/components/tiptap/plugins/upload-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
19 changes: 19 additions & 0 deletions apps/app/components/tiptap/props.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 &&
Expand All @@ -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 &&
Expand Down
13 changes: 13 additions & 0 deletions apps/app/components/tiptap/slash-command/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MinusSquare,
CheckSquare,
ImageIcon,
Table,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
Expand Down Expand Up @@ -46,6 +47,9 @@ const Command = Extension.create({
return [
Suggestion({
editor: this.editor,
allow({ editor }) {
return !editor.isActive("table");
},
...this.options.suggestion,
}),
];
Expand Down Expand Up @@ -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.",
Expand Down
96 changes: 96 additions & 0 deletions apps/app/components/tiptap/table-menu/index.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
5 changes: 5 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7",
"@tiptap/extension-highlight": "^2.0.4",
"@tiptap/extension-horizontal-rule": "^2.0.4",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-link": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/extension-table": "^2.1.6",
"@tiptap/extension-table-cell": "^2.1.6",
"@tiptap/extension-table-header": "^2.1.6",
"@tiptap/extension-table-row": "^2.1.6",
"@tiptap/extension-task-item": "^2.0.4",
"@tiptap/extension-task-list": "^2.0.4",
"@tiptap/extension-text-style": "^2.0.4",
Expand Down
Loading

1 comment on commit 38b7f43

@vercel
Copy link

@vercel vercel bot commented on 38b7f43 Aug 31, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

plane-dev – ./apps/app

plane-dev-git-develop-plane.vercel.app
plane-dev.vercel.app
plane-dev-plane.vercel.app

Please sign in to comment.