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

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -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;

View File

@@ -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 };

View 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 };

View 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";

View 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>
);
}

View File

@@ -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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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";

View 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>
)}
</>
);
}

View 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 };

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";

View 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 };

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";

View 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} />
</>
);
}

View 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";

View 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>
);
}

View 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>
);
}

View 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} />}
</>
);
}

View 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>
);
}

View 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),
},
];

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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 "./color-selector";
export * from "./node-selector";
export * from "./root";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@@ -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,
};
};

View 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";

View 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>[];
};