[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:
Vipin Chaudhary
2025-08-25 02:30:03 +05:30
committed by GitHub
parent a35a5f0bf7
commit 870ad552e3
122 changed files with 8041 additions and 94 deletions

View File

@@ -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

View File

@@ -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"],

View 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",
});
}
}
}

View File

@@ -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],

View 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();

View File

@@ -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

View File

@@ -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

View File

@@ -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" }}

View File

@@ -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;
});

View File

@@ -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: "",

View 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>
));

View File

@@ -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,

View File

@@ -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"])
);
};

View File

@@ -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}

View File

@@ -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)}
/>

View File

@@ -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

View File

@@ -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]);
};

View File

@@ -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";

View File

@@ -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

View File

@@ -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";

View File

@@ -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]
);

View File

@@ -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,

View 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();

View File

@@ -12,7 +12,7 @@ const nextConfig = {
return [
{
source: "/(.*)?",
headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
headers: [{ key: "X-Frame-Options", value: "DENY" }],
},
];
},

View File

@@ -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

View File

@@ -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 });

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -1,3 +1,3 @@
export * from "./sidebar";
export * from "./logo";
export * from "./billing";
// export * from "./billing";

View File

@@ -1,5 +1,4 @@
export * from "./dropdown";
export * from "./favorites";
export * from "./workspace-menu";
export * from "./workspace-menu-item";
export * from "./workspace-menu-header";

View File

@@ -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]);
};

View File

@@ -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"
/>

View File

@@ -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]
);

View 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();

View File

@@ -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"])
);
};

View File

@@ -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",

View File

@@ -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}",
],
};

View File

@@ -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

View File

@@ -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",

View File

@@ -132,6 +132,8 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
}
isTouchDevice={!!isTouchDevice}
tabIndex={tabIndex}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
</>
);

View File

@@ -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}
/>
);

View File

@@ -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>
)}

View File

@@ -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,
});

View File

@@ -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),
}),
});

View File

@@ -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>
);
})}

View File

@@ -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),
];
};

View File

@@ -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>({

View File

@@ -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,
}),
];

View File

@@ -129,6 +129,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
// Initialize main document editor
const editor = useEditor({
embedHandler,
disabledExtensions,
id,
editable,

View File

@@ -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,
}),

View File

@@ -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;

View File

@@ -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;

View File

@@ -7,5 +7,6 @@ export type TExtensions =
| "enter-key"
| "image"
| "nested-pages"
| "external-embed"
| "attachments"
| "mathematics";

View File

@@ -16,7 +16,7 @@ type TCoreHookProps = Pick<
| "isTouchDevice"
| "onEditorFocus"
> &
Pick<IEditorPropsExtended, "extensionOptions" | "isSmoothCursorEnabled">;
Pick<IEditorPropsExtended, "embedHandler" | "extensionOptions" | "isSmoothCursorEnabled">;
export type TEditorHookProps = TCoreHookProps &
Pick<

View File

@@ -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 />

View File

@@ -5,4 +5,5 @@ export enum ADDITIONAL_EXTENSIONS {
MATHEMATICS = "mathematics",
INLINE_MATH = "inlineMath",
BLOCK_MATH = "blockMath",
EXTERNAL_EMBED = "externalEmbed",
}

View File

@@ -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;
};

View File

@@ -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,
];

View 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;
},
});

View File

@@ -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}
/>
</>
);
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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",
];

View File

@@ -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)];
},
});

View File

@@ -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"]} />
));
},
});

View 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;
},
},
});

View 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>;

View File

@@ -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,
};

View File

@@ -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;
};

View File

@@ -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}

View File

@@ -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";

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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;
};

View 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";

View File

@@ -1 +1,2 @@
export * from "./issue-embed";
export * from "./external-embed";

View File

@@ -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 = {

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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を入力してください。"
}
}
}

View File

@@ -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을 입력해 주세요."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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ă."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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-адресу."
}
}
}

View File

@@ -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ệ."
}
}
}

View File

@@ -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。"
}
}
}

View File

@@ -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。"
}
}
}

View 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