[PE-93] refactor: editor mentions extension (#6178)
* refactor: editor mentions * fix: build errors * fix: build errors * chore: add cycle status to search endpoint response * fix: build errors * fix: dynamic mention content in markdown * chore: update entity search endpoint * style: user mention popover * chore: edition specific mention content handler * chore: show deactivated user for old mentions * chore: update search entity keys * refactor: use editor mention hook
This commit is contained in:
parent
c10b875e2a
commit
119d343d5f
78 changed files with 1491 additions and 992 deletions
|
|
@ -37,6 +37,7 @@
|
|||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
|
|||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
|
||||
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
|
|
@ -23,9 +23,7 @@ interface IDocumentReadOnlyEditor {
|
|||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Editor, Extension } from "@tiptap/core";
|
||||
import { Editor, Extensions } from "@tiptap/core";
|
||||
// components
|
||||
import { EditorContainer } from "@/components/editors";
|
||||
// constants
|
||||
|
|
@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content";
|
|||
|
||||
type Props = IEditorProps & {
|
||||
children?: (editor: Editor) => React.ReactNode;
|
||||
extensions: Extension<any, any>[];
|
||||
extensions: Extensions;
|
||||
};
|
||||
|
||||
export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { CustomHorizontalRule } from "./horizontal-rule";
|
|||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
|
|
@ -97,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
|||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
CustomMentionExtensionConfig,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtensionConfig,
|
||||
CustomColorExtension,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
CustomImageExtension,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMention,
|
||||
CustomMentionExtension,
|
||||
CustomQuoteExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomTypographyExtension,
|
||||
|
|
@ -33,7 +33,7 @@ import {
|
|||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
|
||||
|
|
@ -41,17 +41,14 @@ type TArguments = {
|
|||
disabledExtensions: TExtensions[];
|
||||
enableHistory: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
mentionConfig: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
|
|
@ -146,11 +143,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
|||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMention({
|
||||
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: !editable,
|
||||
}),
|
||||
CustomMentionExtension(mentionHandler),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (!editor.isEditable) return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Mention, { MentionOptions } from "@tiptap/extension-mention";
|
||||
// types
|
||||
import { TMentionHandler } from "@/types";
|
||||
// local types
|
||||
import { EMentionComponentAttributeNames } from "./types";
|
||||
|
||||
export type TMentionExtensionOptions = MentionOptions & {
|
||||
renderComponent: TMentionHandler["renderComponent"];
|
||||
};
|
||||
|
||||
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
[EMentionComponentAttributeNames.ID]: {
|
||||
default: null,
|
||||
},
|
||||
[EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: {
|
||||
default: null,
|
||||
},
|
||||
[EMentionComponentAttributeNames.ENTITY_NAME]: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "mention-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
|
||||
addStorage(this) {
|
||||
return {
|
||||
mentionsOpen: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -1,154 +1,31 @@
|
|||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import Mention, { MentionOptions } from "@tiptap/extension-mention";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
// extensions
|
||||
import { MentionList, MentionNodeView } from "@/extensions";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion } from "@/types";
|
||||
import { TMentionHandler } from "@/types";
|
||||
// extension config
|
||||
import { CustomMentionExtensionConfig } from "./extension-config";
|
||||
// node view
|
||||
import { MentionNodeView } from "./mention-node-view";
|
||||
// utils
|
||||
import { renderMentionsDropdown } from "./utils";
|
||||
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const CustomMention = ({
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
readonly,
|
||||
}: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
readonly: boolean;
|
||||
}) =>
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
addStorage(this) {
|
||||
export const CustomMentionExtension = (props: TMentionHandler) => {
|
||||
const { searchCallback, renderComponent } = props;
|
||||
return CustomMentionExtensionConfig.extend({
|
||||
addOptions(this) {
|
||||
return {
|
||||
mentionsOpen: false,
|
||||
markdown: {
|
||||
serialize(state: MarkdownSerializerState, node: Node) {
|
||||
const { attrs } = node;
|
||||
const label = `@${state.esc(attrs.label)}`;
|
||||
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
const safeRedirectionPath = state.esc(attrs.redirect_uri);
|
||||
const url = `${originUrl}${safeRedirectionPath}`;
|
||||
state.write(`[${label}](${url})`);
|
||||
},
|
||||
},
|
||||
...this.parent?.(),
|
||||
renderComponent,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false,
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/",
|
||||
},
|
||||
entity_identifier: {
|
||||
default: null,
|
||||
},
|
||||
entity_name: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionNodeView);
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "mention-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
readonly: readonly,
|
||||
mentionHighlights,
|
||||
suggestion: {
|
||||
// @ts-expect-error - Tiptap types are incorrect
|
||||
render: () => {
|
||||
if (!mentionSuggestions) return;
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props: { ...props, mentionSuggestions },
|
||||
editor: props.editor,
|
||||
});
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () =>
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
component?.ref?.onKeyDown(props);
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
render: renderMentionsDropdown({
|
||||
searchCallback,
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
export * from "./extension";
|
||||
export * from "./mention-node-view";
|
||||
export * from "./mentions-list";
|
||||
export * from "./mentions-without-props";
|
||||
export * from "./extension-config";
|
||||
|
|
|
|||
|
|
@ -1,49 +1,26 @@
|
|||
// TODO: fix all warnings
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
// extension config
|
||||
import { TMentionExtensionOptions } from "./extension-config";
|
||||
// extension types
|
||||
import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types";
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export const MentionNodeView = (props) => {
|
||||
// TODO: move it to web app
|
||||
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.extension.options.mentionHighlights) return;
|
||||
const hightlights = async () => {
|
||||
const userId = await props.extension.options.mentionHighlights?.();
|
||||
setHighlightsState(userId);
|
||||
};
|
||||
hightlights();
|
||||
}, [props.extension.options]);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!props.node.attrs.redirect_uri) {
|
||||
event.preventDefault();
|
||||
}
|
||||
type Props = NodeViewProps & {
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TMentionComponentAttributes;
|
||||
};
|
||||
};
|
||||
|
||||
export const MentionNodeView = (props: Props) => {
|
||||
const {
|
||||
extension,
|
||||
node: { attrs },
|
||||
} = props;
|
||||
return (
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
<a
|
||||
href={props.node.attrs.redirect_uri || "#"}
|
||||
target="_blank"
|
||||
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
||||
"bg-yellow-500/20 text-yellow-500": highlightsState
|
||||
? highlightsState.includes(props.node.attrs.entity_identifier)
|
||||
: false,
|
||||
"cursor-pointer": !props.extension.options.readonly,
|
||||
})}
|
||||
>
|
||||
@{props.node.attrs.label}
|
||||
</a>
|
||||
{(extension.options as TMentionExtensionOptions).renderComponent({
|
||||
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER],
|
||||
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
|
||||
// types
|
||||
import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types";
|
||||
|
||||
export type MentionsListDropdownProps = {
|
||||
command: (item: TMentionSuggestion) => void;
|
||||
query: string;
|
||||
editor: Editor;
|
||||
} & Pick<TMentionHandler, "searchCallback">;
|
||||
|
||||
export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => {
|
||||
const { command, query, searchCallback } = props;
|
||||
// states
|
||||
const [sections, setSections] = useState<TMentionSection[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState({
|
||||
section: 0,
|
||||
item: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// refs
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(sectionIndex: number, itemIndex: number) => {
|
||||
try {
|
||||
const item = sections?.[sectionIndex]?.items?.[itemIndex];
|
||||
const transactionId = uuidv4();
|
||||
if (item) {
|
||||
command({
|
||||
...item,
|
||||
id: transactionId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting mention item:", error);
|
||||
}
|
||||
},
|
||||
[command, sections]
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === "Enter") {
|
||||
selectItem(selectedIndex.section, selectedIndex.item);
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = getNextValidIndex({
|
||||
event,
|
||||
sections,
|
||||
selectedIndex,
|
||||
});
|
||||
setSelectedIndex(newIndex);
|
||||
},
|
||||
}));
|
||||
|
||||
// initialize the select index to 0 by default
|
||||
useEffect(() => {
|
||||
setSelectedIndex({
|
||||
section: 0,
|
||||
item: 0,
|
||||
});
|
||||
}, [sections]);
|
||||
|
||||
// fetch mention sections based on query
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const sectionsResponse = await searchCallback?.(query);
|
||||
setSections(sectionsResponse);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggestions:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSuggestions();
|
||||
}, [query, searchCallback]);
|
||||
|
||||
// scroll to the dropdown item when navigating via keyboard
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
if (!container) return;
|
||||
|
||||
const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
|
||||
if (item) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
|
||||
const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;
|
||||
|
||||
if (!isItemInView) {
|
||||
item.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="z-10 max-h-[90vh] w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-custom-text-400">Loading...</div>
|
||||
) : sections.length ? (
|
||||
sections.map((section, sectionIndex) => (
|
||||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`mention-item-${sectionIndex}-${itemIndex}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": isSelected,
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
|
||||
{item.subTitle && (
|
||||
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
|
||||
)}
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-sm text-custom-text-400">No results</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MentionsListDropdown.displayName = "MentionsListDropdown";
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// types
|
||||
import { IMentionSuggestion } from "@/types";
|
||||
|
||||
interface MentionListProps {
|
||||
command: (item: {
|
||||
id: string;
|
||||
label: string;
|
||||
entity_name: string;
|
||||
entity_identifier: string;
|
||||
target: string;
|
||||
redirect_uri: string;
|
||||
}) => void;
|
||||
query: string;
|
||||
editor: Editor;
|
||||
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
|
||||
}
|
||||
|
||||
export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
const { query, mentionSuggestions } = props;
|
||||
const [items, setItems] = useState<IMentionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const suggestions = await mentionSuggestions();
|
||||
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
|
||||
const transactionId = uuidv4();
|
||||
return {
|
||||
...suggestion,
|
||||
id: transactionId,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
|
||||
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
|
||||
);
|
||||
|
||||
setItems(filteredSuggestions);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggestions:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSuggestions();
|
||||
}, [query, mentionSuggestions]);
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
try {
|
||||
const item = items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
entity_identifier: item.entity_identifier,
|
||||
entity_name: item.entity_name,
|
||||
target: "users",
|
||||
redirect_uri: item.redirect_uri,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="mentions max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-custom-text-400">Loading...</div>
|
||||
) : items.length ? (
|
||||
items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
)}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<Avatar name={item?.title} src={item?.avatar} />
|
||||
<span className="flex-grow truncate">{item.title}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-custom-text-400">No results</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MentionList.displayName = "MentionList";
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { mergeAttributes } from "@tiptap/core";
|
||||
import Mention, { MentionOptions } from "@tiptap/extension-mention";
|
||||
// types
|
||||
import { IMentionHighlight } from "@/types";
|
||||
|
||||
interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const CustomMentionWithoutProps = () =>
|
||||
Mention.extend<CustomMentionOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false,
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/",
|
||||
},
|
||||
entity_identifier: {
|
||||
default: null,
|
||||
},
|
||||
entity_name: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "mention-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
});
|
||||
14
packages/editor/src/core/extensions/mentions/types.ts
Normal file
14
packages/editor/src/core/extensions/mentions/types.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// plane types
|
||||
import { TSearchEntities } from "@plane/types";
|
||||
|
||||
export enum EMentionComponentAttributeNames {
|
||||
ID = "id",
|
||||
ENTITY_IDENTIFIER = "entity_identifier",
|
||||
ENTITY_NAME = "entity_name",
|
||||
}
|
||||
|
||||
export type TMentionComponentAttributes = {
|
||||
[EMentionComponentAttributeNames.ID]: string | null;
|
||||
[EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: string | null;
|
||||
[EMentionComponentAttributeNames.ENTITY_NAME]: TSearchEntities | null;
|
||||
};
|
||||
72
packages/editor/src/core/extensions/mentions/utils.ts
Normal file
72
packages/editor/src/core/extensions/mentions/utils.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Editor } from "@tiptap/core";
|
||||
import { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
import { TMentionHandler } from "@/types";
|
||||
// local components
|
||||
import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown";
|
||||
|
||||
export const renderMentionsDropdown =
|
||||
(props: Pick<TMentionHandler, "searchCallback">): SuggestionOptions["render"] =>
|
||||
// @ts-expect-error - Tiptap types are incorrect
|
||||
() => {
|
||||
const { searchCallback } = props;
|
||||
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
if (!searchCallback) return;
|
||||
if (!props.clientRect) return;
|
||||
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
|
||||
props: {
|
||||
...props,
|
||||
searchCallback,
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () =>
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
props.event?.stopPropagation();
|
||||
if (component?.ref?.onKeyDown(props)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
TableCell,
|
||||
TableRow,
|
||||
Table,
|
||||
CustomMention,
|
||||
CustomMentionExtension,
|
||||
CustomReadOnlyImageExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
|
|
@ -28,20 +28,18 @@ import {
|
|||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, TExtensions, TFileHandler } from "@/types";
|
||||
import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
mentionConfig: {
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
const { disabledExtensions, fileHandler, mentionConfig } = props;
|
||||
const { disabledExtensions, fileHandler, mentionHandler } = props;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
|
|
@ -132,10 +130,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
|||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMention({
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: true,
|
||||
}),
|
||||
CustomMentionExtension(mentionHandler),
|
||||
CharacterCount,
|
||||
CustomColorExtension,
|
||||
CustomTextAlignExtension,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
// helpers
|
||||
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
|
||||
// components
|
||||
import { TSlashCommandSection } from "./command-items-list";
|
||||
import { CommandMenuItem } from "./command-menu-item";
|
||||
|
||||
export type SlashCommandsMenuProps = {
|
||||
editor: Editor;
|
||||
items: TSlashCommandSection[];
|
||||
command: any;
|
||||
};
|
||||
|
||||
export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
|
||||
const { items: sections, command } = props;
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState({
|
||||
|
|
@ -41,12 +45,12 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
|||
if (nextItem < 0) {
|
||||
nextSection = currentSection - 1;
|
||||
if (nextSection < 0) nextSection = sections.length - 1;
|
||||
nextItem = sections[nextSection]?.items.length - 1;
|
||||
nextItem = sections[nextSection]?.items?.length - 1;
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
nextItem = currentItem + 1;
|
||||
if (nextItem >= sections[currentSection].items.length) {
|
||||
if (nextItem >= sections[currentSection]?.items?.length) {
|
||||
nextSection = currentSection + 1;
|
||||
if (nextSection >= sections.length) nextSection = 0;
|
||||
nextItem = 0;
|
||||
|
|
@ -84,7 +88,26 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
|||
item?.scrollIntoView({ block: "nearest" });
|
||||
}, [sections, selectedIndex]);
|
||||
|
||||
const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === "Enter") {
|
||||
selectItem(selectedIndex.section, selectedIndex.item);
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = getNextValidIndex({
|
||||
event,
|
||||
sections,
|
||||
selectedIndex,
|
||||
});
|
||||
setSelectedIndex(newIndex);
|
||||
},
|
||||
}));
|
||||
|
||||
const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0;
|
||||
|
||||
if (areSearchResultsEmpty) return null;
|
||||
|
||||
|
|
@ -98,7 +121,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
|||
<div key={section.key} className="space-y-2">
|
||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||
<div>
|
||||
{section.items.map((item, itemIndex) => (
|
||||
{section.items?.map((item, itemIndex) => (
|
||||
<CommandMenuItem
|
||||
key={item.key}
|
||||
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||
|
|
@ -122,4 +145,6 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
SlashCommandsMenu.displayName = "SlashCommandsMenu";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Editor, Range, Extension } from "@tiptap/core";
|
|||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
// components
|
||||
|
|
@ -55,16 +57,12 @@ const Command = Extension.create<SlashCommandOptions>({
|
|||
},
|
||||
});
|
||||
|
||||
interface CommandListInstance {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
|
||||
let popup: any | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(SlashCommandsMenu, {
|
||||
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
|
@ -91,10 +89,8 @@ const renderItems = () => {
|
|||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (component?.ref?.onKeyDown(props)) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
58
packages/editor/src/core/helpers/tippy.ts
Normal file
58
packages/editor/src/core/helpers/tippy.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export type CommandListInstance = {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
event: KeyboardEvent;
|
||||
sections: {
|
||||
items: any[];
|
||||
}[];
|
||||
selectedIndex: {
|
||||
section: number;
|
||||
item: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const DROPDOWN_NAVIGATION_KEYS = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
export const getNextValidIndex = (
|
||||
args: TArgs
|
||||
):
|
||||
| {
|
||||
section: number;
|
||||
item: number;
|
||||
}
|
||||
| undefined => {
|
||||
const { event, sections, selectedIndex } = args;
|
||||
const direction = event.key === "ArrowUp" ? "up" : "down";
|
||||
if (!sections.length) return { section: 0, item: 0 };
|
||||
// next available selection
|
||||
let nextSection = selectedIndex.section;
|
||||
let nextItem = selectedIndex.item;
|
||||
|
||||
if (direction === "up") {
|
||||
nextItem--;
|
||||
if (nextItem < 0) {
|
||||
// Move to previous section
|
||||
nextSection--;
|
||||
if (nextSection < 0) {
|
||||
// Wrap to last section
|
||||
nextSection = sections?.length - 1;
|
||||
}
|
||||
nextItem = sections?.[nextSection]?.items?.length - 1;
|
||||
}
|
||||
} else {
|
||||
nextItem++;
|
||||
if (nextItem >= sections?.[nextSection]?.items?.length) {
|
||||
// Move to next section
|
||||
nextSection++;
|
||||
if (nextSection >= sections?.length) {
|
||||
// Wrap to first section
|
||||
nextSection = 0;
|
||||
}
|
||||
nextItem = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { section: nextSection, item: nextItem };
|
||||
};
|
||||
|
|
@ -3,7 +3,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
|
|||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
|
||||
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
||||
|
|
@ -19,11 +19,10 @@ import { CoreEditorProps } from "@/props";
|
|||
import type {
|
||||
TDocumentEventsServer,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TEditorCommands,
|
||||
TFileHandler,
|
||||
TExtensions,
|
||||
TMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
|
|
@ -32,16 +31,13 @@ export interface CustomEditorProps {
|
|||
editorProps?: EditorProps;
|
||||
enableHistory: boolean;
|
||||
disabledExtensions: TExtensions[];
|
||||
extensions?: any;
|
||||
extensions?: Extensions;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id?: string;
|
||||
initialValue?: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
mentionHandler: TMentionHandler;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
autofocus?: boolean;
|
||||
|
|
@ -96,10 +92,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
|||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
|
|
@ -13,24 +13,22 @@ import { CoreReadOnlyEditorProps } from "@/props";
|
|||
// types
|
||||
import type {
|
||||
EditorReadOnlyRefApi,
|
||||
IMentionHighlight,
|
||||
TExtensions,
|
||||
TDocumentEventsServer,
|
||||
TFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: any;
|
||||
extensions?: Extensions;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
initialValue?: string;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
provider?: HocuspocusProvider;
|
||||
}
|
||||
|
||||
|
|
@ -63,9 +61,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
|||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
disabledExtensions,
|
||||
mentionConfig: {
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
mentionHandler,
|
||||
fileHandler,
|
||||
}),
|
||||
...extensions,
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types";
|
|||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TRealtimeConfig,
|
||||
TUserDetails,
|
||||
} from "@/types";
|
||||
|
|
@ -27,10 +27,6 @@ type TCollaborativeEditorHookProps = {
|
|||
extensions?: Extensions;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
|
|
@ -41,6 +37,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
|||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
|
@ -48,4 +45,5 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
|||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { JSONContent } from "@tiptap/core";
|
||||
import { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// helpers
|
||||
import { IMarking } from "@/helpers/scroll-to-node";
|
||||
// types
|
||||
import {
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TDisplayConfig,
|
||||
TDocumentEventEmitter,
|
||||
|
|
@ -13,6 +11,8 @@ import {
|
|||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
import { TTextAlign } from "@/extensions";
|
||||
|
|
@ -114,10 +114,7 @@ export interface IEditorProps {
|
|||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
mentionHandler: TMentionHandler;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
|
|
@ -128,10 +125,10 @@ export interface IEditorProps {
|
|||
value?: string | null;
|
||||
}
|
||||
export interface ILiteTextEditor extends IEditorProps {
|
||||
extensions?: any[];
|
||||
extensions?: Extensions;
|
||||
}
|
||||
export interface IRichTextEditor extends IEditorProps {
|
||||
extensions?: any[];
|
||||
extensions?: Extensions;
|
||||
bubbleMenuEnabled?: boolean;
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
|
|
@ -158,9 +155,7 @@ export interface IReadOnlyEditorProps {
|
|||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
}
|
||||
|
||||
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export * from "./editor";
|
|||
export * from "./embed";
|
||||
export * from "./extensions";
|
||||
export * from "./image";
|
||||
export * from "./mention-suggestion";
|
||||
export * from "./mention";
|
||||
export * from "./slash-commands-suggestion";
|
||||
export * from "@/plane-editor/types";
|
||||
export * from "./document-collaborative-events";
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
entity_name: string;
|
||||
entity_identifier: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
|
||||
export type IMentionHighlight = string;
|
||||
27
packages/editor/src/core/types/mention.ts
Normal file
27
packages/editor/src/core/types/mention.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// plane types
|
||||
import { TSearchEntities } from "@plane/types";
|
||||
|
||||
export type TMentionSuggestion = {
|
||||
entity_identifier: string;
|
||||
entity_name: TSearchEntities;
|
||||
icon: React.ReactNode;
|
||||
id: string;
|
||||
subTitle?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type TMentionSection = {
|
||||
key: string;
|
||||
title?: string;
|
||||
items: TMentionSuggestion[];
|
||||
};
|
||||
|
||||
export type TMentionComponentProps = Pick<TMentionSuggestion, "entity_identifier" | "entity_name">;
|
||||
|
||||
export type TReadOnlyMentionHandler = {
|
||||
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
|
||||
};
|
||||
|
||||
export type TMentionHandler = TReadOnlyMentionHandler & {
|
||||
searchCallback?: (query: string) => Promise<TMentionSection[]>;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue