feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
node_modules
build/*
dist/*
out/*

View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
};

View File

@@ -0,0 +1,6 @@
.next
.vercel
.tubro
out/
dist/
build/

View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

82
packages/editor/Readme.md Normal file
View File

@@ -0,0 +1,82 @@
# @plane/editor
## Description
The `@plane/editor` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors.
## Utilities
We provide a wide range of utilities for extending the core itself.
1. Merging classes and custom styling
2. Adding new extensions
3. Adding custom props
4. Base menu items, and their commands
This allows for extensive customization and flexibility in the Editors created using our `editor-core` package.
### Here's a detailed overview of what's exported
1. useEditor - A hook that you can use to extend the Plane editor.
| Prop | Type | Description |
| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
| Prop | Type | Description |
| -------------- | ------------- | ------------------------------------------------------------------------------------------ |
| `value` | `string` | The initial content of the editor. |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
4. UI Wrappers
- `EditorContainer` - Wrap your Editor Container with this to apply base classes and styles.
- `EditorContentWrapper` - Use this to get Editor's Content and base menus.
5. Extending with Custom Styles
```ts
const customEditorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
```
## Core features
- **Content Trimming**: The Editors content is now automatically trimmed of empty line breaks from the start and end before submitting it to the backend. This ensures cleaner, more consistent data.
- **Value Cleaning**: The Editors value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors.
- **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package.
## Base extensions included
- BulletList
- OrderedList
- Blockquote
- Code
- Gapcursor
- Link
- Image
- Basic Marks
- Underline
- TextStyle
- Color
- TaskList
- Markdown
- Table

View File

@@ -0,0 +1,100 @@
{
"name": "@plane/editor",
"version": "1.1.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.cts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./lib": {
"import": "./dist/lib.js",
"require": "./dist/lib.cjs"
},
"./package.json": "./package.json",
"./styles": "./dist/styles/index.css"
},
"scripts": {
"build": "tsc && tsdown",
"dev": "tsdown --watch",
"check:lint": "eslint . --max-warnings 30",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"peerDependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"dependencies": {
"@floating-ui/dom": "^1.7.1",
"@floating-ui/react": "^0.26.4",
"@headlessui/react": "^1.7.3",
"@hocuspocus/provider": "2.15.2",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@tiptap/core": "catalog:",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/html": "catalog:",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@tiptap/suggestion": "^2.22.3",
"emoji-regex": "^10.3.0",
"highlight.js": "^11.8.0",
"is-emoji-supported": "^0.0.5",
"jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.3.2",
"lowlight": "^3.0.0",
"lucide-react": "catalog:",
"prosemirror-codemark": "^0.4.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"uuid": "catalog:",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"postcss": "^8.4.38",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"keywords": [
"editor",
"rich-text",
"markdown",
"nextjs",
"react"
]
}

View File

@@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,12 @@
import { type Editor } from "@tiptap/core";
import type { ReactElement } from "react";
import type { IEditorPropsExtended } from "@/types";
export type DocumentEditorSideEffectsProps = {
editor: Editor;
id: string;
updatePageProperties?: unknown;
extendedEditorProps?: IEditorPropsExtended;
};
export const DocumentEditorSideEffects = (_props: DocumentEditorSideEffectsProps): ReactElement | null => null;

View File

@@ -0,0 +1,14 @@
import { Editor } from "@tiptap/core";
import { LinkViewContainer } from "@/components/editors/link-view-container";
export const LinkContainer = ({
editor,
containerRef,
}: {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
}) => (
<>
<LinkViewContainer editor={editor} containerRef={containerRef} />
</>
);

View File

@@ -0,0 +1,6 @@
// helpers
import { TAssetMetaDataRecord } from "@/helpers/assets";
// local imports
import { ADDITIONAL_EXTENSIONS } from "./extensions";
export const ADDITIONAL_ASSETS_META_DATA_RECORD: Partial<Record<ADDITIONAL_EXTENSIONS, TAssetMetaDataRecord>> = {};

View File

@@ -0,0 +1 @@
export enum ADDITIONAL_EXTENSIONS {}

View File

@@ -0,0 +1,22 @@
// plane imports
import { ADDITIONAL_EXTENSIONS, CORE_EXTENSIONS } from "@plane/utils";
// plane editor imports
import type { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage";
export type NodeFileMapType = Partial<
Record<
CORE_EXTENSIONS | ADDITIONAL_EXTENSIONS,
{
fileSetName: ExtensionFileSetStorageKey;
}
>
>;
export const NODE_FILE_MAP: NodeFileMapType = {
[CORE_EXTENSIONS.IMAGE]: {
fileSetName: "deletedImageSet",
},
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
fileSetName: "deletedImageSet",
},
};

View File

@@ -0,0 +1,13 @@
import type { Extensions } from "@tiptap/core";
// types
import type { IEditorProps } from "@/types";
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "extendedEditorProps"
>;
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
const {} = props;
return [];
};

View File

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

View File

@@ -0,0 +1,3 @@
import { Extensions } from "@tiptap/core";
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];

View File

@@ -0,0 +1,37 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// types
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "extendedEditorProps"
> & {
isEditable: boolean;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};
export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
};
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({ disabledExtensions, flaggedExtensions }),
},
];
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
return documentExtensions;
};

View File

@@ -0,0 +1,3 @@
export * from "./core";
export * from "./document-extensions";
export * from "./slash-commands";

View File

@@ -0,0 +1,42 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { IEditorProps, TExtensions } from "@/types";
export type TRichTextEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "extendedEditorProps"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({
disabledExtensions,
flaggedExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};

View File

@@ -0,0 +1,12 @@
// extensions
import type { TSlashCommandAdditionalOption } from "@/extensions";
// types
import type { IEditorProps } from "@/types";
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
const options: TSlashCommandAdditionalOption[] = [];
return options;
};

View File

@@ -0,0 +1,19 @@
/**
* @description function to extract all additional assets from HTML content
* @param htmlContent
* @returns {string[]} array of additional asset sources
*/
export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => [];
/**
* @description function to replace additional assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced additional assets
*/
export const replaceAdditionalAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent } = props;
return htmlContent;
};

View File

@@ -0,0 +1 @@
export type TAdditionalEditorAsset = never;

View File

@@ -0,0 +1 @@
export type TExtendedFileHandler = object;

View File

@@ -0,0 +1,11 @@
export type IEditorExtensionOptions = unknown;
export type IEditorPropsExtended = unknown;
export type ICollaborativeDocumentEditorPropsExtended = unknown;
export type TExtendedEditorCommands = never;
export type TExtendedCommandExtraProps = unknown;
export type TExtendedEditorRefApi = unknown;

View File

@@ -0,0 +1,3 @@
export * from "./issue-embed";
export * from "./editor-extended";
export * from "./config";

View File

@@ -0,0 +1,17 @@
export type TEmbedConfig = {
issue?: TIssueEmbedConfig;
};
export type TReadOnlyEmbedConfig = TEmbedConfig;
export type TIssueEmbedConfig = {
widgetCallback: ({
issueId,
projectId,
workspaceSlug,
}: {
issueId: string;
projectId: string | undefined;
workspaceSlug: string | undefined;
}) => React.ReactNode;
};

View File

@@ -0,0 +1,4 @@
// extensions
import type { ImageExtensionStorage } from "@/extensions/image";
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;

View File

@@ -0,0 +1 @@
export type TAdditionalActiveDropbarExtensions = never;

View File

@@ -0,0 +1,116 @@
import React from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// constants
import { DocumentEditorSideEffects } from "@/plane-editor/components/document-editor-side-effects";
// types
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const {
aiHandler,
bubbleMenuEnabled = true,
containerClassName,
documentLoaderClassName,
extensions = [],
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
editorProps,
extendedEditorProps,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
dragDropEnabled = true,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
extendedDocumentEditorProps,
} = props;
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
editorProps,
extendedEditorProps,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
dragDropEnabled,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
extendedDocumentEditorProps,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<>
<DocumentEditorSideEffects editor={editor} id={id} extendedEditorProps={extendedEditorProps} />
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
editor={editor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
isTouchDevice={!!isTouchDevice}
isLoading={!hasServerSynced && !hasServerConnectionFailed}
tabIndex={tabIndex}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
/>
</>
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
);
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
export { CollaborativeDocumentEditorWithRef };

View File

@@ -0,0 +1,107 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject, useMemo } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { EditorRefApi, IDocumentEditorProps } from "@/types";
const DocumentEditor = (props: IDocumentEditorProps) => {
const {
bubbleMenuEnabled = false,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
extendedEditorProps,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
isTouchDevice,
handleEditorReady,
mentionHandler,
onChange,
user,
value,
} = props;
const extensions: Extensions = useMemo(() => {
const additionalExtensions: Extensions = [];
additionalExtensions.push(
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
HeadingListExtension,
...DocumentEditorAdditionalExtensions({
disabledExtensions,
extendedEditorProps,
flaggedExtensions,
isEditable: editable,
fileHandler,
userDetails: user ?? {
id: "",
name: "",
color: "",
},
})
);
return additionalExtensions;
}, []);
const editor = useEditor({
disabledExtensions,
editable,
editorClassName,
enableHistory: true,
extendedEditorProps,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
initialValue: value,
mentionHandler,
onChange,
});
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<PageRenderer
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
isTouchDevice={!!isTouchDevice}
/>
);
};
const DocumentEditorWithRef = forwardRef<EditorRefApi, IDocumentEditorProps>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref as MutableRefObject<EditorRefApi | null>} />
));
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditorWithRef };

View File

@@ -0,0 +1,4 @@
export * from "./collaborative-editor";
export * from "./editor";
export * from "./loader";
export * from "./page-renderer";

View File

@@ -0,0 +1,51 @@
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
className?: string;
};
export const DocumentContentLoader = (props: Props) => {
const { className } = props;
return (
<div className={cn("document-editor-loader", className)}>
<Loader className="relative space-y-4">
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
};

View File

@@ -0,0 +1,74 @@
import { Editor } from "@tiptap/react";
// plane imports
import { cn } from "@plane/utils";
// components
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import type { ICollaborativeDocumentEditorPropsExtended, IEditorProps, TAIHandler, TDisplayConfig } from "@/types";
type Props = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
documentLoaderClassName?: string;
editor: Editor;
editorContainerClassName: string;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
id: string;
isLoading?: boolean;
isTouchDevice: boolean;
tabIndex?: number;
flaggedExtensions: IEditorProps["flaggedExtensions"];
disabledExtensions: IEditorProps["disabledExtensions"];
};
export const PageRenderer = (props: Props) => {
const {
aiHandler,
bubbleMenuEnabled,
displayConfig,
documentLoaderClassName,
editor,
editorContainerClassName,
id,
isLoading,
isTouchDevice,
tabIndex,
flaggedExtensions,
disabledExtensions,
} = props;
return (
<div
className={cn("frame-renderer flex-grow w-full", {
"wide-layout": displayConfig.wideLayout,
})}
>
{isLoading ? (
<DocumentContentLoader className={documentLoaderClassName} />
) : (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
</EditorContainer>
)}
</div>
);
};

View File

@@ -0,0 +1,102 @@
import type { Editor } from "@tiptap/react";
import { FC, ReactNode, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { LinkContainer } from "@/plane-editor/components/link-container";
// types
import { TDisplayConfig } from "@/types";
type Props = {
children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor;
editorContainerClassName: string;
id: string;
isTouchDevice: boolean;
};
export const EditorContainer: FC<Props> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice } = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
if (!editor) return;
if (!editor.isEditable) return;
try {
if (editor.isFocused) return; // If editor is already focused, do nothing
const { selection } = editor.state;
const currentNode = selection.$from.node();
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
if (
currentNode.content.size === 0 && // Check if the current node is empty
!(
editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) ||
editor.isActive(CORE_EXTENSIONS.BULLET_LIST) ||
editor.isActive(CORE_EXTENSIONS.TASK_ITEM) ||
editor.isActive(CORE_EXTENSIONS.TABLE) ||
editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) ||
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
}
// Get the last child node in the document
const doc = editor.state.doc;
const lastNode = doc.lastChild;
// Check if its last node and add new node
if (lastNode) {
const isLastNodeParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH;
// Insert a new paragraph if the last node is not a paragraph and not a doc node
if (!isLastNodeParagraph && lastNode.type.name !== CORE_EXTENSIONS.DOCUMENT) {
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
}
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
}
};
const handleContainerMouseLeave = () => {
const dragHandleElement = document.querySelector("#editor-side-menu");
if (!dragHandleElement?.classList.contains("side-menu-hidden")) {
dragHandleElement?.classList.add("side-menu-hidden");
}
};
return (
<>
<div
ref={containerRef}
id={`editor-container-${id}`}
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
className={cn(
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>
{children}
{!isTouchDevice && <LinkContainer editor={editor} containerRef={containerRef} />}
</div>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { type Editor, EditorContent } from "@tiptap/react";
import { FC, ReactNode } from "react";
type Props = {
children?: ReactNode;
editor: Editor | null;
id: string;
tabIndex?: number;
};
export const EditorContentWrapper: FC<Props> = (props) => {
const { editor, children, tabIndex, id } = props;
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} id={id} />
{children}
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { Editor, Extensions } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// hooks
import { getEditorClassNames } from "@/helpers/common";
import { useEditor } from "@/hooks/use-editor";
// types
import { IEditorProps } from "@/types";
import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
editable: boolean;
extensions: Extensions;
};
export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
editorProps,
extendedEditorProps,
extensions,
id,
initialValue,
isTouchDevice,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
onEditorFocus,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
tabIndex,
value,
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editorClassName,
editorProps,
enableHistory: true,
extendedEditorProps,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
isTouchDevice,
initialValue,
mentionHandler,
onChange,
onEditorFocus,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
tabIndex,
value,
});
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={!!isTouchDevice}
>
{children?.(editor)}
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
</div>
</EditorContainer>
);
};

View File

@@ -0,0 +1,6 @@
export * from "./document";
export * from "./lite-text";
export * from "./rich-text";
export * from "./editor-container";
export * from "./editor-content";
export * from "./editor-wrapper";

View File

@@ -0,0 +1,201 @@
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
import { Editor, useEditorState } from "@tiptap/react";
import { FC, useCallback, useEffect, useRef, useState } from "react";
// components
import { LinkView, LinkViewProps } from "@/components/links";
type Props = {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
};
export const LinkViewContainer: FC<Props> = ({ editor, containerRef }) => {
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [virtualElement, setVirtualElement] = useState<Element | null>(null);
const hoverTimeoutRef = useRef<number | null>(null);
const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
linkExtensionStorage: editor.storage.link,
}),
});
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
elements: {
reference: virtualElement,
},
middleware: [
flip({
fallbackPlacements: ["top", "bottom"],
}),
shift({
padding: 5,
}),
hide(),
],
whileElementsMounted: autoUpdate,
placement: "bottom-start",
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
// Clear any existing timeout
const clearHoverTimeout = useCallback(() => {
if (hoverTimeoutRef.current) {
window.clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}, []);
// Set timeout to close link view after delay
const setCloseTimeout = useCallback(() => {
clearHoverTimeout();
hoverTimeoutRef.current = window.setTimeout(() => {
setIsOpen(false);
editorState.linkExtensionStorage.isPreviewOpen = false;
}, 400);
}, [clearHoverTimeout, editorState.linkExtensionStorage]);
const handleLinkHover = useCallback(
(event: MouseEvent) => {
if (!editor || editorState.linkExtensionStorage?.isBubbleMenuOpen) return;
// Find the closest anchor tag from the event target
const target = (event.target as HTMLElement)?.closest("a");
if (!target) return;
const referenceProps = getReferenceProps();
Object.entries(referenceProps).forEach(([key, value]) => {
target.setAttribute(key, value as string);
});
const view = editor.view;
if (!view) return;
try {
const pos = view.posAtDOM(target, 0);
if (pos === undefined || pos < 0) return;
const node = view.state.doc.nodeAt(pos);
if (!node) return;
const linkMark = node.marks?.find((mark) => mark.type.name === "link");
if (!linkMark) return;
setVirtualElement(target);
// Clear any pending close timeout when hovering over a link
clearHoverTimeout();
// Only update if not already open or if hovering over a different link
if (!isOpen || (linkViewProps && (linkViewProps.from !== pos || linkViewProps.to !== pos + node.nodeSize))) {
setLinkViewProps({
view: "LinkPreview", // Always start with preview for new links
url: linkMark.attrs.href,
text: node.text || "",
editor: editor,
from: pos,
to: pos + node.nodeSize,
closeLinkView: () => {
setIsOpen(false);
editorState.linkExtensionStorage.isPreviewOpen = false;
},
});
setIsOpen(true);
}
} catch (error) {
console.error("Error handling link hover:", error);
}
},
[editor, editorState.linkExtensionStorage, getReferenceProps, isOpen, linkViewProps, clearHoverTimeout]
);
// Handle mouse enter on floating element (cancel close timeout)
const handleFloatingMouseEnter = useCallback(() => {
clearHoverTimeout();
}, [clearHoverTimeout]);
// Handle mouse leave from floating element (start close timeout)
const handleFloatingMouseLeave = useCallback(() => {
setCloseTimeout();
}, [setCloseTimeout]);
const handleContainerMouseEnter = useCallback(() => {
// Cancel any pending close timeout when mouse enters container
clearHoverTimeout();
}, [clearHoverTimeout]);
const handleContainerMouseLeave = useCallback(
(event: MouseEvent) => {
if (!editor || !isOpen) return;
// Check if mouse is truly leaving the container area
const relatedTarget = event.relatedTarget as HTMLElement;
const container = containerRef.current;
const floatingElement = refs.floating;
// Only start close timeout if mouse is not moving to the floating element
// and is actually leaving the container
if (
container &&
relatedTarget &&
!container.contains(relatedTarget) &&
(!floatingElement || !floatingElement.current?.contains(relatedTarget))
) {
setCloseTimeout();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[editor, isOpen, setCloseTimeout, refs.floating]
);
// Set up event listeners
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("mouseover", handleLinkHover);
container.addEventListener("mouseenter", handleContainerMouseEnter);
container.addEventListener("mouseleave", handleContainerMouseLeave);
return () => {
container.removeEventListener("mouseover", handleLinkHover);
container.removeEventListener("mouseenter", handleContainerMouseEnter);
container.removeEventListener("mouseleave", handleContainerMouseLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleLinkHover, handleContainerMouseEnter, handleContainerMouseLeave]);
// Cleanup timeout on unmount
useEffect(() => () => clearHoverTimeout(), [clearHoverTimeout]);
// Close link view when bubble menu opens
useEffect(() => {
if (editorState.linkExtensionStorage?.isBubbleMenuOpen && isOpen) {
setIsOpen(false);
}
}, [editorState.linkExtensionStorage, isOpen]);
return (
<>
{isOpen && linkViewProps && virtualElement && (
<div
ref={refs.setFloating}
style={{ ...floatingStyles, zIndex: 100 }}
{...getFloatingProps()}
onMouseEnter={handleFloatingMouseEnter}
onMouseLeave={handleFloatingMouseLeave}
>
<LinkView {...linkViewProps} style={floatingStyles} />
</div>
)}
</>
);
};

View File

@@ -0,0 +1,31 @@
import { forwardRef, useMemo } from "react";
// components
import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
export { LiteTextEditorWithRef };

View File

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

View File

@@ -0,0 +1,59 @@
import { forwardRef, useCallback } from "react";
// components
import { EditorWrapper } from "@/components/editors";
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text-extensions";
// types
import { EditorRefApi, IRichTextEditorProps } from "@/types";
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
extendedEditorProps,
} = props;
const getExtensions = useCallback(() => {
const extensions = [
...externalExtensions,
SideMenuExtension({
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
}),
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
extendedEditorProps,
}),
];
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions, extendedEditorProps]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
{(editor) => (
<>
{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} flaggedExtensions={flaggedExtensions} disabledExtensions={disabledExtensions} />
</>
)}
</EditorWrapper>
);
};
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
export { RichTextEditorWithRef };

View File

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

View File

@@ -0,0 +1,3 @@
export * from "./link-edit-view";
export * from "./link-preview";
export * from "./link-view";

View File

@@ -0,0 +1,150 @@
import { Node } from "@tiptap/pm/model";
import { Link2Off } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
// components
import { LinkViewProps, LinkViews } from "@/components/links";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
type InputViewProps = {
label: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
autoFocus?: boolean;
};
const InputView = ({ label, value, placeholder, onChange, autoFocus }: InputViewProps) => (
<div className="flex flex-col gap-1">
<label className="inline-block font-semibold text-xs text-custom-text-400">{label}</label>
<input
placeholder={placeholder}
onClick={(e) => e.stopPropagation()}
className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm border border-custom-border-300 rounded-md p-2"
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus={autoFocus}
/>
</div>
);
type LinkEditViewProps = {
viewProps: LinkViewProps;
switchView: (view: LinkViews) => void;
};
export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
const { editor, from, to, url: initialUrl, text: initialText, closeLinkView } = viewProps;
// State
const [positionRef] = useState({ from, to });
const [localUrl, setLocalUrl] = useState(initialUrl);
const [localText, setLocalText] = useState(initialText ?? "");
const [linkRemoved, setLinkRemoved] = useState(false);
const hasSubmitted = useRef(false);
const removeLink = useCallback(() => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
setLinkRemoved(true);
closeLinkView();
}, [editor, from, to, closeLinkView]);
// Effects
useEffect(
() =>
// Cleanup effect: Remove link if not submitted and url is empty
() => {
if (!hasSubmitted.current && !linkRemoved && initialUrl === "") {
try {
removeLink();
} catch (e) {
console.error("Error removing link", e);
}
}
},
[removeLink, linkRemoved, initialUrl]
);
// Sync state with props
useEffect(() => {
setLocalUrl(initialUrl);
}, [initialUrl]);
useEffect(() => {
if (initialText) setLocalText(initialText);
}, [initialText]);
// Handlers
const handleTextChange = useCallback((value: string) => {
if (value.trim() !== "") setLocalText(value);
}, []);
const applyChanges = useCallback((): boolean => {
if (linkRemoved) return false;
hasSubmitted.current = true;
const { url, isValid } = isValidHttpUrl(localUrl);
if (to >= editor.state.doc.content.size || !isValid) return false;
// Apply URL change
const tr = editor.state.tr;
tr.removeMark(from, to, editor.schema.marks.link).addMark(from, to, editor.schema.marks.link.create({ href: url }));
editor.view.dispatch(tr);
// Apply text change if different
if (localText !== initialText) {
const node = editor.view.state.doc.nodeAt(from) as Node;
if (!node || !node.marks) return false;
editor
.chain()
.setTextSelection(from)
.deleteRange({ from: positionRef.from, to: positionRef.to })
.insertContent(localText)
.setTextSelection({ from, to: from + localText.length })
.run();
//
// Restore marks
node.marks.forEach((mark) => {
editor.chain().setMark(mark.type.name, mark.attrs).run();
});
}
return true;
}, [linkRemoved, positionRef, editor, from, to, initialText, localText, localUrl]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.stopPropagation();
if (applyChanges()) {
closeLinkView();
setLocalUrl("");
setLocalText("");
}
}
},
[applyChanges, closeLinkView]
);
return (
<div
onKeyDown={handleKeyDown}
className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2 animate-in fade-in translate-y-1"
style={{
transition: "all 0.1s cubic-bezier(.55, .085, .68, .53)",
}}
tabIndex={0}
>
<InputView label="URL" placeholder="Enter or paste URL" value={localUrl} onChange={setLocalUrl} autoFocus />
<InputView label="Text" placeholder="Enter Text to display" value={localText} onChange={handleTextChange} />
<div className="mb-1 bg-custom-border-300 h-[1px] w-full gap-2" />
<div className="flex text-sm text-custom-text-800 gap-2 items-center">
<Link2Off size={14} className="inline-block" />
<button onClick={removeLink} className="cursor-pointer hover:text-custom-text-400 transition-colors">
Remove Link
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react";
// components
import { LinkViewProps, LinkViews } from "@/components/links";
export const LinkPreview = ({
viewProps,
switchView,
}: {
viewProps: LinkViewProps;
switchView: (view: LinkViews) => void;
}) => {
const { editor, from, to, url } = viewProps;
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
viewProps.closeLinkView();
};
const copyLinkToClipboard = () => {
navigator.clipboard.writeText(url);
viewProps.closeLinkView();
};
return (
<div
className="absolute left-0 top-0 max-w-max animate-in fade-in translate-y-1"
style={{
transition: "all 0.2s cubic-bezier(.55, .085, .68, .53)",
}}
>
<div className="shadow-md items-center rounded p-2 flex gap-3 bg-custom-background-90 border-custom-border-100 border-2 text-custom-text-300 text-xs">
<GlobeIcon size={14} className="inline-block" />
<p>{url?.length > 40 ? url.slice(0, 40) + "..." : url}</p>
<div className="flex gap-2">
<button onClick={copyLinkToClipboard} className="cursor-pointer hover:text-custom-text-100 transition-colors">
<Copy size={14} className="inline-block" />
</button>
{editor.isEditable && (
<>
<button
onClick={() => switchView("LinkEditView")}
className="cursor-pointer hover:text-custom-text-100 transition-colors"
>
<PencilIcon size={14} className="inline-block" />
</button>
<button onClick={removeLink} className="cursor-pointer hover:text-custom-text-100 transition-colors">
<Link2Off size={14} className="inline-block" />
</button>
</>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { Editor } from "@tiptap/react";
import { CSSProperties, useEffect, useState } from "react";
// components
import { LinkEditView, LinkPreview } from "@/components/links";
export type LinkViews = "LinkPreview" | "LinkEditView";
export type LinkViewProps = {
view?: LinkViews;
editor: Editor;
from: number;
to: number;
url: string;
text?: string;
closeLinkView: () => void;
};
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
const [currentView, setCurrentView] = useState<LinkViews>(props.view ?? "LinkPreview");
const [prevFrom, setPrevFrom] = useState(props.from);
const switchView = (view: LinkViews) => {
setCurrentView(view);
};
useEffect(() => {
if (props.from !== prevFrom) {
setCurrentView("LinkPreview");
setPrevFrom(props.from);
}
}, [prevFrom, props.from]);
return (
<>
{currentView === "LinkPreview" && <LinkPreview viewProps={props} switchView={switchView} />}
{currentView === "LinkEditView" && <LinkEditView viewProps={props} switchView={switchView} />}
</>
);
};

View File

@@ -0,0 +1,96 @@
import { useCallback, useEffect, useRef, useState } from "react";
import tippy, { type Instance } from "tippy.js";
// plane utils
import { cn } from "@plane/utils";
// types
import type { TAIHandler } from "@/types";
type Props = {
menu: TAIHandler["menu"];
};
export const AIFeaturesMenu: React.FC<Props> = (props) => {
const { menu } = props;
// states
const [isPopupVisible, setIsPopupVisible] = useState(false);
// refs
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(null);
useEffect(() => {
if (!menuRef.current) return;
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - Tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
appendTo: () => document.querySelector(".frame-renderer"),
trigger: "manual",
interactive: true,
arrow: false,
placement: "bottom-start",
animation: "shift-away",
hideOnClick: true,
onShown: () => menuRef.current?.focus(),
});
return () => {
popup.current?.destroy();
popup.current = null;
};
}, []);
const hidePopup = useCallback(() => {
popup.current?.hide();
setIsPopupVisible(false);
}, []);
useEffect(() => {
const handleClickAIHandle = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.matches("#ai-handle") || menuRef.current?.contains(e.target as Node)) {
e.preventDefault();
if (!isPopupVisible) {
popup.current?.setProps({
getReferenceClientRect: () => target.getBoundingClientRect(),
});
popup.current?.show();
setIsPopupVisible(true);
}
return;
}
hidePopup();
return;
};
document.addEventListener("click", handleClickAIHandle);
document.addEventListener("contextmenu", handleClickAIHandle);
document.addEventListener("keydown", hidePopup);
return () => {
document.removeEventListener("click", handleClickAIHandle);
document.removeEventListener("contextmenu", handleClickAIHandle);
document.removeEventListener("keydown", hidePopup);
};
}, [hidePopup, isPopupVisible]);
return (
<div
className={cn("opacity-0 pointer-events-none fixed inset-0 size-full z-10 transition-opacity", {
"opacity-100 pointer-events-auto": isPopupVisible,
})}
>
<div ref={menuRef} className="z-10">
{menu?.({
isOpen: isPopupVisible,
onClose: hidePopup,
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,242 @@
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useDismiss,
useInteractions,
FloatingPortal,
} from "@floating-ui/react";
import type { Editor } from "@tiptap/react";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions?: IEditorProps["disabledExtensions"];
editor: Editor;
flaggedExtensions?: IEditorProps["flaggedExtensions"];
};
export const BlockMenu = (props: Props) => {
const { editor } = props;
const [isOpen, setIsOpen] = useState(false);
const [isAnimatedIn, setIsAnimatedIn] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const virtualReferenceRef = useRef<{ getBoundingClientRect: () => DOMRect }>({
getBoundingClientRect: () => new DOMRect(),
});
// Set up Floating UI with virtual reference element
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset({ crossAxis: -10 }), flip(), shift()],
whileElementsMounted: autoUpdate,
placement: "left-start",
});
const dismiss = useDismiss(context);
const { getFloatingProps } = useInteractions([dismiss]);
const openBlockMenu = useCallback(() => {
if (!isOpen) {
setIsOpen(true);
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SIDE_MENU);
}
}, [editor, isOpen]);
const closeBlockMenu = useCallback(() => {
if (isOpen) {
setIsOpen(false);
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SIDE_MENU);
}
}, [editor, isOpen]);
// Handle click on drag handle
const handleClickDragHandle = useCallback(
(event: MouseEvent) => {
const target = event.target as HTMLElement;
const dragHandle = target.closest("#drag-handle");
if (dragHandle) {
event.preventDefault();
// Update virtual reference with current drag handle position
virtualReferenceRef.current = {
getBoundingClientRect: () => dragHandle.getBoundingClientRect(),
};
// Set the virtual reference as the reference element
refs.setReference(virtualReferenceRef.current);
// Show the menu
openBlockMenu();
return;
}
// If clicking outside and not on a menu item, hide the menu
if (menuRef.current && !menuRef.current.contains(target)) {
closeBlockMenu();
}
},
[refs, openBlockMenu, closeBlockMenu]
);
// Set up event listeners
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeBlockMenu();
}
};
const handleScroll = () => {
closeBlockMenu();
};
document.addEventListener("click", handleClickDragHandle);
document.addEventListener("contextmenu", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("click", handleClickDragHandle);
document.removeEventListener("contextmenu", handleClickDragHandle);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("scroll", handleScroll, true);
};
}, [editor.commands, handleClickDragHandle, closeBlockMenu]);
// Animation effect
useEffect(() => {
if (isOpen) {
setIsAnimatedIn(false);
// Add a small delay before starting the animation
const timeout = setTimeout(() => {
requestAnimationFrame(() => {
setIsAnimatedIn(true);
});
}, 50);
return () => clearTimeout(timeout);
} else {
setIsAnimatedIn(false);
}
}, [isOpen]);
const MENU_ITEMS: {
icon: LucideIcon;
key: string;
label: string;
onClick: (e: React.MouseEvent) => void;
isDisabled?: boolean;
}[] = [
{
icon: Trash2,
key: "delete",
label: "Delete",
onClick: (_e) => {
// Execute the delete action
editor.chain().deleteSelection().focus().run();
},
},
{
icon: Copy,
key: "duplicate",
label: "Duplicate",
isDisabled:
editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
onClick: (_e) => {
try {
const { state } = editor;
const { selection } = state;
const firstChild = selection.content().content.firstChild;
const docSize = state.doc.content.size;
if (!firstChild) {
throw new Error("No content selected or content is not duplicable.");
}
// Directly use selection.to as the insertion position
const insertPos = selection.to;
// Ensure the insertion position is within the document's bounds
if (insertPos < 0 || insertPos > docSize) {
throw new Error("The insertion position is invalid or outside the document.");
}
const contentToInsert = firstChild.toJSON();
// Insert the content at the calculated position
editor
.chain()
.insertContentAt(insertPos, contentToInsert, {
updateSelection: true,
})
.focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false })
.run();
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
}
},
},
];
if (!isOpen) {
return null;
}
return (
<FloatingPortal>
<div
ref={(node) => {
refs.setFloating(node);
menuRef.current = node;
}}
style={{
...floatingStyles,
animationFillMode: "forwards",
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
zIndex: 100,
}}
className={cn(
"max-h-60 min-w-[7rem] overflow-y-scroll rounded-lg border border-custom-border-200 bg-custom-background-100 p-1.5 shadow-custom-shadow-rg",
"transition-all duration-300 transform origin-top-right",
isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
)}
{...getFloatingProps()}
>
{MENU_ITEMS.map((item) => {
if (item.isDisabled) return null;
return (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-1.5 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-90"
onClick={(e) => {
item.onClick(e);
e.preventDefault();
e.stopPropagation();
closeBlockMenu();
}}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
</button>
);
})}
</div>
</FloatingPortal>
);
};

