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:
@@ -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>[];
|
||||
};
|
||||
Reference in New Issue
Block a user