[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:
Henit Chobisa 2023-11-01 16:36:37 +05:30 committed by GitHub
parent 490e032ac6
commit d511799f31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1662 additions and 52 deletions

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View 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

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

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

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

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

View file

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