View File

@@ -0,0 +1,82 @@
import { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, LucideIcon } from "lucide-react";
// plane utils
import { cn } from "@plane/utils";
// components
import { TextAlignItem } from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
editorState: EditorStateType;
};
export const TextAlignmentSelector: React.FC<Props> = (props) => {
const { editor, editorState } = props;
const menuItem = TextAlignItem(editor);
const textAlignmentOptions: {
itemKey: TEditorCommands;
renderKey: string;
icon: LucideIcon;
command: () => void;
isActive: () => boolean;
}[] = [
{
itemKey: "text-align",
renderKey: "text-align-left",
icon: AlignLeft,
command: () =>
menuItem.command({
alignment: "left",
}),
isActive: () => editorState.left,
},
{
itemKey: "text-align",
renderKey: "text-align-center",
icon: AlignCenter,
command: () =>
menuItem.command({
alignment: "center",
}),
isActive: () => editorState.center,
},
{
itemKey: "text-align",
renderKey: "text-align-right",
icon: AlignRight,
command: () =>
menuItem.command({
alignment: "right",
}),
isActive: () => editorState.right,
},
];
if (editorState.code) return null;
return (
<div className="flex gap-0.5 px-2">
{textAlignmentOptions.map((item) => (
<button
key={item.renderKey}
type="button"
onClick={(e) => {
e.stopPropagation();
item.command();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon className="size-4" />
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,113 @@
import type { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { useMemo, type FC } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { COLORS_LIST } from "@/constants/common";
// local imports
import { FloatingMenuRoot } from "../floating-menu/root";
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
import { BackgroundColorItem, TextColorItem } from "../menu-items";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
editorState: EditorStateType;
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, editorState } = props;
// floating ui
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
const activeTextColor = useMemo(() => editorState.color, [editorState.color]);
const activeBackgroundColor = useMemo(() => editorState.backgroundColor, [editorState.backgroundColor]);
return (
<FloatingMenuRoot
classNames={{
buttonContainer: "h-full",
button:
"flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
}}
menuButton={
<>
<span>Color</span>
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
/>
</span>
</>
}
options={options}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
>
<section className="mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg">
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</div>
</section>
</FloatingMenuRoot>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./color-selector";
export * from "./node-selector";
export * from "./root";

View File

@@ -0,0 +1,121 @@
import type { Editor } from "@tiptap/core";
import { Check, Link, Trash2 } from "lucide-react";
import { FC, useCallback, useRef, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
import { FloatingMenuRoot } from "../floating-menu/root";
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
type Props = {
editor: Editor;
};
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
const { editor } = props;
// states
const [error, setError] = useState(false);
// floating ui
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
const { context } = options;
// refs
const inputRef = useRef<HTMLInputElement>(null);
const handleLinkSubmit = useCallback(() => {
const input = inputRef.current;
if (!input) return;
const url = input.value;
if (!url) return;
const { isValid, url: validatedUrl } = isValidHttpUrl(url);
if (isValid) {
setLinkEditor(editor, validatedUrl);
context.onOpenChange(false);
setError(false);
} else {
setError(true);
}
}, [editor, inputRef, context]);
return (
<FloatingMenuRoot
classNames={{
buttonContainer: "h-full",
button: cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded whitespace-nowrap transition-colors",
{
"bg-custom-background-80": context.open,
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
),
}}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
menuButton={
<>
Link
<Link className="shrink-0 size-3" />
</>
}
options={options}
>
<div className="w-60 mt-1 rounded-md bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border-[0.5px] border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r-[0.5px] border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
}}
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
e.stopPropagation();
context.onOpenChange(false);
}}
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
)}
</div>
{error && (
<p className="text-xs text-red-500 my-1 px-2 pointer-events-none animate-in fade-in slide-in-from-top-0">
Please enter a valid URL
</p>
)}
</div>
</FloatingMenuRoot>
);
};

View File

@@ -0,0 +1,104 @@
import { Editor } from "@tiptap/react";
import { Check, ChevronDown } from "lucide-react";
import { FC } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
import {
BulletListItem,
HeadingOneItem,
HeadingThreeItem,
HeadingTwoItem,
NumberedListItem,
QuoteItem,
CodeItem,
TodoListItem,
TextItem,
HeadingFourItem,
HeadingFiveItem,
HeadingSixItem,
EditorMenuItem,
} from "@/components/menus";
// types
import type { TEditorCommands } from "@/types";
// local imports
import { FloatingMenuRoot } from "../floating-menu/root";
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
type Props = {
editor: Editor;
};
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
const { editor } = props;
// floating ui
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
const { context } = options;
const items: EditorMenuItem<TEditorCommands>[] = [
TextItem(editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
HeadingFourItem(editor),
HeadingFiveItem(editor),
HeadingSixItem(editor),
BulletListItem(editor),
NumberedListItem(editor),
TodoListItem(editor),
QuoteItem(editor),
CodeItem(editor),
] as EditorMenuItem<TEditorCommands>[];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<FloatingMenuRoot
classNames={{
buttonContainer: "h-full",
button: cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded whitespace-nowrap transition-colors",
{
"bg-custom-background-80": context.open,
}
),
}}
menuButton={
<>
<span>{activeItem?.name}</span>
<ChevronDown className="shrink-0 size-3" />
</>
}
options={options}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
>
<section className="w-48 max-h-[90vh] mt-1 flex flex-col overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg">
{items.map((item) => (
<button
key={item.name}
type="button"
onClick={(e) => {
item.command();
context.onOpenChange(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
{
"bg-custom-background-80": activeItem.name === item.name,
}
)}
>
<div className="flex items-center space-x-2">
<item.icon className="size-3 flex-shrink-0" />
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</button>
))}
</section>
</FloatingMenuRoot>
);
};

View File

@@ -0,0 +1,223 @@
import { type Editor, isNodeSelection } from "@tiptap/core";
import { BubbleMenu, type BubbleMenuProps, useEditorState } from "@tiptap/react";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
import {
BackgroundColorItem,
BoldItem,
BubbleMenuColorSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
TextColorItem,
UnderLineItem,
} from "@/components/menus";
// constants
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { isCellSelection } from "@/extensions/table/table/utilities/helpers";
// types
import type { TEditorCommands } from "@/types";
// local imports
import { TextAlignmentSelector } from "./alignment-selector";
import { BubbleMenuLinkSelector } from "./link-selector";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export type EditorStateType = {
code: boolean;
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
left: boolean;
right: boolean;
center: boolean;
color:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
backgroundColor:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
};
type Props = {
editor: Editor;
};
export const EditorBubbleMenu: FC<Props> = (props) => {
const { editor } = props;
// states
const [isSelecting, setIsSelecting] = useState(false);
// refs
const menuRef = useRef<HTMLDivElement>(null);
const formattingItems = {
code: CodeItem(editor),
bold: BoldItem(editor),
italic: ItalicItem(editor),
underline: UnderLineItem(editor),
strikethrough: StrikeThroughItem(editor),
"text-align": TextAlignItem(editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
};
const editorState: EditorStateType = useEditorState({
editor,
selector: ({ editor }) => ({
code: formattingItems.code.isActive(),
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
strikethrough: formattingItems.strikethrough.isActive(),
left: formattingItems["text-align"].isActive({ alignment: "left" }),
right: formattingItems["text-align"].isActive({ alignment: "right" }),
center: formattingItems["text-align"].isActive({ alignment: "center" }),
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
}),
});
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
const bubbleMenuProps: EditorBubbleMenuProps = {
editor,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
if (
empty ||
!editor.isEditable ||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
isSelecting
) {
return false;
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
zIndex: 9,
onShow: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = true;
}
editor.commands.addActiveDropbarExtension("bubble-menu");
},
onHide: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = false;
}
setTimeout(() => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
onHidden: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = false;
}
setTimeout(() => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
},
};
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (menuRef.current?.contains(e.target as Node)) return;
function handleMouseMove() {
if (!editor.state.selection.empty) {
setIsSelecting(true);
document.removeEventListener("mousemove", handleMouseMove);
}
}
function handleMouseUp() {
setIsSelecting(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [editor]);
return (
<BubbleMenu {...bubbleMenuProps}>
{!isSelecting && (
<div
ref={menuRef}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg overflow-x-scroll horizontal-scrollbar scrollbar-xs"
>
<div className="px-2">
<BubbleMenuNodeSelector editor={editor} />
</div>
{!editorState.code && (
<div className="px-2">
<BubbleMenuLinkSelector editor={editor} />
</div>
)}
{!editorState.code && (
<div className="px-2">
<BubbleMenuColorSelector editor={editor} editorState={editorState} />
</div>
)}
<div className="flex gap-0.5 px-2">
{basicFormattingOptions.map((item) => (
<button
key={item.key}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
{
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
}
)}
>
<item.icon className="size-4" />
</button>
))}
</div>
<TextAlignmentSelector editor={editor} editorState={editorState} />
</div>
)}
</BubbleMenu>
);
};

View File

@@ -0,0 +1,65 @@
import {
FloatingOverlay,
FloatingPortal,
type UseInteractionsReturn,
type UseFloatingReturn,
} from "@floating-ui/react";
type Props = {
children: React.ReactNode;
classNames?: {
buttonContainer?: string;
button?: string;
};
getFloatingProps: UseInteractionsReturn["getFloatingProps"];
getReferenceProps: UseInteractionsReturn["getReferenceProps"];
menuButton: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
options: UseFloatingReturn;
};
export const FloatingMenuRoot: React.FC<Props> = (props) => {
const { children, classNames, getFloatingProps, getReferenceProps, menuButton, onClick, options } = props;
// derived values
const { refs, floatingStyles, context } = options;
return (
<>
<div className={classNames?.buttonContainer}>
<button
ref={refs.setReference}
{...getReferenceProps()}
type="button"
className={classNames?.button}
onClick={(e) => {
context.onOpenChange(!context.open);
onClick?.(e);
}}
>
{menuButton}
</button>
</div>
{context.open && (
<FloatingPortal>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<div
ref={refs.setFloating}
{...getFloatingProps()}
style={{
...floatingStyles,
zIndex: 100,
}}
>
{children}
</div>
</FloatingPortal>
)}
</>
);
};

View File

@@ -0,0 +1,58 @@
import {
shift,
flip,
useDismiss,
useFloating,
useInteractions,
autoUpdate,
useClick,
useRole,
type UseInteractionsReturn,
type UseFloatingReturn,
} from "@floating-ui/react";
import { useState } from "react";
type TArgs = {
handleOpenChange?: (open: boolean) => void;
};
type TReturn = {
options: UseFloatingReturn;
getReferenceProps: UseInteractionsReturn["getReferenceProps"];
getFloatingProps: UseInteractionsReturn["getFloatingProps"];
};
export const useFloatingMenu = (args: TArgs): TReturn => {
const { handleOpenChange } = args;
// states
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// floating ui
const options = useFloating({
placement: "bottom-start",
middleware: [
flip({
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
}),
shift({
padding: 8,
}),
],
open: isDropdownOpen,
onOpenChange: (open) => {
setIsDropdownOpen(open);
handleOpenChange?.(open);
},
whileElementsMounted: autoUpdate,
});
const { context } = options;
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
return {
options,
getReferenceProps,
getFloatingProps,
};
};

View File

@@ -0,0 +1,4 @@
export * from "./ai-menu";
export * from "./bubble-menu";
export * from "./block-menu";
export * from "./menu-items";

View File

@@ -0,0 +1,278 @@
import type { Editor } from "@tiptap/react";
import {
BoldIcon,
Heading1,
CheckSquare,
Heading2,
Heading3,
TextQuote,
ImageIcon,
TableIcon,
ListIcon,
ListOrderedIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
Heading4,
Heading5,
Heading6,
CaseSensitive,
type LucideIcon,
MinusSquare,
Palette,
AlignCenter,
LinkIcon,
} from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import {
insertHorizontalRule,
insertImage,
insertTableCommand,
setLinkEditor,
setText,
setTextAlign,
toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleHeading,
toggleItalic,
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleTextColor,
toggleUnderline,
unsetLinkEditor,
} from "@/helpers/editor-commands";
// types
import { TCommandWithProps, TEditorCommands } from "@/types";
type isActiveFunction<T extends TEditorCommands> = (params?: TCommandWithProps<T>) => boolean;
type commandFunction<T extends TEditorCommands> = (params?: TCommandWithProps<T>) => void;
export type EditorMenuItem<T extends TEditorCommands> = {
key: T;
name: string;
command: commandFunction<T>;
icon: LucideIcon;
isActive: isActiveFunction<T>;
};
export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
key: "text",
name: "Text",
isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH),
command: () => setText(editor),
icon: CaseSensitive,
});
type SupportedHeadingLevels = Extract<TEditorCommands, "h1" | "h2" | "h3" | "h4" | "h5" | "h6">;
const HeadingItem = <T extends SupportedHeadingLevels>(
editor: Editor,
level: 1 | 2 | 3 | 4 | 5 | 6,
key: T,
name: string,
icon: LucideIcon
): EditorMenuItem<T> => ({
key,
name,
isActive: () => editor.isActive(CORE_EXTENSIONS.HEADING, { level }),
command: () => toggleHeading(editor, level),
icon,
});
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> =>
HeadingItem(editor, 1, "h1", "Heading 1", Heading1);
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> =>
HeadingItem(editor, 2, "h2", "Heading 2", Heading2);
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> =>
HeadingItem(editor, 3, "h3", "Heading 3", Heading3);
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> =>
HeadingItem(editor, 4, "h4", "Heading 4", Heading4);
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> =>
HeadingItem(editor, 5, "h5", "Heading 5", Heading5);
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> =>
HeadingItem(editor, 6, "h6", "Heading 6", Heading6);
export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
key: "bold",
name: "Bold",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD),
command: () => toggleBold(editor),
icon: BoldIcon,
});
export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
key: "italic",
name: "Italic",
isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
key: "underline",
name: "Underline",
isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
key: "strikethrough",
name: "Strikethrough",
isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
});
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE),
command: () => toggleBlockquote(editor),
icon: TextQuote,
});
export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
key: "code",
name: "Code",
isActive: () => editor?.isActive(CORE_EXTENSIONS.CODE_INLINE) || editor?.isActive(CORE_EXTENSIONS.CODE_BLOCK),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
key: "table",
name: "Table",
isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
key: "image",
name: "Image",
isActive: () => editor?.isActive(CORE_EXTENSIONS.IMAGE) || editor?.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
icon: ImageIcon,
});
export const HorizontalRuleItem = (editor: Editor): EditorMenuItem<"divider"> =>
({
key: "divider",
name: "Divider",
isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE),
command: () => insertHorizontalRule(editor),
icon: MinusSquare,
}) as const;
export const LinkItem = (editor: Editor): EditorMenuItem<"link"> =>
({
key: "link",
name: "Link",
isActive: () => editor?.isActive("link"),
command: (props) => {
if (!props) return;
if (props.url) setLinkEditor(editor, props.url, props.text);
else unsetLinkEditor(editor);
},
icon: LinkIcon,
}) as const;
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
key: "text-color",
name: "Color",
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }),
command: (props) => {
if (!props) return;
toggleTextColor(props.color, editor);
},
icon: Palette,
});
export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({
key: "background-color",
name: "Background color",
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { backgroundColor: props?.color }),
command: (props) => {
if (!props) return;
toggleBackgroundColor(props.color, editor);
},
icon: Palette,
});
export const TextAlignItem = (editor: Editor): EditorMenuItem<"text-align"> => ({
key: "text-align",
name: "Text align",
isActive: (props) => editor.isActive({ textAlign: props?.alignment }),
command: (props) => {
if (!props) return;
setTextAlign(props.alignment, editor);
},
icon: AlignCenter,
});
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEditorCommands>[] => {
if (!editor) return [];
return [
TextItem(editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
HeadingFourItem(editor),
HeadingFiveItem(editor),
HeadingSixItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
StrikeThroughItem(editor),
BulletListItem(editor),
TodoListItem(editor),
CodeItem(editor),
NumberedListItem(editor),
QuoteItem(editor),
TableItem(editor),
ImageItem(editor),
HorizontalRuleItem(editor),
LinkItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
TextAlignItem(editor),
] as EditorMenuItem<TEditorCommands>[];
};

View File

@@ -0,0 +1,243 @@
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
CaseSensitive,
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Image,
Italic,
List,
ListOrdered,
ListTodo,
LucideIcon,
Strikethrough,
Table,
TextQuote,
Underline,
} from "lucide-react";
import { TCommandExtraProps, TEditorCommands } from "@/types/editor";
export type TEditorTypes = "lite" | "document";
// Utility type to enforce the necessary extra props or make extraProps optional
export type ExtraPropsForCommand<T extends TEditorCommands> = T extends keyof TCommandExtraProps
? TCommandExtraProps[T]
: object; // Default to empty object for commands without extra props
export type ToolbarMenuItem<T extends TEditorCommands = TEditorCommands> = {
itemKey: T;
renderKey: string;
name: string;
icon: LucideIcon;
shortcut?: string[];
editors: TEditorTypes[];
extraProps?: ExtraPropsForCommand<T>;
};
export const TYPOGRAPHY_ITEMS: ToolbarMenuItem<"text" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6">[] = [
{ itemKey: "text", renderKey: "text", name: "Text", icon: CaseSensitive, editors: ["document"] },
{ itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: Heading1, editors: ["document"] },
{ itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: Heading2, editors: ["document"] },
{ itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: Heading3, editors: ["document"] },
{ itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: Heading4, editors: ["document"] },
{ itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: Heading5, editors: ["document"] },
{ itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: Heading6, editors: ["document"] },
];
export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
{
itemKey: "text-align",
renderKey: "text-align-left",
name: "Left align",
icon: AlignLeft,
shortcut: ["Cmd", "Shift", "L"],
editors: ["lite", "document"],
extraProps: {
alignment: "left",
},
},
{
itemKey: "text-align",
renderKey: "text-align-center",
name: "Center align",
icon: AlignCenter,
shortcut: ["Cmd", "Shift", "E"],
editors: ["lite", "document"],
extraProps: {
alignment: "center",
},
},
{
itemKey: "text-align",
renderKey: "text-align-right",
name: "Right align",
icon: AlignRight,
shortcut: ["Cmd", "Shift", "R"],
editors: ["lite", "document"],
extraProps: {
alignment: "right",
},
},
];
const BASIC_MARK_ITEMS: ToolbarMenuItem<"bold" | "italic" | "underline" | "strikethrough">[] = [
{
itemKey: "bold",
renderKey: "bold",
name: "Bold",
icon: Bold,
shortcut: ["Cmd", "B"],
editors: ["lite", "document"],
},
{
itemKey: "italic",
renderKey: "italic",
name: "Italic",
icon: Italic,
shortcut: ["Cmd", "I"],
editors: ["lite", "document"],
},
{
itemKey: "underline",
renderKey: "underline",
name: "Underline",
icon: Underline,
shortcut: ["Cmd", "U"],
editors: ["lite", "document"],
},
{
itemKey: "strikethrough",
renderKey: "strikethrough",
name: "Strikethrough",
icon: Strikethrough,
shortcut: ["Cmd", "Shift", "S"],
editors: ["lite", "document"],
},
];
const LIST_ITEMS: ToolbarMenuItem<"bulleted-list" | "numbered-list" | "to-do-list">[] = [
{
itemKey: "bulleted-list",
renderKey: "bulleted-list",
name: "Bulleted list",
icon: List,
shortcut: ["Cmd", "Shift", "7"],
editors: ["lite", "document"],
},
{
itemKey: "numbered-list",
renderKey: "numbered-list",
name: "Numbered list",
icon: ListOrdered,
shortcut: ["Cmd", "Shift", "8"],
editors: ["lite", "document"],
},
{
itemKey: "to-do-list",
renderKey: "to-do-list",
name: "To-do list",
icon: ListTodo,
shortcut: ["Cmd", "Shift", "9"],
editors: ["lite", "document"],
},
];
export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [
{ itemKey: "quote", renderKey: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] },
{ itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
];
export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [
{ itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] },
{ itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"] },
];
export const TOOLBAR_ITEMS: {
[editorType in TEditorTypes]: {
[key: string]: ToolbarMenuItem[];
};
} = {
lite: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")),
alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("lite")),
list: LIST_ITEMS.filter((item) => item.editors.includes("lite")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")),
},
document: {
basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")),
alignment: TEXT_ALIGNMENT_ITEMS.filter((item) => item.editors.includes("document")),
list: LIST_ITEMS.filter((item) => item.editors.includes("document")),
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
},
};
export const COLORS_LIST: {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}[] = [
{
key: "gray",
label: "Gray",
textColor: "var(--editor-colors-gray-text)",
backgroundColor: "var(--editor-colors-gray-background)",
},
{
key: "peach",
label: "Peach",
textColor: "var(--editor-colors-peach-text)",
backgroundColor: "var(--editor-colors-peach-background)",
},
{
key: "pink",
label: "Pink",
textColor: "var(--editor-colors-pink-text)",
backgroundColor: "var(--editor-colors-pink-background)",
},
{
key: "orange",
label: "Orange",
textColor: "var(--editor-colors-orange-text)",
backgroundColor: "var(--editor-colors-orange-background)",
},
{
key: "green",
label: "Green",
textColor: "var(--editor-colors-green-text)",
backgroundColor: "var(--editor-colors-green-background)",
},
{
key: "light-blue",
label: "Light blue",
textColor: "var(--editor-colors-light-blue-text)",
backgroundColor: "var(--editor-colors-light-blue-background)",
},
{
key: "dark-blue",
label: "Dark blue",
textColor: "var(--editor-colors-dark-blue-text)",
backgroundColor: "var(--editor-colors-dark-blue-background)",
},
{
key: "purple",
label: "Purple",
textColor: "var(--editor-colors-purple-text)",
backgroundColor: "var(--editor-colors-purple-background)",
},
// {
// key: "pink-blue-gradient",
// label: "Pink blue gradient",
// textColor: "var(--editor-colors-pink-blue-gradient-text)",
// backgroundColor: "var(--editor-colors-pink-blue-gradient-background)",
// },
];

View File

@@ -0,0 +1,62 @@
// types
import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
lineSpacing: "regular",
wideLayout: false,
};
export const ACCEPTED_IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
export const ACCEPTED_ATTACHMENT_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
"image/tiff",
"image/bmp",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"application/rtf",
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/midi",
"audio/x-midi",
"audio/aac",
"audio/flac",
"audio/x-m4a",
"video/mp4",
"video/mpeg",
"video/ogg",
"video/webm",
"video/quicktime",
"video/x-msvideo",
"video/x-ms-wmv",
"application/zip",
"application/x-rar-compressed",
"application/x-tar",
"application/gzip",
"model/gltf-binary",
"model/gltf+json",
"application/octet-stream",
"font/ttf",
"font/otf",
"font/woff",
"font/woff2",
"text/css",
"text/javascript",
"application/json",
"text/xml",
"text/csv",
"application/xml",
];

View File

@@ -0,0 +1,91 @@
import { EPageAccess } from "@plane/constants";
import { TPage } from "@plane/types";
import { CreatePayload, BaseActionPayload } from "@/types";
// Define all payload types for each event.
export type ArchivedPayload = CreatePayload<{ archived_at: string | null }>;
export type UnarchivedPayload = BaseActionPayload;
export type LockedPayload = CreatePayload<{ is_locked: boolean }>;
export type UnlockedPayload = BaseActionPayload;
export type MadePublicPayload = CreatePayload<{ access: EPageAccess }>;
export type MadePrivatePayload = CreatePayload<{ access: EPageAccess }>;
export type DeletedPayload = CreatePayload<{ deleted_at: Date | null }>;
export type DuplicatedPayload = CreatePayload<{ new_page_id: string }>;
export type PropertyUpdatedPayload = CreatePayload<Partial<TPage>>;
export type MovedPayload = CreatePayload<{
new_project_id: string;
new_page_id: string;
}>;
export type RestoredPayload = CreatePayload<{ deleted_page_ids?: string[] }>;
export type ErrorPayload = CreatePayload<{
error_message: string;
error_type: "fetch" | "store";
error_code?: "content_too_large" | "page_locked" | "page_archived";
should_disconnect?: boolean;
}>;
// Enhanced DocumentCollaborativeEvents with payload types.
// Both the client name and server name are defined, and we add a "payloadType" property
// so that we can later derive a mapping from client event to payload type.
export const DocumentCollaborativeEvents = {
lock: {
client: "locked",
server: "lock",
payloadType: {} as LockedPayload,
},
unlock: {
client: "unlocked",
server: "unlock",
payloadType: {} as UnlockedPayload,
},
archive: {
client: "archived",
server: "archive",
payloadType: {} as ArchivedPayload,
},
unarchive: {
client: "unarchived",
server: "unarchive",
payloadType: {} as UnarchivedPayload,
},
"make-public": {
client: "made-public",
server: "make-public",
payloadType: {} as MadePublicPayload,
},
"make-private": {
client: "made-private",
server: "make-private",
payloadType: {} as MadePrivatePayload,
},
delete: {
client: "deleted",
server: "delete",
payloadType: {} as DeletedPayload,
},
move: {
client: "moved",
server: "move",
payloadType: {} as MovedPayload,
},
duplicate: {
client: "duplicated",
server: "duplicate",
payloadType: {} as DuplicatedPayload,
},
property_update: {
client: "property_updated",
server: "property_update",
payloadType: {} as PropertyUpdatedPayload,
},
restore: {
client: "restored",
server: "restore",
payloadType: {} as RestoredPayload,
},
error: {
client: "error",
server: "error",
payloadType: {} as ErrorPayload,
},
} as const;

View File

@@ -0,0 +1,45 @@
export enum CORE_EXTENSIONS {
BLOCKQUOTE = "blockquote",
BOLD = "bold",
BULLET_LIST = "bulletList",
CALLOUT = "calloutComponent",
CHARACTER_COUNT = "characterCount",
CODE_BLOCK = "codeBlock",
CODE_INLINE = "code",
CUSTOM_COLOR = "customColor",
CUSTOM_IMAGE = "imageComponent",
CUSTOM_LINK = "link",
DOCUMENT = "doc",
DROP_CURSOR = "dropCursor",
ENTER_KEY = "enterKey",
GAP_CURSOR = "gapCursor",
HARD_BREAK = "hardBreak",
HEADING = "heading",
HEADINGS_LIST = "headingsList",
HISTORY = "history",
HORIZONTAL_RULE = "horizontalRule",
IMAGE = "image",
ITALIC = "italic",
LIST_ITEM = "listItem",
MARKDOWN_CLIPBOARD = "markdownClipboard",
MENTION = "mention",
ORDERED_LIST = "orderedList",
PARAGRAPH = "paragraph",
PLACEHOLDER = "placeholder",
SIDE_MENU = "editorSideMenu",
SLASH_COMMANDS = "slash-command",
STRIKETHROUGH = "strike",
TABLE = "table",
TABLE_CELL = "tableCell",
TABLE_HEADER = "tableHeader",
TABLE_ROW = "tableRow",
TASK_ITEM = "taskItem",
TASK_LIST = "taskList",
TEXT_ALIGN = "textAlign",
TEXT_STYLE = "textStyle",
TYPOGRAPHY = "typography",
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
EMOJI = "emoji",
}

View File

@@ -0,0 +1,5 @@
export enum CORE_EDITOR_META {
SKIP_FILE_DELETION = "skipFileDeletion",
INTENTIONAL_DELETION = "intentionalDeletion",
ADD_TO_HISTORY = "addToHistory",
}

View File

@@ -0,0 +1,56 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import React, { useState } from "react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { ECalloutAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
export type CustomCalloutNodeViewProps = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// derived values
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
return (
<NodeViewWrapper
className="editor-callout-component group/callout-node relative bg-custom-background-90 rounded-lg text-custom-text-100 p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
disabled={!editor.isEditable}
isOpen={isEmojiPickerOpen}
handleOpen={(val) => setIsEmojiPickerOpen(val)}
updateAttributes={updateAttributes}
/>
<CalloutBlockColorSelector
disabled={!editor.isEditable}
isOpen={isColorPickerOpen}
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[ECalloutAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}
/>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,75 @@
import { Ban, ChevronDown } from "lucide-react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { COLORS_LIST } from "@/constants/common";
type Props = {
disabled: boolean;
isOpen: boolean;
onSelect: (color: string | null) => void;
toggleDropdown: () => void;
};
export const CalloutBlockColorSelector: React.FC<Props> = (props) => {
const { disabled, isOpen, onSelect, toggleDropdown } = props;
const handleColorSelect = (val: string | null) => {
onSelect(val);
toggleDropdown();
};
return (
<div
className={cn("opacity-0 pointer-events-none absolute top-2 right-2 z-10 transition-opacity", {
"group-hover/callout-node:opacity-100 group-hover/callout-node:pointer-events-auto": !disabled,
"opacity-100 pointer-events-auto": isOpen,
})}
contentEditable={false}
>
<div className="relative">
<button
type="button"
onClick={(e) => {
toggleDropdown();
e.stopPropagation();
}}
className={cn(
"flex items-center gap-1 h-full whitespace-nowrap py-1 px-2.5 text-sm font-medium text-custom-text-300 hover:bg-white/10 active:bg-custom-background-80 rounded transition-colors",
{
"bg-white/10": isOpen,
}
)}
disabled={disabled}
>
<span>Color</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect(color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect(null)}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,77 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import { type CustomCalloutExtensionType, ECalloutAttributeNames, type TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CALLOUT]: {
insertCallout: () => ReturnType;
};
}
}
export const CustomCalloutExtensionConfig: CustomCalloutExtensionType = Node.create({
name: CORE_EXTENSIONS.CALLOUT,
group: "block",
content: "block+",
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(ECalloutAttributeNames).reduce(
(acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
},
{} as Record<ECalloutAttributeNames, { default: TCalloutBlockAttributes[ECalloutAttributeNames] }>
),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
const attrs = node.attrs as TCalloutBlockAttributes;
const logoInUse = attrs["data-logo-in-use"];
// add callout logo
if (logoInUse === "emoji") {
state.write(
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
);
} else {
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
}
// add an empty line after the logo
state.write("> \n");
// add '> ' before each line of the callout content
state.wrapBlock("> ", null, node, () => state.renderContent(node));
state.closeBlock(node);
},
},
};
},
parseHTML() {
return [
{
tag: `div[${ECalloutAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.BLOCK_TYPE]}"]`,
},
];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes), 0];
},
});

View File

@@ -0,0 +1,74 @@
import { findParentNodeClosestToPos, type Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// local imports
import { CustomCalloutBlock, type CustomCalloutNodeViewProps } from "./block";
import { CustomCalloutExtensionConfig } from "./extension-config";
import type { CustomCalloutExtensionOptions, CustomCalloutExtensionStorage } from "./types";
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend<
CustomCalloutExtensionOptions,
CustomCalloutExtensionStorage
>({
selectable: true,
draggable: true,
addCommands() {
return {
insertCallout:
() =>
({ commands }) => {
// get stored logo values and background color from the local storage
const storedLogoValues = getStoredLogo();
const storedBackgroundValue = getStoredBackgroundColor();
return commands.insertContent({
type: this.name,
content: [
{
type: CORE_EXTENSIONS.PARAGRAPH,
},
],
attrs: {
...storedLogoValues,
"data-background": storedBackgroundValue,
},
});
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
try {
const isParentNodeCallout: Predicate = (node) => node.type === this.type;
const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout);
// Check if selection is empty and at the beginning of the callout
if (empty && parentNodeDetails) {
const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1;
if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) {
editor.commands.setTextSelection(parentNodeDetails.pos - 1);
return true;
}
}
} catch (error) {
console.error("Error in performing backspace action on callout", error);
}
return false; // Allow the default behavior if conditions are not met
},
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
},
});

View File

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

View File

@@ -0,0 +1,94 @@
// plane imports
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
import { cn, convertHexEmojiToDecimal } from "@plane/utils";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils";
type Props = {
blockAttributes: TCalloutBlockAttributes;
disabled: boolean;
handleOpen: (val: boolean) => void;
isOpen: boolean;
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TEmojiLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {
color: blockAttributes["data-icon-color"],
name: blockAttributes["data-icon-name"],
},
emoji: {
value: blockAttributes["data-emoji-unicode"]?.toString(),
url: blockAttributes["data-emoji-url"],
},
};
return (
<div contentEditable={false}>
<EmojiIconPicker
closeOnSelect={false}
isOpen={isOpen}
handleToggle={handleOpen}
className="flex-shrink-0 grid place-items-center"
buttonClassName={cn("flex-shrink-0 size-8 grid place-items-center rounded-lg", {
"hover:bg-white/10": !disabled,
})}
label={<Logo logo={logoValue} size={18} type="lucide" />}
onChange={(val) => {
// construct the new logo value based on the type of value
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
},
};
if (val.type === "emoji") {
newLogoValue = {
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
"data-emoji-url": val.value.imageUrl,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
},
};
} else if (val.type === "icon") {
newLogoValue = {
"data-icon-name": val.value.name,
"data-icon-color": val.value.color,
};
newLogoValueToStoreInLocalStorage = {
in_use: "icon",
icon: {
name: val.value.name,
color: val.value.color,
},
};
}
// update node attributes
updateAttributes({
"data-logo-in-use": val.type,
...newLogoValue,
});
// update stored logo in local storage
updateStoredLogo(newLogoValueToStoreInLocalStorage);
handleOpen(false);
}}
defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined}
defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
disabled={disabled}
searchDisabled
/>
</div>
);
};

View File

@@ -0,0 +1,33 @@
import type { Node as ProseMirrorNode } from "@tiptap/core";
export enum ECalloutAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
EMOJI_URL = "data-emoji-url",
LOGO_IN_USE = "data-logo-in-use",
BACKGROUND = "data-background",
BLOCK_TYPE = "data-block-type",
}
export type TCalloutBlockIconAttributes = {
[ECalloutAttributeNames.ICON_COLOR]: string | undefined;
[ECalloutAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[ECalloutAttributeNames.EMOJI_UNICODE]: string | undefined;
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;
export type CustomCalloutExtensionOptions = unknown;
export type CustomCalloutExtensionStorage = unknown;
export type CustomCalloutExtensionType = ProseMirrorNode<CustomCalloutExtensionOptions, CustomCalloutExtensionStorage>;

View File

@@ -0,0 +1,88 @@
// plane imports
import type { TEmojiLogoProps } from "@plane/ui";
import { sanitizeHTML } from "@plane/utils";
// types
import {
ECalloutAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
} from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.ICON_COLOR]: undefined,
[ECalloutAttributeNames.ICON_NAME]: undefined,
[ECalloutAttributeNames.EMOJI_UNICODE]: "128161",
[ECalloutAttributeNames.EMOJI_URL]: "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
[ECalloutAttributeNames.BACKGROUND]: undefined,
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component",
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, ECalloutAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage
export const getStoredLogo = (): TStoredLogoValue => {
const fallBackValues: TStoredLogoValue = {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.EMOJI_UNICODE]: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_UNICODE],
[ECalloutAttributeNames.EMOJI_URL]: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_URL],
};
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? "");
if (storedData) {
let parsedData: TEmojiLogoProps;
try {
parsedData = JSON.parse(storedData);
} catch (error) {
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
localStorage.removeItem("editor-calloutComponent-logo");
return fallBackValues;
}
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
return {
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.EMOJI_UNICODE]:
parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_UNICODE],
[ECalloutAttributeNames.EMOJI_URL]:
parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_URL],
};
}
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
return {
[ECalloutAttributeNames.LOGO_IN_USE]: "icon",
[ECalloutAttributeNames.ICON_NAME]:
parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.ICON_NAME],
[ECalloutAttributeNames.ICON_COLOR]:
parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.ICON_COLOR],
};
}
}
}
// fallback values
return fallBackValues;
};
// function to update the stored logo on local storage
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
if (typeof window === "undefined") return;
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
};
// function to get the stored background color from local storage
export const getStoredBackgroundColor = (): string | null => {
if (typeof window !== "undefined") {
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background") ?? "");
}
return null;
};
// function to update the stored background color on local storage
export const updateStoredBackgroundColor = (value: string | null): void => {
if (typeof window === "undefined") return;
if (value === null) {
localStorage.removeItem("editor-calloutComponent-background");
return;
} else {
localStorage.setItem("editor-calloutComponent-background", value);
}
};

View File

@@ -0,0 +1,98 @@
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
type InlineCodeOptions = {
HTMLAttributes: Record<string, unknown>;
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CODE_INLINE]: {
/**
* Set a code mark
*/
setCode: () => ReturnType;
/**
* Toggle inline code
*/
toggleCode: () => ReturnType;
/**
* Unset a code mark
*/
unsetCode: () => ReturnType;
};
}
}
export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
export const CustomCodeInlineExtension = Mark.create<InlineCodeOptions>({
name: CORE_EXTENSIONS.CODE_INLINE,
addOptions() {
return {
HTMLAttributes: {
class:
"rounded bg-custom-background-80 px-[6px] py-[1.5px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200",
spellcheck: "false",
},
};
},
excludes: "_",
code: true,
exitable: true,
parseHTML() {
return [{ tag: "code" }];
},
renderHTML({ HTMLAttributes }) {
return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setCode:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleCode:
() =>
({ commands }) =>
commands.toggleMark(this.name),
unsetCode:
() =>
({ commands }) =>
commands.unsetMark(this.name),
};
},
addKeyboardShortcuts() {
return {
"Mod-e": () => this.editor.commands.toggleCode(),
};
},
addInputRules() {
return [
markInputRule({
find: inputRegex,
type: this.type,
}),
];
},
addPasteRules() {
return [
markPasteRule({
find: pasteRegex,
type: this.type,
}),
];
},
});

View File

@@ -0,0 +1,35 @@
// import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block";
import { CodeBlockOptions, CodeBlock } from "./code-block";
import { LowlightPlugin } from "./lowlight-plugin";
type CodeBlockLowlightOptions = CodeBlockOptions & {
lowlight: any;
defaultLanguage: string | null | undefined;
};
export const CodeBlockLowlight = CodeBlock.extend<CodeBlockLowlightOptions>({
addOptions() {
return {
...(this.parent?.() ?? {
languageClassPrefix: "language-",
exitOnTripleEnter: true,
exitOnArrowDown: true,
HTMLAttributes: {},
}),
lowlight: {},
defaultLanguage: null,
};
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
LowlightPlugin({
name: this.name,
lowlight: this.options.lowlight,
defaultLanguage: this.options.defaultLanguage,
}),
];
},
});

View File

@@ -0,0 +1,63 @@
"use client";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
import { CopyIcon, CheckIcon } from "lucide-react";
import { useState } from "react";
// ui
import { Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// we just have ts support for now
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
type Props = {
node: ProseMirrorNode;
};
export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
const [copied, setCopied] = useState(false);
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
try {
await navigator.clipboard.writeText(node.textContent);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
} catch {
setCopied(false);
}
e.preventDefault();
e.stopPropagation();
};
return (
<NodeViewWrapper className="code-block relative group/code">
<Tooltip tooltipContent="Copy code">
<button
type="button"
className={cn(
"group/button hidden group-hover/code:flex items-center justify-center absolute top-2 right-2 z-10 size-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out backdrop-blur-sm",
{
"bg-green-500/30 hover:bg-green-500/30 active:bg-green-500/30": copied,
}
)}
onClick={copyToClipboard}
>
{copied ? (
<CheckIcon className="h-3 w-3 text-green-500" strokeWidth={3} />
) : (
<CopyIcon className="h-3 w-3 text-custom-text-300 group-hover/button:text-custom-text-100" />
)}
</button>
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,348 @@
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export type CodeBlockOptions = {
/**
* Adds a prefix to language classes that are applied to code tags.
* Defaults to `'language-'`.
*/
languageClassPrefix: string;
/**
* Define whether the node should be exited on triple enter.
* Defaults to `true`.
*/
exitOnTripleEnter: boolean;
/**
* Define whether the node should be exited on arrow down if there is no node after it.
* Defaults to `true`.
*/
exitOnArrowDown: boolean;
/**
* Custom HTML attributes that should be added to the rendered HTML tag.
*/
HTMLAttributes: Record<string, unknown>;
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CODE_BLOCK]: {
/**
* Set a code block
*/
setCodeBlock: (attributes?: { language: string }) => ReturnType;
/**
* Toggle a code block
*/
toggleCodeBlock: (attributes?: { language: string }) => ReturnType;
};
}
}
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
export const CodeBlock = Node.create<CodeBlockOptions>({
name: CORE_EXTENSIONS.CODE_BLOCK,
addOptions() {
return {
languageClassPrefix: "language-",
exitOnTripleEnter: true,
exitOnArrowDown: true,
HTMLAttributes: {},
};
},
content: "text*",
marks: "",
group: "block",
code: true,
defining: true,
addAttributes() {
return {
language: {
default: null,
parseHTML: (element) => {
const { languageClassPrefix } = this.options;
// @ts-expect-error element is a DOM element
const classNames = [...(element.firstElementChild?.classList || [])];
const languages = classNames
.filter((className) => className.startsWith(languageClassPrefix))
.map((className) => className.replace(languageClassPrefix, ""));
const language = languages[0];
if (!language) {
return null;
}
return language;
},
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: "pre",
preserveWhitespace: "full",
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
[
"code",
{
class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null,
},
0,
],
];
},
addCommands() {
return {
setCodeBlock:
(attributes) =>
({ commands }) =>
commands.setNode(this.name, attributes),
toggleCodeBlock:
(attributes) =>
({ commands }) =>
commands.toggleNode(this.name, CORE_EXTENSIONS.PARAGRAPH, attributes),
};
},
addKeyboardShortcuts() {
return {
"Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(),
// remove codeBlock when at start of document or codeBlock is empty
Backspace: () => {
try {
const { empty, $anchor } = this.editor.state.selection;
const isAtStart = $anchor.pos === 1;
if (!empty || $anchor.parent.type.name !== this.name) {
return false;
}
if (isAtStart || !$anchor.parent.textContent.length) {
return this.editor.commands.clearNodes();
}
return false;
} catch (error) {
console.error("Error handling Backspace in code block:", error);
return false;
}
},
// exit node on triple enter
Enter: ({ editor }) => {
try {
if (!this.options.exitOnTripleEnter) {
return false;
}
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n");
if (!isAtEnd || !endsWithDoubleNewline) {
return false;
}
return editor
.chain()
.command(({ tr }) => {
tr.delete($from.pos - 2, $from.pos);
return true;
})
.exitCode()
.run();
} catch (error) {
console.error("Error handling Enter in code block:", error);
return false;
}
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
return false;
}
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
if (!isAtEnd) {
return false;
}
const after = $from.after();
if (after === undefined) {
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return false;
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in code block:", error);
return false;
}
},
};
},
addInputRules() {
return [
textblockTypeInputRule({
find: backtickInputRegex,
type: this.type,
getAttributes: (match) => ({
language: match[1],
}),
}),
textblockTypeInputRule({
find: tildeInputRegex,
type: this.type,
getAttributes: (match) => ({
language: match[1],
}),
}),
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("codeBlockVSCodeHandlerCustom"),
props: {
handlePaste: (view, event) => {
try {
if (!event.clipboardData) {
return false;
}
if (this.editor.isActive(this.type.name)) {
return false;
}
if (this.editor.isActive(CORE_EXTENSIONS.CODE_INLINE)) {
// Check if it's an inline code block
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
if (!text) {
console.error("Pasted text is empty.");
return false;
}
const { tr } = view.state;
const { $from, $to } = tr.selection;
if ($from.pos > $to.pos) {
console.error("Invalid selection range.");
return false;
}
const docSize = tr.doc.content.size;
if ($from.pos < 0 || $to.pos > docSize) {
console.error("Selection range is out of document bounds.");
return false;
}
// Extend the current selection to replace it with the pasted text
// wrapped in an inline code mark
const codeMark = view.state.schema.marks.code.create();
tr.replaceWith($from.pos, $to.pos, view.state.schema.text(text, [codeMark]));
view.dispatch(tr);
return true;
}
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
if (vscodeData && language) {
const { tr } = view.state;
const { $from } = tr.selection;
// Check if the current line is empty
const isCurrentLineEmpty = !$from.parent.textContent.trim();
let insertPos;
if (isCurrentLineEmpty) {
// If the current line is empty, use the current position
insertPos = $from.pos - 1;
} else {
// If the current line is not empty, insert below the current block node
insertPos = $from.end($from.depth) + 1;
}
// Ensure insertPos is within document bounds
if (insertPos < 0 || insertPos > tr.doc.content.size) {
console.error("Invalid insert position.");
return false;
}
// Create a new code block node with the pasted content
const textNode = view.state.schema.text(text.replace(/\r\n?/g, "\n"));
const codeBlock = this.type.create({ language }, textNode);
if (insertPos <= tr.doc.content.size) {
tr.insert(insertPos, codeBlock);
view.dispatch(tr);
return true;
}
return false;
} else {
// TODO: complicated paste logic, to be handled later
return false;
}
} catch (error) {
console.error("Error handling paste in CodeBlock extension:", error);
return false;
}
},
},
}),
];
},
});

View File

@@ -0,0 +1,124 @@
import { Selection } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
// components
import { CodeBlockLowlight } from "./code-block-lowlight";
import { CodeBlockComponent } from "./code-block-node-view";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent);
},
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
// Use ProseMirror's insertText transaction to insert the tab character
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
editor.view.dispatch(tr);
return true;
} catch (error) {
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
return false;
}
},
ArrowUp: ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtStart = $from.parentOffset === 0;
if (!isAtStart) {
return false;
}
// Check if codeBlock is the first node
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
if (isFirstNode) {
// Insert a new paragraph at the start of the document and move the cursor to it
return editor.commands.command(({ tr }) => {
const node = editor.schema.nodes.paragraph.create();
tr.insert(0, node);
tr.setSelection(Selection.near(tr.doc.resolve(1)));
return true;
});
}
return false;
} catch (error) {
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
return false;
}
},
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
return false;
}
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
if (!isAtEnd) {
return false;
}
const after = $from.after();
if (after === undefined) {
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
return false;
}
},
};
},
}).configure({
lowlight,
defaultLanguage: "plaintext",
exitOnTripleEnter: false,
HTMLAttributes: {
class: "",
},
});

View File

@@ -0,0 +1,155 @@
// TODO: check all the type errors and fix them
import { findChildren } from "@tiptap/core";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import highlight from "highlight.js/lib/core";
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
return nodes
.map((node) => {
const classes = [...className, ...(node.properties ? node.properties.className : [])];
if (node.children) {
return parseNodes(node.children, classes);
}
return {
text: node.value,
classes,
};
})
.flat();
}
function getHighlightNodes(result: any) {
// `.value` for lowlight v1, `.children` for lowlight v2
return result.value || result.children || [];
}
function registered(aliasOrLanguage: string) {
return Boolean(highlight.getLanguage(aliasOrLanguage));
}
function getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}: {
doc: ProsemirrorNode;
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
const decorations: Decoration[] = [];
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
let from = block.pos + 1;
const language = block.node.attrs.language || defaultLanguage;
const languages = lowlight.listLanguages();
const nodes =
language && (languages.includes(language) || registered(language))
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent));
parseNodes(nodes).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(" "),
});
decorations.push(decoration);
}
from = to;
});
});
return DecorationSet.create(doc, decorations);
}
function isFunction(param: () => any) {
return typeof param === "function";
}
export function LowlightPlugin({
name,
lowlight,
defaultLanguage,
}: {
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
if (!["highlight", "highlightAuto", "listLanguages"].every((api) => isFunction(lowlight[api]))) {
throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension");
}
const lowlightPlugin: Plugin = new Plugin({
key: new PluginKey("lowlight"),
state: {
init: (_, { doc }) =>
getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}),
apply: (transaction, decorationSet, oldState, newState) => {
const oldNodeName = oldState.selection.$head.parent.type.name;
const newNodeName = newState.selection.$head.parent.type.name;
const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
if (
transaction.docChanged &&
// Apply decorations if:
// selection includes named node,
([oldNodeName, newNodeName].includes(name) ||
// OR transaction adds/removes named node,
newNodes.length !== oldNodes.length ||
// OR transaction has changes that completely encapsulate a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(
(step) =>
// @ts-expect-error type error
step.from !== undefined &&
// @ts-expect-error type error
step.to !== undefined &&
oldNodes.some(
(node) =>
// @ts-expect-error type error
node.pos >= step.from &&
// @ts-expect-error type error
node.pos + node.node.nodeSize <= step.to
)
))
) {
return getDecorations({
doc: transaction.doc,
name,
lowlight,
defaultLanguage,
});
}
return decorationSet.map(transaction.mapping, transaction.doc);
},
},
props: {
decorations(state) {
return lowlightPlugin.getState(state);
},
},
});
return lowlightPlugin;
}

View File

@@ -0,0 +1,124 @@
import { Editor, findParentNode } from "@tiptap/core";
type ReplaceCodeBlockParams = {
editor: Editor;
from: number;
to: number;
textContent: string;
cursorPosInsideCodeblock: number;
};
export function replaceCodeWithText(editor: Editor): void {
try {
const { from, to } = editor.state.selection;
const cursorPosInsideCodeblock = from;
let replaced = false;
editor.state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type === editor.state.schema.nodes.codeBlock) {
const startPos = pos;
const endPos = pos + node.nodeSize;
const textContent = node.textContent;
if (textContent.length === 0) {
editor.chain().focus().toggleCodeBlock().run();
} else {
transformCodeBlockToParagraphs({
editor,
from: startPos,
to: endPos,
textContent,
cursorPosInsideCodeblock,
});
}
replaced = true;
return false;
}
});
if (!replaced) {
console.log("No code block to replace.");
}
} catch (error) {
console.error("An error occurred while replacing code block content:", error);
}
}
function transformCodeBlockToParagraphs({
editor,
from,
to,
textContent,
cursorPosInsideCodeblock,
}: ReplaceCodeBlockParams): void {
const { schema } = editor.state;
const { paragraph } = schema.nodes;
const docSize = editor.state.doc.content.size;
if (from < 0 || to > docSize || from > to) {
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
return;
}
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
const lines = textContent.split(/\r?\n/);
const tr = editor.state.tr;
let insertPos = from;
// Remove the code block first
tr.delete(from, to);
// For each line, create a paragraph node and insert it
lines.forEach((line) => {
// if the line is empty, create a paragraph node with no content
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
tr.insert(insertPos, paragraphNode);
insertPos += paragraphNode.nodeSize;
});
// Now persist the focus to the converted paragraph
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
editor.view.dispatch(tr);
editor.chain().focus(cursorPosOutsideCodeblock).run();
}
/**
* Calculates the line number where the cursor is located inside the code block.
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
*
* @param {string} textContent - The content of the code block.
* @param {number} cursorPosition - The absolute cursor position in the document.
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
* @returns {number} The 1-based line number where the cursor is located.
*/
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
// Split the text content into lines, handling both Unix and Windows newlines
const lines = textContent.split(/\r?\n/);
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
let startPosition = 0;
let lineNumber = 0;
for (let i = 0; i < lines.length; i++) {
// Calculate the end position of the current line
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
// Check if the cursor position is within the current line
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
lineNumber = i + 1; // Line numbers are 1-based
break;
}
// Update the start position for the next line
startPosition = endPosition;
}
return lineNumber;
}

View File

@@ -0,0 +1,115 @@
import { Selection } from "@tiptap/pm/state";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
// components
import { CodeBlockLowlight } from "./code-block-lowlight";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.extend({
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
// Use ProseMirror's insertText transaction to insert the tab character
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
editor.view.dispatch(tr);
return true;
} catch (error) {
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
return false;
}
},
ArrowUp: ({ editor }) => {
try {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtStart = $from.parentOffset === 0;
if (!isAtStart) {
return false;
}
// Check if codeBlock is the first node
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
if (isFirstNode) {
// Insert a new paragraph at the start of the document and move the cursor to it
return editor.commands.command(({ tr }) => {
const node = editor.schema.nodes.paragraph.create();
tr.insert(0, node);
tr.setSelection(Selection.near(tr.doc.resolve(1)));
return true;
});
}
return false;
} catch (error) {
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
return false;
}
},
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
return false;
}
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
if (!isAtEnd) {
return false;
}
const after = $from.after();
if (after === undefined) {
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
return false;
}
},
};
},
}).configure({
lowlight,
defaultLanguage: "plaintext",
exitOnTripleEnter: false,
});

View File

@@ -0,0 +1,60 @@
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { TextStyle } from "@tiptap/extension-text-style";
import { Underline } from "@tiptap/extension-underline";
// plane editor imports
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
// extensions
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color";
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
import { CustomLinkExtension } from "./custom-link";
import { EmojiExtension } from "./emoji/extension";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionConfig } from "./image";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { CustomStarterKitExtension } from "./starter-kit";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
export const CoreEditorExtensionsWithoutProps = [
CustomStarterKitExtension({
enableHistory: true,
}),
EmojiExtension,
CustomQuoteExtension,
CustomHorizontalRule,
CustomLinkExtension,
ImageExtensionConfig,
CustomImageExtensionConfig,
Underline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex",
},
nested: true,
}),
CustomCodeInlineExtension,
CustomCodeBlockExtensionWithoutProps,
Table,
TableHeader,
TableCell,
TableRow,
CustomMentionExtensionConfig,
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];

View File

@@ -0,0 +1,149 @@
import { Mark, mergeAttributes } from "@tiptap/core";
// constants
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_COLOR]: {
/**
* Set the text color
* @param {string} color The color to set
* @example editor.commands.setTextColor('red')
*/
setTextColor: (color: string) => ReturnType;
/**
* Unset the text color
* @example editor.commands.unsetTextColor()
*/
unsetTextColor: () => ReturnType;
/**
* Set the background color
* @param {string} backgroundColor The color to set
* @example editor.commands.setBackgroundColor('red')
*/
setBackgroundColor: (backgroundColor: string) => ReturnType;
/**
* Unset the background color
* @example editor.commands.unsetBackgroundColorColor()
*/
unsetBackgroundColor: () => ReturnType;
};
}
}
export const CustomColorExtension = Mark.create({
name: CORE_EXTENSIONS.CUSTOM_COLOR,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
color: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"),
renderHTML: (attributes: { color: string }) => {
const { color } = attributes;
if (!color) {
return {};
}
let elementAttributes: Record<string, string> = {
"data-text-color": color,
};
if (!COLORS_LIST.find((c) => c.key === color)) {
elementAttributes = {
...elementAttributes,
style: `color: ${color}`,
};
}
return elementAttributes;
},
},
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"),
renderHTML: (attributes: { backgroundColor: string }) => {
const { backgroundColor } = attributes;
if (!backgroundColor) {
return {};
}
let elementAttributes: Record<string, string> = {
"data-background-color": backgroundColor,
};
if (!COLORS_LIST.find((c) => c.key === backgroundColor)) {
elementAttributes = {
...elementAttributes,
style: `background-color: ${backgroundColor}`,
};
}
return elementAttributes;
},
},
};
},
addStorage() {
return {
markdown: {
serialize: {
open: "",
close: "",
mixable: true,
expelEnclosingWhitespace: true,
},
},
};
},
// @ts-expect-error types are incorrect
// TODO: check this and update types
parseHTML() {
return [
{
tag: "span",
getAttrs: (node) => node.getAttribute("data-text-color") && null,
},
{
tag: "span",
getAttrs: (node) => node.getAttribute("data-background-color") && null,
},
];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setTextColor:
(color: string) =>
({ chain }) =>
chain().setMark(this.name, { color }).run(),
unsetTextColor:
() =>
({ chain }) =>
chain().setMark(this.name, { color: null }).run(),
setBackgroundColor:
(backgroundColor: string) =>
({ chain }) =>
chain().setMark(this.name, { backgroundColor }).run(),
unsetBackgroundColor:
() =>
({ chain }) =>
chain().setMark(this.name, { backgroundColor: null }).run(),
};
},
});

View File

@@ -0,0 +1,340 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane imports
import { cn } from "@plane/utils";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString, getImageBlockId } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
imageFromFileSystem: string | undefined;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
src: string | undefined;
downloadSrc: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
editor,
editorContainer,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
downloadSrc: resolvedDownloadSrc,
updateAttributes,
} = props;
const {
width: nodeWidth,
height: nodeHeight,
aspectRatio: nodeAspectRatio,
src: imgNodeSrc,
alignment: nodeAlignment,
} = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
// refs
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
// extension options
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
console.error(`${errorMessage}:`, error);
}
},
[updateAttributes]
);
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
let closestEditorContainer: HTMLDivElement | null = null;
if (editorContainer) {
closestEditorContainer = editorContainer;
} else {
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
}
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer);
const aspectRatioCalculated = img.naturalWidth / img.naturalHeight;
if (nodeWidth === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize: TCustomImageSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
"Failed to update attributes while initializing an image for the first time:"
);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
// or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs
if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) {
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated };
updateAttributesSafely(
newSize,
"Failed to update attributes while initializing images with width but no aspect ratio:"
);
return newSize;
});
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(nodeWidth) ?? "35%",
height: ensurePixelString(nodeHeight) ?? "auto",
aspectRatio: nodeAspectRatio,
}));
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
if (nodeAlignment === "right") {
const newWidth = Math.max(containerRect.current.right - clientX, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
} else {
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
}
},
[nodeAlignment, size.aspectRatio]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributesSafely]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
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]);
const handleImageMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (isTouchDevice) {
e.preventDefault();
editor.commands.blur();
}
const pos = getPos();
if (pos === undefined) return;
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos, isTouchDevice]
);
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(resolvedImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad;
// show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
return (
<div
id={getImageBlockId(node.attrs.id ?? "")}
className={cn("w-fit max-w-full transition-all", {
"ml-[50%] -translate-x-1/2": nodeAlignment === "center",
"ml-[100%] -translate-x-full": nodeAlignment === "right",
})}
>
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
>
{showImageLoader && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
)}
<img
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
try {
setHasErroredOnFirstLoad(true);
// this is a type error from tiptap, don't remove await until it's fixed
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
if (!resolvedImageSrc) {
throw new Error("No resolved image source available");
}
if (isTouchDevice) {
const refreshedSrc = await extension.options.getImageSource?.(imgNodeSrc);
imageRef.current.src = refreshedSrc;
} else {
imageRef.current.src = resolvedImageSrc;
}
} catch {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", e);
} finally {
setHasErroredOnFirstLoad(false);
setHasTriedRestoringImageOnce(true);
}
}}
width={size.width}
className={cn("image-component block rounded-md", {
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
hidden: showImageLoader,
"read-only-image": !editor.isEditable,
"blur-sm opacity-80 loading-image": !resolvedImageSrc,
})}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showUploadStatus && node.attrs.id && <ImageUploadStatus editor={editor} nodeId={node.attrs.id} />}
{showImageToolbar && (
<ImageToolbarRoot
alignment={nodeAlignment ?? "left"}
editor={editor}
aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
downloadSrc={resolvedDownloadSrc}
handleAlignmentChange={(alignment) =>
updateAttributesSafely({ alignment }, "Failed to update attributes while changing alignment:")
}
height={size.height}
isTouchDevice={isTouchDevice}
width={size.width}
src={resolvedImageSrc}
/>
)}
{selected && displayedImageSrc === resolvedImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30 pointer-events-none" />
)}
{showImageResizer && (
<>
<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/image-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 translate-y-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto":
!isResizing,
"left-0 -translate-x-1/2 cursor-nesw-resize": nodeAlignment === "right",
"right-0 translate-x-1/2 cursor-nwse-resize": nodeAlignment !== "right",
}
)}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
</>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,88 @@
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// local imports
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtensionType;
node: NodeViewProps["node"] & {
attrs: TCustomImageAttributes;
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc);
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (closestEditorContainer) {
setEditorContainer(closestEditorContainer as HTMLDivElement);
}
}, []);
// the image is already uploaded if the image-component node has src attribute
// and we need to remove the blob from our file system
useEffect(() => {
if (resolvedSrc || imgNodeSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
}, [resolvedSrc, imgNodeSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
setResolvedDownloadSrc(undefined);
return;
}
const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
setResolvedDownloadSrc(downloadUrl);
};
getImageSource();
}, [imgNodeSrc, extension.options]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
downloadSrc={resolvedDownloadSrc}
{...props}
/>
) : (
<CustomImageUploader
failedToLoadImage={failedToLoadImage}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
setIsUploaded={setIsUploaded}
{...props}
/>
)}
</div>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,64 @@
import { ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { Tooltip } from "@plane/ui";
// local imports
import type { TCustomImageAlignment } from "../../types";
import { IMAGE_ALIGNMENT_OPTIONS } from "../../utils";
type Props = {
activeAlignment: TCustomImageAlignment;
handleChange: (alignment: TCustomImageAlignment) => void;
isTouchDevice: boolean;
toggleToolbarViewStatus: (val: boolean) => void;
};
export const ImageAlignmentAction: React.FC<Props> = (props) => {
const { activeAlignment, handleChange, isTouchDevice, toggleToolbarViewStatus } = props;
// states
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement>(null);
// derived values
const activeAlignmentDetails = IMAGE_ALIGNMENT_OPTIONS.find((option) => option.value === activeAlignment);
useOutsideClickDetector(dropdownRef, () => setIsDropdownOpen(false));
useEffect(() => {
toggleToolbarViewStatus(isDropdownOpen);
}, [isDropdownOpen, toggleToolbarViewStatus]);
return (
<div ref={dropdownRef} className="h-full relative">
<Tooltip disabled={isTouchDevice} tooltipContent="Align">
<button
type="button"
className="h-full flex items-center gap-1 text-white/60 hover:text-white transition-colors"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{activeAlignmentDetails && <activeAlignmentDetails.icon className="flex-shrink-0 size-3" />}
<ChevronDown className="flex-shrink-0 size-2" />
</button>
</Tooltip>
{isDropdownOpen && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-0.5 h-7 bg-black/80 flex items-center gap-2 px-2 rounded">
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
<Tooltip disabled={isTouchDevice} key={option.value} tooltipContent={option.label}>
<button
type="button"
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
onClick={() => {
handleChange(option.value);
setIsDropdownOpen(false);
}}
>
<option.icon className="size-3" />
</button>
</Tooltip>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,24 @@
import { Download } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/ui";
type Props = {
src: string;
};
export const ImageDownloadAction: React.FC<Props> = (props) => {
const { src } = props;
return (
<Tooltip tooltipContent="Download">
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
aria-label="Download image"
>
<Download className="size-3" />
</button>
</Tooltip>
);
};

View File

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

View File

@@ -0,0 +1,305 @@
import { Download, ExternalLink, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
// plane imports
import { cn } from "@plane/utils";
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2;
const ZOOM_SPEED = 0.05;
const ZOOM_STEPS = [0.5, 1, 1.5, 2];
type Props = {
aspectRatio: number;
downloadSrc: string;
isFullScreenEnabled: boolean;
isTouchDevice: boolean;
src: string;
toggleFullScreenMode: (val: boolean) => void;
width: string;
};
const ImageFullScreenModalWithoutPortal = (props: Props) => {
const { aspectRatio, isFullScreenEnabled, isTouchDevice, downloadSrc, src, toggleFullScreenMode, width } = props;
// refs
const dragStart = useRef({ x: 0, y: 0 });
const dragOffset = useRef({ x: 0, y: 0 });
const [magnification, setMagnification] = useState<number>(1);
const [initialMagnification, setInitialMagnification] = useState(1);
const [isDragging, setIsDragging] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const widthInNumber = useMemo(() => {
if (!width) return 0;
return Number(width.replace("px", ""));
}, [width]);
const setImageRef = useCallback(
(node: HTMLImageElement | null) => {
if (!node || !isFullScreenEnabled) return;
imgRef.current = node;
const viewportWidth = window.innerWidth * 0.9;
const viewportHeight = window.innerHeight * 0.75;
const imageWidth = widthInNumber;
const imageHeight = imageWidth / aspectRatio;
const widthRatio = viewportWidth / imageWidth;
const heightRatio = viewportHeight / imageHeight;
setInitialMagnification(Math.min(widthRatio, heightRatio));
setMagnification(1);
// Reset image position
node.style.left = "0px";
node.style.top = "0px";
},
[isFullScreenEnabled, widthInNumber, aspectRatio]
);
const handleClose = useCallback(() => {
if (isDragging) return;
toggleFullScreenMode(false);
setMagnification(1);
setInitialMagnification(1);
}, [isDragging, toggleFullScreenMode]);
const handleMagnification = useCallback((direction: "increase" | "decrease") => {
setMagnification((prev) => {
// Find the appropriate target zoom level based on current magnification
let targetZoom: number;
if (direction === "increase") {
targetZoom = ZOOM_STEPS.find((step) => step > prev) ?? MAX_ZOOM;
} else {
// Reverse the array to find the next lower step
targetZoom = [...ZOOM_STEPS].reverse().find((step) => step < prev) ?? MIN_ZOOM;
}
// Reset position when zoom matches initial magnification
if (targetZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}
return targetZoom;
});
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") handleClose();
if (e.key === "+" || e.key === "=") handleMagnification("increase");
if (e.key === "-") handleMagnification("decrease");
}
},
[handleClose, handleMagnification]
);
const handleMouseDown = (e: React.MouseEvent) => {
if (!imgRef.current) return;
const imgWidth = imgRef.current.offsetWidth * magnification;
const imgHeight = imgRef.current.offsetHeight * magnification;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (imgWidth > viewportWidth || imgHeight > viewportHeight) {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
dragOffset.current = {
x: parseInt(imgRef.current.style.left || "0"),
y: parseInt(imgRef.current.style.top || "0"),
};
}
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging || !imgRef.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
// Apply the scale factor to the drag movement
const scaledDx = dx / magnification;
const scaledDy = dy / magnification;
imgRef.current.style.left = `${dragOffset.current.x + scaledDx}px`;
imgRef.current.style.top = `${dragOffset.current.y + scaledDy}px`;
},
[isDragging, magnification]
);
const handleMouseUp = useCallback(() => {
if (!isDragging || !imgRef.current) return;
setIsDragging(false);
}, [isDragging]);
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!imgRef.current || !isFullScreenEnabled) return;
e.preventDefault();
// Handle pinch-to-zoom
if (e.ctrlKey || e.metaKey) {
const delta = e.deltaY;
setMagnification((prev) => {
const newZoom = prev * (1 - delta * ZOOM_SPEED);
const clampedZoom = Math.min(Math.max(newZoom, MIN_ZOOM), MAX_ZOOM);
// Reset position when zoom matches initial magnification
if (clampedZoom === 1 && imgRef.current) {
imgRef.current.style.left = "0px";
imgRef.current.style.top = "0px";
}
return clampedZoom;
});
return;
}
},
[isFullScreenEnabled]
);
// Event listeners
useEffect(() => {
if (!isFullScreenEnabled) return;
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("wheel", handleWheel, { passive: false });
return () => {
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("wheel", handleWheel);
};
}, [isFullScreenEnabled, handleKeyDown, handleMouseMove, handleMouseUp, handleWheel]);
if (!isFullScreenEnabled) return null;
return (
<div
className={cn("fixed inset-0 size-full z-50 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
role="dialog"
aria-modal="true"
aria-label="Fullscreen image viewer"
>
<div
ref={modalRef}
onMouseDown={(e) => e.target === modalRef.current && handleClose()}
className="relative size-full grid place-items-center overflow-hidden"
>
<button
type="button"
onClick={handleClose}
className="absolute top-10 right-10 size-8 grid place-items-center"
aria-label="Close image viewer"
>
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
ref={setImageRef}
src={src}
className="read-only-image rounded-lg"
style={{
width: `${widthInNumber * initialMagnification}px`,
maxWidth: "none",
maxHeight: "none",
aspectRatio,
position: "relative",
transform: `scale(${magnification})`,
transformOrigin: "center",
transition: "width 0.2s ease, transform 0.2s ease",
}}
onMouseDown={handleMouseDown}
/>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={(e) => {
if (isTouchDevice) {
e.preventDefault();
e.stopPropagation();
}
handleMagnification("decrease");
}}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification <= MIN_ZOOM}
aria-label="Zoom out"
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={(e) => {
if (isTouchDevice) {
e.preventDefault();
e.stopPropagation();
}
handleMagnification("increase");
}}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification >= MAX_ZOOM}
aria-label="Zoom in"
>
<Plus className="size-4" />
</button>
</div>
{!isTouchDevice && (
<button
type="button"
onClick={() => window.open(downloadSrc, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Download image"
>
<Download className="size-4" />
</button>
)}
{!isTouchDevice && (
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
aria-label="Open image in new tab"
>
<ExternalLink className="size-4" />
</button>
)}
</div>
</div>
</div>
);
};
export const ImageFullScreenModal: React.FC<Props> = (props) => {
let modal = <ImageFullScreenModalWithoutPortal {...props} />;
const portal = document.querySelector("#editor-portal");
if (portal) {
modal = ReactDOM.createPortal(modal, portal);
} else {
console.warn("Portal element #editor-portal not found. Rendering in document.body");
if (typeof document !== "undefined" && document.body) {
modal = ReactDOM.createPortal(modal, document.body);
}
}
return modal;
};

View File

@@ -0,0 +1,58 @@
import { Maximize } from "lucide-react";
import { useEffect, useState } from "react";
// plane imports
import { Tooltip } from "@plane/ui";
// local imports
import { ImageFullScreenModal } from "./modal";
type Props = {
image: {
aspectRatio: number;
downloadSrc: string;
height: string;
src: string;
width: string;
};
isTouchDevice: boolean;
toggleToolbarViewStatus: (val: boolean) => void;
};
export const ImageFullScreenActionRoot: React.FC<Props> = (props) => {
const { image, isTouchDevice, toggleToolbarViewStatus } = props;
// states
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
// derived values
const { downloadSrc, src, width, aspectRatio } = image;
useEffect(() => {
toggleToolbarViewStatus(isFullScreenEnabled);
}, [isFullScreenEnabled, toggleToolbarViewStatus]);
return (
<>
<ImageFullScreenModal
aspectRatio={aspectRatio}
downloadSrc={downloadSrc}
isFullScreenEnabled={isFullScreenEnabled}
isTouchDevice={isTouchDevice}
src={src}
width={width}
toggleFullScreenMode={setIsFullScreenEnabled}
/>
<Tooltip tooltipContent="View in full screen" disabled={isTouchDevice}>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsFullScreenEnabled(true);
}}
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
aria-label="View image in full screen"
>
<Maximize className="size-3" />
</button>
</Tooltip>
</>
);
};

View File

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

View File

@@ -0,0 +1,57 @@
import type { Editor } from "@tiptap/core";
import { useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// local imports
import type { TCustomImageAlignment } from "../../types";
import { ImageAlignmentAction } from "./alignment";
import { ImageDownloadAction } from "./download";
import { ImageFullScreenActionRoot } from "./full-screen";
type Props = {
alignment: TCustomImageAlignment;
editor: Editor;
aspectRatio: number;
downloadSrc: string;
handleAlignmentChange: (alignment: TCustomImageAlignment) => void;
height: string;
isTouchDevice: boolean;
src: string;
width: string;
};
export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { alignment, editor, downloadSrc, handleAlignmentChange, isTouchDevice } = props;
// states
const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
// derived values
const isEditable = editor.isEditable;
return (
<>
<div
className={cn(
"absolute top-1 right-1 h-7 z-20 bg-black/80 rounded flex items-center gap-2 px-2 opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity",
{
"opacity-100 pointer-events-auto": shouldShowToolbar,
}
)}
>
{!isTouchDevice && <ImageDownloadAction src={downloadSrc} />}
{isEditable && (
<ImageAlignmentAction
activeAlignment={alignment}
handleChange={handleAlignmentChange}
isTouchDevice={isTouchDevice}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
)}
<ImageFullScreenActionRoot
image={props}
isTouchDevice={isTouchDevice}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
</div>
</>
);
};

View File

@@ -0,0 +1,60 @@
import { Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
type Props = {
editor: Editor;
nodeId: string;
};
export const ImageUploadStatus: React.FC<Props> = (props) => {
const { editor, nodeId } = props;
// Displayed status that will animate smoothly
const [displayStatus, setDisplayStatus] = useState(0);
// Animation frame ID for cleanup
const animationFrameRef = useRef<number | null>(null);
// subscribe to image upload status
const uploadStatus: number | undefined = useEditorState({
editor,
selector: ({ editor }) => editor.storage.utility?.assetsUploadStatus?.[nodeId],
});
useEffect(() => {
const animateToValue = (start: number, end: number, startTime: number) => {
const duration = 200;
const animation = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
// Calculate current display value
const currentValue = Math.floor(start + (end - start) * easeOutCubic);
setDisplayStatus(currentValue);
// Continue animation if not complete
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
}
};
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
};
animateToValue(displayStatus, uploadStatus == undefined ? 100 : uploadStatus, performance.now());
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [displayStatus, uploadStatus]);
if (uploadStatus === undefined) return null;
return (
<div className="absolute top-1 right-1 z-20 bg-black/60 rounded text-xs font-medium w-10 text-center">
{displayStatus}%
</div>
);
};

View File

@@ -0,0 +1,216 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { EFileError } from "@/helpers/file";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
maxFileSize,
node,
selected,
setIsUploaded,
updateAttributes,
} = props;
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const { id: imageEntityId } = node.attrs;
// derived values
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
const onUpload = useCallback(
(url: string) => {
if (url) {
if (!imageEntityId) return;
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({
src: url,
});
imageComponentImageFileMap?.delete(imageEntityId);
const pos = getPos();
// get current node
const getCurrentSelection = editor.state.selection;
const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from);
// only if the cursor is at the current image component, manipulate
// the cursor position
if (
currentNode &&
currentNode.type.name === node.type.name &&
currentNode.attrs.src === url &&
pos !== undefined
) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
// If there is a paragraph node after the image component, move the focus to the next node
editor.commands.setTextSelection(pos + 1);
} else {
// create a new paragraph after the image component post upload
editor.commands.createParagraphNear();
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
);
const handleProgressStatus = useCallback(
(isUploading: boolean) => {
editor.storage.utility.uploadInProgress = isUploading;
},
[editor]
);
const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => {
alert(message);
}, []);
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editorCommand: uploadImageEditorCommand,
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
maxFileSize,
onInvalidFile: handleInvalidFile,
onUpload,
});
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
editor,
getPos,
type: "image",
uploader: uploadFile,
});
// the meta data of the image component
const meta = useMemo(
() => imageComponentImageFileMap?.get(imageEntityId ?? ""),
[imageComponentImageFileMap, imageEntityId]
);
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
if (meta) {
if (meta.event === "drop" && "file" in meta) {
uploadFile(meta.file);
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
if (meta.hasOpenedFileInputOnce) return;
if (!isTouchDevice) {
fileInputRef.current.click();
}
hasTriggeredFilePickerRef.current = true;
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId, isTouchDevice]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const filesList = e.target.files;
const pos = getPos();
if (!filesList || pos === undefined) {
return;
}
await uploadFirstFileAndInsertRemaining({
editor,
filesList,
pos,
type: "image",
uploader: uploadFile,
});
},
[uploadFile, editor, getPos]
);
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
return "Error loading image";
}
if (isUploading) {
return "Uploading...";
}
if (draggedInside && editor.isEditable) {
return "Drop image here";
}
return "Add an image";
}, [draggedInside, editor.isEditable, failedToLoadImage, isImageBeingUploaded]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 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": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside && 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":
selected && editor.isEditable,
"text-red-500 cursor-default": failedToLoadImage,
"hover:text-red-500": failedToLoadImage && editor.isEditable,
"bg-red-500/10": failedToLoadImage && selected,
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage && editor.isEditable) {
fileInputRef.current?.click();
}
}}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">{getDisplayMessage()}</div>
<input
className="size-0 overflow-hidden"
ref={fileInputRef}
hidden
type="file"
accept={ACCEPTED_IMAGE_MIME_TYPES.join(",")}
onChange={onFileChange}
multiple
/>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import {
type CustomImageExtensionType,
type CustomImageExtensionStorage,
ECustomImageAttributeNames,
type InsertImageComponentProps,
CustomImageExtensionOptions,
TCustomImageAttributes,
} from "./types";
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
};
}
interface Storage {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
}
}
export const CustomImageExtensionConfig: CustomImageExtensionType = BaseImageExtension.extend<
CustomImageExtensionOptions,
CustomImageExtensionStorage
>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
group: "block",
atom: true,
addAttributes() {
const attributes = {
...this.parent?.(),
...Object.values(ECustomImageAttributeNames).reduce(
(acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
},
{} as Record<ECustomImageAttributeNames, { default: TCustomImageAttributes[ECustomImageAttributeNames] }>
),
};
return attributes;
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
});

View File

@@ -0,0 +1,125 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// helpers
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler } from "@/types";
// local imports
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler;
isEditable: boolean;
};
export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
// derived values
const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend<CustomImageExtensionOptions, CustomImageExtensionStorage>({
selectable: isEditable,
draggable: isEditable,
addOptions() {
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
return {
...this.parent?.(),
getImageDownloadSource: getAssetDownloadSrc,
getImageSource: getAssetSrc,
restoreImage: restoreImageFn,
uploadImage: upload,
};
},
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize: this.storage.maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
));
},
});
};

View File

@@ -0,0 +1,56 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
export enum ECustomImageAttributeNames {
ID = "id",
WIDTH = "width",
HEIGHT = "height",
ASPECT_RATIO = "aspectRatio",
SOURCE = "src",
ALIGNMENT = "alignment",
}
export type Pixel = `${number}px`;
export type PixelAttribute<TDefault> = Pixel | TDefault;
export type TCustomImageSize = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
export type TCustomImageAlignment = "left" | "center" | "right";
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null;
[ECustomImageAttributeNames.ALIGNMENT]: TCustomImageAlignment;
};
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
export type CustomImageExtensionOptions = {
getImageDownloadSource: TFileHandler["getAssetDownloadSrc"];
getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"];
};
export type CustomImageExtensionStorage = {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
};
export type CustomImageExtensionType = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;

View File

@@ -0,0 +1,53 @@
import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
// local imports
import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null,
[ECustomImageAttributeNames.ID]: null,
[ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
[ECustomImageAttributeNames.ALIGNMENT]: "left",
};
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
export const ensurePixelString = <TDefault>(
value: Pixel | TDefault | number | undefined | null,
defaultValue?: TDefault
) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
export const IMAGE_ALIGNMENT_OPTIONS: {
label: string;
value: TCustomImageAlignment;
icon: LucideIcon;
}[] = [
{
label: "Left",
value: "left",
icon: AlignLeft,
},
{
label: "Center",
value: "center",
icon: AlignCenter,
},
{
label: "Right",
value: "right",
icon: AlignRight,
},
];
export const getImageBlockId = (id: string) => `editor-image-block-${id}`;

View File

@@ -0,0 +1,268 @@
import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { find, registerCustomProtocol, reset } from "linkifyjs";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// local imports
import { autolink } from "./helpers/autolink";
import { clickHandler } from "./helpers/clickHandler";
import { pasteHandler } from "./helpers/pasteHandler";
type LinkProtocolOptions = {
scheme: string;
optionalSlashes?: boolean;
};
type LinkOptions = {
/**
* If enabled, it adds links as you type.
*/
autolink: boolean;
/**
* An array of custom protocols to be registered with linkifyjs.
*/
protocols: Array<LinkProtocolOptions | string>;
/**
* If enabled, links will be opened on click.
*/
openOnClick: boolean;
/**
* If enabled, links will be inclusive i.e. if you move your cursor to the
* link text, and start typing, it'll be a part of the link itself.
*/
inclusive: boolean;
/**
* Adds a link to the current selection if the pasted content only contains an url.
*/
linkOnPaste: boolean;
/**
* A list of HTML attributes to be rendered.
*/
HTMLAttributes: Record<string, unknown>;
/**
* A validation function that modifies link verification for the auto linker.
* @param url - The url to be validated.
* @returns - True if the url is valid, false otherwise.
*/
validate?: (url: string) => boolean;
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_LINK]: {
/**
* Set a link mark
*/
setLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Toggle a link mark
*/
toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Unset a link mark
*/
unsetLink: () => ReturnType;
};
}
interface Storage {
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
}
}
export type CustomLinkStorage = {
isPreviewOpen: boolean;
posToInsert: { from: number; to: number };
isBubbleMenuOpen: boolean;
};
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
name: CORE_EXTENSIONS.CUSTOM_LINK,
priority: 1000,
keepOnSplit: false,
onCreate() {
this.options.protocols.forEach((protocol) => {
if (typeof protocol === "string") {
registerCustomProtocol(protocol);
return;
}
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes);
});
},
onDestroy() {
reset();
},
inclusive() {
return this.options.inclusive;
},
addOptions() {
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
inclusive: false,
protocols: ["http", "https"],
HTMLAttributes: {
target: "_blank",
rel: "noopener noreferrer nofollow",
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
validate: (url: string) => isValidHttpUrl(url).isValid,
};
},
addAttributes() {
return {
href: {
default: null,
},
target: {
default: this.options.HTMLAttributes.target,
},
rel: {
default: this.options.HTMLAttributes.rel,
},
class: {
default: this.options.HTMLAttributes.class,
},
};
},
parseHTML() {
return [
{
tag: "a[href]",
getAttrs: (node) => {
if (typeof node === "string") {
return null;
}
const href = node.getAttribute("href")?.toLowerCase() || "";
if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) {
return false;
}
return {};
},
},
];
},
renderHTML({ HTMLAttributes }) {
const href = HTMLAttributes.href?.toLowerCase() || "";
if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) {
return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0];
}
return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setLink:
(attributes) =>
({ chain }) =>
chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(),
toggleLink:
(attributes) =>
({ chain }) =>
chain()
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
.setMeta("preventAutolink", true)
.run(),
unsetLink:
() =>
({ chain }) =>
chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(),
};
},
addPasteRules() {
return [
markPasteRule({
find: (text) => {
const foundLinks: PasteRuleMatch[] = [];
if (text) {
const links = find(text).filter((item) => item.isLink);
if (links.length) {
links.forEach((link) =>
foundLinks.push({
text: link.value,
data: {
href: link.href,
},
index: link.start,
})
);
}
}
return foundLinks;
},
type: this.type,
getAttributes: (match) => ({
href: match.data?.href,
}),
}),
];
},
addProseMirrorPlugins() {
const plugins: Plugin[] = [];
if (this.options.autolink) {
plugins.push(
autolink({
type: this.type,
validate: this.options.validate,
})
);
}
if (this.options.openOnClick) {
plugins.push(
clickHandler({
type: this.type,
})
);
}
if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type,
})
);
}
return plugins;
},
addStorage() {
return {
isPreviewOpen: false,
isBubbleMenuOpen: false,
posToInsert: { from: 0, to: 0 },
};
},
});

View File

@@ -0,0 +1,118 @@
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
getMarksBetween,
NodeWithPos,
} from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
type AutolinkOptions = {
type: MarkType;
validate?: (url: string) => boolean;
};
export function autolink(options: AutolinkOptions): Plugin {
return new Plugin({
key: new PluginKey("autolink"),
appendTransaction: (transactions, oldState, newState) => {
const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);
const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink"));
if (!docChanges || preventAutolink) {
return;
}
const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const changes = getChangedRanges(transform);
changes.forEach(({ newRange }) => {
// Now lets see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock);
let textBlock: NodeWithPos | undefined;
let textBeforeWhitespace: string | undefined;
if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
undefined,
" "
);
} else if (
nodesInChangedRanges.length &&
// We want to make sure to include the block separator argument to treat hard breaks like spaces.
newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ")
) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " ");
}
if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== "");
if (wordsBeforeWhitespace.length <= 0) {
return false;
}
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
if (!lastWordBeforeSpace) {
return false;
}
find(lastWordBeforeSpace)
.filter((link) => link.isLink)
// Calculate link position.
.map((link) => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// ignore link inside code mark
.filter((link) => {
if (!newState.schema.marks.code) {
return true;
}
return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code);
})
// validate link
.filter((link) => {
if (options.validate) {
return options.validate(link.value);
}
return true;
})
// Add link mark.
.forEach((link) => {
if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) {
return;
}
tr.addMark(
link.from,
link.to,
options.type.create({
href: link.href,
})
);
});
}
});
if (!tr.steps.length) {
return;
}
return tr;
},
});
}

View File

@@ -0,0 +1,46 @@
import { getAttributes } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
type ClickHandlerOptions = {
type: MarkType;
};
export function clickHandler(options: ClickHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handleClickLink"),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false;
}
let a = event.target as HTMLElement;
const els: HTMLElement[] = [];
while (a?.nodeName !== "DIV") {
els.push(a);
a = a?.parentNode as HTMLElement;
}
if (!els.find((value) => value.nodeName === "A")) {
return false;
}
const attrs = getAttributes(view.state, options.type.name);
const link = event.target as HTMLLinkElement;
const href = link?.href ?? attrs.href;
const target = link?.target ?? attrs.target;
if (link && href) {
window.open(href, target);
return true;
}
return false;
},
},
});
}

View File

@@ -0,0 +1,44 @@
import { Editor } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
type PasteHandlerOptions = {
editor: Editor;
type: MarkType;
};
export function pasteHandler(options: PasteHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handlePasteLink"),
props: {
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;
if (empty) {
return false;
}
let textContent = "";
slice.content.forEach((node) => {
textContent += node.textContent;
});
const link = find(textContent).find((item) => item.isLink && item.value === textContent);
if (!textContent || !link) {
return false;
}
options.editor.commands.setMark(options.type, {
href: link.href,
});
return true;
},
},
});
}

View File

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

Some files were not shown because too many files have changed in this diff Show More