From b8f2654e8042c1976d113bf4d8729a5514d97c0b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 22 Apr 2026 20:57:24 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D1=83?= =?UTF-8?q?=D0=BD=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20emoji?= =?UTF-8?q?=20popup=20=D0=B2=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8F=D1=85?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D0=B8=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/editor/lite-text/toolbar.tsx | 186 +--------------- .../core/components/emoji/minimal-picker.tsx | 200 ++++++++++++++++++ .../issue-detail/reactions/issue-comment.tsx | 23 +- .../issues/issue-detail/reactions/issue.tsx | 23 +- 4 files changed, 239 insertions(+), 193 deletions(-) create mode 100644 plane-src/apps/web/core/components/emoji/minimal-picker.tsx diff --git a/plane-src/apps/web/core/components/editor/lite-text/toolbar.tsx b/plane-src/apps/web/core/components/editor/lite-text/toolbar.tsx index 42d450e..9dc3b30 100644 --- a/plane-src/apps/web/core/components/editor/lite-text/toolbar.tsx +++ b/plane-src/apps/web/core/components/editor/lite-text/toolbar.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import React, { useEffect, useState, useCallback, useMemo } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { ArrowUp, Paperclip, SmilePlus } from "lucide-react"; import type { LucideIcon } from "lucide-react"; @@ -17,12 +17,12 @@ import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; import { GlobeIcon, LockIcon } from "@plane/propel/icons"; import type { ISvgIcons } from "@plane/propel/icons"; -import { Popover } from "@plane/propel/popover"; import { Tooltip } from "@plane/propel/tooltip"; // constants import { cn } from "@plane/utils"; import type { ToolbarMenuItem } from "@/constants/editor"; import { IMAGE_ITEM, TOOLBAR_ITEMS } from "@/constants/editor"; +import { MinimalEmojiPicker } from "@/components/emoji/minimal-picker"; // helpers type Props = { @@ -59,178 +59,6 @@ const COMMENT_ACCESS_SPECIFIERS: TCommentAccessType[] = [ ]; const toolbarItems = TOOLBAR_ITEMS.lite; -const COMMENT_RECENT_EMOJI_STORAGE_KEY = "nodedc-comment-emoji-recent"; -const COMMENT_RECENT_EMOJI_LIMIT = 8; -const COMMENT_EMOJIS = [ - "๐Ÿ˜€", - "๐Ÿ˜ƒ", - "๐Ÿ˜„", - "๐Ÿ˜", - "๐Ÿ˜†", - "๐Ÿ˜…", - "๐Ÿคฃ", - "๐Ÿ˜‚", - "๐Ÿ™‚", - "๐Ÿ˜‰", - "๐Ÿ˜Š", - "๐Ÿ˜‡", - "๐Ÿฅฐ", - "๐Ÿ˜", - "๐Ÿคฉ", - "๐Ÿ˜˜", - "๐Ÿ˜—", - "๐Ÿ˜š", - "๐Ÿ˜‹", - "๐Ÿ˜›", - "๐Ÿ˜œ", - "๐Ÿคช", - "๐Ÿซ ", - "๐Ÿค—", - "๐Ÿค”", - "๐Ÿซก", - "๐Ÿค", - "๐Ÿ‘", - "๐Ÿ™Œ", - "๐Ÿ‘", - "๐Ÿ‘Ž", - "๐Ÿ”ฅ", - "๐Ÿ’ฏ", - "โœจ", - "๐ŸŽ‰", - "โค๏ธ", - "๐Ÿงก", - "๐Ÿ’›", - "๐Ÿ’š", - "๐Ÿ’™", - "๐Ÿ’œ", - "๐Ÿค", - "๐ŸคŽ", - "๐Ÿ–ค", - "๐Ÿ˜Ž", - "๐Ÿค“", - "๐Ÿฅณ", - "๐Ÿ˜ด", - "๐Ÿคฏ", - "๐Ÿ˜ฌ", - "๐Ÿ˜Œ", - "๐Ÿฅฒ", - "๐Ÿ˜ญ", - "๐Ÿ˜ก", - "๐Ÿคฎ", - "๐Ÿคข", - "๐Ÿคก", - "๐Ÿ‘€", - "๐Ÿ™", - "๐Ÿ‘Œ", - "โœ…", - "โŒ", -]; - -const readRecentCommentEmojis = (storageKey: string) => { - if (typeof window === "undefined") return []; - - try { - const value = window.localStorage.getItem(storageKey); - if (!value) return []; - - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed.filter((emoji): emoji is string => typeof emoji === "string") : []; - } catch { - return []; - } -}; - -const writeRecentCommentEmoji = (storageKey: string, emoji: string, limit: number) => { - if (typeof window === "undefined") return []; - - const next = [emoji, ...readRecentCommentEmojis(storageKey).filter((value) => value !== emoji)].slice(0, limit); - - try { - window.localStorage.setItem(storageKey, JSON.stringify(next)); - } catch { - return next; - } - - return next; -}; - -type CompactCommentEmojiPickerProps = { - isOpen: boolean; - onEmojiSelect: (emoji: string) => void; - onOpenChange: (value: boolean) => void; -}; - -const CompactCommentEmojiPicker: React.FC = ({ - isOpen, - onEmojiSelect, - onOpenChange, -}) => { - const [recentEmojis, setRecentEmojis] = useState(() => - readRecentCommentEmojis(COMMENT_RECENT_EMOJI_STORAGE_KEY) - ); - - const handleEmojiSelect = useCallback( - (emoji: string) => { - setRecentEmojis( - writeRecentCommentEmoji(COMMENT_RECENT_EMOJI_STORAGE_KEY, emoji, COMMENT_RECENT_EMOJI_LIMIT) - ); - onEmojiSelect(emoji); - onOpenChange(false); - }, - [onEmojiSelect, onOpenChange] - ); - - const mainEmojis = useMemo( - () => COMMENT_EMOJIS.filter((emoji) => !recentEmojis.includes(emoji)), - [recentEmojis] - ); - - const renderEmojiButton = (emoji: string, isRecent = false) => ( - - ); - - return ( - - - -
- -
-
-
- event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > -
- {recentEmojis.length > 0 && ( -
- {recentEmojis.map((emoji) => renderEmojiButton(emoji, true))} -
- )} -
{mainEmojis.map((emoji) => renderEmojiButton(emoji))}
-
-
-
- ); -}; export function IssueCommentToolbar(props: Props) { const { t } = useTranslation(); @@ -292,10 +120,18 @@ export function IssueCommentToolbar(props: Props) { - +
+ +
+ + } isOpen={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen} onEmojiSelect={(emoji) => editorRef?.setEditorValueAtCursorPosition(emoji)} + emojiStorageKey="nodedc-comment-emoji-recent" /> {showSubmitButton && ( diff --git a/plane-src/apps/web/core/components/emoji/minimal-picker.tsx b/plane-src/apps/web/core/components/emoji/minimal-picker.tsx new file mode 100644 index 0000000..ddcee82 --- /dev/null +++ b/plane-src/apps/web/core/components/emoji/minimal-picker.tsx @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import React, { useCallback, useMemo, useState } from "react"; +import { Popover } from "@plane/propel/popover"; +import { cn } from "@plane/utils"; +import type { TAlign, TPlacement, TSide } from "@plane/propel/utils/placement"; + +const DEFAULT_RECENT_EMOJI_LIMIT = 8; +const DEFAULT_EMOJIS = [ + "๐Ÿ˜€", + "๐Ÿ˜ƒ", + "๐Ÿ˜„", + "๐Ÿ˜", + "๐Ÿ˜†", + "๐Ÿ˜…", + "๐Ÿคฃ", + "๐Ÿ˜‚", + "๐Ÿ™‚", + "๐Ÿ˜‰", + "๐Ÿ˜Š", + "๐Ÿ˜‡", + "๐Ÿฅฐ", + "๐Ÿ˜", + "๐Ÿคฉ", + "๐Ÿ˜˜", + "๐Ÿ˜—", + "๐Ÿ˜š", + "๐Ÿ˜‹", + "๐Ÿ˜›", + "๐Ÿ˜œ", + "๐Ÿคช", + "๐Ÿซ ", + "๐Ÿค—", + "๐Ÿค”", + "๐Ÿซก", + "๐Ÿค", + "๐Ÿ‘", + "๐Ÿ™Œ", + "๐Ÿ‘", + "๐Ÿ‘Ž", + "๐Ÿ”ฅ", + "๐Ÿ’ฏ", + "โœจ", + "๐ŸŽ‰", + "โค๏ธ", + "๐Ÿงก", + "๐Ÿ’›", + "๐Ÿ’š", + "๐Ÿ’™", + "๐Ÿ’œ", + "๐Ÿค", + "๐ŸคŽ", + "๐Ÿ–ค", + "๐Ÿ˜Ž", + "๐Ÿค“", + "๐Ÿฅณ", + "๐Ÿ˜ด", + "๐Ÿคฏ", + "๐Ÿ˜ฌ", + "๐Ÿ˜Œ", + "๐Ÿฅฒ", + "๐Ÿ˜ญ", + "๐Ÿ˜ก", + "๐Ÿคฎ", + "๐Ÿคข", + "๐Ÿคก", + "๐Ÿ‘€", + "๐Ÿ™", + "๐Ÿ‘Œ", + "โœ…", + "โŒ", +]; + +const readRecentEmojis = (storageKey: string) => { + if (typeof window === "undefined") return []; + + try { + const value = window.localStorage.getItem(storageKey); + if (!value) return []; + + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((emoji): emoji is string => typeof emoji === "string") : []; + } catch { + return []; + } +}; + +const writeRecentEmoji = (storageKey: string, emoji: string, limit: number) => { + if (typeof window === "undefined") return []; + + const next = [emoji, ...readRecentEmojis(storageKey).filter((value) => value !== emoji)].slice(0, limit); + + try { + window.localStorage.setItem(storageKey, JSON.stringify(next)); + } catch { + return next; + } + + return next; +}; + +type MinimalEmojiPickerProps = { + align?: TAlign; + disabled?: boolean; + dropdownClassName?: string; + emojiStorageKey?: string; + isOpen: boolean; + label: React.ReactNode; + onEmojiSelect: (emoji: string) => void; + onOpenChange: (value: boolean) => void; + placement?: TPlacement; + recentEmojiLimit?: number; + side?: TSide; + sideOffset?: number; +}; + +export const MinimalEmojiPicker: React.FC = ({ + align = "start", + disabled = false, + dropdownClassName, + emojiStorageKey = "nodedc-recent-emojis", + isOpen, + label, + onEmojiSelect, + onOpenChange, + placement = "top-start", + recentEmojiLimit = DEFAULT_RECENT_EMOJI_LIMIT, + side = "top", + sideOffset = 8, +}) => { + const [recentEmojis, setRecentEmojis] = useState(() => readRecentEmojis(emojiStorageKey)); + + const handleEmojiSelect = useCallback( + (emoji: string) => { + setRecentEmojis(writeRecentEmoji(emojiStorageKey, emoji, recentEmojiLimit)); + onEmojiSelect(emoji); + onOpenChange(false); + }, + [emojiStorageKey, onEmojiSelect, onOpenChange, recentEmojiLimit] + ); + + const mainEmojis = useMemo( + () => DEFAULT_EMOJIS.filter((emoji) => !recentEmojis.includes(emoji)), + [recentEmojis] + ); + + return ( + + + {label} + + event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
+ {recentEmojis.length > 0 && ( +
+ {recentEmojis.map((emoji) => ( + + ))} +
+ )} +
+ {mainEmojis.map((emoji) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx b/plane-src/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx index 2ece8b9..3c64c9d 100644 --- a/plane-src/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx +++ b/plane-src/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx @@ -7,13 +7,14 @@ import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { stringToEmoji } from "@plane/propel/emoji-icon-picker"; -import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction"; +import { EmojiReactionGroup } from "@plane/propel/emoji-reaction"; import type { EmojiReactionType } from "@plane/propel/emoji-reaction"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; +import { MinimalEmojiPicker } from "@/components/emoji/minimal-picker"; export type TIssueCommentReaction = { workspaceSlug: string; @@ -110,24 +111,28 @@ export const IssueCommentReaction = observer(function IssueCommentReaction(props const handleReactionClick = (emoji: string) => { if (disabled) return; - // Convert emoji back to decimal string format for the API - const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0)); - const reactionString = emojiCodePoints.join("-"); + const reactionString = Array.from(emoji) + .map((char) => char.codePointAt(0)) + .join("-"); issueCommentReactionOperations.react(reactionString); }; const handleEmojiSelect = (emoji: string) => { - // emoji is already in decimal string format from EmojiReactionPicker - issueCommentReactionOperations.react(emoji); + // Convert emoji back to decimal string format for the API + const reactionString = Array.from(emoji) + .map((char) => char.codePointAt(0)) + .join("-"); + issueCommentReactionOperations.react(reactionString); }; return (
- { if (disabled) return; - // Convert emoji back to decimal string format for the API - const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0)); - const reactionString = emojiCodePoints.join("-"); + const reactionString = Array.from(emoji) + .map((char) => char.codePointAt(0)) + .join("-"); issueReactionOperations.react(reactionString); }; const handleEmojiSelect = (emoji: string) => { - // emoji is already in decimal string format from EmojiReactionPicker - issueReactionOperations.react(emoji); + // Convert emoji back to decimal string format for the API + const reactionString = Array.from(emoji) + .map((char) => char.codePointAt(0)) + .join("-"); + issueReactionOperations.react(reactionString); }; return (
-