From b078e24d828c68782302a52b111d5ce6900b9445 Mon Sep 17 00:00:00 2001 From: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com> Date: Thu, 3 Aug 2023 00:15:02 +0530 Subject: [PATCH] added bubblemenu support with extensions --- .../components/issues/EditorBubbleMenu.tsx | 116 +++++++++++++++ apps/app/components/issues/extensions.tsx | 127 ++++++++++++++++ apps/app/components/issues/link-selector.tsx | 78 ++++++++++ apps/app/components/issues/node-selector.tsx | 135 ++++++++++++++++++ apps/app/components/issues/props.tsx | 13 +- apps/app/components/issues/tiptap.tsx | 15 +- apps/app/components/issues/utils.ts | 6 + apps/app/package.json | 12 ++ apps/app/styles/editor.css | 91 +++++++++++- 9 files changed, 584 insertions(+), 9 deletions(-) create mode 100644 apps/app/components/issues/EditorBubbleMenu.tsx create mode 100644 apps/app/components/issues/extensions.tsx create mode 100644 apps/app/components/issues/link-selector.tsx create mode 100644 apps/app/components/issues/node-selector.tsx create mode 100644 apps/app/components/issues/utils.ts diff --git a/apps/app/components/issues/EditorBubbleMenu.tsx b/apps/app/components/issues/EditorBubbleMenu.tsx new file mode 100644 index 000000000..7945c4589 --- /dev/null +++ b/apps/app/components/issues/EditorBubbleMenu.tsx @@ -0,0 +1,116 @@ +import { BubbleMenu, BubbleMenuProps } from "@tiptap/react"; +import { FC, useState } from "react"; +import { + BoldIcon, + ItalicIcon, + UnderlineIcon, + StrikethroughIcon, + CodeIcon, +} from "lucide-react"; + +import { NodeSelector } from "./node-selector"; +import { LinkSelector } from "./link-selector"; +import { cn } from "./utils"; + +export interface BubbleMenuItem { + name: string; + isActive: () => boolean; + command: () => void; + icon: typeof BoldIcon; +} + +type EditorBubbleMenuProps = Omit; + +export const EditorBubbleMenu: FC = (props) => { + const items: BubbleMenuItem[] = [ + { + name: "bold", + isActive: () => props.editor.isActive("bold"), + command: () => props.editor.chain().focus().toggleBold().run(), + icon: BoldIcon, + }, + { + name: "italic", + isActive: () => props.editor.isActive("italic"), + command: () => props.editor.chain().focus().toggleItalic().run(), + icon: ItalicIcon, + }, + { + name: "underline", + isActive: () => props.editor.isActive("underline"), + command: () => props.editor.chain().focus().toggleUnderline().run(), + icon: UnderlineIcon, + }, + { + name: "strike", + isActive: () => props.editor.isActive("strike"), + command: () => props.editor.chain().focus().toggleStrike().run(), + icon: StrikethroughIcon, + }, + { + name: "code", + isActive: () => props.editor.isActive("code"), + command: () => props.editor.chain().focus().toggleCode().run(), + icon: CodeIcon, + }, + ]; + + const bubbleMenuProps: EditorBubbleMenuProps = { + ...props, + shouldShow: ({ editor }) => { + if (editor.isActive("image")) { + return false; + } + return editor.view.state.selection.content().size > 0; + }, + tippyOptions: { + moveTransition: "transform 0.15s ease-out", + onHidden: () => { + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }, + }, + }; + + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); + + return ( + + { + setIsNodeSelectorOpen(!isNodeSelectorOpen); + setIsLinkSelectorOpen(false); + }} + /> + { + setIsLinkSelectorOpen(!isLinkSelectorOpen); + setIsNodeSelectorOpen(false); + }} + /> +
+ {items.map((item, index) => ( + + ))} +
+
+ ); +}; diff --git a/apps/app/components/issues/extensions.tsx b/apps/app/components/issues/extensions.tsx new file mode 100644 index 000000000..7e5a2d6a6 --- /dev/null +++ b/apps/app/components/issues/extensions.tsx @@ -0,0 +1,127 @@ +import StarterKit from "@tiptap/starter-kit"; +import HorizontalRule from "@tiptap/extension-horizontal-rule"; +import TiptapLink from "@tiptap/extension-link"; +import TiptapImage from "@tiptap/extension-image"; +import Placeholder from "@tiptap/extension-placeholder"; +import TiptapUnderline from "@tiptap/extension-underline"; +import TextStyle from "@tiptap/extension-text-style"; +import { Color } from "@tiptap/extension-color"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import { Markdown } from "tiptap-markdown"; +import Highlight from "@tiptap/extension-highlight"; + +// import SlashCommand from "./slash-command"; +import { InputRule } from "@tiptap/core"; + +export const TiptapExtensions = [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside leading-3 -mt-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside leading-3 -mt-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "leading-normal -mb-2", + }, + }, + blockquote: { + HTMLAttributes: { + class: "border-l-4 border-stone-700", + }, + }, + codeBlock: { + HTMLAttributes: { + class: + "rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800", + }, + }, + code: { + HTMLAttributes: { + class: + "rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-stone-900", + spellcheck: "false", + }, + }, + horizontalRule: false, + dropcursor: { + color: "#DBEAFE", + width: 4, + }, + gapcursor: false, + }), + HorizontalRule.extend({ + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range }) => { + const attributes = {}; + + const { tr } = state; + const start = range.from; + const end = range.to; + + tr.insert(start - 1, this.type.create(attributes)).delete( + tr.mapping.map(start), + tr.mapping.map(end), + ); + }, + }), + ]; + }, + }).configure({ + HTMLAttributes: { + class: "mt-4 mb-6 border-t border-stone-300", + }, + }), + TiptapLink.configure({ + HTMLAttributes: { + class: + "text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer", + }, + }), + TiptapImage.configure({ + allowBase64: true, + HTMLAttributes: { + class: "rounded-lg border border-stone-200", + }, + }), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + return "Press '/' for commands, or '++' for AI autocomplete..."; + }, + includeChildren: true, + }), + // SlashCommand, + TiptapUnderline, + TextStyle, + Color, + Highlight.configure({ + multicolor: true, + }), + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + Markdown.configure({ + html: false, + transformCopiedText: true, + }), +]; diff --git a/apps/app/components/issues/link-selector.tsx b/apps/app/components/issues/link-selector.tsx new file mode 100644 index 000000000..7bc3ea2e9 --- /dev/null +++ b/apps/app/components/issues/link-selector.tsx @@ -0,0 +1,78 @@ +import { Editor } from "@tiptap/core"; +import { Check, Trash } from "lucide-react"; +import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react"; +import { cn } from './utils'; + +interface LinkSelectorProps { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export const LinkSelector: FC = ({ + editor, + isOpen, + setIsOpen, +}) => { + const inputRef = useRef(null); + + // Autofocus on input by default + useEffect(() => { + inputRef.current && inputRef.current?.focus(); + }); + + return ( +
+ + {isOpen && ( +
{ + e.preventDefault(); + const input = e.target[0] as HTMLInputElement; + editor.chain().focus().setLink({ href: input.value }).run(); + setIsOpen(false); + }} + className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-stone-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-top-1" + > + + {editor.getAttributes("link").href ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; + diff --git a/apps/app/components/issues/node-selector.tsx b/apps/app/components/issues/node-selector.tsx new file mode 100644 index 000000000..4591e8707 --- /dev/null +++ b/apps/app/components/issues/node-selector.tsx @@ -0,0 +1,135 @@ +import { Editor } from "@tiptap/core"; +import { + Check, + ChevronDown, + Heading1, + Heading2, + Heading3, + TextQuote, + ListOrdered, + TextIcon, + Code, + CheckSquare, +} from "lucide-react"; +import { Dispatch, FC, SetStateAction } from "react"; + +import { BubbleMenuItem } from "./EditorBubbleMenu"; + +interface NodeSelectorProps { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +} + +export const NodeSelector: FC = ({ + editor, + isOpen, + setIsOpen, +}) => { + const items: BubbleMenuItem[] = [ + { + name: "Text", + icon: TextIcon, + command: () => + editor.chain().focus().toggleNode("paragraph", "paragraph").run(), + // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! + isActive: () => + editor.isActive("paragraph") && + !editor.isActive("bulletList") && + !editor.isActive("orderedList"), + }, + { + name: "Heading 1", + icon: Heading1, + command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive("heading", { level: 1 }), + }, + { + name: "Heading 2", + icon: Heading2, + command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + isActive: () => editor.isActive("heading", { level: 2 }), + }, + { + name: "Heading 3", + icon: Heading3, + command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), + isActive: () => editor.isActive("heading", { level: 3 }), + }, + { + name: "To-do List", + icon: CheckSquare, + command: () => editor.chain().focus().toggleTaskList().run(), + isActive: () => editor.isActive("taskItem"), + }, + { + name: "Bullet List", + icon: ListOrdered, + command: () => editor.chain().focus().toggleBulletList().run(), + isActive: () => editor.isActive("bulletList"), + }, + { + name: "Numbered List", + icon: ListOrdered, + command: () => editor.chain().focus().toggleOrderedList().run(), + isActive: () => editor.isActive("orderedList"), + }, + { + name: "Quote", + icon: TextQuote, + command: () => + editor + .chain() + .focus() + .toggleNode("paragraph", "paragraph") + .toggleBlockquote() + .run(), + isActive: () => editor.isActive("blockquote"), + }, + { + name: "Code", + icon: Code, + command: () => editor.chain().focus().toggleCodeBlock().run(), + isActive: () => editor.isActive("codeBlock"), + }, + ]; + + const activeItem = items.filter((item) => item.isActive()).pop() ?? { + name: "Multiple", + }; + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/app/components/issues/props.tsx b/apps/app/components/issues/props.tsx index 54306429c..6470b37c1 100644 --- a/apps/app/components/issues/props.tsx +++ b/apps/app/components/issues/props.tsx @@ -3,5 +3,16 @@ import { EditorProps } from "@tiptap/pm/view"; export const TiptapEditorProps: EditorProps = { attributes: { class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, - } + }, + handleDOMEvents: { + keydown: (_view, event) => { + // prevent default event listeners from firing when slash command is active + if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { + const slashCommand = document.querySelector("#slash-command"); + if (slashCommand) { + return true; + } + } + }, + }, }; diff --git a/apps/app/components/issues/tiptap.tsx b/apps/app/components/issues/tiptap.tsx index d3f554d4c..9e298ef3c 100644 --- a/apps/app/components/issues/tiptap.tsx +++ b/apps/app/components/issues/tiptap.tsx @@ -2,6 +2,7 @@ import Placeholder from '@tiptap/extension-placeholder'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { EditorBubbleMenu } from './EditorBubbleMenu'; +import { TiptapExtensions } from './extensions'; import { TiptapEditorProps } from "./props"; type TiptapProps = { @@ -14,12 +15,13 @@ type TiptapProps = { const Tiptap = ({ value, noBorder, borderOnFocus, customClassName }: TiptapProps) => { const editor = useEditor({ editorProps: TiptapEditorProps, - extensions: [ - StarterKit, - Placeholder.configure({ - placeholder: 'Description...', - }) - ], + extensions: TiptapExtensions, + // extensions: [ + // StarterKit, + // Placeholder.configure({ + // placeholder: 'Description...', + // }) + // ], content: value, }); @@ -35,6 +37,7 @@ const Tiptap = ({ value, noBorder, borderOnFocus, customClassName }: TiptapProps }} className={`tiptap-editor-container relative min-h-[150px] ${editorClassNames}`} > + {editor && } ); diff --git a/apps/app/components/issues/utils.ts b/apps/app/components/issues/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/apps/app/components/issues/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/app/package.json b/apps/app/package.json index 8c8c0d1f7..eb8bbaae0 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -27,17 +27,27 @@ "@nivo/scatterplot": "0.80.0", "@sentry/nextjs": "^7.36.0", "@tailwindcss/typography": "^0.5.9", + "@tiptap/extension-color": "^2.0.4", + "@tiptap/extension-highlight": "^2.0.4", + "@tiptap/extension-image": "^2.0.4", + "@tiptap/extension-link": "^2.0.4", "@tiptap/extension-placeholder": "^2.0.4", + "@tiptap/extension-task-item": "^2.0.4", + "@tiptap/extension-task-list": "^2.0.4", + "@tiptap/extension-text-style": "^2.0.4", + "@tiptap/extension-underline": "^2.0.4", "@tiptap/pm": "^2.0.4", "@tiptap/react": "^2.0.4", "@tiptap/starter-kit": "^2.0.4", "@types/lodash.debounce": "^4.0.7", "@types/react-datepicker": "^4.8.0", "axios": "^1.1.3", + "clsx": "^2.0.0", "cmdk": "^0.2.0", "dotenv": "^16.0.3", "js-cookie": "^3.0.1", "lodash.debounce": "^4.0.8", + "lucide-react": "^0.263.1", "next": "12.3.2", "next-pwa": "^5.6.0", "next-themes": "^0.2.1", @@ -51,6 +61,8 @@ "react-hook-form": "^7.38.0", "react-markdown": "^8.0.7", "swr": "^2.1.3", + "tailwind-merge": "^1.14.0", + "tiptap-markdown": "^0.8.2", "tlds": "^1.238.0", "uuid": "^9.0.0" }, diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index 293a017b2..6f45ecc2a 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -9,13 +9,100 @@ } .ProseMirror p.is-editor-empty:first-child::before { - color: rgb(var(--color-text-400)); content: attr(data-placeholder); float: left; - height: 0; + color: rgb(var(--color-text-400)); pointer-events: none; + height: 0; } +.ProseMirror .is-empty::before { + content: attr(data-placeholder); + float: left; + color: rgb(var(--color-text-400)); + pointer-events: none; + height: 0; +} + +/* Custom image styles */ + +.ProseMirror img { + transition: filter 0.1s ease-in-out; + + &:hover { + cursor: pointer; + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + outline: 3px solid #5abbf7; + filter: brightness(90%); + } +} + +/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ + +ul[data-type="taskList"] li > label { + margin-right: 0.2rem; + user-select: none; +} + +@media screen and (max-width: 768px) { + ul[data-type="taskList"] li > label { + margin-right: 0.5rem; + } +} + +ul[data-type="taskList"] li > label input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + background-color: var(--novel-white); + margin: 0; + cursor: pointer; + width: 1.2em; + height: 1.2em; + position: relative; + top: 5px; + border: 2px solid var(--novel-stone-900); + margin-right: 0.3rem; + display: grid; + place-content: center; + + &:hover { + background-color: var(--novel-stone-50); + } + + &:active { + background-color: var(--novel-stone-200); + } + + &::before { + content: ""; + width: 0.65em; + height: 0.65em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em; + transform-origin: center; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + + &:checked::before { + transform: scale(1); + } +} + +ul[data-type="taskList"] li[data-checked="true"] > div > p { + color: var(--novel-stone-400); + text-decoration: line-through; + text-decoration-thickness: 2px; +} + +/* Overwrite tippy-box original max-width */ + +.tippy-box { + max-width: 400px !important; +} .ProseMirror { position: relative; word-wrap: break-word;