mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[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:
parent
320608ea73
commit
38b7f4382f
@ -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}
|
||||
|
@ -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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -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
|
||||
];
|
||||
|
31
apps/app/components/tiptap/extensions/table/table-cell.ts
Normal file
31
apps/app/components/tiptap/extensions/table/table-cell.ts
Normal 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];
|
||||
},
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
|
||||
|
||||
const TableHeader = BaseTableHeader.extend({
|
||||
content: "paragraph"
|
||||
});
|
||||
|
||||
export { TableHeader };
|
9
apps/app/components/tiptap/extensions/table/table.ts
Normal file
9
apps/app/components/tiptap/extensions/table/table.ts
Normal 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 };
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 = {};
|
||||
|
@ -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 &&
|
||||
|
@ -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.",
|
||||
|
96
apps/app/components/tiptap/table-menu/index.tsx
Normal file
96
apps/app/components/tiptap/table-menu/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
25
yarn.lock
25
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"
|
||||
|
Loading…
Reference in New Issue
Block a user