base
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
@@ -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
|
||||
@@ -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;
|
||||
+169
@@ -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>
|
||||
);
|
||||
}
|
||||
+89
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+63
@@ -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;
|
||||
};
|
||||
+131
@@ -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>
|
||||
);
|
||||
}
|
||||
+70
@@ -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>
|
||||
);
|
||||
}
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
@@ -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";
|
||||
+312
@@ -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;
|
||||
}
|
||||
+64
@@ -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";
|
||||
+63
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+66
@@ -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
Reference in New Issue
Block a user