diff --git a/lib/canvas-comments/CanvasCommentButton.tsx b/lib/canvas-comments/CanvasCommentButton.tsx new file mode 100644 index 0000000..ed0754b --- /dev/null +++ b/lib/canvas-comments/CanvasCommentButton.tsx @@ -0,0 +1,670 @@ +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import type { BlockNoteEditor } from '@blocknote/core'; +import EmojiPicker, { EmojiStyle } from 'emoji-picker-react'; +import * as Popover from '@radix-ui/react-popover'; +import type { EmojiClickData } from 'emoji-picker-react'; +import { SmilePlus, MessageCircle } from 'lucide-react'; +import './canvas-comment-button.css'; + +// Utility function to combine class names +const cn = (...classes: (string | undefined | null | false)[]): string => { + return classes.filter(Boolean).join(' '); +}; + +// Button component using basic HTML +const Button = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { variant?: string } +>(({ className, variant, ...props }, ref) => { + const variants: Record = { + ghost: 'bg-transparent hover:bg-transparent text-muted-foreground', + default: 'bg-primary text-primary-foreground', + }; + + return ( + + + ) : ( + + )} + + + +
+ {threads.map((thread) => ( + + ))} +
+
+
+ + ) : hasComments && !hasReactions ? ( + // Single thread: open directly + TooltipComponent ? ( + 1 ? 's' : ''}`} side='top' delayDuration={100}> + + + ) : ( + + ) + ) : null} + + , + document.body, + ); +}; + +// Hover buttons for blocks without activity +interface HoverActivityButtonProps { + blockId: string; + onReactionButtonHover: () => void; + onReactionButtonLeave: () => void; + onEmojiSelect: (emoji: string) => void; + onCommentClick: () => void; + emojiPickerOpen: boolean; + onEmojiPickerOpenChange: (open: boolean) => void; + isCommentPanelOpen?: boolean | undefined; + TooltipComponent?: React.ComponentType<{ content: string; side?: 'top'; delayDuration?: number; children: React.ReactNode }>; +} + +const HoverActivityButton = React.forwardRef( + ( + { + blockId, + onReactionButtonHover, + onReactionButtonLeave, + onEmojiSelect, + onCommentClick, + emojiPickerOpen, + onEmojiPickerOpenChange, + isCommentPanelOpen, + TooltipComponent, + }, + ref, + ) => { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => { + const updatePosition = () => { + const blockElement = document.querySelector(`[data-id="${blockId}"]`) as HTMLElement | null; + if (blockElement) { + const rect = blockElement.getBoundingClientRect(); + const buttonHeight = 36; + const padding = 12; + + // Always position to the right of the block + let left = rect.right + padding; + + // Center vertically relative to block + let top = rect.top + rect.height / 2 - buttonHeight / 2; + + setPosition({ top, left }); + } + }; + + // Initial update + updatePosition(); + + // When panel state changes, we need multiple updates during the CSS transition + // slideIn is 0.2s, slideOut is 0.3s - update several times during this period + const transitionTimeouts: NodeJS.Timeout[] = []; + if (isCommentPanelOpen !== undefined) { + [50, 150, 300, 400].forEach(delay => { + transitionTimeouts.push(setTimeout(updatePosition, delay)); + }); + } + + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + // Only update position periodically if emoji picker is not open + const interval = setInterval(() => { + if (!emojiPickerOpen) { + updatePosition(); + } + }, 100); + + return () => { + transitionTimeouts.forEach(clearTimeout); + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + clearInterval(interval); + }; + }, [blockId, isCommentPanelOpen, emojiPickerOpen]); + + if (!position) return null; + + return createPortal( +
+ {/* Reaction button with emoji picker */} + + {TooltipComponent ? ( + + + + + + ) : ( + + + + )} + + + { + const emojiName = emoji.isCustom + ? `custom:${emoji.emoji}:${emoji.names[0] || 'custom'}` + : emoji.emoji; + onEmojiSelect(emojiName); + }} + searchPlaceholder='Search emoji...' + /> + + + + + {/* Comment button */} + {TooltipComponent ? ( + + + + ) : ( + + )} +
, + document.body, + ); + }, +); + +HoverActivityButton.displayName = 'HoverActivityButton'; diff --git a/lib/canvas-comments/canvas-comment-button.css b/lib/canvas-comments/canvas-comment-button.css new file mode 100644 index 0000000..afa8f75 --- /dev/null +++ b/lib/canvas-comments/canvas-comment-button.css @@ -0,0 +1,147 @@ +.bn-block-activity-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 2px 4px; + background: transparent; + border: none; + border-radius: 4px; + box-shadow: none; +} + +.bn-block-hover-buttons { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 2px 4px; + background: transparent; + border: none; + border-radius: 4px; + box-shadow: none; +} + +.bn-activity-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 2px 4px; + min-width: 28px; + height: 24px; + border-radius: 4px; + background: transparent; + border: none; + color: #9ca3af; + cursor: pointer; + transition: all 0.15s ease; + font-size: 14px; + font-weight: 500; +} + +.bn-activity-btn:hover { + background: transparent; + color: #6b7280; + transform: scale(1.05); +} + +/* Reactions have border and background when active (user has reacted) */ +.bn-reaction-btn-with-count { + border: 1px solid transparent; +} + +.bn-reaction-btn-with-count.bn-activity-btn--active { + background: #f0f9ff; + border: 1px solid #3b82f6; + color: #3b82f6; +} + +.bn-reaction-btn-with-count.bn-activity-btn--active:hover { + background: #e0f2fe; + border-color: #0284c7; + color: #0284c7; +} + +.bn-activity-btn:active { + transform: scale(0.95); +} + +.bn-activity-btn--active { + background: #f0f9ff; + border: 1px solid #3b82f6; + color: #3b82f6; +} + +.bn-activity-btn--active:hover { + background: #e0f2fe; + border-color: #0284c7; + color: #0284c7; +} + +.bn-activity-btn .emoji { + font-size: 16px; + line-height: 1; +} + +.bn-activity-btn .count { + font-size: 12px; + font-weight: 500; + min-width: 14px; + color: #9ca3af; + padding: 0; + line-height: 1; +} + +.bn-reaction-btn-with-count { + flex-shrink: 0; +} + +.bn-comment-btn-with-count { + flex-shrink: 0; +} + +/* Threads list popover */ +.bn-threads-list { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 100px; + max-width: 120px; +} + +.bn-thread-item { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 8px; + text-align: center; + border: none; + border-radius: 3px; + background: #f9fafb; + color: #374151; + cursor: pointer; + font-size: 12px; + transition: all 0.15s ease; +} + +.bn-thread-item:hover { + background: #f3f4f6; + color: #1f2937; +} + +.bn-thread-count { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 3px; + background: #dbeafe; + color: #1e40af; + border-radius: 8px; + font-size: 10px; + font-weight: 600; +} diff --git a/lib/canvas-comments/main.ts b/lib/canvas-comments/main.ts new file mode 100644 index 0000000..d6f771c --- /dev/null +++ b/lib/canvas-comments/main.ts @@ -0,0 +1,2 @@ +export { CanvasCommentButton } from './CanvasCommentButton'; +export type { CanvasCommentButtonProps } from './CanvasCommentButton'; diff --git a/lib/main.ts b/lib/main.ts index d7d5812..975014f 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -101,6 +101,15 @@ export { insertGroupMention, } from "./mentions/main.js"; +// Canvas comments exports +export { + CanvasCommentButton, +} from "./canvas-comments/main.js"; + +export type { + CanvasCommentButtonProps, +} from "./canvas-comments/main.js"; + // Common utilities exports export { asBlockNoteEditorForView, diff --git a/package-lock.json b/package-lock.json index b98bc25..9bf4e77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,10 @@ "@handsontable/react": "^16.2.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", + "emoji-picker-react": "^4.18.0", "handsontable": "^16.2.0", "hyperformula": "^3.1.0", + "lucide-react": "^0.577.0", "pyodide": "^0.29.0", "recharts": "^3.6.0", "reveal.js": "^5.1.0" @@ -5432,6 +5434,21 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-picker-react": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", + "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -5863,6 +5880,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -7008,6 +7031,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 6246bad..e36f3b4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,10 @@ "types": "./dist/main.d.ts", "import": "./dist/main.js" }, + "./canvas-comments": { + "types": "./dist/canvas-comments/main.d.ts", + "import": "./dist/canvas-comments/main.js" + }, "./multicolumn": { "types": "./dist/multicolumn/main.d.ts", "import": "./dist/multicolumn/main.js" @@ -105,8 +109,10 @@ "@handsontable/react": "^16.2.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", + "emoji-picker-react": "^4.18.0", "handsontable": "^16.2.0", "hyperformula": "^3.1.0", + "lucide-react": "^0.577.0", "pyodide": "^0.29.0", "recharts": "^3.6.0", "reveal.js": "^5.1.0"