[PE-45] feat: page export as PDF & Markdown (#5705)

* feat: export page as pdf and markdown

* chore: add image conversion logic
This commit is contained in:
Aaryan Khandelwal 2024-10-08 16:54:02 +05:30 committed by GitHub
parent 20c9e232e7
commit b27249486a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1271 additions and 189 deletions

View file

@ -44,7 +44,7 @@
}
&.sans-serif {
--font-style: sans-serif;
--font-style: "Inter", sans-serif;
}
&.serif {

View file

@ -4,6 +4,9 @@ export enum EModalPosition {
}
export enum EModalWidth {
SM = "sm:max-w-sm",
MD = "sm:max-w-md",
LG = "sm:max-w-lg",
XL = "sm:max-w-xl",
XXL = "sm:max-w-2xl",
XXXL = "sm:max-w-3xl",

View file

@ -1,2 +1,3 @@
export * from "./lite-text-editor";
export * from "./pdf";
export * from "./rich-text-editor";

View file

@ -0,0 +1,53 @@
"use client";
import { Document, Font, Page, PageProps } from "@react-pdf/renderer";
import { Html } from "react-pdf-html";
// constants
import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor";
Font.register({
family: "Inter",
fonts: [
{ src: "/fonts/inter/thin.ttf", fontWeight: "thin" },
{ src: "/fonts/inter/thin.ttf", fontWeight: "thin", fontStyle: "italic" },
{ src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight" },
{ src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight", fontStyle: "italic" },
{ src: "/fonts/inter/light.ttf", fontWeight: "light" },
{ src: "/fonts/inter/light.ttf", fontWeight: "light", fontStyle: "italic" },
{ src: "/fonts/inter/regular.ttf", fontWeight: "normal" },
{ src: "/fonts/inter/regular.ttf", fontWeight: "normal", fontStyle: "italic" },
{ src: "/fonts/inter/medium.ttf", fontWeight: "medium" },
{ src: "/fonts/inter/medium.ttf", fontWeight: "medium", fontStyle: "italic" },
{ src: "/fonts/inter/semibold.ttf", fontWeight: "semibold" },
{ src: "/fonts/inter/semibold.ttf", fontWeight: "semibold", fontStyle: "italic" },
{ src: "/fonts/inter/bold.ttf", fontWeight: "bold" },
{ src: "/fonts/inter/bold.ttf", fontWeight: "bold", fontStyle: "italic" },
{ src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold" },
{ src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold", fontStyle: "italic" },
{ src: "/fonts/inter/heavy.ttf", fontWeight: "heavy" },
{ src: "/fonts/inter/heavy.ttf", fontWeight: "heavy", fontStyle: "italic" },
],
});
type Props = {
content: string;
pageFormat: PageProps["size"];
};
export const PDFDocument: React.FC<Props> = (props) => {
const { content, pageFormat } = props;
return (
<Document>
<Page
size={pageFormat}
style={{
backgroundColor: "#ffffff",
padding: 64,
}}
>
<Html stylesheet={EDITOR_PDF_DOCUMENT_STYLESHEET}>{content}</Html>
</Page>
</Document>
);
};

View file

@ -0,0 +1 @@
export * from "./document";

View file

@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { ExportPageModal } from "@/components/pages";
// helpers
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
@ -27,6 +30,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const router = useRouter();
// store values
const {
name,
archived_at,
is_locked,
id,
@ -38,6 +42,8 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
canCurrentUserLockPage,
restore,
} = page;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// store hooks
const { workspaceSlug, projectId } = useParams();
// page filters
@ -157,26 +163,41 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
icon: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
label: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
];
return (
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() => handleFullWidth(!isFullWidth)}
>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</CustomMenu.MenuItem>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;
return (
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
<item.icon className="h-3 w-3" />
{item.label}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
<>
<ExportPageModal
editorRef={editorRef}
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
pageTitle={name ?? ""}
/>
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() => handleFullWidth(!isFullWidth)}
>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</CustomMenu.MenuItem>
{MENU_ITEMS.map((item) => {
if (!item.shouldRender) return null;
return (
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
<item.icon className="h-3 w-3" />
{item.label}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View file

@ -1,6 +1,6 @@
"use client";
import { CSSProperties, useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// editor
import { EditorRefApi } from "@plane/editor";
@ -23,27 +23,21 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
// states
const [isLengthVisible, setIsLengthVisible] = useState(false);
// page filters
const { fontSize, fontStyle } = usePageFilters();
const { fontSize } = usePageFilters();
// ui
const titleClassName = cn("bg-transparent tracking-[-2%] font-semibold", {
"text-[1.6rem] leading-[1.8rem]": fontSize === "small-font",
"text-[2rem] leading-[2.25rem]": fontSize === "large-font",
});
const titleStyle: CSSProperties = {
fontFamily: fontStyle,
};
return (
<>
{readOnly ? (
<h6 className={cn(titleClassName, "break-words")} style={titleStyle}>
{title}
</h6>
<h6 className={cn(titleClassName, "break-words")}>{title}</h6>
) : (
<>
<TextArea
className={cn(titleClassName, "w-full outline-none p-0 border-none resize-none rounded-none")}
style={titleStyle}
placeholder="Untitled"
onKeyDown={(e) => {
if (e.key === "Enter") {

View file

@ -0,0 +1,279 @@
"use client";
import { useState } from "react";
import { PageProps, pdf } from "@react-pdf/renderer";
import { Controller, useForm } from "react-hook-form";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// plane ui
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";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
isOpen: boolean;
onClose: () => void;
pageTitle: string;
};
type TExportFormats = "pdf" | "markdown";
type TPageFormats = Exclude<PageProps["size"], undefined>;
type TContentVariety = "everything" | "no-assets";
type TFormValues = {
export_format: TExportFormats;
page_format: TPageFormats;
content_variety: TContentVariety;
};
const EXPORT_FORMATS: {
key: TExportFormats;
label: string;
}[] = [
{
key: "pdf",
label: "PDF",
},
{
key: "markdown",
label: "Markdown",
},
];
const PAGE_FORMATS: {
key: TPageFormats;
label: string;
}[] = [
{
key: "A4",
label: "A4",
},
{
key: "A3",
label: "A3",
},
{
key: "A2",
label: "A2",
},
{
key: "LETTER",
label: "Letter",
},
{
key: "LEGAL",
label: "Legal",
},
{
key: "TABLOID",
label: "Tabloid",
},
];
const CONTENT_VARIETY: {
key: TContentVariety;
label: string;
}[] = [
{
key: "everything",
label: "Everything",
},
{
key: "no-assets",
label: "No images",
},
];
const defaultValues: TFormValues = {
export_format: "pdf",
page_format: "A4",
content_variety: "everything",
};
export const ExportPageModal: React.FC<Props> = (props) => {
const { editorRef, isOpen, onClose, pageTitle } = props;
// states
const [isExporting, setIsExporting] = useState(false);
// form info
const { control, reset, watch } = useForm<TFormValues>({
defaultValues,
});
// derived values
const selectedExportFormat = watch("export_format");
const selectedPageFormat = watch("page_format");
const selectedContentVariety = watch("content_variety");
const isPDFSelected = selectedExportFormat === "pdf";
const fileName = pageTitle?.toLowerCase()?.replace(/ /g, "-");
// handle modal close
const handleClose = () => {
onClose();
setTimeout(() => {
reset();
}, 300);
};
// handle export as a PDF
const handleExportAsPDF = async () => {
try {
const pageContent = `<h1 class="page-title">${pageTitle}</h1>${editorRef?.getDocument().html ?? "<p></p>"}`;
const parsedPageContent = await replaceCustomComponentsFromHTMLContent({
htmlContent: pageContent,
noAssets: selectedContentVariety === "no-assets",
});
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
throw new Error(`Error in exporting as a PDF: ${error}`);
}
};
// handle export as markdown
const handleExportAsMarkdown = async () => {
try {
const markdownContent = editorRef?.getMarkDown() ?? "";
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
markdownContent,
noAssets: selectedContentVariety === "no-assets",
});
const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName}.md`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
throw new Error(`Error in exporting as markdown: ${error}`);
}
};
// handle export
const handleExport = async () => {
setIsExporting(true);
try {
if (selectedExportFormat === "pdf") {
await handleExportAsPDF();
}
if (selectedExportFormat === "markdown") {
await handleExportAsMarkdown();
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page exported successfully.",
});
handleClose();
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be exported. Please try again later.",
});
} finally {
setIsExporting(false);
}
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.SM}>
<div>
<div className="p-5 space-y-5">
<h3 className="text-xl font-medium text-custom-text-200">Export page</h3>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Export format</h6>
<Controller
control={control}
name="export_format"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={EXPORT_FORMATS.find((format) => format.key === value)?.label}
buttonClassName="border-none"
value={value}
onChange={(val: TExportFormats) => onChange(val)}
className="flex-shrink-0"
placement="bottom-end"
>
{EXPORT_FORMATS.map((format) => (
<CustomSelect.Option key={format.key} value={format.key}>
{format.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Include content</h6>
<Controller
control={control}
name="content_variety"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={CONTENT_VARIETY.find((variety) => variety.key === value)?.label}
buttonClassName="border-none"
value={value}
onChange={(val: TContentVariety) => onChange(val)}
className="flex-shrink-0"
placement="bottom-end"
>
{CONTENT_VARIETY.map((variety) => (
<CustomSelect.Option key={variety.key} value={variety.key}>
{variety.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
{isPDFSelected && (
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">Page format</h6>
<Controller
control={control}
name="page_format"
render={({ field: { onChange, value } }) => (
<CustomSelect
label={PAGE_FORMATS.find((format) => format.key === value)?.label}
buttonClassName="border-none"
value={value}
onChange={(val: TPageFormats) => onChange(val)}
className="flex-shrink-0"
placement="bottom-end"
>
{PAGE_FORMATS.map((format) => (
<CustomSelect.Option key={format.key.toString()} value={format.key}>
{format.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
)}
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="sm" loading={isExporting} onClick={handleExport}>
{isExporting ? "Exporting" : "Export"}
</Button>
</div>
</div>
</ModalCore>
);
};

View file

@ -1,3 +1,4 @@
export * from "./create-page-modal";
export * from "./delete-page-modal";
export * from "./export-page-modal";
export * from "./page-form";

View file

@ -1,3 +1,4 @@
import { Styles, StyleSheet } from "@react-pdf/renderer";
import {
Bold,
CaseSensitive,
@ -23,6 +24,8 @@ import {
import { TEditorCommands, TEditorFontStyle } from "@plane/editor";
// ui
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
// helpers
import { convertRemToPixel } from "@/helpers/common.helper";
type TEditorTypes = "lite" | "document";
@ -131,3 +134,180 @@ export const EDITOR_FONT_STYLES: {
icon: MonospaceIcon,
},
];
const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = {
"*:not(.courier, .courier-bold)": {
fontFamily: "Inter",
},
".courier": {
fontFamily: "Courier",
},
".courier-bold": {
fontFamily: "Courier-Bold",
},
};
const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = {
// page title
"h1.page-title": {
fontSize: convertRemToPixel(1.6),
fontWeight: "bold",
marginTop: 0,
marginBottom: convertRemToPixel(2),
},
// headings
"h1:not(.page-title)": {
fontSize: convertRemToPixel(1.4),
fontWeight: "semibold",
marginTop: convertRemToPixel(2),
marginBottom: convertRemToPixel(0.25),
},
h2: {
fontSize: convertRemToPixel(1.2),
fontWeight: "semibold",
marginTop: convertRemToPixel(1.4),
marginBottom: convertRemToPixel(0.0625),
},
h3: {
fontSize: convertRemToPixel(1.1),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h4: {
fontSize: convertRemToPixel(1),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h5: {
fontSize: convertRemToPixel(0.9),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h6: {
fontSize: convertRemToPixel(0.8),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
// paragraph
"p:not(table p)": {
fontSize: convertRemToPixel(0.8),
},
"p:not(ol p, ul p)": {
marginTop: convertRemToPixel(0.25),
marginBottom: convertRemToPixel(0.0625),
},
};
const EDITOR_PDF_LIST_STYLES: Styles = {
"ul, ol": {
fontSize: convertRemToPixel(0.8),
marginHorizontal: -20,
},
"ol p, ul p": {
marginVertical: 0,
},
"ol li, ul li": {
marginTop: convertRemToPixel(0.45),
},
"ul ul, ul ol, ol ol, ol ul": {
marginVertical: 0,
},
"ul[data-type='taskList']": {
position: "relative",
},
"div.input-checkbox": {
position: "absolute",
top: convertRemToPixel(0.15),
left: -convertRemToPixel(1.2),
height: convertRemToPixel(0.75),
width: convertRemToPixel(0.75),
borderWidth: "1.5px",
borderStyle: "solid",
borderRadius: convertRemToPixel(0.125),
},
"div.input-checkbox:not(.checked)": {
backgroundColor: "#ffffff",
borderColor: "#171717",
},
"div.input-checkbox.checked": {
backgroundColor: "#3f76ff",
borderColor: "#3f76ff",
},
"ul li[data-checked='true'] p": {
color: "#a3a3a3",
},
};
const EDITOR_PDF_CODE_STYLES: Styles = {
// code block
"[data-node-type='code-block']": {
marginVertical: convertRemToPixel(0.5),
padding: convertRemToPixel(1),
borderRadius: convertRemToPixel(0.5),
backgroundColor: "#f7f7f7",
fontSize: convertRemToPixel(0.7),
},
// inline code block
// TODO: update width
"[data-node-type='inline-code-block']": {
margin: 0,
paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8),
paddingHorizontal: convertRemToPixel(0.375),
border: "0.5px solid #e5e5e5",
borderRadius: convertRemToPixel(0.25),
backgroundColor: "#e8e8e8",
color: "#f97316",
fontSize: convertRemToPixel(0.7),
},
};
export const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({
...EDITOR_PDF_FONT_FAMILY_STYLES,
...EDITOR_PDF_TYPOGRAPHY_STYLES,
...EDITOR_PDF_LIST_STYLES,
...EDITOR_PDF_CODE_STYLES,
// quote block
blockquote: {
borderLeft: "3px solid gray",
paddingLeft: convertRemToPixel(1),
marginTop: convertRemToPixel(0.625),
marginBottom: 0,
marginHorizontal: 0,
},
// image
img: {
marginVertical: 0,
borderRadius: convertRemToPixel(0.375),
},
// divider
"div[data-type='horizontalRule']": {
marginVertical: convertRemToPixel(1),
height: 1,
width: "100%",
backgroundColor: "gray",
},
// mention block
"[data-node-type='mention-block']": {
margin: 0,
color: "#3f76ff",
backgroundColor: "#3f76ff33",
paddingHorizontal: convertRemToPixel(0.375),
},
// table
table: {
marginTop: convertRemToPixel(0.5),
marginBottom: convertRemToPixel(1),
marginHorizontal: 0,
},
"table td": {
padding: convertRemToPixel(0.625),
border: "1px solid #e5e5e5",
},
"table p": {
fontSize: convertRemToPixel(0.7),
},
});

View file

@ -37,3 +37,5 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) =>
};
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const convertRemToPixel = (rem: number): number => rem * 0.9 * 16;

View file

@ -0,0 +1,156 @@
// helpers
import { getBase64Image } from "@/helpers/file.helper";
/**
* @description function to replace all the custom components from the html component to make it pdf compatible
* @param props
* @returns {Promise<string>}
*/
export const replaceCustomComponentsFromHTMLContent = async (props: {
htmlContent: string;
noAssets?: boolean;
}): Promise<string> => {
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, "<br>") || "";
// 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 = /<mention-component[^>]*label="([^"]+)"[^>]*redirect_uri="([^"]+)"[^>]*><\/mention-component>/g;
const originUrl = window?.location?.origin ?? "";
parsedMarkdownContent = parsedMarkdownContent.replace(
mentionRegex,
(_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})`
);
// replace the matched image components with <img src={src} >
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
if (noAssets) {
// remove all image components
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
} else {
// replace the matched image components with <img src={src} >
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => `<img src="${src}" >`);
}
// remove all issue-embed components
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
return parsedMarkdownContent;
};

View file

@ -0,0 +1,31 @@
/**
* @description encode image via URL to base64
* @param {string} url
* @returns
*/
export const getBase64Image = async (url: string): Promise<string> => {
const response = await fetch(url);
// check if the response is OK
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to convert image to base64."));
}
};
reader.onerror = () => {
reject(new Error("Failed to read the image file."));
};
reader.readAsDataURL(blob);
});
};

View file

@ -32,6 +32,7 @@
"@plane/types": "*",
"@plane/ui": "*",
"@popperjs/core": "^2.11.8",
"@react-pdf/renderer": "^3.4.5",
"@sentry/nextjs": "^8.32.0",
"@sqlite.org/sqlite-wasm": "^3.46.0-build2",
"axios": "^1.7.4",
@ -57,6 +58,7 @@
"react-dropzone": "^14.2.3",
"react-hook-form": "7.51.5",
"react-markdown": "^8.0.7",
"react-pdf-html": "^2.1.2",
"react-popper": "^2.3.0",
"sharp": "^0.32.1",
"smooth-scroll-into-view-if-needed": "^2.0.2",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

678
yarn.lock

File diff suppressed because it is too large Load diff