mirror of
https://github.com/makeplane/plane.git
synced 2025-12-29 00:24:56 +01:00
[WIKI-345] feat: editor external embeds (#2924)
* refactor: iframe service * refactor: name iframely * refactor: use extension storage * refactor:error handler * refactor : error handling * fix: drag handler inside error * feat: refactor space embed handler * refactor: handle convert UI * refactor : iframely service , controller * refactor: fix modal opening logic * feat: loading in twit embed. * fix: twit frame * feat :tweet fix in space. * refactor: change name casing * feat: add icon link * feat: added animation * fix : iframe styles * refactor : update link-container * refactor : fix build * feat: handle link url mark * fix: iframely service created in web * fix: group issue * fix: use live URL instead * fix : close embed modal * feat: handle arrow keys * fix: handle error * fix : remove logs * feat: handle bookmark * feat: handle og image * chore:remove observer * feat: handle bookmark and embed * feat: handle custom render * chore: clean up * feat: handle conversion * fix : handle links properly * feat: handle figma embed * refactor : put iframely controller in ee * refactor: better icon * feat: feature flag external embed * feat: timeout * feat: refactor embed component * refactor: upgrade plan * feat :handle block menu * feat: handle comment * fix : reloads * refactor : remove border * fix : embed order * fix :Embed handler * feat: insert embed v1 * feat : web bookmark command * chore: fix text * feat: ui updates * feat: handle cursor focus * feat: update isopened in storage * feat: add platform name * fix:deny plane embed * feat: props update * feat: handle embed options properly * chore: minor changes * chore : add external embeds in the page form * chore : convert bookmark to rich card * feat : update thumnail not found * feat: add new loading animation * fix : handle paste embed * feat:block translation * feat: basic local setup * feat: embed translation for all languages * chore : update feature flag name * feat: handle feature flag in space * FIX: add build in i18n package * fix : update props for embed handler * chore : remove comments * chore : move hooks * FIX : package update * FIX: live * feat: handle unique ID * feat: handle thumbnails * chore : remove useless fetch * chore : update types * refactor : twitter theme * chore : remove slash command for rich card * chore: different text in readonly * refactor : change editor name * refactor: update modal style * refactor : make the html simple * refactor : external extension * refactor : rename extension * refactor: attribute names * refactor : add entity type * refactor: figma hook * refactor: remove translations * fix : creds * feat: handle iframely api auth * feat: handle space embed load * feat: handle paste link * feat: styles updates * chore : remove editable condition * remove link-container * feat : feature flag slash command * chore * fix * chore : refactor external embed * refactor : fix embed insert * chore : remove auth * chore : remove old code * fix : build * fix:auth * Fix: floating portal Fix * fix: refactor * fix : update types * fix: build * fix : update iframe response types * refactor: embed ui components * refactor : emebd components * refactor : add tailwind animations * refactor core * refactor ce * refactor : move icons * refactor lite editor * small refactor * refactor : update icons import * build fix * fix: cors * feat: update project structure * refactor : embed handler * refactor : embed hooks * refactor : packages * chore: embed setup in dev wiki * refactor: embed extension * refactor:fix types * refactor: external emebds * chore: clean imports * chore :remove readonly editor types * chore : remove logs * Revert changes to dev-wiki/ * refactor: remove upgrade plane component * feat: add unique id * refactor : update fetching logic with useSWR * Feat: Handle auth in iframley API * feat:update embed select style * refactor : remove useless component * refactor : widge embed * Remove changes to i18n locales path * refactor: utils * refactor : update emebd handler * wip -- * fix : build * refactor : block menu * refactor:remove unused code * refactor : update block menu * refactor: slash command feature flag * refactor: add badge in slash command * refactor: editor attribute * refactor : embed handler * fix : swr * fix : build * refactor: feature flag space * refactor : storage types * refactor: remove embeddable * refactor: space remove feature flag * refactor: update space feature flag * refactor: external embed * fix :rerender * build : fix hooks * fix: block menu refactor * refactor: hooks * refactor: move tldjs * refactor :extension * refactor: page render * refactor : update NodeViewProps types * refactor : embed handler space * refactor: update has_embed_failed * refactor: remove useless render code * refactor : twitter embed * fix : build * refactor : attribute with commands * refactor : external embed extension. * refactor: external embed storage. * fix : rich and embed types * fix : web embed * style : selection * refactor: space embed handler * fix : extension storage * refactor: embed types * refactor: imports * fix : page renderer * chore: add comment * chore: update comments * chore: install tldjs * refactor: update ui package * fix :dev-wiki pnpm changes * chore: minor improvements * refactor : update embed type * refactor : update component name * refactor :url modifier * refactor: remove external embed ce * feat: disabled external embed * refactor: add disabled props * refactor: remove get attribute method * refactor: package ui styles * feat: translations * refactor: theme type * feat: jwt auth * improve: add jwt auth for user * chore :comment translations * refactor: api params * refactor: update types * refactor: update props * refactor: constants * refactor: lite comments * refactor: add type imports * refactor:external embed node view * refactor: move tldjs to dev dep * refactor: add flagged check * refactor: update slice imports * refactor: update type changes * chore : remove comments * refactor : update nodeview types * chore :remove type export * chore: update icon * chore: update iframley types * fix: build errors --------- Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
@@ -9,9 +9,12 @@ from plane.api.views.base import BaseAPIView
|
||||
from plane.db.models import User
|
||||
from plane.utils.openapi.decorators import user_docs
|
||||
from plane.utils.openapi import USER_EXAMPLE
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from plane.authentication.session import BaseSessionAuthentication
|
||||
|
||||
|
||||
class UserEndpoint(BaseAPIView):
|
||||
authentication_classes = [JWTAuthentication, BaseSessionAuthentication]
|
||||
serializer_class = UserLiteSerializer
|
||||
model = User
|
||||
|
||||
|
||||
@@ -42,12 +42,23 @@ export function configureServerMiddleware(app: express.Application): void {
|
||||
* @param app Express application
|
||||
*/
|
||||
function configureCors(app: express.Application): void {
|
||||
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
|
||||
for (const origin of origins) {
|
||||
logger.info(`Adding CORS allowed origin: ${origin}`);
|
||||
const corsOrigins = env.CORS_ALLOWED_ORIGINS;
|
||||
if (corsOrigins === "*") {
|
||||
logger.info("Enabling CORS for all origins");
|
||||
app.use(
|
||||
cors({
|
||||
origin,
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const origins = corsOrigins?.split(",").map((origin) => origin.trim()) || [];
|
||||
logger.info(`Enabling CORS for specific origins: ${origins.join(", ")}`);
|
||||
app.use(
|
||||
cors({
|
||||
origin: origins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
|
||||
|
||||
118
apps/live/src/ee/controllers/iframely.controller.ts
Normal file
118
apps/live/src/ee/controllers/iframely.controller.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Controller, Get } from "@plane/decorators";
|
||||
import type { Request, Response } from "express";
|
||||
import axios from "axios";
|
||||
// services
|
||||
import { IframelyAPI } from "@/ee/services/iframely.service";
|
||||
// helpers
|
||||
import { env } from "@/env";
|
||||
import { handleAuthentication } from "@/core/lib/authentication";
|
||||
|
||||
@Controller("/iframely")
|
||||
export class IframelyController {
|
||||
@Get("/")
|
||||
async getIframely(req: Request, res: Response) {
|
||||
try {
|
||||
const { url: sourceURL, _theme, workspaceSlug, userId } = req.query;
|
||||
const { cookie } = req.headers || req.query;
|
||||
// Validate environment configuration
|
||||
if (!env.IFRAMELY_URL) {
|
||||
return res.status(500).json({
|
||||
error: "An unexpected error occurred",
|
||||
code: "SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!sourceURL) {
|
||||
return res.status(400).json({
|
||||
error: "URL parameter is required",
|
||||
code: "MISSING_URL",
|
||||
});
|
||||
}
|
||||
|
||||
if (!cookie || typeof cookie !== "string") {
|
||||
return res.status(401).json({
|
||||
error: "Authentication required",
|
||||
code: "MISSING_AUTHENTICATION",
|
||||
});
|
||||
}
|
||||
|
||||
if (!userId || typeof userId !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "User ID parameter is required",
|
||||
code: "MISSING_USER_ID",
|
||||
});
|
||||
}
|
||||
|
||||
if (!workspaceSlug || typeof workspaceSlug !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "Workspace slug parameter is required",
|
||||
code: "MISSING_WORKSPACE_SLUG",
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
try {
|
||||
await handleAuthentication({
|
||||
cookie,
|
||||
userId,
|
||||
workspaceSlug,
|
||||
});
|
||||
} catch (_error) {
|
||||
// handleAuthentication throws errors for unauthorized access
|
||||
return res.status(401).json({
|
||||
error: "Authentication failed",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
// If authentication is successful, proceed with the request
|
||||
const response = await IframelyAPI.getIframe({
|
||||
url: sourceURL as string,
|
||||
theme: _theme as string,
|
||||
});
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
let errorMessage = "An error occurred while fetching the embed data";
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
switch (status) {
|
||||
case 404:
|
||||
errorMessage = "The requested content is no longer available";
|
||||
errorCode = "CONTENT_NOT_FOUND";
|
||||
break;
|
||||
case 410:
|
||||
errorMessage = "This content has been permanently removed";
|
||||
errorCode = "CONTENT_REMOVED";
|
||||
break;
|
||||
case 401:
|
||||
case 403:
|
||||
errorMessage = "This content is private or requires authentication";
|
||||
errorCode = "CONTENT_PRIVATE";
|
||||
break;
|
||||
case 415:
|
||||
errorMessage = "This type of content is not supported";
|
||||
errorCode = "UNSUPPORTED_CONTENT";
|
||||
break;
|
||||
case 418:
|
||||
errorMessage = "The content server took too long to respond";
|
||||
errorCode = "TIMEOUT";
|
||||
break;
|
||||
}
|
||||
|
||||
return res.status(status || 500).json({
|
||||
error: errorMessage,
|
||||
code: errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: "An unexpected error occurred",
|
||||
code: "SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BroadcastController } from "./broadcast.controller";
|
||||
import { CONTROLLERS as CEControllers } from "@/ce/controllers";
|
||||
import { IframelyController } from "./iframely.controller";
|
||||
|
||||
export const CONTROLLERS = {
|
||||
// Core system controllers (health checks, status endpoints)
|
||||
CORE: [...CEControllers.CORE],
|
||||
CORE: [...CEControllers.CORE, IframelyController],
|
||||
|
||||
// Document management controllers
|
||||
DOCUMENT: [...CEControllers.DOCUMENT],
|
||||
|
||||
27
apps/live/src/ee/services/iframely.service.ts
Normal file
27
apps/live/src/ee/services/iframely.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// services
|
||||
import { APIService } from "@/core/services/api.service";
|
||||
// types
|
||||
import { IframelyResponse } from "@plane/types";
|
||||
// helpers
|
||||
import { env } from "@/env";
|
||||
|
||||
const IFRAMELY_URL = env.IFRAMELY_URL ?? "";
|
||||
|
||||
export class IframelyService extends APIService {
|
||||
constructor() {
|
||||
super(IFRAMELY_URL);
|
||||
}
|
||||
|
||||
async getIframe({ url, theme }: { url: string; theme: string }): Promise<IframelyResponse> {
|
||||
return this.get(`${this.baseURL}/iframely`, {
|
||||
params: { url: url, group: true, _theme: theme },
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const IframelyAPI = new IframelyService();
|
||||
@@ -31,6 +31,9 @@ const envSchema = z.object({
|
||||
|
||||
// Live server secret key
|
||||
LIVE_SERVER_SECRET_KEY: z.string(),
|
||||
|
||||
// Iframely configuration
|
||||
IFRAMELY_URL: z.string(),
|
||||
});
|
||||
|
||||
// Validate the environment variables
|
||||
|
||||
@@ -47,7 +47,6 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
const isEmpty = isCommentEmpty(props.initialValue);
|
||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||
const { liteText: liteTextEditorExtensions } = useEditorFlagging(anchor);
|
||||
|
||||
return (
|
||||
<div className="border border-custom-border-200 rounded p-3 space-y-3">
|
||||
<LiteTextEditorWithRef
|
||||
|
||||
@@ -7,12 +7,13 @@ import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// plane web imports
|
||||
import { EmbedHandler } from "@/plane-web/components/editor/external-embed/embed-handler";
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// local imports
|
||||
import { EditorMentionsRoot } from "./embeds/mentions";
|
||||
|
||||
type RichTextEditorWrapperProps = MakeOptional<
|
||||
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "isSmoothCursorEnabled">,
|
||||
Omit<IRichTextEditorProps, "editable" | "fileHandler" | "mentionHandler" | "isSmoothCursorEnabled" | "embedHandler">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> & {
|
||||
anchor: string;
|
||||
@@ -57,6 +58,11 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
})}
|
||||
flaggedExtensions={richTextEditorExtensions.flagged}
|
||||
{...rest}
|
||||
embedHandler={{
|
||||
externalEmbedComponent: {
|
||||
widgetCallback: EmbedHandler,
|
||||
},
|
||||
}}
|
||||
containerClassName={containerClassName}
|
||||
editorClassName="min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden"
|
||||
displayConfig={{ fontSize: "large-font" }}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { memo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane editor
|
||||
import { EExternalEmbedEntityType, ExternalEmbedNodeViewProps, TExternalEmbedBlockAttributes } from "@plane/editor";
|
||||
// plane types
|
||||
import { IframelyResponse } from "@plane/types";
|
||||
// plane components
|
||||
import { EmbedLoading } from "@plane/ui/src/editor/embed-loading";
|
||||
import { ErrorState } from "@plane/ui/src/editor/error-state";
|
||||
import { HTMLContent } from "@plane/ui/src/editor/html-content";
|
||||
import { InViewportRenderer } from "@plane/ui/src/editor/is-in-viewport";
|
||||
import { RichCard } from "@plane/ui/src/editor/rich-card";
|
||||
import { TwitterEmbed } from "@plane/ui/src/editor/twitter-embed";
|
||||
|
||||
// Main wrapper component that uses lazy loading through InViewportRenderer
|
||||
export const EmbedHandler: React.FC<ExternalEmbedNodeViewProps> = memo(
|
||||
observer((props) => (
|
||||
<InViewportRenderer placeholder={<EmbedLoading />}>
|
||||
<EmbedHandlerRender {...props} />
|
||||
</InViewportRenderer>
|
||||
))
|
||||
);
|
||||
|
||||
const EmbedHandlerRender: React.FC<ExternalEmbedNodeViewProps> = observer((props) => {
|
||||
const { node } = props;
|
||||
const { src, embed_data: storedEmbedData, is_rich_card, entity_type, has_embed_failed } = node.attrs;
|
||||
// dervied values
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isThemeDark = resolvedTheme?.startsWith("dark");
|
||||
const theme = isThemeDark ? "dark" : "light";
|
||||
|
||||
// Parse embed data from node attributes
|
||||
const embedData = React.useMemo(() => {
|
||||
if (!storedEmbedData) return null;
|
||||
try {
|
||||
return JSON.parse(storedEmbedData) as IframelyResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [storedEmbedData]);
|
||||
|
||||
// Handle error states first
|
||||
if (!src) {
|
||||
return <ErrorState error="No URL provided" code="400" theme={theme} />;
|
||||
}
|
||||
|
||||
if (embedData?.error && embedData?.code) {
|
||||
return <ErrorState error={embedData.error} code={embedData.code} theme={theme} />;
|
||||
}
|
||||
|
||||
if (src && !embedData) {
|
||||
return <ErrorState error="No embed data available" code="404" theme={theme} />;
|
||||
}
|
||||
|
||||
// Handle direct iframe embed
|
||||
if (!embedData?.html && entity_type === EExternalEmbedEntityType.EMBED && !has_embed_failed && !is_rich_card && src) {
|
||||
return (
|
||||
<div className="w-full h-[400px] rounded overflow-hidden my-4">
|
||||
<iframe src={src} width="100%" height="100%" frameBorder="0" allowFullScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle rich card
|
||||
if (embedData?.meta && (is_rich_card || !embedData.html) && src) {
|
||||
return <RichCard iframelyData={embedData} src={src} theme={theme} />;
|
||||
}
|
||||
|
||||
// Handle HTML content (including Twitter embeds)
|
||||
if (embedData?.html && !is_rich_card) {
|
||||
return embedData.html.includes("<iframe") ? (
|
||||
<HTMLContent html={embedData.html} />
|
||||
) : (
|
||||
<TwitterEmbed iframelyData={embedData} />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { FileText } from "lucide-react";
|
||||
// plane imports
|
||||
import { DocumentEditorWithRef, type EditorRefApi } from "@plane/editor";
|
||||
import { ERowVariant, Logo, Row } from "@plane/ui";
|
||||
import { ERowVariant, Row } from "@plane/ui";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor/embeds/mentions";
|
||||
// helpers
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store/publish";
|
||||
import { EmbedHandler } from "@/plane-web/components/editor/external-embed/embed-handler";
|
||||
// plane web components
|
||||
import { WorkItemEmbedCard } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
@@ -18,6 +18,7 @@ import { usePage, usePagesList } from "@/plane-web/hooks/store";
|
||||
// local imports
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { PageEmbedCardRoot } from "./page/root";
|
||||
import { PageHeader } from "./page-head";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
@@ -54,18 +55,7 @@ export const PageDetailsMainContent: React.FC<Props> = observer((props) => {
|
||||
variant={ERowVariant.HUGGING}
|
||||
>
|
||||
<div id="page-content-container" className="flex flex-col size-full space-y-4">
|
||||
<div className="w-full py-3 page-header-container">
|
||||
<div className="space-y-2 block bg-transparent w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out">
|
||||
<div className="size-[60px] bg-custom-background-80 rounded grid place-items-center">
|
||||
{pageDetails.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails.logo_props} size={36} type="lucide" />
|
||||
) : (
|
||||
<FileText className="size-9 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] break-words">{pageDetails.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader pageDetails={pageDetails} />
|
||||
<div className="size-full">
|
||||
<DocumentEditorWithRef
|
||||
editable={false}
|
||||
@@ -87,6 +77,9 @@ export const PageDetailsMainContent: React.FC<Props> = observer((props) => {
|
||||
issue: {
|
||||
widgetCallback: ({ issueId }) => <WorkItemEmbedCard anchor={anchor} issueId={issueId} />,
|
||||
},
|
||||
externalEmbedComponent: {
|
||||
widgetCallback: EmbedHandler,
|
||||
},
|
||||
page: {
|
||||
widgetCallback: ({ pageId }) => <PageEmbedCardRoot pageId={pageId} />,
|
||||
workspaceSlug: "",
|
||||
|
||||
22
apps/space/ee/components/pages/page-head.tsx
Normal file
22
apps/space/ee/components/pages/page-head.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { Logo } from "@plane/ui";
|
||||
import { IPage } from "@/plane-web/store/pages";
|
||||
|
||||
type Props = {
|
||||
pageDetails: IPage;
|
||||
};
|
||||
export const PageHeader: React.FC<Props> = observer(({ pageDetails }) => (
|
||||
<div className="w-full py-3 page-header-container">
|
||||
<div className="space-y-2 block bg-transparent w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out">
|
||||
<div className="size-[60px] bg-custom-background-80 rounded grid place-items-center">
|
||||
{pageDetails.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails.logo_props} size={36} type="lucide" />
|
||||
) : (
|
||||
<FileText className="size-9 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="tracking-[-2%] font-bold text-[2rem] leading-[2.375rem] break-words">{pageDetails.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
@@ -14,9 +14,13 @@ export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType
|
||||
if (!hasFetchedFeatureFlag(anchor, "EDITOR_MATHEMATICS")) {
|
||||
fetchFeatureFlag(anchor, "EDITOR_MATHEMATICS");
|
||||
}
|
||||
if (!hasFetchedFeatureFlag(anchor, "EDITOR_EXTERNAL_EMBEDS")) {
|
||||
fetchFeatureFlag(anchor, "EDITOR_EXTERNAL_EMBEDS");
|
||||
}
|
||||
}, [anchor, fetchFeatureFlag, hasFetchedFeatureFlag]);
|
||||
|
||||
const isMathematicsEnabled = getFeatureFlag(anchor, "EDITOR_MATHEMATICS", true);
|
||||
const isExternalEmbedEnabled = getFeatureFlag(anchor, "EDITOR_EXTERNAL_EMBEDS", true);
|
||||
|
||||
const documentDisabled: TExtensions[] = [];
|
||||
const documentFlagged: TExtensions[] = [];
|
||||
@@ -27,12 +31,20 @@ export const useEditorFlagging = (anchor: string): TEditorFlaggingHookReturnType
|
||||
const liteTextDisabled: TExtensions[] = [];
|
||||
const liteTextFlagged: TExtensions[] = [];
|
||||
|
||||
liteTextDisabled.push("external-embed");
|
||||
|
||||
if (!isMathematicsEnabled) {
|
||||
documentFlagged.push("mathematics");
|
||||
richTextFlagged.push("mathematics");
|
||||
liteTextFlagged.push("mathematics");
|
||||
}
|
||||
|
||||
if (!isExternalEmbedEnabled) {
|
||||
documentFlagged.push("external-embed");
|
||||
richTextFlagged.push("external-embed");
|
||||
liteTextFlagged.push("external-embed");
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
disabled: documentDisabled,
|
||||
|
||||
@@ -71,7 +71,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
return (
|
||||
comment?.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component", "embed-component"])
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
|
||||
// local imports
|
||||
import { EditorMentionsRoot } from "../embeds/mentions";
|
||||
import { EmbedHandler } from "@/plane-web/components/pages/editor/external-embed/embed-handler";
|
||||
|
||||
type DocumentEditorWrapperProps = MakeOptional<
|
||||
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "embedHandler" | "user">,
|
||||
@@ -58,6 +59,7 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
const {
|
||||
data: { is_smooth_cursor_enabled },
|
||||
} = useUserProfile();
|
||||
@@ -85,6 +87,9 @@ export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProp
|
||||
}}
|
||||
embedHandler={{
|
||||
issue: issueEmbedProps,
|
||||
externalEmbedComponent: {
|
||||
widgetCallback: EmbedHandler,
|
||||
},
|
||||
...embedHandler,
|
||||
}}
|
||||
isSmoothCursorEnabled={is_smooth_cursor_enabled}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { EditorMentionsRoot } from "@/components/editor/embeds/mentions";
|
||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUserProfile } from "@/hooks/store/use-user-profile";
|
||||
// plane web components
|
||||
import { EmbedHandler } from "@/plane-web/components/pages/editor/external-embed/embed-handler";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
|
||||
type RichTextEditorWrapperProps = MakeOptional<
|
||||
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler">,
|
||||
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler" | "embedHandler">,
|
||||
"disabledExtensions" | "editable" | "flaggedExtensions" | "isSmoothCursorEnabled"
|
||||
> & {
|
||||
workspaceSlug: string;
|
||||
@@ -78,6 +80,9 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
display_name: getUserDetails(id)?.display_name ?? "",
|
||||
}),
|
||||
}}
|
||||
embedHandler={{
|
||||
externalEmbedComponent: { widgetCallback: EmbedHandler },
|
||||
}}
|
||||
{...rest}
|
||||
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
// plane constants
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
|
||||
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
|
||||
// components
|
||||
import { TSticky } from "@plane/types";
|
||||
// helpers
|
||||
@@ -12,6 +12,7 @@ import { useEditorConfig } from "@/hooks/editor";
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// local imports
|
||||
import { StickyEditorToolbar } from "./toolbar";
|
||||
|
||||
interface StickyEditorWrapperProps
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, memo, useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import {
|
||||
EExternalEmbedAttributeNames,
|
||||
EExternalEmbedEntityType,
|
||||
ExternalEmbedNodeViewProps,
|
||||
TExternalEmbedBlockAttributes,
|
||||
} from "@plane/editor";
|
||||
import type { IframelyResponse } from "@plane/types";
|
||||
import CrossOriginLoader from "@plane/ui/src/editor/cross-origin-loader";
|
||||
import { EmbedLoading } from "@plane/ui/src/editor/embed-loading";
|
||||
import { ErrorState } from "@plane/ui/src/editor/error-state";
|
||||
import { HTMLContent } from "@plane/ui/src/editor/html-content";
|
||||
import { InViewportRenderer } from "@plane/ui/src/editor/is-in-viewport";
|
||||
import { RichCard } from "@plane/ui/src/editor/rich-card";
|
||||
import { TwitterEmbed } from "@plane/ui/src/editor/twitter-embed";
|
||||
// local hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// plane web services
|
||||
import { iframelyService } from "@/plane-web/services/iframely.service";
|
||||
|
||||
// Types
|
||||
type ErrorData = {
|
||||
error: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
type EmbedData = IframelyResponse | ErrorData | null;
|
||||
|
||||
const useEmbedDataManager = (externalEmbedNodeView: ExternalEmbedNodeViewProps) => {
|
||||
// attributes
|
||||
const { src, embed_data: storedEmbedData } = externalEmbedNodeView.node.attrs;
|
||||
// derived values
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { data: currentUser } = useUser();
|
||||
const isThemeDark = resolvedTheme?.startsWith("dark") ?? false;
|
||||
const userId = currentUser?.id;
|
||||
|
||||
// SWR for fetching embed data
|
||||
const shouldFetch = src && !storedEmbedData;
|
||||
const swrKey = shouldFetch ? [src, isThemeDark, workspaceSlug.toString(), userId || ""] : null;
|
||||
|
||||
const {
|
||||
data: iframelyData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR(
|
||||
swrKey,
|
||||
([src, isThemeDark, workspaceSlug, userId]: [string, boolean, string, string]) =>
|
||||
iframelyService.getEmbedData(src, isThemeDark, workspaceSlug, userId),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 300000,
|
||||
}
|
||||
);
|
||||
|
||||
// Single useEffect for all attribute updates
|
||||
useEffect(() => {
|
||||
const updates: Partial<TExternalEmbedBlockAttributes> = {};
|
||||
|
||||
// Handle successful data fetch
|
||||
if (iframelyData) {
|
||||
updates[EExternalEmbedAttributeNames.EMBED_DATA] = JSON.stringify(iframelyData);
|
||||
if (iframelyData?.meta?.site) {
|
||||
updates[EExternalEmbedAttributeNames.ENTITY_NAME] = iframelyData.meta.site;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (error) {
|
||||
const errorData = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const defaultError = {
|
||||
error: errorData?.response?.data?.error || errorData?.message || "Failed to load embed",
|
||||
code: errorData?.response?.data?.code || "UNKNOWN_ERROR",
|
||||
};
|
||||
updates[EExternalEmbedAttributeNames.EMBED_DATA] = JSON.stringify(defaultError);
|
||||
}
|
||||
|
||||
// Batch all updates in one call
|
||||
if (Object.keys(updates).length > 0) {
|
||||
externalEmbedNodeView.updateAttributes(updates);
|
||||
}
|
||||
}, [src, iframelyData, error, externalEmbedNodeView]);
|
||||
|
||||
// Parse and return current embed data
|
||||
const currentEmbedData: EmbedData = useMemo(() => {
|
||||
if (storedEmbedData) {
|
||||
try {
|
||||
return JSON.parse(storedEmbedData);
|
||||
} catch {}
|
||||
}
|
||||
return iframelyData || null;
|
||||
}, [storedEmbedData, iframelyData]);
|
||||
|
||||
// Handle Twitter theme updates
|
||||
useTwitterThemeHandler({
|
||||
storedEmbedData: storedEmbedData || null,
|
||||
isThemeDark,
|
||||
updateAttributes: externalEmbedNodeView.updateAttributes,
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
currentEmbedData,
|
||||
isThemeDark,
|
||||
updateAttributes: externalEmbedNodeView.updateAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
// React State Family - Handles component state and interactions
|
||||
const useEmbedState = (externalEmbedNodeView: ExternalEmbedNodeViewProps) => {
|
||||
const embedAttrs = externalEmbedNodeView.node.attrs;
|
||||
|
||||
const [directEmbedState, setDirectEmbedState] = useState({
|
||||
hasTriedEmbedding: embedAttrs[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING],
|
||||
isEmbeddable: !embedAttrs[EExternalEmbedAttributeNames.HAS_EMBED_FAILED],
|
||||
});
|
||||
|
||||
const { src, is_rich_card, has_embed_failed } = embedAttrs;
|
||||
|
||||
const handleDirectEmbedLoaded = useCallback(() => {
|
||||
setDirectEmbedState({ hasTriedEmbedding: true, isEmbeddable: true });
|
||||
externalEmbedNodeView.updateAttributes({
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: false,
|
||||
[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING]: true,
|
||||
});
|
||||
}, [externalEmbedNodeView]);
|
||||
|
||||
const handleDirectEmbedError = useCallback(() => {
|
||||
setDirectEmbedState({ hasTriedEmbedding: true, isEmbeddable: false });
|
||||
externalEmbedNodeView.updateAttributes({
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: true,
|
||||
[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING]: true,
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: true,
|
||||
[EExternalEmbedAttributeNames.ENTITY_TYPE]: EExternalEmbedEntityType.RICH_CARD,
|
||||
});
|
||||
}, [externalEmbedNodeView]);
|
||||
|
||||
return {
|
||||
directEmbedState,
|
||||
src: src,
|
||||
isRichCardView: is_rich_card,
|
||||
isEmbedFailed: has_embed_failed,
|
||||
handleDirectEmbedLoaded,
|
||||
handleDirectEmbedError,
|
||||
};
|
||||
};
|
||||
|
||||
// Pure JSX Renderer Family - Clean JSX rendering without complex logic
|
||||
const EmbedRenderer: React.FC<{
|
||||
isLoading: boolean;
|
||||
currentEmbedData: EmbedData;
|
||||
isThemeDark: boolean;
|
||||
src: string;
|
||||
isRichCardView: boolean;
|
||||
directEmbedState: { hasTriedEmbedding: boolean; isEmbeddable: boolean };
|
||||
isEmbedFailed: boolean;
|
||||
handleDirectEmbedLoaded: () => void;
|
||||
handleDirectEmbedError: () => void;
|
||||
}> = ({
|
||||
isLoading,
|
||||
currentEmbedData,
|
||||
isThemeDark,
|
||||
src,
|
||||
isRichCardView,
|
||||
directEmbedState,
|
||||
isEmbedFailed,
|
||||
handleDirectEmbedLoaded,
|
||||
handleDirectEmbedError,
|
||||
}) => {
|
||||
const theme = isThemeDark ? "dark" : "light";
|
||||
// Determine if we should show loading animations based on whether we have data
|
||||
const showLoading = !currentEmbedData;
|
||||
|
||||
// Loading state
|
||||
if (isLoading && !currentEmbedData) {
|
||||
return <EmbedLoading showLoading={showLoading} />;
|
||||
}
|
||||
|
||||
// From here we know it's IframelyResponse
|
||||
const embedData = currentEmbedData as IframelyResponse;
|
||||
|
||||
// Direct embed attempts (no HTML and not rich card)
|
||||
if (!embedData?.html && !isRichCardView) {
|
||||
// Success case - show direct iframe
|
||||
if (directEmbedState.hasTriedEmbedding && directEmbedState.isEmbeddable && !isEmbedFailed) {
|
||||
return (
|
||||
<div className="w-full h-[400px] rounded overflow-hidden my-4">
|
||||
<iframe src={src} width="100%" height="100%" frameBorder="0" allowFullScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Testing phase - try to load directly
|
||||
if (!directEmbedState.hasTriedEmbedding) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-0 h-0 overflow-hidden">
|
||||
<CrossOriginLoader src={src} onLoaded={handleDirectEmbedLoaded} onError={handleDirectEmbedError} />
|
||||
</div>
|
||||
<EmbedLoading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Error state
|
||||
if (currentEmbedData && "error" in currentEmbedData && "code" in currentEmbedData) {
|
||||
const errorData = currentEmbedData as ErrorData;
|
||||
return <ErrorState error={errorData.error} code={errorData.code} theme={theme} />;
|
||||
}
|
||||
|
||||
// Rich card rendering
|
||||
if ((!embedData?.html && embedData?.meta) || (isRichCardView && embedData?.meta)) {
|
||||
return <RichCard iframelyData={embedData} src={src} theme={theme} showLoading={showLoading} />;
|
||||
}
|
||||
|
||||
// HTML content rendering
|
||||
if (embedData?.html && !isRichCardView) {
|
||||
const hasIframe = embedData.html.includes("<iframe");
|
||||
return hasIframe ? (
|
||||
<HTMLContent html={embedData.html} showLoading={showLoading} />
|
||||
) : (
|
||||
<TwitterEmbed iframelyData={embedData} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Main Entry Component - Simple orchestration
|
||||
export const EmbedHandler: React.FC<ExternalEmbedNodeViewProps> = memo(
|
||||
observer((props) => {
|
||||
const hasEmbedData = props.node.attrs.embed_data;
|
||||
|
||||
return (
|
||||
<InViewportRenderer placeholder={<EmbedLoading showLoading={!hasEmbedData} />}>
|
||||
<EmbedHandlerRender {...props} />
|
||||
</InViewportRenderer>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Main Component - Clean orchestration of families
|
||||
const EmbedHandlerRender: React.FC<ExternalEmbedNodeViewProps> = observer((externalEmbedNodeView) => {
|
||||
// Data Management Family
|
||||
const { isLoading, currentEmbedData, isThemeDark } = useEmbedDataManager(externalEmbedNodeView);
|
||||
|
||||
// React State Family
|
||||
const { directEmbedState, src, isRichCardView, isEmbedFailed, handleDirectEmbedLoaded, handleDirectEmbedError } =
|
||||
useEmbedState(externalEmbedNodeView);
|
||||
|
||||
const { id } = externalEmbedNodeView.node.attrs;
|
||||
|
||||
return (
|
||||
<div key={id} className="embed-handler-wrapper">
|
||||
<EmbedRenderer
|
||||
isLoading={isLoading}
|
||||
currentEmbedData={currentEmbedData}
|
||||
isThemeDark={isThemeDark}
|
||||
src={src as string}
|
||||
isRichCardView={isRichCardView}
|
||||
directEmbedState={directEmbedState}
|
||||
isEmbedFailed={isEmbedFailed}
|
||||
handleDirectEmbedLoaded={handleDirectEmbedLoaded}
|
||||
handleDirectEmbedError={handleDirectEmbedError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type UseTwitterThemeHandlerProps = {
|
||||
storedEmbedData: string | null;
|
||||
isThemeDark: boolean | undefined;
|
||||
updateAttributes: (attrs: { embed_data: string }) => void;
|
||||
};
|
||||
const useTwitterThemeHandler = ({ storedEmbedData, isThemeDark, updateAttributes }: UseTwitterThemeHandlerProps) => {
|
||||
useEffect(() => {
|
||||
if (!storedEmbedData) return;
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(storedEmbedData);
|
||||
|
||||
// Only proceed if we have Twitter embed HTML
|
||||
if (parsedData.html && parsedData.html.includes("twitter-tweet")) {
|
||||
let updatedHtml = parsedData.html;
|
||||
|
||||
// Update theme based on current theme setting
|
||||
if (isThemeDark) {
|
||||
if (updatedHtml.includes('data-theme="light"')) {
|
||||
updatedHtml = updatedHtml.replace('data-theme="light"', 'data-theme="dark"');
|
||||
} else if (!updatedHtml.includes('data-theme="dark"')) {
|
||||
updatedHtml = updatedHtml.replace("twitter-tweet", 'twitter-tweet data-theme="dark"');
|
||||
}
|
||||
} else {
|
||||
if (updatedHtml.includes('data-theme="dark"')) {
|
||||
updatedHtml = updatedHtml.replace('data-theme="dark"', 'data-theme="light"');
|
||||
} else if (!updatedHtml.includes('data-theme="light"')) {
|
||||
updatedHtml = updatedHtml.replace("twitter-tweet", 'twitter-tweet data-theme="light"');
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if there were changes
|
||||
if (updatedHtml !== parsedData.html) {
|
||||
const updatedData = { ...parsedData, html: updatedHtml };
|
||||
updateAttributes({ embed_data: JSON.stringify(updatedData) });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating Twitter theme:", error);
|
||||
}
|
||||
}, [isThemeDark, storedEmbedData, updateAttributes]);
|
||||
};
|
||||
@@ -8,10 +8,11 @@ import { calculateTimeAgo, cn, getFileURL, getPageName } from "@plane/utils";
|
||||
// components
|
||||
import { DocumentEditor } from "@/components/editor/document/editor";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member"
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// plane web imports
|
||||
import { PageEmbedCardRoot } from "@/plane-web/components/pages";
|
||||
import { EmbedHandler } from "@/plane-web/components/pages/editor/external-embed/embed-handler";
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EUserProjectRoles, EUserWorkspaceRoles } from "@plane/types";
|
||||
import { Button, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// ce imports
|
||||
import { TProjectTeamspaceList } from "@/ce/components/projects/teamspaces";
|
||||
import type { TProjectTeamspaceList } from "@/ce/components/projects/teamspaces/teamspace-list";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EFileAssetType, TSearchEntityRequestPayload } from "@plane/types";
|
||||
// components
|
||||
import { DocumentEditor } from "@/components/editor/document/editor";
|
||||
// hooks
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset"
|
||||
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
@@ -12,11 +12,13 @@ import { PriorityIcon, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// plane web components
|
||||
import { IssueEmbedCard, IssueEmbedUpgradeCard, PageEmbedCardRoot } from "@/plane-web/components/pages";
|
||||
import { EmbedHandler } from "@/plane-web/components/pages/editor/external-embed/embed-handler";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
// store
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
// plane editor
|
||||
|
||||
export type TEmbedHookProps = {
|
||||
fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
@@ -230,6 +232,7 @@ export const useEditorEmbeds = (props: TEmbedHookProps) => {
|
||||
() => ({
|
||||
issue: issueEmbedProps,
|
||||
...(pageEmbedProps && { page: pageEmbedProps }),
|
||||
externalEmbedComponent: { widgetCallback: EmbedHandler },
|
||||
}),
|
||||
[issueEmbedProps, pageEmbedProps]
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ export const useEditorFlagging = (workspaceSlug: string, storeType?: EPageStoreT
|
||||
const { isNestedPagesEnabled } = usePageStore(storeType || EPageStoreType.WORKSPACE);
|
||||
const isEditorAttachmentsEnabled = useFlag(workspaceSlug, "EDITOR_ATTACHMENTS");
|
||||
const isEditorMathematicsEnabled = useFlag(workspaceSlug, "EDITOR_MATHEMATICS");
|
||||
const isExternalEmbedEnabled = useFlag(workspaceSlug, "EDITOR_EXTERNAL_EMBEDS");
|
||||
|
||||
// disabled and flagged in the document editor
|
||||
const documentDisabled: TExtensions[] = [];
|
||||
const documentFlagged: TExtensions[] = [];
|
||||
@@ -29,6 +31,8 @@ export const useEditorFlagging = (workspaceSlug: string, storeType?: EPageStoreT
|
||||
const liteTextDisabled: TExtensions[] = [];
|
||||
const liteTextFlagged: TExtensions[] = [];
|
||||
|
||||
liteTextDisabled.push("external-embed");
|
||||
|
||||
if (!isWorkItemEmbedEnabled) {
|
||||
documentFlagged.push("issue-embed");
|
||||
}
|
||||
@@ -50,6 +54,12 @@ export const useEditorFlagging = (workspaceSlug: string, storeType?: EPageStoreT
|
||||
richTextFlagged.push("mathematics");
|
||||
liteTextFlagged.push("mathematics");
|
||||
}
|
||||
if (!isExternalEmbedEnabled) {
|
||||
documentFlagged.push("external-embed");
|
||||
richTextFlagged.push("external-embed");
|
||||
liteTextFlagged.push("external-embed");
|
||||
}
|
||||
|
||||
return {
|
||||
document: {
|
||||
disabled: documentDisabled,
|
||||
|
||||
38
apps/web/ee/services/iframely.service.ts
Normal file
38
apps/web/ee/services/iframely.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { LIVE_URL } from "@plane/constants";
|
||||
import { IframelyResponse } from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class IframelyService extends APIService {
|
||||
constructor() {
|
||||
super(LIVE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches embed data for a URL from the iframely service
|
||||
*/
|
||||
async getEmbedData(
|
||||
url: string,
|
||||
isDarkTheme: boolean = false,
|
||||
workspaceSlug: string,
|
||||
userId: string
|
||||
): Promise<IframelyResponse> {
|
||||
const response = await this.get(
|
||||
`/iframely`,
|
||||
{
|
||||
params: {
|
||||
url: url,
|
||||
_theme: isDarkTheme ? "dark" : "light",
|
||||
workspaceSlug,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const iframelyService = new IframelyService();
|
||||
@@ -12,7 +12,7 @@ const nextConfig = {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)?",
|
||||
headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
|
||||
headers: [{ key: "X-Frame-Options", value: "DENY" }],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useParams } from "next/navigation";
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
// wrappers
|
||||
import WorkspaceAccessWrapper from "@/layouts/access/workspace-wrapper";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
// plane web components
|
||||
// import { PagesAppCommandPalette } from "@/plane-web/components/command-palette";
|
||||
import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags";
|
||||
@@ -16,6 +15,7 @@ import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
// local components
|
||||
// import { FloatingActionsRoot } from "../../(projects)/floating-action-bar";
|
||||
import { PagesAppSidebar } from "./sidebar";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
|
||||
|
||||
export default function WorkspacePagesLayout({ children }: { children: React.ReactNode }) {
|
||||
// router
|
||||
|
||||
@@ -16,7 +16,7 @@ import "@/lib/polyfills";
|
||||
// mobx store provider
|
||||
import { StoreProvider } from "@/lib/store-context";
|
||||
// wrappers
|
||||
import { InstanceWrapper } from "@/lib/wrappers";
|
||||
import { InstanceWrapper } from "@/lib/wrappers/instance-wrapper";
|
||||
// dynamic imports
|
||||
const StoreWrapper = dynamic(() => import("@/lib/wrappers/store-wrapper"), { ssr: false });
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useHead } from "@plane/ui";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type PageHeadTitleProps = {
|
||||
title?: string;
|
||||
@@ -8,7 +8,11 @@ type PageHeadTitleProps = {
|
||||
export const PageHead: React.FC<PageHeadTitleProps> = (props) => {
|
||||
const { title } = props;
|
||||
|
||||
useHead({ title });
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
document.title = title ?? "Plane | Simple, extensible, open-source project management tool.";
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -289,6 +289,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
serverHandler={serverHandler}
|
||||
user={userConfig}
|
||||
disabledExtensions={disabledExtensions}
|
||||
// flaggedExtensions={["external-embed"]}
|
||||
flaggedExtensions={[]}
|
||||
aiHandler={{
|
||||
menu: getAIMenu,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./sidebar";
|
||||
export * from "./logo";
|
||||
export * from "./billing";
|
||||
// export * from "./billing";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./dropdown";
|
||||
export * from "./favorites";
|
||||
export * from "./workspace-menu";
|
||||
export * from "./workspace-menu-item";
|
||||
export * from "./workspace-menu-header";
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, memo, useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import {
|
||||
EExternalEmbedAttributeNames,
|
||||
EExternalEmbedEntityType,
|
||||
ExternalEmbedNodeViewProps,
|
||||
TExternalEmbedBlockAttributes,
|
||||
} from "@plane/editor";
|
||||
import type { IframelyResponse } from "@plane/types";
|
||||
import CrossOriginLoader from "@plane/ui/src/editor/cross-origin-loader";
|
||||
import { EmbedLoading } from "@plane/ui/src/editor/embed-loading";
|
||||
import { ErrorState } from "@plane/ui/src/editor/error-state";
|
||||
import { HTMLContent } from "@plane/ui/src/editor/html-content";
|
||||
import { InViewportRenderer } from "@plane/ui/src/editor/is-in-viewport";
|
||||
import { RichCard } from "@plane/ui/src/editor/rich-card";
|
||||
import { TwitterEmbed } from "@plane/ui/src/editor/twitter-embed";
|
||||
// local hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// plane web services
|
||||
import { iframelyService } from "@/plane-web/services/iframely.service";
|
||||
|
||||
// Types
|
||||
type ErrorData = {
|
||||
error: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
type EmbedData = IframelyResponse | ErrorData | null;
|
||||
|
||||
const useEmbedDataManager = (externalEmbedNodeView: ExternalEmbedNodeViewProps) => {
|
||||
// attributes
|
||||
const { src, embed_data: storedEmbedData } = externalEmbedNodeView.node.attrs;
|
||||
// derived values
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { data: currentUser } = useUser();
|
||||
const isThemeDark = resolvedTheme?.startsWith("dark") ?? false;
|
||||
const userId = currentUser?.id;
|
||||
|
||||
// SWR for fetching embed data
|
||||
const shouldFetch = src && !storedEmbedData;
|
||||
const swrKey = shouldFetch ? [src, isThemeDark, workspaceSlug.toString(), userId || ""] : null;
|
||||
|
||||
const {
|
||||
data: iframelyData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR(
|
||||
swrKey,
|
||||
([src, isThemeDark, workspaceSlug, userId]: [string, boolean, string, string]) =>
|
||||
iframelyService.getEmbedData(src, isThemeDark, workspaceSlug, userId),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 300000,
|
||||
}
|
||||
);
|
||||
|
||||
// Single useEffect for all attribute updates
|
||||
useEffect(() => {
|
||||
const updates: Partial<TExternalEmbedBlockAttributes> = {};
|
||||
|
||||
// Handle successful data fetch
|
||||
if (iframelyData) {
|
||||
updates[EExternalEmbedAttributeNames.EMBED_DATA] = JSON.stringify(iframelyData);
|
||||
if (iframelyData?.meta?.site) {
|
||||
updates[EExternalEmbedAttributeNames.ENTITY_NAME] = iframelyData.meta.site;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (error) {
|
||||
const errorData = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const defaultError = {
|
||||
error: errorData?.response?.data?.error || errorData?.message || "Failed to load embed",
|
||||
code: errorData?.response?.data?.code || "UNKNOWN_ERROR",
|
||||
};
|
||||
updates[EExternalEmbedAttributeNames.EMBED_DATA] = JSON.stringify(defaultError);
|
||||
}
|
||||
|
||||
// Batch all updates in one call
|
||||
if (Object.keys(updates).length > 0) {
|
||||
externalEmbedNodeView.updateAttributes(updates);
|
||||
}
|
||||
}, [src, iframelyData, error, externalEmbedNodeView]);
|
||||
|
||||
// Parse and return current embed data
|
||||
const currentEmbedData: EmbedData = useMemo(() => {
|
||||
if (storedEmbedData) {
|
||||
try {
|
||||
return JSON.parse(storedEmbedData);
|
||||
} catch {}
|
||||
}
|
||||
return iframelyData || null;
|
||||
}, [storedEmbedData, iframelyData]);
|
||||
|
||||
// Handle Twitter theme updates
|
||||
useTwitterThemeHandler({
|
||||
storedEmbedData: storedEmbedData || null,
|
||||
isThemeDark,
|
||||
updateAttributes: externalEmbedNodeView.updateAttributes,
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
currentEmbedData,
|
||||
isThemeDark,
|
||||
updateAttributes: externalEmbedNodeView.updateAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
// React State Family - Handles component state and interactions
|
||||
const useEmbedState = (externalEmbedNodeView: ExternalEmbedNodeViewProps) => {
|
||||
const embedAttrs = externalEmbedNodeView.node.attrs;
|
||||
|
||||
const [directEmbedState, setDirectEmbedState] = useState({
|
||||
hasTriedEmbedding: embedAttrs[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING],
|
||||
isEmbeddable: !embedAttrs[EExternalEmbedAttributeNames.HAS_EMBED_FAILED],
|
||||
});
|
||||
|
||||
const { src, is_rich_card, has_embed_failed } = embedAttrs;
|
||||
|
||||
const handleDirectEmbedLoaded = useCallback(() => {
|
||||
setDirectEmbedState({ hasTriedEmbedding: true, isEmbeddable: true });
|
||||
externalEmbedNodeView.updateAttributes({
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: false,
|
||||
[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING]: true,
|
||||
});
|
||||
}, [externalEmbedNodeView]);
|
||||
|
||||
const handleDirectEmbedError = useCallback(() => {
|
||||
setDirectEmbedState({ hasTriedEmbedding: true, isEmbeddable: false });
|
||||
externalEmbedNodeView.updateAttributes({
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: true,
|
||||
[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING]: true,
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: true,
|
||||
[EExternalEmbedAttributeNames.ENTITY_TYPE]: EExternalEmbedEntityType.RICH_CARD,
|
||||
});
|
||||
}, [externalEmbedNodeView]);
|
||||
|
||||
return {
|
||||
directEmbedState,
|
||||
src: src,
|
||||
isRichCardView: is_rich_card,
|
||||
isEmbedFailed: has_embed_failed,
|
||||
handleDirectEmbedLoaded,
|
||||
handleDirectEmbedError,
|
||||
};
|
||||
};
|
||||
// Pure JSX Renderer Family - Clean JSX rendering without complex logic
|
||||
const EmbedRenderer: React.FC<{
|
||||
isLoading: boolean;
|
||||
currentEmbedData: EmbedData;
|
||||
isThemeDark: boolean;
|
||||
src: string;
|
||||
isRichCardView: boolean;
|
||||
directEmbedState: { hasTriedEmbedding: boolean; isEmbeddable: boolean };
|
||||
isEmbedFailed: boolean;
|
||||
handleDirectEmbedLoaded: () => void;
|
||||
handleDirectEmbedError: () => void;
|
||||
}> = ({
|
||||
isLoading,
|
||||
currentEmbedData,
|
||||
isThemeDark,
|
||||
src,
|
||||
isRichCardView,
|
||||
directEmbedState,
|
||||
isEmbedFailed,
|
||||
handleDirectEmbedLoaded,
|
||||
handleDirectEmbedError,
|
||||
}) => {
|
||||
const theme = isThemeDark ? "dark" : "light";
|
||||
// Determine if we should show loading animations based on whether we have data
|
||||
const showLoading = !currentEmbedData;
|
||||
|
||||
// Loading state
|
||||
if (isLoading && !currentEmbedData) {
|
||||
return <EmbedLoading showLoading={showLoading} />;
|
||||
}
|
||||
|
||||
// From here we know it's IframelyResponse
|
||||
const embedData = currentEmbedData as IframelyResponse;
|
||||
|
||||
// Direct embed attempts (no HTML and not rich card)
|
||||
if (!embedData?.html && !isRichCardView) {
|
||||
// Success case - show direct iframe
|
||||
if (directEmbedState.hasTriedEmbedding && directEmbedState.isEmbeddable && !isEmbedFailed) {
|
||||
return (
|
||||
<div className="w-full h-[400px] rounded overflow-hidden my-4">
|
||||
<iframe src={src} width="100%" height="100%" frameBorder="0" allowFullScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Testing phase - try to load directly
|
||||
if (!directEmbedState.hasTriedEmbedding) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-0 h-0 overflow-hidden">
|
||||
<CrossOriginLoader src={src} onLoaded={handleDirectEmbedLoaded} onError={handleDirectEmbedError} />
|
||||
</div>
|
||||
<EmbedLoading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
// Error state
|
||||
if (currentEmbedData && "error" in currentEmbedData && "code" in currentEmbedData) {
|
||||
const errorData = currentEmbedData as ErrorData;
|
||||
return <ErrorState error={errorData.error} code={errorData.code} theme={theme} />;
|
||||
}
|
||||
|
||||
// Rich card rendering
|
||||
if ((!embedData?.html && embedData?.meta) || (isRichCardView && embedData?.meta)) {
|
||||
return <RichCard iframelyData={embedData} src={src} theme={theme} showLoading={showLoading} />;
|
||||
}
|
||||
|
||||
// HTML content rendering
|
||||
if (embedData?.html && !isRichCardView) {
|
||||
const hasIframe = embedData.html.includes("<iframe");
|
||||
return hasIframe ? (
|
||||
<HTMLContent html={embedData.html} showLoading={showLoading} />
|
||||
) : (
|
||||
<TwitterEmbed iframelyData={embedData} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Main Entry Component - Simple orchestration
|
||||
export const EmbedHandler: React.FC<ExternalEmbedNodeViewProps> = memo(
|
||||
observer((props) => {
|
||||
const hasEmbedData = props.node.attrs.embed_data;
|
||||
|
||||
return (
|
||||
<InViewportRenderer placeholder={<EmbedLoading showLoading={!hasEmbedData} />}>
|
||||
<EmbedHandlerRender {...props} />
|
||||
</InViewportRenderer>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Main Component - Clean orchestration of families
|
||||
const EmbedHandlerRender: React.FC<ExternalEmbedNodeViewProps> = observer((externalEmbedNodeView) => {
|
||||
// Data Management Family
|
||||
const { isLoading, currentEmbedData, isThemeDark } = useEmbedDataManager(externalEmbedNodeView);
|
||||
|
||||
// React State Family
|
||||
const { directEmbedState, src, isRichCardView, isEmbedFailed, handleDirectEmbedLoaded, handleDirectEmbedError } =
|
||||
useEmbedState(externalEmbedNodeView);
|
||||
|
||||
const { id } = externalEmbedNodeView.node.attrs;
|
||||
|
||||
return (
|
||||
<div key={id} className="embed-handler-wrapper">
|
||||
<EmbedRenderer
|
||||
isLoading={isLoading}
|
||||
currentEmbedData={currentEmbedData}
|
||||
isThemeDark={isThemeDark}
|
||||
src={src as string}
|
||||
isRichCardView={isRichCardView}
|
||||
directEmbedState={directEmbedState}
|
||||
isEmbedFailed={isEmbedFailed}
|
||||
handleDirectEmbedLoaded={handleDirectEmbedLoaded}
|
||||
handleDirectEmbedError={handleDirectEmbedError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type UseTwitterThemeHandlerProps = {
|
||||
storedEmbedData: string | null;
|
||||
isThemeDark: boolean | undefined;
|
||||
updateAttributes: (attrs: { embed_data: string }) => void;
|
||||
};
|
||||
const useTwitterThemeHandler = ({ storedEmbedData, isThemeDark, updateAttributes }: UseTwitterThemeHandlerProps) => {
|
||||
useEffect(() => {
|
||||
if (!storedEmbedData) return;
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(storedEmbedData);
|
||||
|
||||
// Only proceed if we have Twitter embed HTML
|
||||
if (parsedData.html && parsedData.html.includes("twitter-tweet")) {
|
||||
let updatedHtml = parsedData.html;
|
||||
|
||||
// Update theme based on current theme setting
|
||||
if (isThemeDark) {
|
||||
if (updatedHtml.includes('data-theme="light"')) {
|
||||
updatedHtml = updatedHtml.replace('data-theme="light"', 'data-theme="dark"');
|
||||
} else if (!updatedHtml.includes('data-theme="dark"')) {
|
||||
updatedHtml = updatedHtml.replace("twitter-tweet", 'twitter-tweet data-theme="dark"');
|
||||
}
|
||||
} else {
|
||||
if (updatedHtml.includes('data-theme="dark"')) {
|
||||
updatedHtml = updatedHtml.replace('data-theme="dark"', 'data-theme="light"');
|
||||
} else if (!updatedHtml.includes('data-theme="light"')) {
|
||||
updatedHtml = updatedHtml.replace("twitter-tweet", 'twitter-tweet data-theme="light"');
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if there were changes
|
||||
if (updatedHtml !== parsedData.html) {
|
||||
const updatedData = { ...parsedData, html: updatedHtml };
|
||||
updateAttributes({ embed_data: JSON.stringify(updatedData) });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating Twitter theme:", error);
|
||||
}
|
||||
}, [isThemeDark, storedEmbedData, updateAttributes]);
|
||||
};
|
||||
@@ -17,8 +17,6 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
// assets
|
||||
import AllFiltersImage from "@/public/empty-state/pages/all-filters.svg";
|
||||
import NameFilterImage from "@/public/empty-state/pages/name-filter.svg";
|
||||
|
||||
type Props = {
|
||||
pageType: TPageNavigationTabs;
|
||||
@@ -134,13 +132,19 @@ export const WikiPagesListLayoutRoot: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedFiltersImage = useResolvedAssetPath({ basePath: "/empty-state/pages/all-filters", extension: "svg" });
|
||||
const resolvedNameFilterImage = useResolvedAssetPath({
|
||||
basePath: "/empty-state/pages/name-filter",
|
||||
extension: "svg",
|
||||
});
|
||||
|
||||
// if no pages match the filter criteria
|
||||
if (filters.searchQuery && pageIds.length === 0)
|
||||
return (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<div className="text-center">
|
||||
<Image
|
||||
src={filters.searchQuery.length > 0 ? NameFilterImage : AllFiltersImage}
|
||||
src={filters.searchQuery.length > 0 ? resolvedNameFilterImage : resolvedFiltersImage}
|
||||
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
|
||||
alt="No matching pages"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useFlag } from "@/plane-web/hooks/store/use-flag";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "./store/use-page-store";
|
||||
import { EmbedHandler } from "../components/pages/editor/external-embed/embed-handler";
|
||||
|
||||
export type TEmbedHookProps = {
|
||||
fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
@@ -185,6 +186,9 @@ export const useEditorEmbeds = (props: TEmbedHookProps) => {
|
||||
() => ({
|
||||
issue: issueEmbedProps,
|
||||
...(pageEmbedProps && { page: pageEmbedProps }),
|
||||
externalEmbedComponent: {
|
||||
widgetCallback: EmbedHandler,
|
||||
},
|
||||
}),
|
||||
[issueEmbedProps, pageEmbedProps]
|
||||
);
|
||||
|
||||
39
dev-wiki/ee/services/iframely.service.ts
Normal file
39
dev-wiki/ee/services/iframely.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// import { LIVE_URL } from "@plane/constants";
|
||||
import { LIVE_BASE_URL } from "@plane/constants";
|
||||
import { IframelyResponse } from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class IframelyService extends APIService {
|
||||
constructor() {
|
||||
super(LIVE_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches embed data for a URL from the iframely service
|
||||
*/
|
||||
async getEmbedData(
|
||||
url: string,
|
||||
isDarkTheme: boolean = false,
|
||||
workspaceSlug: string,
|
||||
userId: string
|
||||
): Promise<IframelyResponse> {
|
||||
const response = await this.get(
|
||||
`/iframely`,
|
||||
{
|
||||
params: {
|
||||
url: url,
|
||||
_theme: isDarkTheme ? "dark" : "light",
|
||||
workspaceSlug,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const iframelyService = new IframelyService();
|
||||
@@ -227,7 +227,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
return (
|
||||
comment?.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component", "embed-component"])
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"cmdk": "^1.0.0",
|
||||
"comlink": "^4.4.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv": "^16.6.1",
|
||||
"export-to-csv": "^1.4.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"isomorphic-dompurify": "^2.12.0",
|
||||
|
||||
@@ -4,4 +4,18 @@ const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
presets: [sharedConfig],
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./core/**/*.{js,ts,jsx,tsx}",
|
||||
"./ce/**/*.{js,ts,jsx,tsx}",
|
||||
"./ee/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.tsx",
|
||||
"./constants/**/*.{js,ts,jsx,tsx}",
|
||||
"./layouts/**/*.tsx",
|
||||
"./helpers/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/propel/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/src/**/*.{js,ts,jsx,tsx}",
|
||||
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ export enum E_FEATURE_FLAGS {
|
||||
SHARED_PAGES = "SHARED_PAGES",
|
||||
EDITOR_ATTACHMENTS = "EDITOR_ATTACHMENTS",
|
||||
EDITOR_MATHEMATICS = "EDITOR_MATHEMATICS",
|
||||
EDITOR_EXTERNAL_EMBEDS = "EDITOR_EXTERNAL_EMBEDS",
|
||||
// analytics
|
||||
ANALYTICS_ADVANCED = "ANALYTICS_ADVANCED",
|
||||
// app rail
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"tldjs": "^2.3.2",
|
||||
"uuid": "^10.0.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
@@ -93,6 +94,7 @@
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/tldjs": "^2.3.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"postcss": "^8.4.38",
|
||||
|
||||
@@ -132,6 +132,8 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
tabIndex={tabIndex}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
mentionHandler,
|
||||
onChange,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
isSmoothCursorEnabled,
|
||||
});
|
||||
@@ -103,6 +104,8 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
editor={editor}
|
||||
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
|
||||
id={id}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@plane/utils";
|
||||
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
import type { IEditorProps, TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type Props = {
|
||||
aiHandler?: TAIHandler;
|
||||
@@ -19,6 +19,8 @@ type Props = {
|
||||
isLoading?: boolean;
|
||||
isTouchDevice: boolean;
|
||||
tabIndex?: number;
|
||||
flaggedExtensions: IEditorProps["flaggedExtensions"];
|
||||
disabledExtensions: IEditorProps["disabledExtensions"];
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: Props) => {
|
||||
@@ -34,6 +36,8 @@ export const PageRenderer = (props: Props) => {
|
||||
isTouchDevice,
|
||||
tabIndex,
|
||||
titleEditor,
|
||||
flaggedExtensions,
|
||||
disabledExtensions,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -75,7 +79,11 @@ export const PageRenderer = (props: Props) => {
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||
<BlockMenu editor={editor} />
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
tabIndex,
|
||||
value,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
isSmoothCursorEnabled,
|
||||
} = props;
|
||||
@@ -70,6 +71,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
tabIndex,
|
||||
value,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
isSmoothCursorEnabled,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Editor, useEditorState } from "@tiptap/react";
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react";
|
||||
// components
|
||||
import { LinkView, LinkViewProps } from "@/components/links";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
@@ -18,7 +20,7 @@ export const LinkViewContainer: FC<Props> = ({ editor, containerRef }) => {
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }: { editor: Editor }) => ({
|
||||
linkExtensionStorage: editor.storage.link,
|
||||
linkExtensionStorage: getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_LINK),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -8,17 +8,22 @@ import {
|
||||
useInteractions,
|
||||
FloatingPortal,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||
import { type Editor, useEditorState } from "@tiptap/react";
|
||||
import { Copy, LucideIcon, Trash2, Link, Code, Bookmark } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane imports
|
||||
// import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
// types
|
||||
import { EExternalEmbedAttributeNames, IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||
};
|
||||
|
||||
export const BlockMenu = (props: Props) => {
|
||||
@@ -29,6 +34,9 @@ export const BlockMenu = (props: Props) => {
|
||||
const virtualReferenceRef = useRef<{ getBoundingClientRect: () => DOMRect }>({
|
||||
getBoundingClientRect: () => new DOMRect(),
|
||||
});
|
||||
// const { t } = useTranslation();
|
||||
const isEmbedFlagged =
|
||||
props.flaggedExtensions?.includes("external-embed") || props.disabledExtensions?.includes("external-embed");
|
||||
|
||||
// Set up Floating UI with virtual reference element
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
@@ -72,6 +80,51 @@ export const BlockMenu = (props: Props) => {
|
||||
[refs]
|
||||
);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => {
|
||||
const selection = editor.state.selection;
|
||||
const content = selection.content().content;
|
||||
const firstChild = content.firstChild;
|
||||
let linkUrl: string | null = null;
|
||||
const foundLinkMarks: string[] = [];
|
||||
|
||||
const isEmbedActive = editor.isActive(ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED);
|
||||
const isRichCard = firstChild?.attrs[EExternalEmbedAttributeNames.IS_RICH_CARD];
|
||||
const isNotEmbeddable = firstChild?.attrs[EExternalEmbedAttributeNames.HAS_EMBED_FAILED];
|
||||
|
||||
if (firstChild) {
|
||||
for (let i = 0; i < firstChild.childCount; i++) {
|
||||
const node = firstChild.child(i);
|
||||
const linkMarks = node.marks?.filter(
|
||||
(mark) => mark.type.name === CORE_EXTENSIONS.CUSTOM_LINK && mark.attrs?.href
|
||||
);
|
||||
|
||||
if (linkMarks && linkMarks.length > 0) {
|
||||
linkMarks.forEach((mark) => {
|
||||
foundLinkMarks.push(mark.attrs.href);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (firstChild.attrs.src) {
|
||||
foundLinkMarks.push(firstChild.attrs.src);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundLinkMarks.length === 1) {
|
||||
linkUrl = foundLinkMarks[0];
|
||||
}
|
||||
|
||||
return {
|
||||
isEmbedActive,
|
||||
isLinkEmbeddable: isEmbedActive || !!linkUrl,
|
||||
linkUrl,
|
||||
isRichCard,
|
||||
isNotEmbeddable,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -121,6 +174,95 @@ export const BlockMenu = (props: Props) => {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
isDisabled?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
icon: Link,
|
||||
key: "link",
|
||||
label: "Convert to Link",
|
||||
// label: "externalEmbedComponent.block_menu.convert_to_link",
|
||||
isDisabled: !editorState.isEmbedActive || !editorState.linkUrl || isEmbedFlagged,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const node = selection.content().content.firstChild;
|
||||
if (node?.type.name === ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED) {
|
||||
const LinkValue = node.attrs.src;
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(selection, {
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: LinkValue,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: LinkValue,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Code,
|
||||
key: "embed",
|
||||
label: "Convert to Embed",
|
||||
// label: "externalEmbedComponent.block_menu.convert_to_embed",
|
||||
isDisabled:
|
||||
editorState.isNotEmbeddable ||
|
||||
!editorState.isLinkEmbeddable ||
|
||||
(editorState.isEmbedActive && !editorState.isRichCard) ||
|
||||
isEmbedFlagged,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const LinkValue = editorState.linkUrl;
|
||||
if (LinkValue) {
|
||||
editor
|
||||
.chain()
|
||||
.insertExternalEmbed({
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: false,
|
||||
[EExternalEmbedAttributeNames.SOURCE]: LinkValue,
|
||||
pos: selection,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Bookmark,
|
||||
key: "richcard",
|
||||
label: "Convert to Rich Card",
|
||||
// label: "externalEmbedComponent.block_menu.convert_to_richcard",
|
||||
isDisabled: !editorState.isLinkEmbeddable || !editorState.linkUrl || editorState.isRichCard || isEmbedFlagged,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const LinkValue = editorState.linkUrl;
|
||||
if (LinkValue) {
|
||||
editor
|
||||
.chain()
|
||||
.insertExternalEmbed({
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: true,
|
||||
[EExternalEmbedAttributeNames.SOURCE]: LinkValue,
|
||||
pos: selection,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
key: "delete",
|
||||
@@ -155,15 +297,26 @@ export const BlockMenu = (props: Props) => {
|
||||
if (insertPos < 0 || insertPos > docSize) {
|
||||
throw new Error("The insertion position is invalid or outside the document.");
|
||||
}
|
||||
let contentToInsert = firstChild.toJSON();
|
||||
if (contentToInsert.type === ADDITIONAL_EXTENSIONS.BLOCK_MATH) {
|
||||
contentToInsert = {
|
||||
type: ADDITIONAL_EXTENSIONS.BLOCK_MATH,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
const contentToInsert = firstChild.toJSON();
|
||||
if (contentToInsert.type === ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED) {
|
||||
return editor
|
||||
.chain()
|
||||
.insertExternalEmbed({
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: contentToInsert.attrs.is_rich_card,
|
||||
[EExternalEmbedAttributeNames.SOURCE]: contentToInsert.attrs.src,
|
||||
pos: insertPos,
|
||||
})
|
||||
.focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false })
|
||||
.run();
|
||||
} else if (contentToInsert.type === ADDITIONAL_EXTENSIONS.BLOCK_MATH) {
|
||||
return editor
|
||||
.chain()
|
||||
.setBlockMath({
|
||||
latex: contentToInsert.attrs.latex,
|
||||
},
|
||||
};
|
||||
pos: insertPos,
|
||||
})
|
||||
.focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false })
|
||||
.run();
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
@@ -219,6 +372,7 @@ export const BlockMenu = (props: Props) => {
|
||||
>
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
{/* {t(item.label)} */}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LinkIcon,
|
||||
Sigma,
|
||||
SquareRadical,
|
||||
FileCode2,
|
||||
} from "lucide-react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
@@ -52,7 +53,7 @@ import {
|
||||
} from "@/helpers/editor-commands";
|
||||
// plane editor imports
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
import { insertBlockMath, insertInlineMath } from "@/plane-editor/helpers/editor-commands";
|
||||
import { insertBlockMath, insertExternalEmbed, insertInlineMath } from "@/plane-editor/helpers/editor-commands";
|
||||
// types
|
||||
import { TCommandWithProps, TEditorCommands } from "@/types";
|
||||
|
||||
@@ -274,6 +275,17 @@ export const InlineEquationItem = (editor: Editor): EditorMenuItem<"inline-equat
|
||||
icon: SquareRadical,
|
||||
});
|
||||
|
||||
export const ExternalEmbedItem = (editor: Editor): EditorMenuItem<"external-embed"> => ({
|
||||
key: "external-embed",
|
||||
name: "External embed",
|
||||
isActive: () => editor.isActive(ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
insertExternalEmbed({ editor, is_rich_card: props.is_rich_card });
|
||||
},
|
||||
icon: FileCode2,
|
||||
});
|
||||
|
||||
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEditorCommands>[] => {
|
||||
if (!editor) return [];
|
||||
|
||||
@@ -303,5 +315,6 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
|
||||
TextAlignItem(editor),
|
||||
BlockEquationItem(editor),
|
||||
InlineEquationItem(editor),
|
||||
ExternalEmbedItem(editor),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ declare module "@tiptap/core" {
|
||||
export type CustomLinkStorage = {
|
||||
isPreviewOpen: boolean;
|
||||
posToInsert: { from: number; to: number };
|
||||
isBubbleMenuOpen: boolean;
|
||||
};
|
||||
|
||||
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
|
||||
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
TableRow,
|
||||
UtilityExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
import type { IEditorPropsExtended } from "@/plane-editor/types/editor-extended";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
import type { IEditorProps, TEmbedConfig } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
import { EmojiExtension } from "./emoji/extension";
|
||||
@@ -50,7 +51,7 @@ type TArguments = Pick<
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
editable: boolean;
|
||||
} & Pick<IEditorPropsExtended, "extensionOptions" | "isSmoothCursorEnabled">;
|
||||
} & Pick<IEditorPropsExtended, "embedHandler" | "extensionOptions" | "isSmoothCursorEnabled">;
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const {
|
||||
@@ -64,6 +65,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
tabIndex,
|
||||
editable,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
isSmoothCursorEnabled,
|
||||
} = args;
|
||||
@@ -121,6 +123,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -129,6 +129,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
|
||||
// Initialize main document editor
|
||||
const editor = useEditor({
|
||||
embedHandler,
|
||||
disabledExtensions,
|
||||
id,
|
||||
editable,
|
||||
|
||||
@@ -38,6 +38,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
tabIndex,
|
||||
value,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
isSmoothCursorEnabled = false,
|
||||
} = props;
|
||||
@@ -67,6 +68,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
||||
placeholder,
|
||||
tabIndex,
|
||||
// additional props
|
||||
embedHandler,
|
||||
extensionOptions,
|
||||
isSmoothCursorEnabled,
|
||||
}),
|
||||
|
||||
@@ -21,6 +21,7 @@ const generalSelectors = [
|
||||
".image-component",
|
||||
".image-upload-component",
|
||||
".editor-callout-component",
|
||||
".editor-embed-component",
|
||||
".editor-attachment-component",
|
||||
".page-embed-component",
|
||||
".editor-mathematics-component",
|
||||
@@ -106,6 +107,11 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip elements inside .editor-embed-component
|
||||
if (elem.closest(".editor-embed-component") && !elem.matches(".editor-embed-component")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// apply general selector
|
||||
if (elem.matches(generalSelectors)) {
|
||||
return elem;
|
||||
|
||||
@@ -54,6 +54,7 @@ export type TEditorCommands =
|
||||
| "page-embed"
|
||||
| "attachment"
|
||||
| "emoji"
|
||||
| "external-embed"
|
||||
| "block-equation"
|
||||
| "inline-equation";
|
||||
|
||||
@@ -83,6 +84,10 @@ export type TCommandExtraProps = {
|
||||
"inline-equation": {
|
||||
latex: string;
|
||||
};
|
||||
"external-embed": {
|
||||
src: string;
|
||||
is_rich_card: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Create a utility type that maps a command to its extra props or an empty object if none are defined
|
||||
@@ -163,6 +168,7 @@ export type IEditorProps = {
|
||||
editable: boolean;
|
||||
editorClassName?: string;
|
||||
editorProps?: EditorProps;
|
||||
embedHandler?: TEmbedConfig;
|
||||
extensions?: Extensions;
|
||||
flaggedExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
|
||||
@@ -7,5 +7,6 @@ export type TExtensions =
|
||||
| "enter-key"
|
||||
| "image"
|
||||
| "nested-pages"
|
||||
| "external-embed"
|
||||
| "attachments"
|
||||
| "mathematics";
|
||||
|
||||
@@ -16,7 +16,7 @@ type TCoreHookProps = Pick<
|
||||
| "isTouchDevice"
|
||||
| "onEditorFocus"
|
||||
> &
|
||||
Pick<IEditorPropsExtended, "extensionOptions" | "isSmoothCursorEnabled">;
|
||||
Pick<IEditorPropsExtended, "embedHandler" | "extensionOptions" | "isSmoothCursorEnabled">;
|
||||
|
||||
export type TEditorHookProps = TCoreHookProps &
|
||||
Pick<
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { ProBadge } from "@/plane-editor/components/badges/pro-badge";
|
||||
|
||||
export const MathUpgradeModal: React.FC = () => (
|
||||
export const UpgradeNowModal: React.FC = () => (
|
||||
<div className="bg-custom-background-100 border border-custom-border-200 rounded-lg w-72 my-2 transition-all duration-300 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="flex flex-col space-y-2 p-3 pb-0">
|
||||
<ProBadge />
|
||||
@@ -5,4 +5,5 @@ export enum ADDITIONAL_EXTENSIONS {
|
||||
MATHEMATICS = "mathematics",
|
||||
INLINE_MATH = "inlineMath",
|
||||
BLOCK_MATH = "blockMath",
|
||||
EXTERNAL_EMBED = "externalEmbed",
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ import { Extensions } from "@tiptap/core";
|
||||
// ce imports
|
||||
import type { TCoreAdditionalExtensionsProps } from "src/ce/extensions";
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
import { MathematicsExtension } from "@/plane-editor/extensions/mathematics";
|
||||
import type { IEditorPropsExtended } from "@/plane-editor/types/editor-extended";
|
||||
// types
|
||||
import type { TExternalEmbedConfig } from "@/types";
|
||||
// local imports
|
||||
import { ExternalEmbedExtension } from "../external-embed/extension";
|
||||
import { MathematicsExtension } from "../mathematics/extension";
|
||||
|
||||
type Props = TCoreAdditionalExtensionsProps & Pick<IEditorPropsExtended, "extensionOptions">;
|
||||
type Props = TCoreAdditionalExtensionsProps & Pick<IEditorPropsExtended, "embedHandler" | "extensionOptions">;
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const { flaggedExtensions, extensionOptions } = props;
|
||||
const { flaggedExtensions, extensionOptions, disabledExtensions } = props;
|
||||
const extensions: Extensions = [];
|
||||
extensions.push(
|
||||
MathematicsExtension({
|
||||
@@ -16,5 +20,12 @@ export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
...extensionOptions?.[ADDITIONAL_EXTENSIONS.MATHEMATICS],
|
||||
})
|
||||
);
|
||||
const widgetCallback: TExternalEmbedConfig["widgetCallback"] =
|
||||
props.embedHandler?.externalEmbedComponent?.widgetCallback ?? (() => null);
|
||||
if (!disabledExtensions?.includes("external-embed")) {
|
||||
extensions.push(
|
||||
ExternalEmbedExtension({ isFlagged: !!flaggedExtensions?.includes("external-embed"), widgetCallback })
|
||||
);
|
||||
}
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { CustomAttachmentExtensionConfig } from "../attachments/extension-config";
|
||||
import { ExternalEmbedExtensionConfig } from "../external-embed/extension-config";
|
||||
import { MathematicsExtensionConfig } from "../mathematics/extension-config";
|
||||
import { PageEmbedExtensionConfig } from "../page-embed/extension-config";
|
||||
|
||||
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [
|
||||
ExternalEmbedExtensionConfig,
|
||||
CustomAttachmentExtensionConfig,
|
||||
MathematicsExtensionConfig,
|
||||
];
|
||||
|
||||
57
packages/editor/src/ee/extensions/external-embed/commands.ts
Normal file
57
packages/editor/src/ee/extensions/external-embed/commands.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { RawCommands } from "@tiptap/core";
|
||||
import type { NodeType } from "@tiptap/pm/model";
|
||||
import tldjs from "tldjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// constants
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
// types
|
||||
import { EExternalEmbedAttributeNames, EExternalEmbedEntityType } from "@/types";
|
||||
import type { InsertExternalEmbedCommandProps } from "./types";
|
||||
// hooks
|
||||
import { useModifiedEmbedUrl } from "./utils/url-modify";
|
||||
|
||||
export const externalEmbedCommands = (nodeType: NodeType): Partial<RawCommands> => ({
|
||||
insertExternalEmbed:
|
||||
(props: InsertExternalEmbedCommandProps) =>
|
||||
({ commands, editor }) => {
|
||||
const uniqueID = uuidv4();
|
||||
const modifiedUrl = useModifiedEmbedUrl({ url: props[EExternalEmbedAttributeNames.SOURCE] || "" });
|
||||
|
||||
const options = {
|
||||
[EExternalEmbedAttributeNames.SOURCE]: modifiedUrl,
|
||||
[EExternalEmbedAttributeNames.ID]: uniqueID,
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: props[EExternalEmbedAttributeNames.IS_RICH_CARD],
|
||||
[EExternalEmbedAttributeNames.ENTITY_TYPE]: props[EExternalEmbedAttributeNames.IS_RICH_CARD]
|
||||
? EExternalEmbedEntityType.RICH_CARD
|
||||
: EExternalEmbedEntityType.EMBED,
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: false,
|
||||
};
|
||||
|
||||
if (modifiedUrl) {
|
||||
const sourceURL = new URL(modifiedUrl);
|
||||
const domain = tldjs.getDomain(modifiedUrl) || tldjs.getSubdomain(modifiedUrl) || sourceURL.hostname;
|
||||
const siteName = domain.split(".")[0];
|
||||
if (siteName) {
|
||||
options[EExternalEmbedAttributeNames.ENTITY_NAME] = siteName;
|
||||
}
|
||||
} else {
|
||||
const storage = getExtensionStorage(editor, ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED);
|
||||
if (storage) {
|
||||
storage.openInput = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.pos) {
|
||||
commands.insertContentAt(props.pos, {
|
||||
type: nodeType.name,
|
||||
attrs: options,
|
||||
});
|
||||
} else {
|
||||
commands.insertContent({ type: nodeType.name, attrs: options });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { FileCode2 } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState, memo, useCallback } from "react";
|
||||
// plane imports
|
||||
// import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// constants
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
// components
|
||||
import { ExternalEmbedInputModal } from "./floating-input-modal";
|
||||
import { ExternalEmbedNodeViewProps } from "@/types";
|
||||
|
||||
export const ExternalEmbedBlock: React.FC<ExternalEmbedNodeViewProps> = memo((externalEmbedProps) => {
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const embedButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { isFlagged } = externalEmbedProps.extension.options;
|
||||
// const { t } = useTranslation();
|
||||
|
||||
// subscribe to external embed storage state
|
||||
const shouldOpenInput = useEditorState({
|
||||
editor: externalEmbedProps.editor,
|
||||
selector: ({ editor }) => {
|
||||
const storage = getExtensionStorage(editor, ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED);
|
||||
return editor.isEditable && storage.openInput;
|
||||
},
|
||||
});
|
||||
|
||||
// handlers
|
||||
const handleEmbedButtonClick = useCallback(() => {
|
||||
if (externalEmbedProps.editor.isEditable) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [externalEmbedProps.editor.isEditable]);
|
||||
|
||||
// effects
|
||||
useEffect(() => {
|
||||
if (shouldOpenInput) {
|
||||
setIsOpen(true);
|
||||
// Reset the openInput flag using proper pattern
|
||||
const ExternalEmbedExtensionStorage = getExtensionStorage(
|
||||
externalEmbedProps.editor,
|
||||
ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED
|
||||
);
|
||||
ExternalEmbedExtensionStorage.openInput = false;
|
||||
}
|
||||
}, [shouldOpenInput, externalEmbedProps.editor]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={embedButtonRef}
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-2 py-3 px-2 my-2 rounded-lg text-custom-text-300 bg-custom-background-90 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
|
||||
{
|
||||
"hover:text-custom-text-200 hover:bg-custom-background-80 cursor-pointer":
|
||||
externalEmbedProps.editor.isEditable,
|
||||
"text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200":
|
||||
externalEmbedProps.selected && externalEmbedProps.editor.isEditable,
|
||||
}
|
||||
)}
|
||||
onClick={handleEmbedButtonClick}
|
||||
>
|
||||
<FileCode2 className="size-4" />
|
||||
|
||||
<div className="text-base font-medium">
|
||||
{"Insert your preferred embed link here, such as YouTube video, Figma design, etc."}
|
||||
{/* {t("externalEmbedComponent.placeholder.insert_embed")} */}
|
||||
</div>
|
||||
|
||||
<input className="size-0 overflow-hidden" hidden type="file" multiple />
|
||||
</div>
|
||||
<ExternalEmbedInputModal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
referenceElement={embedButtonRef.current}
|
||||
externalEmbedProps={externalEmbedProps}
|
||||
isFlagged={isFlagged}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
hide,
|
||||
shift,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
FloatingPortal,
|
||||
} from "@floating-ui/react";
|
||||
// components
|
||||
import { UpgradeNowModal } from "@/plane-editor/components/modal/upgrade-modal";
|
||||
import { ExternalEmbedInputView } from "./input-view";
|
||||
import { ExternalEmbedNodeViewProps } from "@/types";
|
||||
|
||||
type ExternalEmbedInputModalProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
referenceElement: HTMLElement | null;
|
||||
externalEmbedProps: ExternalEmbedNodeViewProps;
|
||||
isFlagged: boolean;
|
||||
};
|
||||
|
||||
export const ExternalEmbedInputModal: React.FC<ExternalEmbedInputModalProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
referenceElement,
|
||||
externalEmbedProps,
|
||||
isFlagged,
|
||||
}) => {
|
||||
// hooks
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
elements: {
|
||||
reference: referenceElement,
|
||||
},
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top", "bottom"],
|
||||
}),
|
||||
shift({
|
||||
padding: 5,
|
||||
}),
|
||||
hide(),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
// handlers
|
||||
const dismiss = useDismiss(context);
|
||||
const { getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
if (!isOpen || !referenceElement) return null;
|
||||
return (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
transform: `${floatingStyles.transform} translateY(6px)`,
|
||||
}}
|
||||
{...getFloatingProps()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isFlagged ? (
|
||||
<UpgradeNowModal />
|
||||
) : (
|
||||
<ExternalEmbedInputView
|
||||
style={floatingStyles}
|
||||
setIsOpen={setIsOpen}
|
||||
externalEmbedProps={externalEmbedProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { find } from "linkifyjs";
|
||||
import { type CSSProperties, useState, useRef, useEffect } from "react";
|
||||
// plane imports
|
||||
// import { useTranslation } from "@plane/i18n";
|
||||
import { Input, Button } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// constants
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
// types
|
||||
import { EExternalEmbedAttributeNames } from "@/plane-editor/types/external-embed";
|
||||
import { ExternalEmbedNodeViewProps } from "@/types";
|
||||
|
||||
type ExternalEmbedInputViewProps = {
|
||||
style: CSSProperties;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
externalEmbedProps: ExternalEmbedNodeViewProps;
|
||||
};
|
||||
|
||||
export const ExternalEmbedInputView: React.FC<ExternalEmbedInputViewProps> = ({
|
||||
style: _style,
|
||||
setIsOpen,
|
||||
externalEmbedProps,
|
||||
}) => {
|
||||
// states
|
||||
const [url, setUrl] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// translation
|
||||
// const { t } = useTranslation();
|
||||
|
||||
// effects
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// handlers
|
||||
const handleEmbedClick = () => {
|
||||
setError(false);
|
||||
const link = find(url);
|
||||
const { selection } = externalEmbedProps.editor.state;
|
||||
const { from, to } = selection;
|
||||
if (link && link.length > 0 && link[0]?.href) {
|
||||
externalEmbedProps.editor
|
||||
.chain()
|
||||
.insertExternalEmbed({
|
||||
[EExternalEmbedAttributeNames.SOURCE]: link[0].href,
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: false,
|
||||
pos: { from, to },
|
||||
})
|
||||
.run();
|
||||
setIsOpen(false);
|
||||
const ExternalEmbedExtensionStorage = getExtensionStorage(
|
||||
externalEmbedProps.editor,
|
||||
ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED
|
||||
);
|
||||
ExternalEmbedExtensionStorage.openInput = false;
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-custom-background-90 border border-custom-border-300 rounded-md p-3 pt-1 shadow-lg z-[9999]"
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateY(0)" : "translateY(10px)",
|
||||
transition: "opacity 0.3s ease-out, transform 0.3s ease-out",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
{error ? (
|
||||
<p className="text-red-500 text-xs my-1">
|
||||
Please enter a valid URL.
|
||||
{/* {t("externalEmbedComponent.error.not_valid_link")} */}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-custom-text-300 my-1">
|
||||
Works with YouTube, Figma, Google Docs and more
|
||||
{/* {t("externalEmbedComponent.input_modal.works_with_links")} */}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 w-full h-7 ">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={cn("w-full min-w-[250px] focus:outline-none focus:ring-1 focus:ring-custom-primary-200", {
|
||||
"border-red-500 focus:ring-red-500": error,
|
||||
"border-custom-border-300 focus:ring-custom-primary-200": !error,
|
||||
})}
|
||||
placeholder="Enter or paste a link"
|
||||
// placeholder={t("externalEmbedComponent.placeholder.link")}
|
||||
value={url}
|
||||
type="url"
|
||||
inputSize="sm"
|
||||
hasError={!!error}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleEmbedClick();
|
||||
}
|
||||
}}
|
||||
mode="primary"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEmbedClick();
|
||||
}}
|
||||
>
|
||||
Embed
|
||||
{/* {t("externalEmbedComponent.input_modal.embed")} */}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NodeViewProps } from "@tiptap/core";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
// types
|
||||
import { TExternalEmbedBlockAttributes } from "@/types";
|
||||
// components
|
||||
import { ExternalEmbedBlock } from "./block";
|
||||
import { ExternalEmbedExtension } from "../types";
|
||||
|
||||
export type ExternalEmbedNodeViewProps = Omit<NodeViewProps, "extension"> & {
|
||||
extension: ExternalEmbedExtension;
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TExternalEmbedBlockAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<TExternalEmbedBlockAttributes>) => void;
|
||||
};
|
||||
|
||||
export const ExternalEmbedNodeView: React.FC<ExternalEmbedNodeViewProps> = (props) => {
|
||||
const { extension, node, selected } = props;
|
||||
const ExternalEmbedComponent = extension.options.externalEmbedCallbackComponent;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="editor-embed-component relative" contentEditable={false}>
|
||||
{!node.attrs.src || node.attrs.src.trim() === "" ? (
|
||||
<ExternalEmbedBlock {...props} />
|
||||
) : (
|
||||
<div className="relative" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||
<ExternalEmbedComponent {...props} />
|
||||
{selected && (
|
||||
<div className="absolute inset-0 size-full bg-custom-primary-500/30 pointer-events-none rounded-md" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
export const EMBED_SEARCH_TERMS = [
|
||||
"embed",
|
||||
"link",
|
||||
"url",
|
||||
"iframe",
|
||||
"video",
|
||||
"audio",
|
||||
"richcard",
|
||||
// Video platforms
|
||||
"youtube",
|
||||
"vimeo",
|
||||
"loom",
|
||||
"wistia",
|
||||
"twitch",
|
||||
"dailymotion",
|
||||
// Design & collaboration tools
|
||||
"figma",
|
||||
"miro",
|
||||
"canva",
|
||||
"whimsical",
|
||||
"lucidchart",
|
||||
"draw.io",
|
||||
// Code & development
|
||||
"codepen",
|
||||
"codesandbox",
|
||||
"github",
|
||||
"gist",
|
||||
"replit",
|
||||
"stackblitz",
|
||||
// Music & audio
|
||||
"spotify",
|
||||
"soundcloud",
|
||||
"apple music",
|
||||
"bandcamp",
|
||||
// Social media
|
||||
"twitter",
|
||||
"x",
|
||||
"instagram",
|
||||
"linkedin",
|
||||
"tiktok",
|
||||
"facebook",
|
||||
// Documents & productivity
|
||||
"google docs",
|
||||
"google sheets",
|
||||
"google slides",
|
||||
"notion",
|
||||
"airtable",
|
||||
"typeform",
|
||||
// Maps & location
|
||||
"google maps",
|
||||
"mapbox",
|
||||
// Creative portfolios
|
||||
"dribbble",
|
||||
"behance",
|
||||
"artstation",
|
||||
// Other popular platforms
|
||||
"calendly",
|
||||
"hubspot",
|
||||
"mailchimp",
|
||||
"stripe",
|
||||
"paypal",
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
|
||||
// types
|
||||
import { EExternalEmbedAttributeNames } from "@/plane-editor/types/external-embed";
|
||||
import type { ExternalEmbedExtension, InsertExternalEmbedCommandProps } from "./types";
|
||||
// utils
|
||||
import { DEFAULT_EXTERNAL_EMBED_ATTRIBUTES } from "./utils/attribute";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED]: {
|
||||
insertExternalEmbed: (props: InsertExternalEmbedCommandProps) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ExternalEmbedExtensionConfig: ExternalEmbedExtension = Node.create({
|
||||
name: ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED,
|
||||
group: "block",
|
||||
atom: true,
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
const attributes = {
|
||||
...Object.values(EExternalEmbedAttributeNames).reduce((acc, value) => {
|
||||
acc[value] = {
|
||||
default: DEFAULT_EXTERNAL_EMBED_ATTRIBUTES[value],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
return attributes;
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "external-embed",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["external-embed", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// commands
|
||||
import { externalEmbedCommands } from "./commands";
|
||||
// components
|
||||
import { ExternalEmbedNodeView } from "./components/node-view";
|
||||
// config
|
||||
import { ExternalEmbedExtensionConfig } from "./extension-config";
|
||||
// plugins
|
||||
import { createExternalEmbedPastePlugin } from "./plugins";
|
||||
// types
|
||||
import { ExternalEmbedExtensionStorage, ExternalEmbedProps } from "./types";
|
||||
import { ExternalEmbedNodeViewProps } from "@/types";
|
||||
|
||||
export const ExternalEmbedExtension = (props: ExternalEmbedProps) =>
|
||||
ExternalEmbedExtensionConfig.extend({
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
externalEmbedCallbackComponent: props?.widgetCallback,
|
||||
isFlagged: !!props?.isFlagged,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage(): ExternalEmbedExtensionStorage {
|
||||
return {
|
||||
posToInsert: { from: 0, to: 0 },
|
||||
url: "",
|
||||
openInput: false,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
createExternalEmbedPastePlugin({
|
||||
isFlagged: this.options.isFlagged,
|
||||
editor: this.editor,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return externalEmbedCommands(this.type);
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((props) => (
|
||||
<ExternalEmbedNodeView {...props} node={props.node as ExternalEmbedNodeViewProps["node"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
48
packages/editor/src/ee/extensions/external-embed/plugins.ts
Normal file
48
packages/editor/src/ee/extensions/external-embed/plugins.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { find } from "linkifyjs";
|
||||
import type { Slice } from "@tiptap/pm/model";
|
||||
// plane editor imports
|
||||
import { EExternalEmbedAttributeNames } from "@/plane-editor/types/external-embed";
|
||||
|
||||
export const EXTERNAL_EMBED_PASTE_PLUGIN_KEY = new PluginKey("externalEmbedPastePlugin");
|
||||
|
||||
export const createExternalEmbedPastePlugin = (options: { isFlagged: boolean; editor: Editor }): Plugin =>
|
||||
new Plugin({
|
||||
key: EXTERNAL_EMBED_PASTE_PLUGIN_KEY,
|
||||
props: {
|
||||
handlePaste: (view: EditorView, event: ClipboardEvent, slice: Slice) => {
|
||||
const { from } = view.state.selection;
|
||||
const $from = view.state.doc.resolve(from);
|
||||
const paragraphNode = $from.node($from.depth);
|
||||
const isEmpty = paragraphNode.content.size === 0;
|
||||
let textContent = "";
|
||||
|
||||
slice.content.forEach((node) => {
|
||||
textContent += node.textContent;
|
||||
});
|
||||
|
||||
const { isFlagged } = options;
|
||||
const link = find(textContent).find((item) => item.isLink && item.value === textContent);
|
||||
|
||||
if (link?.href && isEmpty && !isFlagged) {
|
||||
const { from, to } = view.state.selection;
|
||||
|
||||
options.editor
|
||||
.chain()
|
||||
.insertExternalEmbed({
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: false,
|
||||
[EExternalEmbedAttributeNames.SOURCE]: link.href,
|
||||
pos: { from, to },
|
||||
})
|
||||
.createParagraphNear()
|
||||
.run();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/editor/src/ee/extensions/external-embed/types.ts
Normal file
28
packages/editor/src/ee/extensions/external-embed/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/core";
|
||||
import type { Range } from "@tiptap/react";
|
||||
import { EExternalEmbedAttributeNames, ExternalEmbedNodeViewProps } from "@/plane-editor/types/external-embed";
|
||||
|
||||
// Extension-specific Types
|
||||
export type ExternalEmbedProps = {
|
||||
widgetCallback: (props: ExternalEmbedNodeViewProps) => React.ReactNode;
|
||||
isFlagged: boolean;
|
||||
};
|
||||
|
||||
export type InsertExternalEmbedCommandProps = {
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: boolean;
|
||||
[EExternalEmbedAttributeNames.SOURCE]?: string;
|
||||
pos?: number | Range;
|
||||
};
|
||||
|
||||
export type ExternalEmbedExtensionOptions = {
|
||||
externalEmbedCallbackComponent: (props: ExternalEmbedNodeViewProps) => React.ReactNode;
|
||||
isFlagged: boolean;
|
||||
};
|
||||
|
||||
export type ExternalEmbedExtensionStorage = {
|
||||
posToInsert: { from: number; to: number };
|
||||
url: string;
|
||||
openInput: boolean;
|
||||
};
|
||||
|
||||
export type ExternalEmbedExtension = ProseMirrorNode<ExternalEmbedExtensionOptions, ExternalEmbedExtensionStorage>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { EExternalEmbedAttributeNames, EExternalEmbedEntityType, TExternalEmbedBlockAttributes } from "@/types";
|
||||
|
||||
export const DEFAULT_EXTERNAL_EMBED_ATTRIBUTES: TExternalEmbedBlockAttributes = {
|
||||
[EExternalEmbedAttributeNames.SOURCE]: null,
|
||||
[EExternalEmbedAttributeNames.ID]: null,
|
||||
[EExternalEmbedAttributeNames.EMBED_DATA]: null,
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: false,
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: false,
|
||||
[EExternalEmbedAttributeNames.ENTITY_NAME]: null,
|
||||
[EExternalEmbedAttributeNames.ENTITY_TYPE]: EExternalEmbedEntityType.EMBED,
|
||||
[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING]: false,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
type EmbedUrlModifier = {
|
||||
match: (url: string) => boolean;
|
||||
modify: (url: string) => string;
|
||||
};
|
||||
|
||||
const embedUrlModifiers: Record<string, EmbedUrlModifier> = {
|
||||
figma: {
|
||||
match: (url) => url.includes("www.figma.com"),
|
||||
modify: (url) => {
|
||||
let modifiedUrl = url.replace("www.figma.com", "embed.figma.com");
|
||||
if (!modifiedUrl.includes("embed-host=")) {
|
||||
modifiedUrl += modifiedUrl.includes("?") ? "&embed-host=share" : "?embed-host=share";
|
||||
}
|
||||
return modifiedUrl;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const useModifiedEmbedUrl = ({ url }: { url: string }) => {
|
||||
// Find the first matching modifier and apply it, or return the original URL
|
||||
const modifier = Object.values(embedUrlModifiers).find((mod) => mod.match(url));
|
||||
return modifier ? modifier.modify(url) : url;
|
||||
};
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
} from "@floating-ui/react";
|
||||
import { FC, useEffect } from "react";
|
||||
// types
|
||||
import { UpgradeNowModal } from "@/plane-editor/components/modal/upgrade-modal";
|
||||
import { TMathModalBaseProps } from "../types";
|
||||
// components
|
||||
import { MathInputModal } from "./input-modal";
|
||||
import { MathUpgradeModal } from "./upgrade-modal";
|
||||
|
||||
type TFloatingMathModalProps = TMathModalBaseProps & {
|
||||
isOpen: boolean;
|
||||
@@ -88,7 +88,7 @@ export const FloatingMathModal: FC<TFloatingMathModalProps> = ({
|
||||
{/* Modal content */}
|
||||
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 100 }} {...getFloatingProps()}>
|
||||
{isFlagged ? (
|
||||
<MathUpgradeModal />
|
||||
<UpgradeNowModal />
|
||||
) : (
|
||||
<MathInputModal
|
||||
latex={latex}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
import { Paperclip } from "lucide-react";
|
||||
// root
|
||||
import { SlashCommands, TSlashCommandAdditionalOption } from "@/extensions/slash-commands/root";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
// core imports
|
||||
import {
|
||||
TRichTextEditorAdditionalExtensionsProps,
|
||||
TRichTextEditorAdditionalExtensionsRegistry,
|
||||
} from "src/ce/extensions/rich-text-extensions";
|
||||
// extensions
|
||||
import { SlashCommands, TSlashCommandAdditionalOption } from "@/extensions/slash-commands/root";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
// local imports
|
||||
import { insertAttachment } from "../helpers/editor-commands";
|
||||
import { CustomAttachmentExtension } from "./attachments/extension";
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
// extensions
|
||||
import { Sigma, SquareRadical } from "lucide-react";
|
||||
import { FileCode2, Sigma, SquareRadical } from "lucide-react";
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
import type { CommandProps, IEditorProps, TExtensions } from "@/types";
|
||||
import { ProBadge } from "../components/badges/pro-badge";
|
||||
import { insertBlockMath, insertInlineMath } from "../helpers/editor-commands";
|
||||
import { insertBlockMath, insertExternalEmbed, insertInlineMath } from "../helpers/editor-commands";
|
||||
import { EMBED_SEARCH_TERMS } from "./external-embed/constants";
|
||||
|
||||
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const { flaggedExtensions } = props;
|
||||
|
||||
// General options
|
||||
const options: TSlashCommandAdditionalOption[] = [];
|
||||
|
||||
// Math options
|
||||
const mathOptions: TSlashCommandAdditionalOption[] = [
|
||||
{
|
||||
const coreSlashCommandRegistry: {
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
getOption: (props: Props) => TSlashCommandAdditionalOption;
|
||||
}[] = [
|
||||
{
|
||||
// Block equation slash command
|
||||
isEnabled: (disabledExtensions, flaggedExtensions) =>
|
||||
!flaggedExtensions?.includes("mathematics") && !disabledExtensions?.includes("mathematics"),
|
||||
getOption: ({ flaggedExtensions }) => ({
|
||||
commandKey: "block-equation",
|
||||
key: "block-equation",
|
||||
title: "Block equation",
|
||||
@@ -29,8 +30,13 @@ export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCom
|
||||
section: "general",
|
||||
pushAfter: "attachment",
|
||||
badge: flaggedExtensions?.includes("mathematics") ? <ProBadge /> : undefined,
|
||||
},
|
||||
{
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Inline equation slash command
|
||||
isEnabled: (disabledExtensions, flaggedExtensions) =>
|
||||
!flaggedExtensions?.includes("mathematics") && !disabledExtensions?.includes("mathematics"),
|
||||
getOption: ({ flaggedExtensions }) => ({
|
||||
commandKey: "inline-equation",
|
||||
key: "inline-equation",
|
||||
title: "Inline equation",
|
||||
@@ -43,11 +49,34 @@ export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCom
|
||||
section: "general",
|
||||
pushAfter: "block-equation",
|
||||
badge: flaggedExtensions?.includes("mathematics") ? <ProBadge /> : undefined,
|
||||
},
|
||||
];
|
||||
// Remove Slash if exteension is flagged
|
||||
if (!flaggedExtensions?.includes("mathematics")) {
|
||||
options.push(...mathOptions);
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
// External embed slash command
|
||||
isEnabled: (disabledExtensions, flaggedExtensions) =>
|
||||
!flaggedExtensions?.includes("external-embed") && !disabledExtensions?.includes("external-embed"),
|
||||
getOption: ({ flaggedExtensions }) => ({
|
||||
commandKey: "external-embed",
|
||||
key: "embed",
|
||||
title: "Embed",
|
||||
icon: <FileCode2 className="size-3.5" />,
|
||||
description: "Insert an Embed",
|
||||
searchTerms: EMBED_SEARCH_TERMS,
|
||||
command: ({ editor, range }: CommandProps) => insertExternalEmbed({ editor, range, is_rich_card: false }),
|
||||
badge: flaggedExtensions?.includes("external-embed") ? <ProBadge /> : undefined,
|
||||
section: "general",
|
||||
pushAfter: "code",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const { disabledExtensions = [], flaggedExtensions = [] } = props;
|
||||
|
||||
// Filter enabled slash command options from the registry
|
||||
const options = coreSlashCommandRegistry
|
||||
.filter((command) => command.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((command) => command.getOption(props));
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
// plane editor extensions
|
||||
import { type InsertAttachmentComponentProps } from "@/plane-editor/extensions/attachments/types";
|
||||
// types
|
||||
import { EExternalEmbedAttributeNames } from "@/plane-editor/types/external-embed";
|
||||
|
||||
export const insertAttachment = ({
|
||||
editor,
|
||||
@@ -32,3 +34,18 @@ export const insertInlineMath = ({ editor, range, latex }: { editor: Editor; ran
|
||||
if (range) editor.chain().focus().deleteRange(range).setInlineMath({ latex, pos: range.from }).run();
|
||||
else editor.chain().focus().setInlineMath({ latex }).run();
|
||||
};
|
||||
|
||||
export const insertExternalEmbed = ({
|
||||
editor,
|
||||
range,
|
||||
is_rich_card = false,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range?: Range;
|
||||
is_rich_card?: boolean;
|
||||
}) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertExternalEmbed({ [EExternalEmbedAttributeNames.IS_RICH_CARD]: is_rich_card, pos: range })
|
||||
.run();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ADDITIONAL_EXTENSIONS } from "../constants/extensions";
|
||||
import { MathematicsExtensionOptions } from "../extensions/mathematics/types";
|
||||
import type { TEmbedConfig } from "./issue-embed";
|
||||
|
||||
export type IEditorExtensionOptions = {
|
||||
[ADDITIONAL_EXTENSIONS.MATHEMATICS]?: Pick<MathematicsExtensionOptions, "onClick">;
|
||||
};
|
||||
|
||||
export type IEditorPropsExtended = {
|
||||
embedHandler?: TEmbedConfig;
|
||||
extensionOptions?: IEditorExtensionOptions;
|
||||
isSmoothCursorEnabled: boolean;
|
||||
};
|
||||
|
||||
30
packages/editor/src/ee/types/external-embed.ts
Normal file
30
packages/editor/src/ee/types/external-embed.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Core Enums
|
||||
export enum EExternalEmbedEntityType {
|
||||
EMBED = "embed",
|
||||
RICH_CARD = "rich_card",
|
||||
}
|
||||
|
||||
export enum EExternalEmbedAttributeNames {
|
||||
SOURCE = "src",
|
||||
ID = "id",
|
||||
EMBED_DATA = "embed_data",
|
||||
IS_RICH_CARD = "is_rich_card",
|
||||
HAS_EMBED_FAILED = "has_embed_failed",
|
||||
HAS_TRIED_EMBEDDING = "has_tried_embedding",
|
||||
ENTITY_NAME = "entity_name",
|
||||
ENTITY_TYPE = "entity_type",
|
||||
}
|
||||
|
||||
// Core Types with strict mapping
|
||||
export type TExternalEmbedBlockAttributes = Record<EExternalEmbedAttributeNames, unknown> & {
|
||||
[EExternalEmbedAttributeNames.SOURCE]: string | null;
|
||||
[EExternalEmbedAttributeNames.ID]: string | null;
|
||||
[EExternalEmbedAttributeNames.EMBED_DATA]: string | null;
|
||||
[EExternalEmbedAttributeNames.IS_RICH_CARD]: boolean;
|
||||
[EExternalEmbedAttributeNames.HAS_EMBED_FAILED]: boolean;
|
||||
[EExternalEmbedAttributeNames.HAS_TRIED_EMBEDDING]: boolean;
|
||||
[EExternalEmbedAttributeNames.ENTITY_NAME]: string | null;
|
||||
[EExternalEmbedAttributeNames.ENTITY_TYPE]: EExternalEmbedEntityType;
|
||||
};
|
||||
|
||||
export type { ExternalEmbedNodeViewProps } from "../extensions/external-embed/components/node-view";
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./issue-embed";
|
||||
export * from "./external-embed";
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
// types
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { TPage } from "@plane/types";
|
||||
import { TEmbedItem } from "@/types";
|
||||
import { PageEmbedExtensionAttributes } from "../extensions/page-embed/extension-config";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { TPage } from "@plane/types";
|
||||
import { ExternalEmbedNodeViewProps, TEmbedItem } from "@/types";
|
||||
import type { PageEmbedExtensionAttributes } from "../extensions/page-embed/extension-config";
|
||||
|
||||
export type TEmbedConfig = {
|
||||
issue?: TIssueEmbedConfig;
|
||||
page?: TPageEmbedConfig;
|
||||
externalEmbedComponent?: TExternalEmbedConfig;
|
||||
};
|
||||
|
||||
export type TReadOnlyEmbedConfig = {
|
||||
issue?: Omit<TIssueEmbedConfig, "searchCallback">;
|
||||
page?: Omit<TPageEmbedConfig, "createCallback" | "searchCallback">;
|
||||
externalEmbedComponent?: TExternalEmbedConfig;
|
||||
};
|
||||
|
||||
export type TExternalEmbedConfig = {
|
||||
widgetCallback: (props: ExternalEmbedNodeViewProps) => React.ReactNode;
|
||||
};
|
||||
|
||||
export type TIssueEmbedConfig = {
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
import { ADDITIONAL_EXTENSIONS } from "../constants/extensions";
|
||||
import { type AttachmentExtensionStorage } from "../extensions/attachments/types";
|
||||
import { type CollaborationCursorStorage } from "../extensions/collaboration-cursor";
|
||||
import { ExternalEmbedExtensionStorage } from "../extensions/external-embed/types";
|
||||
import { type MathematicsExtensionStorage } from "../extensions/mathematics/types";
|
||||
|
||||
export type ExtensionStorageMap = CoreExtensionStorageMap & {
|
||||
[ADDITIONAL_EXTENSIONS.ATTACHMENT]: AttachmentExtensionStorage;
|
||||
[ADDITIONAL_EXTENSIONS.COLLABORATION_CURSOR]: CollaborationCursorStorage;
|
||||
[ADDITIONAL_EXTENSIONS.MATHEMATICS]: MathematicsExtensionStorage;
|
||||
[ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED]: ExternalEmbedExtensionStorage;
|
||||
};
|
||||
|
||||
export type ExtensionFileSetStorageKey =
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
&.node-imageComponent,
|
||||
&.node-image,
|
||||
&.node-externalEmbed,
|
||||
&.table-wrapper,
|
||||
&.node-blockMath {
|
||||
--horizontal-offset: 0px;
|
||||
@@ -76,7 +77,8 @@
|
||||
|
||||
.ProseMirror node-image,
|
||||
.ProseMirror node-imageComponent,
|
||||
.ProseMirror node-blockMath {
|
||||
.ProseMirror node-blockMath,
|
||||
.ProseMirror node-externalEmbed {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Klikněte pro nahrání přílohy"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Převést na vložený obsah",
|
||||
"convert_to_link": "Převést na odkaz",
|
||||
"convert_to_richcard": "Převést na bohatou kartu"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Vložte svůj preferovaný odkaz pro vložení, například video YouTube, design Figma atd.",
|
||||
"link": "Zadejte nebo vložte odkaz"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Vložit",
|
||||
"works_with_links": "Funguje s YouTube, Figma, Google Docs a dalšími"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Zadejte prosím platnou URL adresu."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Klicken Sie, um Anhang hochzuladen"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "In Einbettung umwandeln",
|
||||
"convert_to_link": "In Link umwandeln",
|
||||
"convert_to_richcard": "In Rich Card umwandeln"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Fügen Sie hier Ihren bevorzugten Einbettungslink ein, z.B. YouTube-Video, Figma-Design usw.",
|
||||
"link": "Link eingeben oder einfügen"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Einbetten",
|
||||
"works_with_links": "Funktioniert mit YouTube, Figma, Google Docs und mehr"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Bitte geben Sie eine gültige URL ein."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Click to upload attachment"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Convert to Embed",
|
||||
"convert_to_link": "Convert to Link",
|
||||
"convert_to_richcard": "Convert to Rich Card"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Insert your preferred embed link here, such as YouTube video, Figma design, etc.",
|
||||
"link": "Enter or paste a link"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Embed",
|
||||
"works_with_links": "Works with YouTube, Figma, Google Docs and more"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Please enter a valid URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Haga clic para subir archivo adjunto"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Convertir a incrustación",
|
||||
"convert_to_link": "Convertir a enlace",
|
||||
"convert_to_richcard": "Convertir a tarjeta enriquecida"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Inserte aquí su enlace de incrustación preferido, como video de YouTube, diseño de Figma, etc.",
|
||||
"link": "Ingrese o pegue un enlace"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Incrustar",
|
||||
"works_with_links": "Funciona con YouTube, Figma, Google Docs y más"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Por favor, ingrese una URL válida."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Cliquez pour télécharger la pièce jointe"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Convertir en intégration",
|
||||
"convert_to_link": "Convertir en lien",
|
||||
"convert_to_richcard": "Convertir en carte enrichie"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Insérez votre lien d'intégration préféré ici, comme une vidéo YouTube, un design Figma, etc.",
|
||||
"link": "Entrez ou collez un lien"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Intégrer",
|
||||
"works_with_links": "Fonctionne avec YouTube, Figma, Google Docs et plus encore"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Veuillez entrer une URL valide."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Klik untuk mengunggah lampiran"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Ubah menjadi konten tertanam",
|
||||
"convert_to_link": "Ubah menjadi tautan",
|
||||
"convert_to_richcard": "Ubah menjadi kartu kaya"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Masukkan tautan tertanam pilihan Anda di sini, seperti video YouTube, desain Figma, dll.",
|
||||
"link": "Masukkan atau tempel tautan"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Tanam",
|
||||
"works_with_links": "Bekerja dengan YouTube, Figma, Google Docs dan lainnya"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Silakan masukkan URL yang valid."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Clicca per caricare allegato"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Converti in incorporamento",
|
||||
"convert_to_link": "Converti in link",
|
||||
"convert_to_richcard": "Converti in scheda ricca"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Inserisci qui il tuo link di incorporamento preferito, come video YouTube, design Figma, ecc.",
|
||||
"link": "Inserisci o incolla un link"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Incorpora",
|
||||
"works_with_links": "Funziona con YouTube, Figma, Google Docs e altro"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Inserisci un URL valido."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "クリックして添付ファイルをアップロード"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "埋め込みに変換",
|
||||
"convert_to_link": "リンクに変換",
|
||||
"convert_to_richcard": "リッチカードに変換"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "YouTubeビデオ、Figmaデザインなど、お好みの埋め込みリンクをここに挿入してください",
|
||||
"link": "リンクを入力または貼り付け"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "埋め込み",
|
||||
"works_with_links": "YouTube、Figma、Google Docsなどで動作します"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "有効なURLを入力してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "첨부 파일을 업로드하려면 클릭하세요"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "임베드로 변환",
|
||||
"convert_to_link": "링크로 변환",
|
||||
"convert_to_richcard": "리치 카드로 변환"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "YouTube 동영상, Figma 디자인 등 원하는 임베드 링크를 여기에 삽입하세요",
|
||||
"link": "링크 입력 또는 붙여넣기"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "임베드",
|
||||
"works_with_links": "YouTube, Figma, Google Docs 등과 함께 작동"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "유효한 URL을 입력해 주세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Kliknij, aby przesłać załącznik"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Konwertuj na osadzenie",
|
||||
"convert_to_link": "Konwertuj na link",
|
||||
"convert_to_richcard": "Konwertuj na bogatą kartę"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Wstaw tutaj preferowany link do osadzenia, np. film YouTube, projekt Figma itp.",
|
||||
"link": "Wprowadź lub wklej link"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Osadź",
|
||||
"works_with_links": "Działa z YouTube, Figma, Google Docs i innymi"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Wprowadź prawidłowy adres URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Clique para fazer upload do anexo"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Converter para incorporação",
|
||||
"convert_to_link": "Converter para link",
|
||||
"convert_to_richcard": "Converter para cartão rico"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Insira aqui seu link de incorporação preferido, como vídeo do YouTube, design do Figma, etc.",
|
||||
"link": "Digite ou cole um link"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Incorporar",
|
||||
"works_with_links": "Funciona com YouTube, Figma, Google Docs e mais"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Por favor, insira uma URL válida."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Faceți clic pentru a încărca atașamentul"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Convertește în încorporare",
|
||||
"convert_to_link": "Convertește în link",
|
||||
"convert_to_richcard": "Convertește în card bogat"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Introduceți aici linkul preferat pentru încorporare, cum ar fi video YouTube, design Figma etc.",
|
||||
"link": "Introduceți sau lipiți un link"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Încorporează",
|
||||
"works_with_links": "Funcționează cu YouTube, Figma, Google Docs și altele"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Vă rugăm să introduceți o adresă URL validă."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Нажмите для загрузки вложения"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Преобразовать во встраиваемый контент",
|
||||
"convert_to_link": "Преобразовать в ссылку",
|
||||
"convert_to_richcard": "Преобразовать в rich-карточку"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Вставьте здесь предпочитаемую ссылку для встраивания, например, видео YouTube, дизайн Figma и т.д.",
|
||||
"link": "Введите или вставьте ссылку"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Встроить",
|
||||
"works_with_links": "Работает с YouTube, Figma, Google Docs и другими"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Пожалуйста, введите действительный URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Kliknite pre nahranie prílohy"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Konvertovať na vložený obsah",
|
||||
"convert_to_link": "Konvertovať na odkaz",
|
||||
"convert_to_richcard": "Konvertovať na bohatú kartu"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Vložte sem svoj preferovaný odkaz na vloženie, napríklad video YouTube, dizajn Figma atď.",
|
||||
"link": "Zadajte alebo vložte odkaz"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Vložiť",
|
||||
"works_with_links": "Funguje s YouTube, Figma, Google Docs a ďalšími"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Prosím, zadajte platnú URL adresu."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Ek yüklemek için tıklayın"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Gömülü içeriğe dönüştür",
|
||||
"convert_to_link": "Bağlantıya dönüştür",
|
||||
"convert_to_richcard": "Zengin karta dönüştür"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "YouTube videosu, Figma tasarımı vb. tercih ettiğiniz gömme bağlantısını buraya ekleyin",
|
||||
"link": "Bir bağlantı girin veya yapıştırın"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Göm",
|
||||
"works_with_links": "YouTube, Figma, Google Docs ve daha fazlasıyla çalışır"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Lütfen geçerli bir URL girin."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Натисніть для завантаження вкладення"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Перетворити на вбудований вміст",
|
||||
"convert_to_link": "Перетворити на посилання",
|
||||
"convert_to_richcard": "Перетворити на rich-картку"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Вставте тут своє бажане посилання для вбудовування, наприклад, відео YouTube, дизайн Figma тощо",
|
||||
"link": "Введіть або вставте посилання"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Вбудувати",
|
||||
"works_with_links": "Працює з YouTube, Figma, Google Docs та іншими"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Будь ласка, введіть дійсну URL-адресу."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "Nhấp để tải lên tệp đính kèm"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "Chuyển thành nội dung nhúng",
|
||||
"convert_to_link": "Chuyển thành liên kết",
|
||||
"convert_to_richcard": "Chuyển thành thẻ phong phú"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "Chèn liên kết nhúng ưa thích của bạn vào đây, như video YouTube, thiết kế Figma, v.v.",
|
||||
"link": "Nhập hoặc dán một liên kết"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "Nhúng",
|
||||
"works_with_links": "Hoạt động với YouTube, Figma, Google Docs và nhiều hơn nữa"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "Vui lòng nhập một URL hợp lệ."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "点击上传附件"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "转换为嵌入内容",
|
||||
"convert_to_link": "转换为链接",
|
||||
"convert_to_richcard": "转换为富卡片"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "在此插入您喜欢的嵌入链接,如 YouTube 视频、Figma 设计等",
|
||||
"link": "输入或粘贴链接"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "嵌入",
|
||||
"works_with_links": "适用于 YouTube、Figma、Google Docs 等"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "请输入有效的 URL。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,23 @@
|
||||
"aria": {
|
||||
"click_to_upload": "點擊上傳附件"
|
||||
}
|
||||
},
|
||||
"externalEmbedComponent": {
|
||||
"block_menu": {
|
||||
"convert_to_embed": "轉換為嵌入內容",
|
||||
"convert_to_link": "轉換為連結",
|
||||
"convert_to_richcard": "轉換為豐富卡片"
|
||||
},
|
||||
"placeholder": {
|
||||
"insert_embed": "在此插入您喜歡的嵌入連結,如 YouTube 影片、Figma 設計等",
|
||||
"link": "輸入或貼上連結"
|
||||
},
|
||||
"input_modal": {
|
||||
"embed": "嵌入",
|
||||
"works_with_links": "適用於 YouTube、Figma、Google Docs 等"
|
||||
},
|
||||
"error": {
|
||||
"not_valid_link": "請輸入有效的 URL。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
packages/types/src/editor/iframely.ts
Normal file
40
packages/types/src/editor/iframely.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface IframelyResponse {
|
||||
error?: string;
|
||||
code?: string;
|
||||
html?: string;
|
||||
meta?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
medium?: string;
|
||||
keywords?: string;
|
||||
canonical?: string;
|
||||
site: string;
|
||||
};
|
||||
links?: {
|
||||
thumbnail?: Array<{
|
||||
href: string;
|
||||
type: string;
|
||||
rel: string[];
|
||||
media?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}>;
|
||||
icon?: Array<{
|
||||
href: string;
|
||||
rel: string[];
|
||||
type: string;
|
||||
media?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
rel?: string[];
|
||||
}
|
||||
|
||||
export interface IframelyError {
|
||||
message: string;
|
||||
code: number;
|
||||
source: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user