[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:
Aaryan Khandelwal 2024-12-20 13:41:25 +05:30 committed by GitHub
parent c10b875e2a
commit 119d343d5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 1491 additions and 992 deletions

View file

@ -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",

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View file

@ -28,6 +28,7 @@ export * from "./workspace-views";
export * from "./common";
export * from "./pragmatic";
export * from "./publish";
export * from "./search";
export * from "./workspace-notifications";
export * from "./favorite";
export * from "./file";

81
packages/types/src/search.d.ts vendored Normal file
View file

@ -0,0 +1,81 @@
import { ICycle } from "./cycle";
import { TIssue } from "./issues/issue";
import { IModule } from "./module";
import { TPage } from "./pages";
import { IProject } from "./project";
import { IUser } from "./users";
import { IWorkspace } from "./workspace";
export type TSearchEntities =
| "user_mention"
| "issue_mention"
| "project_mention"
| "cycle_mention"
| "module_mention"
| "page_mention";
export type TUserSearchResponse = {
member__avatar_url: IUser["avatar_url"];
member__display_name: IUser["display_name"];
member__id: IUser["id"];
};
export type TProjectSearchResponse = {
name: IProject["name"];
id: IProject["id"];
identifier: IProject["identifier"];
logo_props: IProject["logo_props"];
workspace__slug: IWorkspace["slug"];
};
export type TIssueSearchResponse = {
name: TIssue["name"];
id: TIssue["id"];
sequence_id: TIssue["sequence_id"];
project__identifier: IProject["identifier"];
project_id: TIssue["project_id"];
priority: TIssue["priority"];
state_id: TIssue["state_id"];
type_id: TIssue["type_id"];
};
export type TCycleSearchResponse = {
name: ICycle["name"];
id: ICycle["id"];
project_id: ICycle["project_id"];
project__identifier: IProject["identifier"];
status: ICycle["status"];
workspace__slug: IWorkspace["slug"];
};
export type TModuleSearchResponse = {
name: IModule["name"];
id: IModule["id"];
project_id: IModule["project_id"];
project__identifier: IProject["identifier"];
status: IModule["status"];
workspace__slug: IWorkspace["slug"];
};
export type TPageSearchResponse = {
name: TPage["name"];
id: TPage["id"];
logo_props: TPage["logo_props"];
projects__id: TPage["project_ids"];
workspace__slug: IWorkspace["slug"];
};
export type TSearchResponse = {
cycle_mention?: TCycleSearchResponse[];
issue_mention?: TIssueSearchResponse[];
module_mention?: TModuleSearchResponse[];
page_mention?: TPageSearchResponse[];
project_mention?: TProjectSearchResponse[];
user_mention?: TUserSearchResponse[];
};
export type TSearchEntityRequestPayload = {
count: number;
query_type: TSearchEntities[];
query: string;
};