diff --git a/.gitignore b/.gitignore index 921881df4..1e99e102a 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,6 @@ package-lock.json # lock files package-lock.json pnpm-lock.yaml -pnpm-workspace.yaml \ No newline at end of file +pnpm-workspace.yaml + +.npmrc diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index be54c8f75..e87ed84d8 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -53,7 +53,7 @@ export const CommandPalette: React.FC = () => { workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? () => - issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string) + issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string) : null ); @@ -78,24 +78,18 @@ export const CommandPalette: React.FC = () => { const handleKeyDown = useCallback( (e: KeyboardEvent) => { + const { key, ctrlKey, metaKey, altKey, shiftKey } = e; + if (!key) return; + const keyPressed = key.toLowerCase(); + const cmdClicked = ctrlKey || metaKey; // if on input, textarea or editor, don't do anything if ( - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLInputElement || - (e.target as Element).classList?.contains("remirror-editor") - ) - return; - - const { key, ctrlKey, metaKey, altKey, shiftKey } = e; - - if (!key) return; - - const keyPressed = key.toLowerCase(); - - const cmdClicked = ctrlKey || metaKey; - - if (cmdClicked) { - if (keyPressed === "k") { + !(e.target instanceof HTMLTextAreaElement) && + !(e.target instanceof HTMLInputElement) && + // !(e.target as Element).classList?.contains("remirror-editor") && + (e.target === document || (e.target instanceof Element && !e.target.closest(".tiptap-editor-container"))) + ) { + if ((ctrlKey || metaKey) && keyPressed === "k") { e.preventDefault(); setIsPaletteOpen(true); } else if (keyPressed === "c" && altKey) { diff --git a/apps/app/components/core/modals/gpt-assistant-modal.tsx b/apps/app/components/core/modals/gpt-assistant-modal.tsx index 7c05e036a..b9bf09ace 100644 --- a/apps/app/components/core/modals/gpt-assistant-modal.tsx +++ b/apps/app/components/core/modals/gpt-assistant-modal.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState, forwardRef, useRef } from "react"; +import React, { useEffect, useState, forwardRef, useRef } from "react"; import { useRouter } from "next/router"; -import dynamic from "next/dynamic"; // react-hook-form import { useForm } from "react-hook-form"; @@ -15,6 +14,7 @@ import useUserAuth from "hooks/use-user-auth"; import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { IIssue, IPageBlock } from "types"; +import Tiptap, { ITiptapRichTextEditor } from "components/tiptap"; type Props = { isOpen: boolean; handleClose: () => void; @@ -32,17 +32,12 @@ type FormData = { task: string; }; -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { - ssr: false, -}); +const TiptapEditor = React.forwardRef< + ITiptapRichTextEditor, + ITiptapRichTextEditor +>((props, ref) => ); -import { IRemirrorRichTextEditor } from "components/rich-text-editor"; - -const WrappedRemirrorRichTextEditor = forwardRef( - (props, ref) => -); - -WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; +TiptapEditor.displayName = "TiptapEditor"; export const GptAssistantModal: React.FC = ({ isOpen, @@ -146,15 +141,14 @@ export const GptAssistantModal: React.FC = ({ return (
{((content && content !== "") || (htmlContent && htmlContent !== "

")) && (
Content: - {content}

} + ${content}

`} customClassName="-m-3" noBorder borderOnFocus={false} @@ -166,7 +160,7 @@ export const GptAssistantModal: React.FC = ({ {response !== "" && (
Response: - ${response}

`} customClassName="-mx-3 -my-3" noBorder @@ -185,11 +179,10 @@ export const GptAssistantModal: React.FC = ({ type="text" name="task" register={register} - placeholder={`${ - content && content !== "" - ? "Tell AI what action to perform on this content..." - : "Ask AI anything..." - }`} + placeholder={`${content && content !== "" + ? "Tell AI what action to perform on this content..." + : "Ask AI anything..." + }`} autoComplete="off" />
@@ -225,8 +218,8 @@ export const GptAssistantModal: React.FC = ({ {isSubmitting ? "Generating response..." : response === "" - ? "Generate response" - : "Generate again"} + ? "Generate response" + : "Generate again"}
diff --git a/apps/app/components/issues/comment/add-comment.tsx b/apps/app/components/issues/comment/add-comment.tsx index b7504d932..87fb745a7 100644 --- a/apps/app/components/issues/comment/add-comment.tsx +++ b/apps/app/components/issues/comment/add-comment.tsx @@ -1,7 +1,6 @@ import React from "react"; import { useRouter } from "next/router"; -import dynamic from "next/dynamic"; import { mutate } from "swr"; @@ -12,28 +11,19 @@ import issuesServices from "services/issues.service"; // hooks import useToast from "hooks/use-toast"; // ui -import { Loader, SecondaryButton } from "components/ui"; +import { SecondaryButton } from "components/ui"; // types import type { ICurrentUserResponse, IIssueComment } from "types"; // fetch-keys import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import Tiptap, { ITiptapRichTextEditor } from "components/tiptap"; -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { - ssr: false, - loading: () => ( - - - - ), -}); -import { IRemirrorRichTextEditor } from "components/rich-text-editor"; +const TiptapEditor = React.forwardRef< + ITiptapRichTextEditor, + ITiptapRichTextEditor +>((props, ref) => ); -const WrappedRemirrorRichTextEditor = React.forwardRef< - IRemirrorRichTextEditor, - IRemirrorRichTextEditor ->((props, ref) => ); - -WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; +TiptapEditor.displayName = "TiptapEditor"; const defaultValues: Partial = { comment_json: "", @@ -51,6 +41,7 @@ export const AddComment: React.FC = ({ issueId, user, disabled = false }) handleSubmit, control, setValue, + watch, formState: { isSubmitting }, reset, } = useForm({ defaultValues }); @@ -99,17 +90,24 @@ export const AddComment: React.FC = ({ issueId, user, disabled = false })
( - setValue("comment_json", jsonValue)} - onHTMLChange={(htmlValue) => setValue("comment_html", htmlValue)} - placeholder="Enter your comment..." + render={({ field: { value, onChange } }) => + { + onChange(comment_html); + setValue("comment_json", comment_json); + }} /> - )} + } /> diff --git a/apps/app/components/issues/comment/comment-card.tsx b/apps/app/components/issues/comment/comment-card.tsx index 6805c377a..5a340609a 100644 --- a/apps/app/components/issues/comment/comment-card.tsx +++ b/apps/app/components/issues/comment/comment-card.tsx @@ -1,7 +1,5 @@ import React, { useEffect, useState } from "react"; -import dynamic from "next/dynamic"; - // react-hook-form import { useForm } from "react-hook-form"; // icons @@ -15,17 +13,14 @@ import { CommentReaction } from "components/issues"; import { timeAgo } from "helpers/date-time.helper"; // types import type { IIssueComment } from "types"; +import Tiptap, { ITiptapRichTextEditor } from "components/tiptap"; -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { ssr: false }); +const TiptapEditor = React.forwardRef< + ITiptapRichTextEditor, + ITiptapRichTextEditor +>((props, ref) => ); -import { IRemirrorRichTextEditor } from "components/rich-text-editor"; - -const WrappedRemirrorRichTextEditor = React.forwardRef< - IRemirrorRichTextEditor, - IRemirrorRichTextEditor ->((props, ref) => ); - -WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; +TiptapEditor.displayName = "TiptapEditor"; type Props = { comment: IIssueComment; @@ -45,6 +40,7 @@ export const CommentCard: React.FC = ({ comment, onSubmit, handleCommentD formState: { isSubmitting }, handleSubmit, setFocus, + watch, setValue, } = useForm({ defaultValues: comment, @@ -55,9 +51,10 @@ export const CommentCard: React.FC = ({ comment, onSubmit, handleCommentD setIsEditing(false); onSubmit(formData); + console.log("watching", formData.comment_html) - editorRef.current?.setEditorValue(formData.comment_json); - showEditorRef.current?.setEditorValue(formData.comment_json); + editorRef.current?.setEditorValue(formData.comment_html); + showEditorRef.current?.setEditorValue(formData.comment_html); }; useEffect(() => { @@ -106,14 +103,15 @@ export const CommentCard: React.FC = ({ comment, onSubmit, handleCommentD className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`} onSubmit={handleSubmit(onEnter)} > - { - setValue("comment_json", jsonValue); - setValue("comment_html", htmlValue); - }} - placeholder="Enter Your comment..." + { + setValue("comment_json", comment_json); + setValue("comment_html", comment_html); + }} />
- -
diff --git a/apps/app/components/issues/description-form.tsx b/apps/app/components/issues/description-form.tsx index e81c8c1b3..1eb02915f 100644 --- a/apps/app/components/issues/description-form.tsx +++ b/apps/app/components/issues/description-form.tsx @@ -1,23 +1,15 @@ import { FC, useCallback, useEffect, useState } from "react"; -import dynamic from "next/dynamic"; - // react-hook-form import { Controller, useForm } from "react-hook-form"; // hooks import useReloadConfirmations from "hooks/use-reload-confirmation"; // components -import { Loader, TextArea } from "components/ui"; -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { - ssr: false, - loading: () => ( - - - - ), -}); +import { TextArea } from "components/ui"; + // types import { IIssue } from "types"; +import Tiptap from "components/tiptap"; export interface IssueDescriptionFormValues { name: string; @@ -63,7 +55,8 @@ export const IssueDescriptionForm: FC = ({ const handleDescriptionFormSubmit = useCallback( async (formData: Partial) => { - if (!formData.name || formData.name.length === 0 || formData.name.length > 255) return; + console.log("formdata", formData) + if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; await handleFormSubmit({ name: formData.name ?? "", @@ -106,9 +99,8 @@ export const IssueDescriptionForm: FC = ({ {characterLimit && (
255 ? "text-red-500" : "" - }`} + className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : "" + }`} > {watch("name").length} @@ -119,45 +111,35 @@ export const IssueDescriptionForm: FC = ({ {errors.name ? errors.name.message : null}
{ + render={({ field: { value, onChange } }) => { if (!value && !watch("description_html")) return <>; return ( - { - setShowAlert(true); - setValue("description", jsonValue); - }} - onHTMLChange={(htmlValue) => { - setShowAlert(true); - setValue("description_html", htmlValue); - }} - onBlur={() => { + debouncedUpdatesEnabled={true} + setIsSubmitting={setIsSubmitting} + customClassName="min-h-[150px]" + editorContentCustomClassNames="pt-9" + onChange={(description: Object, description_html: string) => { setIsSubmitting(true); - handleSubmit(handleDescriptionFormSubmit)() - .then(() => setShowAlert(false)) - .finally(() => setIsSubmitting(false)); + onChange(description_html); + setValue("description", description); + handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false)); }} - placeholder="Description" - editable={isAllowed} /> ); }} /> - {isSubmitting && ( -
- Saving... -
- )} +
+ {isSubmitting ? "Saving..." : "Saved"} +
); diff --git a/apps/app/components/issues/form.tsx b/apps/app/components/issues/form.tsx index 63c6ad964..7fda6f7cf 100644 --- a/apps/app/components/issues/form.tsx +++ b/apps/app/components/issues/form.tsx @@ -1,6 +1,5 @@ import React, { FC, useState, useEffect, useRef } from "react"; -import dynamic from "next/dynamic"; import { useRouter } from "next/router"; // react-hook-form @@ -36,24 +35,8 @@ import { import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline"; // types import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types"; +import Tiptap from "components/tiptap"; // rich-text-editor -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { - ssr: false, - loading: () => ( - - - - ), -}); - -import { IRemirrorRichTextEditor } from "components/rich-text-editor"; - -const WrappedRemirrorRichTextEditor = React.forwardRef< - IRemirrorRichTextEditor, - IRemirrorRichTextEditor ->((props, ref) => ); - -WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; const defaultValues: Partial = { project: "", @@ -147,6 +130,8 @@ export const IssueForm: FC = ({ reValidateMode: "onChange", }); + console.log("values", getValues()); + const issueName = watch("name"); const handleCreateUpdateIssue = async (formData: Partial) => { @@ -349,9 +334,8 @@ export const IssueForm: FC = ({ {issueName && issueName !== "" && (
( - setValue("description", jsonValue)} - onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} - placeholder="Description" - ref={editorRef} - /> - )} + render={({ field: { value, onChange } }) => { + if (!value && !watch("description_html")) return <>; + + return ( + { + onChange(description_html); + setValue("description", description); + }} + /> + ); + }} /> = ({ onClick={() => setCreateMore((prevData) => !prevData)} > Create more - {}} size="md" /> + { }} size="md" />
Discard @@ -565,8 +555,8 @@ export const IssueForm: FC = ({ ? "Updating Issue..." : "Update Issue" : isSubmitting - ? "Adding Issue..." - : "Add Issue"} + ? "Adding Issue..." + : "Add Issue"}
diff --git a/apps/app/components/issues/main-content.tsx b/apps/app/components/issues/main-content.tsx index 316d39e8a..d2ff8a1ec 100644 --- a/apps/app/components/issues/main-content.tsx +++ b/apps/app/components/issues/main-content.tsx @@ -50,11 +50,11 @@ export const IssueMainContent: React.FC = ({ workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, workspaceSlug && projectId && issueDetails?.parent ? () => - issuesService.subIssues( - workspaceSlug as string, - projectId as string, - issueDetails.parent ?? "" - ) + issuesService.subIssues( + workspaceSlug as string, + projectId as string, + issueDetails.parent ?? "" + ) : null ); const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id); @@ -97,9 +97,8 @@ export const IssueMainContent: React.FC = ({ diff --git a/apps/app/components/pages/create-update-block-inline.tsx b/apps/app/components/pages/create-update-block-inline.tsx index 5e2dcb43c..da7384921 100644 --- a/apps/app/components/pages/create-update-block-inline.tsx +++ b/apps/app/components/pages/create-update-block-inline.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; -import dynamic from "next/dynamic"; import { mutate } from "swr"; @@ -18,11 +17,12 @@ import useToast from "hooks/use-toast"; // components import { GptAssistantModal } from "components/core"; // ui -import { Loader, PrimaryButton, SecondaryButton, TextArea } from "components/ui"; +import { PrimaryButton, SecondaryButton, TextArea } from "components/ui"; // types import { ICurrentUserResponse, IPageBlock } from "types"; // fetch-keys import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; +import Tiptap, { ITiptapRichTextEditor } from "components/tiptap"; type Props = { handleClose: () => void; @@ -39,22 +39,12 @@ const defaultValues = { description_html: null, }; -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { - ssr: false, - loading: () => ( - - - - ), -}); -import { IRemirrorRichTextEditor } from "components/rich-text-editor"; +const TiptapEditor = React.forwardRef< + ITiptapRichTextEditor, + ITiptapRichTextEditor +>((props, ref) => ); -const WrappedRemirrorRichTextEditor = React.forwardRef< - IRemirrorRichTextEditor, - IRemirrorRichTextEditor ->((props, ref) => ); - -WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; +TiptapEditor.displayName = "TiptapEditor"; export const CreateUpdateBlockInline: React.FC = ({ handleClose, @@ -242,9 +232,9 @@ export const CreateUpdateBlockInline: React.FC = ({ description: !data.description || data.description === "" ? { - type: "doc", - content: [{ type: "paragraph" }], - } + type: "doc", + content: [{ type: "paragraph" }], + } : data.description, description_html: data.description_html ?? "

", }); @@ -297,23 +287,22 @@ export const CreateUpdateBlockInline: React.FC = ({
{ + render={({ field: { value, onChange } }) => { if (!data) return ( - setValue("description", jsonValue)} - onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} - placeholder="Write something..." +

"} + debouncedUpdatesEnabled={false} customClassName="text-sm" noBorder borderOnFocus={false} - ref={editorRef} + onChange={(description: Object, description_html: string) => { + onChange(description_html); + setValue("description", description); + }} /> ); else if (!value || !watch("description_html")) @@ -322,21 +311,23 @@ export const CreateUpdateBlockInline: React.FC = ({ ); return ( - 0 ? value : watch("description_html") && watch("description_html") !== "" - ? watch("description_html") - : { type: "doc", content: [{ type: "paragraph" }] } + ? watch("description_html") + : { type: "doc", content: [{ type: "paragraph" }] } } - onJSONChange={(jsonValue) => setValue("description", jsonValue)} - onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} - placeholder="Write something..." + debouncedUpdatesEnabled={false} customClassName="text-sm" noBorder borderOnFocus={false} - ref={editorRef} + onChange={(description: Object, description_html: string) => { + onChange(description_html); + setValue("description", description); + }} /> ); }} @@ -344,9 +335,8 @@ export const CreateUpdateBlockInline: React.FC = ({
diff --git a/apps/app/components/pages/page-form.tsx b/apps/app/components/pages/page-form.tsx index b23daee18..c4c669e1e 100644 --- a/apps/app/components/pages/page-form.tsx +++ b/apps/app/components/pages/page-form.tsx @@ -1,7 +1,5 @@ import { useEffect } from "react"; -import dynamic from "next/dynamic"; - // react-hook-form import { useForm } from "react-hook-form"; // ui @@ -16,16 +14,6 @@ type Props = { data?: IPage | null; }; -// rich-text-editor -const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { - ssr: false, - loading: () => ( - - - - ), -}); - const defaultValues = { name: "", description: "", diff --git a/apps/app/components/pages/single-page-block.tsx b/apps/app/components/pages/single-page-block.tsx index c518e1789..8a13e3ea1 100644 --- a/apps/app/components/pages/single-page-block.tsx +++ b/apps/app/components/pages/single-page-block.tsx @@ -19,7 +19,6 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { GptAssistantModal } from "components/core"; import { CreateUpdateBlockInline } from "components/pages"; -import RemirrorRichTextEditor, { IRemirrorRichTextEditor } from "components/rich-text-editor"; // ui import { CustomMenu, TextArea } from "components/ui"; // icons @@ -39,6 +38,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; import { ICurrentUserResponse, IIssue, IPageBlock, IProject } from "types"; // fetch-keys import { PAGE_BLOCKS_LIST } from "constants/fetch-keys"; +import Tiptap, { ITiptapRichTextEditor } from "components/tiptap"; type Props = { block: IPageBlock; @@ -48,12 +48,12 @@ type Props = { user: ICurrentUserResponse | undefined; }; -const WrappedRemirrorRichTextEditor = React.forwardRef< - IRemirrorRichTextEditor, - IRemirrorRichTextEditor ->((props, ref) => ); +const TiptapEditor = React.forwardRef< + ITiptapRichTextEditor, + ITiptapRichTextEditor +>((props, ref) => ); -WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; +TiptapEditor.displayName = "TiptapEditor"; export const SinglePageBlock: React.FC = ({ block, @@ -328,9 +328,8 @@ export const SinglePageBlock: React.FC = ({
) : (
@@ -344,9 +343,8 @@ export const SinglePageBlock: React.FC = ({ void; - onJSONChange?: (jsonValue: any) => void; - onHTMLChange?: (htmlValue: any) => void; - value?: any; - showToolbar?: boolean; - editable?: boolean; - customClassName?: string; - gptOption?: boolean; - noBorder?: boolean; - borderOnFocus?: boolean; - forwardedRef?: any; -} - -const RemirrorRichTextEditor: React.FC = (props) => { - const { - placeholder, - mentions = [], - tags = [], - onBlur = () => {}, - onJSONChange = () => {}, - onHTMLChange = () => {}, - value = "", - showToolbar = true, - editable = true, - customClassName, - gptOption = false, - noBorder = false, - borderOnFocus = true, - forwardedRef, - } = props; - - const [disableToolbar, setDisableToolbar] = useState(false); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - // remirror error handler - const onError: InvalidContentHandler = useCallback( - ({ json, invalidContent, transformers }: any) => - // Automatically remove all invalid nodes and marks. - transformers.remove(json, invalidContent), - [] - ); - - const uploadImageHandler = (value: any): any => { - try { - const formData = new FormData(); - formData.append("asset", value[0].file); - formData.append("attributes", JSON.stringify({})); - - return [ - () => - new Promise(async (resolve, reject) => { - const imageUrl = await fileService - .uploadFile(workspaceSlug as string, formData) - .then((response) => response.asset); - - resolve({ - align: "left", - alt: "Not Found", - height: "100%", - width: "35%", - src: imageUrl, - }); - }), - ]; - } catch { - return []; - } - }; - - // remirror manager - const { manager, state } = useRemirror({ - extensions: () => [ - new BoldExtension(), - new ItalicExtension(), - new UnderlineExtension(), - new HeadingExtension({ levels: [1, 2, 3] }), - new FontSizeExtension({ defaultSize: "16", unit: "px" }), - new OrderedListExtension(), - new ListItemExtension(), - new BulletListExtension({ enableSpine: true }), - new CalloutExtension({ defaultType: "warn" }), - new CodeBlockExtension(), - new CodeExtension(), - new PlaceholderExtension({ - placeholder: placeholder || "Enter text...", - emptyNodeClass: "empty-node", - }), - new HistoryExtension(), - new LinkExtension({ - autoLink: true, - autoLinkAllowedTLDs: tlds, - selectTextOnClick: true, - defaultTarget: "_blank", - }), - new ImageExtension({ - enableResizing: true, - uploadHandler: uploadImageHandler, - createPlaceholder() { - const div = document.createElement("div"); - div.className = - "w-[35%] aspect-video bg-custom-background-80 text-custom-text-200 animate-pulse"; - return div; - }, - }), - new DropCursorExtension(), - new StrikeExtension(), - new MentionAtomExtension({ - matchers: [ - { name: "at", char: "@" }, - { name: "tag", char: "#" }, - ], - }), - new TableExtension(), - ], - content: value, - selection: "start", - stringHandler: "html", - onError, - }); - - useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - manager.view.updateState(manager.createState({ content: "", selection: "start" })); - }, - setEditorValue: (value: any) => { - manager.view.updateState( - manager.createState({ - content: value, - selection: "end", - }) - ); - }, - })); - - return ( -
- { - const html = event.helpers.getHTML(); - const json = event.helpers.getJSON(); - - setDisableToolbar(true); - - onBlur(json, html); - }} - onFocus={() => setDisableToolbar(false)} - > -
- -
- - {editable && !disableToolbar && ( - - - - - - )} - - - {} - {} -
-
- ); -}; - -RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor"; - -export default RemirrorRichTextEditor; diff --git a/apps/app/components/rich-text-editor/mention-autocomplete.tsx b/apps/app/components/rich-text-editor/mention-autocomplete.tsx deleted file mode 100644 index b0ba6955e..000000000 --- a/apps/app/components/rich-text-editor/mention-autocomplete.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState, useEffect, FC } from "react"; -// remirror imports -import { cx } from "@remirror/core"; -import { useMentionAtom, MentionAtomNodeAttributes, FloatingWrapper } from "@remirror/react"; - -// export const; - -export interface IMentionAutoComplete { - mentions?: any[]; - tags?: any[]; -} - -export const MentionAutoComplete: FC = (props) => { - const { mentions = [], tags = [] } = props; - // states - const [options, setOptions] = useState([]); - - const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = useMentionAtom({ - items: options, - }); - - useEffect(() => { - if (!state) { - return; - } - const searchTerm = state.query.full.toLowerCase(); - let filteredOptions: MentionAtomNodeAttributes[] = []; - - if (state.name === "tag") { - filteredOptions = tags.filter((tag) => tag?.label.toLowerCase().includes(searchTerm)); - } else if (state.name === "at") { - filteredOptions = mentions.filter((user) => user?.label.toLowerCase().includes(searchTerm)); - } - - filteredOptions = filteredOptions.sort().slice(0, 5); - setOptions(filteredOptions); - }, [state, mentions, tags]); - - const enabled = Boolean(state); - return ( - -
- {enabled && - options.map((user, index) => { - const isHighlighted = indexIsSelected(index); - const isHovered = indexIsHovered(index); - - return ( -
- {user.label} -
- ); - })} -
-
- ); -}; diff --git a/apps/app/components/rich-text-editor/sample.tsx b/apps/app/components/rich-text-editor/sample.tsx deleted file mode 100644 index f4b7c84fe..000000000 --- a/apps/app/components/rich-text-editor/sample.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { TableExtension } from "@remirror/extension-react-tables"; -import { - EditorComponent, - ReactComponentExtension, - Remirror, - TableComponents, - tableControllerPluginKey, - ThemeProvider, - useCommands, - useRemirror, - useRemirrorContext, -} from "@remirror/react"; -import type { AnyExtension } from "remirror"; - -const CommandMenu: React.FC = () => { - const { createTable, ...commands } = useCommands(); - - return ( -
-

commands:

-

- - - - - - - - -

-
- ); -}; - -const ProsemirrorDocData: React.FC = () => { - const ctx = useRemirrorContext({ autoUpdate: false }); - const [jsonPluginState, setJsonPluginState] = useState(""); - const [jsonDoc, setJsonDoc] = useState(""); - const { addHandler, view } = ctx; - - useEffect(() => { - addHandler("updated", () => { - setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2)); - - const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values; - setJsonPluginState( - JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2) - ); - }); - }, [addHandler, view]); - - return ( -
-

tableControllerPluginKey.getState(view.state)

-
-        {jsonPluginState}
-      
-

view.state.doc.toJSON()

-
-        {jsonDoc}
-      
-
- ); -}; - -const Table = ({ - children, - extensions, -}: { - children?: React.ReactElement; - extensions: () => AnyExtension[]; -}): JSX.Element => { - const { manager, state } = useRemirror({ extensions }); - - return ( - - - - - - - {children} - - - ); -}; - -const Basic = (): JSX.Element => ; - -const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()]; - -export default Basic; diff --git a/apps/app/components/rich-text-editor/toolbar/float-tool-tip.tsx b/apps/app/components/rich-text-editor/toolbar/float-tool-tip.tsx deleted file mode 100644 index 9fd825807..000000000 --- a/apps/app/components/rich-text-editor/toolbar/float-tool-tip.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import React, { - ChangeEvent, - HTMLProps, - KeyboardEvent, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions"; -// buttons -import { - ToggleBoldButton, - ToggleItalicButton, - ToggleUnderlineButton, - ToggleStrikeButton, - ToggleOrderedListButton, - ToggleBulletListButton, - ToggleCodeButton, - ToggleHeadingButton, - useActive, - CommandButton, - useAttrs, - useChainedCommands, - useCurrentSelection, - useExtensionEvent, - useUpdateReason, -} from "@remirror/react"; -import { EditorState } from "remirror"; - -type Props = { - gptOption?: boolean; - editorState: Readonly; - setDisableToolbar: React.Dispatch>; -}; - -const useLinkShortcut = () => { - const [linkShortcut, setLinkShortcut] = useState(); - const [isEditing, setIsEditing] = useState(false); - - useExtensionEvent( - LinkExtension, - "onShortcut", - useCallback( - (props) => { - if (!isEditing) { - setIsEditing(true); - } - - return setLinkShortcut(props); - }, - [isEditing] - ) - ); - - return { linkShortcut, isEditing, setIsEditing }; -}; - -const useFloatingLinkState = () => { - const chain = useChainedCommands(); - const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut(); - const { to, empty } = useCurrentSelection(); - - const url = (useAttrs().link()?.href as string) ?? ""; - const [href, setHref] = useState(url); - - // A positioner which only shows for links. - const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []); - - const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]); - - const updateReason = useUpdateReason(); - - useLayoutEffect(() => { - if (!isEditing) { - return; - } - - if (updateReason.doc || updateReason.selection) { - setIsEditing(false); - } - }, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]); - - useEffect(() => { - setHref(url); - }, [url]); - - const submitHref = useCallback(() => { - setIsEditing(false); - const range = linkShortcut ?? undefined; - - if (href === "") { - chain.removeLink(); - } else { - chain.updateLink({ href, auto: false }, range); - } - - chain.focus(range?.to ?? to).run(); - }, [setIsEditing, linkShortcut, chain, href, to]); - - const cancelHref = useCallback(() => { - setIsEditing(false); - }, [setIsEditing]); - - const clickEdit = useCallback(() => { - if (empty) { - chain.selectLink(); - } - - setIsEditing(true); - }, [chain, empty, setIsEditing]); - - return useMemo( - () => ({ - href, - setHref, - linkShortcut, - linkPositioner, - isEditing, - setIsEditing, - clickEdit, - onRemove, - submitHref, - cancelHref, - }), - [ - href, - linkShortcut, - linkPositioner, - isEditing, - clickEdit, - onRemove, - submitHref, - cancelHref, - setIsEditing, - ] - ); -}; - -const DelayAutoFocusInput = ({ - autoFocus, - setDisableToolbar, - ...rest -}: HTMLProps & { - setDisableToolbar: React.Dispatch>; -}) => { - const inputRef = useRef(null); - - useEffect(() => { - if (!autoFocus) { - return; - } - - setDisableToolbar(false); - - const frame = window.requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - - return () => { - window.cancelAnimationFrame(frame); - }; - }, [autoFocus, setDisableToolbar]); - - useEffect(() => { - setDisableToolbar(false); - }, [setDisableToolbar]); - - return ( - <> - - { - if (rest.onKeyDown) rest.onKeyDown(e); - setDisableToolbar(false); - }} - className={`${rest.className} mt-1`} - onFocus={() => { - setDisableToolbar(false); - }} - onBlur={() => { - setDisableToolbar(true); - }} - /> - - ); -}; - -export const CustomFloatingToolbar: React.FC = ({ - gptOption, - editorState, - setDisableToolbar, -}) => { - const { isEditing, setIsEditing, clickEdit, onRemove, submitHref, href, setHref, cancelHref } = - useFloatingLinkState(); - - const active = useActive(); - const activeLink = active.link(); - - const handleClickEdit = useCallback(() => { - clickEdit(); - }, [clickEdit]); - - return ( -
-
-
- - - -
-
- - - - -
-
- - -
- {gptOption && ( -
- -
- )} -
- -
- {activeLink ? ( -
- { - window.open(href, "_blank"); - }} - icon="externalLinkFill" - enabled - /> - - -
- ) : ( - { - if (isEditing) { - setIsEditing(false); - } else { - handleClickEdit(); - } - }} - icon="link" - enabled - active={isEditing} - /> - )} -
- - {isEditing && ( -
- ) => setHref(e.target.value)} - value={href} - onKeyDown={(e: KeyboardEvent) => { - const { code } = e; - - if (code === "Enter") { - submitHref(); - } - - if (code === "Escape") { - cancelHref(); - } - }} - /> -
- )} -
- ); -}; diff --git a/apps/app/components/rich-text-editor/toolbar/heading-controls.tsx b/apps/app/components/rich-text-editor/toolbar/heading-controls.tsx deleted file mode 100644 index 3297958f0..000000000 --- a/apps/app/components/rich-text-editor/toolbar/heading-controls.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// remirror -import { useCommands, useActive } from "@remirror/react"; -// ui -import { CustomMenu } from "components/ui"; - -const HeadingControls = () => { - const { toggleHeading, focus } = useCommands(); - - const active = useActive(); - - return ( -
- - { - toggleHeading({ level: 1 }); - focus(); - }} - className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`} - > - Heading 1 - - { - toggleHeading({ level: 2 }); - focus(); - }} - className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`} - > - Heading 2 - - { - toggleHeading({ level: 3 }); - focus(); - }} - className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`} - > - Heading 3 - - -
- ); -}; - -export default HeadingControls; diff --git a/apps/app/components/rich-text-editor/toolbar/index.tsx b/apps/app/components/rich-text-editor/toolbar/index.tsx deleted file mode 100644 index 8362ced57..000000000 --- a/apps/app/components/rich-text-editor/toolbar/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// buttons -import { - ToggleBoldButton, - ToggleItalicButton, - ToggleUnderlineButton, - ToggleStrikeButton, - ToggleOrderedListButton, - ToggleBulletListButton, - RedoButton, - UndoButton, -} from "@remirror/react"; -// headings -import HeadingControls from "./heading-controls"; - -export const RichTextToolbar: React.FC = () => ( -
-
- - -
-
- -
-
- - - - -
-
- - -
-
-); diff --git a/apps/app/components/rich-text-editor/toolbar/link.tsx b/apps/app/components/rich-text-editor/toolbar/link.tsx deleted file mode 100644 index 045736b99..000000000 --- a/apps/app/components/rich-text-editor/toolbar/link.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React, { - ChangeEvent, - HTMLProps, - KeyboardEvent, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions"; -import { - CommandButton, - FloatingToolbar, - FloatingWrapper, - useActive, - useAttrs, - useChainedCommands, - useCurrentSelection, - useExtensionEvent, - useUpdateReason, -} from "@remirror/react"; - -const useLinkShortcut = () => { - const [linkShortcut, setLinkShortcut] = useState(); - const [isEditing, setIsEditing] = useState(false); - - useExtensionEvent( - LinkExtension, - "onShortcut", - useCallback( - (props) => { - if (!isEditing) { - setIsEditing(true); - } - - return setLinkShortcut(props); - }, - [isEditing] - ) - ); - - return { linkShortcut, isEditing, setIsEditing }; -}; - -const useFloatingLinkState = () => { - const chain = useChainedCommands(); - const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut(); - const { to, empty } = useCurrentSelection(); - - const url = (useAttrs().link()?.href as string) ?? ""; - const [href, setHref] = useState(url); - - // A positioner which only shows for links. - const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []); - - const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]); - - const updateReason = useUpdateReason(); - - useLayoutEffect(() => { - if (!isEditing) { - return; - } - - if (updateReason.doc || updateReason.selection) { - setIsEditing(false); - } - }, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]); - - useEffect(() => { - setHref(url); - }, [url]); - - const submitHref = useCallback(() => { - setIsEditing(false); - const range = linkShortcut ?? undefined; - - if (href === "") { - chain.removeLink(); - } else { - chain.updateLink({ href, auto: false }, range); - } - - chain.focus(range?.to ?? to).run(); - }, [setIsEditing, linkShortcut, chain, href, to]); - - const cancelHref = useCallback(() => { - setIsEditing(false); - }, [setIsEditing]); - - const clickEdit = useCallback(() => { - if (empty) { - chain.selectLink(); - } - - setIsEditing(true); - }, [chain, empty, setIsEditing]); - - return useMemo( - () => ({ - href, - setHref, - linkShortcut, - linkPositioner, - isEditing, - clickEdit, - onRemove, - submitHref, - cancelHref, - }), - [href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref] - ); -}; - -const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps) => { - const inputRef = useRef(null); - - useEffect(() => { - if (!autoFocus) { - return; - } - - const frame = window.requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - - return () => { - window.cancelAnimationFrame(frame); - }; - }, [autoFocus]); - - return ; -}; - -export const FloatingLinkToolbar = () => { - const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } = - useFloatingLinkState(); - - const active = useActive(); - const activeLink = active.link(); - - const { empty } = useCurrentSelection(); - - const handleClickEdit = useCallback(() => { - clickEdit(); - }, [clickEdit]); - - const linkEditButtons = activeLink ? ( - <> - { - window.open(href, "_blank"); - }} - icon="externalLinkFill" - enabled - /> - - - - ) : ( - - ); - - return ( - <> - {!isEditing && ( - - {linkEditButtons} - - )} - {!isEditing && empty && ( - - {linkEditButtons} - - )} - - - ) => setHref(e.target.value)} - value={href} - onKeyDown={(e: KeyboardEvent) => { - const { code } = e; - - if (code === "Enter") { - submitHref(); - } - - if (code === "Escape") { - cancelHref(); - } - }} - /> - - - ); -}; diff --git a/apps/app/components/rich-text-editor/toolbar/table-controls.tsx b/apps/app/components/rich-text-editor/toolbar/table-controls.tsx deleted file mode 100644 index 20f49b6db..000000000 --- a/apps/app/components/rich-text-editor/toolbar/table-controls.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useCommands } from "@remirror/react"; - -export const TableControls = () => { - const { createTable, ...commands } = useCommands(); - - return ( -
- - -
- ); -}; diff --git a/apps/app/components/tiptap/bubble-menu/index.tsx b/apps/app/components/tiptap/bubble-menu/index.tsx new file mode 100644 index 000000000..a70481b88 --- /dev/null +++ b/apps/app/components/tiptap/bubble-menu/index.tsx @@ -0,0 +1,119 @@ +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.isEditable) { + return false; + } + 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/tiptap/bubble-menu/link-selector.tsx b/apps/app/components/tiptap/bubble-menu/link-selector.tsx new file mode 100644 index 000000000..c58c0de0b --- /dev/null +++ b/apps/app/components/tiptap/bubble-menu/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); + + useEffect(() => { + inputRef.current && inputRef.current?.focus(); + }); + + return ( +
+ + {isOpen && ( +
{ + e.preventDefault(); + const form = e.target as HTMLFormElement; + const input = form.elements[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-custom-border-100 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1" + > + + {editor.getAttributes("link").href ? ( + + ) : ( + + )} + + )} +
+ ); +}; + diff --git a/apps/app/components/tiptap/bubble-menu/node-selector.tsx b/apps/app/components/tiptap/bubble-menu/node-selector.tsx new file mode 100644 index 000000000..e74bfa400 --- /dev/null +++ b/apps/app/components/tiptap/bubble-menu/node-selector.tsx @@ -0,0 +1,136 @@ +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 "../bubble-menu"; +import { cn } from "../utils"; + +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(), + isActive: () => + editor.isActive("paragraph") && + !editor.isActive("bulletList") && + !editor.isActive("orderedList"), + }, + { + name: "H1", + icon: Heading1, + command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive("heading", { level: 1 }), + }, + { + name: "H2", + icon: Heading2, + command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + isActive: () => editor.isActive("heading", { level: 2 }), + }, + { + name: "H3", + 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/tiptap/extensions/index.tsx b/apps/app/components/tiptap/extensions/index.tsx new file mode 100644 index 000000000..6eed0108a --- /dev/null +++ b/apps/app/components/tiptap/extensions/index.tsx @@ -0,0 +1,143 @@ +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 CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { lowlight } from 'lowlight/lib/core' +import SlashCommand from "../slash-command"; +import { InputRule } from "@tiptap/core"; +import { Node as ProseMirrorNode } from '@tiptap/pm/model'; + +import ts from 'highlight.js/lib/languages/typescript' + +import 'highlight.js/styles/github-dark.css'; +import UploadImagesPlugin from "../plugins/upload-image"; +import UniqueID from "@tiptap-pro/extension-unique-id"; + +lowlight.registerLanguage('ts', ts) + +const CustomImage = TiptapImage.extend({ + addProseMirrorPlugins() { + return [UploadImagesPlugin()]; + }, +}); + +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", + }, + }, + code: { + HTMLAttributes: { + class: + "rounded-md bg-custom-bg-1000 px-1 py-1 font-mono font-medium text-stone-900", + spellcheck: "false", + }, + }, + codeBlock: false, + horizontalRule: false, + dropcursor: { + color: "#DBEAFE", + width: 2, + }, + gapcursor: false, + }), + CodeBlockLowlight.configure({ + lowlight, + }), + HorizontalRule.extend({ + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range, commands }) => { + commands.splitBlock(); + + const attributes = {}; + const { tr } = state; + const start = range.from; + const end = range.to; + // @ts-ignore + tr.replaceWith(start - 1, end, this.type.create(attributes)); + } + }), + ]; + }, + }).configure({ + HTMLAttributes: { + class: "mb-6 border-t border-custom-border-400", + }, + }), + TiptapLink.configure({ + HTMLAttributes: { + class: + "text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer", + }, + }), + CustomImage.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..."; + }, + includeChildren: true, + }), + UniqueID.configure({ + types: ['image'], + }), + 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: true, + transformCopiedText: true, + }), +]; diff --git a/apps/app/components/tiptap/index.tsx b/apps/app/components/tiptap/index.tsx new file mode 100644 index 000000000..14e77825b --- /dev/null +++ b/apps/app/components/tiptap/index.tsx @@ -0,0 +1,144 @@ +// @ts-nocheck +import { useEditor, EditorContent, Editor } from '@tiptap/react'; +import { useDebouncedCallback } from 'use-debounce'; +import { EditorBubbleMenu } from './bubble-menu'; +import { TiptapExtensions } from './extensions'; +import { TiptapEditorProps } from './props'; +import { Node } from "@tiptap/pm/model"; +import { Editor as CoreEditor } from "@tiptap/core"; +import { useCallback, useImperativeHandle, useRef } from 'react'; +import { EditorState } from '@tiptap/pm/state'; +import fileService from 'services/file.service'; + +export interface ITiptapRichTextEditor { + value: string; + noBorder?: boolean; + borderOnFocus?: boolean; + customClassName?: string; + editorContentCustomClassNames?: string; + onChange?: (json: any, html: string) => void; + setIsSubmitting?: (isSubmitting: boolean) => void; + editable?: boolean; + forwardedRef?: any; + debouncedUpdatesEnabled?: boolean; +} + +const Tiptap = (props: ITiptapRichTextEditor) => { + const { + onChange, + debouncedUpdatesEnabled, + forwardedRef, + editable, + setIsSubmitting, + editorContentCustomClassNames, + value, + noBorder, + borderOnFocus, + customClassName + } = props; + + const editor = useEditor({ + editable: editable ?? true, + editorProps: TiptapEditorProps, + extensions: TiptapExtensions, + content: value, + onUpdate: async ({ editor }) => { + // for instant feedback loop + setIsSubmitting?.(true); + checkForNodeDeletions(editor) + if (debouncedUpdatesEnabled) { + debouncedUpdates({ onChange, editor }); + } else { + onChange?.(editor.getJSON(), editor.getHTML()); + } + } + }); + + const editorRef: React.MutableRefObject = useRef(null) + + useImperativeHandle(forwardedRef, () => ({ + clearEditor: () => { + console.log('clearContent') + console.log(editorRef) + editorRef.current?.commands.clearContent() + }, + setEditorValue: (content: string) => { + console.log(editorRef, forwardedRef, content) + editorRef.current?.commands.setContent(content) + } + })) + + const previousState = useRef(); + + const onNodeDeleted = useCallback( + async (node: Node) => { + if (node.type.name === 'image') { + const assetUrlWithWorkspaceId = new URL(node.attrs.src).pathname.substring(1); + const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId); + if (resStatus === 204) { + console.log("file deleted successfully"); + } + } + }, + [], + ); + + const checkForNodeDeletions = useCallback( + (editor: CoreEditor) => { + const prevNodesById: Record = {}; + previousState.current?.doc.forEach((node) => { + if (node.attrs.id) { + prevNodesById[node.attrs.id] = node; + } + }); + + const nodesById: Record = {}; + editor.state?.doc.forEach((node) => { + if (node.attrs.id) { + nodesById[node.attrs.id] = node; + } + }); + + previousState.current = editor.state; + + for (const [id, node] of Object.entries(prevNodesById)) { + if (nodesById[id] === undefined) { + onNodeDeleted(node); + } + } + }, + [onNodeDeleted], + ); + + const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => { + setTimeout(async () => { + if (onChange) { + onChange(editor.getJSON(), editor.getHTML()); + } + }, 500); + }, 1000); + + const editorClassNames = `mt-2 p-3 relative focus:outline-none rounded-md focus:border-custom-border-200 + ${noBorder ? '' : 'border border-custom-border-200' + } ${borderOnFocus ? 'focus:border border-custom-border-200' : 'focus:border-0' + } ${customClassName}`; + + if (!editor) return null + editorRef.current = editor + + return ( +
{ + editor?.chain().focus().run(); + }} + className={`tiptap-editor-container relative ${editorClassNames}`} + > + {editor && } +
+ +
+
+ ); +}; + +export default Tiptap; diff --git a/apps/app/components/tiptap/plugins/upload-image.tsx b/apps/app/components/tiptap/plugins/upload-image.tsx new file mode 100644 index 000000000..3f372d9d4 --- /dev/null +++ b/apps/app/components/tiptap/plugins/upload-image.tsx @@ -0,0 +1,120 @@ +// @ts-nocheck +import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; +import fileService from "services/file.service"; + +const uploadKey = new PluginKey("upload-image"); + +const UploadImagesPlugin = () => + new Plugin({ + key: uploadKey, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(uploadKey); + if (action && action.add) { + const { id, pos, src } = action.add; + + const placeholder = document.createElement("div"); + placeholder.setAttribute("class", "img-placeholder"); + const image = document.createElement("img"); + image.setAttribute( + "class", + "opacity-10 rounded-lg border border-stone-200", + ); + image.src = src; + placeholder.appendChild(image); + const deco = Decoration.widget(pos + 1, placeholder, { + id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove( + set.find(undefined, undefined, (spec) => spec.id == action.remove.id), + ); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + } + }, + }); + +export default UploadImagesPlugin; + +function findPlaceholder(state: EditorState, id: {}) { + const decos = uploadKey.getState(state); + const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id); + return found.length ? found[0].from : null; +} + +export async function startImageUpload(file: File, view: EditorView, pos: number) { + if (!file.type.includes("image/")) { + return; + } else if (file.size / 1024 / 1024 > 20) { + return; + } + + const id = {}; + + const tr = view.state.tr; + if (!tr.selection.empty) tr.deleteSelection(); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + tr.setMeta(uploadKey, { + add: { + id, + pos, + src: reader.result, + }, + }); + view.dispatch(tr); + }; + + const src = await UploadImageHandler(file); + console.log(src, "src") + const { schema } = view.state; + pos = findPlaceholder(view.state, id); + + if (pos == null) return; + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + const transaction = view.state.tr + .replaceWith(pos, pos, node) + .setMeta(uploadKey, { remove: { id } }); + view.dispatch(transaction); +} + +const UploadImageHandler = (file: File): Promise => { + try { + const formData = new FormData(); + formData.append("asset", file); + formData.append("attributes", JSON.stringify({})); + + return new Promise(async (resolve, reject) => { + const imageUrl = await fileService + .uploadFile("plane", formData) + .then((response) => response.asset); + + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(imageUrl); + }; + }) + } + catch (error) { + console.log(error) + return Promise.reject(error); + } +}; diff --git a/apps/app/components/tiptap/props.tsx b/apps/app/components/tiptap/props.tsx new file mode 100644 index 000000000..1ffbebe6d --- /dev/null +++ b/apps/app/components/tiptap/props.tsx @@ -0,0 +1,56 @@ +import { EditorProps } from "@tiptap/pm/view"; +import { startImageUpload } from "./plugins/upload-image"; + +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; + } + } + }, + }, + handlePaste: (view, event) => { + if ( + event.clipboardData && + event.clipboardData.files && + event.clipboardData.files[0] + ) { + event.preventDefault(); + const file = event.clipboardData.files[0]; + const pos = view.state.selection.from; + + startImageUpload(file, view, pos); + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if ( + !moved && + event.dataTransfer && + event.dataTransfer.files && + event.dataTransfer.files[0] + ) { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + // here we deduct 1 from the pos or else the image will create an extra node + if (coordinates) { + startImageUpload(file, view, coordinates.pos - 1); + } + return true; + } + return false; + }, +}; + diff --git a/apps/app/components/tiptap/slash-command/index.tsx b/apps/app/components/tiptap/slash-command/index.tsx new file mode 100644 index 000000000..ab93945a9 --- /dev/null +++ b/apps/app/components/tiptap/slash-command/index.tsx @@ -0,0 +1,373 @@ +import React, { + useState, + useEffect, + useCallback, + ReactNode, + useRef, + useLayoutEffect, +} from "react"; +import { Editor, Range, Extension } from "@tiptap/core"; +import Suggestion from "@tiptap/suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import tippy from "tippy.js"; +import { + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Text, + TextQuote, + Code, + MinusSquare, + CheckSquare, + ImageIcon, +} from "lucide-react"; +import { startImageUpload } from "../plugins/upload-image"; + +interface CommandItemProps { + title: string; + description: string; + icon: ReactNode; +} + +interface CommandProps { + editor: Editor; + range: Range; +} + +const Command = Extension.create({ + name: "slash-command", + addOptions() { + return { + suggestion: { + char: "/", + command: ({ + editor, + range, + props, + }: { + editor: Editor; + range: Range; + props: any; + }) => { + props.command({ editor, range }); + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); + +const getSuggestionItems = ({ query }: { query: string }) => + [ + { + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .run(); + }, + }, + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 1 }) + .run(); + }, + }, + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 2 }) + .run(); + }, + }, + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 3 }) + .run(); + }, + }, + { + title: "To-do List", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + title: "Divider", + description: "Visually divide blocks", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run() + }, + }, + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .toggleBlockquote() + .run(), + }, + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: "Image", + description: "Upload an image from your computer.", + searchTerms: ["photo", "picture", "media"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).run(); + // upload image + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + if (input.files?.length) { + const file = input.files[0]; + const pos = editor.view.state.selection.from; + startImageUpload(file, editor.view, pos); + } + }; + input.click(); + }, + }, + ].filter((item) => { + if (typeof query === "string" && query.length > 0) { + const search = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(search) || + item.description.toLowerCase().includes(search) || + (item.searchTerms && + item.searchTerms.some((term: string) => term.includes(search))) + ); + } + return true; + });; + +export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { + const containerHeight = container.offsetHeight; + const itemHeight = item ? item.offsetHeight : 0; + + const top = item.offsetTop; + const bottom = top + itemHeight; + + if (top < container.scrollTop) { + container.scrollTop -= container.scrollTop - top + 5; + } else if (bottom > containerHeight + container.scrollTop) { + container.scrollTop += bottom - containerHeight - container.scrollTop + 5; + } +}; + +const CommandList = ({ + items, + command, + editor, + range, +}: { + items: CommandItemProps[]; + command: any; + editor: any; + range: any; +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = useCallback( + (index: number) => { + const item = items[index]; + if (item) { + command(item); + } + }, + [command, items], + ); + + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + if (e.key === "ArrowUp") { + setSelectedIndex((selectedIndex + items.length - 1) % items.length); + return true; + } + if (e.key === "ArrowDown") { + setSelectedIndex((selectedIndex + 1) % items.length); + return true; + } + if (e.key === "Enter") { + selectItem(selectedIndex); + return true; + } + return false; + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [items, selectedIndex, setSelectedIndex, selectItem]); + + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + const commandListContainer = useRef(null); + + useLayoutEffect(() => { + const container = commandListContainer?.current; + + const item = container?.children[selectedIndex] as HTMLElement; + + if (item && container) updateScrollView(container, item); + }, [selectedIndex]); + + return items.length > 0 ? ( +
+ {items.map((item: CommandItemProps, index: number) => + + )} +
+ ) : null; +}; + +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + component = new ReactRenderer(CommandList, { + props, + editor: props.editor, + }); + + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + // @ts-ignore + return component?.ref?.onKeyDown(props); + }, + onExit: () => { + popup?.[0].destroy(); + component?.destroy(); + }, + }; +}; + +const SlashCommand = Command.configure({ + suggestion: { + items: getSuggestionItems, + render: renderItems, + }, +}); + +export default SlashCommand; diff --git a/apps/app/components/tiptap/utils.ts b/apps/app/components/tiptap/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/apps/app/components/tiptap/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 6b1d58692..dec75df03 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -11,6 +11,8 @@ "dependencies": { "@blueprintjs/core": "^4.16.3", "@blueprintjs/popover2": "^1.13.3", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.12", "@jitsu/nextjs": "^3.1.5", @@ -23,25 +25,48 @@ "@nivo/line": "0.80.0", "@nivo/pie": "0.80.0", "@nivo/scatterplot": "0.80.0", - "@remirror/core": "^2.0.11", - "@remirror/extension-react-tables": "^2.2.11", - "@remirror/pm": "^2.0.3", - "@remirror/react": "^2.0.24", "@sentry/nextjs": "^7.36.0", "@tailwindcss/typography": "^0.5.9", + "@tiptap-pro/extension-unique-id": "^2.1.0", + "@tiptap/extension-code-block-lowlight": "^2.0.4", + "@tiptap/extension-color": "^2.0.4", + "@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-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", + "@tiptap/suggestion": "^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", + "highlight.js": "^11.8.0", "js-cookie": "^3.0.1", "lodash.debounce": "^4.0.8", "mobx": "^6.10.0", "mobx-react-lite": "^4.0.3", + "lowlight": "^2.9.0", + "lucide-react": "^0.263.1", "next": "12.3.2", "next-pwa": "^5.6.0", "next-themes": "^0.2.1", "nprogress": "^0.2.0", + "prosemirror-commands": "^1.5.2", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.19.3", + "prosemirror-schema-list": "^1.3.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.7.4", + "prosemirror-view": "^1.31.7", "react": "18.2.0", "react-beautiful-dnd": "^13.1.1", "react-color": "^2.19.3", @@ -52,8 +77,13 @@ "react-markdown": "^8.0.7", "remirror": "^2.0.23", "sharp": "^0.32.1", + "sonner": "^0.6.2", "swr": "^2.1.3", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.6", + "tiptap-markdown": "^0.8.2", "tlds": "^1.238.0", + "use-debounce": "^9.0.4", "uuid": "^9.0.0" }, "devDependencies": { @@ -76,5 +106,8 @@ "tailwindcss": "^3.1.6", "tsconfig": "*", "typescript": "4.7.4" + }, + "resolutions": { + "prosemirror-model": "1.18.1" } } diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index ccfd69cf4..102bbe3fb 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -45,6 +45,7 @@ const defaultValues = { const IssueDetailsPage: NextPage = () => { const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; + console.log(workspaceSlug, "workspaceSlug") const { user } = useUserAuth(); diff --git a/apps/app/postcss.config.js b/apps/app/postcss.config.js index 12a703d90..cbfea5ea2 100644 --- a/apps/app/postcss.config.js +++ b/apps/app/postcss.config.js @@ -1,5 +1,6 @@ module.exports = { plugins: { + "tailwindcss/nesting": {}, tailwindcss: {}, autoprefixer: {}, }, diff --git a/apps/app/services/file.service.ts b/apps/app/services/file.service.ts index ad87e3a19..d2f01428d 100644 --- a/apps/app/services/file.service.ts +++ b/apps/app/services/file.service.ts @@ -40,6 +40,14 @@ class FileServices extends APIService { }); } + async deleteImage(assetUrlWithWorkspaceId: string): Promise { + return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`) + .then((response) => response?.status) + .catch((error) => { + throw error?.response?.data; + }); + } + async deleteFile(workspaceId: string, assetUrl: string): Promise { const lastIndex = assetUrl.lastIndexOf("/"); const assetId = assetUrl.substring(lastIndex + 1); @@ -50,7 +58,6 @@ class FileServices extends APIService { throw error?.response?.data; }); } - async uploadUserFile(file: FormData): Promise { return this.mediaUpload(`/api/users/file-assets/`, file) .then((response) => response?.data) diff --git a/apps/app/styles/editor.css b/apps/app/styles/editor.css index ea4b2e601..f8f91ca34 100644 --- a/apps/app/styles/editor.css +++ b/apps/app/styles/editor.css @@ -1,11 +1,96 @@ -.empty-node::after { +.ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); + float: left; color: rgb(var(--color-text-400)); - - position: absolute; pointer-events: none; - top: 15px; - margin-left: 1px; + 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: rgb(var(--color-background-100)); + margin: 0; + cursor: pointer; + width: 1.2rem; + height: 1.2rem; + position: relative; + border: 2px solid rgb(var(--color-text-100)); + margin-right: 0.3rem; + display: grid; + place-content: center; + + &:hover { + background-color: rgb(var(--color-background-80)); + } + + &:active { + background-color: rgb(var(--color-background-90)); + } + + &::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: rgb(var(--color-text-200)); + text-decoration: line-through; + text-decoration-thickness: 2px; +} + +/* Overwrite tippy-box original max-width */ + +.tippy-box { + max-width: 400px !important; } .ProseMirror { @@ -31,6 +116,22 @@ -moz-appearance: textfield; } +.ProseMirror pre { + background: #121212; + border-radius: 0.375rem; + border-color: rgba(var(--color-background-100)); + border: 0.5px solid; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; +} + +.ProseMirror pre code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; +} + .ProseMirror-icon { display: inline-block; line-height: 0.8; diff --git a/apps/app/tailwind.config.js b/apps/app/tailwind.config.js index fbe0994b4..11e1946a5 100644 --- a/apps/app/tailwind.config.js +++ b/apps/app/tailwind.config.js @@ -182,5 +182,8 @@ module.exports = { custom: ["Inter", "sans-serif"], }, }, - plugins: [require("@tailwindcss/typography")], + plugins: [ + require("tailwindcss-animate"), + require("@tailwindcss/typography") + ], };