feat: tagging members in description (#140)
* feat: tagging members in description * chore: tiptap mention package * feat: avatar before names in members dropdown
This commit is contained in:
@@ -33,6 +33,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tiptap/extension-link": "^2.22.2",
|
"@tiptap/extension-link": "^2.22.2",
|
||||||
|
"@tiptap/extension-mention": "^3.0.9",
|
||||||
"@tiptap/extension-placeholder": "^2.14.0",
|
"@tiptap/extension-placeholder": "^2.14.0",
|
||||||
"@tiptap/pm": "^2.14.0",
|
"@tiptap/pm": "^2.14.0",
|
||||||
"@tiptap/react": "^2.14.0",
|
"@tiptap/react": "^2.14.0",
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
|
import Mention from "@tiptap/extension-mention";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -69,6 +72,16 @@ export interface RenderSuggestionsProps {
|
|||||||
command: (item: SlashCommandItem) => void;
|
command: (item: SlashCommandItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceMember = {
|
||||||
|
publicId: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
image: string | null;
|
||||||
|
} | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
const CommandsList = forwardRef<
|
const CommandsList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
{
|
{
|
||||||
@@ -182,6 +195,125 @@ const RenderSuggestions = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MentionItem = { id: string; label: string; image: string | null };
|
||||||
|
|
||||||
|
const MentionList = forwardRef<
|
||||||
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
|
{
|
||||||
|
items: MentionItem[];
|
||||||
|
command: (item: MentionItem) => void;
|
||||||
|
}
|
||||||
|
>(({ items, command }, ref) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
const item = items[selectedIndex];
|
||||||
|
if (item) {
|
||||||
|
command(item);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||||
|
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
||||||
|
{items.length > 0 ? items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => command(item)}
|
||||||
|
className={twMerge(
|
||||||
|
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||||
|
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size="xs"
|
||||||
|
name={item.label}
|
||||||
|
imageUrl={
|
||||||
|
item.image ? getAvatarUrl(item.image) : undefined
|
||||||
|
}
|
||||||
|
email={item.label}
|
||||||
|
/>
|
||||||
|
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)) : (
|
||||||
|
<div className="flex items-center justify-start p-2">
|
||||||
|
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
MentionList.displayName = "MentionList";
|
||||||
|
|
||||||
|
const renderMentionSuggestions = () => {
|
||||||
|
let reactRenderer: ReactRenderer;
|
||||||
|
let popup: TippyInstance[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: any) => {
|
||||||
|
reactRenderer = new ReactRenderer(MentionList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!props.clientRect) return;
|
||||||
|
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: reactRenderer.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate(props: any) {
|
||||||
|
reactRenderer.updateProps(props);
|
||||||
|
if (!props.clientRect) return;
|
||||||
|
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||||||
|
},
|
||||||
|
onKeyDown(props: SuggestionKeyDownProps) {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup[0]?.hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
reactRenderer.ref as {
|
||||||
|
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
|
||||||
|
}
|
||||||
|
).onKeyDown?.(props) ?? false
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
popup[0]?.destroy();
|
||||||
|
reactRenderer.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||||
name: "slash-commands",
|
name: "slash-commands",
|
||||||
addOptions() {
|
addOptions() {
|
||||||
@@ -285,11 +417,13 @@ export default function Editor({
|
|||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
workspaceMembers,
|
||||||
}: {
|
}: {
|
||||||
content: string | null;
|
content: string | null;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
workspaceMembers: WorkspaceMember[];
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -300,7 +434,7 @@ export default function Editor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: readOnly
|
placeholder: readOnly
|
||||||
? ""
|
? ""
|
||||||
: t`Add description... (type '/' to open commands)`,
|
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||||
}),
|
}),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@@ -321,6 +455,38 @@ export default function Editor({
|
|||||||
char: "/",
|
char: "/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Mention.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'mention',
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
char: "@",
|
||||||
|
items: ({ query }: { query: string }) => {
|
||||||
|
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
|
||||||
|
id: member.publicId,
|
||||||
|
label: member?.user?.name ?? member.email,
|
||||||
|
image: member?.user?.image ?? null,
|
||||||
|
}));
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||||
|
},
|
||||||
|
command: ({ editor, range, props }: any) => {
|
||||||
|
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertContent(mentionHTML)
|
||||||
|
.focus()
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
render: renderMentionSuggestions,
|
||||||
|
},
|
||||||
|
renderText({ options, node }) {
|
||||||
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||||
@@ -369,6 +535,14 @@ export default function Editor({
|
|||||||
.tiptap p {
|
.tiptap p {
|
||||||
margin: 0 0 1rem 0 !important;
|
margin: 0 0 1rem 0 !important;
|
||||||
}
|
}
|
||||||
|
.tiptap .mention {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
color: rgb(59, 130, 246);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<EditorContent
|
<EditorContent
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { generateUID } from "@kan/shared/utils";
|
|||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import Editor from "~/components/Editor";
|
import Editor, { WorkspaceMember } from "~/components/Editor";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
@@ -322,6 +322,17 @@ export function NewCardForm({
|
|||||||
setValue("description", value);
|
setValue("description", value);
|
||||||
saveFormState({ ...formState, description: value });
|
saveFormState({ ...formState, description: value });
|
||||||
}}
|
}}
|
||||||
|
workspaceMembers={
|
||||||
|
boardData?.workspace.members?.map((member): WorkspaceMember => ({
|
||||||
|
publicId: member.publicId,
|
||||||
|
email: member.email,
|
||||||
|
user: member.user ? {
|
||||||
|
id: member.publicId,
|
||||||
|
name: member.user.name,
|
||||||
|
image: member.user.image ?? null,
|
||||||
|
} : null,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export default function CardPage() {
|
|||||||
content={card.description}
|
content={card.description}
|
||||||
onChange={(e) => setValue("description", e)}
|
onChange={(e) => setValue("description", e)}
|
||||||
onBlur={() => handleSubmit(onSubmit)()}
|
onBlur={() => handleSubmit(onSubmit)()}
|
||||||
|
workspaceMembers={board?.workspace?.members ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ export function CardModal({
|
|||||||
{data?.description && (
|
{data?.description && (
|
||||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Editor content={data.description} readOnly />
|
<Editor
|
||||||
|
content={data.description}
|
||||||
|
readOnly
|
||||||
|
workspaceMembers={data?.list.board.workspace.members ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -123,6 +123,9 @@ importers:
|
|||||||
'@tiptap/extension-link':
|
'@tiptap/extension-link':
|
||||||
specifier: ^2.22.2
|
specifier: ^2.22.2
|
||||||
version: 2.22.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
version: 2.22.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
||||||
|
'@tiptap/extension-mention':
|
||||||
|
specifier: ^3.0.9
|
||||||
|
version: 3.0.9(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(@tiptap/suggestion@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0))
|
||||||
'@tiptap/extension-placeholder':
|
'@tiptap/extension-placeholder':
|
||||||
specifier: ^2.14.0
|
specifier: ^2.14.0
|
||||||
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
||||||
@@ -2776,6 +2779,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@tiptap/core': ^2.7.0
|
'@tiptap/core': ^2.7.0
|
||||||
|
|
||||||
|
'@tiptap/extension-mention@3.0.9':
|
||||||
|
resolution: {integrity: sha512-DTQNAQkHZ+7Enlt3KvjqN6eECINlqPpET4Drzwj8Mmz9kMILc87cz3G2cwEKRrS9A1Xn3H3VpWvElWE2Wq9JHw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.0.9
|
||||||
|
'@tiptap/pm': ^3.0.9
|
||||||
|
'@tiptap/suggestion': ^3.0.9
|
||||||
|
|
||||||
'@tiptap/extension-ordered-list@2.14.0':
|
'@tiptap/extension-ordered-list@2.14.0':
|
||||||
resolution: {integrity: sha512-QUZcyuW9AKvSfpFHcGmbyRCqxcpY0VNf0xipEtogxbA+JDDw3ZSPqU1dUgz9wk00RahPTwNDdY5aVjdQ5N4N9Q==}
|
resolution: {integrity: sha512-QUZcyuW9AKvSfpFHcGmbyRCqxcpY0VNf0xipEtogxbA+JDDw3ZSPqU1dUgz9wk00RahPTwNDdY5aVjdQ5N4N9Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9621,6 +9631,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
||||||
|
|
||||||
|
'@tiptap/extension-mention@3.0.9(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(@tiptap/suggestion@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
||||||
|
'@tiptap/pm': 2.14.0
|
||||||
|
'@tiptap/suggestion': 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)
|
||||||
|
|
||||||
'@tiptap/extension-ordered-list@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))':
|
'@tiptap/extension-ordered-list@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
'@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user