[WIKI-788] fix: editor markdown copy rules (#8140)
This commit is contained in:
parent
f510020daa
commit
d462546055
32 changed files with 1467 additions and 189 deletions
|
|
@ -6,6 +6,9 @@ import type { MakeOptional } from "@plane/types";
|
||||||
import { cn, isCommentEmpty } from "@plane/utils";
|
import { cn, isCommentEmpty } from "@plane/utils";
|
||||||
// helpers
|
// helpers
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
|
// hooks
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
|
// plane web imports
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
// local imports
|
// local imports
|
||||||
import { EditorMentionsRoot } from "./embeds/mentions";
|
import { EditorMentionsRoot } from "./embeds/mentions";
|
||||||
|
|
@ -13,7 +16,7 @@ import { IssueCommentToolbar } from "./toolbar";
|
||||||
|
|
||||||
type LiteTextEditorWrapperProps = MakeOptional<
|
type LiteTextEditorWrapperProps = MakeOptional<
|
||||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||||
"disabledExtensions" | "flaggedExtensions"
|
"disabledExtensions" | "flaggedExtensions" | "getEditorMetaData"
|
||||||
> & {
|
> & {
|
||||||
anchor: string;
|
anchor: string;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
|
|
@ -47,6 +50,10 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
const isEmpty = isCommentEmpty(props.initialValue);
|
const isEmpty = isCommentEmpty(props.initialValue);
|
||||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||||
const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor);
|
const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor);
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
||||||
|
|
@ -60,6 +67,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
uploadFile: editable ? props.uploadFile : async () => "",
|
uploadFile: editable ? props.uploadFile : async () => "",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
})}
|
})}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { RichTextEditorWithRef } from "@plane/editor";
|
import { RichTextEditorWithRef } from "@plane/editor";
|
||||||
import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor";
|
import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor";
|
||||||
|
|
@ -7,6 +7,7 @@ import type { MakeOptional } from "@plane/types";
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web imports
|
// plane web imports
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
// local imports
|
// local imports
|
||||||
|
|
@ -14,7 +15,7 @@ import { EditorMentionsRoot } from "./embeds/mentions";
|
||||||
|
|
||||||
type RichTextEditorWrapperProps = MakeOptional<
|
type RichTextEditorWrapperProps = MakeOptional<
|
||||||
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||||
"disabledExtensions" | "flaggedExtensions"
|
"disabledExtensions" | "flaggedExtensions" | "getEditorMetaData"
|
||||||
> & {
|
> & {
|
||||||
anchor: string;
|
anchor: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|
@ -37,7 +38,13 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||||
disabledExtensions: additionalDisabledExtensions = [],
|
disabledExtensions: additionalDisabledExtensions = [],
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
// store hooks
|
||||||
const { getMemberById } = useMember();
|
const { getMemberById } = useMember();
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
|
// editor flaggings
|
||||||
const { richText: richTextEditorExtensions } = useEditorFlagging(anchor);
|
const { richText: richTextEditorExtensions } = useEditorFlagging(anchor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -56,6 +63,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||||
uploadFile: editable ? props.uploadFile : async () => "",
|
uploadFile: editable ? props.uploadFile : async () => "",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
})}
|
})}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
flaggedExtensions={richTextEditorExtensions.flagged}
|
flaggedExtensions={richTextEditorExtensions.flagged}
|
||||||
extendedEditorProps={{}}
|
extendedEditorProps={{}}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|
|
||||||
69
apps/space/core/hooks/use-parse-editor-content.ts
Normal file
69
apps/space/core/hooks/use-parse-editor-content.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
// helpers
|
||||||
|
import type { TCustomComponentsMetaData } from "@plane/utils";
|
||||||
|
// helpers
|
||||||
|
import { getEditorAssetSrc } from "@/helpers/editor.helper";
|
||||||
|
// hooks
|
||||||
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
|
|
||||||
|
type TArgs = {
|
||||||
|
anchor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useParseEditorContent = (args: TArgs) => {
|
||||||
|
const { anchor } = args;
|
||||||
|
// store hooks
|
||||||
|
const { getMemberById } = useMember();
|
||||||
|
|
||||||
|
const getEditorMetaData = useCallback(
|
||||||
|
(htmlContent: string): TCustomComponentsMetaData => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||||
|
const imageMetaData: TCustomComponentsMetaData["file_assets"] = [];
|
||||||
|
// process image components
|
||||||
|
const imageComponents = doc.querySelectorAll("image-component");
|
||||||
|
imageComponents.forEach((element) => {
|
||||||
|
const src = element.getAttribute("src");
|
||||||
|
if (src) {
|
||||||
|
const assetSrc = src.startsWith("http") ? src : getEditorAssetSrc(anchor, src);
|
||||||
|
if (assetSrc) {
|
||||||
|
imageMetaData.push({
|
||||||
|
id: src,
|
||||||
|
name: src,
|
||||||
|
url: assetSrc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// process user mentions
|
||||||
|
const userMentions: TCustomComponentsMetaData["user_mentions"] = [];
|
||||||
|
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||||
|
mentionComponents.forEach((element) => {
|
||||||
|
const id = element.getAttribute("entity_identifier");
|
||||||
|
if (id) {
|
||||||
|
const userDetails = getMemberById(id);
|
||||||
|
const originUrl = typeof window !== "undefined" && (window.location.origin ?? "");
|
||||||
|
const path = `profile/${id}`;
|
||||||
|
const url = `${originUrl}/${path}`;
|
||||||
|
if (userDetails) {
|
||||||
|
userMentions.push({
|
||||||
|
id,
|
||||||
|
display_name: userDetails.member__display_name,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file_assets: imageMetaData,
|
||||||
|
user_mentions: userMentions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[anchor, getMemberById]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getEditorMetaData,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ import { cn } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
// local imports
|
// local imports
|
||||||
|
|
@ -14,7 +15,7 @@ import { EditorMentionsRoot } from "../embeds/mentions";
|
||||||
|
|
||||||
type DocumentEditorWrapperProps = MakeOptional<
|
type DocumentEditorWrapperProps = MakeOptional<
|
||||||
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "user" | "extendedEditorProps">,
|
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "user" | "extendedEditorProps">,
|
||||||
"disabledExtensions" | "editable" | "flaggedExtensions"
|
"disabledExtensions" | "editable" | "flaggedExtensions" | "getEditorMetaData"
|
||||||
> & {
|
> & {
|
||||||
extendedEditorProps?: Partial<IEditorPropsExtended>;
|
extendedEditorProps?: Partial<IEditorPropsExtended>;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
|
|
@ -44,6 +45,11 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
|
||||||
} = props;
|
} = props;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { document: documentEditorExtensions } = useEditorFlagging({
|
const { document: documentEditorExtensions } = useEditorFlagging({
|
||||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||||
|
|
@ -68,6 +74,7 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
searchCallback: async (query) => {
|
searchCallback: async (query) => {
|
||||||
const res = await fetchMentions(query);
|
const res = await fetchMentions(query);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { IssueCommentToolbar } from "@/components/editor/lite-text/toolbar";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
// plane web service
|
// plane web service
|
||||||
|
|
@ -22,7 +23,7 @@ const workspaceService = new WorkspaceService();
|
||||||
|
|
||||||
type LiteTextEditorWrapperProps = MakeOptional<
|
type LiteTextEditorWrapperProps = MakeOptional<
|
||||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||||
"disabledExtensions" | "flaggedExtensions"
|
"disabledExtensions" | "flaggedExtensions" | "getEditorMetaData"
|
||||||
> & {
|
> & {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|
@ -80,6 +81,11 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
});
|
});
|
||||||
// store hooks
|
// store hooks
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
// use editor mention
|
// use editor mention
|
||||||
const { fetchMentions } = useEditorMention({
|
const { fetchMentions } = useEditorMention({
|
||||||
searchEntity: async (payload) =>
|
searchEntity: async (payload) =>
|
||||||
|
|
@ -124,6 +130,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
handleEditorReady={(ready) => {
|
handleEditorReady={(ready) => {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
setEditorRef(isMutableRefObject<EditorRefApi>(ref) ? ref.current : null);
|
setEditorRef(isMutableRefObject<EditorRefApi>(ref) ? ref.current : null);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { RichTextEditorWithRef } from "@plane/editor";
|
import { RichTextEditorWithRef } from "@plane/editor";
|
||||||
import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor";
|
import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor";
|
||||||
|
|
@ -9,12 +9,13 @@ import { EditorMentionsRoot } from "@/components/editor/embeds/mentions";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
|
|
||||||
type RichTextEditorWrapperProps = MakeOptional<
|
type RichTextEditorWrapperProps = MakeOptional<
|
||||||
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||||
"disabledExtensions" | "editable" | "flaggedExtensions"
|
"disabledExtensions" | "editable" | "flaggedExtensions" | "getEditorMetaData"
|
||||||
> & {
|
> & {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|
@ -53,6 +54,11 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||||
});
|
});
|
||||||
// editor config
|
// editor config
|
||||||
const { getEditorFileHandlers } = useEditorConfig();
|
const { getEditorFileHandlers } = useEditorConfig();
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RichTextEditorWithRef
|
<RichTextEditorWithRef
|
||||||
|
|
@ -66,6 +72,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
searchCallback: async (query) => {
|
searchCallback: async (query) => {
|
||||||
const res = await fetchMentions(query);
|
const res = await fetchMentions(query);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { TSticky } from "@plane/types";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEditorConfig } from "@/hooks/editor";
|
import { useEditorConfig } from "@/hooks/editor";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
import { StickyEditorToolbar } from "./toolbar";
|
import { StickyEditorToolbar } from "./toolbar";
|
||||||
|
|
@ -17,7 +18,7 @@ import { StickyEditorToolbar } from "./toolbar";
|
||||||
interface StickyEditorWrapperProps
|
interface StickyEditorWrapperProps
|
||||||
extends Omit<
|
extends Omit<
|
||||||
Omit<ILiteTextEditorProps, "extendedEditorProps">,
|
Omit<ILiteTextEditorProps, "extendedEditorProps">,
|
||||||
"disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler"
|
"disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "getEditorMetaData"
|
||||||
> {
|
> {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|
@ -55,6 +56,11 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||||
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
|
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
|
||||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||||
});
|
});
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
// editor config
|
// editor config
|
||||||
const { getEditorFileHandlers } = useEditorConfig();
|
const { getEditorFileHandlers } = useEditorConfig();
|
||||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||||
|
|
@ -62,6 +68,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||||
}
|
}
|
||||||
// derived values
|
// derived values
|
||||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("relative border border-custom-border-200 rounded", parentClassName)}
|
className={cn("relative border border-custom-border-200 rounded", parentClassName)}
|
||||||
|
|
@ -79,6 +86,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
renderComponent: () => <></>,
|
renderComponent: () => <></>,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
import { useUser } from "@/hooks/store/user";
|
import { useUser } from "@/hooks/store/user";
|
||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
// plane web imports
|
// plane web imports
|
||||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
import { EditorAIMenu } from "@/plane-web/components/pages";
|
||||||
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
|
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
|
||||||
|
|
@ -57,10 +58,9 @@ type Props = {
|
||||||
isNavigationPaneOpen: boolean;
|
isNavigationPaneOpen: boolean;
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||||
projectId: string;
|
projectId?: string;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
storeType: EPageStoreType;
|
storeType: EPageStoreType;
|
||||||
|
|
||||||
extendedEditorProps: TExtendedEditorExtensionsConfig;
|
extendedEditorProps: TExtendedEditorExtensionsConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -103,6 +103,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
storeType,
|
storeType,
|
||||||
});
|
});
|
||||||
|
// parse content
|
||||||
|
const { getEditorMetaData } = useParseEditorContent({
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
// page filters
|
// page filters
|
||||||
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
|
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
|
||||||
// translation
|
// translation
|
||||||
|
|
@ -235,6 +240,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||||
ref={editorForwardRef}
|
ref={editorForwardRef}
|
||||||
containerClassName="h-full p-0 pb-64"
|
containerClassName="h-full p-0 pb-64"
|
||||||
displayConfig={displayConfig}
|
displayConfig={displayConfig}
|
||||||
|
getEditorMetaData={getEditorMetaData}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
searchCallback: async (query) => {
|
searchCallback: async (query) => {
|
||||||
const res = await fetchMentions(query);
|
const res = await fetchMentions(query);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { PageEditorHeaderLogoPicker } from "./logo-picker";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
projectId: string;
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ type TPageRootProps = {
|
||||||
page: TPageInstance;
|
page: TPageInstance;
|
||||||
storeType: EPageStoreType;
|
storeType: EPageStoreType;
|
||||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||||
projectId: string;
|
projectId?: string;
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
||||||
import type { PageProps } from "@react-pdf/renderer";
|
import type { PageProps } from "@react-pdf/renderer";
|
||||||
import { pdf } from "@react-pdf/renderer";
|
import { pdf } from "@react-pdf/renderer";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { useParams } from "react-router";
|
||||||
// plane editor
|
// plane editor
|
||||||
import type { EditorRefApi } from "@plane/editor";
|
import type { EditorRefApi } from "@plane/editor";
|
||||||
// plane ui
|
// plane ui
|
||||||
|
|
@ -100,13 +101,17 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||||
const { editorRef, isOpen, onClose, pageTitle } = props;
|
const { editorRef, isOpen, onClose, pageTitle } = props;
|
||||||
// states
|
// states
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
// params
|
||||||
|
const { workspaceSlug, projectId } = useParams();
|
||||||
// form info
|
// form info
|
||||||
const { control, reset, watch } = useForm<TFormValues>({
|
const { control, reset, watch } = useForm<TFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
// parse editor content
|
// parse editor content
|
||||||
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } =
|
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } = useParseEditorContent({
|
||||||
useParseEditorContent();
|
projectId,
|
||||||
|
workspaceSlug: workspaceSlug ?? "",
|
||||||
|
});
|
||||||
// derived values
|
// derived values
|
||||||
const selectedExportFormat = watch("export_format");
|
const selectedExportFormat = watch("export_format");
|
||||||
const selectedPageFormat = watch("page_format");
|
const selectedPageFormat = watch("page_format");
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
// plane types
|
// plane types
|
||||||
import type { TSearchEntities } from "@plane/types";
|
import type { TSearchEntities } from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { getBase64Image } from "@plane/utils";
|
import { getBase64Image, getEditorAssetSrc } from "@plane/utils";
|
||||||
|
import type { TCustomComponentsMetaData } from "@plane/utils";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
|
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
|
||||||
|
|
||||||
export const useParseEditorContent = () => {
|
type TArgs = {
|
||||||
// params
|
projectId?: string;
|
||||||
const { workspaceSlug } = useParams();
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useParseEditorContent = (args: TArgs) => {
|
||||||
|
const { projectId, workspaceSlug } = args;
|
||||||
// store hooks
|
// store hooks
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
// parse additional content
|
// parse additional content
|
||||||
|
|
@ -150,7 +154,7 @@ export const useParseEditorContent = () => {
|
||||||
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
||||||
return serializedDoc;
|
return serializedDoc;
|
||||||
},
|
},
|
||||||
[getUserDetails]
|
[getUserDetails, parseAdditionalEditorContent]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -160,7 +164,6 @@ export const useParseEditorContent = () => {
|
||||||
*/
|
*/
|
||||||
const replaceCustomComponentsFromMarkdownContent = useCallback(
|
const replaceCustomComponentsFromMarkdownContent = useCallback(
|
||||||
(props: { markdownContent: string; noAssets?: boolean }): string => {
|
(props: { markdownContent: string; noAssets?: boolean }): string => {
|
||||||
const start = performance.now();
|
|
||||||
const { markdownContent, noAssets = false } = props;
|
const { markdownContent, noAssets = false } = props;
|
||||||
let parsedMarkdownContent = markdownContent;
|
let parsedMarkdownContent = markdownContent;
|
||||||
// replace the matched mention components with [display_name](redirect_url)
|
// replace the matched mention components with [display_name](redirect_url)
|
||||||
|
|
@ -203,15 +206,68 @@ export const useParseEditorContent = () => {
|
||||||
// remove all issue-embed components
|
// remove all issue-embed components
|
||||||
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
||||||
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
||||||
const end = performance.now();
|
|
||||||
console.log("Exec time:", end - start);
|
|
||||||
return parsedMarkdownContent;
|
return parsedMarkdownContent;
|
||||||
},
|
},
|
||||||
[getUserDetails, workspaceSlug]
|
[getUserDetails, parseAdditionalEditorContent, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getEditorMetaData = useCallback(
|
||||||
|
(htmlContent: string): TCustomComponentsMetaData => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||||
|
const imageMetaData: TCustomComponentsMetaData["file_assets"] = [];
|
||||||
|
// process image components
|
||||||
|
const imageComponents = doc.querySelectorAll("image-component");
|
||||||
|
imageComponents.forEach((element) => {
|
||||||
|
const src = element.getAttribute("src");
|
||||||
|
if (src) {
|
||||||
|
const assetSrc = src.startsWith("http")
|
||||||
|
? src
|
||||||
|
: getEditorAssetSrc({
|
||||||
|
assetId: src,
|
||||||
|
projectId,
|
||||||
|
workspaceSlug,
|
||||||
|
});
|
||||||
|
if (assetSrc) {
|
||||||
|
imageMetaData.push({
|
||||||
|
id: src,
|
||||||
|
name: src,
|
||||||
|
url: assetSrc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// process user mentions
|
||||||
|
const userMentions: TCustomComponentsMetaData["user_mentions"] = [];
|
||||||
|
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||||
|
mentionComponents.forEach((element) => {
|
||||||
|
const id = element.getAttribute("entity_identifier");
|
||||||
|
if (id) {
|
||||||
|
const userDetails = getUserDetails(id);
|
||||||
|
const originUrl = typeof window !== "undefined" && (window.location.origin ?? "");
|
||||||
|
const path = `${workspaceSlug}/profile/${id}`;
|
||||||
|
const url = `${originUrl}/${path}`;
|
||||||
|
if (userDetails) {
|
||||||
|
userMentions.push({
|
||||||
|
id,
|
||||||
|
display_name: userDetails.display_name,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file_assets: imageMetaData,
|
||||||
|
user_mentions: userMentions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[getUserDetails, projectId, workspaceSlug]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
replaceCustomComponentsFromHTMLContent,
|
replaceCustomComponentsFromHTMLContent,
|
||||||
replaceCustomComponentsFromMarkdownContent,
|
replaceCustomComponentsFromMarkdownContent,
|
||||||
|
getEditorMetaData,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
id,
|
id,
|
||||||
dragDropEnabled = true,
|
dragDropEnabled = true,
|
||||||
|
|
@ -57,6 +58,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||||
extensions,
|
extensions,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
|
getEditorMetaData,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,10 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
|
handleEditorReady,
|
||||||
id,
|
id,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
handleEditorReady,
|
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
onChange,
|
onChange,
|
||||||
user,
|
user,
|
||||||
|
|
@ -72,6 +73,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
id,
|
id,
|
||||||
initialValue: value,
|
initialValue: value,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||||
editorProps,
|
editorProps,
|
||||||
extendedEditorProps,
|
extendedEditorProps,
|
||||||
extensions,
|
extensions,
|
||||||
|
getEditorMetaData,
|
||||||
id,
|
id,
|
||||||
initialValue,
|
initialValue,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
|
|
@ -55,6 +56,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
id,
|
id,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
initialValue,
|
initialValue,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ type TArguments = Pick<
|
||||||
| "disabledExtensions"
|
| "disabledExtensions"
|
||||||
| "flaggedExtensions"
|
| "flaggedExtensions"
|
||||||
| "fileHandler"
|
| "fileHandler"
|
||||||
|
| "getEditorMetaData"
|
||||||
| "isTouchDevice"
|
| "isTouchDevice"
|
||||||
| "mentionHandler"
|
| "mentionHandler"
|
||||||
| "placeholder"
|
| "placeholder"
|
||||||
|
|
@ -60,6 +61,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
enableHistory,
|
enableHistory,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
|
getEditorMetaData,
|
||||||
isTouchDevice = false,
|
isTouchDevice = false,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
|
@ -114,6 +116,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
UtilityExtension({
|
UtilityExtension({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
|
getEditorMetaData,
|
||||||
isEditable: editable,
|
isEditable: editable,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Extension } from "@tiptap/core";
|
import { Extension } from "@tiptap/core";
|
||||||
import codemark from "prosemirror-codemark";
|
import codemark from "prosemirror-codemark";
|
||||||
// helpers
|
// helpers
|
||||||
import type { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
import { restorePublicImages } from "@/helpers/image-helpers";
|
import { restorePublicImages } from "@/helpers/image-helpers";
|
||||||
// plugins
|
// plugins
|
||||||
import type { TAdditionalActiveDropbarExtensions } from "@/plane-editor/types/utils";
|
import type { TAdditionalActiveDropbarExtensions } from "@/plane-editor/types/utils";
|
||||||
|
|
@ -50,18 +50,18 @@ export type UtilityExtensionStorage = {
|
||||||
isTouchDevice: boolean;
|
isTouchDevice: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = Pick<IEditorProps, "disabledExtensions"> & {
|
type Props = Pick<IEditorProps, "disabledExtensions" | "getEditorMetaData"> & {
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
isEditable: boolean;
|
isEditable: boolean;
|
||||||
isTouchDevice: boolean;
|
isTouchDevice: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UtilityExtension = (props: Props) => {
|
export const UtilityExtension = (props: Props) => {
|
||||||
const { disabledExtensions, fileHandler, isEditable, isTouchDevice } = props;
|
const { disabledExtensions, fileHandler, getEditorMetaData, isEditable, isTouchDevice } = props;
|
||||||
const { restore } = fileHandler;
|
const { restore } = fileHandler;
|
||||||
|
|
||||||
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
|
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
|
||||||
name: "utility",
|
name: CORE_EXTENSIONS.UTILITY,
|
||||||
priority: 1000,
|
priority: 1000,
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
|
@ -72,7 +72,10 @@ export const UtilityExtension = (props: Props) => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
}),
|
}),
|
||||||
...codemark({ markType: this.editor.schema.marks.code }),
|
...codemark({ markType: this.editor.schema.marks.code }),
|
||||||
MarkdownClipboardPlugin(this.editor),
|
MarkdownClipboardPlugin({
|
||||||
|
editor: this.editor,
|
||||||
|
getEditorMetaData,
|
||||||
|
}),
|
||||||
DropHandlerPlugin({
|
DropHandlerPlugin({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||||
extensions = [],
|
extensions = [],
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
|
getEditorMetaData,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
id,
|
id,
|
||||||
|
|
@ -109,6 +110,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
|
getEditorMetaData,
|
||||||
handleEditorReady,
|
handleEditorReady,
|
||||||
id = "",
|
id = "",
|
||||||
initialValue,
|
initialValue,
|
||||||
|
|
@ -65,6 +66,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||||
extendedEditorProps,
|
extendedEditorProps,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
|
getEditorMetaData,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
mentionHandler,
|
mentionHandler,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,44 @@
|
||||||
import type { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
import type { Fragment } from "@tiptap/pm/model";
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
// constants
|
// plane imports
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { convertHTMLToMarkdown } from "@plane/utils";
|
||||||
|
import type { TCustomComponentsMetaData } from "@plane/utils";
|
||||||
|
|
||||||
export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
|
type TArgs = {
|
||||||
new Plugin({
|
editor: Editor;
|
||||||
|
getEditorMetaData: (htmlContent: string) => TCustomComponentsMetaData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarkdownClipboardPlugin = (args: TArgs): Plugin => {
|
||||||
|
const { editor, getEditorMetaData } = args;
|
||||||
|
|
||||||
|
return new Plugin({
|
||||||
key: new PluginKey("markdownClipboard"),
|
key: new PluginKey("markdownClipboard"),
|
||||||
props: {
|
props: {
|
||||||
clipboardTextSerializer: (slice) => {
|
handleDOMEvents: {
|
||||||
const markdownSerializer = editor.storage.markdown.serializer;
|
copy: (view, event) => {
|
||||||
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
|
try {
|
||||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
event.preventDefault();
|
||||||
|
event.clipboardData?.clearData();
|
||||||
if (nodeSelect) {
|
// editor meta data
|
||||||
return markdownSerializer.serialize(slice.content);
|
const editorHTML = editor.getHTML();
|
||||||
}
|
const metaData = getEditorMetaData(editorHTML);
|
||||||
|
// meta data from selection
|
||||||
const processTableContent = (tableNode: Node | Fragment) => {
|
const clipboardHTML = view.serializeForClipboard(view.state.selection.content()).dom.innerHTML;
|
||||||
let result = "";
|
// convert to markdown
|
||||||
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
|
const markdown = convertHTMLToMarkdown({
|
||||||
tableRowNode.content?.forEach?.((cell: Node) => {
|
description_html: clipboardHTML,
|
||||||
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
|
metaData,
|
||||||
result += cellContent + "\n";
|
|
||||||
});
|
});
|
||||||
});
|
event.clipboardData?.setData("text/plain", markdown);
|
||||||
return result;
|
event.clipboardData?.setData("text/html", clipboardHTML);
|
||||||
};
|
return true;
|
||||||
|
} catch (error) {
|
||||||
if (isTableRow) {
|
console.error("Failed to copy markdown content to clipboard:", error);
|
||||||
const rowsCount = slice.content?.childCount || 0;
|
return false;
|
||||||
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
|
|
||||||
if (rowsCount === 1 || cellsCount === 1) {
|
|
||||||
return processTableContent(slice.content);
|
|
||||||
} else {
|
|
||||||
return markdownSerializer.serialize(slice.content);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
const traverseToParentOfLeaf = (node: Node | null, parent: Fragment | Node, depth: number): Node | Fragment => {
|
|
||||||
let currentNode = node;
|
|
||||||
let currentParent = parent;
|
|
||||||
let currentDepth = depth;
|
|
||||||
|
|
||||||
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
|
|
||||||
if (currentNode.content?.childCount > 1) {
|
|
||||||
if (currentNode.content.firstChild?.type?.name === CORE_EXTENSIONS.LIST_ITEM) {
|
|
||||||
return currentParent;
|
|
||||||
} else {
|
|
||||||
return currentNode.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentParent = currentNode;
|
|
||||||
currentNode = currentNode.content?.firstChild || null;
|
|
||||||
currentDepth--;
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentParent;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (slice.content.childCount > 1) {
|
|
||||||
return markdownSerializer.serialize(slice.content);
|
|
||||||
} else {
|
|
||||||
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
|
|
||||||
|
|
||||||
let currentNode = targetNode;
|
|
||||||
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
|
|
||||||
currentNode = currentNode.firstChild;
|
|
||||||
}
|
|
||||||
if (currentNode instanceof Node && currentNode.isText) {
|
|
||||||
return currentNode.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return markdownSerializer.serialize(targetNode);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type { MarkType, NodeType } from "@tiptap/pm/model";
|
||||||
import type { Selection } from "@tiptap/pm/state";
|
import type { Selection } from "@tiptap/pm/state";
|
||||||
import type { EditorProps, EditorView } from "@tiptap/pm/view";
|
import type { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||||
import type { NodeViewProps as TNodeViewProps } from "@tiptap/react";
|
import type { NodeViewProps as TNodeViewProps } from "@tiptap/react";
|
||||||
|
// plane imports
|
||||||
|
import type { TCustomComponentsMetaData } from "@plane/utils";
|
||||||
// extension types
|
// extension types
|
||||||
import type { TTextAlign } from "@/extensions";
|
import type { TTextAlign } from "@/extensions";
|
||||||
// plane editor imports
|
// plane editor imports
|
||||||
|
|
@ -19,7 +21,6 @@ import type {
|
||||||
TDocumentEventEmitter,
|
TDocumentEventEmitter,
|
||||||
TDocumentEventsServer,
|
TDocumentEventsServer,
|
||||||
TEditorAsset,
|
TEditorAsset,
|
||||||
TEmbedConfig,
|
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
TMentionHandler,
|
TMentionHandler,
|
||||||
|
|
@ -151,6 +152,7 @@ export type IEditorProps = {
|
||||||
flaggedExtensions: TExtensions[];
|
flaggedExtensions: TExtensions[];
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
|
getEditorMetaData: (htmlContent: string) => TCustomComponentsMetaData;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
id: string;
|
id: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type TCoreHookProps = Pick<
|
||||||
| "extendedEditorProps"
|
| "extendedEditorProps"
|
||||||
| "extensions"
|
| "extensions"
|
||||||
| "flaggedExtensions"
|
| "flaggedExtensions"
|
||||||
|
| "getEditorMetaData"
|
||||||
| "handleEditorReady"
|
| "handleEditorReady"
|
||||||
| "isTouchDevice"
|
| "isTouchDevice"
|
||||||
| "onEditorFocus"
|
| "onEditorFocus"
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,26 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "3.2.7",
|
"dompurify": "3.2.7",
|
||||||
|
"hast": "^1.0.0",
|
||||||
|
"hast-util-to-mdast": "^10.1.2",
|
||||||
"lodash-es": "catalog:",
|
"lodash-es": "catalog:",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
|
"mdast": "^3.0.0",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
"rehype-parse": "^9.0.1",
|
||||||
|
"rehype-remark": "^10.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-stringify": "^11.0.0",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"unified": "^11.0.5",
|
||||||
"uuid": "catalog:"
|
"uuid": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@plane/eslint-config": "workspace:*",
|
"@plane/eslint-config": "workspace:*",
|
||||||
"@plane/typescript-config": "workspace:*",
|
"@plane/typescript-config": "workspace:*",
|
||||||
|
"@types/hast": "^3.0.4",
|
||||||
"@types/lodash-es": "catalog:",
|
"@types/lodash-es": "catalog:",
|
||||||
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// local imports
|
// local imports
|
||||||
import { getFileURL } from "./file";
|
import { getFileURL } from "../file";
|
||||||
|
|
||||||
type TEditorSrcArgs = {
|
type TEditorSrcArgs = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
2
packages/utils/src/editor/index.ts
Normal file
2
packages/utils/src/editor/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./common";
|
||||||
|
export * from "./markdown-parser";
|
||||||
6
packages/utils/src/editor/markdown-parser/common.ts
Normal file
6
packages/utils/src/editor/markdown-parser/common.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { Text as MDASTText } from "mdast";
|
||||||
|
|
||||||
|
export const createTextNode = (value: string): MDASTText => ({
|
||||||
|
type: "text",
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { Handle } from "hast-util-to-mdast";
|
||||||
|
// local imports
|
||||||
|
import { createTextNode } from "./common";
|
||||||
|
import type { TCustomComponentsMetaData } from "./types";
|
||||||
|
|
||||||
|
type TArgs = {
|
||||||
|
metaData: TCustomComponentsMetaData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCustomComponents = (args: TArgs): Record<string, Handle> => {
|
||||||
|
const { metaData } = args;
|
||||||
|
|
||||||
|
const getFileAssetDetails = (id: string) => metaData.file_assets.find((asset) => asset.id === id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"image-component": (_state, node) => {
|
||||||
|
const properties = node.properties || {};
|
||||||
|
const src = String(properties.src);
|
||||||
|
const fileAssetDetails = getFileAssetDetails(src);
|
||||||
|
if (!src || !fileAssetDetails) return createTextNode("");
|
||||||
|
return createTextNode(``);
|
||||||
|
},
|
||||||
|
img: (_state, node) => {
|
||||||
|
const properties = node.properties || {};
|
||||||
|
const src = String(properties.src);
|
||||||
|
const alt = String(properties.alt);
|
||||||
|
if (!src || !alt) return createTextNode("");
|
||||||
|
return createTextNode(``);
|
||||||
|
},
|
||||||
|
"mention-component": (_state, node) => {
|
||||||
|
const properties = node.properties || {};
|
||||||
|
const userId = String(properties.entity_identifier);
|
||||||
|
const userDetails = metaData.user_mentions.find((user) => user.id === userId);
|
||||||
|
if (!userDetails) return createTextNode("");
|
||||||
|
return createTextNode(`[@${userDetails.display_name || "Unknown user"}](${userDetails.url || ""}) `);
|
||||||
|
},
|
||||||
|
...parseExtendedCustomComponents({ metaData }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExtendedCustomComponents = (_args: TArgs): Record<string, Handle> => ({});
|
||||||
2
packages/utils/src/editor/markdown-parser/index.ts
Normal file
2
packages/utils/src/editor/markdown-parser/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./root";
|
||||||
42
packages/utils/src/editor/markdown-parser/marks-handler.ts
Normal file
42
packages/utils/src/editor/markdown-parser/marks-handler.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { Handle } from "hast-util-to-mdast";
|
||||||
|
import type { PhrasingContent, Text as MDASTText } from "mdast";
|
||||||
|
// local imports
|
||||||
|
import { createTextNode } from "./common";
|
||||||
|
|
||||||
|
const processMarkElement = (state: Parameters<Handle>[0], node: Parameters<Handle>[1], wrapper: string): MDASTText => {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
// Process all children and collect their text content
|
||||||
|
const processedChildren: PhrasingContent[] = [];
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === "text") {
|
||||||
|
// Direct text child - keep as is
|
||||||
|
processedChildren.push(child as MDASTText);
|
||||||
|
} else if (child.type === "element") {
|
||||||
|
// Element child - recursively process it
|
||||||
|
const processed = state.one(child, node);
|
||||||
|
if (processed) {
|
||||||
|
if (Array.isArray(processed)) {
|
||||||
|
processedChildren.push(...(processed as PhrasingContent[]));
|
||||||
|
} else {
|
||||||
|
processedChildren.push(processed as PhrasingContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate all text content and wrap with the specified wrapper
|
||||||
|
const combinedText = processedChildren.map((child) => (child.type === "text" ? child.value : "")).join("");
|
||||||
|
|
||||||
|
return createTextNode(`${wrapper}${combinedText}${wrapper}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty element - return empty text
|
||||||
|
return createTextNode("");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseMarks: Record<string, Handle> = {
|
||||||
|
u: (state, node) => processMarkElement(state, node, ""),
|
||||||
|
i: (state, node) => processMarkElement(state, node, "_"),
|
||||||
|
em: (state, node) => processMarkElement(state, node, "_"),
|
||||||
|
};
|
||||||
143
packages/utils/src/editor/markdown-parser/root.ts
Normal file
143
packages/utils/src/editor/markdown-parser/root.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// - Parses TipTap/ProseMirror HTML fragments
|
||||||
|
// - Removes <u> tags (Markdown has no underline)
|
||||||
|
// - Adds a space after checkbox inputs for correct GFM task list rendering
|
||||||
|
// - Converts to Markdown using rehype→remark, GFM, and remark-stringify
|
||||||
|
|
||||||
|
import type { Element as HASTElement, ElementContent, Parent as HASTParent } from "hast";
|
||||||
|
import rehypeParse from "rehype-parse";
|
||||||
|
import rehypeRemark from "rehype-remark";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkStringify from "remark-stringify";
|
||||||
|
import { unified } from "unified";
|
||||||
|
// local imports
|
||||||
|
import { parseCustomComponents } from "./custom-components-handler";
|
||||||
|
import { parseMarks } from "./marks-handler";
|
||||||
|
import type { TCustomComponentsMetaData } from "./types";
|
||||||
|
|
||||||
|
// Rehype plugin to handle TipTap task lists and convert them to GFM-compatible format
|
||||||
|
// TipTap structure: <li data-type="taskItem"><label><input><span></span></label><div><p>text</p></div></li>
|
||||||
|
// We need: <li><input> text (with space after checkbox for proper GFM rendering)
|
||||||
|
function addSpacesToCheckboxes() {
|
||||||
|
return (tree: HASTParent) => {
|
||||||
|
const helper = (node: HASTParent): void => {
|
||||||
|
if (!Array.isArray(node.children) || node.children.length === 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
|
const child = node.children[i];
|
||||||
|
|
||||||
|
// Check if this is a task list item
|
||||||
|
if (
|
||||||
|
child &&
|
||||||
|
child.type === "element" &&
|
||||||
|
child.tagName === "li" &&
|
||||||
|
child.properties &&
|
||||||
|
child.properties["data-type"] === "taskItem"
|
||||||
|
) {
|
||||||
|
const liElement = child as HASTElement;
|
||||||
|
|
||||||
|
// Find the label and div elements
|
||||||
|
const label = liElement.children?.find(
|
||||||
|
(c) => c.type === "element" && (c as HASTElement).tagName === "label"
|
||||||
|
) as HASTElement | undefined;
|
||||||
|
|
||||||
|
const contentDiv = liElement.children?.find(
|
||||||
|
(c) => c.type === "element" && (c as HASTElement).tagName === "div"
|
||||||
|
) as HASTElement | undefined;
|
||||||
|
|
||||||
|
if (label && contentDiv) {
|
||||||
|
// Find the checkbox input
|
||||||
|
const checkbox = label.children?.find(
|
||||||
|
(c) =>
|
||||||
|
c.type === "element" &&
|
||||||
|
(c as HASTElement).tagName === "input" &&
|
||||||
|
(c as HASTElement).properties?.type === "checkbox"
|
||||||
|
) as HASTElement | undefined;
|
||||||
|
|
||||||
|
if (checkbox) {
|
||||||
|
// Extract text content from the div, unwrapping any paragraph tags
|
||||||
|
const textContent: ElementContent[] = [];
|
||||||
|
if (contentDiv.children) {
|
||||||
|
for (const child of contentDiv.children) {
|
||||||
|
if (child.type === "element" && (child as HASTElement).tagName === "p") {
|
||||||
|
// Unwrap paragraph - add its children directly
|
||||||
|
const pElement = child as HASTElement;
|
||||||
|
if (pElement.children) {
|
||||||
|
textContent.push(...pElement.children);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keep other elements as-is
|
||||||
|
textContent.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the structure: move checkbox and content to be direct children of li
|
||||||
|
liElement.children = [
|
||||||
|
checkbox,
|
||||||
|
{ type: "text", value: " " }, // Add space after checkbox
|
||||||
|
...textContent,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (child && child.type === "element") {
|
||||||
|
helper(child as HASTElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
helper(tree);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TArgs = {
|
||||||
|
description_html: string;
|
||||||
|
metaData: TCustomComponentsMetaData;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a string by escaping HTML entities to prevent XSS attacks
|
||||||
|
* @param str - The string to sanitize
|
||||||
|
* @returns The sanitized string with escaped HTML entities
|
||||||
|
*/
|
||||||
|
function sanitizeHTML(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertHTMLToMarkdown(args: TArgs): string {
|
||||||
|
const { description_html, metaData, name } = args;
|
||||||
|
|
||||||
|
let updatedDescriptionHtml = description_html;
|
||||||
|
if (name) {
|
||||||
|
const sanitizedName = sanitizeHTML(name);
|
||||||
|
updatedDescriptionHtml = `<h1>${sanitizedName}</h1>\n\n${description_html}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = unified()
|
||||||
|
.use(rehypeParse, { fragment: true })
|
||||||
|
.use(addSpacesToCheckboxes)
|
||||||
|
.use(rehypeRemark, {
|
||||||
|
handlers: {
|
||||||
|
...parseCustomComponents({
|
||||||
|
metaData,
|
||||||
|
}),
|
||||||
|
...parseMarks,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkStringify, {
|
||||||
|
handlers: {
|
||||||
|
text: (node: { value: string }): string => node.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.processSync(updatedDescriptionHtml);
|
||||||
|
|
||||||
|
const markdown = String(result.value ?? result);
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
16
packages/utils/src/editor/markdown-parser/types.ts
Normal file
16
packages/utils/src/editor/markdown-parser/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type TCoreCustomComponentsMetaData = {
|
||||||
|
file_assets: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
user_mentions: {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TExtendedCustomComponentsMetaData = unknown;
|
||||||
|
|
||||||
|
export type TCustomComponentsMetaData = TCoreCustomComponentsMetaData & TExtendedCustomComponentsMetaData;
|
||||||
1025
pnpm-lock.yaml
generated
1025
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue