snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
10
packages/editor/.prettierignore
Normal file
10
packages/editor/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.next/
|
||||
.react-router/
|
||||
.turbo/
|
||||
.vite/
|
||||
build/
|
||||
dist/
|
||||
node_modules/
|
||||
out/
|
||||
pnpm-lock.yaml
|
||||
storybook-static/
|
||||
82
packages/editor/Readme.md
Normal file
82
packages/editor/Readme.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# @plane/editor
|
||||
|
||||
## Description
|
||||
|
||||
The `@plane/editor` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors.
|
||||
|
||||
## Utilities
|
||||
|
||||
We provide a wide range of utilities for extending the core itself.
|
||||
|
||||
1. Merging classes and custom styling
|
||||
2. Adding new extensions
|
||||
3. Adding custom props
|
||||
4. Base menu items, and their commands
|
||||
|
||||
This allows for extensive customization and flexibility in the Editors created using our `editor-core` package.
|
||||
|
||||
### Here's a detailed overview of what's exported
|
||||
|
||||
1. useEditor - A hook that you can use to extend the Plane editor.
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
|
||||
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
|
||||
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
|
||||
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
|
||||
| `value` | `html string` | The initial content of the editor. |
|
||||
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
|
||||
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
|
||||
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
|
||||
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
|
||||
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
|
||||
|
||||
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------- | ------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `value` | `string` | The initial content of the editor. |
|
||||
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
|
||||
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
|
||||
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
|
||||
|
||||
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
|
||||
|
||||
4. UI Wrappers
|
||||
|
||||
- `EditorContainer` - Wrap your Editor Container with this to apply base classes and styles.
|
||||
- `EditorContentWrapper` - Use this to get Editor's Content and base menus.
|
||||
|
||||
5. Extending with Custom Styles
|
||||
|
||||
```ts
|
||||
const customEditorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
});
|
||||
```
|
||||
|
||||
## Core features
|
||||
|
||||
- **Content Trimming**: The Editor’s content is now automatically trimmed of empty line breaks from the start and end before submitting it to the backend. This ensures cleaner, more consistent data.
|
||||
- **Value Cleaning**: The Editor’s value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors.
|
||||
- **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package.
|
||||
|
||||
## Base extensions included
|
||||
|
||||
- BulletList
|
||||
- OrderedList
|
||||
- Blockquote
|
||||
- Code
|
||||
- Gapcursor
|
||||
- Link
|
||||
- Image
|
||||
- Basic Marks
|
||||
- Underline
|
||||
- TextStyle
|
||||
- Color
|
||||
- TaskList
|
||||
- Markdown
|
||||
- Table
|
||||
102
packages/editor/package.json
Normal file
102
packages/editor/package.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "1.4.2",
|
||||
"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": "catalog:",
|
||||
"@floating-ui/react": "catalog:",
|
||||
"@headlessui/react": "catalog:",
|
||||
"@hocuspocus/provider": "catalog:",
|
||||
"@makeplane/propel": "catalog:",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@tiptap/core": "catalog:",
|
||||
"@tiptap/extension-blockquote": "catalog:",
|
||||
"@tiptap/extension-character-count": "catalog:",
|
||||
"@tiptap/extension-collaboration": "catalog:",
|
||||
"@tiptap/extension-document": "catalog:",
|
||||
"@tiptap/extension-emoji": "catalog:",
|
||||
"@tiptap/extension-heading": "catalog:",
|
||||
"@tiptap/extension-image": "catalog:",
|
||||
"@tiptap/extension-list-item": "catalog:",
|
||||
"@tiptap/extension-mention": "catalog:",
|
||||
"@tiptap/extension-placeholder": "catalog:",
|
||||
"@tiptap/extension-task-item": "catalog:",
|
||||
"@tiptap/extension-task-list": "catalog:",
|
||||
"@tiptap/extension-text": "catalog:",
|
||||
"@tiptap/extension-text-align": "catalog:",
|
||||
"@tiptap/extension-text-style": "catalog:",
|
||||
"@tiptap/extension-underline": "catalog:",
|
||||
"@tiptap/html": "catalog:",
|
||||
"@tiptap/pm": "catalog:",
|
||||
"@tiptap/react": "catalog:",
|
||||
"@tiptap/starter-kit": "catalog:",
|
||||
"@tiptap/suggestion": "catalog:",
|
||||
"buffer": "catalog:",
|
||||
"emoji-regex": "catalog:",
|
||||
"highlight.js": "catalog:",
|
||||
"is-emoji-supported": "catalog:",
|
||||
"jsx-dom-cjs": "catalog:",
|
||||
"linkifyjs": "catalog:",
|
||||
"lodash-es": "catalog:",
|
||||
"lowlight": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"prosemirror-codemark": "catalog:",
|
||||
"tippy.js": "catalog:",
|
||||
"tiptap-markdown": "catalog:",
|
||||
"uuid": "catalog:",
|
||||
"y-indexeddb": "catalog:",
|
||||
"y-prosemirror": "catalog:",
|
||||
"y-protocols": "catalog:",
|
||||
"yjs": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"postcss": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
3
packages/editor/postcss.config.js
Normal file
3
packages/editor/postcss.config.js
Normal file
@@ -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,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 };
|
||||
120
packages/editor/src/components/editors/document/editor.tsx
Normal file
120
packages/editor/src/components/editors/document/editor.tsx
Normal file
@@ -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 "@/extensions/document-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 };
|
||||
10
packages/editor/src/components/editors/document/index.ts
Normal file
10
packages/editor/src/components/editors/document/index.ts
Normal file
@@ -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";
|
||||
57
packages/editor/src/components/editors/document/loader.tsx
Normal file
57
packages/editor/src/components/editors/document/loader.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
185
packages/editor/src/components/editors/editor-container.tsx
Normal file
185
packages/editor/src/components/editors/editor-container.tsx
Normal file
@@ -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 "@/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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
packages/editor/src/components/editors/editor-content.tsx
Normal file
32
packages/editor/src/components/editors/editor-content.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
104
packages/editor/src/components/editors/editor-wrapper.tsx
Normal file
104
packages/editor/src/components/editors/editor-wrapper.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
12
packages/editor/src/components/editors/index.ts
Normal file
12
packages/editor/src/components/editors/index.ts
Normal file
@@ -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";
|
||||
209
packages/editor/src/components/editors/link-view-container.tsx
Normal file
209
packages/editor/src/components/editors/link-view-container.tsx
Normal file
@@ -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 | null>;
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
packages/editor/src/components/editors/lite-text/editor.tsx
Normal file
40
packages/editor/src/components/editors/lite-text/editor.tsx
Normal file
@@ -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";
|
||||
81
packages/editor/src/components/editors/rich-text/editor.tsx
Normal file
81
packages/editor/src/components/editors/rich-text/editor.tsx
Normal file
@@ -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 "@/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";
|
||||
22
packages/editor/src/components/link-container.tsx
Normal file
22
packages/editor/src/components/link-container.tsx
Normal file
@@ -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 | null>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<LinkViewContainer editor={editor} containerRef={containerRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
packages/editor/src/components/links/index.ts
Normal file
9
packages/editor/src/components/links/index.ts
Normal file
@@ -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";
|
||||
158
packages/editor/src/components/links/link-edit-view.tsx
Normal file
158
packages/editor/src/components/links/link-edit-view.tsx
Normal file
@@ -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 { UnlinkOutline } from "@makeplane/propel/icons";
|
||||
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">
|
||||
<UnlinkOutline width={14} height={14} className="inline-block" />
|
||||
<button onClick={removeLink} className="cursor-pointer transition-colors hover:text-placeholder">
|
||||
Remove Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
packages/editor/src/components/links/link-preview.tsx
Normal file
61
packages/editor/src/components/links/link-preview.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { CopyOutline, EditOutline, GlobeOutline, UnlinkOutline } from "@makeplane/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">
|
||||
<GlobeOutline 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">
|
||||
<CopyOutline width={14} height={14} className="inline-block" />
|
||||
</button>
|
||||
{editor.isEditable && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => switchView("LinkEditView")}
|
||||
className="cursor-pointer transition-colors hover:text-primary"
|
||||
>
|
||||
<EditOutline width={14} height={14} className="inline-block" />
|
||||
</button>
|
||||
<button onClick={removeLink} className="cursor-pointer transition-colors hover:text-primary">
|
||||
<UnlinkOutline width={14} height={14} className="inline-block" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
packages/editor/src/components/links/link-view.tsx
Normal file
46
packages/editor/src/components/links/link-view.tsx
Normal file
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
packages/editor/src/components/menus/ai-menu.tsx
Normal file
103
packages/editor/src/components/menus/ai-menu.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
93
packages/editor/src/components/menus/block-menu-options.tsx
Normal file
93
packages/editor/src/components/menus/block-menu-options.tsx
Normal file
@@ -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 { DragDropOutline } from "@makeplane/propel/icons";
|
||||
// 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: DragDropOutline,
|
||||
key: "table-full-width",
|
||||
label: "Fit to width",
|
||||
isDisabled: !editor.isActive(CORE_EXTENSIONS.TABLE),
|
||||
onClick: () => setTableToFullWidth(editor),
|
||||
},
|
||||
];
|
||||
255
packages/editor/src/components/menus/block-menu.tsx
Normal file
255
packages/editor/src/components/menus/block-menu.tsx
Normal file
@@ -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 { CopyOutline, DeleteOutline } from "@makeplane/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: DeleteOutline,
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
onClick: (_e) => {
|
||||
// Execute the delete action
|
||||
editor.chain().deleteSelection().focus().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: CopyOutline,
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
isDisabled:
|
||||
editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
|
||||
onClick: (_e) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const firstChild = selection.content().content.firstChild;
|
||||
const docSize = state.doc.content.size;
|
||||
|
||||
if (!firstChild) {
|
||||
throw new Error("No content selected or content is not duplicable.");
|
||||
}
|
||||
|
||||
// Directly use selection.to as the insertion position
|
||||
const insertPos = selection.to;
|
||||
|
||||
// Ensure the insertion position is within the document's bounds
|
||||
if (insertPos < 0 || insertPos > docSize) {
|
||||
throw new Error("The insertion position is invalid or outside the document.");
|
||||
}
|
||||
|
||||
const contentToInsert = firstChild.toJSON();
|
||||
|
||||
// Insert the content at the calculated position
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(insertPos, contentToInsert, {
|
||||
updateSelection: true,
|
||||
})
|
||||
.focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false })
|
||||
.run();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
...getNodeOptions(editor),
|
||||
];
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={(node) => {
|
||||
refs.setFloating(node);
|
||||
menuRef.current = node;
|
||||
}}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
animationFillMode: "forwards",
|
||||
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
|
||||
zIndex: 100,
|
||||
}}
|
||||
className={cn(
|
||||
"max-h-60 min-w-[7rem] overflow-y-scroll rounded-lg border border-subtle bg-surface-1 p-1.5 shadow-raised-200",
|
||||
"origin-top-right transform transition-all duration-300",
|
||||
isAnimatedIn ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
)}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (item.isDisabled) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 truncate rounded-sm px-1 py-1.5 text-11 text-secondary hover:bg-layer-1"
|
||||
onClick={(e) => {
|
||||
item.onClick(e);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeBlockMenu();
|
||||
}}
|
||||
disabled={item.isDisabled}
|
||||
>
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import { AlignCenterOutline, AlignLeftOutline, AlignRightOutline } from "@makeplane/propel/icons";
|
||||
// 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: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
command: () => void;
|
||||
isActive: () => boolean;
|
||||
}[] = [
|
||||
{
|
||||
itemKey: "text-align",
|
||||
renderKey: "text-align-left",
|
||||
icon: AlignLeftOutline,
|
||||
command: () =>
|
||||
menuItem.command({
|
||||
alignment: "left",
|
||||
}),
|
||||
isActive: () => editorState.left,
|
||||
},
|
||||
{
|
||||
itemKey: "text-align",
|
||||
renderKey: "text-align-center",
|
||||
icon: AlignCenterOutline,
|
||||
command: () =>
|
||||
menuItem.command({
|
||||
alignment: "center",
|
||||
}),
|
||||
isActive: () => editorState.center,
|
||||
},
|
||||
{
|
||||
itemKey: "text-align",
|
||||
renderKey: "text-align-right",
|
||||
icon: AlignRightOutline,
|
||||
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,117 @@
|
||||
/**
|
||||
* 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 { Ban } from "lucide-react";
|
||||
import { TextOutline } from "@makeplane/propel/icons";
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<TextOutline
|
||||
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 { DeleteOutline, LinkOutline, TickOutline } from "@makeplane/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
|
||||
<LinkOutline 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);
|
||||
}}
|
||||
>
|
||||
<DeleteOutline 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();
|
||||
}}
|
||||
>
|
||||
<TickOutline 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 { ChevronDownOutline, TickOutline } from "@makeplane/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>
|
||||
<ChevronDownOutline 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 && <TickOutline className="size-3 flex-shrink-0 text-tertiary" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
</FloatingMenuRoot>
|
||||
);
|
||||
}
|
||||
234
packages/editor/src/components/menus/bubble-menu/root.tsx
Normal file
234
packages/editor/src/components/menus/bubble-menu/root.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
67
packages/editor/src/components/menus/floating-menu/root.tsx
Normal file
67
packages/editor/src/components/menus/floating-menu/root.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { FloatingOverlay, FloatingPortal } from "@floating-ui/react";
|
||||
import type { UseInteractionsReturn, UseFloatingReturn } from "@floating-ui/react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
classNames?: {
|
||||
buttonContainer?: string;
|
||||
button?: string;
|
||||
};
|
||||
getFloatingProps: UseInteractionsReturn["getFloatingProps"];
|
||||
getReferenceProps: UseInteractionsReturn["getReferenceProps"];
|
||||
menuButton: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
options: UseFloatingReturn;
|
||||
};
|
||||
|
||||
export function FloatingMenuRoot(props: Props) {
|
||||
const { children, classNames, getFloatingProps, getReferenceProps, menuButton, onClick, options } = props;
|
||||
// derived values
|
||||
const { refs, floatingStyles, context } = options;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames?.buttonContainer}>
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
className={classNames?.button}
|
||||
onClick={(e) => {
|
||||
context.onOpenChange(!context.open);
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{menuButton}
|
||||
</button>
|
||||
</div>
|
||||
{context.open && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
shift,
|
||||
flip,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
autoUpdate,
|
||||
useClick,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import type { UseInteractionsReturn, UseFloatingReturn } from "@floating-ui/react";
|
||||
import { useState } from "react";
|
||||
|
||||
type TArgs = {
|
||||
handleOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type TReturn = {
|
||||
options: UseFloatingReturn;
|
||||
getReferenceProps: UseInteractionsReturn["getReferenceProps"];
|
||||
getFloatingProps: UseInteractionsReturn["getFloatingProps"];
|
||||
};
|
||||
|
||||
export const useFloatingMenu = (args: TArgs): TReturn => {
|
||||
const { handleOpenChange } = args;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// floating ui
|
||||
const options = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: (open) => {
|
||||
setIsDropdownOpen(open);
|
||||
handleOpenChange?.(open);
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const { context } = options;
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
return {
|
||||
options,
|
||||
getReferenceProps,
|
||||
getFloatingProps,
|
||||
};
|
||||
};
|
||||
10
packages/editor/src/components/menus/index.ts
Normal file
10
packages/editor/src/components/menus/index.ts
Normal file
@@ -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";
|
||||
285
packages/editor/src/components/menus/menu-items.ts
Normal file
285
packages/editor/src/components/menus/menu-items.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 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 {
|
||||
AlignCenterOutline,
|
||||
BoldOutline,
|
||||
CheckSquareOutline,
|
||||
CodeOutline,
|
||||
H1Outline,
|
||||
H2Outline,
|
||||
H3Outline,
|
||||
H4Outline,
|
||||
H5Outline,
|
||||
H6Outline,
|
||||
ImageOutline,
|
||||
ItalicOutline,
|
||||
LinkOutline,
|
||||
ListOutline,
|
||||
MinusSquareOutline,
|
||||
NumberedListOutline,
|
||||
PaletteOutline,
|
||||
QuoteOutline,
|
||||
StrikethroughOutline,
|
||||
TableOutline,
|
||||
TextOutline,
|
||||
UnderlineOutline,
|
||||
} from "@makeplane/propel/icons";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import {
|
||||
insertHorizontalRule,
|
||||
insertImage,
|
||||
insertTableCommand,
|
||||
setLinkEditor,
|
||||
setText,
|
||||
setTextAlign,
|
||||
toggleBackgroundColor,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleHeading,
|
||||
toggleItalic,
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
toggleTaskList,
|
||||
toggleTextColor,
|
||||
toggleUnderline,
|
||||
unsetLinkEditor,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import type { TCommandWithProps, TEditorCommands } from "@/types";
|
||||
type isActiveFunction<T extends TEditorCommands> = (params?: TCommandWithProps<T>) => boolean;
|
||||
type commandFunction<T extends TEditorCommands> = (params?: TCommandWithProps<T>) => void;
|
||||
|
||||
export type EditorMenuItem<T extends TEditorCommands> = {
|
||||
key: T;
|
||||
name: string;
|
||||
command: commandFunction<T>;
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
isActive: isActiveFunction<T>;
|
||||
};
|
||||
|
||||
export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH),
|
||||
command: () => setText(editor),
|
||||
icon: TextOutline,
|
||||
});
|
||||
|
||||
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: ComponentType<SVGProps<SVGSVGElement>>
|
||||
): 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", H1Outline);
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> =>
|
||||
HeadingItem(editor, 2, "h2", "Heading 2", H2Outline);
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> =>
|
||||
HeadingItem(editor, 3, "h3", "Heading 3", H3Outline);
|
||||
|
||||
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> =>
|
||||
HeadingItem(editor, 4, "h4", "Heading 4", H4Outline);
|
||||
|
||||
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> =>
|
||||
HeadingItem(editor, 5, "h5", "Heading 5", H5Outline);
|
||||
|
||||
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> =>
|
||||
HeadingItem(editor, 6, "h6", "Heading 6", H6Outline);
|
||||
|
||||
export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
|
||||
key: "bold",
|
||||
name: "Bold",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldOutline,
|
||||
});
|
||||
|
||||
export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
|
||||
key: "italic",
|
||||
name: "Italic",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicOutline,
|
||||
});
|
||||
|
||||
export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||
key: "underline",
|
||||
name: "Underline",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineOutline,
|
||||
});
|
||||
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
|
||||
key: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughOutline,
|
||||
});
|
||||
|
||||
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: ListOutline,
|
||||
});
|
||||
|
||||
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: NumberedListOutline,
|
||||
});
|
||||
|
||||
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: CheckSquareOutline,
|
||||
});
|
||||
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteOutline,
|
||||
});
|
||||
|
||||
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: CodeOutline,
|
||||
});
|
||||
|
||||
export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
|
||||
key: "table",
|
||||
name: "Table",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableOutline,
|
||||
});
|
||||
|
||||
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: ImageOutline,
|
||||
});
|
||||
|
||||
export const HorizontalRuleItem = (editor: Editor): EditorMenuItem<"divider"> =>
|
||||
({
|
||||
key: "divider",
|
||||
name: "Divider",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE),
|
||||
command: () => insertHorizontalRule(editor),
|
||||
icon: MinusSquareOutline,
|
||||
}) 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: LinkOutline,
|
||||
}) 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: PaletteOutline,
|
||||
});
|
||||
|
||||
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: PaletteOutline,
|
||||
});
|
||||
|
||||
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: AlignCenterOutline,
|
||||
});
|
||||
|
||||
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>[];
|
||||
};
|
||||
12
packages/editor/src/constants/assets.ts
Normal file
12
packages/editor/src/constants/assets.ts
Normal file
@@ -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>> = {};
|
||||
266
packages/editor/src/constants/common.ts
Normal file
266
packages/editor/src/constants/common.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
import {
|
||||
AlignCenterOutline,
|
||||
AlignLeftOutline,
|
||||
AlignRightOutline,
|
||||
BoldOutline,
|
||||
CodeOutline,
|
||||
H1Outline,
|
||||
H2Outline,
|
||||
H3Outline,
|
||||
H4Outline,
|
||||
H5Outline,
|
||||
H6Outline,
|
||||
ImageOutline,
|
||||
ItalicOutline,
|
||||
ListOutline,
|
||||
NumberedListOutline,
|
||||
QuoteOutline,
|
||||
StrikethroughOutline,
|
||||
TableEditorOutline,
|
||||
TextOutline,
|
||||
ToDoOutline,
|
||||
UnderlineOutline,
|
||||
} from "@makeplane/propel/icons";
|
||||
import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/propel/icons";
|
||||
import type { TCommandExtraProps, TEditorCommands, TEditorFontStyle } from "@/types";
|
||||
|
||||
export type TEditorTypes = "lite" | "document" | "sticky";
|
||||
|
||||
// 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: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
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: TextOutline, editors: ["document"] },
|
||||
{ itemKey: "h1", renderKey: "h1", name: "Heading 1", icon: H1Outline, editors: ["document"] },
|
||||
{ itemKey: "h2", renderKey: "h2", name: "Heading 2", icon: H2Outline, editors: ["document"] },
|
||||
{ itemKey: "h3", renderKey: "h3", name: "Heading 3", icon: H3Outline, editors: ["document"] },
|
||||
{ itemKey: "h4", renderKey: "h4", name: "Heading 4", icon: H4Outline, editors: ["document"] },
|
||||
{ itemKey: "h5", renderKey: "h5", name: "Heading 5", icon: H5Outline, editors: ["document"] },
|
||||
{ itemKey: "h6", renderKey: "h6", name: "Heading 6", icon: H6Outline, editors: ["document"] },
|
||||
];
|
||||
|
||||
export const TEXT_ALIGNMENT_ITEMS: ToolbarMenuItem<"text-align">[] = [
|
||||
{
|
||||
itemKey: "text-align",
|
||||
renderKey: "text-align-left",
|
||||
name: "Left align",
|
||||
icon: AlignLeftOutline,
|
||||
shortcut: ["Cmd", "Shift", "L"],
|
||||
editors: ["lite", "document"],
|
||||
extraProps: {
|
||||
alignment: "left",
|
||||
},
|
||||
},
|
||||
{
|
||||
itemKey: "text-align",
|
||||
renderKey: "text-align-center",
|
||||
name: "Center align",
|
||||
icon: AlignCenterOutline,
|
||||
shortcut: ["Cmd", "Shift", "E"],
|
||||
editors: ["lite", "document"],
|
||||
extraProps: {
|
||||
alignment: "center",
|
||||
},
|
||||
},
|
||||
{
|
||||
itemKey: "text-align",
|
||||
renderKey: "text-align-right",
|
||||
name: "Right align",
|
||||
icon: AlignRightOutline,
|
||||
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: BoldOutline,
|
||||
shortcut: ["Cmd", "B"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
itemKey: "italic",
|
||||
renderKey: "italic",
|
||||
name: "Italic",
|
||||
icon: ItalicOutline,
|
||||
shortcut: ["Cmd", "I"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
itemKey: "underline",
|
||||
renderKey: "underline",
|
||||
name: "Underline",
|
||||
icon: UnderlineOutline,
|
||||
shortcut: ["Cmd", "U"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
itemKey: "strikethrough",
|
||||
renderKey: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
icon: StrikethroughOutline,
|
||||
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: ListOutline,
|
||||
shortcut: ["Cmd", "Shift", "7"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
itemKey: "numbered-list",
|
||||
renderKey: "numbered-list",
|
||||
name: "Numbered list",
|
||||
icon: NumberedListOutline,
|
||||
shortcut: ["Cmd", "Shift", "8"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
{
|
||||
itemKey: "to-do-list",
|
||||
renderKey: "to-do-list",
|
||||
name: "To-do list",
|
||||
icon: ToDoOutline,
|
||||
shortcut: ["Cmd", "Shift", "9"],
|
||||
editors: ["lite", "document"],
|
||||
},
|
||||
];
|
||||
|
||||
export const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [
|
||||
{ itemKey: "quote", renderKey: "quote", name: "Quote", icon: QuoteOutline, editors: ["lite", "document"] },
|
||||
{ itemKey: "code", renderKey: "code", name: "Code", icon: CodeOutline, editors: ["lite", "document"] },
|
||||
];
|
||||
|
||||
export const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [
|
||||
{ itemKey: "table", renderKey: "table", name: "Table", icon: TableEditorOutline, editors: ["document"] },
|
||||
{ itemKey: "image", renderKey: "image", name: "Image", icon: ImageOutline, editors: ["lite", "document"] },
|
||||
];
|
||||
|
||||
export const IMAGE_ITEM = COMPLEX_ITEMS.find((item): item is ToolbarMenuItem<"image"> => item.itemKey === "image")!;
|
||||
|
||||
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")),
|
||||
},
|
||||
sticky: {
|
||||
basic: BASIC_MARK_ITEMS.filter((item) => ["bold", "italic"].includes(item.itemKey)),
|
||||
list: LIST_ITEMS.filter((item) => ["to-do-list"].includes(item.itemKey)),
|
||||
},
|
||||
};
|
||||
|
||||
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)",
|
||||
// },
|
||||
];
|
||||
|
||||
export const EDITOR_FONT_STYLES: {
|
||||
key: TEditorFontStyle;
|
||||
label: string;
|
||||
icon: React.FC;
|
||||
}[] = [
|
||||
{ key: "sans-serif", label: "Sans serif", icon: SansSerifIcon },
|
||||
{ key: "serif", label: "Serif", icon: SerifIcon },
|
||||
{ key: "monospace", label: "Mono", icon: MonospaceIcon },
|
||||
];
|
||||
69
packages/editor/src/constants/config.ts
Normal file
69
packages/editor/src/constants/config.ts
Normal file
@@ -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;
|
||||
77
packages/editor/src/constants/extension.ts
Normal file
77
packages/editor/src/constants/extension.ts
Normal file
@@ -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,
|
||||
];
|
||||
9
packages/editor/src/constants/extensions.ts
Normal file
9
packages/editor/src/constants/extensions.ts
Normal file
@@ -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 = [];
|
||||
11
packages/editor/src/constants/meta.ts
Normal file
11
packages/editor/src/constants/meta.ts
Normal file
@@ -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",
|
||||
}
|
||||
29
packages/editor/src/constants/utility.ts
Normal file
29
packages/editor/src/constants/utility.ts
Normal file
@@ -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 "@/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",
|
||||
},
|
||||
};
|
||||
38
packages/editor/src/contexts/collaboration-context.tsx
Normal file
38
packages/editor/src/contexts/collaboration-context.tsx
Normal file
@@ -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
|
||||
}
|
||||
7
packages/editor/src/contexts/index.ts
Normal file
7
packages/editor/src/contexts/index.ts
Normal file
@@ -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,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;
|
||||
};
|
||||
66
packages/editor/src/extensions/callout/block.tsx
Normal file
66
packages/editor/src/extensions/callout/block.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
82
packages/editor/src/extensions/callout/color-selector.tsx
Normal file
82
packages/editor/src/extensions/callout/color-selector.tsx
Normal file
@@ -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 { ChevronDownOutline } from "@makeplane/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>
|
||||
<ChevronDownOutline 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>
|
||||
);
|
||||
}
|
||||
84
packages/editor/src/extensions/callout/extension-config.ts
Normal file
84
packages/editor/src/extensions/callout/extension-config.ts
Normal file
@@ -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];
|
||||
},
|
||||
});
|
||||
82
packages/editor/src/extensions/callout/extension.tsx
Normal file
82
packages/editor/src/extensions/callout/extension.tsx
Normal file
@@ -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"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
7
packages/editor/src/extensions/callout/index.ts
Normal file
7
packages/editor/src/extensions/callout/index.ts
Normal file
@@ -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";
|
||||
104
packages/editor/src/extensions/callout/logo-selector.tsx
Normal file
104
packages/editor/src/extensions/callout/logo-selector.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
41
packages/editor/src/extensions/callout/types.ts
Normal file
41
packages/editor/src/extensions/callout/types.ts
Normal file
@@ -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>;
|
||||
91
packages/editor/src/extensions/callout/utils.ts
Normal file
91
packages/editor/src/extensions/callout/utils.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
104
packages/editor/src/extensions/code-inline/index.tsx
Normal file
104
packages/editor/src/extensions/code-inline/index.tsx
Normal file
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
42
packages/editor/src/extensions/code/code-block-lowlight.ts
Normal file
42
packages/editor/src/extensions/code/code-block-lowlight.ts
Normal file
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
72
packages/editor/src/extensions/code/code-block-node-view.tsx
Normal file
72
packages/editor/src/extensions/code/code-block-node-view.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 { CopyOutline, TickOutline } from "@makeplane/propel/icons";
|
||||
import { useState } from "react";
|
||||
// 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 ? (
|
||||
<TickOutline className="h-3 w-3 text-success-primary" />
|
||||
) : (
|
||||
<CopyOutline 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>
|
||||
);
|
||||
}
|
||||
353
packages/editor/src/extensions/code/code-block.ts
Normal file
353
packages/editor/src/extensions/code/code-block.ts
Normal file
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
130
packages/editor/src/extensions/code/index.tsx
Normal file
130
packages/editor/src/extensions/code/index.tsx
Normal file
@@ -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: "",
|
||||
},
|
||||
});
|
||||
161
packages/editor/src/extensions/code/lowlight-plugin.ts
Normal file
161
packages/editor/src/extensions/code/lowlight-plugin.ts
Normal file
@@ -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;
|
||||
}
|
||||
15
packages/editor/src/extensions/code/types.ts
Normal file
15
packages/editor/src/extensions/code/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export enum ECodeBlockAttributeNames {
|
||||
ID = "id",
|
||||
LANGUAGE = "language",
|
||||
}
|
||||
|
||||
export type TCodeBlockAttributes = {
|
||||
[ECodeBlockAttributeNames.ID]: string | null;
|
||||
[ECodeBlockAttributeNames.LANGUAGE]: string | null;
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { findParentNode } from "@tiptap/core";
|
||||
|
||||
type ReplaceCodeBlockParams = {
|
||||
editor: Editor;
|
||||
from: number;
|
||||
to: number;
|
||||
textContent: string;
|
||||
cursorPosInsideCodeblock: number;
|
||||
};
|
||||
|
||||
export function replaceCodeWithText(editor: Editor): void {
|
||||
try {
|
||||
const { from, to } = editor.state.selection;
|
||||
const cursorPosInsideCodeblock = from;
|
||||
let replaced = false;
|
||||
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.type === editor.state.schema.nodes.codeBlock) {
|
||||
const startPos = pos;
|
||||
const endPos = pos + node.nodeSize;
|
||||
const textContent = node.textContent;
|
||||
|
||||
if (textContent.length === 0) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else {
|
||||
transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from: startPos,
|
||||
to: endPos,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
});
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
console.log("No code block to replace.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while replacing code block content:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function transformCodeBlockToParagraphs({
|
||||
editor,
|
||||
from,
|
||||
to,
|
||||
textContent,
|
||||
cursorPosInsideCodeblock,
|
||||
}: ReplaceCodeBlockParams): void {
|
||||
const { schema } = editor.state;
|
||||
const { paragraph } = schema.nodes;
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (from < 0 || to > docSize || from > to) {
|
||||
console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n)
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const tr = editor.state.tr;
|
||||
let insertPos = from;
|
||||
|
||||
// Remove the code block first
|
||||
tr.delete(from, to);
|
||||
|
||||
// For each line, create a paragraph node and insert it
|
||||
lines.forEach((line) => {
|
||||
// if the line is empty, create a paragraph node with no content
|
||||
const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line));
|
||||
tr.insert(insertPos, paragraphNode);
|
||||
insertPos += paragraphNode.nodeSize;
|
||||
});
|
||||
|
||||
// Now persist the focus to the converted paragraph
|
||||
const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos;
|
||||
|
||||
if (parentNodeOffset === undefined) throw new Error("Invalid code block offset");
|
||||
|
||||
const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset);
|
||||
const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1);
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
editor.chain().focus(cursorPosOutsideCodeblock).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the line number where the cursor is located inside the code block.
|
||||
* Assumes the indexing of the content inside the code block is like ProseMirror's indexing.
|
||||
*
|
||||
* @param {string} textContent - The content of the code block.
|
||||
* @param {number} cursorPosition - The absolute cursor position in the document.
|
||||
* @param {number} codeBlockNodePos - The starting position of the code block node in the document.
|
||||
* @returns {number} The 1-based line number where the cursor is located.
|
||||
*/
|
||||
function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number {
|
||||
// Split the text content into lines, handling both Unix and Windows newlines
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos;
|
||||
|
||||
let startPosition = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// Calculate the end position of the current line
|
||||
const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character
|
||||
|
||||
// Check if the cursor position is within the current line
|
||||
if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) {
|
||||
lineNumber = i + 1; // Line numbers are 1-based
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the start position for the next line
|
||||
startPosition = endPosition;
|
||||
}
|
||||
|
||||
return lineNumber;
|
||||
}
|
||||
121
packages/editor/src/extensions/code/without-props.tsx
Normal file
121
packages/editor/src/extensions/code/without-props.tsx
Normal file
@@ -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,
|
||||
});
|
||||
66
packages/editor/src/extensions/core-without-props.ts
Normal file
66
packages/editor/src/extensions/core-without-props.ts
Normal file
@@ -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 "@/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];
|
||||
19
packages/editor/src/extensions/core/extensions.ts
Normal file
19
packages/editor/src/extensions/core/extensions.ts
Normal file
@@ -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 [];
|
||||
};
|
||||
7
packages/editor/src/extensions/core/index.ts
Normal file
7
packages/editor/src/extensions/core/index.ts
Normal file
@@ -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";
|
||||
9
packages/editor/src/extensions/core/without-props.ts
Normal file
9
packages/editor/src/extensions/core/without-props.ts
Normal file
@@ -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 = [];
|
||||
155
packages/editor/src/extensions/custom-color.ts
Normal file
155
packages/editor/src/extensions/custom-color.ts
Normal file
@@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
355
packages/editor/src/extensions/custom-image/components/block.tsx
Normal file
355
packages/editor/src/extensions/custom-image/components/block.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { ECustomImageAttributeNames } from "../types";
|
||||
import type { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString, getImageBlockId, isImageDuplicating } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
editorContainer: HTMLDivElement | null;
|
||||
imageFromFileSystem: string | undefined;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
src: string | undefined;
|
||||
downloadSrc: string | undefined;
|
||||
};
|
||||
|
||||
export function CustomImageBlock(props: CustomImageBlockProps) {
|
||||
// props
|
||||
const {
|
||||
editor,
|
||||
editorContainer,
|
||||
extension,
|
||||
getPos,
|
||||
imageFromFileSystem,
|
||||
node,
|
||||
selected,
|
||||
setEditorContainer,
|
||||
setFailedToLoadImage,
|
||||
src: resolvedImageSrc,
|
||||
downloadSrc: resolvedDownloadSrc,
|
||||
updateAttributes,
|
||||
} = props;
|
||||
const {
|
||||
width: nodeWidth,
|
||||
height: nodeHeight,
|
||||
aspectRatio: nodeAspectRatio,
|
||||
src: imgNodeSrc,
|
||||
alignment: nodeAlignment,
|
||||
status,
|
||||
} = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<TCustomImageSize>({
|
||||
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
|
||||
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRect = useRef<DOMRect | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
// extension options
|
||||
const isTouchDevice = !!(editor.storage.utility as { isTouchDevice?: boolean } | undefined)?.isTouchDevice;
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
console.error(`${errorMessage}:`, error);
|
||||
}
|
||||
},
|
||||
[updateAttributes]
|
||||
);
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
let closestEditorContainer: HTMLDivElement | null = null;
|
||||
|
||||
if (editorContainer) {
|
||||
closestEditorContainer = editorContainer;
|
||||
} else {
|
||||
closestEditorContainer = img.closest(".editor-container");
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorContainer(closestEditorContainer);
|
||||
const aspectRatioCalculated = img.naturalWidth / img.naturalHeight;
|
||||
|
||||
if (nodeWidth === "35%") {
|
||||
const editorWidth = closestEditorContainer.clientWidth;
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatioCalculated;
|
||||
|
||||
const initialComputedSize: TCustomImageSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
};
|
||||
setSize(initialComputedSize);
|
||||
updateAttributesSafely(
|
||||
initialComputedSize,
|
||||
"Failed to update attributes while initializing an image for the first time:"
|
||||
);
|
||||
} else {
|
||||
// as the aspect ratio in not stored for old images, we need to update the attrs
|
||||
// or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs
|
||||
if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) {
|
||||
setSize((prevSize) => {
|
||||
const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated };
|
||||
updateAttributesSafely(
|
||||
newSize,
|
||||
"Failed to update attributes while initializing images with width but no aspect ratio:"
|
||||
);
|
||||
return newSize;
|
||||
});
|
||||
}
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
setSize((prevSize) => ({
|
||||
...prevSize,
|
||||
width: ensurePixelString(nodeWidth) ?? "35%",
|
||||
height: ensurePixelString(nodeHeight) ?? "auto",
|
||||
aspectRatio: nodeAspectRatio,
|
||||
}));
|
||||
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
|
||||
|
||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||
|
||||
if (nodeAlignment === "right") {
|
||||
const newWidth = Math.max(containerRect.current.right - clientX, MIN_SIZE);
|
||||
const newHeight = newWidth / size.aspectRatio;
|
||||
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
|
||||
} else {
|
||||
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
|
||||
const newHeight = newWidth / size.aspectRatio;
|
||||
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
|
||||
}
|
||||
},
|
||||
[nodeAlignment, size.aspectRatio]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributesSafely]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRect.current = containerRef.current.getBoundingClientRect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizing) {
|
||||
window.addEventListener("mousemove", handleResize);
|
||||
window.addEventListener("mouseup", handleResizeEnd);
|
||||
window.addEventListener("mouseleave", handleResizeEnd);
|
||||
window.addEventListener("touchmove", handleResize);
|
||||
window.addEventListener("touchend", handleResizeEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleResize);
|
||||
window.removeEventListener("mouseup", handleResizeEnd);
|
||||
window.removeEventListener("mouseleave", handleResizeEnd);
|
||||
window.removeEventListener("touchmove", handleResize);
|
||||
window.removeEventListener("touchend", handleResizeEnd);
|
||||
};
|
||||
}
|
||||
}, [isResizing, handleResize, handleResizeEnd]);
|
||||
|
||||
const handleImageMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isTouchDevice) {
|
||||
e.preventDefault();
|
||||
editor.commands.blur();
|
||||
}
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
|
||||
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
|
||||
},
|
||||
[editor, getPos, isTouchDevice]
|
||||
);
|
||||
|
||||
const isDuplicating = isImageDuplicating(status);
|
||||
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
|
||||
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
|
||||
const showImageLoader =
|
||||
(!resolvedImageSrc && !isDuplicating) || !initialResizeComplete || hasErroredOnFirstLoad || isDuplicating; // show the image upload status only when the resolvedImageSrc is not ready
|
||||
const showUploadStatus = !resolvedImageSrc && !isDuplicating;
|
||||
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||
const showImageToolbar = resolvedImageSrc && resolvedDownloadSrc && initialResizeComplete && !isDuplicating;
|
||||
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
|
||||
const showImageResizer = editor.isEditable && resolvedImageSrc && initialResizeComplete && !isDuplicating;
|
||||
// show the preview image from the file system if the remote image's src is not set
|
||||
const displayedImageSrc = resolvedImageSrc || imageFromFileSystem;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={
|
||||
node.attrs[ECustomImageAttributeNames.ID]
|
||||
? getImageBlockId(node.attrs[ECustomImageAttributeNames.ID])
|
||||
: undefined
|
||||
}
|
||||
className={cn("w-fit max-w-full transition-all", {
|
||||
"ml-[50%] -translate-x-1/2": nodeAlignment === "center",
|
||||
"ml-[100%] -translate-x-full": nodeAlignment === "right",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/image-component relative inline-block max-w-full"
|
||||
onMouseDown={handleImageMouseDown}
|
||||
style={{
|
||||
width: size.width,
|
||||
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
|
||||
}}
|
||||
>
|
||||
{showImageLoader && (
|
||||
<div className="animate-pulse rounded-md bg-layer-1" style={{ width: size.width, height: size.height }} />
|
||||
)}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={displayedImageSrc}
|
||||
alt=""
|
||||
onLoad={handleImageLoad}
|
||||
onError={(_e) =>
|
||||
void (async () => {
|
||||
// for old image extension this command doesn't exist or if the image failed to load for the first time
|
||||
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
|
||||
setFailedToLoadImage(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setHasErroredOnFirstLoad(true);
|
||||
// this is a type error from tiptap, don't remove await until it's fixed
|
||||
if (!imgNodeSrc) {
|
||||
throw new Error("No source image to restore from");
|
||||
}
|
||||
await extension.options.restoreImage?.(imgNodeSrc);
|
||||
if (!imageRef.current) {
|
||||
throw new Error("Image reference not found");
|
||||
}
|
||||
if (!resolvedImageSrc) {
|
||||
throw new Error("No resolved image source available");
|
||||
}
|
||||
if (isTouchDevice) {
|
||||
const refreshedSrc = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
imageRef.current.src = refreshedSrc;
|
||||
} else {
|
||||
imageRef.current.src = resolvedImageSrc;
|
||||
}
|
||||
} catch (error) {
|
||||
// if the image failed to even restore, then show the error state
|
||||
setFailedToLoadImage(true);
|
||||
console.error("Error while loading image", error);
|
||||
} finally {
|
||||
setHasErroredOnFirstLoad(false);
|
||||
setHasTriedRestoringImageOnce(true);
|
||||
}
|
||||
})()
|
||||
}
|
||||
width={size.width}
|
||||
className={cn("image-component block rounded-md", {
|
||||
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
|
||||
hidden: showImageLoader,
|
||||
"read-only-image": !editor.isEditable,
|
||||
"loading-image opacity-80 blur-sm": !resolvedImageSrc,
|
||||
})}
|
||||
style={{
|
||||
width: size.width,
|
||||
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
|
||||
}}
|
||||
/>
|
||||
{showUploadStatus && node.attrs[ECustomImageAttributeNames.ID] && (
|
||||
<ImageUploadStatus editor={editor} nodeId={node.attrs[ECustomImageAttributeNames.ID]} />
|
||||
)}
|
||||
{showImageToolbar && (
|
||||
<ImageToolbarRoot
|
||||
alignment={nodeAlignment ?? "left"}
|
||||
editor={editor}
|
||||
aspectRatio={size.aspectRatio === null ? 1 : size.aspectRatio}
|
||||
downloadSrc={resolvedDownloadSrc}
|
||||
handleAlignmentChange={(alignment) =>
|
||||
updateAttributesSafely({ alignment }, "Failed to update attributes while changing alignment:")
|
||||
}
|
||||
height={size.height}
|
||||
isTouchDevice={isTouchDevice}
|
||||
width={size.width}
|
||||
src={resolvedImageSrc}
|
||||
/>
|
||||
)}
|
||||
{selected && displayedImageSrc === resolvedImageSrc && (
|
||||
<div className="pointer-events-none absolute inset-0 size-full bg-accent-primary/30" />
|
||||
)}
|
||||
{showImageResizer && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-md border-2 border-accent-strong transition-opacity duration-100 ease-in-out",
|
||||
{
|
||||
"opacity-100": isResizing,
|
||||
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 size-4 translate-y-1/2 rounded-full border-2 border-white bg-accent-primary transition-opacity duration-100 ease-in-out",
|
||||
{
|
||||
"pointer-events-auto opacity-100": isResizing,
|
||||
"pointer-events-none opacity-0 group-hover/image-component:pointer-events-auto group-hover/image-component:opacity-100":
|
||||
!isResizing,
|
||||
"left-0 -translate-x-1/2 cursor-nesw-resize": nodeAlignment === "right",
|
||||
"right-0 translate-x-1/2 cursor-nwse-resize": nodeAlignment !== "right",
|
||||
}
|
||||
)}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// local imports
|
||||
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
|
||||
import { ECustomImageAttributeNames, ECustomImageStatus } from "../types";
|
||||
import { hasImageDuplicationFailed } from "../utils";
|
||||
import { CustomImageBlock } from "./block";
|
||||
import { CustomImageUploader } from "./uploader";
|
||||
|
||||
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
||||
extension: CustomImageExtensionType;
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TCustomImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
||||
};
|
||||
|
||||
export function CustomImageNodeView(props: CustomImageNodeViewProps) {
|
||||
const { editor, extension, node, updateAttributes } = props;
|
||||
const { src: imgNodeSrc, status } = node.attrs;
|
||||
|
||||
const [isUploaded, setIsUploaded] = useState(!!imgNodeSrc);
|
||||
const [resolvedSrc, setResolvedSrc] = useState<string | undefined>(undefined);
|
||||
const [resolvedDownloadSrc, setResolvedDownloadSrc] = useState<string | undefined>(undefined);
|
||||
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
||||
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
|
||||
|
||||
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
|
||||
const imageComponentRef = useRef<HTMLDivElement>(null);
|
||||
const hasRetriedOnMount = useRef(false);
|
||||
const isDuplicatingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||
if (closestEditorContainer) {
|
||||
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// the image is already uploaded if the image-component node has src attribute
|
||||
// and we need to remove the blob from our file system
|
||||
useEffect(() => {
|
||||
if (resolvedSrc || imgNodeSrc) {
|
||||
setIsUploaded(true);
|
||||
setImageFromFileSystem(undefined);
|
||||
} else {
|
||||
setIsUploaded(false);
|
||||
}
|
||||
}, [resolvedSrc, imgNodeSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imgNodeSrc) {
|
||||
setResolvedSrc(undefined);
|
||||
setResolvedDownloadSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedSrc(undefined);
|
||||
setResolvedDownloadSrc(undefined);
|
||||
setFailedToLoadImage(false);
|
||||
|
||||
const getImageSource = async () => {
|
||||
try {
|
||||
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url);
|
||||
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
|
||||
setResolvedDownloadSrc(downloadUrl);
|
||||
} catch (error) {
|
||||
console.error("Error fetching image source:", error);
|
||||
setFailedToLoadImage(true);
|
||||
}
|
||||
};
|
||||
void getImageSource();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [imgNodeSrc, extension.options.getImageSource, extension.options.getImageDownloadSource]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDuplication = async () => {
|
||||
if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate calls - check if already duplicating this asset
|
||||
if (isDuplicatingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDuplicatingRef.current = true;
|
||||
try {
|
||||
hasRetriedOnMount.current = true;
|
||||
|
||||
const newAssetId = await extension.options.duplicateImage(imgNodeSrc);
|
||||
|
||||
if (!newAssetId) {
|
||||
throw new Error("Duplication returned invalid asset ID");
|
||||
}
|
||||
|
||||
setFailedToLoadImage(false);
|
||||
updateAttributes({ src: newAssetId, status: ECustomImageStatus.UPLOADED });
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to duplicate image:", error);
|
||||
// Update status to failed
|
||||
updateAttributes({ status: ECustomImageStatus.DUPLICATION_FAILED });
|
||||
} finally {
|
||||
isDuplicatingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void handleDuplication();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, imgNodeSrc, extension.options.duplicateImage, updateAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasImageDuplicationFailed(status) && !hasRetriedOnMount.current && imgNodeSrc) {
|
||||
hasRetriedOnMount.current = true;
|
||||
// Add a small delay before retrying to avoid immediate retries
|
||||
updateAttributes({ status: ECustomImageStatus.DUPLICATING });
|
||||
}
|
||||
}, [status, imgNodeSrc, updateAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === ECustomImageStatus.UPLOADED) {
|
||||
hasRetriedOnMount.current = false;
|
||||
setFailedToLoadImage(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const hasDuplicationFailed = hasImageDuplicationFailed(status);
|
||||
const hasValidImageSource = imageFromFileSystem || (isUploaded && resolvedSrc);
|
||||
const shouldShowBlock = hasValidImageSource && !failedToLoadImage && !hasDuplicationFailed;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper key={node.attrs[ECustomImageAttributeNames.ID]}>
|
||||
<div className="mx-0 my-2 p-0" data-drag-handle ref={imageComponentRef}>
|
||||
{shouldShowBlock && !hasDuplicationFailed ? (
|
||||
<CustomImageBlock
|
||||
editorContainer={editorContainer}
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
src={resolvedSrc}
|
||||
downloadSrc={resolvedDownloadSrc}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
hasDuplicationFailed={hasDuplicationFailed}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={(editor.storage.imageComponent as { maxFileSize?: number } | undefined)?.maxFileSize ?? 0}
|
||||
setIsUploaded={setIsUploaded}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { ChevronDownOutline } from "@makeplane/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" />}
|
||||
<ChevronDownOutline className="size-2 flex-shrink-0" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute top-full left-1/2 mt-0.5 flex h-7 -translate-x-1/2 items-center gap-2 rounded-sm bg-black/80 px-2">
|
||||
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
|
||||
<Tooltip disabled={isTouchDevice} key={option.value} tooltipContent={option.label}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-full flex-shrink-0 place-items-center text-white/60 transition-colors hover:text-white"
|
||||
onClick={() => {
|
||||
handleChange(option.value);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<option.icon className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { DownloadOutline } from "@makeplane/propel/icons";
|
||||
// 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"
|
||||
>
|
||||
<DownloadOutline className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { AddOutline, CloseOutline, DownloadOutline, MinusOutline, NewTabOutline } from "@makeplane/propel/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 2;
|
||||
const ZOOM_SPEED = 0.05;
|
||||
const ZOOM_STEPS = [0.5, 1, 1.5, 2];
|
||||
|
||||
type Props = {
|
||||
aspectRatio: number;
|
||||
downloadSrc: string;
|
||||
isFullScreenEnabled: boolean;
|
||||
isTouchDevice: boolean;
|
||||
src: string;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
width: string;
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<CloseOutline 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"
|
||||
>
|
||||
<MinusOutline 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"
|
||||
>
|
||||
<AddOutline 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"
|
||||
>
|
||||
<DownloadOutline 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"
|
||||
>
|
||||
<NewTabOutline className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageFullScreenModal(props: Props) {
|
||||
let modal = <ImageFullScreenModalWithoutPortal {...props} />;
|
||||
const portal = document.querySelector("#editor-portal");
|
||||
if (portal) {
|
||||
modal = ReactDOM.createPortal(modal, portal);
|
||||
} else {
|
||||
console.warn("Portal element #editor-portal not found. Rendering in document.body");
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
modal = ReactDOM.createPortal(modal, document.body);
|
||||
}
|
||||
}
|
||||
return modal;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { FullScreenOutline } from "@makeplane/propel/icons";
|
||||
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"
|
||||
>
|
||||
<FullScreenOutline className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import type { TCustomImageAlignment } from "../../types";
|
||||
import { ImageAlignmentAction } from "./alignment";
|
||||
import { ImageDownloadAction } from "./download";
|
||||
import { ImageFullScreenActionRoot } from "./full-screen";
|
||||
|
||||
type Props = {
|
||||
alignment: TCustomImageAlignment;
|
||||
editor: Editor;
|
||||
aspectRatio: number;
|
||||
downloadSrc: string;
|
||||
handleAlignmentChange: (alignment: TCustomImageAlignment) => void;
|
||||
height: string;
|
||||
isTouchDevice: boolean;
|
||||
src: string;
|
||||
width: string;
|
||||
};
|
||||
|
||||
export function ImageToolbarRoot(props: Props) {
|
||||
const { alignment, editor, downloadSrc, handleAlignmentChange, isTouchDevice } = props;
|
||||
// states
|
||||
const [shouldShowToolbar, setShouldShowToolbar] = useState(false);
|
||||
// derived values
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1 right-1 z-20 flex h-7 items-center gap-2 rounded-sm bg-black/80 px-2 opacity-0 transition-opacity group-hover/image-component:pointer-events-auto group-hover/image-component:opacity-100",
|
||||
{
|
||||
"pointer-events-auto opacity-100": shouldShowToolbar,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{!isTouchDevice && <ImageDownloadAction src={downloadSrc} />}
|
||||
{isEditable && (
|
||||
<ImageAlignmentAction
|
||||
activeAlignment={alignment}
|
||||
handleChange={handleAlignmentChange}
|
||||
isTouchDevice={isTouchDevice}
|
||||
toggleToolbarViewStatus={setShouldShowToolbar}
|
||||
/>
|
||||
)}
|
||||
<ImageFullScreenActionRoot
|
||||
image={props}
|
||||
isTouchDevice={isTouchDevice}
|
||||
toggleToolbarViewStatus={setShouldShowToolbar}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
nodeId: string;
|
||||
};
|
||||
|
||||
export function ImageUploadStatus(props: Props) {
|
||||
const { editor, nodeId } = props;
|
||||
// Displayed status that will animate smoothly
|
||||
const [displayStatus, setDisplayStatus] = useState(0);
|
||||
// Animation frame ID for cleanup
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
// subscribe to image upload status
|
||||
const uploadStatus: number | undefined = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => editor.storage.utility?.assetsUploadStatus?.[nodeId],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const animateToValue = (start: number, end: number, startTime: number) => {
|
||||
const duration = 200;
|
||||
|
||||
const animation = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
// Calculate current display value
|
||||
const currentValue = Math.floor(start + (end - start) * easeOutCubic);
|
||||
setDisplayStatus(currentValue);
|
||||
|
||||
// Continue animation if not complete
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
|
||||
}
|
||||
};
|
||||
animationFrameRef.current = requestAnimationFrame((time) => animation(time));
|
||||
};
|
||||
animateToValue(displayStatus, uploadStatus == undefined ? 100 : uploadStatus, performance.now());
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [displayStatus, uploadStatus]);
|
||||
|
||||
if (uploadStatus === undefined) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-1 right-1 z-20 w-10 rounded-sm bg-black/60 text-center text-11 font-medium">
|
||||
{displayStatus}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { ImageOutline, RefreshOutline } from "@makeplane/propel/icons";
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageOutline 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"
|
||||
>
|
||||
<RefreshOutline 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)];
|
||||
},
|
||||
});
|
||||
134
packages/editor/src/extensions/custom-image/extension.tsx
Normal file
134
packages/editor/src/extensions/custom-image/extension.tsx
Normal file
@@ -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"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
}
|
||||
73
packages/editor/src/extensions/custom-image/types.ts
Normal file
73
packages/editor/src/extensions/custom-image/types.ts
Normal file
@@ -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>;
|
||||
70
packages/editor/src/extensions/custom-image/utils.ts
Normal file
70
packages/editor/src/extensions/custom-image/utils.ts
Normal file
@@ -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 { AlignCenterOutline, AlignLeftOutline, AlignRightOutline } from "@makeplane/propel/icons";
|
||||
import type { ComponentType, SVGProps } from "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: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
}[] = [
|
||||
{
|
||||
label: "Left",
|
||||
value: "left",
|
||||
icon: AlignLeftOutline,
|
||||
},
|
||||
{
|
||||
label: "Center",
|
||||
value: "center",
|
||||
icon: AlignCenterOutline,
|
||||
},
|
||||
{
|
||||
label: "Right",
|
||||
value: "right",
|
||||
icon: AlignRightOutline,
|
||||
},
|
||||
];
|
||||
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;
|
||||
296
packages/editor/src/extensions/custom-link/extension.tsx
Normal file
296
packages/editor/src/extensions/custom-link/extension.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
// Blocked link protocols — browsers execute these in the page's security context.
|
||||
const BLOCKED_LINK_PROTOCOLS = ["javascript:", "data:", "vbscript:"];
|
||||
|
||||
/**
|
||||
* Returns true if the raw href value contains a dangerous protocol.
|
||||
*
|
||||
* Per the WHATWG URL spec, browsers strip ASCII Tab (U+0009), LF (U+000A), and
|
||||
* CR (U+000D) from URL strings during parsing, and strip leading C0 controls /
|
||||
* whitespace before the scheme. A naive `startsWith("javascript:")` check is
|
||||
* therefore bypassable with a whitespace prefix (e.g. `"\tjavascript:alert(1)"`).
|
||||
* We replicate that normalization before our protocol check (GHSA-v2vv-7wq3-8w2j).
|
||||
*/
|
||||
function isDangerousHref(rawHref: string): boolean {
|
||||
const normalized = rawHref
|
||||
.replace(/[\t\n\r]/g, "") // strip Tab/LF/CR (stripped anywhere by WHATWG parser)
|
||||
// oxlint-disable-next-line no-control-regex -- intentional: mirrors WHATWG C0-control stripping before the URL scheme
|
||||
.replace(/^[\s\u0000-\u0008\u000E-\u001F]+/, "") // strip leading C0 controls + whitespace
|
||||
.toLowerCase();
|
||||
return BLOCKED_LINK_PROTOCOLS.some((protocol) => normalized.startsWith(protocol));
|
||||
}
|
||||
|
||||
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") || "";
|
||||
if (isDangerousHref(href)) {
|
||||
return false;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const href = (HTMLAttributes.href as string) || "";
|
||||
if (isDangerousHref(href)) {
|
||||
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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
119
packages/editor/src/extensions/custom-link/helpers/autolink.ts
Normal file
119
packages/editor/src/extensions/custom-link/helpers/autolink.ts
Normal file
@@ -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,62 @@
|
||||
/**
|
||||
* 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) {
|
||||
// Defence-in-depth: link.href is the browser-resolved URL (whitespace
|
||||
// already stripped by the browser's WHATWG URL parser), so a protocol
|
||||
// check here is sufficient to catch any dangerous URI that slipped past
|
||||
// the editor's parse/render-time guards. Matches the blocked-scheme list
|
||||
// in isValidHttpUrl (javascript:, data:, vbscript:, file:, about:)
|
||||
// to keep the policy consistent (GHSA-v2vv-7wq3-8w2j).
|
||||
if (/^(javascript|data|vbscript|file|about):/i.test(href)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.open(href, target);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 { MarkType } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { find } from "linkifyjs";
|
||||
|
||||
type PasteHandlerOptions = {
|
||||
editor: Editor;
|
||||
type: MarkType;
|
||||
};
|
||||
|
||||
export function pasteHandler(options: PasteHandlerOptions): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey("handlePasteLink"),
|
||||
props: {
|
||||
handlePaste: (view, event, slice) => {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const { empty } = selection;
|
||||
|
||||
if (empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let textContent = "";
|
||||
|
||||
slice.content.forEach((node) => {
|
||||
textContent += node.textContent;
|
||||
});
|
||||
|
||||
const link = find(textContent).find((item) => item.isLink && item.value === textContent);
|
||||
|
||||
if (!textContent || !link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
options.editor.commands.setMark(options.type, {
|
||||
href: link.href,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
7
packages/editor/src/extensions/custom-link/index.ts
Normal file
7
packages/editor/src/extensions/custom-link/index.ts
Normal file
@@ -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,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 "./list-keymap";
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 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 { getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import type { Node, NodeType } from "@tiptap/pm/model";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode: Node | null = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
|
||||
while (currentDepth > 0 && targetDepth === null) {
|
||||
currentNode = $from.node(currentDepth);
|
||||
|
||||
if (currentNode.type === nodeType) {
|
||||
targetDepth = currentDepth;
|
||||
} else {
|
||||
currentDepth -= 1;
|
||||
currentPos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDepth === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
|
||||
};
|
||||
|
||||
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth > listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNextListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
const pos = listItemPos.$pos;
|
||||
|
||||
// Adjust the position to ensure we're within the list item, especially for edge cases
|
||||
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
|
||||
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (
|
||||
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
|
||||
node.type.name as CORE_EXTENSIONS
|
||||
)
|
||||
) {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract 1 from the calculated depth to get the parent list's depth
|
||||
// This adjustment is necessary because the depth calculation includes the current list
|
||||
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
|
||||
depth = depth > 0 ? depth - 1 : 0;
|
||||
|
||||
// Double the depth value to get results as 2, 4, 6, 8, etc.
|
||||
depth = depth * 2;
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
// this is required to still handle the undo handling
|
||||
if (editor.commands.undoInputRule()) {
|
||||
return true;
|
||||
}
|
||||
// Check if a node range is selected, and if so, fall back to default backspace functionality
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) {
|
||||
// A range is selected, not just a cursor position; fall back to default behavior
|
||||
return false; // Let the editor handle backspace by default
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
|
||||
const { $anchor } = editor.state.selection;
|
||||
|
||||
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
|
||||
|
||||
const listDescendants: Array<{ node: Node; pos: number }> = [];
|
||||
|
||||
$listPos.node().descendants((node, pos) => {
|
||||
if (node.type.name === name) {
|
||||
listDescendants.push({ node, pos });
|
||||
}
|
||||
});
|
||||
|
||||
const lastItem = listDescendants.at(-1);
|
||||
|
||||
if (!lastItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
|
||||
|
||||
// Check if positions are within the valid range
|
||||
const startPos = $anchor.start() - 1;
|
||||
const endPos = $anchor.end() + 1;
|
||||
if (startPos < 0 || endPos > editor.state.doc.content.size) {
|
||||
return false; // Invalid position, abort operation
|
||||
}
|
||||
|
||||
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
|
||||
}
|
||||
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the start of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is the paragraph node inside of the current list item (maybe with a hard break)
|
||||
const isParaSibling = isCurrentParagraphASibling(editor.state);
|
||||
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentNode = listItemPos.$pos.node(listItemPos.depth);
|
||||
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist) {
|
||||
editor.chain().liftListItem(name).run();
|
||||
return editor.commands.joinItemBackward();
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist && nextListItemIsSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasListItemBefore(name, editor.state)) {
|
||||
return editor.chain().liftListItem(name).run();
|
||||
}
|
||||
|
||||
if (!currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// otherwise in the end, a backspace should
|
||||
// always just lift the list item if
|
||||
// joining / merging is not possible
|
||||
return editor.chain().liftListItem(name).run();
|
||||
};
|
||||
|
||||
export const handleDelete = (editor: Editor, name: string) => {
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the end of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtEndOfNode(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the next node is a list with a deeper depth
|
||||
if (nextListIsDeeper(name, editor.state)) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus(editor.state.selection.from + 4)
|
||||
.lift(name)
|
||||
.joinBackward()
|
||||
.run();
|
||||
}
|
||||
|
||||
if (nextListIsHigher(name, editor.state)) {
|
||||
return editor.chain().joinForward().joinBackward().run();
|
||||
}
|
||||
|
||||
return editor.commands.joinItemForward();
|
||||
};
|
||||
|
||||
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
|
||||
const { $anchor } = editorState.selection;
|
||||
|
||||
const previousNodePos = Math.max(0, $anchor.pos - 2);
|
||||
|
||||
const previousNode = editorState.doc.resolve(previousNodePos).node();
|
||||
|
||||
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getPrevListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth === listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let hasSubList = false;
|
||||
|
||||
node.descendants((child) => {
|
||||
if (child.type === nodeType) {
|
||||
hasSubList = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasSubList;
|
||||
};
|
||||
|
||||
const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
const { $from } = state.selection;
|
||||
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
|
||||
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (
|
||||
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
|
||||
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
|
||||
) {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
paragraphNodesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// If there are more than one paragraph nodes, the current paragraph is a sibling.
|
||||
return paragraphNodesCount > 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export function isCursorInSubList(editor: Editor) {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
// Check if the current node is a list item
|
||||
const listItem = editor.schema.nodes.listItem;
|
||||
const taskItem = editor.schema.nodes.taskItem;
|
||||
|
||||
// Traverse up the document tree from the current position
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === listItem || node.type === taskItem) {
|
||||
// If the parent of the list item is also a list, it's a sub-list
|
||||
const parent = $from.node(depth - 1);
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === editor.schema.nodes.bulletList ||
|
||||
parent.type === editor.schema.nodes.orderedList ||
|
||||
parent.type === editor.schema.nodes.taskList)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection;
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - 2);
|
||||
|
||||
if ($targetPos.index() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
140
packages/editor/src/extensions/custom-list-keymap/list-keymap.ts
Normal file
140
packages/editor/src/extensions/custom-list-keymap/list-keymap.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
|
||||
|
||||
export type ListKeymapOptions = {
|
||||
listTypes: Array<{
|
||||
itemName: string;
|
||||
wrapperNames: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
Extension.create<ListKeymapOptions>({
|
||||
name: "listKeymap",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
listTypes: [
|
||||
{
|
||||
itemName: "listItem",
|
||||
wrapperNames: ["bulletList", "orderedList"],
|
||||
},
|
||||
{
|
||||
itemName: "taskItem",
|
||||
wrapperNames: ["taskList"],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
"Shift-Tab": () => {
|
||||
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Delete:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Delete": ({ editor }) => {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
},
|
||||
Backspace: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBackspace(editor, itemName, wrapperNames)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Backspace:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Backspace": ({ editor }) => {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBackspace(editor, itemName, wrapperNames)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
43
packages/editor/src/extensions/document-extensions.tsx
Normal file
43
packages/editor/src/extensions/document-extensions.tsx
Normal file
@@ -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;
|
||||
}
|
||||
182
packages/editor/src/extensions/emoji/components/emojis-list.tsx
Normal file
182
packages/editor/src/extensions/emoji/components/emojis-list.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { FloatingOverlay } from "@floating-ui/react";
|
||||
import type { SuggestionKeyDownProps, SuggestionProps } from "@tiptap/suggestion";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type EmojiItem = {
|
||||
name: string;
|
||||
emoji: string;
|
||||
shortcodes: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type EmojiListRef = {
|
||||
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
|
||||
};
|
||||
|
||||
export type EmojisListDropdownProps = SuggestionProps<EmojiItem, { name: string }> & {
|
||||
onClose: () => void;
|
||||
forceOpen?: boolean;
|
||||
};
|
||||
|
||||
export const EmojisListDropdown = forwardRef(function EmojisListDropdown(
|
||||
props: EmojisListDropdownProps,
|
||||
ref: React.ForwardedRef<EmojiListRef>
|
||||
) {
|
||||
const { items, command, query, onClose, forceOpen = false } = props;
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// refs
|
||||
const dropdownContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number): void => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
command({ name: item.name });
|
||||
}
|
||||
},
|
||||
[command, items]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
// Allow keyboard navigation if we have items to show
|
||||
if (items.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't handle keyboard if modal shouldn't be visible (query empty without forceOpen)
|
||||
if (query.length === 0 && !forceOpen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[items.length, query.length, forceOpen, selectItem, selectedIndex]
|
||||
);
|
||||
|
||||
// Show animation
|
||||
useEffect(() => {
|
||||
setIsVisible(false);
|
||||
const timeout = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
// Reset selection when items change
|
||||
useEffect(() => setSelectedIndex(0), [items]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const container = dropdownContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const item = container.querySelector(`#emoji-item-${selectedIndex}`) as HTMLElement;
|
||||
if (item) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
|
||||
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
|
||||
item.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeyDown: ({ event }: SuggestionKeyDownProps): boolean => handleKeyDown(event),
|
||||
}),
|
||||
[handleKeyDown]
|
||||
);
|
||||
|
||||
useOutsideClickDetector(dropdownContainerRef, onClose);
|
||||
|
||||
if (query.length === 0 && !forceOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
<div
|
||||
ref={dropdownContainerRef}
|
||||
className={cn(
|
||||
"invisible relative max-h-80 w-[14rem] space-y-2 overflow-y-auto rounded-md border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 opacity-0 shadow-raised-200 transition-opacity",
|
||||
{
|
||||
"visible opacity-100": isVisible,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{items.length ? (
|
||||
items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const emojiKey = item.shortcodes.join(" - ");
|
||||
|
||||
return (
|
||||
<button
|
||||
key={emojiKey}
|
||||
id={`emoji-item-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 truncate rounded-sm px-2 py-1.5 text-left text-13 text-secondary transition-colors duration-150 hover:bg-layer-1-hover",
|
||||
{
|
||||
"bg-layer-1-hover": isSelected,
|
||||
}
|
||||
)}
|
||||
onClick={() => selectItem(index)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<span className="grid size-5 flex-shrink-0 place-items-center text-14">{item.emoji}</span>
|
||||
<span className="flex-grow truncate">
|
||||
<span className="font-medium">:{item.name}:</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-center text-13 text-placeholder">No emojis found</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
EmojisListDropdown.displayName = "EmojisListDropdown";
|
||||
455
packages/editor/src/extensions/emoji/emoji.ts
Normal file
455
packages/editor/src/extensions/emoji/emoji.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
combineTransactionSteps,
|
||||
escapeForRegEx,
|
||||
findChildrenInRange,
|
||||
getChangedRanges,
|
||||
InputRule,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
nodeInputRule,
|
||||
PasteRule,
|
||||
removeDuplicates,
|
||||
} from "@tiptap/core";
|
||||
import type { EmojiStorage } from "@tiptap/extension-emoji";
|
||||
import { emojis, emojiToShortcode, shortcodeToEmoji } from "@tiptap/extension-emoji";
|
||||
import { Fragment } from "@tiptap/pm/model";
|
||||
import type { Transaction } from "@tiptap/pm/state";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { isEmojiSupported } from "is-emoji-supported";
|
||||
// helpers
|
||||
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
|
||||
|
||||
// Extended storage type to include our custom forceOpen flag
|
||||
export interface ExtendedEmojiStorage extends EmojiStorage {
|
||||
forceOpen: boolean;
|
||||
}
|
||||
|
||||
export type EmojiItem = {
|
||||
/**
|
||||
* A unique name of the emoji which will be stored as attribute
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The emoji unicode character
|
||||
*/
|
||||
emoji?: string;
|
||||
/**
|
||||
* A list of unique shortcodes that are used by input rules to find the emoji
|
||||
*/
|
||||
shortcodes: string[];
|
||||
/**
|
||||
* A list of tags that can help for searching emojis
|
||||
*/
|
||||
tags: string[];
|
||||
/**
|
||||
* A name that can help to group emojis
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* A list of unique emoticons
|
||||
*/
|
||||
emoticons?: string[];
|
||||
/**
|
||||
* The unicode version the emoji was introduced
|
||||
*/
|
||||
version?: number;
|
||||
/**
|
||||
* A fallback image if the current system doesn't support the emoji or for custom emojis
|
||||
*/
|
||||
fallbackImage?: string;
|
||||
/**
|
||||
* Store some custom data
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type EmojiOptions = {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
emojis: EmojiItem[];
|
||||
enableEmoticons: boolean;
|
||||
forceFallbackImages: boolean;
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
export const EmojiSuggestionPluginKey = new PluginKey("emojiSuggestion");
|
||||
|
||||
export const inputRegex = /:([a-zA-Z0-9_+-]+):$/;
|
||||
|
||||
export const pasteRegex = /:([a-zA-Z0-9_+-]+):/g;
|
||||
|
||||
export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
|
||||
name: "emoji",
|
||||
|
||||
inline: true,
|
||||
|
||||
group: "inline",
|
||||
|
||||
selectable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
// emojis: ,
|
||||
emojis: emojis,
|
||||
enableEmoticons: false,
|
||||
forceFallbackImages: false,
|
||||
suggestion: {
|
||||
char: ":",
|
||||
pluginKey: EmojiSuggestionPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(" ");
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, state, dispatch }) => {
|
||||
if (!dispatch) return true;
|
||||
|
||||
const { schema } = state;
|
||||
const emojiNode = schema.nodes[this.name].create(props);
|
||||
const spaceNode = schema.text(" ");
|
||||
|
||||
const fragment = Fragment.from([emojiNode, spaceNode]);
|
||||
|
||||
tr.replaceWith(range.from, range.to, fragment);
|
||||
|
||||
const newPos = range.from + fragment.size;
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(newPos)));
|
||||
|
||||
tr.setStoredMarks(tr.doc.resolve(range.from).marks());
|
||||
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
const { emojis } = this.options;
|
||||
const supportMap: Record<number, boolean> = removeDuplicates(emojis.map((item) => item.version))
|
||||
.filter((version) => typeof version === "number")
|
||||
.reduce((versions, version) => {
|
||||
const emoji = emojis.find((item) => item.version === version && item.emoji);
|
||||
|
||||
return {
|
||||
...versions,
|
||||
[version]: emoji ? isEmojiSupported(emoji.emoji as string) : false,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
emojis: this.options.emojis,
|
||||
isSupported: (emojiItem) => (emojiItem.version ? supportMap[emojiItem.version] : false),
|
||||
forceOpen: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
name: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.dataset.name,
|
||||
renderHTML: (attributes) => ({
|
||||
"data-name": attributes.name,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis);
|
||||
const attributes = mergeAttributes(HTMLAttributes, this.options.HTMLAttributes, { "data-type": this.name });
|
||||
|
||||
if (!emojiItem) {
|
||||
return ["span", attributes, `:${node.attrs.name}:`];
|
||||
}
|
||||
|
||||
const renderFallbackImage = false;
|
||||
|
||||
return [
|
||||
"span",
|
||||
attributes,
|
||||
renderFallbackImage
|
||||
? [
|
||||
"img",
|
||||
{
|
||||
src: emojiItem.fallbackImage,
|
||||
draggable: "false",
|
||||
loading: "lazy",
|
||||
align: "absmiddle",
|
||||
},
|
||||
]
|
||||
: emojiItem.emoji || `:${emojiItem.shortcodes[0]}:`,
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis);
|
||||
|
||||
return emojiItem?.emoji || `:${node.attrs.name}:`;
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setEmoji:
|
||||
(shortcode) =>
|
||||
({ chain }) => {
|
||||
const emojiItem = shortcodeToEmoji(shortcode, this.options.emojis);
|
||||
|
||||
if (!emojiItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
name: emojiItem.name,
|
||||
},
|
||||
})
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks());
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
const inputRules: InputRule[] = [];
|
||||
|
||||
inputRules.push(
|
||||
new InputRule({
|
||||
find: inputRegex,
|
||||
handler: ({ range, match, chain }) => {
|
||||
const name = match[1];
|
||||
|
||||
if (!shortcodeToEmoji(name, this.options.emojis)) {
|
||||
return;
|
||||
}
|
||||
|
||||
chain()
|
||||
.insertContentAt(range, {
|
||||
type: this.name,
|
||||
attrs: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks());
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (this.options.enableEmoticons) {
|
||||
// get the list of supported emoticons
|
||||
const emoticons = this.options.emojis
|
||||
.map((item) => item.emoticons)
|
||||
.flat()
|
||||
.filter((item) => item) as string[];
|
||||
|
||||
const emoticonRegex = new RegExp(`(?:^|\\s)(${emoticons.map((item) => escapeForRegEx(item)).join("|")}) $`);
|
||||
|
||||
inputRules.push(
|
||||
nodeInputRule({
|
||||
find: emoticonRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const emoji = this.options.emojis.find((item) => item.emoticons?.includes(match[1]));
|
||||
|
||||
if (!emoji) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
name: emoji.name,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return inputRules;
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
new PasteRule({
|
||||
find: pasteRegex,
|
||||
handler: ({ range, match, chain }) => {
|
||||
const name = match[1];
|
||||
|
||||
if (!shortcodeToEmoji(name, this.options.emojis)) {
|
||||
return;
|
||||
}
|
||||
|
||||
chain()
|
||||
.insertContentAt(
|
||||
range,
|
||||
{
|
||||
type: this.name,
|
||||
attrs: {
|
||||
name,
|
||||
},
|
||||
},
|
||||
{
|
||||
updateSelection: false,
|
||||
}
|
||||
)
|
||||
.command(({ tr, state }) => {
|
||||
tr.setStoredMarks(state.doc.resolve(state.selection.to - 1).marks());
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const isTouchDevice = !!this.editor.storage.utility.isTouchDevice;
|
||||
if (isTouchDevice) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
findSuggestionMatch: customFindSuggestionMatch,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
|
||||
new Plugin({
|
||||
key: new PluginKey("emoji"),
|
||||
props: {
|
||||
// double click to select emoji doesn’t work by default
|
||||
// that’s why we simulate this behavior
|
||||
handleDoubleClickOn: (view, pos, node) => {
|
||||
if (node.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const from = pos;
|
||||
const to = from + node.nodeSize;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
||||
// replace text emojis with emoji node on any change
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
const docChanges =
|
||||
transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);
|
||||
|
||||
if (!docChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tr } = newState;
|
||||
const transform = combineTransactionSteps(oldState.doc, transactions as Transaction[]);
|
||||
const changes = getChangedRanges(transform);
|
||||
|
||||
changes.forEach(({ newRange }) => {
|
||||
// We don’t want to add emoji inline nodes within code blocks.
|
||||
// Because this would split the code block.
|
||||
|
||||
// This only works if the range of changes is within a code node.
|
||||
// For all other cases (e.g. the whole document is set/pasted and the parent of the range is `doc`)
|
||||
// it doesn't and we have to double check later.
|
||||
if (newState.doc.resolve(newRange.from).parent.type.spec.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textNodes = findChildrenInRange(newState.doc, newRange, (node) => node.type.isText);
|
||||
|
||||
textNodes.forEach(({ node, pos }) => {
|
||||
if (!node.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = [...node.text.matchAll(emojiRegex())];
|
||||
|
||||
matches.forEach((match) => {
|
||||
if (match.index === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emoji = match[0];
|
||||
const name = emojiToShortcode(emoji, this.options.emojis);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = tr.mapping.map(pos + match.index);
|
||||
|
||||
// Double check parent node is not a code block.
|
||||
if (newState.doc.resolve(from).parent.type.spec.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const to = from + emoji.length;
|
||||
const emojiNode = this.type.create({
|
||||
name,
|
||||
});
|
||||
|
||||
tr.replaceRangeWith(from, to, emojiNode);
|
||||
|
||||
tr.setStoredMarks(newState.doc.resolve(from).marks());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!tr.steps.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return tr;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
38
packages/editor/src/extensions/emoji/extension.ts
Normal file
38
packages/editor/src/extensions/emoji/extension.ts
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
// local imports
|
||||
import { gitHubEmojis, shortcodeToEmoji } from "@tiptap/extension-emoji";
|
||||
import type { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Emoji } from "./emoji";
|
||||
import { emojiSuggestion } from "./suggestion";
|
||||
|
||||
export const EmojiExtension = Emoji.extend({
|
||||
addStorage() {
|
||||
const extensionOptions = this.options;
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
markdown: {
|
||||
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
|
||||
const emojiItem = shortcodeToEmoji(node.attrs.name, extensionOptions.emojis);
|
||||
if (emojiItem?.emoji) {
|
||||
state.write(emojiItem?.emoji);
|
||||
} else {
|
||||
state.write(`:${node.attrs.name}:`);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
// Filter out emojis without emoji value and remove fallbackImage property to prevent CDN calls
|
||||
|
||||
emojis: gitHubEmojis.filter((item) => item.emoji).map(({ fallbackImage, ...emoji }) => emoji),
|
||||
suggestion: emojiSuggestion,
|
||||
enableEmoticons: true,
|
||||
});
|
||||
111
packages/editor/src/extensions/emoji/suggestion.ts
Normal file
111
packages/editor/src/extensions/emoji/suggestion.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { EmojiOptions, EmojiStorage } from "@tiptap/extension-emoji";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
|
||||
import type { CommandListInstance } from "@/helpers/tippy";
|
||||
import { DROPDOWN_NAVIGATION_KEYS } from "@/helpers/tippy";
|
||||
// local imports
|
||||
import { EmojisListDropdown } from "./components/emojis-list";
|
||||
import type { EmojisListDropdownProps, EmojiItem } from "./components/emojis-list";
|
||||
import type { ExtendedEmojiStorage } from "./emoji";
|
||||
|
||||
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
|
||||
|
||||
export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
|
||||
const { emojis } = editor.storage.emoji as EmojiStorage;
|
||||
|
||||
if (query.trim() === "") {
|
||||
const defaultEmojis = DEFAULT_EMOJIS.map((name) =>
|
||||
emojis.find((emoji) => emoji.shortcodes.includes(name) || emoji.name === name)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
return defaultEmojis as EmojiItem[];
|
||||
}
|
||||
return emojis
|
||||
.filter(({ shortcodes, tags }) => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return (
|
||||
shortcodes.find((shortcode: string) => shortcode.startsWith(lowerQuery)) ||
|
||||
tags.find((tag: string) => tag.startsWith(lowerQuery))
|
||||
);
|
||||
})
|
||||
.slice(0, 5) as EmojiItem[];
|
||||
},
|
||||
|
||||
allowSpaces: false,
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<CommandListInstance, EmojisListDropdownProps> | null = null;
|
||||
let cleanup: () => void = () => {};
|
||||
let editorRef: Editor | null = null;
|
||||
|
||||
const handleClose = (editor?: Editor) => {
|
||||
component?.destroy();
|
||||
component = null;
|
||||
(editor || editorRef)?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.EMOJI);
|
||||
const emojiStorage = editor?.storage.emoji as ExtendedEmojiStorage;
|
||||
emojiStorage.forceOpen = false;
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
const emojiStorage = props.editor.storage.emoji as ExtendedEmojiStorage;
|
||||
const forceOpen = emojiStorage.forceOpen || false;
|
||||
component = new ReactRenderer<CommandListInstance, EmojisListDropdownProps>(EmojisListDropdown, {
|
||||
props: {
|
||||
...props,
|
||||
onClose: () => handleClose(props.editor),
|
||||
forceOpen,
|
||||
} satisfies EmojisListDropdownProps,
|
||||
editor: props.editor,
|
||||
className: "fixed z-[100]",
|
||||
});
|
||||
if (!props.clientRect) return;
|
||||
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.EMOJI);
|
||||
const element = component.element as HTMLElement;
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, element).cleanup;
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
if (!component || !component.element) return;
|
||||
const emojiStorage = props.editor.storage.emoji as ExtendedEmojiStorage;
|
||||
const forceOpen = emojiStorage.forceOpen || false;
|
||||
component.updateProps({ ...props, forceOpen });
|
||||
if (!props.clientRect) return;
|
||||
cleanup();
|
||||
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
handleClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown({ event }) ?? false;
|
||||
},
|
||||
|
||||
onExit: ({ editor }) => {
|
||||
component?.element.remove();
|
||||
handleClose(editor);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
38
packages/editor/src/extensions/enter-key.ts
Normal file
38
packages/editor/src/extensions/enter-key.ts
Normal file
@@ -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 { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
Extension.create({
|
||||
name: CORE_EXTENSIONS.ENTER_KEY,
|
||||
|
||||
addKeyboardShortcuts(this) {
|
||||
return {
|
||||
Enter: () => {
|
||||
const { activeDropbarExtensions } = this.editor.storage.utility;
|
||||
|
||||
if (activeDropbarExtensions.length === 0) {
|
||||
onEnterKeyPress?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
"Shift-Enter": ({ editor }) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
() => commands.newlineInCode(),
|
||||
() => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM),
|
||||
() => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM),
|
||||
() => commands.createParagraphNear(),
|
||||
() => commands.liftEmptyBlock(),
|
||||
() => commands.splitBlock(),
|
||||
]),
|
||||
};
|
||||
},
|
||||
});
|
||||
156
packages/editor/src/extensions/extensions.ts
Normal file
156
packages/editor/src/extensions/extensions.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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 { Extensions } from "@tiptap/core";
|
||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
||||
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";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// extensions
|
||||
import {
|
||||
CustomCalloutExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionExtension,
|
||||
CustomQuoteExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomTypographyExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
UtilityExtension,
|
||||
} from "@/extensions";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/extensions/core";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
import { EmojiExtension } from "./emoji/extension";
|
||||
import { CustomPlaceholderExtension } from "./placeholder";
|
||||
import { CustomStarterKitExtension } from "./starter-kit";
|
||||
import { UniqueID } from "./unique-id/extension";
|
||||
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
| "disabledExtensions"
|
||||
| "flaggedExtensions"
|
||||
| "fileHandler"
|
||||
| "getEditorMetaData"
|
||||
| "isTouchDevice"
|
||||
| "mentionHandler"
|
||||
| "placeholder"
|
||||
| "showPlaceholderOnEmpty"
|
||||
| "tabIndex"
|
||||
| "extendedEditorProps"
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
editable: boolean;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
getEditorMetaData,
|
||||
isTouchDevice = false,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
showPlaceholderOnEmpty,
|
||||
tabIndex,
|
||||
editable,
|
||||
extendedEditorProps,
|
||||
provider,
|
||||
} = args;
|
||||
|
||||
const extensions = [
|
||||
CustomStarterKitExtension({
|
||||
enableHistory,
|
||||
}),
|
||||
EmojiExtension,
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomKeymap,
|
||||
ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension,
|
||||
CustomTypographyExtension,
|
||||
Underline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "relative",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: false,
|
||||
transformPastedText: true,
|
||||
breaks: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionExtension(mentionHandler),
|
||||
CustomPlaceholderExtension({ placeholder, showPlaceholderOnEmpty }),
|
||||
CharacterCount,
|
||||
CustomColorExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
UtilityExtension({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
getEditorMetaData,
|
||||
isEditable: editable,
|
||||
isTouchDevice,
|
||||
}),
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
extendedEditorProps,
|
||||
}),
|
||||
UniqueID.configure({
|
||||
provider,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ImageExtension({
|
||||
fileHandler,
|
||||
}),
|
||||
CustomImageExtension({
|
||||
fileHandler,
|
||||
isEditable: editable,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user