Compare commits

...

15 Commits

Author SHA1 Message Date
Aaryan Khandelwal e734b4cfbc refactor: export components 2024-10-08 17:02:48 +05:30
Aaryan Khandelwal 2bd3dffaf6 fix: merge conflicts from preview 2024-10-08 16:52:47 +05:30
Aaryan Khandelwal 39195d0d89 [WEB-2532] fix: custom theme mutation logic (#5685)
* fix: custom theme mutation logic

* chore: update querySelector element
2024-10-08 16:47:16 +05:30
Mihir 6bf0e27b66 [WEB-2433] chore-Update name of the Layout (#5661)
* Updated layout names

* Corrected character casing for titles
2024-10-08 16:44:50 +05:30
M. Palanikannan 5fb7e98b7c fix: drag handle scrolling fixed (#5619)
* fix: drag handle scrolling fixed

* fix: closest scrollable parent found and scrolled

* fix: removed overflow auto from framerenderer

* fix: make dragging dynamic and smoother
2024-10-08 16:44:05 +05:30
Prateek Shourya 328b6961a2 [WEB-2605] fix: update URL regex pattern to allow complex links. (#5767) 2024-10-08 13:20:27 +05:30
Bavisetti Narayan 39eabc28b5 chore: only admin can changed the project settings (#5766) 2024-10-07 20:07:24 +05:30
Bavisetti Narayan c92fe6191e [WEB-2600] fix: estimate point deletion (#5762)
* chore: only delete the cascade fields

* chore: logged the issue activity
2024-10-07 17:23:37 +05:30
pablohashescobar 7bb04003ea fix: instance trace 2024-10-07 15:56:27 +05:30
sriram veeraghanta 19dab1fad0 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-10-07 13:20:07 +05:30
M. Palanikannan 5f7b6ecf7f fix: image deletion on submit fixed in comments (#5748)
* fix: image deletion on submit fixed in comments

* fix: cleareditor added to read only editor

* fix: image component double drop fixed

* feat: multiple image selection and uploading

* fix: click event on read only instance

* fix: made things async

* fix: prevented default behaviour

* fix: removed extra dep and cleaned up logic
2024-10-07 13:12:16 +05:30
guru_sainath dfd3af13cf fix: handled favorite entity data null (#5756) 2024-10-07 12:57:15 +05:30
Aaryan Khandelwal 0e17d3db54 fix: merge conflicts resolved from preview 2024-09-26 14:25:59 +05:30
Aaryan Khandelwal 53a91ea190 chore: add image conversion logic 2024-09-26 14:25:00 +05:30
Aaryan Khandelwal cd587c8311 feat: export page as pdf and markdown 2024-09-25 18:01:20 +05:30
59 changed files with 1560 additions and 354 deletions
+62 -2
View File
@@ -1,5 +1,9 @@
import random
import string
import json
# Django imports
from django.utils import timezone
# Third party imports
from rest_framework.response import Response
@@ -19,6 +23,7 @@ from plane.app.serializers import (
EstimateReadSerializer,
)
from plane.utils.cache import invalidate_cache
from plane.bgtasks.issue_activities_task import issue_activity
def generate_random_name(length=10):
@@ -249,11 +254,66 @@ class EstimatePointEndpoint(BaseViewSet):
)
# update all the issues with the new estimate
if new_estimate_id:
_ = Issue.objects.filter(
issues = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_point_id=estimate_point_id,
).update(estimate_point_id=new_estimate_id)
)
for issue in issues:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"estimate_point": (
str(new_estimate_id)
if new_estimate_id
else None
),
}
),
actor_id=str(request.user.id),
issue_id=issue.id,
project_id=str(project_id),
current_instance=json.dumps(
{
"estimate_point": (
str(issue.estimate_point_id)
if issue.estimate_point_id
else None
),
}
),
epoch=int(timezone.now().timestamp()),
)
issues.update(estimate_point_id=new_estimate_id)
else:
issues = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_point_id=estimate_point_id,
)
for issue in issues:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"estimate_point": None,
}
),
actor_id=str(request.user.id),
issue_id=issue.id,
project_id=str(project_id),
current_instance=json.dumps(
{
"estimate_point": (
str(issue.estimate_point_id)
if issue.estimate_point_id
else None
),
}
),
epoch=int(timezone.now().timestamp()),
)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
+12 -1
View File
@@ -413,9 +413,20 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def partial_update(self, request, slug, pk=None):
try:
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
is_active=True,
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
+21 -13
View File
@@ -2,6 +2,7 @@
from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
# Third party imports
@@ -18,17 +19,25 @@ def soft_delete_related_objects(
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
# Check if the field has CASCADE on delete
if (
hasattr(field.remote_field, "on_delete")
and field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object]
if related_object is not None
else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
@@ -154,8 +163,7 @@ def hard_delete():
if hasattr(model, "deleted_at"):
# Get all instances where 'deleted_at' is greater than 30 days ago
_ = model.all_objects.filter(
deleted_at__lt=timezone.now()
- timezone.timedelta(days=days)
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
).delete()
return
@@ -465,7 +465,7 @@ def track_estimate_points(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
verb="removed" if new_estimate is None else "updated",
old_identifier=(
current_instance.get("estimate_point")
if current_instance.get("estimate_point") is not None
@@ -1700,16 +1700,12 @@ def issue_activity(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue"
if inbox
else "issue"
else "inbox_issue" if inbox else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox
if inbox
else activity.issue_id
else inbox if inbox else activity.issue_id
),
verb=activity.verb,
field=(
@@ -82,6 +82,7 @@ def instance_traces():
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
@@ -71,6 +71,17 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
console.error(`${errorMessage}:`, error);
}
},
[updateAttributes]
);
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
@@ -105,17 +116,25 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
};
setSize(initialComputedSize);
updateAttributes(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
"Failed to update attributes while initializing an image for the first time:"
);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio };
updateAttributes(newSize);
return newSize;
});
if (!aspectRatio) {
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio };
updateAttributesSafely(
newSize,
"Failed to update attributes while initializing images with width but no aspect ratio:"
);
return newSize;
});
}
}
setInitialResizeComplete(true);
}, [width, updateAttributes, editorContainer]);
}, [width, updateAttributes, editorContainer, aspectRatio]);
// for real time resizing
useLayoutEffect(() => {
@@ -142,7 +161,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributes(size);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
@@ -5,9 +5,7 @@ import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
@@ -74,7 +72,11 @@ export const CustomImageUploader = (props: {
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
uploader: uploadFile,
editor,
pos: getPos(),
});
// the meta data of the image component
const meta = useMemo(
@@ -82,9 +84,6 @@ export const CustomImageUploader = (props: {
[imageComponentImageFileMap, imageEntityId]
);
// if the image component is dropped, we check if it has an existing file
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
@@ -100,27 +99,20 @@ export const CustomImageUploader = (props: {
}
}, [meta, uploadFile, imageComponentImageFileMap]);
// check if the image is dropped and set the local image as the existing file
useEffect(() => {
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
uploadFile(file);
}
async (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const fileList = e.target.files;
if (!fileList) {
return;
}
await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile);
},
[uploadFile]
[uploadFile, editor, getPos]
);
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded || existingFile;
const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
return "Error loading image";
}
@@ -134,13 +126,14 @@ export const CustomImageUploader = (props: {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
@@ -153,7 +146,7 @@ export const CustomImageUploader = (props: {
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage) {
if (!failedToLoadImage && editor.isEditable) {
fileInputRef.current?.click();
}
}}
@@ -167,6 +160,7 @@ export const CustomImageUploader = (props: {
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
multiple
/>
</div>
);
+3 -10
View File
@@ -21,7 +21,7 @@ export const DropHandlerExtension = () =>
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -41,7 +41,7 @@ export const DropHandlerExtension = () =>
if (coordinates) {
const pos = coordinates.pos;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
@@ -54,7 +54,7 @@ export const DropHandlerExtension = () =>
},
});
const insertImages = async ({
export const insertImagesSafely = async ({
editor,
files,
initialPos,
@@ -72,13 +72,6 @@ const insertImages = async ({
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
// Check if the position has a non-empty node
const nodeAtPos = editor.state.doc.nodeAt(pos);
if (nodeAtPos && nodeAtPos.content.size > 0) {
// Move to the end of the current node
pos += nodeAtPos.nodeSize;
}
try {
// Insert the image at the current position
editor.commands.insertImageComponent({ file, pos, event });
@@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => {
ai: aiEnabled,
dragDrop: dragDropEnabled,
},
scrollThreshold: { up: 300, down: 100 },
scrollThreshold: { up: 200, down: 100 },
}),
];
},
+1 -1
View File
@@ -126,7 +126,7 @@ export const useEditor = (props: CustomEditorProps) => {
forwardedRef,
() => ({
clearEditor: (emitUpdate = false) => {
editorRef.current?.commands.clearContent(emitUpdate);
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
@@ -1,6 +1,7 @@
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
import { insertImagesSafely } from "@/extensions/drop";
export const useUploader = ({
onUpload,
@@ -63,7 +64,15 @@ export const useUploader = ({
return { uploading, uploadFile };
};
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
export const useDropZone = ({
uploader,
editor,
pos,
}: {
uploader: (file: File) => Promise<void>;
editor: Editor;
pos: number;
}) => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -86,40 +95,16 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
}, []);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
if (e.dataTransfer.files.length === 0) {
return;
}
const fileList = e.dataTransfer.files;
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item) {
files.push(item);
}
}
if (files.some((file) => file.type.indexOf("image") === -1)) {
return;
}
e.preventDefault();
const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
uploader(file);
} else {
console.error("No file found");
}
await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader);
},
[uploader]
[uploader, editor, pos]
);
const onDragEnter = () => {
@@ -143,3 +128,40 @@ function trimFileName(fileName: string, maxLength = 100) {
return fileName;
}
// Upload the first image and insert the remaining images for uploading multiple image
// post insertion of image-component
export async function uploadFirstImageAndInsertRemaining(
editor: Editor,
fileList: FileList,
pos: number,
uploaderFn: (file: File) => Promise<void>
) {
const filteredFiles: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) {
filteredFiles.push(item);
}
}
if (filteredFiles.length !== fileList.length) {
console.warn("Some files were not images and have been ignored.");
}
if (filteredFiles.length === 0) {
console.error("No image files found to upload");
return;
}
// Upload the first image
const firstFile = filteredFiles[0];
uploaderFn(firstFile);
// Insert the remaining images
const remainingFiles = filteredFiles.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
}
}
@@ -70,8 +70,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const editorRef: MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
@@ -233,14 +233,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
dragHandleElement.addEventListener("click", (e) => handleClick(e, view));
dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view));
const isScrollable = (node: HTMLElement | SVGElement) => {
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
return false;
}
const style = getComputedStyle(node);
return ["overflow", "overflow-y"].some((propertyName) => {
const value = style.getPropertyValue(propertyName);
return value === "auto" || value === "scroll";
});
};
const getScrollParent = (node: HTMLElement | SVGElement) => {
let currentParent = node.parentElement;
while (currentParent) {
if (isScrollable(currentParent)) {
return currentParent;
}
currentParent = currentParent.parentElement;
}
return document.scrollingElement || document.documentElement;
};
const maxScrollSpeed = 100;
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const frameRenderer = document.querySelector(".frame-renderer");
if (!frameRenderer) return;
if (e.clientY < options.scrollThreshold.up) {
frameRenderer.scrollBy({ top: -70, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
frameRenderer.scrollBy({ top: 70, behavior: "smooth" });
const scrollableParent = getScrollParent(dragHandleElement);
if (!scrollableParent) return;
const scrollThreshold = options.scrollThreshold;
if (e.clientY < scrollThreshold.up) {
const overflow = scrollThreshold.up - e.clientY;
const ratio = Math.min(overflow / scrollThreshold.up, 1);
const scrollAmount = -maxScrollSpeed * ratio;
scrollableParent.scrollBy({ top: scrollAmount });
} else if (window.innerHeight - e.clientY < scrollThreshold.down) {
const overflow = e.clientY - (window.innerHeight - scrollThreshold.down);
const ratio = Math.min(overflow / scrollThreshold.down, 1);
const scrollAmount = maxScrollSpeed * ratio;
scrollableParent.scrollBy({ top: scrollAmount });
}
});
@@ -17,6 +17,8 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
});
transactions.forEach((transaction) => {
// if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically)
if (transaction.getMeta("skipImageDeletion")) return;
// transaction could be a selection
if (!transaction.docChanged) return;
+1 -1
View File
@@ -44,7 +44,7 @@
}
&.sans-serif {
--font-style: sans-serif;
--font-style: "Inter", sans-serif;
}
&.serif {
+3
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",
@@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => {
const { getCycleById } = useCycle();
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
@@ -28,7 +28,7 @@ import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/h
export const ProjectIssuesMobileHeader = observer(() => {
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
const [analyticsModal, setAnalyticsModal] = useState(false);
@@ -31,7 +31,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
const { getModuleById } = useModule();
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "kanban", title: "Board", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
const { workspaceSlug, projectId, moduleId } = useParams() as {
+2 -7
View File
@@ -52,13 +52,8 @@ const ProfileAppearancePage = observer(() => {
const applyThemeChange = (theme: Partial<IUserTheme>) => {
setTheme(theme?.theme || "system");
const customThemeElement = window.document?.querySelector<HTMLElement>("[data-theme='custom']");
if (theme?.theme === "custom" && theme?.palette && customThemeElement) {
applyTheme(
theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
false,
customThemeElement
);
if (theme?.theme === "custom" && theme?.palette) {
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
} else unsetCustomCssVariables();
};
+1
View File
@@ -1,2 +1,3 @@
export * from "./lite-text-editor";
export * from "./pdf";
export * from "./rich-text-editor";
@@ -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>
);
};
+1
View File
@@ -0,0 +1 @@
export * from "./document";
@@ -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>
</>
);
});
+3 -9
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") {
@@ -0,0 +1,282 @@
"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(/[^a-z0-9-_]/g, "-")
.replace(/-+/g, "-");
// handle modal close
const handleClose = () => {
onClose();
setTimeout(() => {
reset();
}, 300);
};
const initiateDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
};
// 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();
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
} 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" });
initiateDownload(blob, `${fileName}.md`);
} 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>
);
};
@@ -1,3 +1,4 @@
export * from "./create-page-modal";
export * from "./delete-page-modal";
export * from "./export-page-modal";
export * from "./page-form";
@@ -182,7 +182,7 @@ export const SidebarFavoritesMenu = observer(() => {
.map((fav, index) => (
<Tooltip
key={fav.id}
tooltipContent={fav.entity_data ? fav.entity_data.name : fav.name}
tooltipContent={fav?.entity_data ? fav.entity_data?.name : fav?.name}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
+2 -2
View File
@@ -40,12 +40,12 @@ export const CYCLE_VIEW_LAYOUTS: {
{
key: "board",
icon: LayoutGrid,
title: "Grid layout",
title: "Gallery layout",
},
{
key: "gantt",
icon: GanttChartSquare,
title: "Gantt layout",
title: "Timeline layout",
},
];
+179
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,179 @@ 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
"[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),
},
});
+7 -7
View File
@@ -156,24 +156,24 @@ export const ISSUE_EXTRA_OPTIONS: {
];
export const ISSUE_LAYOUT_MAP = {
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List Layout", label: "List", icon: List },
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Kanban Layout", label: "Kanban", icon: Kanban },
[EIssueLayoutTypes.LIST]: { key: EIssueLayoutTypes.LIST, title: "List layout", label: "List", icon: List },
[EIssueLayoutTypes.KANBAN]: { key: EIssueLayoutTypes.KANBAN, title: "Board layout", label: "Board", icon: Kanban },
[EIssueLayoutTypes.CALENDAR]: {
key: EIssueLayoutTypes.CALENDAR,
title: "Calendar Layout",
title: "Calendar layout",
label: "Calendar",
icon: Calendar,
},
[EIssueLayoutTypes.SPREADSHEET]: {
key: EIssueLayoutTypes.SPREADSHEET,
title: "Spreadsheet Layout",
label: "Spreadsheet",
title: "Table layout",
label: "Table",
icon: Sheet,
},
[EIssueLayoutTypes.GANTT]: {
key: EIssueLayoutTypes.GANTT,
title: "Gantt Chart Layout",
label: "Gantt",
title: "Timeline layout",
label: "Timeline",
icon: GanttChartSquare,
},
};
+2 -2
View File
@@ -62,12 +62,12 @@ export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title:
{
key: "board",
icon: LayoutGrid,
title: "Grid layout",
title: "Gallery layout",
},
{
key: "gantt",
icon: GanttChartSquare,
title: "Gantt layout",
title: "Timeline layout",
},
];
+1 -1
View File
@@ -13,7 +13,7 @@ import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hook
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
const favoriteItemId = favorite?.entity_data?.id;
const favoriteItemLogoProps = favorite?.entity_data?.logo_props;
const favoriteItemName = favorite?.entity_data.name || favorite?.name;
const favoriteItemName = favorite?.entity_data?.name || favorite?.name;
const favoriteItemEntityType = favorite?.entity_type;
// store hooks
+3 -27
View File
@@ -21,8 +21,6 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
const { setQuery } = useRouterParams();
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: userProfile } = useUserProfile();
// states
const [dom, setDom] = useState<HTMLElement | null>(null);
/**
* Sidebar collapsed fetching from local storage
@@ -44,36 +42,14 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
const currentThemePalette = userProfile?.theme?.palette;
if (currentTheme) {
setTheme(currentTheme);
if (currentTheme === "custom" && currentThemePalette && dom) {
if (currentTheme === "custom" && currentThemePalette) {
applyTheme(
currentThemePalette !== ",,,," ? currentThemePalette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
false,
dom
false
);
} else unsetCustomCssVariables();
}
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme, dom]);
useEffect(() => {
if (dom) return;
const observer = new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
const customThemeElement = window.document?.querySelector<HTMLElement>("[data-theme='custom']");
if (customThemeElement) {
setDom(customThemeElement);
observer.disconnect();
break;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [dom]);
}, [userProfile?.theme?.theme, userProfile?.theme?.palette, setTheme]);
useEffect(() => {
if (!params) return;
+2
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;
+157
View File
@@ -0,0 +1,157 @@
// 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 = 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 <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;
};
+42
View File
@@ -0,0 +1,42 @@
/**
* @description encode image via URL to base64
* @param {string} url
* @returns
*/
export const getBase64Image = async (url: string): Promise<string> => {
if (!url || typeof url !== "string") {
throw new Error("Invalid URL provided");
}
// Try to create a URL object to validate the URL
try {
new URL(url);
} catch (error) {
throw new Error("Invalid URL format");
}
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);
});
};
+1 -1
View File
@@ -270,7 +270,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to match valid URLs (with or without http/https)
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z]{2,6})(\/[\w.-]*)*\/?(\?[=&\w.-]*)?$/i;
const urlPattern = /^(https?:\/\/)?([\w.-]+\.[a-z]{2,6})(\/[\w\-.~:/?#[\]@!$&'()*+,;=%]*)?$/i;
// test if the URL matches the pattern
return urlPattern.test(url);
};
+10 -9
View File
@@ -59,8 +59,9 @@ const calculateShades = (hexValue: string): TShades => {
return shades as TShades;
};
export const applyTheme = (palette: string, isDarkPalette: boolean, dom: HTMLElement | null) => {
export const applyTheme = (palette: string, isDarkPalette: boolean) => {
if (!palette) return;
const themeElement = document?.querySelector("html");
// palette: [bg, text, primary, sidebarBg, sidebarText]
const values: string[] = palette.split(",");
values.push(isDarkPalette ? "dark" : "light");
@@ -80,27 +81,27 @@ export const applyTheme = (palette: string, isDarkPalette: boolean, dom: HTMLEle
const sidebarBackgroundRgbValues = `${sidebarBackgroundShades[shade].r}, ${sidebarBackgroundShades[shade].g}, ${sidebarBackgroundShades[shade].b}`;
const sidebarTextRgbValues = `${sidebarTextShades[shade].r}, ${sidebarTextShades[shade].g}, ${sidebarTextShades[shade].b}`;
dom?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
dom?.style.setProperty(`--color-text-${shade}`, textRgbValues);
dom?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
dom?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
dom?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
themeElement?.style.setProperty(`--color-background-${shade}`, bgRgbValues);
themeElement?.style.setProperty(`--color-text-${shade}`, textRgbValues);
themeElement?.style.setProperty(`--color-primary-${shade}`, primaryRgbValues);
themeElement?.style.setProperty(`--color-sidebar-background-${shade}`, sidebarBackgroundRgbValues);
themeElement?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
if (i >= 100 && i <= 400) {
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
dom?.style.setProperty(
themeElement?.style.setProperty(
`--color-border-${shade}`,
`${bgShades[borderShade].r}, ${bgShades[borderShade].g}, ${bgShades[borderShade].b}`
);
dom?.style.setProperty(
themeElement?.style.setProperty(
`--color-sidebar-border-${shade}`,
`${sidebarBackgroundShades[borderShade].r}, ${sidebarBackgroundShades[borderShade].g}, ${sidebarBackgroundShades[borderShade].b}`
);
}
}
dom?.style.setProperty("--color-scheme", values[5]);
themeElement?.style.setProperty("--color-scheme", values[5]);
};
export const unsetCustomCssVariables = () => {
+2
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.
+518 -160
View File
File diff suppressed because it is too large Load Diff