Compare commits

...

1 Commits

Author SHA1 Message Date
Aaryan Khandelwal
5888f68b7b feat: embed component 2024-11-25 18:43:42 +05:30
19 changed files with 240 additions and 23 deletions

View File

@@ -6,7 +6,7 @@ import { COLORS_LIST } from "@/constants/common";
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
import { ECalloutAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
@@ -45,7 +45,7 @@ export const CustomCalloutBlock: React.FC<Props> = (props) => {
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[EAttributeNames.BACKGROUND]: val,
[ECalloutAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}

View File

@@ -2,7 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
import { ECalloutAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
@@ -23,7 +23,7 @@ export const CustomCalloutExtensionConfig = Node.create({
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EAttributeNames).reduce((acc, value) => {
...Object.values(ECalloutAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
@@ -60,7 +60,7 @@ export const CustomCalloutExtensionConfig = Node.create({
parseHTML() {
return [
{
tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`,
tag: `div[${ECalloutAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.BLOCK_TYPE]}"]`,
},
];
},

View File

@@ -1,10 +1,12 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// block
import { CustomCalloutBlock } from "./block";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
// types
import { ECalloutAttributeNames } from "./types";
// utils
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
@@ -30,7 +32,7 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
],
attrs: {
...storedLogoValues,
"data-background": storedBackgroundValue,
[ECalloutAttributeNames.BACKGROUND]: storedBackgroundValue,
},
});
},

View File

@@ -1,3 +1,2 @@
export * from "./block";
export * from "./extension";
export * from "./read-only-extension";

View File

@@ -1,6 +1,6 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// block
import { CustomCalloutBlock } from "./block";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";

View File

@@ -1,4 +1,4 @@
export enum EAttributeNames {
export enum ECalloutAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
@@ -9,18 +9,18 @@ export enum EAttributeNames {
}
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
[EAttributeNames.ICON_NAME]: string | undefined;
[ECalloutAttributeNames.ICON_COLOR]: string | undefined;
[ECalloutAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
[EAttributeNames.EMOJI_URL]: string | undefined;
[ECalloutAttributeNames.EMOJI_UNICODE]: string | undefined;
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string;
[EAttributeNames.BLOCK_TYPE]: "callout-component";
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;

View File

@@ -4,7 +4,7 @@ import { sanitizeHTML } from "@plane/helpers";
import { TEmojiLogoProps } from "@plane/ui";
// types
import {
EAttributeNames,
ECalloutAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
@@ -20,7 +20,7 @@ export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-block-type": "callout-component",
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
type TStoredLogoValue = Pick<TCalloutBlockAttributes, ECalloutAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage

View File

@@ -19,6 +19,7 @@ import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
import { CustomEmbedExtensionConfig } from "./embed/extension-config";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -89,6 +90,7 @@ export const CoreEditorExtensionsWithoutProps = [
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
CustomEmbedExtensionConfig,
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];

View File

@@ -0,0 +1,81 @@
import React, { useCallback, useEffect, useState } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// helpers
import { cn } from "@/helpers/common";
// types
import { EEmbedAttributeNames, TEmbedBlockAttributes } from "./types";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TEmbedBlockAttributes;
};
updateAttributes: (attrs: Partial<TEmbedBlockAttributes>) => void;
};
export const CustomEmbedBlock: React.FC<Props> = (props) => {
const { editor, node } = props;
// states
const [isResizing, setIsResizing] = useState(false);
// derived values
const embedSource = node.attrs[EEmbedAttributeNames.SOURCE];
const embedWidth = node.attrs[EEmbedAttributeNames.WIDTH];
const handleResizeStart = () => {
setIsResizing(true);
};
const handleResize = useCallback((e: MouseEvent | TouchEvent) => {}, []);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
}, []);
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
window.addEventListener("touchmove", handleResize);
window.addEventListener("touchend", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
window.removeEventListener("touchmove", handleResize);
window.removeEventListener("touchend", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
return (
<NodeViewWrapper as="div" className="editor-embed-component group/embed-component relative my-2">
<iframe className="rounded-md" src={embedSource} width={embedWidth} />
{editor.isEditable && (
<>
<div
className={cn(
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
{
"opacity-100": isResizing,
"opacity-0 group-hover/embed-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 pointer-events-none group-hover/embed-component:opacity-100 group-hover/embed-component:pointer-events-auto":
!isResizing,
}
)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
</>
)}
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,57 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EEmbedAttributeNames } from "./types";
// utils
import { DEFAULT_EMBED_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
embedComponent: {
insertEmbed: () => ReturnType;
};
}
}
export const CustomEmbedExtensionConfig = Node.create({
name: "embedComponent",
group: "block",
atom: true,
inline: false,
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EEmbedAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_EMBED_BLOCK_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {},
},
};
},
parseHTML() {
return [
{
tag: `div[${EEmbedAttributeNames.BLOCK_TYPE}="${DEFAULT_EMBED_BLOCK_ATTRIBUTES[EEmbedAttributeNames.BLOCK_TYPE]}"]`,
},
];
},
// Render HTML for the embed node
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes), 0];
},
});

View File

@@ -0,0 +1,35 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// block
import { CustomEmbedBlock } from "./block";
// config
import { CustomEmbedExtensionConfig } from "./extension-config";
// types
import { EEmbedAttributeNames } from "./types";
export const CustomEmbedExtension = CustomEmbedExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertEmbed:
() =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs: {
[EEmbedAttributeNames.SOURCE]: "https://www.youtube.com/embed/z5rd-ZE2f3I?si=H9bkPwKyJtCXdRGW",
[EEmbedAttributeNames.WIDTH]: "100%",
},
}),
};
},
addKeyboardShortcuts() {
return {};
},
addNodeView() {
return ReactNodeViewRenderer(CustomEmbedBlock);
},
});

