From b9940f95653e390263856e9eac853d7a0d26070b Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Mon, 16 Dec 2024 19:04:46 +0530 Subject: [PATCH] fix: dynamic mention content in markdown --- .../extensions/mentions/extension-config.ts | 12 -- .../pages/editor/header/options-dropdown.tsx | 9 +- .../pages/modals/export-page-modal.tsx | 11 +- web/core/hooks/use-parse-editor-content.ts | 193 ++++++++++++++++++ web/helpers/editor.helper.ts | 157 +------------- 5 files changed, 208 insertions(+), 174 deletions(-) create mode 100644 web/core/hooks/use-parse-editor-content.ts diff --git a/packages/editor/src/core/extensions/mentions/extension-config.ts b/packages/editor/src/core/extensions/mentions/extension-config.ts index 3997505fedc..827137a1df1 100644 --- a/packages/editor/src/core/extensions/mentions/extension-config.ts +++ b/packages/editor/src/core/extensions/mentions/extension-config.ts @@ -1,7 +1,5 @@ import { mergeAttributes } from "@tiptap/core"; import Mention, { MentionOptions } from "@tiptap/extension-mention"; -import { MarkdownSerializerState } from "@tiptap/pm/markdown"; -import { Node } from "@tiptap/pm/model"; // types import { TMentionHandler } from "@/types"; // local types @@ -45,16 +43,6 @@ export const CustomMentionExtensionConfig = Mention.extend = observer((props) => { const { updateQueryParams } = useQueryParams(); // collaborative actions const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page); + // parse editor content + const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent(); // menu items list const MENU_ITEMS: { @@ -72,7 +75,11 @@ export const PageOptionsDropdown: React.FC = observer((props) => { key: "copy-markdown", action: () => { if (!editorRef) return; - copyTextToClipboard(editorRef.getMarkDown()).then(() => + const markdownContent = editorRef.getMarkDown(); + const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({ + markdownContent, + }); + copyTextToClipboard(parsedMarkdownContent).then(() => setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", diff --git a/web/core/components/pages/modals/export-page-modal.tsx b/web/core/components/pages/modals/export-page-modal.tsx index cf4f0a0f4b5..475b1b1bb71 100644 --- a/web/core/components/pages/modals/export-page-modal.tsx +++ b/web/core/components/pages/modals/export-page-modal.tsx @@ -9,11 +9,8 @@ import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui"; // components import { PDFDocument } from "@/components/editor"; -// helpers -import { - replaceCustomComponentsFromHTMLContent, - replaceCustomComponentsFromMarkdownContent, -} from "@/helpers/editor.helper"; +// hooks +import { useParseEditorContent } from "@/hooks/use-parse-editor-content"; type Props = { editorRef: EditorRefApi | EditorReadOnlyRefApi | null; @@ -104,6 +101,9 @@ export const ExportPageModal: React.FC = (props) => { const { control, reset, watch } = useForm({ defaultValues, }); + // parse editor content + const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } = + useParseEditorContent(); // derived values const selectedExportFormat = watch("export_format"); const selectedPageFormat = watch("page_format"); @@ -179,6 +179,7 @@ export const ExportPageModal: React.FC = (props) => { }); handleClose(); } catch (error) { + console.error("Error in exporting page:", error); setToast({ type: TOAST_TYPE.ERROR, title: "Error!", diff --git a/web/core/hooks/use-parse-editor-content.ts b/web/core/hooks/use-parse-editor-content.ts new file mode 100644 index 00000000000..1b0647cb5fb --- /dev/null +++ b/web/core/hooks/use-parse-editor-content.ts @@ -0,0 +1,193 @@ +import { useCallback } from "react"; +import { useParams } from "next/navigation"; +// plane types +import { TSearchEntities } from "@plane/types"; +// helpers +import { getBase64Image } from "@/helpers/file.helper"; +// hooks +import { useMember } from "@/hooks/store"; + +export const useParseEditorContent = () => { + // params + const { workspaceSlug } = useParams(); + // store hooks + const { getUserDetails } = useMember(); + + /** + * @description function to replace all the custom components from the html component to make it pdf compatible + * @param props + * @returns {Promise} + */ + const replaceCustomComponentsFromHTMLContent = useCallback( + async (props: { htmlContent: string; noAssets?: boolean }): Promise => { + const { htmlContent, noAssets = false } = props; + // create a DOM parser + const parser = new DOMParser(); + // parse the HTML string into a DOM document + const doc = parser.parseFromString(htmlContent, "text/html"); + // replace all mention-component elements + const mentionComponents = doc.querySelectorAll("mention-component"); + mentionComponents.forEach((component) => { + // create a span element to replace the mention-component + const span = doc.createElement("span"); + span.setAttribute("data-node-type", "mention-block"); + // get the user id from the component + const id = component.getAttribute("entity_identifier") || ""; + const entityType = (component.getAttribute("entity_name") || "user_mention") as TSearchEntities; + let textContent = "user"; + if (entityType === "user_mention") { + const userDetails = getUserDetails(id); + textContent = userDetails?.display_name ?? ""; + } + span.textContent = `@${textContent}`; + // replace the mention-component with the span element + component.replaceWith(span); + }); + // handle code inside pre elements + const preElements = doc.querySelectorAll("pre"); + preElements.forEach((preElement) => { + const codeElement = preElement.querySelector("code"); + if (codeElement) { + // create a div element with the required attributes for code blocks + const div = doc.createElement("div"); + div.setAttribute("data-node-type", "code-block"); + div.setAttribute("class", "courier"); + // transfer the content from the code block + div.innerHTML = codeElement.innerHTML.replace(/\n/g, "
") || ""; + // replace the pre element with the new div + preElement.replaceWith(div); + } + }); + // handle inline code elements (not inside pre tags) + const inlineCodeElements = doc.querySelectorAll("code"); + inlineCodeElements.forEach((codeElement) => { + // check if the code element is inside a pre element + if (!codeElement.closest("pre")) { + // create a span element with the required attributes for inline code blocks + const span = doc.createElement("span"); + span.setAttribute("data-node-type", "inline-code-block"); + span.setAttribute("class", "courier-bold"); + // transfer the code content + span.textContent = codeElement.textContent || ""; + // replace the standalone code element with the new span + codeElement.replaceWith(span); + } + }); + // handle image-component elements + const imageComponents = doc.querySelectorAll("image-component"); + if (noAssets) { + // if no assets is enabled, remove the image component elements + imageComponents.forEach((component) => component.remove()); + // remove default img elements + const imageElements = doc.querySelectorAll("img"); + imageElements.forEach((img) => img.remove()); + } else { + // if no assets is not enabled, replace the image component elements with img elements + imageComponents.forEach((component) => { + // get the image src from the component + const src = component.getAttribute("src") ?? ""; + const height = component.getAttribute("height") ?? ""; + const width = component.getAttribute("width") ?? ""; + // create an img element to replace the image-component + const img = doc.createElement("img"); + img.src = src; + img.style.height = height; + img.style.width = width; + // replace the image-component with the img element + component.replaceWith(img); + }); + } + // convert all images to base64 + const imgElements = doc.querySelectorAll("img"); + await Promise.all( + Array.from(imgElements).map(async (img) => { + // get the image src from the img element + const src = img.getAttribute("src"); + if (src) { + try { + const base64Image = await getBase64Image(src); + img.src = base64Image; + } catch (error) { + // log the error if the image conversion fails + console.error("Failed to convert image to base64:", error); + } + } + }) + ); + // replace all checkbox elements + const checkboxComponents = doc.querySelectorAll("input[type='checkbox']"); + checkboxComponents.forEach((component) => { + // get the checked status from the element + const checked = component.getAttribute("checked"); + // create a div element to replace the input element + const div = doc.createElement("div"); + div.classList.value = "input-checkbox"; + // add the checked class if the checkbox is checked + if (checked === "checked" || checked === "true") div.classList.add("checked"); + // replace the input element with the div element + component.replaceWith(div); + }); + // remove all issue-embed-component elements + const issueEmbedComponents = doc.querySelectorAll("issue-embed-component"); + issueEmbedComponents.forEach((component) => component.remove()); + // serialize the document back into a string + let serializedDoc = doc.body.innerHTML; + // remove null colors from table elements + serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, ""); + return serializedDoc; + }, + [getUserDetails] + ); + + /** + * @description function to replace all the custom components from the markdown content + * @param props + * @returns {string} + */ + const replaceCustomComponentsFromMarkdownContent = useCallback( + (props: { markdownContent: string; noAssets?: boolean }): string => { + const start = performance.now(); + const { markdownContent, noAssets = false } = props; + let parsedMarkdownContent = markdownContent; + // replace the matched mention components with [display_name](redirect_url) + const mentionRegex = + /]*entity_identifier="([^"]+)"[^>]*entity_name="([^"]+)"[^>]*><\/mention-component>/g; + const originUrl = typeof window !== "undefined" && (window.location.origin ?? ""); + parsedMarkdownContent = parsedMarkdownContent.replace(mentionRegex, (_match, id, entity_type) => { + const entityType = entity_type as TSearchEntities; + if (!id || !entityType) return ""; + if (entityType === "user_mention") { + const userDetails = getUserDetails(id); + if (!userDetails) return ""; + return `[${userDetails.display_name}](${originUrl}/${workspaceSlug}/profile/${id})`; + } + return ""; + }); + // replace the matched image components with + const imageComponentRegex = /]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g; + const imgTagRegex = /]*src="([^"]+)"[^>]*\/?>/g; + if (noAssets) { + // remove all image components + parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, ""); + } else { + // replace the matched image components with + parsedMarkdownContent = parsedMarkdownContent.replace( + imageComponentRegex, + (_match, src) => `` + ); + } + // remove all issue-embed components + const issueEmbedRegex = /]*>[^]*<\/issue-embed-component>/g; + parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, ""); + const end = performance.now(); + console.log("Exec time:", end - start); + return parsedMarkdownContent; + }, + [getUserDetails, workspaceSlug] + ); + + return { + replaceCustomComponentsFromHTMLContent, + replaceCustomComponentsFromMarkdownContent, + }; +}; diff --git a/web/helpers/editor.helper.ts b/web/helpers/editor.helper.ts index e0cbef2e165..a3b05041d23 100644 --- a/web/helpers/editor.helper.ts +++ b/web/helpers/editor.helper.ts @@ -1,7 +1,7 @@ // plane editor import { TFileHandler } from "@plane/editor"; // helpers -import { getBase64Image, getFileURL } from "@/helpers/file.helper"; +import { getFileURL } from "@/helpers/file.helper"; // services import { FileService } from "@/services/file.service"; const fileService = new FileService(); @@ -111,161 +111,6 @@ export const getReadOnlyEditorFileHandlers = ( }; }; -/** - * @description function to replace all the custom components from the html component to make it pdf compatible - * @param props - * @returns {Promise} - */ -export const replaceCustomComponentsFromHTMLContent = async (props: { - htmlContent: string; - noAssets?: boolean; -}): Promise => { - const { htmlContent, noAssets = false } = props; - // create a DOM parser - const parser = new DOMParser(); - // parse the HTML string into a DOM document - const doc = parser.parseFromString(htmlContent, "text/html"); - // replace all mention-component elements - const mentionComponents = doc.querySelectorAll("mention-component"); - mentionComponents.forEach((component) => { - // get the user label from the component (or use any other attribute) - const label = component.getAttribute("label") || "user"; - // create a span element to replace the mention-component - const span = doc.createElement("span"); - span.setAttribute("data-node-type", "mention-block"); - span.textContent = `@${label}`; - // replace the mention-component with the anchor element - component.replaceWith(span); - }); - // handle code inside pre elements - const preElements = doc.querySelectorAll("pre"); - preElements.forEach((preElement) => { - const codeElement = preElement.querySelector("code"); - if (codeElement) { - // create a div element with the required attributes for code blocks - const div = doc.createElement("div"); - div.setAttribute("data-node-type", "code-block"); - div.setAttribute("class", "courier"); - // transfer the content from the code block - div.innerHTML = codeElement.innerHTML.replace(/\n/g, "
") || ""; - // replace the pre element with the new div - preElement.replaceWith(div); - } - }); - // handle inline code elements (not inside pre tags) - const inlineCodeElements = doc.querySelectorAll("code"); - inlineCodeElements.forEach((codeElement) => { - // check if the code element is inside a pre element - if (!codeElement.closest("pre")) { - // create a span element with the required attributes for inline code blocks - const span = doc.createElement("span"); - span.setAttribute("data-node-type", "inline-code-block"); - span.setAttribute("class", "courier-bold"); - // transfer the code content - span.textContent = codeElement.textContent || ""; - // replace the standalone code element with the new span - codeElement.replaceWith(span); - } - }); - // handle image-component elements - const imageComponents = doc.querySelectorAll("image-component"); - if (noAssets) { - // if no assets is enabled, remove the image component elements - imageComponents.forEach((component) => component.remove()); - // remove default img elements - const imageElements = doc.querySelectorAll("img"); - imageElements.forEach((img) => img.remove()); - } else { - // if no assets is not enabled, replace the image component elements with img elements - imageComponents.forEach((component) => { - // get the image src from the component - const src = component.getAttribute("src") ?? ""; - const height = component.getAttribute("height") ?? ""; - const width = component.getAttribute("width") ?? ""; - // create an img element to replace the image-component - const img = doc.createElement("img"); - img.src = src; - img.style.height = height; - img.style.width = width; - // replace the image-component with the img element - component.replaceWith(img); - }); - } - // convert all images to base64 - const imgElements = doc.querySelectorAll("img"); - await Promise.all( - Array.from(imgElements).map(async (img) => { - // get the image src from the img element - const src = img.getAttribute("src"); - if (src) { - try { - const base64Image = await getBase64Image(src); - img.src = base64Image; - } catch (error) { - // log the error if the image conversion fails - console.error("Failed to convert image to base64:", error); - } - } - }) - ); - // replace all checkbox elements - const checkboxComponents = doc.querySelectorAll("input[type='checkbox']"); - checkboxComponents.forEach((component) => { - // get the checked status from the element - const checked = component.getAttribute("checked"); - // create a div element to replace the input element - const div = doc.createElement("div"); - div.classList.value = "input-checkbox"; - // add the checked class if the checkbox is checked - if (checked === "checked" || checked === "true") div.classList.add("checked"); - // replace the input element with the div element - component.replaceWith(div); - }); - // remove all issue-embed-component elements - const issueEmbedComponents = doc.querySelectorAll("issue-embed-component"); - issueEmbedComponents.forEach((component) => component.remove()); - // serialize the document back into a string - let serializedDoc = doc.body.innerHTML; - // remove null colors from table elements - serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, ""); - return serializedDoc; -}; - -/** - * @description function to replace all the custom components from the markdown content - * @param props - * @returns {string} - */ -export const replaceCustomComponentsFromMarkdownContent = (props: { - markdownContent: string; - noAssets?: boolean; -}): string => { - const { markdownContent, noAssets = false } = props; - let parsedMarkdownContent = markdownContent; - // replace the matched mention components with [label](redirect_uri) - const mentionRegex = /]*label="([^"]+)"[^>]*redirect_uri="([^"]+)"[^>]*><\/mention-component>/g; - const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - parsedMarkdownContent = parsedMarkdownContent.replace( - mentionRegex, - (_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})` - ); - // replace the matched image components with - const imageComponentRegex = /]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g; - const imgTagRegex = /]*src="([^"]+)"[^>]*\/?>/g; - if (noAssets) { - // remove all image components - parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, ""); - } else { - // replace the matched image components with - parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => ``); - } - // remove all issue-embed components - const issueEmbedRegex = /]*>[^]*<\/issue-embed-component>/g; - parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, ""); - return parsedMarkdownContent; -}; - export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => { if (!jsx) return "";