This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
+82
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 Editor’s 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 Editor’s 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
+101
View File
@@ -0,0 +1,101 @@
{
"name": "@plane/editor",
"version": "1.3.0",
"private": true,
"description": "Core Editor that powers Plane",
"keywords": [
"editor",
"markdown",
"nextjs",
"react",
"rich-text"
],
"license": "AGPL-3.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./lib": "./dist/lib.js",
"./package.json": "./package.json",
"./styles.css": "./dist/styles/index.css",
"./styles": "./dist/styles/index.css"
},
"scripts": {
"build": "tsc && tsdown",
"dev": "tsdown --watch --no-clean",
"check:lint": "oxlint --max-warnings=416 .",
"check:types": "tsc --noEmit",
"check:format": "oxfmt --check .",
"fix:lint": "oxlint --fix .",
"fix:format": "oxfmt .",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"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/propel": "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-document": "^2.22.3",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-heading": "^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": "^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",
"buffer": "^6.0.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",
"lodash-es": "catalog:",
"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/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"postcss": "^8.4.38",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {
"react": "catalog:",
"react-dom": "catalog:"
}
}
@@ -0,0 +1,3 @@
import postcssConfig from "@plane/tailwind-config/postcss.config.js";
export default postcssConfig;
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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;
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import { LinkViewContainer } from "@/components/editors/link-view-container";
export function LinkContainer({
editor,
containerRef,
}: {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
}) {
return (
<>
<LinkViewContainer editor={editor} containerRef={containerRef} />
</>
);
}
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// helpers
import type { TAssetMetaDataRecord } from "@/helpers/assets";
// local imports
import type { ADDITIONAL_EXTENSIONS } from "./extensions";
export const ADDITIONAL_ASSETS_META_DATA_RECORD: Partial<Record<ADDITIONAL_EXTENSIONS, TAssetMetaDataRecord>> = {};
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum ADDITIONAL_EXTENSIONS {}
export const ADDITIONAL_BLOCK_NODE_TYPES = [];
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import type { ADDITIONAL_EXTENSIONS } from "@plane/utils";
import { 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",
},
};
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 [];
};
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./extensions";
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Extensions } from "@tiptap/core";
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function DocumentEditorAdditionalExtensions(props: TDocumentEditorAdditionalExtensionsProps) {
const { disabledExtensions, flaggedExtensions } = props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
return documentExtensions;
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./core";
export * from "./document-extensions";
export * from "./slash-commands";
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import type { 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 function 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;
}
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// 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;
};
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { v4 as uuidv4 } from "uuid";
import { ECustomImageAttributeNames, ECustomImageStatus } from "@/extensions/custom-image/types";
export type AssetDuplicationContext = {
element: Element;
originalHtml: string;
};
export type AssetDuplicationResult = {
modifiedHtml: string;
shouldProcess: boolean;
};
export type AssetDuplicationHandler = (context: AssetDuplicationContext) => AssetDuplicationResult;
const imageComponentHandler: AssetDuplicationHandler = ({ element, originalHtml }) => {
const src = element.getAttribute("src");
if (!src || src.startsWith("http")) {
return { modifiedHtml: originalHtml, shouldProcess: false };
}
// Capture the original HTML BEFORE making any modifications
const originalTag = element.outerHTML;
// Use setAttribute to update attributes
const newId = uuidv4();
element.setAttribute(ECustomImageAttributeNames.STATUS, ECustomImageStatus.DUPLICATING);
element.setAttribute(ECustomImageAttributeNames.ID, newId);
// Get the modified HTML AFTER the changes
const modifiedTag = element.outerHTML;
const modifiedHtml = originalHtml.replaceAll(originalTag, modifiedTag);
return { modifiedHtml, shouldProcess: true };
};
export const assetDuplicationHandlers: Record<string, AssetDuplicationHandler> = {
"image-component": imageComponentHandler,
};
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
/**
* @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;
};
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type TAdditionalEditorAsset = never;
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type TExtendedFileHandler = object;
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type IEditorExtensionOptions = unknown;
export type IEditorPropsExtended = unknown;
export type ICollaborativeDocumentEditorPropsExtended = unknown;
export type TExtendedEditorCommands = never;
export type TExtendedCommandExtraProps = unknown;
export type TExtendedEditorRefApi = unknown;
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./issue-embed";
export * from "./editor-extended";
export * from "./config";
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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;
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// extensions
import type { ImageExtensionStorage } from "@/extensions/image";
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type TAdditionalActiveDropbarExtensions = never;
@@ -0,0 +1,169 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useMemo } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// contexts
import { CollaborationProvider, useCollaboration } from "@/contexts/collaboration-context";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import type { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
// Inner component that has access to collaboration context
function CollaborativeDocumentEditorInner(props: ICollaborativeDocumentEditorProps) {
const {
aiHandler,
bubbleMenuEnabled = true,
containerClassName,
documentLoaderClassName,
extensions = [],
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
editorProps,
extendedEditorProps,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id,
dragDropEnabled = true,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
tabIndex,
user,
extendedDocumentEditorProps,
titleRef,
updatePageProperties,
isFetchingFallbackBinary,
} = props;
// Get non-null provider from context
const { provider, state, actions } = useCollaboration();
// Editor initialization with guaranteed non-null provider
const { editor, titleEditor } = useCollaborativeEditor({
provider,
disabledExtensions,
editable,
editorClassName,
editorProps,
extendedEditorProps,
extensions,
fileHandler,
flaggedExtensions,
getEditorMetaData,
forwardedRef,
handleEditorReady,
id,
dragDropEnabled,
isTouchDevice,
mentionHandler,
onAssetChange,
onChange,
onEditorFocus,
onTransaction,
placeholder,
tabIndex,
titleRef,
updatePageProperties,
user,
actions,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
// Show loader ONLY when cache is known empty and server hasn't synced yet
const shouldShowSyncLoader = state.isCacheReady && !state.hasCachedContent && !state.isServerSynced;
const shouldWaitForFallbackBinary = isFetchingFallbackBinary && !state.hasCachedContent && state.isServerDisconnected;
const isLoading = shouldShowSyncLoader || shouldWaitForFallbackBinary;
// Gate content rendering on isDocReady to prevent empty editor flash
const showContentSkeleton = !state.isDocReady;
if (!editor || !titleEditor) return null;
return (
<>
<div
className={cn(
"transition-opacity duration-200",
showContentSkeleton && !isLoading && "pointer-events-none opacity-0"
)}
>
<PageRenderer
aiHandler={aiHandler}
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
documentLoaderClassName={documentLoaderClassName}
disabledExtensions={disabledExtensions}
extendedDocumentEditorProps={extendedDocumentEditorProps}
editor={editor}
flaggedExtensions={flaggedExtensions}
titleEditor={titleEditor}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
extendedEditorProps={extendedEditorProps}
id={id}
isLoading={isLoading}
isTouchDevice={!!isTouchDevice}
tabIndex={tabIndex}
provider={provider}
state={state}
/>
</div>
</>
);
}
// Outer component that provides collaboration context
function CollaborativeDocumentEditor(props: ICollaborativeDocumentEditorProps) {
const { id, realtimeConfig, serverHandler, user } = props;
const token = useMemo(() => JSON.stringify(user), [user]);
return (
<CollaborationProvider
docId={id}
serverUrl={realtimeConfig.url}
authToken={token}
onStateChange={serverHandler?.onStateChange}
>
<CollaborativeDocumentEditorInner {...props} />
</CollaborationProvider>
);
}
const CollaborativeDocumentEditorWithRef = React.forwardRef(function CollaborativeDocumentEditorWithRef(
props: ICollaborativeDocumentEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return (
<CollaborativeDocumentEditor key={props.id} {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi>} />
);
});
CollaborativeDocumentEditorWithRef.displayName = "CollaborativeDocumentEditorWithRef";
export { CollaborativeDocumentEditorWithRef };
@@ -0,0 +1,120 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Extensions } from "@tiptap/core";
import type { MutableRefObject } from "react";
import { forwardRef, 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 type { EditorRefApi, IDocumentEditorProps } from "@/types";
function DocumentEditor(props: IDocumentEditorProps) {
const {
bubbleMenuEnabled = false,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
extendedEditorProps,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id,
isTouchDevice,
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;
}, [disabledExtensions, editable, extendedEditorProps, fileHandler, flaggedExtensions, user]);
const editor = useEditor({
disabledExtensions,
editable,
editorClassName,
enableHistory: true,
extendedEditorProps,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
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")}
extendedEditorProps={extendedEditorProps}
id={id}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
isTouchDevice={!!isTouchDevice}
/>
);
}
const DocumentEditorWithRef = forwardRef(function DocumentEditorWithRef(
props: IDocumentEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return <DocumentEditor {...props} forwardedRef={ref as MutableRefObject<EditorRefApi | null>} />;
});
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditorWithRef };
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./collaborative-editor";
export * from "./editor";
export * from "./loader";
export * from "./page-renderer";
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
className?: string;
};
export function 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>
);
}
@@ -0,0 +1,122 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
// plane imports
import { cn } from "@plane/utils";
// components
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
import { BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import type { TCollabValue } from "@/contexts";
import type {
ICollaborativeDocumentEditorPropsExtended,
IEditorProps,
IEditorPropsExtended,
TAIHandler,
TDisplayConfig,
} from "@/types";
type Props = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
disabledExtensions: IEditorProps["disabledExtensions"];
displayConfig: TDisplayConfig;
documentLoaderClassName?: string;
editor: Editor;
titleEditor?: Editor;
editorContainerClassName: string;
extendedDocumentEditorProps?: ICollaborativeDocumentEditorPropsExtended;
extendedEditorProps: IEditorPropsExtended;
flaggedExtensions: IEditorProps["flaggedExtensions"];
id: string;
isLoading?: boolean;
isTouchDevice: boolean;
tabIndex?: number;
provider?: HocuspocusProvider;
state?: TCollabValue["state"];
};
export function PageRenderer(props: Props) {
const {
bubbleMenuEnabled,
disabledExtensions,
displayConfig,
documentLoaderClassName,
editor,
editorContainerClassName,
extendedEditorProps,
flaggedExtensions,
id,
isLoading,
isTouchDevice,
tabIndex,
titleEditor,
provider,
state,
} = props;
return (
<div
className={cn("frame-renderer w-full flex-grow", {
"wide-layout": displayConfig.wideLayout,
})}
>
{isLoading ? (
<DocumentContentLoader className={documentLoaderClassName} />
) : (
<>
{titleEditor && (
<div className="relative w-full py-3">
<EditorContainer
editor={titleEditor}
id={id + "-title"}
isTouchDevice={isTouchDevice}
editorContainerClassName="page-title-editor bg-transparent py-3 border-none"
displayConfig={displayConfig}
>
<EditorContentWrapper
editor={titleEditor}
id={id + "-title"}
tabIndex={tabIndex}
className="no-scrollbar placeholder-placeholder w-full resize-none rounded-none border-none bg-transparent p-0 text-[2rem] leading-[2.375rem] font-bold tracking-[-2%] outline-none"
/>
</EditorContainer>
</div>
)}
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
isTouchDevice={isTouchDevice}
provider={provider}
state={state}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && !isTouchDevice && (
<div>
{bubbleMenuEnabled && (
<EditorBubbleMenu
editor={editor}
disabledExtensions={disabledExtensions}
extendedEditorProps={extendedEditorProps}
flaggedExtensions={flaggedExtensions}
/>
)}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
/>
</div>
)}
</EditorContainer>
</>
)}
</div>
);
}
@@ -0,0 +1,185 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/react";
import type { ReactNode } from "react";
import { useCallback, useEffect, 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 type { TCollabValue } from "@/contexts";
import { LinkContainer } from "@/plane-editor/components/link-container";
// plugins
import { nodeHighlightPluginKey } from "@/plugins/highlight";
// types
import type { TDisplayConfig } from "@/types";
type Props = {
children: ReactNode;
displayConfig: TDisplayConfig;
editor: Editor;
editorContainerClassName: string;
id: string;
isTouchDevice: boolean;
provider?: HocuspocusProvider | undefined;
state?: TCollabValue["state"];
};
export function EditorContainer(props: Props) {
const { children, displayConfig, editor, editorContainerClassName, id, isTouchDevice, provider, state } = props;
// refs
const containerRef = useRef<HTMLDivElement>(null);
const hasScrolledOnce = useRef(false);
const scrollToNode = useCallback(
(nodeId: string) => {
if (!editor) return false;
const doc = editor.state.doc;
let pos: number | null = null;
doc.descendants((node, position) => {
if (node.attrs && node.attrs.id === nodeId) {
pos = position;
return false;
}
});
if (pos === null) {
return false;
}
const nodePosition = pos;
const tr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId });
editor.view.dispatch(tr);
requestAnimationFrame(() => {
const domNode = editor.view.nodeDOM(nodePosition);
if (domNode instanceof HTMLElement) {
domNode.scrollIntoView({ behavior: "instant", block: "center" });
}
});
editor.once("focus", () => {
const clearTr = editor.state.tr.setMeta(nodeHighlightPluginKey, { nodeId: null });
editor.view.dispatch(clearTr);
});
hasScrolledOnce.current = true;
return true;
},
[editor]
);
useEffect(() => {
const nodeId = window.location.href.split("#")[1];
const handleSynced = () => scrollToNode(nodeId);
if (nodeId && !hasScrolledOnce.current) {
if (provider && state) {
const { hasCachedContent } = state;
// If the provider is synced or the cached content is available and the server is disconnected, scroll to the node
if (hasCachedContent) {
const hasScrolled = handleSynced();
if (!hasScrolled) {
provider.on("synced", handleSynced);
}
} else if (provider.isSynced) {
handleSynced();
} else {
provider.on("synced", handleSynced);
}
} else {
handleSynced();
}
return () => {
if (provider) {
provider.off("synced", handleSynced);
}
};
}
}, [scrollToNode, provider, state]);
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 relative cursor-text line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
editorContainerClassName
)}
>
{children}
{!isTouchDevice && <LinkContainer editor={editor} containerRef={containerRef} />}
</div>
</>
);
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import type { ReactNode } from "react";
type Props = {
className?: string;
children?: ReactNode;
editor: Editor | null;
id: string;
tabIndex?: number;
};
export function EditorContentWrapper(props: Props) {
const { editor, className, children, tabIndex, id } = props;
return (
<div
tabIndex={tabIndex}
onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}
className={className}
>
<EditorContent editor={editor} id={id} />
{children}
</div>
);
}
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { 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 type { IEditorProps } from "@/types";
import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
editable: boolean;
extensions: Extensions;
};
export function EditorWrapper(props: Props) {
const {
children,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
editorProps,
extendedEditorProps,
extensions,
getEditorMetaData,
id,
initialValue,
isTouchDevice,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
onEditorFocus,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
showPlaceholderOnEmpty,
tabIndex,
value,
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editorClassName,
editorProps,
enableHistory: true,
extendedEditorProps,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
id,
isTouchDevice,
initialValue,
mentionHandler,
onChange,
onEditorFocus,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
showPlaceholderOnEmpty,
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>
);
}
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./document";
export * from "./lite-text";
export * from "./rich-text";
export * from "./editor-container";
export * from "./editor-content";
export * from "./editor-wrapper";
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
// components
import type { LinkViewProps } from "@/components/links";
import { LinkView } from "@/components/links";
type Props = {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
};
export function LinkViewContainer({ editor, containerRef }: Props) {
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>
)}
</>
);
}
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { forwardRef, useMemo } from "react";
// components
import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import type { EditorRefApi, ILiteTextEditorProps } from "@/types";
function LiteTextEditor(props: ILiteTextEditorProps) {
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(function LiteTextEditorWithRef(
props: ILiteTextEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return <LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
});
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
export { LiteTextEditorWithRef };
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";
@@ -0,0 +1,81 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 type { EditorRefApi, IRichTextEditorProps } from "@/types";
function RichTextEditor(props: IRichTextEditorProps) {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
extendedEditorProps,
workItemIdentifier,
} = 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
disabledExtensions={disabledExtensions}
editor={editor}
extendedEditorProps={extendedEditorProps}
flaggedExtensions={flaggedExtensions}
/>
)}
<BlockMenu
editor={editor}
flaggedExtensions={flaggedExtensions}
disabledExtensions={disabledExtensions}
workItemIdentifier={workItemIdentifier}
/>
</>
)}
</EditorWrapper>
);
}
const RichTextEditorWithRef = forwardRef(function RichTextEditorWithRef(
props: IRichTextEditorProps,
ref: React.ForwardedRef<EditorRefApi>
) {
return <RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />;
});
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
export { RichTextEditorWithRef };
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./link-edit-view";
export * from "./link-preview";
export * from "./link-view";
@@ -0,0 +1,158 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Node } from "@tiptap/pm/model";
import { Link2Off } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
// components
import type { 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;
};
function InputView({ label, value, placeholder, onChange, autoFocus }: InputViewProps) {
return (
<div className="flex flex-col gap-1">
<label className="inline-block text-11 font-semibold text-placeholder">{label}</label>
<input
placeholder={placeholder}
onClick={(e) => e.stopPropagation()}
className="w-[280px] rounded-md border border-strong bg-layer-1 p-2 text-13 text-primary outline-none"
value={value}
onChange={(e) => onChange(e.target.value)}
autoFocus={autoFocus}
/>
</div>
);
}
type LinkEditViewProps = {
viewProps: LinkViewProps;
switchView: (view: LinkViews) => void;
};
export function 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 animate-in fade-in flex translate-y-1 flex-col gap-3 rounded-sm border-2 border-subtle bg-layer-1 p-2"
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="bg-strong mb-1 h-[1px] w-full gap-2" />
<div className="flex items-center gap-2 text-13 text-secondary">
<Link2Off size={14} className="inline-block" />
<button onClick={removeLink} className="cursor-pointer transition-colors hover:text-placeholder">
Remove Link
</button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Link2Off } from "lucide-react";
import { CopyIcon, GlobeIcon, EditIcon } from "@plane/propel/icons";
// components
import type { LinkViewProps, LinkViews } from "@/components/links";
export function 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="animate-in fade-in absolute top-0 left-0 max-w-max translate-y-1"
style={{
transition: "all 0.2s cubic-bezier(.55, .085, .68, .53)",
}}
>
<div className="shadow-md flex items-center gap-3 rounded-sm border-2 border-subtle bg-layer-1 p-2 text-11 text-tertiary">
<GlobeIcon width={14} height={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 transition-colors hover:text-primary">
<CopyIcon width={14} height={14} className="inline-block" />
</button>
{editor.isEditable && (
<>
<button
onClick={() => switchView("LinkEditView")}
className="cursor-pointer transition-colors hover:text-primary"
>
<EditIcon width={14} height={14} className="inline-block" />
</button>
<button onClick={removeLink} className="cursor-pointer transition-colors hover:text-primary">
<Link2Off size={14} className="inline-block" />
</button>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/react";
import type { CSSProperties } from "react";
import { 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 function 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} />}
</>
);
}
@@ -0,0 +1,103 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import tippy from "tippy.js";
import type { Instance } from "tippy.js";
// plane utils
import { cn } from "@plane/utils";
// types
import type { TAIHandler } from "@/types";
type Props = {
menu: TAIHandler["menu"];
};
export function AIFeaturesMenu(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("pointer-events-none fixed inset-0 z-10 size-full opacity-0 transition-opacity", {
"pointer-events-auto opacity-100": isPopupVisible,
})}
>
<div ref={menuRef} className="z-10">
{menu?.({
isOpen: isPopupVisible,
onClose: hidePopup,
})}
</div>
</div>
);
}
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TableMap } from "@tiptap/pm/tables";
import type { Editor } from "@tiptap/react";
import { MoveHorizontal } from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import type { BlockMenuOption } from "./block-menu";
const findSelectedTable = (editor: Editor): { tableNode: ProseMirrorNode | null; tablePos: number } => {
const { state } = editor;
const selectedNode = state.selection.content().content.firstChild;
if (selectedNode?.type.name === CORE_EXTENSIONS.TABLE) {
return {
tableNode: selectedNode,
tablePos: state.selection.from,
};
}
return { tableNode: null, tablePos: -1 };
};
const setTableToFullWidth = (editor: Editor): void => {
try {
const { state, view } = editor;
// Find the selected table
const { tableNode, tablePos } = findSelectedTable(editor);
if (!tableNode) return;
// Get content width from CSS variable
const editorContainer = view.dom.closest(".editor-container");
if (!editorContainer) return;
const contentWidthVar = getComputedStyle(editorContainer).getPropertyValue("--editor-content-width").trim();
if (!contentWidthVar) return;
const contentWidth = parseInt(contentWidthVar);
if (isNaN(contentWidth) || contentWidth <= 0) return;
// Calculate equal width for each column
const map = TableMap.get(tableNode);
const equalWidth = Math.floor(contentWidth / map.width);
// Update all cell widths
const tr = state.tr;
const tableStart = tablePos + 1;
const updatedCells = new Set<number>();
for (let row = 0; row < map.height; row++) {
for (let col = 0; col < map.width; col++) {
const cellIndex = row * map.width + col;
const cellPos = map.map[cellIndex];
// Skip if cell already updated (for merged cells)
if (updatedCells.has(cellPos)) continue;
const cell = state.doc.nodeAt(tableStart + cellPos);
if (!cell) continue;
// Handle colspan for merged cells
const colspan = cell.attrs.colspan || 1;
tr.setNodeMarkup(tableStart + cellPos, null, {
...cell.attrs,
colwidth: Array(colspan).fill(equalWidth),
});
updatedCells.add(cellPos);
}
}
view.dispatch(tr);
} catch (error) {
console.error("Error setting table to full width:", error);
}
};
export const getNodeOptions = (editor: Editor): BlockMenuOption[] => [
{
icon: MoveHorizontal,
key: "table-full-width",
label: "Fit to width",
isDisabled: !editor.isActive(CORE_EXTENSIONS.TABLE),
onClick: () => setTableToFullWidth(editor),
},
];
@@ -0,0 +1,255 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useDismiss,
useInteractions,
FloatingPortal,
} from "@floating-ui/react";
import type { Editor } from "@tiptap/react";
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { CopyIcon, TrashIcon } from "@plane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import type { IEditorProps } from "@/types";
// components
import { getNodeOptions } from "./block-menu-options";
type Props = {
disabledExtensions?: IEditorProps["disabledExtensions"];
editor: Editor;
flaggedExtensions?: IEditorProps["flaggedExtensions"];
workItemIdentifier?: IEditorProps["workItemIdentifier"];
};
export type BlockMenuOption = {
icon: LucideIcon | React.FC<ISvgIcons>;
key: string;
label: string;
onClick: (e: React.MouseEvent) => void;
isDisabled?: boolean;
};
export function 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: BlockMenuOption[] = [
{
icon: TrashIcon,
key: "delete",
label: "Delete",
onClick: (_e) => {
// Execute the delete action
editor.chain().deleteSelection().focus().run();
},
},
{
icon: CopyIcon,
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);
}
}
},
},
...getNodeOptions(editor),
];
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-subtle bg-surface-1 p-1.5 shadow-raised-200",
"origin-top-right transform transition-all duration-300",
isAnimatedIn ? "scale-100 opacity-100" : "scale-75 opacity-0"
)}
{...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-sm px-1 py-1.5 text-11 text-secondary hover:bg-layer-1"
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>
);
}
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import type { LucideIcon } from "lucide-react";
import { AlignCenter, AlignLeft, AlignRight } from "lucide-react";
// plane utils
import { cn } from "@plane/utils";
// components
import { TextAlignItem } from "@/components/menus";
// types
import type { TEditorCommands } from "@/types";
import type { EditorStateType } from "./root";
type Props = {
editor: Editor;
editorState: EditorStateType;
};
export function TextAlignmentSelector(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(
"grid size-7 place-items-center rounded-sm text-tertiary transition-colors hover:bg-layer-1 active:bg-layer-1",
{
"bg-layer-1 text-primary": item.isActive(),
}
)}
>
<item.icon className="size-4" />
</button>
))}
</div>
);
}
@@ -0,0 +1,116 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { useMemo } 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 type { EditorStateType } from "./root";
type Props = {
editor: Editor;
editorState: EditorStateType;
};
export function BubbleMenuColorSelector(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-13 font-medium text-tertiary hover:bg-layer-1 active:bg-layer-1 rounded-sm transition-colors",
}}
menuButton={
<>
<span>Color</span>
<span
className={cn("grid size-6 flex-shrink-0 place-items-center rounded-sm border-[0.5px] border-strong", {
"bg-surface-1": !activeBackgroundColor,
})}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
>
<ALargeSmall
className={cn("size-3.5", {
"text-primary": !activeTextColor,
})}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
/>
</span>
</>
}
options={options}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
>
<section className="mt-1 space-y-2 rounded-md border-[0.5px] border-strong bg-surface-1 p-2 shadow-raised-200">
<div className="space-y-1.5">
<p className="text-11 font-semibold text-tertiary">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="size-6 flex-shrink-0 rounded-sm border-[0.5px] border-strong-1 transition-opacity hover:opacity-60"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="grid size-6 flex-shrink-0 place-items-center rounded-sm border-[0.5px] border-strong-1 text-tertiary transition-colors hover:bg-layer-1"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</div>
<div className="space-y-1.5">
<p className="text-11 font-semibold text-tertiary">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="size-6 flex-shrink-0 rounded-sm border-[0.5px] border-strong-1 transition-opacity hover:opacity-60"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="grid size-6 flex-shrink-0 place-items-center rounded-sm border-[0.5px] border-strong-1 text-tertiary transition-colors hover:bg-layer-1"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</div>
</section>
</FloatingMenuRoot>
);
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./color-selector";
export * from "./node-selector";
export * from "./root";
@@ -0,0 +1,128 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import { useCallback, useRef, useState } from "react";
import { LinkIcon, TrashIcon, CheckIcon } from "@plane/propel/icons";
// 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 function BubbleMenuLinkSelector(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(
"flex h-full items-center gap-1 rounded-sm px-3 text-13 font-medium whitespace-nowrap text-tertiary transition-colors hover:bg-layer-1 active:bg-layer-1",
{
"bg-layer-1": context.open,
"text-primary": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
),
}}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
menuButton={
<>
Link
<LinkIcon className="size-3 shrink-0" />
</>
}
options={options}
>
<div className="mt-1 w-60 rounded-md bg-surface-1 shadow-raised-200">
<div
className={cn("flex rounded-sm border-[0.5px] border-strong transition-colors", {
"border-danger-strong": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 rounded-sm border-r-[0.5px] border-strong bg-surface-1 px-1.5 py-2 text-11 outline-none placeholder:text-placeholder"
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-xs p-1 text-danger-primary transition-all hover:bg-danger-subtle-hover"
onClick={(e) => {
unsetLinkEditor(editor);
e.stopPropagation();
context.onOpenChange(false);
}}
>
<TrashIcon className="size-4" />
</button>
) : (
<button
type="button"
className="grid aspect-square h-full place-items-center rounded-xs p-1 text-tertiary transition-all hover:bg-layer-1"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<CheckIcon className="size-4" />
</button>
)}
</div>
{error && (
<p className="animate-in fade-in slide-in-from-top-0 pointer-events-none my-1 px-2 text-11 text-danger-primary">
Please enter a valid URL
</p>
)}
</div>
</FloatingMenuRoot>
);
}
@@ -0,0 +1,110 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/react";
import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons";
// plane utils
import { cn } from "@plane/utils";
// components
import type { EditorMenuItem } from "@/components/menus";
import {
BulletListItem,
HeadingOneItem,
HeadingThreeItem,
HeadingTwoItem,
NumberedListItem,
QuoteItem,
CodeItem,
TodoListItem,
TextItem,
HeadingFourItem,
HeadingFiveItem,
HeadingSixItem,
} 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 function BubbleMenuNodeSelector(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(
"flex h-full items-center gap-1 rounded-sm px-3 text-13 font-medium whitespace-nowrap text-tertiary transition-colors hover:bg-layer-1 active:bg-layer-1",
{
"bg-layer-1": context.open,
}
),
}}
menuButton={
<>
<span>{activeItem?.name}</span>
<ChevronDownIcon className="size-3 shrink-0" />
</>
}
options={options}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
>
<section className="mt-1 flex max-h-[90vh] w-48 flex-col overflow-y-scroll rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 shadow-raised-200">
{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-sm px-1 py-1.5 text-13 text-secondary hover:bg-layer-1",
{
"bg-layer-1": 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 && <CheckIcon className="size-3 flex-shrink-0 text-tertiary" />}
</button>
))}
</section>
</FloatingMenuRoot>
);
}
@@ -0,0 +1,234 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { isNodeSelection } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { BubbleMenu, useEditorState } from "@tiptap/react";
import type { BubbleMenuProps } from "@tiptap/react";
import { useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
import type { EditorMenuItem } from "@/components/menus";
import {
BackgroundColorItem,
BoldItem,
BubbleMenuColorSelector,
BubbleMenuNodeSelector,
CodeItem,
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 { IEditorPropsExtended, TEditorCommands, TExtensions } 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 = {
disabledExtensions: TExtensions[];
editor: Editor;
extendedEditorProps: IEditorPropsExtended;
flaggedExtensions: TExtensions[];
};
export function EditorBubbleMenu(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="horizontal-scrollbar flex scrollbar-xs divide-x divide-subtle-1 overflow-x-scroll rounded-lg border border-subtle bg-surface-1 py-2 shadow-raised-200"
>
<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(
"grid size-7 place-items-center rounded-sm text-tertiary transition-colors hover:bg-layer-1 active:bg-layer-1",
{
"bg-layer-1 text-primary": editorState[item.key],
}
)}
>
<item.icon className="size-4" />
</button>
))}
</div>
<TextAlignmentSelector editor={editor} editorState={editorState} />
</div>
)}
</BubbleMenu>
);
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { FloatingOverlay, FloatingPortal } from "@floating-ui/react";
import type { UseInteractionsReturn, 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 function FloatingMenuRoot(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>
)}
</>
);
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import {
shift,
flip,
useDismiss,
useFloating,
useInteractions,
autoUpdate,
useClick,
useRole,
} from "@floating-ui/react";
import type { UseInteractionsReturn, 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,
};
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./ai-menu";
export * from "./bubble-menu";
export * from "./block-menu";
export * from "./menu-items";
@@ -0,0 +1,286 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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,
MinusSquare,
Palette,
AlignCenter,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { LinkIcon } from "@plane/propel/icons";
// 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 type { TCommandWithProps, TEditorCommands } from "@/types";
import type { ISvgIcons } from "@plane/propel/icons";
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 | React.FC<ISvgIcons>;
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>[];
};
@@ -0,0 +1,249 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { LucideIcon } from "lucide-react";
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
CaseSensitive,
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Image,
Italic,
List,
ListOrdered,
ListTodo,
Strikethrough,
Table,
TextQuote,
Underline,
} from "lucide-react";
import type { 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)",
// },
];
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// types
import type { 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",
"text/markdown",
"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",
];
@@ -0,0 +1,97 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { EPageAccess } from "@plane/constants";
import type { TPage } from "@plane/types";
import type { 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;
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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",
UNIQUE_ID = "uniqueID",
}
export const BLOCK_NODE_TYPES = [
// Basic block nodes
CORE_EXTENSIONS.PARAGRAPH,
CORE_EXTENSIONS.HEADING,
CORE_EXTENSIONS.BLOCKQUOTE,
CORE_EXTENSIONS.CODE_BLOCK,
CORE_EXTENSIONS.HORIZONTAL_RULE,
// List nodes
CORE_EXTENSIONS.BULLET_LIST,
CORE_EXTENSIONS.ORDERED_LIST,
CORE_EXTENSIONS.LIST_ITEM,
CORE_EXTENSIONS.TASK_LIST,
CORE_EXTENSIONS.TASK_ITEM,
// Table nodes
CORE_EXTENSIONS.TABLE,
// Media and embed nodes
CORE_EXTENSIONS.IMAGE,
CORE_EXTENSIONS.CUSTOM_IMAGE,
CORE_EXTENSIONS.CALLOUT,
CORE_EXTENSIONS.WORK_ITEM_EMBED,
];
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum CORE_EDITOR_META {
SKIP_FILE_DELETION = "skipFileDeletion",
INTENTIONAL_DELETION = "intentionalDeletion",
ADD_TO_HISTORY = "addToHistory",
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { createContext, useContext } from "react";
// hooks
import { useYjsSetup } from "@/hooks/use-yjs-setup";
export type TCollabValue = NonNullable<ReturnType<typeof useYjsSetup>>;
const CollabContext = createContext<TCollabValue | null>(null);
type CollabProviderProps = Parameters<typeof useYjsSetup>[0] & {
fallback?: React.ReactNode;
children: React.ReactNode;
};
export function CollaborationProvider({ fallback = null, children, ...args }: CollabProviderProps) {
const setup = useYjsSetup(args);
// Only wait for provider setup, not content ready
// Consumers can check state.isDocReady to gate content rendering
if (!setup) {
return <>{fallback}</>;
}
return <CollabContext.Provider value={setup}>{children}</CollabContext.Provider>;
}
export function useCollaboration(): TCollabValue {
const ctx = useContext(CollabContext);
if (!ctx) {
throw new Error("useCollaboration must be used inside <CollaborationProvider>");
}
return ctx; // guaranteed non-null
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./collaboration-context";
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import { useState } from "react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import type { TCalloutBlockAttributes } from "./types";
import { ECalloutAttributeNames } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
export type CustomCalloutNodeViewProps = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
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
key={node.attrs[ECalloutAttributeNames.ID]}
className="editor-callout-component group/callout-node relative my-2 flex items-start gap-4 rounded-lg bg-layer-3 p-4 break-words text-primary transition-colors duration-500"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
key={node.attrs[ECalloutAttributeNames.ID]}
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>
);
}
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Ban } from "lucide-react";
import { ChevronDownIcon } from "@plane/propel/icons";
// 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 function CalloutBlockColorSelector(props: Props) {
const { disabled, isOpen, onSelect, toggleDropdown } = props;
const handleColorSelect = (val: string | null) => {
onSelect(val);
toggleDropdown();
};
return (
<div
className={cn("pointer-events-none absolute top-2 right-2 z-10 opacity-0 transition-opacity", {
"group-hover/callout-node:pointer-events-auto group-hover/callout-node:opacity-100": !disabled,
"pointer-events-auto opacity-100": isOpen,
})}
contentEditable={false}
>
<div className="relative">
<button
type="button"
onClick={(e) => {
toggleDropdown();
e.stopPropagation();
}}
className={cn(
"flex h-full items-center gap-1 rounded-sm px-2.5 py-1 text-13 font-medium whitespace-nowrap text-tertiary transition-colors hover:bg-layer-1-hover active:bg-layer-1-active",
{
"bg-layer-1": isOpen,
}
)}
disabled={disabled}
>
<span className="text-12">Color</span>
<ChevronDownIcon className="size-3 flex-shrink-0" />
</button>
{isOpen && (
<section className="animate-in fade-in slide-in-from-top-1 absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-strong bg-surface-1 p-2 shadow-raised-200">
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="size-6 flex-shrink-0 rounded-sm border-[0.5px] border-strong-1 transition-opacity hover:opacity-60"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect(color.key)}
/>
))}
<button
type="button"
className="grid size-6 flex-shrink-0 place-items-center rounded-sm border-[0.5px] border-strong-1 text-tertiary transition-colors hover:bg-layer-1-hover"
onClick={() => handleColorSelect(null)}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import type { MarkdownSerializerState } from "@tiptap/pm/markdown";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import { ECalloutAttributeNames } from "./types";
import type { CustomCalloutExtensionType, 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];
},
});
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "@tiptap/react";
import type { Predicate } 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 } from "./block";
import 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"]} />
));
},
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./extension";
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import type { TLogoProps } from "@plane/types";
import { cn } from "@plane/utils";
// types
import type { 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 function CalloutBlockLogoSelector(props: Props) {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TLogoProps = {
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}>
<EmojiPicker
closeOnSelect={true}
isOpen={isOpen}
handleToggle={handleOpen}
className="grid flex-shrink-0 place-items-center"
buttonClassName={cn("grid size-8 flex-shrink-0 place-items-center rounded-lg text-primary", {
"hover:bg-layer-1-hover": !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: TLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
},
};
if (val.type === "emoji") {
// val.value is now a string in decimal format (e.g. "128512")
const emojiValue = val.value;
newLogoValue = {
"data-emoji-unicode": emojiValue,
"data-emoji-url": undefined,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: emojiValue,
url: undefined,
},
};
} else if (val.type === "icon") {
const iconValue = val.value as { name: string; color: string };
newLogoValue = {
"data-icon-name": iconValue.name,
"data-icon-color": iconValue.color,
};
newLogoValueToStoreInLocalStorage = {
in_use: "icon",
icon: {
name: iconValue.name,
color: iconValue.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>
);
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Node as ProseMirrorNode } from "@tiptap/core";
export enum ECalloutAttributeNames {
ID = "id",
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.ID]: string | null;
[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>;
@@ -0,0 +1,91 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import type { TLogoProps } from "@plane/types";
import { sanitizeHTML } from "@plane/utils";
// types
import type { TCalloutBlockAttributes, TCalloutBlockEmojiAttributes, TCalloutBlockIconAttributes } from "./types";
import { ECalloutAttributeNames } from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
[ECalloutAttributeNames.ID]: null,
[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: TLogoProps;
try {
parsedData = JSON.parse(storedData) as TLogoProps;
} 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: TLogoProps): 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);
}
};
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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-sm bg-layer-3 px-[6px] py-[1.5px] font-code font-medium text-(--extended-color-orange-600) border-[0.5px] border-subtle",
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,
}),
];
},
});
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block";
import type { CodeBlockOptions } from "./code-block";
import { 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,
}),
];
},
});
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { CheckIcon } from "lucide-react";
import { useState } from "react";
import { CopyIcon } from "@plane/propel/icons";
// ui
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
// types
import type { TCodeBlockAttributes } from "./types";
import { ECodeBlockAttributeNames } from "./types";
// we just have ts support for now
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
type Props = {
node: ProseMirrorNode;
};
export function CodeBlockComponent({ node }: Props) {
const [copied, setCopied] = useState(false);
// derived values
const attrs = node.attrs as TCodeBlockAttributes;
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 key={attrs[ECodeBlockAttributeNames.ID]} className="code-block group/code relative">
<Tooltip tooltipContent="Copy code">
<button
type="button"
className={cn(
"group/button absolute top-2 right-2 z-10 hidden size-8 items-center justify-center rounded-md border border-subtle bg-layer-1 backdrop-blur-sm transition duration-150 ease-in-out group-hover/code:flex",
{
"bg-success-subtle hover:bg-success-subtle-1 active:bg-success-subtle-1": copied,
}
)}
onClick={(e) => void copyToClipboard(e)}
>
{copied ? (
<CheckIcon className="h-3 w-3 text-success-primary" strokeWidth={3} />
) : (
<CopyIcon className="h-3 w-3 text-tertiary group-hover/button:text-primary" />
)}
</button>
</Tooltip>
<pre className="my-2 rounded-lg bg-layer-3 p-4 text-primary">
<NodeViewContent as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);
}
@@ -0,0 +1,353 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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;
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;
}
},
},
}),
];
},
});
@@ -0,0 +1,130 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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: "",
},
});
@@ -0,0 +1,161 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// TODO: check all the type errors and fix them
import { findChildren } from "@tiptap/core";
import type { 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;
}
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export enum ECodeBlockAttributeNames {
ID = "id",
LANGUAGE = "language",
}
export type TCodeBlockAttributes = {
[ECodeBlockAttributeNames.ID]: string | null;
[ECodeBlockAttributeNames.LANGUAGE]: string | null;
};
@@ -0,0 +1,131 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import { 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;
}
@@ -0,0 +1,121 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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,
});
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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];
@@ -0,0 +1,155 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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(),
};
},
});
@@ -0,0 +1,355 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 { ECustomImageAttributeNames } from "../types";
import type { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString, getImageBlockId, isImageDuplicating } 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 function CustomImageBlock(props: CustomImageBlockProps) {
// 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,
status,
} = 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 as { isTouchDevice?: boolean } | undefined)?.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");
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]
);
const isDuplicating = isImageDuplicating(status);
// 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 && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || isDuplicating; // show the image upload status only when the resolvedImageSrc is not ready
const showUploadStatus = !resolvedImageSrc && !isDuplicating;
// 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 && !isDuplicating;
// 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 && !isDuplicating;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
return (
<div
id={
node.attrs[ECustomImageAttributeNames.ID]
? getImageBlockId(node.attrs[ECustomImageAttributeNames.ID])
: undefined
}
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 rounded-md bg-layer-1" style={{ width: size.width, height: size.height }} />
)}
<img
ref={imageRef}
src={displayedImageSrc}
alt=""
onLoad={handleImageLoad}
onError={(_e) =>
void (async () => {
// 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 (error) {
// if the image failed to even restore, then show the error state
setFailedToLoadImage(true);
console.error("Error while loading image", error);
} 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,
"loading-image opacity-80 blur-sm": !resolvedImageSrc,
})}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
}}
/>
{showUploadStatus && node.attrs[ECustomImageAttributeNames.ID] && (
<ImageUploadStatus editor={editor} nodeId={node.attrs[ECustomImageAttributeNames.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="pointer-events-none absolute inset-0 size-full bg-accent-primary/30" />
)}
{showImageResizer && (
<>
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-md border-2 border-accent-strong 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 size-4 translate-y-1/2 rounded-full border-2 border-white bg-accent-primary transition-opacity duration-100 ease-in-out",
{
"pointer-events-auto opacity-100": isResizing,
"pointer-events-none opacity-0 group-hover/image-component:pointer-events-auto group-hover/image-component:opacity-100":
!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>
);
}
@@ -0,0 +1,165 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// local imports
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { ECustomImageAttributeNames, ECustomImageStatus } from "../types";
import { hasImageDuplicationFailed } from "../utils";
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 function CustomImageNodeView(props: CustomImageNodeViewProps) {
const { editor, extension, node, updateAttributes } = props;
const { src: imgNodeSrc, status } = 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);
const hasRetriedOnMount = useRef(false);
const isDuplicatingRef = useRef(false);
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;
}
setResolvedSrc(undefined);
setResolvedDownloadSrc(undefined);
setFailedToLoadImage(false);
const getImageSource = async () => {
try {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
setResolvedDownloadSrc(downloadUrl);
} catch (error) {
console.error("Error fetching image source:", error);
setFailedToLoadImage(true);
}
};
void getImageSource();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imgNodeSrc, extension.options.getImageSource, extension.options.getImageDownloadSource]);
useEffect(() => {
const handleDuplication = async () => {
if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) {
return;
}
// Prevent duplicate calls - check if already duplicating this asset
if (isDuplicatingRef.current) {
return;
}
isDuplicatingRef.current = true;
try {
hasRetriedOnMount.current = true;
const newAssetId = await extension.options.duplicateImage(imgNodeSrc);
if (!newAssetId) {
throw new Error("Duplication returned invalid asset ID");
}
setFailedToLoadImage(false);
updateAttributes({ src: newAssetId, status: ECustomImageStatus.UPLOADED });
} catch (error: unknown) {
console.error("Failed to duplicate image:", error);
// Update status to failed
updateAttributes({ status: ECustomImageStatus.DUPLICATION_FAILED });
} finally {
isDuplicatingRef.current = false;
}
};
void handleDuplication();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]);
useEffect(() => {
if (hasImageDuplicationFailed(status) && !hasRetriedOnMount.current && imgNodeSrc) {
hasRetriedOnMount.current = true;
// Add a small delay before retrying to avoid immediate retries
updateAttributes({ status: ECustomImageStatus.DUPLICATING });
}
}, [status, imgNodeSrc, updateAttributes]);
useEffect(() => {
if (status === ECustomImageStatus.UPLOADED) {
hasRetriedOnMount.current = false;
setFailedToLoadImage(false);
}
}, [status]);
const hasDuplicationFailed = hasImageDuplicationFailed(status);
const hasValidImageSource = imageFromFileSystem || (isUploaded && resolvedSrc);
const shouldShowBlock = hasValidImageSource && !failedToLoadImage && !hasDuplicationFailed;
return (
<NodeViewWrapper key={node.attrs[ECustomImageAttributeNames.ID]}>
<div className="mx-0 my-2 p-0" data-drag-handle ref={imageComponentRef}>
{shouldShowBlock && !hasDuplicationFailed ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
downloadSrc={resolvedDownloadSrc}
{...props}
/>
) : (
<CustomImageUploader
failedToLoadImage={failedToLoadImage}
hasDuplicationFailed={hasDuplicationFailed}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={(editor.storage.imageComponent as { maxFileSize?: number } | undefined)?.maxFileSize ?? 0}
setIsUploaded={setIsUploaded}
{...props}
/>
)}
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef, useState } from "react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { ChevronDownIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// 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 function ImageAlignmentAction(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="relative h-full">
<Tooltip disabled={isTouchDevice} tooltipContent="Align">
<button
type="button"
className="flex h-full items-center gap-1 text-white/60 transition-colors hover:text-white"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{activeAlignmentDetails && <activeAlignmentDetails.icon className="size-3 flex-shrink-0" />}
<ChevronDownIcon className="size-2 flex-shrink-0" />
</button>
</Tooltip>
{isDropdownOpen && (
<div className="absolute top-full left-1/2 mt-0.5 flex h-7 -translate-x-1/2 items-center gap-2 rounded-sm bg-black/80 px-2">
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
<Tooltip disabled={isTouchDevice} key={option.value} tooltipContent={option.label}>
<button
type="button"
className="grid h-full flex-shrink-0 place-items-center text-white/60 transition-colors hover:text-white"
onClick={() => {
handleChange(option.value);
setIsDropdownOpen(false);
}}
>
<option.icon className="size-3" />
</button>
</Tooltip>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Download } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
type Props = {
src: string;
};
export function ImageDownloadAction(props: Props) {
const { src } = props;
return (
<Tooltip tooltipContent="Download">
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="grid h-full flex-shrink-0 place-items-center text-white/60 transition-colors hover:text-white"
aria-label="Download image"
>
<Download className="size-3" />
</button>
</Tooltip>
);
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
@@ -0,0 +1,312 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Download, Minus } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { NewTabIcon, PlusIcon, CloseIcon } from "@plane/propel/icons";
// 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;
};
function 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("pointer-events-none fixed inset-0 z-50 size-full bg-black/90 opacity-0 transition-opacity", {
"editor-image-full-screen-modal pointer-events-auto opacity-100": 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 grid size-full place-items-center overflow-hidden"
>
<button
type="button"
onClick={handleClose}
className="absolute top-10 right-10 grid size-8 place-items-center"
aria-label="Close image viewer"
>
<CloseIcon className="size-8 text-white/60 transition-colors hover:text-white" />
</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 flex -translate-x-1/2 items-center justify-center gap-1 divide-x divide-subtle-1 rounded-md border border-subtle-1 bg-black py-2">
<div className="flex items-center">
<button
type="button"
onClick={(e) => {
if (isTouchDevice) {
e.preventDefault();
e.stopPropagation();
}
handleMagnification("decrease");
}}
className="grid size-6 place-items-center text-white/60 transition-colors duration-200 hover:text-white disabled:text-white/30"
disabled={magnification <= MIN_ZOOM}
aria-label="Zoom out"
>
<Minus className="size-4" />
</button>
<span className="w-12 text-center text-13 text-white">{Math.round(100 * magnification)}%</span>
<button
type="button"
onClick={(e) => {
if (isTouchDevice) {
e.preventDefault();
e.stopPropagation();
}
handleMagnification("increase");
}}
className="grid size-6 place-items-center text-white/60 transition-colors duration-200 hover:text-white disabled:text-white/30"
disabled={magnification >= MAX_ZOOM}
aria-label="Zoom in"
>
<PlusIcon className="size-4" />
</button>
</div>
{!isTouchDevice && (
<button
type="button"
onClick={() => window.open(downloadSrc, "_blank")}
className="grid size-8 flex-shrink-0 place-items-center text-white/60 transition-colors duration-200 hover:text-white"
aria-label="Download image"
>
<Download className="size-4" />
</button>
)}
{!isTouchDevice && (
<button
type="button"
onClick={() => window.open(src, "_blank")}
className="grid size-8 flex-shrink-0 place-items-center text-white/60 transition-colors duration-200 hover:text-white"
aria-label="Open image in new tab"
>
<NewTabIcon className="size-4" />
</button>
)}
</div>
</div>
</div>
);
}
export function ImageFullScreenModal(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;
}
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Maximize } from "lucide-react";
import { useEffect, useState } from "react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
// 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 function ImageFullScreenActionRoot(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="grid h-full flex-shrink-0 place-items-center text-on-color/60 transition-colors hover:text-on-color"
aria-label="View image in full screen"
>
<Maximize className="size-3" />
</button>
</Tooltip>
</>
);
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 function ImageToolbarRoot(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(
"pointer-events-none absolute top-1 right-1 z-20 flex h-7 items-center gap-2 rounded-sm bg-black/80 px-2 opacity-0 transition-opacity group-hover/image-component:pointer-events-auto group-hover/image-component:opacity-100",
{
"pointer-events-auto opacity-100": shouldShowToolbar,
}
)}
>
{!isTouchDevice && <ImageDownloadAction src={downloadSrc} />}
{isEditable && (
<ImageAlignmentAction
activeAlignment={alignment}
handleChange={handleAlignmentChange}
isTouchDevice={isTouchDevice}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
)}
<ImageFullScreenActionRoot
image={props}
isTouchDevice={isTouchDevice}
toggleToolbarViewStatus={setShouldShowToolbar}
/>
</div>
</>
);
}
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
type Props = {
editor: Editor;
nodeId: string;
};
export function ImageUploadStatus(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 w-10 rounded-sm bg-black/60 text-center text-11 font-medium">
{displayStatus}%
</div>
);
}
@@ -0,0 +1,267 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ImageIcon, RotateCcw } from "lucide-react";
import type { ChangeEvent } from "react";
import { 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 type { EFileError } from "@/helpers/file";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { ECustomImageStatus } from "../types";
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
hasDuplicationFailed: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
};
export function CustomImageUploader(props: CustomImageUploaderProps) {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
maxFileSize,
node,
selected,
setIsUploaded,
updateAttributes,
hasDuplicationFailed,
} = props;
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const hasTriedUploadingOnMountRef = 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,
status: ECustomImageStatus.UPLOADED,
});
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) => {
updateAttributes({ status: ECustomImageStatus.UPLOADING });
return await extension.options.uploadImage?.(imageEntityId ?? "", file);
},
[extension.options, imageEntityId, updateAttributes]
);
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,
});
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
if (hasTriedUploadingOnMountRef.current) return;
// the meta data of the image component
const meta = imageComponentImageFileMap?.get(imageEntityId ?? "");
if (meta) {
if (meta.event === "drop" && "file" in meta) {
hasTriedUploadingOnMountRef.current = true;
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 });
}
} else {
hasTriedUploadingOnMountRef.current = true;
}
}, [imageEntityId, isTouchDevice, uploadFile, imageComponentImageFileMap]);
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 isErrorState = failedToLoadImage || hasDuplicationFailed;
const borderColor =
selected && editor.isEditable && !isErrorState
? "color-mix(in srgb, var(--border-color-accent-strong) 20%, transparent)"
: undefined;
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded;
if (isErrorState) {
return "Error loading image";
}
if (isUploading) {
return "Uploading...";
}
if (draggedInside && editor.isEditable) {
return "Drop image here";
}
return "Add an image";
}, [draggedInside, editor.isEditable, isErrorState, isImageBeingUploaded]);
const handleRetryClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (hasDuplicationFailed && editor.isEditable) {
updateAttributes({ status: ECustomImageStatus.DUPLICATING });
}
},
[hasDuplicationFailed, editor.isEditable, updateAttributes]
);
return (
<div
className={cn(
"image-upload-component flex cursor-default items-center justify-start gap-2 rounded-lg border border-dashed bg-layer-3 px-2 py-3 text-tertiary transition-all duration-200 ease-in-out",
{
"border-subtle": !(selected && editor.isEditable && !isErrorState),
"cursor-pointer hover:bg-layer-3-hover hover:text-secondary": editor.isEditable && !isErrorState,
"bg-layer-3-hover text-secondary": draggedInside && editor.isEditable && !isErrorState,
"bg-accent-primary/10 text-accent-secondary hover:bg-accent-primary/10 hover:text-accent-secondary":
selected && editor.isEditable && !isErrorState,
"cursor-default bg-danger-subtle text-danger-primary": isErrorState,
"hover:bg-danger-subtle-hover hover:text-danger-primary": isErrorState && editor.isEditable,
"bg-danger-subtle-selected": isErrorState && selected,
"hover:bg-danger-subtle-active": isErrorState && selected && editor.isEditable,
}
)}
style={borderColor ? { borderColor } : undefined}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage && editor.isEditable && !hasDuplicationFailed) {
fileInputRef.current?.click();
}
}}
>
<ImageIcon className="size-4" />
<div className="flex-1 text-14 font-medium">{getDisplayMessage()}</div>
{hasDuplicationFailed && editor.isEditable && (
<button
type="button"
onClick={handleRetryClick}
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 font-medium text-danger-primary transition-all duration-200 ease-in-out hover:bg-danger-subtle-hover",
{
"hover:bg-danger-subtle-hover": selected,
}
)}
title="Retry duplication"
>
<RotateCcw className="size-3" />
<span className="text-11">Retry</span>
</button>
)}
<input
className="size-0 overflow-hidden"
ref={fileInputRef}
hidden
type="file"
accept={ACCEPTED_IMAGE_MIME_TYPES.join(",")}
onChange={onFileChange}
multiple
/>
</div>
);
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { ECustomImageAttributeNames } from "./types";
import type {
CustomImageExtensionOptions,
TCustomImageAttributes,
CustomImageExtensionType,
CustomImageExtensionStorage,
InsertImageComponentProps,
} 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)];
},
});
@@ -0,0 +1,134 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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 type { CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
import { ECustomImageAttributeNames, ECustomImageStatus } from "./types";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler;
isEditable: boolean;
};
export function 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;
const duplicate = "duplicate" in fileHandler ? fileHandler.duplicate : undefined;
return {
...this.parent?.(),
getImageDownloadSource: getAssetDownloadSrc,
getImageSource: getAssetSrc,
restoreImage: restoreImageFn,
uploadImage: upload,
duplicateImage: duplicate,
};
},
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 = {
[ECustomImageAttributeNames.ID]: fileId,
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING,
};
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"]} />
));
},
});
}
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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",
STATUS = "status",
}
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 enum ECustomImageStatus {
PENDING = "pending",
UPLOADING = "uploading",
UPLOADED = "uploaded",
DUPLICATING = "duplicating",
DUPLICATION_FAILED = "duplication-failed",
}
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;
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus;
};
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"];
duplicateImage?: TFileHandler["duplicate"];
};
export type CustomImageExtensionStorage = {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
};
export type CustomImageExtensionType = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
// local imports
import { ECustomImageAttributeNames, ECustomImageStatus } from "./types";
import type { TCustomImageAlignment, Pixel, 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",
[ECustomImageAttributeNames.STATUS]: ECustomImageStatus.PENDING,
};
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}`;
export const isImageDuplicating = (status: ECustomImageStatus) => status === ECustomImageStatus.DUPLICATING;
export const isImageDuplicationComplete = (status: ECustomImageStatus) =>
status === ECustomImageStatus.UPLOADED || status === ECustomImageStatus.DUPLICATION_FAILED;
export const hasImageDuplicationFailed = (status: ECustomImageStatus) =>
status === ECustomImageStatus.DUPLICATION_FAILED;
@@ -0,0 +1,275 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { PasteRuleMatch } from "@tiptap/core";
import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core";
import type { 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-accent-secondary underline underline-offset-[3px] hover:text-accent-primary 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 },
};
},
});
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { NodeWithPos } from "@tiptap/core";
import { combineTransactionSteps, findChildrenInRange, getChangedRanges, getMarksBetween } from "@tiptap/core";
import type { 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 let’s 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;
},
});
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { getAttributes } from "@tiptap/core";
import type { 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;
},
},
});
}

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