View File

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

View File

@@ -0,0 +1,11 @@
export enum EEmbedAttributeNames {
SOURCE = "src",
WIDTH = "width",
BLOCK_TYPE = "data-block-type",
}
export type TEmbedBlockAttributes = {
[EEmbedAttributeNames.SOURCE]: string | undefined;
[EEmbedAttributeNames.WIDTH]: string | number | undefined;
[EEmbedAttributeNames.BLOCK_TYPE]: "embed-component";
};

View File

@@ -0,0 +1,8 @@
// types
import { TEmbedBlockAttributes } from "./types";
export const DEFAULT_EMBED_BLOCK_ATTRIBUTES: TEmbedBlockAttributes = {
src: undefined,
width: undefined,
"data-block-type": "embed-component",
};

View File

@@ -13,6 +13,7 @@ import {
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
CustomColorExtension,
CustomEmbedExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
@@ -162,5 +163,6 @@ export const CoreEditorExtensions = (args: TArguments) => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
CustomEmbedExtension,
];
};

View File

@@ -4,6 +4,7 @@ export * from "./code-inline";
export * from "./custom-image";
export * from "./custom-link";
export * from "./custom-list-keymap";
export * from "./embed";
export * from "./image";
export * from "./issue-embed";
export * from "./mentions";

View File

@@ -14,6 +14,7 @@ import {
ListTodo,
MessageSquareText,
MinusSquare,
ScreenShare,
Table,
TextQuote,
} from "lucide-react";
@@ -37,6 +38,7 @@ import {
insertImage,
insertCallout,
setText,
insertEmbed,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
@@ -189,6 +191,15 @@ export const getSlashCommandFilteredSections =
searchTerms: ["callout", "comment", "message", "info", "alert"],
command: ({ editor, range }: CommandProps) => insertCallout(editor, range),
},
{
commandKey: "embed",
key: "embed",
title: "Embed",
icon: <ScreenShare className="size-3.5" />,
description: "Insert embed",
searchTerms: ["embed", "hyperlink", "video", "pdf"],
command: ({ editor, range }) => insertEmbed(editor, range),
},
{
commandKey: "divider",
key: "divider",

View File

@@ -189,7 +189,13 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
else editor.chain().focus().setHorizontalRule().run();
};
export const insertCallout = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();
};
export const insertEmbed = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertEmbed().run();
else editor.chain().focus().insertEmbed().run();
};

View File

@@ -39,7 +39,8 @@ export type TEditorCommands =
| "text-color"
| "background-color"
| "text-align"
| "callout";
| "callout"
| "embed";
export type TCommandExtraProps = {
image: {
@@ -121,7 +122,7 @@ export interface IEditorProps {
onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: any[];