From 38b7f4382fda94d04f9c833adc09263df6b0ea4e Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:41:41 +0530 Subject: [PATCH] [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 --- .../components/tiptap/bubble-menu/index.tsx | 4 +- .../tiptap/bubble-menu/link-selector.tsx | 5 +- .../components/tiptap/extensions/index.tsx | 15 ++- .../tiptap/extensions/table/table-cell.ts | 31 ++++++ .../tiptap/extensions/table/table-header.ts | 7 ++ .../tiptap/extensions/table/table.ts | 9 ++ apps/app/components/tiptap/index.tsx | 2 + .../tiptap/plugins/delete-image.tsx | 2 +- .../tiptap/plugins/upload-image.tsx | 2 - apps/app/components/tiptap/props.tsx | 19 ++++ .../components/tiptap/slash-command/index.tsx | 13 +++ .../components/tiptap/table-menu/index.tsx | 96 +++++++++++++++++++ apps/app/package.json | 5 + apps/app/styles/editor.css | 79 +++++++++++++++ yarn.lock | 25 +++++ 15 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 apps/app/components/tiptap/extensions/table/table-cell.ts create mode 100644 apps/app/components/tiptap/extensions/table/table-header.ts create mode 100644 apps/app/components/tiptap/extensions/table/table.ts create mode 100644 apps/app/components/tiptap/table-menu/index.tsx diff --git a/apps/app/components/tiptap/bubble-menu/index.tsx b/apps/app/components/tiptap/bubble-menu/index.tsx index e68900782..7e72963b6 100644 --- a/apps/app/components/tiptap/bubble-menu/index.tsx +++ b/apps/app/components/tiptap/bubble-menu/index.tsx @@ -77,14 +77,14 @@ export const EditorBubbleMenu: FC = (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" > - { setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsLinkSelectorOpen(false); }} - /> + />} >; + setIsOpen: Dispatch> } @@ -52,7 +52,8 @@ export const LinkSelector: FC = ({ 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(); } }} > diff --git a/apps/app/components/tiptap/extensions/index.tsx b/apps/app/components/tiptap/extensions/index.tsx index 2c5ffd10a..fa257b20a 100644 --- a/apps/app/components/tiptap/extensions/index.tsx +++ b/apps/app/components/tiptap/extensions/index.tsx @@ -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 ]; diff --git a/apps/app/components/tiptap/extensions/table/table-cell.ts b/apps/app/components/tiptap/extensions/table/table-cell.ts new file mode 100644 index 000000000..94c5aced2 --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table-cell.ts @@ -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]; + }, +}); diff --git a/apps/app/components/tiptap/extensions/table/table-header.ts b/apps/app/components/tiptap/extensions/table/table-header.ts new file mode 100644 index 000000000..d04fe85d3 --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table-header.ts @@ -0,0 +1,7 @@ +import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header"; + +const TableHeader = BaseTableHeader.extend({ + content: "paragraph" +}); + +export { TableHeader }; diff --git a/apps/app/components/tiptap/extensions/table/table.ts b/apps/app/components/tiptap/extensions/table/table.ts new file mode 100644 index 000000000..b05dedb3b --- /dev/null +++ b/apps/app/components/tiptap/extensions/table/table.ts @@ -0,0 +1,9 @@ +import { Table as BaseTable } from "@tiptap/extension-table"; + +const Table = BaseTable.configure({ + resizable: true, + cellMinWidth: 100, + allowTableNodeSelection: true +}); + +export { Table }; diff --git a/apps/app/components/tiptap/index.tsx b/apps/app/components/tiptap/index.tsx index f0315cad4..869e44aad 100644 --- a/apps/app/components/tiptap/index.tsx +++ b/apps/app/components/tiptap/index.tsx @@ -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 && }
+ {editor?.isActive("table") && } {editor?.isActive("image") && }
diff --git a/apps/app/components/tiptap/plugins/delete-image.tsx b/apps/app/components/tiptap/plugins/delete-image.tsx index 57ab65c63..262a3f591 100644 --- a/apps/app/components/tiptap/plugins/delete-image.tsx +++ b/apps/app/components/tiptap/plugins/delete-image.tsx @@ -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); } diff --git a/apps/app/components/tiptap/plugins/upload-image.tsx b/apps/app/components/tiptap/plugins/upload-image.tsx index 0657bc82b..a13f8e18a 100644 --- a/apps/app/components/tiptap/plugins/upload-image.tsx +++ b/apps/app/components/tiptap/plugins/upload-image.tsx @@ -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 = {}; diff --git a/apps/app/components/tiptap/props.tsx b/apps/app/components/tiptap/props.tsx index d50fc29b0..69cddca1f 100644 --- a/apps/app/components/tiptap/props.tsx +++ b/apps/app/components/tiptap/props.tsx @@ -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 && diff --git a/apps/app/components/tiptap/slash-command/index.tsx b/apps/app/components/tiptap/slash-command/index.tsx index 38f5c9c0a..c843f0762 100644 --- a/apps/app/components/tiptap/slash-command/index.tsx +++ b/apps/app/components/tiptap/slash-command/index.tsx @@ -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: , + 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.", diff --git a/apps/app/components/tiptap/table-menu/index.tsx b/apps/app/components/tiptap/table-menu/index.tsx new file mode 100644 index 000000000..878679a29 --- /dev/null +++ b/apps/app/components/tiptap/table-menu/index.tsx @@ -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 ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}; diff --git a/apps/app/package.json b/apps/app/package.json index 578a95716..b9f3bf25e 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -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", diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 57c23c911..3332185d2 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -30,6 +30,10 @@ } } +.ProseMirror-gapcursor:after { + border-top: 1px solid rgb(var(--color-text-100)) !important; +} + /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ ul[data-type="taskList"] li > label { @@ -150,3 +154,78 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transform: rotate(360deg); } } + +#tiptap-container { + table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + margin-bottom: 1.5rem; + margin-top: 1.5rem; + border: 2px solid rgb(var(--color-border-100)); + width: 100%; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + + td, + th { + min-width: 1em; + border: 2px solid rgb(var(--color-border-400)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } + } + + th { + font-weight: bold; + text-align: left; + background-color: rgb(var(--color-primary-300)); + } + + td:hover { + background-color: rgba(var(--color-primary-300), 0.1); + } + + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; right: 0; top: 0; bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; + } + + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 2px; + background-color: rgb(var(--color-primary-400)); + pointer-events: none; + } + } +} + +.tableWrapper { + overflow-x: auto; +} + +.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.ProseMirror table * p { + padding: 0px 1px; + margin: 6px 2px; +} + +.ProseMirror table * .is-empty::before { + opacity: 0; +} diff --git a/yarn.lock b/yarn.lock index ac134d60f..3eafeb5b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,6 +2282,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.0.4.tgz#c100a792fd41535ad6382aa8133d0d9c0b2cb2b8" integrity sha512-VxmKfBQjSSu1mNvHlydA4dJW/zawGKyqmnryiFNcUV9s+/HWLR5i9SiUl4wJM/B8sG8cQxClne5/LrCAeGNYuA== +"@tiptap/extension-gapcursor@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.1.7.tgz#5c0303ba37b4c066f3a3c5835fd0b298f0d3e919" + integrity sha512-7eoInzzk1sssoD3RMkwFC86U15Ja4ANve+8wIC+xhN4R3Oe3PY3lFbp1GQxCmaJj8b3rtjNKIQZ2zO0PH58afA== + "@tiptap/extension-hard-break@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.0.4.tgz#a4f70fa9a473270f7ec89f20a14b9122af5657bc" @@ -2349,6 +2354,26 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.4.tgz#13286dcf8780c55610ed65b24238b8395a5be824" integrity sha512-Men7LK6N/Dh3/G4/z2Z9WkDHM2Gxx1XyxYix2ZMf5CnqY37SeDNUnGDqit65pdIN3Y/TQnOZTkKSBilSAtXfJA== +"@tiptap/extension-table-cell@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.1.7.tgz#87841144b8368c9611ad46f2134b637e2c33c8bc" + integrity sha512-p3e4FNdbKVIjOLHDcXrRtlP6FYPoN6hBUFjq6QZbf5g4+ao2Uq4bQCL+eKbYMxUVERl8g/Qu9X+jG99fVsBDjA== + +"@tiptap/extension-table-header@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.1.7.tgz#4757834655e2c4edffa65bc6f6807eb59401e0d8" + integrity sha512-rolSUQxFJf/CEj2XBJpeMsLiLHASKrVIzZ2A/AZ9pT6WpFqmECi8r9xyutpJpx21n2Hrk46Y+uGFOKhyvbZ5ug== + +"@tiptap/extension-table-row@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.1.7.tgz#f736a61035b271423ef18f65a25f8d1e240263a1" + integrity sha512-DBCaEMEuCCoOmr4fdDfp2jnmyWPt672rmCZ5WUuenJ47Cy4Ox2dV+qk5vBZ/yDQcq12WvzLMhdSnAo9pMMMa6Q== + +"@tiptap/extension-table@^2.1.6": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.1.7.tgz#c8a83744f60c76ae1e41438b04d5ac9e984afa66" + integrity sha512-nlKs35vTQOFW9lfw76S7kJvqVJAfHUlz1muQgWT0gNUlKJYINMXjUIg4Wcx8LTaITCCkp0lMGrLETGRNI+RyxA== + "@tiptap/extension-task-item@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@tiptap/extension-task-item/-/extension-task-item-2.0.4.tgz#71f46d35ac629ca10c5c23d4ad170007338a436e"