[FEATURE] Enabled User @mentions and @mention-filters in core editor package (#2544)
* feat: created custom mention component * feat: added mention suggestions and suggestion highlights * feat: created mention suggestion list for displaying mention suggestions * feat: created custom mention text component, for handling click event * feat: exposed mention component * feat: integrated and exposed `mentions` componenet with `editor-core` * feat: integrated mentions extension with the core editor package * feat: exposed suggestion types from mentions * feat: added `mention-suggestion` parameters in `r-t-e` and `l-t-e` * feat: added `IssueMention` model in apiserver models * chore: updated activities background job and added bs4 in requirements * feat: added mention removal logic in issue_activity * chore: exposed mention types from `r-t-e` and `l-t-e` * feat: integrated mentions in side peek view description form * feat: added mentions in issue modal form * feat: created custom react-hook for editor suggestions * feat: integrated mention suggestions block in RichTextEditor * feat: added `mentions` integration in `lite-text-editor` instances * fix: tailwind loading nodemodules from packages * feat: added styles for the mention suggestion list * fix: update module import to resolve build failure * feat: added mentions as an issue filter * feat: added UI Changes to Implement `mention` filters * feat: added `mentions` as a filter option in the header * feat: added mentions in the filter list options * feat: added mentions in default display filter options * feat: added filters in applied and issue params in store * feat: modified types for adding mentions as a filter option * feat: modified `notification-card` to display message when it exists in object * feat: rewrote user mention management upon the changes made in develop * chore: merged debounce PR with the current PR for tracing changes * fix: mentions_filters updated with the new setup * feat: updated requirements for bs4 * feat: modified `mentions-filter` to remove many to many dependency * feat: implemented list manipulation instead of for loop * feat: added readonly functionality in `read-only` editor core * feat: added UI Changes for read-only mode * feat: added mentions store in web Root Store * chore: renamed `use-editor-suggestions` hook * feat: UI Improvements for conditional highlights w.r.t readonly in mentionNode * fix: removed mentions from `filter_set` parameters * fix: minor merge fixes * fix: package lock updates --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
490e032ac6
commit
d511799f31
60 changed files with 1662 additions and 52 deletions
|
|
@ -21,18 +21,18 @@
|
|||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1"
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-moveable" : "^0.54.2",
|
||||
"@blueprintjs/popover2": "^2.0.10",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-color": "^2.1.11",
|
||||
"@tiptap/extension-image": "^2.1.7",
|
||||
"@tiptap/extension-link": "^2.1.7",
|
||||
"@tiptap/extension-mention": "^2.1.12",
|
||||
"@tiptap/extension-table": "^2.1.6",
|
||||
"@tiptap/extension-table-cell": "^2.1.6",
|
||||
"@tiptap/extension-table-header": "^2.1.6",
|
||||
|
|
@ -44,9 +44,10 @@
|
|||
"@tiptap/pm": "^2.1.7",
|
||||
"@tiptap/react": "^2.1.7",
|
||||
"@tiptap/starter-kit": "^2.1.10",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/node": "18.15.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.36.0",
|
||||
|
|
@ -54,6 +55,7 @@
|
|||
"eventsource-parser": "^0.1.0",
|
||||
"lucide-react": "^0.244.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
|
|
|
|||
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
10
packages/editor/core/src/types/mention-suggestion.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export type IMentionHighlight = string
|
||||
|
|
@ -17,9 +17,12 @@ import ImageExtension from "./image";
|
|||
|
||||
import { DeleteImage } from "../../types/delete-image";
|
||||
import { isValidHttpUrl } from "../../lib/utils";
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
import { Mentions } from "../mentions";
|
||||
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
|
||||
deleteFile: DeleteImage,
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
|
|
@ -94,4 +97,5 @@ export const CoreEditorExtensions = (
|
|||
TableHeader,
|
||||
CustomTableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { EditorProps } from "@tiptap/pm/view";
|
|||
import { getTrimmedHTML } from "../../lib/utils";
|
||||
import { UploadImage } from "../../types/upload-image";
|
||||
import { useInitializedContent } from "./useInitializedContent";
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
interface CustomEditorProps {
|
||||
uploadFile: UploadImage;
|
||||
|
|
@ -26,6 +27,8 @@ interface CustomEditorProps {
|
|||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: any;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
export const useEditor = ({
|
||||
|
|
@ -38,6 +41,8 @@ export const useEditor = ({
|
|||
setIsSubmitting,
|
||||
forwardedRef,
|
||||
setShouldShowAlert,
|
||||
mentionHighlights,
|
||||
mentionSuggestions
|
||||
}: CustomEditorProps) => {
|
||||
const editor = useCustomEditor(
|
||||
{
|
||||
|
|
@ -45,7 +50,7 @@ export const useEditor = ({
|
|||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
|
||||
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
|
||||
content:
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
onUpdate: async ({ editor }) => {
|
||||
|
|
@ -77,4 +82,4 @@ export const useEditor = ({
|
|||
}
|
||||
|
||||
return editor;
|
||||
};
|
||||
};
|
||||
|
|
@ -7,21 +7,19 @@ import {
|
|||
} from "react";
|
||||
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { EditorProps } from '@tiptap/pm/view';
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
value: string;
|
||||
forwardedRef?: any;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
export const useReadOnlyEditor = ({
|
||||
value,
|
||||
forwardedRef,
|
||||
extensions = [],
|
||||
editorProps = {},
|
||||
}: CustomReadOnlyEditorProps) => {
|
||||
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
|
||||
const editor = useCustomEditor({
|
||||
editable: false,
|
||||
content:
|
||||
|
|
@ -30,7 +28,7 @@ export const useReadOnlyEditor = ({
|
|||
...CoreReadOnlyEditorProps,
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
||||
extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
|
||||
});
|
||||
|
||||
const hasIntiliazedContent = useRef(false);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { EditorProps } from '@tiptap/pm/view';
|
|||
import { useEditor } from './hooks/useEditor';
|
||||
import { EditorContainer } from '../ui/components/editor-container';
|
||||
import { EditorContentWrapper } from '../ui/components/editor-content';
|
||||
import { IMentionSuggestion } from '../types/mention-suggestion';
|
||||
|
||||
interface ICoreEditor {
|
||||
value: string;
|
||||
|
|
@ -30,6 +31,8 @@ interface ICoreEditor {
|
|||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[];
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
extensions?: Extension[];
|
||||
editorProps?: EditorProps;
|
||||
}
|
||||
|
|
@ -61,7 +64,6 @@ const CoreEditor = ({
|
|||
const editor = useEditor({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
editable,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
|
|
|
|||
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
111
packages/editor/core/src/ui/mentions/MentionList.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Editor } from '@tiptap/react';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { IMentionSuggestion } from '../../types/mention-suggestion';
|
||||
|
||||
interface MentionListProps {
|
||||
items: IMentionSuggestion[];
|
||||
command: (item: { id: string, label: string, target: string, redirect_uri: string }) => void;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item.id, label: item.title, target: "users", redirect_uri: item.redirect_uri })
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(((selectedIndex + props.items.length) - 1) % props.items.length)
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [props.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 (
|
||||
props.items && props.items.length !== 0 ? <div className="items">
|
||||
{ props.items.length ? props.items.map((item, index) => (
|
||||
<div className={`item ${index === selectedIndex ? 'is-selected' : ''} w-72 flex items-center p-3 rounded shadow-md`} onClick={() => selectItem(index)}>
|
||||
{item.avatar ? <div
|
||||
className={`rounded border-[0.5px] ${index ? "border-custom-border-200 bg-custom-background-100" : "border-transparent"
|
||||
}`}
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "24px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.avatar}
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded"
|
||||
alt={item.title}
|
||||
/>
|
||||
</div> :
|
||||
<div
|
||||
className="grid place-items-center text-xs capitalize text-white rounded bg-gray-700 border-[0.5px] border-custom-border-200"
|
||||
style={{
|
||||
height: "24px",
|
||||
width: "24px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{item.title.charAt(0)}
|
||||
</div>
|
||||
}
|
||||
<div className="ml-7 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{item.title}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
: <div className="item">No result</div>
|
||||
}
|
||||
</div> : <></>
|
||||
)
|
||||
})
|
||||
|
||||
MentionList.displayName = "MentionList"
|
||||
|
||||
export default MentionList
|
||||
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
59
packages/editor/core/src/ui/mentions/custom.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Mention, MentionOptions } from '@tiptap/extension-mention'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import mentionNodeView from './mentionNodeView'
|
||||
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: IMentionHighlight[]
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
default: null,
|
||||
},
|
||||
self: {
|
||||
default: false
|
||||
},
|
||||
redirect_uri: {
|
||||
default: "/"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(mentionNodeView)
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'mention-component',
|
||||
getAttrs: (node: string | HTMLElement) => {
|
||||
if (typeof node === 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: node.getAttribute('data-mention-id') || '',
|
||||
target: node.getAttribute('data-mention-target') || '',
|
||||
label: node.innerText.slice(1) || '',
|
||||
redirect_uri: node.getAttribute('redirect_uri')
|
||||
}
|
||||
},
|
||||
}]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['mention-component', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
15
packages/editor/core/src/ui/mentions/index.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import suggestion from "./suggestion";
|
||||
import { CustomMention } from "./custom";
|
||||
import { IMentionHighlight, IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => CustomMention.configure({
|
||||
HTMLAttributes: {
|
||||
'class' : "mention",
|
||||
},
|
||||
readonly: readonly,
|
||||
mentionHighlights: mentionHighlights,
|
||||
suggestion: suggestion(mentionSuggestions),
|
||||
})
|
||||
|
||||
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
32
packages/editor/core/src/ui/mentions/mentionNodeView.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable react/display-name */
|
||||
// @ts-nocheck
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { IMentionHighlight } from '../../types/mention-suggestion'
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default props => {
|
||||
|
||||
const router = useRouter()
|
||||
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.extension.options.readonly){
|
||||
router.push(props.node.attrs.redirect_uri)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="w-fit inline mention-component" >
|
||||
<span className={cn("px-1 py-0.5 inline rounded-md font-bold bg-custom-primary-500 mention", {
|
||||
"text-[#D9C942] bg-[#544D3B] hover:bg-[#544D3B]" : highlights ? highlights.includes(props.node.attrs.id) : false,
|
||||
"cursor-pointer" : !props.extension.options.readonly,
|
||||
"hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
|
||||
})} onClick={handleClick} data-mention-target={props.node.attrs.target} data-mention-id={props.node.attrs.id}>@{ props.node.attrs.label }</span>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
59
packages/editor/core/src/ui/mentions/suggestion.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { Editor } from "@tiptap/core";
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import MentionList from './MentionList'
|
||||
import { IMentionSuggestion } from './mentions';
|
||||
|
||||
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
items: ({ query }: { query: string }) => suggestions.filter(suggestion => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
|
||||
render: () => {
|
||||
let reactRenderer: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer?.updateProps(props)
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return reactRenderer?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
reactRenderer?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
export default Suggestion;
|
||||
|
|
@ -15,8 +15,12 @@ import { TableRow } from "@tiptap/extension-table-row";
|
|||
|
||||
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||
import { isValidHttpUrl } from "../../lib/utils";
|
||||
import { Mentions } from "../mentions";
|
||||
import { IMentionSuggestion } from "../../types/mention-suggestion";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = [
|
||||
export const CoreReadOnlyEditorExtensions = (
|
||||
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
|
|
@ -89,4 +93,5 @@ export const CoreReadOnlyEditorExtensions = [
|
|||
TableHeader,
|
||||
CustomTableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue