diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 26b67fb..bf6cb9c 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -1,5 +1,32 @@ # Version Log +## v0.8.2 - The Antigravity Styling Update + +### 🛠 Improvements + +- **Antigravity Design Language:** Applied a cleaner, VS Code-inspired design across the application: + - Sharper corners (`rounded-sm`) on buttons, modals, tooltips, and dropdowns. + - Reduced focus ring intensity for a more subtle, native appearance. + - Removed shadows from modals, tooltips, and context menus for a flatter look. + - Status bar controls (LLM selectors, database dropdown, zoom buttons) now use text-only hover/focus instead of visible rings. +- **Improved Markdown Preview Zoom:** Text now reflows properly when zooming in or out, eliminating whitespace issues at different zoom levels. +- **Better Tree Selection Visibility:** Fixed the tree item selection highlight in light mode to be clearly visible instead of nearly transparent. + +### 🐛 Fixes + +- Disabled the scroll synchronization between the code editor and Markdown preview panes, as the sync behavior was unreliable. Panes now scroll independently. +- Fixed search box styling to be more professional with a subtle background and no visible border. + +## v0.8.1 - The Native Fonts & Focus Fix Update + +### 🛠 Improvements + +- Updated the application font stack to match VS Code on Windows, using **Segoe UI** for the interface and **Consolas** for code. This provides a more native and consistent look for Windows users. + +### 🐛 Fixes + +- Resolved an issue where the "Create Document from Template" dialog would lose focus or reset the cursor position while typing in variable inputs. The focus trap logic has been optimized to handle dynamic content updates correctly. + ## v0.8.0 - The Emoji & Command Palette Update ### ✨ Features diff --git a/components/Button.tsx b/components/Button.tsx index 96c45b6..b857871 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -9,15 +9,15 @@ interface ButtonProps extends React.ButtonHTMLAttributes { const Button = React.forwardRef( ({ children, variant = 'primary', isLoading = false, className, ...props }, ref) => { - const baseClasses = 'inline-flex items-center justify-center px-3 py-1.5 border text-xs font-semibold rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-background disabled:opacity-60 disabled:cursor-not-allowed transition-colors duration-150'; - + const baseClasses = 'inline-flex items-center justify-center px-3 py-1.5 border text-xs font-semibold rounded-sm focus:outline-none focus:ring-1 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-100'; + const variantClasses = { - primary: 'bg-primary text-primary-text border-transparent hover:bg-primary-hover focus:ring-primary', - secondary: 'bg-secondary text-text-main border-border-color hover:bg-border-color focus:ring-primary', - destructive: 'bg-destructive-bg text-destructive-text border-destructive-border hover:bg-destructive-bg-hover focus:ring-destructive-text', - ghost: 'bg-transparent text-text-main border-transparent hover:bg-border-color focus:ring-primary', + primary: 'bg-primary text-primary-text border-transparent hover:bg-primary-hover focus:ring-primary/50', + secondary: 'bg-secondary text-text-main border-border-color hover:bg-border-color/50 focus:ring-primary/50', + destructive: 'bg-destructive-bg text-destructive-text border-destructive-border hover:bg-destructive-bg-hover focus:ring-destructive-text/50', + ghost: 'bg-transparent text-text-secondary border-transparent hover:text-text-main focus:ring-primary/30', }; - + const disabled = props.disabled || isLoading; return ( diff --git a/components/ContextMenu.tsx.bak b/components/ContextMenu.tsx.bak new file mode 100644 index 0000000..d1fda60 --- /dev/null +++ b/components/ContextMenu.tsx.bak @@ -0,0 +1,273 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; + +export type MenuItem = + | { + label: string; + action: () => void; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + submenu?: never; + } + | { + label: string; + submenu: MenuItem[]; + icon?: React.FC<{ className?: string }>; + disabled?: boolean; + shortcut?: string; + action?: never; + } + | { type: 'separator' }; + +interface ContextMenuProps { + isOpen: boolean; + position: { x: number; y: number }; + items: MenuItem[]; + onClose: () => void; +} + +const EDGE_MARGIN = 8; + +const ContextMenu: React.FC = ({ isOpen, position, items, onClose }) => { + const menuRef = useRef(null); + const [openSubmenu, setOpenSubmenu] = useState(null); + const [menuStyle, setMenuStyle] = useState<{ top: number; left: number; maxHeight: number; overflowY: React.CSSProperties['overflowY'] }>({ + top: position.y, + left: position.x, + maxHeight: 0, + overflowY: 'visible', + }); + + const recalculatePosition = useCallback(() => { + const menu = menuRef.current; + if (!menu) return; + + const { innerWidth, innerHeight } = window; + const rect = menu.getBoundingClientRect(); + const maxHeight = Math.max(innerHeight - EDGE_MARGIN * 2, 0); + + let left = rect.left; + let top = rect.top; + + if (rect.right > innerWidth - EDGE_MARGIN) { + left = Math.max(EDGE_MARGIN, innerWidth - rect.width - EDGE_MARGIN); + } + if (left < EDGE_MARGIN) { + left = EDGE_MARGIN; + } + + if (rect.bottom > innerHeight - EDGE_MARGIN) { + top = Math.max(EDGE_MARGIN, innerHeight - rect.height - EDGE_MARGIN); + } + if (top < EDGE_MARGIN) { + top = EDGE_MARGIN; + } + + const overflowY: React.CSSProperties['overflowY'] = rect.height > maxHeight ? 'auto' : 'visible'; + + setMenuStyle((previous) => { + if ( + previous.top === top && + previous.left === left && + previous.maxHeight === maxHeight && + previous.overflowY === overflowY + ) { + return previous; + } + + return { + top, + left, + maxHeight, + overflowY, + }; + }); + }, []); + + useEffect(() => { + if (!isOpen) return; + + setOpenSubmenu(null); + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + useLayoutEffect(() => { + if (!isOpen) return; + + setMenuStyle((previous) => { + if (previous.top === position.y && previous.left === position.x) { + return previous; + } + + return { + top: position.y, + left: position.x, + maxHeight: previous.maxHeight, + overflowY: previous.overflowY, + }; + }); + + const frame = requestAnimationFrame(() => { + recalculatePosition(); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen, position.x, position.y, recalculatePosition]); + + useLayoutEffect(() => { + if (!isOpen) return; + + const frame = requestAnimationFrame(() => { + recalculatePosition(); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen, items, recalculatePosition]); + + useEffect(() => { + if (!isOpen) return; + + const handleResize = () => { + recalculatePosition(); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [isOpen, recalculatePosition]); + + if (!isOpen) return null; + + const overlayRoot = document.getElementById('overlay-root'); + if (!overlayRoot) return null; + + const hasEnabledItem = (menuItems: MenuItem[]): boolean => { + return menuItems.some(item => { + if ('type' in item) { + return false; + } + + if ('submenu' in item) { + return !item.disabled && hasEnabledItem(item.submenu); + } + + return !item.disabled; + }); + }; + + const renderItems = (menuItems: MenuItem[], depth = 0) => { + return menuItems.map((item, index) => { + if ('type' in item) { + return
  • ; + } + + if ('submenu' in item) { + const menuKey = `${depth}-${index}`; + const isOpen = openSubmenu === menuKey; + const hasEnabledSubitem = hasEnabledItem(item.submenu); + const Icon = item.icon; + + return ( +
  • setOpenSubmenu(menuKey)} + onMouseLeave={() => setOpenSubmenu(null)} + > + + {isOpen && item.submenu.length > 0 && ( +
    +
      {renderItems(item.submenu, depth + 1)}
    +
    + )} +
  • + ); + } + + const { label, action, icon: Icon, disabled, shortcut } = item; + + return ( +
  • + +
  • + ); + }); + }; + + return ReactDOM.createPortal( +
    +
      + {renderItems(items)} +
    + +
    , + overlayRoot + ); +}; + +export default ContextMenu; \ No newline at end of file diff --git a/components/IconButton.tsx b/components/IconButton.tsx index 6c84f17..ad4f5b4 100644 --- a/components/IconButton.tsx +++ b/components/IconButton.tsx @@ -9,34 +9,34 @@ interface IconButtonProps extends React.ButtonHTMLAttributes tooltipPosition?: 'top' | 'bottom'; } -const IconButton: React.FC = ({ children, tooltip, className, variant = 'primary', size='md', tooltipPosition = 'top', ...props }) => { - const [isHovered, setIsHovered] = useState(false); - const wrapperRef = useRef(null); - const { ['aria-label']: ariaLabel, ...buttonProps } = props; - const computedAriaLabel = ariaLabel ?? tooltip; +const IconButton: React.FC = ({ children, tooltip, className, variant = 'primary', size = 'md', tooltipPosition = 'top', ...props }) => { + const [isHovered, setIsHovered] = useState(false); + const wrapperRef = useRef(null); + const { ['aria-label']: ariaLabel, ...buttonProps } = props; + const computedAriaLabel = ariaLabel ?? tooltip; - const handleMouseEnter = useCallback(() => { - if (tooltip) setIsHovered(true); - }, [tooltip]); + const handleMouseEnter = useCallback(() => { + if (tooltip) setIsHovered(true); + }, [tooltip]); - const handleMouseLeave = useCallback(() => { - setIsHovered(false); - }, []); + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); - const baseClasses = "flex items-center justify-center rounded-md focus:outline-none transition-colors"; - - const variantClasses = { - primary: 'text-text-secondary hover:bg-border-color hover:text-text-main', - ghost: 'text-text-secondary/80 hover:bg-border-color hover:text-text-main', - destructive: 'text-destructive-text bg-transparent hover:bg-destructive-bg' - }; + const baseClasses = "flex items-center justify-center rounded-sm focus:outline-none transition-colors duration-100"; + + const variantClasses = { + primary: 'text-text-secondary hover:text-text-main hover:bg-border-color/30', + ghost: 'text-text-secondary hover:text-text-main', + destructive: 'text-destructive-text bg-transparent hover:bg-destructive-bg/50' + }; + + const sizeClasses = { + xs: 'w-6 h-6', + sm: 'w-7 h-7', + md: 'w-8 h-8' + }; - const sizeClasses = { - xs: 'w-6 h-6', - sm: 'w-7 h-7', - md: 'w-8 h-8' - }; - return ( <> = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const PlusIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const MinusIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const TrashIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const SparklesIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const FileIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const InfoIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const TerminalIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const CodeIcon: React.FC = (props) => { - return ; -}; - -export const DownloadIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const ChevronDownIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const ChevronRightIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const UndoIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const RedoIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const CommandIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const SunIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const MoonIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const FolderIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const FolderOpenIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const FolderPlusIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const FolderDownIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const LockClosedIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const LockOpenIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const KeyboardIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const CopyIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const CheckIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const SearchIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const XIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const DocumentDuplicateIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const HistoryIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const ArrowLeftIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const ArrowUpIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const ArrowDownIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const EyeIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const PencilIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const RefreshIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const LayoutHorizontalIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const LayoutVerticalIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const MinimizeIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const MaximizeIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const RestoreIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const CloseIcon: React.FC = (props) => { - // Fix: CloseIcon should just be an alias for XIcon, which is defined above. - return ; -}; - -export const WarningIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const DatabaseIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const SaveIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const ExpandAllIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const CollapseAllIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; - -export const FormatIcon: React.FC = (props) => { - const { iconSet } = useIconSet(); - switch (iconSet) { - case 'lucide': return ; - case 'feather': return ; - // FIX: Add missing case for TablerIcons to resolve "does not exist" error. - case 'tabler': return ; - // FIX: Add missing case for MaterialIcons to resolve "does not exist" error. - case 'material': return ; - case 'heroicons': default: return ; - } -}; +export type IconProps = { + className?: string; +}; + +const ICON_SETS = { + heroicons: HeroIcons, + lucide: LucideIcons, + feather: FeatherIcons, + tabler: TablerIcons, + material: MaterialIcons, +}; + +type IconName = keyof typeof HeroIcons; + +const usePolymorphicIcon = (name: IconName) => { + const { iconSet } = useIconSet(); + const set = ICON_SETS[iconSet] || HeroIcons; + // Fallback to HeroIcons if the icon doesn't exist in the selected set + // This handling is important because strict TS might complain, + // and some sets might miss an icon. + return (set as any)[name] || (HeroIcons as any)[name]; +}; + +/** + * Helper to create a unified icon component. + * We rely on the fact that all icon sets export components with the same names. + */ +function createIcon(name: IconName): React.FC { + const IconComponent: React.FC = (props) => { + const Icon = usePolymorphicIcon(name); + if (!Icon) return null; + return ; + }; + IconComponent.displayName = name; + return IconComponent; +} + +export const GearIcon = createIcon('GearIcon'); +export const PlusIcon = createIcon('PlusIcon'); +export const MinusIcon = createIcon('MinusIcon'); +export const TrashIcon = createIcon('TrashIcon'); +export const SparklesIcon = createIcon('SparklesIcon'); +export const FileIcon = createIcon('FileIcon'); +export const InfoIcon = createIcon('InfoIcon'); +export const TerminalIcon = createIcon('TerminalIcon'); +export const CodeIcon = createIcon('TerminalIcon'); // Alias +export const DownloadIcon = createIcon('DownloadIcon'); +export const ChevronDownIcon = createIcon('ChevronDownIcon'); +export const ChevronRightIcon = createIcon('ChevronRightIcon'); +export const UndoIcon = createIcon('UndoIcon'); +export const RedoIcon = createIcon('RedoIcon'); +export const CommandIcon = createIcon('CommandIcon'); +export const SunIcon = createIcon('SunIcon'); +export const MoonIcon = createIcon('MoonIcon'); +export const FolderIcon = createIcon('FolderIcon'); +export const FolderOpenIcon = createIcon('FolderOpenIcon'); +export const FolderPlusIcon = createIcon('FolderPlusIcon'); +export const FolderDownIcon = createIcon('FolderDownIcon'); +export const LockClosedIcon = createIcon('LockClosedIcon'); +export const LockOpenIcon = createIcon('LockOpenIcon'); +export const KeyboardIcon = createIcon('KeyboardIcon'); +export const CopyIcon = createIcon('CopyIcon'); +export const CheckIcon = createIcon('CheckIcon'); +export const SearchIcon = createIcon('SearchIcon'); +export const XIcon = createIcon('XIcon'); +export const DocumentDuplicateIcon = createIcon('DocumentDuplicateIcon'); +export const HistoryIcon = createIcon('HistoryIcon'); +export const ArrowLeftIcon = createIcon('ArrowLeftIcon'); +export const ArrowUpIcon = createIcon('ArrowUpIcon'); +export const ArrowDownIcon = createIcon('ArrowDownIcon'); +export const EyeIcon = createIcon('EyeIcon'); +export const PencilIcon = createIcon('PencilIcon'); +export const RefreshIcon = createIcon('RefreshIcon'); +export const LayoutHorizontalIcon = createIcon('LayoutHorizontalIcon'); +export const LayoutVerticalIcon = createIcon('LayoutVerticalIcon'); +export const MinimizeIcon = createIcon('MinimizeIcon'); +export const MaximizeIcon = createIcon('MaximizeIcon'); +export const RestoreIcon = createIcon('RestoreIcon'); +export const CloseIcon = createIcon('XIcon'); // Alias +export const WarningIcon = createIcon('WarningIcon'); +export const DatabaseIcon = createIcon('DatabaseIcon'); +export const SaveIcon = createIcon('SaveIcon'); +export const ExpandAllIcon = createIcon('ExpandAllIcon'); +export const CollapseAllIcon = createIcon('CollapseAllIcon'); +export const FormatIcon = createIcon('FormatIcon'); diff --git a/components/LanguageDropdown.tsx b/components/LanguageDropdown.tsx index 49ea873..de2719a 100644 --- a/components/LanguageDropdown.tsx +++ b/components/LanguageDropdown.tsx @@ -95,7 +95,7 @@ const LanguageDropdown = React.forwardRef @@ -106,7 +106,7 @@ const LanguageDropdown = React.forwardRef {isOpen && (
    @@ -119,11 +119,10 @@ const LanguageDropdown = React.forwardRef handleSelect(language.id)} - className={`text-left px-3 py-1.5 rounded-md transition-colors ${ - language.id === selectedLanguage.id - ? 'bg-secondary/70 text-primary font-semibold' - : 'text-text-main hover:bg-secondary' - }`} + className={`text-left px-3 py-1 rounded-sm transition-colors ${language.id === selectedLanguage.id + ? 'bg-tree-selected text-text-main font-medium' + : 'text-text-main hover:bg-tree-selected' + }`} > {language.label} diff --git a/components/Modal.tsx b/components/Modal.tsx index 08d97f8..58da276 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -40,7 +40,7 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef }; }, [onClose]); - // Effect for focus trapping + // Effect for setting initial focus - RUNS ONLY ONCE useEffect(() => { const focusTimer = setTimeout(() => { const modalNode = modalRef.current; @@ -58,23 +58,29 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef focusableElements[0].focus(); } } - }, 0); // Use a timeout to ensure the DOM is ready for focus + }, 50); // Small delay to ensure render - // Focus trapping logic for Tab key - const modalNode = modalRef.current; - if (!modalNode) return () => clearTimeout(focusTimer); - - const focusableElements = modalNode.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - if (focusableElements.length === 0) return () => clearTimeout(focusTimer); - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; + return () => clearTimeout(focusTimer); + }, []); // Empty dependency array ensures this runs only on mount + // Effect for focus trapping (Tab key) + useEffect(() => { const handleTabKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; + const modalNode = modalRef.current; + if (!modalNode) return; + + // Re-query focusable elements every time Tab is pressed to handle dynamic content changes + const focusableElements = modalNode.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + if (e.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { e.preventDefault(); @@ -87,16 +93,21 @@ const Modal: React.FC = ({ onClose, children, title, initialFocusRef } } }; - - modalNode.addEventListener('keydown', handleTabKey); + + // Attach listener to the specific modal node if possible, or window/document if needed for trapping. + // Attaching to modalNode is better for containment, but we need to ensure the modal has focus. + // For a robust trap, listening on the modal node is good IF the focus is inside. + const modalNode = modalRef.current; + if (modalNode) { + modalNode.addEventListener('keydown', handleTabKey); + } return () => { - clearTimeout(focusTimer); - if (modalNode) { - modalNode.removeEventListener('keydown', handleTabKey); - } + if (modalNode) { + modalNode.removeEventListener('keydown', handleTabKey); + } }; - }, [onClose, initialFocusRef]); + }, []); // Run once to attach handlers const modalContent = (
    = ({ onClose, children, title, initialFocusRef >
    e.stopPropagation()} >
    diff --git a/components/PromptEditor.tsx b/components/PromptEditor.tsx index 3dab0ea..fad7e68 100644 --- a/components/PromptEditor.tsx +++ b/components/PromptEditor.tsx @@ -427,54 +427,15 @@ const DocumentEditor: React.FC = ({ }; }, [handleGlobalMouseMove, handleGlobalMouseUp]); - // --- Scroll Synchronization Logic --- - const handleEditorScroll = useCallback((scrollInfo: { scrollTop: number; scrollHeight: number; clientHeight: number; }) => { - if (!viewMode.startsWith('split-') || isSyncing.current || !previewScrollRef.current) return; - - if (scrollInfo.scrollHeight <= scrollInfo.clientHeight) return; - - const percentage = scrollInfo.scrollTop / (scrollInfo.scrollHeight - scrollInfo.clientHeight); - - const previewEl = previewScrollRef.current; - if (previewEl.scrollHeight <= previewEl.clientHeight) return; - const newPreviewScrollTop = percentage * (previewEl.scrollHeight - previewEl.clientHeight); - - isSyncing.current = true; - previewEl.scrollTop = newPreviewScrollTop; - - if (syncTimeout.current) clearTimeout(syncTimeout.current); - syncTimeout.current = window.setTimeout(() => { - isSyncing.current = false; - }, 100); - }, [viewMode]); - - const handlePreviewScroll = useCallback((e: React.UIEvent) => { - if (!viewMode.startsWith('split-') || isSyncing.current) return; - - const editorHandle = editorEngine === 'monaco' ? editorRef.current : richTextEditorRef.current; - if (!editorHandle) return; - - const previewEl = e.currentTarget; - const { scrollTop, scrollHeight, clientHeight } = previewEl; - - if (scrollHeight <= clientHeight) return; - - const percentage = scrollTop / (scrollHeight - clientHeight); - - editorHandle.getScrollInfo().then(editorInfo => { - if (!isSyncing.current && editorInfo.scrollHeight > editorInfo.clientHeight) { - const newEditorScrollTop = percentage * (editorInfo.scrollHeight - editorInfo.clientHeight); - - isSyncing.current = true; - editorHandle.setScrollTop(newEditorScrollTop); + // --- Scroll Synchronization Logic (DISABLED) --- + // Scroll sync between editor and preview is disabled per user request. + const handleEditorScroll = useCallback((_scrollInfo: { scrollTop: number; scrollHeight: number; clientHeight: number; }) => { + // Scroll sync disabled - panes scroll independently + }, []); - if (syncTimeout.current) clearTimeout(syncTimeout.current); - syncTimeout.current = window.setTimeout(() => { - isSyncing.current = false; - }, 100); - } - }); - }, [viewMode, editorEngine]); + const handlePreviewScroll = useCallback((_e: React.UIEvent) => { + // Scroll sync disabled - panes scroll independently + }, []); diff --git a/components/PromptTreeItem.tsx b/components/PromptTreeItem.tsx index 8b772ef..cc9744c 100644 --- a/components/PromptTreeItem.tsx +++ b/components/PromptTreeItem.tsx @@ -131,7 +131,7 @@ const DocumentTreeItem: React.FC = (props) => { searchTerm, isKnownNodeId, } = baseChildProps; - + const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(node.title); const [dropPosition, setDropPosition] = useState<'before' | 'after' | 'inside' | null>(null); @@ -173,7 +173,7 @@ const DocumentTreeItem: React.FC = (props) => { return node.title; }, [emojiForNode, isFolder, node.title]); const isActiveTab = isOpenInTab && activeDocumentId === node.id; - + useEffect(() => { if (renamingNodeId === node.id) { setIsRenaming(true); @@ -196,7 +196,7 @@ const DocumentTreeItem: React.FC = (props) => { setRenameEmojiAnchor(null); } }, [isRenaming]); - + useEffect(() => { if (isRenaming && !isSelected) { setIsRenaming(false); @@ -346,7 +346,7 @@ const DocumentTreeItem: React.FC = (props) => { e.dataTransfer.setData('application/json', JSON.stringify(draggedIds)); const transferPayload = onRequestNodeExport(draggedIds); if (transferPayload) { - e.dataTransfer.setData(DOCFORGE_DRAG_MIME, JSON.stringify(transferPayload)); + e.dataTransfer.setData(DOCFORGE_DRAG_MIME, JSON.stringify(transferPayload)); } e.dataTransfer.effectAllowed = transferPayload ? 'copyMove' : 'move'; }; @@ -384,7 +384,7 @@ const DocumentTreeItem: React.FC = (props) => { } } }; - + const handleDragLeave = (e: React.DragEvent) => { e.stopPropagation(); setDropPosition(null); @@ -396,11 +396,11 @@ const DocumentTreeItem: React.FC = (props) => { const finalDropPosition = getDropPosition(e, isFolder, itemRef.current); setDropPosition(null); - + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const parentId = finalDropPosition === 'inside' ? node.id : node.parentId; - onDropFiles(e.dataTransfer.files, parentId); - return; + const parentId = finalDropPosition === 'inside' ? node.id : node.parentId; + onDropFiles(e.dataTransfer.files, parentId); + return; } const localDragIds = readLocalDragIds(e.dataTransfer); @@ -428,7 +428,7 @@ const DocumentTreeItem: React.FC = (props) => { } } }; - + const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -458,211 +458,209 @@ const DocumentTreeItem: React.FC = (props) => { className="relative" data-item-id={node.id} > -
    !isRenaming && onSelectNode(node.id, e)} - onDoubleClick={(e) => !isRenaming && handleRenameStart(e)} - onMouseEnter={() => { - if (rowRef.current) { - setLockedRowHeight(rowRef.current.getBoundingClientRect().height); - } - setIsHovered(true); - }} - onMouseLeave={() => { - setIsHovered(false); - setLockedRowHeight(null); - }} - style={{ - paddingTop: `${paddingTopBottom}px`, - paddingBottom: `${paddingTopBottom}px`, - paddingLeft: `${rowPaddingLeft}px`, - minHeight: lockedRowHeight !== null ? `${lockedRowHeight}px` : undefined, - }} - className={`w-full text-left pr-1 rounded-md group flex justify-between items-center transition-colors duration-150 text-xs relative focus:outline-none ${ - isSelected ? 'bg-tree-selected text-text-main' : 'hover:bg-border-color/30 text-text-secondary hover:text-text-main' - } ${isFocused ? 'ring-2 ring-primary ring-offset-[-2px] ring-offset-secondary' : ''}`} - > -
    - {isFolder && node.children.length > 0 ? ( - - ) : ( -
    // Spacer for alignment - )} - - {isFolder ? ( - isExpanded ? : - ) : emojiForNode ? ( - - ) : ( - isCodeFile ? : - )} - - {isOpenInTab && ( -
    )} +
    - {dropPosition &&
    + {highlightMatches(node.searchSnippet, searchTerm)} +
    + )} + + {dropPosition &&
    } - {dropPosition === 'inside' &&
    } + {dropPosition === 'inside' &&
    } - {isFolder && isExpanded && ( -
      - {node.children.map((childNode, index) => ( - 0} - canMoveDown={index < node.children.length - 1} - /> - ))} -
    - )} - + {isFolder && isExpanded && ( +
      + {node.children.map((childNode, index) => ( + 0} + canMoveDown={index < node.children.length - 1} + /> + ))} +
    + )} + ); }; diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx index 2d4aa05..6f70a27 100644 --- a/components/RichTextEditor.tsx +++ b/components/RichTextEditor.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from 'react'; @@ -17,101 +16,28 @@ import { ListPlugin } from '@lexical/react/LexicalListPlugin'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; import { TablePlugin } from '@lexical/react/LexicalTablePlugin'; import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'; import { - $createHeadingNode, - $createQuoteNode, - $isHeadingNode, HeadingNode, QuoteNode, - type HeadingTagType, } from '@lexical/rich-text'; import { - INSERT_ORDERED_LIST_COMMAND, - INSERT_UNORDERED_LIST_COMMAND, - REMOVE_LIST_COMMAND, ListItemNode, ListNode, - $isListNode, - type ListType, } from '@lexical/list'; -import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; -import { mergeRegister } from '@lexical/utils'; -import { $setBlocksType } from '@lexical/selection'; -import { - $createParagraphNode, - $getRoot, - $getSelection, - $isRangeSelection, - $isNodeSelection, - CAN_REDO_COMMAND, - CAN_UNDO_COMMAND, - COMMAND_PRIORITY_CRITICAL, - COMMAND_PRIORITY_EDITOR, - FORMAT_ELEMENT_COMMAND, - FORMAT_TEXT_COMMAND, - PASTE_COMMAND, - REDO_COMMAND, - SELECTION_CHANGE_COMMAND, - UNDO_COMMAND, - type EditorState, - type LexicalEditor, - type NodeSelection, - type RangeSelection, - $createNodeSelection, - $createRangeSelection, - $createTextNode, - $getNodeByKey, - $nodesOfType, - $setSelection, -} from 'lexical'; +import { LinkNode } from '@lexical/link'; import { - $createTableSelection, - $deleteTableColumn__EXPERIMENTAL, - $deleteTableRow__EXPERIMENTAL, - $getTableCellNodeFromLexicalNode, - $getTableNodeFromLexicalNodeOrThrow, - $insertTableColumn__EXPERIMENTAL, - $insertTableRow__EXPERIMENTAL, - $isTableCellNode, - $isTableRowNode, - $isTableSelection, - INSERT_TABLE_COMMAND, TableCellNode, - TableCellHeaderStates, TableNode, TableRowNode, - type TableSelection, } from '@lexical/table'; -import IconButton from './IconButton'; -import Button from './Button'; +import { $getRoot } from 'lexical'; + +import { ImageNode } from './rich-text/ImageNode'; import ContextMenuComponent, { type MenuItem as ContextMenuItem } from './ContextMenu'; -import { RedoIcon, UndoIcon } from './Icons'; -import { - AlignCenterIcon, - AlignJustifyIcon, - AlignLeftIcon, - AlignRightIcon, - BoldIcon, - BulletListIcon, - ClearFormattingIcon, - CodeInlineIcon, - HeadingOneIcon, - HeadingThreeIcon, - HeadingTwoIcon, - ImageIcon as ToolbarImageIcon, - ItalicIcon, - LinkIcon as ToolbarLinkIcon, - NumberListIcon, - ParagraphIcon, - QuoteIcon, - StrikethroughIcon, - TableIcon, - UnderlineIcon, -} from './rich-text/RichTextToolbarIcons'; -import { $createImageNode, ImageNode, INSERT_IMAGE_COMMAND, type ImagePayload } from './rich-text/ImageNode'; -import Modal from './Modal'; +import { ToolbarPlugin } from './rich-text/ToolbarPlugin'; +import { TableColumnResizePlugin } from './rich-text/TableColumnResizePlugin'; +import type { ToolbarButtonConfig } from './rich-text/types'; export interface RichTextEditorHandle { focus: () => void; @@ -128,43 +54,12 @@ interface RichTextEditorProps { onFocusChange?: (hasFocus: boolean) => void; } -interface ToolbarButtonConfig { - id: string; - label: string; - icon: React.FC<{ className?: string }>; - group: 'history' | 'inline-format' | 'structure' | 'insert' | 'alignment' | 'utility' | 'table'; - isActive?: boolean; - disabled?: boolean; - onClick: () => void; -} - -type BlockType = 'paragraph' | HeadingTagType | ListType | 'quote'; - interface ContextMenuState { x: number; y: number; visible: boolean; } -type SelectionSnapshot = - | { - type: 'range'; - anchorKey: string; - anchorOffset: number; - anchorType: 'text' | 'element'; - focusKey: string; - focusOffset: number; - focusType: 'text' | 'element'; - } - | { type: 'node'; keys: string[] } - | { - type: 'table'; - tableKey: string; - anchorCellKey: string; - focusCellKey: string; - } - | null; - const RICH_TEXT_THEME = { paragraph: 'mb-3 text-base leading-7 text-text-main', heading: { @@ -199,1995 +94,197 @@ const RICH_TEXT_THEME = { const Placeholder: React.FC = () => null; -const MIN_COLUMN_WIDTH = 72; - -const normalizeUrl = (url: string): string => { - const trimmed = url.trim(); - if (!trimmed) { - return ''; - } - - if (/^[a-zA-Z][\w+.-]*:/.test(trimmed)) { - return trimmed; - } - - return `https://${trimmed}`; -}; - -const LinkModal: React.FC<{ - isOpen: boolean; - initialUrl: string; - onSubmit: (url: string) => void; - onRemove: () => void; - onClose: () => void; -}> = ({ isOpen, initialUrl, onSubmit, onRemove, onClose }) => { - const inputRef = useRef(null); - const [url, setUrl] = useState(initialUrl); - - useEffect(() => { - setUrl(initialUrl); - }, [initialUrl]); - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - onSubmit(url); - }; - - if (!isOpen) { - return null; - } - - return ( - -
    -
    - - setUrl(event.target.value)} - className="w-full rounded-md border border-border-color bg-background px-3 py-2 text-sm text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30" - placeholder="https://example.com" - /> -

    - Enter a valid URL. If you omit the protocol, https:// will be added automatically. -

    -
    -
    - - - -
    -
    -
    - ); -}; - -const ensureColGroupWithWidths = ( - tableElement: HTMLTableElement, - preferredWidths: number[] = [], -): HTMLTableColElement[] => { - const firstRow = tableElement.rows[0]; - const columnCount = firstRow?.cells.length ?? 0; - if (columnCount === 0) { - return []; - } - - let colGroup = tableElement.querySelector('colgroup'); - if (!colGroup) { - colGroup = document.createElement('colgroup'); - tableElement.insertBefore(colGroup, tableElement.firstChild); - } - - while (colGroup.children.length < columnCount) { - const col = document.createElement('col'); - colGroup.appendChild(col); - } - - while (colGroup.children.length > columnCount) { - colGroup.lastElementChild?.remove(); - } - - const colElements = Array.from(colGroup.children) as HTMLTableColElement[]; - - if (preferredWidths.length === columnCount && preferredWidths.some(width => width > 0)) { - colElements.forEach((col, index) => { - const width = preferredWidths[index]; - if (Number.isFinite(width) && width > 0) { - col.style.width = `${Math.max(MIN_COLUMN_WIDTH, width)}px`; - } - }); - } else { - const existingWidths = colElements.map(col => parseFloat(col.style.width || '')); - const needInitialization = existingWidths.some(width => Number.isNaN(width) || width <= 0); - - if (needInitialization) { - const columnWidths = Array.from(firstRow.cells).map(cell => cell.getBoundingClientRect().width || MIN_COLUMN_WIDTH); - colElements.forEach((col, index) => { - const width = Math.max(MIN_COLUMN_WIDTH, columnWidths[index] ?? MIN_COLUMN_WIDTH); - col.style.width = `${width}px`; - }); - } - } - - return colElements; -}; - -const getColumnWidthsFromState = (editor: LexicalEditor, tableKey: string): number[] => { - let widths: number[] = []; - - editor.getEditorState().read(() => { - const tableNode = $getNodeByKey(tableKey); - if (!tableNode) { - return; - } - - const firstRow = tableNode.getChildren()[0]; - if (!firstRow) { - return; - } - - widths = firstRow - .getChildren() - .map(cell => cell.getWidth()) - .filter((width): width is number => Number.isFinite(width)); - }); - - return widths; -}; - -const attachColumnResizeHandles = ( - tableElement: HTMLTableElement, - editor: LexicalEditor, - tableKey: string, -): (() => void) => { - const container = tableElement.parentElement ?? tableElement; - const originalContainerPosition = container.style.position; - const restoreContainerPosition = originalContainerPosition === '' && getComputedStyle(container).position === 'static'; - - if (restoreContainerPosition) { - container.style.position = 'relative'; - } - - tableElement.style.tableLayout = 'fixed'; - - const overlay = document.createElement('div'); - overlay.style.position = 'absolute'; - overlay.style.inset = '0'; - overlay.style.pointerEvents = 'none'; - overlay.style.zIndex = '10'; - container.appendChild(overlay); - - const cleanupHandles: Array<() => void> = []; - const resizeObserver = new ResizeObserver(() => renderHandles()); - - function renderHandles() { - overlay.replaceChildren(); +const RichTextEditor = forwardRef( + ({ html, onChange, readOnly = false, onScroll, onFocusChange }, ref) => { + const [editorRef, setEditorRef] = useState(null); + const scrollContainerRef = useRef(null); + const [toolbarActions, setToolbarActions] = useState([]); + const [contextMenu, setContextMenu] = useState({ x: 0, y: 0, visible: false }); + const [contextMenuItems, setContextMenuItems] = useState([]); - const firstRow = tableElement.rows[0]; - if (!firstRow) { - return; - } + // Track if we are currently processing an external HTML update to avoid loops + const isUpdatingFromServer = useRef(false); - const storedColumnWidths = getColumnWidthsFromState(editor, tableKey); - const cols = ensureColGroupWithWidths(tableElement, storedColumnWidths); - const containerRect = container.getBoundingClientRect(); - const cells = Array.from(firstRow.cells); + useImperativeHandle(ref, () => ({ + focus: () => { + editorRef?.focus(); + }, + format: () => { + // Exposed for parent components if needed + }, + setScrollTop: (scrollTop: number) => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = scrollTop; + } + }, + getScrollInfo: async () => { + if (scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + return { scrollTop, scrollHeight, clientHeight }; + } + return { scrollTop: 0, scrollHeight: 0, clientHeight: 0 }; + }, + })); + + const initialConfig = { + namespace: 'DocForgeEditor', + theme: RICH_TEXT_THEME, + onError: (error: Error) => { + console.error('Lexical Error:', error); + }, + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + LinkNode, + TableNode, + TableCellNode, + TableRowNode, + ImageNode, + ], + editable: !readOnly, + }; - cells.forEach((cell, columnIndex) => { - if (columnIndex === cells.length - 1) { - return; + const handleScroll = useCallback(() => { + if (onScroll && scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + onScroll({ scrollTop, scrollHeight, clientHeight }); } + }, [onScroll]); - const cellRect = cell.getBoundingClientRect(); - const handle = document.createElement('div'); - handle.setAttribute('role', 'presentation'); - handle.contentEditable = 'false'; - handle.style.position = 'absolute'; - handle.style.top = `${tableElement.offsetTop}px`; - handle.style.left = `${cellRect.right - containerRect.left - 3}px`; - handle.style.width = '6px'; - handle.style.height = `${tableElement.offsetHeight}px`; - handle.style.cursor = 'col-resize'; - handle.style.pointerEvents = 'auto'; - handle.style.userSelect = 'none'; - - let startX = 0; - let leftWidth = 0; - let rightWidth = 0; - - const handleMouseMove = (event: MouseEvent) => { - const deltaX = event.clientX - startX; - const nextLeftWidth = Math.max(MIN_COLUMN_WIDTH, leftWidth + deltaX); - const nextRightWidth = Math.max(MIN_COLUMN_WIDTH, rightWidth - deltaX); - - cols[columnIndex].style.width = `${nextLeftWidth}px`; - cols[columnIndex + 1].style.width = `${nextRightWidth}px`; - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - - const updatedWidths = cols.map(col => parseFloat(col.style.width || '')); - editor.update(() => { - const tableNode = $getNodeByKey(tableKey); - if (!tableNode) { - return; - } - - const rows = tableNode.getChildren(); - rows.forEach(row => { - const cellsInRow = row.getChildren(); - cellsInRow.forEach((cellNode, cellIndex) => { - const width = updatedWidths[cellIndex]; - if (Number.isFinite(width) && width > 0) { - cellNode.setWidth(Math.max(MIN_COLUMN_WIDTH, width)); - } - }); - }); - }); - }; - - const handleMouseDown = (event: MouseEvent) => { - event.preventDefault(); - startX = event.clientX; - leftWidth = parseFloat(cols[columnIndex].style.width || `${cell.offsetWidth}`); - rightWidth = parseFloat( - cols[columnIndex + 1].style.width || `${cells[columnIndex + 1]?.offsetWidth ?? MIN_COLUMN_WIDTH}`, - ); - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - handle.addEventListener('mousedown', handleMouseDown); - cleanupHandles.push(() => handle.removeEventListener('mousedown', handleMouseDown)); - overlay.appendChild(handle); - }); - } - - resizeObserver.observe(tableElement); - renderHandles(); - - return () => { - cleanupHandles.forEach(cleanup => cleanup()); - resizeObserver.disconnect(); - overlay.remove(); - - if (restoreContainerPosition) { - container.style.position = originalContainerPosition; - } - }; -}; - -const TableColumnResizePlugin: React.FC = () => { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - const cleanupMap = new Map void>(); + // Handle initial HTML load and updates + useEffect(() => { + if (!editorRef) return; - const cleanupTable = (key: string) => { - const cleanup = cleanupMap.get(key); - if (cleanup) { - cleanup(); - cleanupMap.delete(key); - } - }; + editorRef.update(() => { + // Check if content is actually different to avoid cursor jumping or unnecessary updates + const currentHtml = $generateHtmlFromNodes(editorRef, null); + if (currentHtml === html) return; - const initializeTable = (tableNode: TableNode) => { - const tableKey = tableNode.getKey(); - const tableElement = editor.getElementByKey(tableKey); - if (tableElement instanceof HTMLTableElement) { - cleanupTable(tableKey); - cleanupMap.set(tableKey, attachColumnResizeHandles(tableElement, editor, tableKey)); - } - }; + isUpdatingFromServer.current = true; + const parser = new DOMParser(); + const dom = parser.parseFromString(html || '


    ', 'text/html'); + const nodes = $generateNodesFromDOM(editorRef, dom); - editor.getEditorState().read(() => { - const tableNodes = $nodesOfType(TableNode); - tableNodes.forEach(tableNode => { - initializeTable(tableNode); + $getRoot().clear(); + $getRoot().append(...nodes); + isUpdatingFromServer.current = false; }); - }); + }, [html, editorRef]); - const unregisterMutationListener = editor.registerMutationListener(TableNode, mutations => { - editor.getEditorState().read(() => { - mutations.forEach((mutation, key) => { - if (mutation === 'created') { - const tableNode = $getNodeByKey(key); - if (tableNode) { - initializeTable(tableNode); - } - } else if (mutation === 'destroyed') { - cleanupTable(key); - } - }); - }); - }); + const handleEditorChange = (editorState: any) => { + // Skip if this change was triggered by our own sync + if (isUpdatingFromServer.current) return; - return () => { - unregisterMutationListener(); - cleanupMap.forEach(cleanup => cleanup()); - cleanupMap.clear(); + editorState.read(() => { + // Generate HTML + const htmlString = $generateHtmlFromNodes(editorRef, null); + onChange(htmlString); + }); }; - }, [editor]); - - return null; -}; - -const TableModal: React.FC<{ - isOpen: boolean; - onClose: () => void; - onInsertTable: (rows: number, columns: number, includeHeaderRow: boolean) => void; - onInsertRowAbove: () => void; - onInsertRowBelow: () => void; - onInsertColumnLeft: () => void; - onInsertColumnRight: () => void; - onDeleteRow: () => void; - onDeleteColumn: () => void; - onDeleteTable: () => void; - onToggleHeaderRow: () => void; - isInTable: boolean; - hasHeaderRow: boolean; -}> = ({ - isOpen, - onClose, - onInsertTable, - onInsertRowAbove, - onInsertRowBelow, - onInsertColumnLeft, - onInsertColumnRight, - onDeleteRow, - onDeleteColumn, - onDeleteTable, - onToggleHeaderRow, - isInTable, - hasHeaderRow, -}) => { - const [rows, setRows] = useState(3); - const [columns, setColumns] = useState(3); - const [includeHeaderRow, setIncludeHeaderRow] = useState(true); - const [hoveredGrid, setHoveredGrid] = useState<{ rows: number; columns: number } | null>(null); - const inputRef = useRef(null); - - const displayRows = hoveredGrid?.rows ?? rows; - const displayColumns = hoveredGrid?.columns ?? columns; - - useEffect(() => { - if (!isOpen) { - return; - } - setRows(3); - setColumns(3); - setIncludeHeaderRow(true); - setHoveredGrid(null); - }, [isOpen]); - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - onInsertTable(displayRows, displayColumns, includeHeaderRow); - }; - const handleGridClick = (row: number, column: number) => { - setRows(row); - setColumns(column); - setHoveredGrid(null); - onInsertTable(row, column, includeHeaderRow); - }; - - if (!isOpen) { - return null; - } - - return ( - -
    -
    -
    -
    - Select size - - {displayRows} × {displayColumns} cells - -
    -
    - {Array.from({ length: 6 }).map((_, rowIndex) => ( - - {Array.from({ length: 6 }).map((_, columnIndex) => { - const rowNumber = rowIndex + 1; - const columnNumber = columnIndex + 1; - const isActive = - (hoveredGrid?.rows ?? rows) >= rowNumber && (hoveredGrid?.columns ?? columns) >= columnNumber; - return ( -
    -
    -
    - -
    -
    - Rows - setRows(Math.max(1, Math.min(20, Number(event.target.value) || 1)))} - className="w-full rounded-md border border-border-color bg-primary-text/5 px-3 py-2 text-sm text-text-main focus:border-primary focus:ring-1 focus:ring-primary" - /> -
    -
    - Columns - setColumns(Math.max(1, Math.min(20, Number(event.target.value) || 1)))} - className="w-full rounded-md border border-border-color bg-primary-text/5 px-3 py-2 text-sm text-text-main focus:border-primary focus:ring-1 focus:ring-primary" - /> -
    -
    - -
    - -
    -
    -
    - -
    -
    - Current table tools - {isInTable ? 'Actions apply to the selected cell' : 'Place the caret inside a table to enable tools'} -
    -
    - - - - - - - - + return ( +
    +
    +
    + {/* + We instantiate the ToolbarPlugin inside the Composer, but we render the buttons here. + Actually, the ToolbarPlugin is a component *inside* the Composer that renders nothing but uses the context. + Wait, the extracted toolbar plugin RENDS modals but we need the buttons. + The extracted ToolbarPlugin accepts `onActionsChange` which gives us the buttons config. + We render the buttons here using that config. + */} + {toolbarActions.map(action => ( + + ))}
    - - - ); -}; - -const ToolbarButton: React.FC = ({ label, icon: Icon, isActive = false, disabled = false, onClick }) => ( - { - // Prevent the toolbar button from stealing focus, which would clear the - // user's selection in the editor before the command executes. - event.preventDefault(); - }} - onClick={onClick} - disabled={disabled} - aria-pressed={isActive} - aria-label={label} - className={`transition-all duration-200 ${isActive - ? 'bg-primary/10 text-primary hover:bg-primary/20 hover:text-primary shadow-sm' - : 'text-text-secondary hover:text-text-main hover:bg-secondary-hover' - } disabled:opacity-30 disabled:pointer-events-none`} - > - - -); - -const ToolbarPlugin: React.FC<{ - readOnly: boolean; - onActionsChange: (actions: ToolbarButtonConfig[]) => void; -}> = ({ readOnly, onActionsChange }) => { - const [editor] = useLexicalComposerContext(); - const fileInputRef = useRef(null); - const [isBold, setIsBold] = useState(false); - const [isItalic, setIsItalic] = useState(false); - const [isUnderline, setIsUnderline] = useState(false); - const [isStrikethrough, setIsStrikethrough] = useState(false); - const [isCode, setIsCode] = useState(false); - const [isLink, setIsLink] = useState(false); - const [blockType, setBlockType] = useState('paragraph'); - const [alignment, setAlignment] = useState<'left' | 'center' | 'right' | 'justify'>('left'); - const [canUndo, setCanUndo] = useState(false); - const [canRedo, setCanRedo] = useState(false); - const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); - const [linkDraftUrl, setLinkDraftUrl] = useState(''); - const [isTableModalOpen, setIsTableModalOpen] = useState(false); - const [isInTable, setIsInTable] = useState(false); - const [hasHeaderRow, setHasHeaderRow] = useState(false); - const pendingLinkSelectionRef = useRef(null); - const pendingTableSelectionRef = useRef(null); - const closeLinkModal = useCallback(() => { - setIsLinkModalOpen(false); - }, []); - const dismissLinkModal = useCallback(() => { - pendingLinkSelectionRef.current = null; - closeLinkModal(); - }, [closeLinkModal]); - const restoreSelectionFromSnapshot = useCallback( - (snapshot: SelectionSnapshot | null | undefined = pendingLinkSelectionRef.current) => { - const snapshotToUse = snapshot ?? pendingLinkSelectionRef.current; - - if (!snapshotToUse) { - return null; - } - - if (snapshotToUse.type === 'table') { - const selection = $createTableSelection(); - const tableNode = $getNodeByKey(snapshotToUse.tableKey); - const anchorCell = $getNodeByKey(snapshotToUse.anchorCellKey); - const focusCell = $getNodeByKey(snapshotToUse.focusCellKey); - - if (!tableNode || !anchorCell || !focusCell) { - return null; - } - - selection.set(snapshotToUse.tableKey, snapshotToUse.anchorCellKey, snapshotToUse.focusCellKey); - return selection; - } - - if (snapshotToUse.type === 'range') { - const selection = $createRangeSelection(); - const anchorNode = $getNodeByKey(snapshotToUse.anchorKey); - const focusNode = $getNodeByKey(snapshotToUse.focusKey); - - if (!anchorNode || !focusNode) { - return null; - } - - selection.anchor.set(snapshotToUse.anchorKey, snapshotToUse.anchorOffset, snapshotToUse.anchorType); - selection.focus.set(snapshotToUse.focusKey, snapshotToUse.focusOffset, snapshotToUse.focusType); - return selection; - } - - const selection = $createNodeSelection(); - snapshotToUse.keys.forEach(key => { - const node = $getNodeByKey(key); - if (node) { - selection.add(node.getKey()); - } - }); - - return selection.getNodes().length > 0 ? selection : null; - }, []); - - const updateToolbar = useCallback(() => { - const selection = $getSelection(); - const hasValidSelection = $isRangeSelection(selection) || $isTableSelection(selection); - - if (!hasValidSelection) { - setIsBold(false); - setIsItalic(false); - setIsUnderline(false); - setIsStrikethrough(false); - setIsCode(false); - setIsLink(false); - setBlockType('paragraph'); - setAlignment('left'); - setIsInTable(false); - setHasHeaderRow(false); - return; - } - - const anchorNode = selection.anchor.getNode(); - const tableCellNode = $getTableCellNodeFromLexicalNode(anchorNode); - if (tableCellNode) { - const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); - setIsInTable(true); - const firstRow = tableNode.getFirstChild(); - let hasHeader = false; - if (firstRow && $isTableRowNode(firstRow)) { - hasHeader = firstRow.getChildren().some(child => $isTableCellNode(child) && child.hasHeaderState(TableCellHeaderStates.ROW)); - } - setHasHeaderRow(hasHeader); - } else { - setIsInTable(false); - setHasHeaderRow(false); - } - - if (!$isRangeSelection(selection)) { - setIsBold(false); - setIsItalic(false); - setIsUnderline(false); - setIsStrikethrough(false); - setIsCode(false); - setIsLink(false); - setBlockType('paragraph'); - setAlignment('left'); - return; - } - - setIsBold(selection.hasFormat('bold')); - setIsItalic(selection.hasFormat('italic')); - setIsUnderline(selection.hasFormat('underline')); - setIsStrikethrough(selection.hasFormat('strikethrough')); - setIsCode(selection.hasFormat('code')); - - const element = anchorNode.getTopLevelElementOrThrow(); - - if ($isHeadingNode(element)) { - setBlockType(element.getTag()); - } else if ($isListNode(element)) { - setBlockType(element.getListType()); - } else if (element.getType() === 'quote') { - setBlockType('quote'); - } else { - setBlockType('paragraph'); - } - - const elementAlignment = element.getFormatType(); - setAlignment((elementAlignment || 'left') as 'left' | 'center' | 'right' | 'justify'); - - const nodes = selection.getNodes(); - setIsLink(nodes.some(node => $isLinkNode(node) || $isLinkNode(node.getParent()))); - }, []); +
    + + {/* Capture the editor instance */} + + + + + + + + + + } + placeholder={} + ErrorBoundary={LexicalErrorBoundary} + /> + {/* Ref Handler */} + + {/* Focus Handler */} + + - useEffect(() => { - return mergeRegister( - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - updateToolbar(); - return false; - }, - COMMAND_PRIORITY_CRITICAL, - ), - editor.registerUpdateListener(({ editorState }) => { - editorState.read(() => { - updateToolbar(); - }); - }), - editor.registerCommand( - CAN_UNDO_COMMAND, - payload => { - setCanUndo(payload); - return false; - }, - COMMAND_PRIORITY_CRITICAL, - ), - editor.registerCommand( - CAN_REDO_COMMAND, - payload => { - setCanRedo(payload); - return false; - }, - COMMAND_PRIORITY_CRITICAL, - ), + {/* Context Menu would go here if we kept it enabled */} +
    +
    ); - }, [editor, updateToolbar]); - - const formatHeading = useCallback( - (heading: HeadingTagType) => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode(heading)); - } - }); - }, - [editor], - ); - - const formatParagraph = useCallback(() => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createParagraphNode()); - } - }); - }, [editor]); - - const formatQuote = useCallback(() => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createQuoteNode()); - } - }); - }, [editor]); - - const captureLinkState = useCallback(() => { - let detectedUrl = ''; - - editor.getEditorState().read(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - pendingLinkSelectionRef.current = { - type: 'range', - anchorKey: selection.anchor.key, - anchorOffset: selection.anchor.offset, - anchorType: selection.anchor.type, - focusKey: selection.focus.key, - focusOffset: selection.focus.offset, - focusType: selection.focus.type, - }; - - const selectionNodes = selection.getNodes(); - if (selectionNodes.length === 0) { - return; - } - - const firstNode = selectionNodes[0]; - const linkNode = $isLinkNode(firstNode) - ? firstNode - : $isLinkNode(firstNode.getParent()) - ? firstNode.getParent() - : null; - - if ($isLinkNode(linkNode)) { - detectedUrl = linkNode.getURL(); - } - return; - } - - if ($isNodeSelection(selection)) { - const nodes = selection.getNodes(); - pendingLinkSelectionRef.current = { type: 'node', keys: nodes.map(node => node.getKey()) }; - } else { - pendingLinkSelectionRef.current = null; - } - }); - - if (!pendingLinkSelectionRef.current) { - return false; - } - - setLinkDraftUrl(detectedUrl); - setIsLinkModalOpen(true); - return true; - }, [editor]); - - const captureTableSelection = useCallback(() => { - let snapshot: SelectionSnapshot = null; - - editor.getEditorState().read(() => { - const selection = $getSelection(); - - if ($isTableSelection(selection)) { - const anchorCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); - const focusCell = $getTableCellNodeFromLexicalNode(selection.focus.getNode()); - - if (!anchorCell || !focusCell) { - return; - } - - const tableNode = $getTableNodeFromLexicalNodeOrThrow(anchorCell); - snapshot = { - type: 'table', - tableKey: tableNode.getKey(), - anchorCellKey: anchorCell.getKey(), - focusCellKey: focusCell.getKey(), - }; - return; - } - - if ($isRangeSelection(selection)) { - const anchorCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); - const focusCell = $getTableCellNodeFromLexicalNode(selection.focus.getNode()); - - if (!anchorCell || !focusCell) { - return; - } - - snapshot = { - type: 'range', - anchorKey: selection.anchor.key, - anchorOffset: selection.anchor.offset, - anchorType: selection.anchor.type, - focusKey: selection.focus.key, - focusOffset: selection.focus.offset, - focusType: selection.focus.type, - }; - } - }); - - pendingTableSelectionRef.current = snapshot; - return snapshot !== null; - }, [editor]); - - const applyLink = useCallback( - (url: string) => { - closeLinkModal(); - - const selectionSnapshot = pendingLinkSelectionRef.current; - pendingLinkSelectionRef.current = null; - - const normalizedUrl = normalizeUrl(url); - if (!normalizedUrl) { - editor.focus(); - return; - } - - editor.update(() => { - const selectionFromSnapshot = restoreSelectionFromSnapshot(selectionSnapshot); - const selectionToUse = selectionFromSnapshot ?? (() => { - const activeSelection = $getSelection(); - if ($isRangeSelection(activeSelection) || $isNodeSelection(activeSelection)) { - return activeSelection; - } - const root = $getRoot(); - return root.selectEnd(); - })(); - - if (!selectionToUse) { - return; - } - - $setSelection(selectionToUse); - editor.dispatchCommand(TOGGLE_LINK_COMMAND, normalizedUrl); - }); - editor.focus(); - }, - [closeLinkModal, editor, restoreSelectionFromSnapshot], - ); - - const removeLink = useCallback(() => { - closeLinkModal(); - - const selectionSnapshot = pendingLinkSelectionRef.current; - pendingLinkSelectionRef.current = null; - - editor.update(() => { - const selectionFromSnapshot = restoreSelectionFromSnapshot(selectionSnapshot); - const selectionToUse = selectionFromSnapshot ?? (() => { - const activeSelection = $getSelection(); - if ($isRangeSelection(activeSelection) || $isNodeSelection(activeSelection)) { - return activeSelection; - } - const root = $getRoot(); - return root.selectEnd(); - })(); - - if (!selectionToUse) { - return; - } - - $setSelection(selectionToUse); - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); - }); - editor.focus(); - }, [closeLinkModal, editor, restoreSelectionFromSnapshot]); - - const toggleLink = useCallback(() => { - if (readOnly) { - return; - } - - const hasSelection = captureLinkState(); - if (!hasSelection) { - editor.focus(); - } - }, [captureLinkState, editor, readOnly]); - - const insertImage = useCallback( - (payload: ImagePayload) => { - if (!payload.src) { - return; - } - editor.dispatchCommand(INSERT_IMAGE_COMMAND, payload); - }, - [editor], - ); - - const closeTableModal = useCallback(() => { - pendingTableSelectionRef.current = null; - setIsTableModalOpen(false); - }, []); - - const openTableModal = useCallback(() => { - if (readOnly) { - return; - } - - captureTableSelection(); - setIsTableModalOpen(true); - }, [captureTableSelection, readOnly]); - - const runWithActiveTable = useCallback( - (action: (selection: RangeSelection | NodeSelection | TableSelection) => void) => { - editor.update(() => { - let selection = $getSelection(); - if (!selection || (!$isRangeSelection(selection) && !$isTableSelection(selection))) { - const restoredSelection = restoreSelectionFromSnapshot(pendingTableSelectionRef.current); - if (restoredSelection) { - $setSelection(restoredSelection); - selection = restoredSelection; - } - } - - if (!selection || (!$isRangeSelection(selection) && !$isTableSelection(selection))) { - return; - } - const anchorNode = selection.anchor.getNode(); - if (!$getTableCellNodeFromLexicalNode(anchorNode)) { - return; - } - action(selection); - }); - }, - [editor, restoreSelectionFromSnapshot], - ); - - const insertTable = useCallback( - (rows: number, columns: number, includeHeaderRow: boolean) => { - if (readOnly) { - return; - } - const normalizedRows = Math.max(1, Math.min(20, rows)); - const normalizedColumns = Math.max(1, Math.min(20, columns)); - editor.dispatchCommand(INSERT_TABLE_COMMAND, { - columns: String(normalizedColumns), - rows: String(normalizedRows), - includeHeaders: includeHeaderRow ? { rows: true, columns: false } : false, - }); - setIsTableModalOpen(false); - editor.focus(); - }, - [editor, readOnly], - ); - - const insertTableRow = useCallback( - (insertAfter: boolean) => - runWithActiveTable(() => { - $insertTableRow__EXPERIMENTAL(insertAfter); - }), - [runWithActiveTable], - ); - - const insertTableColumn = useCallback( - (insertAfter: boolean) => - runWithActiveTable(() => { - $insertTableColumn__EXPERIMENTAL(insertAfter); - }), - [runWithActiveTable], - ); - - const deleteTableRow = useCallback( - () => - runWithActiveTable(() => { - $deleteTableRow__EXPERIMENTAL(); - }), - [runWithActiveTable], - ); - - const deleteTableColumn = useCallback( - () => - runWithActiveTable(() => { - $deleteTableColumn__EXPERIMENTAL(); - }), - [runWithActiveTable], - ); - - const deleteTable = useCallback(() => { - runWithActiveTable(selection => { - const tableCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); - if (!tableCell) { - return; - } - const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCell); - tableNode.remove(); - }); - setIsTableModalOpen(false); - }, [runWithActiveTable]); - - const selectTable = useCallback( - () => - runWithActiveTable(selection => { - const tableCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); - if (!tableCell) { - return; - } - const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCell); - const firstRow = tableNode.getFirstChild(); - const lastRow = tableNode.getLastChild(); - - if (!$isTableRowNode(firstRow) || !$isTableRowNode(lastRow)) { - return; - } - - const firstCell = firstRow.getFirstChild(); - const lastCell = lastRow.getLastChild(); - - if (!$isTableCellNode(firstCell) || !$isTableCellNode(lastCell)) { - return; - } - - const tableSelection = $createTableSelection(); - tableSelection.set(tableNode.getKey(), firstCell.getKey(), lastCell.getKey()); - $setSelection(tableSelection); - }), - [runWithActiveTable], - ); - - const toggleHeaderRow = useCallback( - () => - runWithActiveTable(selection => { - const tableCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); - if (!tableCell) { - return; - } - const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCell); - const firstRow = tableNode.getFirstChild(); - if (!firstRow || !$isTableRowNode(firstRow)) { - return; - } - const shouldAddHeader = !firstRow - .getChildren() - .some(child => $isTableCellNode(child) && child.hasHeaderState(TableCellHeaderStates.ROW)); - - firstRow.getChildren().forEach(child => { - if ($isTableCellNode(child)) { - child.setHeaderStyles(shouldAddHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS); - } - }); - }), - [runWithActiveTable], - ); - - const openImagePicker = useCallback(() => { - if (readOnly) { - return; - } - fileInputRef.current?.click(); - }, [readOnly]); - - const handleImageFileChange = useCallback( - (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - event.target.value = ''; - if (!file) { - return; - } - - const reader = new FileReader(); - reader.addEventListener('load', () => { - const result = reader.result; - if (typeof result === 'string') { - const altText = file.name.replace(/\.[^/.]+$/, ''); - insertImage({ src: result, altText }); - } - }); - reader.readAsDataURL(file); - }, - [insertImage], - ); - - const toolbarButtons = useMemo( - () => [ - { - id: 'undo', - label: 'Undo', - icon: UndoIcon, - group: 'history', - disabled: readOnly || !canUndo, - onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined), - }, - { - id: 'redo', - label: 'Redo', - icon: RedoIcon, - group: 'history', - disabled: readOnly || !canRedo, - onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined), - }, - { - id: 'bold', - label: 'Bold', - icon: BoldIcon, - group: 'inline-format', - isActive: isBold, - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'), - }, - { - id: 'italic', - label: 'Italic', - icon: ItalicIcon, - group: 'inline-format', - isActive: isItalic, - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'), - }, - { - id: 'underline', - label: 'Underline', - icon: UnderlineIcon, - group: 'inline-format', - isActive: isUnderline, - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'), - }, - { - id: 'strikethrough', - label: 'Strikethrough', - icon: StrikethroughIcon, - group: 'inline-format', - isActive: isStrikethrough, - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'), - }, - { - id: 'code', - label: 'Inline Code', - icon: CodeInlineIcon, - group: 'inline-format', - isActive: isCode, - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'), - }, - { - id: 'paragraph', - label: 'Paragraph', - icon: ParagraphIcon, - group: 'structure', - isActive: blockType === 'paragraph', - disabled: readOnly, - onClick: formatParagraph, - }, - { - id: 'h1', - label: 'Heading 1', - icon: HeadingOneIcon, - group: 'structure', - isActive: blockType === 'h1', - disabled: readOnly, - onClick: () => formatHeading('h1'), - }, - { - id: 'h2', - label: 'Heading 2', - icon: HeadingTwoIcon, - group: 'structure', - isActive: blockType === 'h2', - disabled: readOnly, - onClick: () => formatHeading('h2'), - }, - { - id: 'h3', - label: 'Heading 3', - icon: HeadingThreeIcon, - group: 'structure', - isActive: blockType === 'h3', - disabled: readOnly, - onClick: () => formatHeading('h3'), - }, - { - id: 'bulleted', - label: 'Bulleted List', - icon: BulletListIcon, - group: 'structure', - isActive: blockType === 'bullet', - disabled: readOnly, - onClick: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), - }, - { - id: 'numbered', - label: 'Numbered List', - icon: NumberListIcon, - group: 'structure', - isActive: blockType === 'number', - disabled: readOnly, - onClick: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), - }, - { - id: 'quote', - label: 'Block Quote', - icon: QuoteIcon, - group: 'structure', - isActive: blockType === 'quote', - disabled: readOnly, - onClick: formatQuote, - }, - { - id: 'link', - label: isLink ? 'Edit or Remove Link' : 'Insert Link', - icon: ToolbarLinkIcon, - group: 'insert', - isActive: isLink, - disabled: readOnly, - onClick: toggleLink, - }, - { - id: 'table', - label: isInTable ? 'Table tools' : 'Insert Table', - icon: TableIcon, - group: 'insert', - disabled: readOnly, - onClick: openTableModal, - }, - { - id: 'image', - label: 'Insert Image', - icon: ToolbarImageIcon, - group: 'insert', - disabled: readOnly, - onClick: openImagePicker, - }, - { - id: 'align-left', - label: 'Align Left', - icon: AlignLeftIcon, - group: 'alignment', - isActive: alignment === 'left', - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'), - }, - { - id: 'align-center', - label: 'Align Center', - icon: AlignCenterIcon, - group: 'alignment', - isActive: alignment === 'center', - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'), - }, - { - id: 'align-right', - label: 'Align Right', - icon: AlignRightIcon, - group: 'alignment', - isActive: alignment === 'right', - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'), - }, - { - id: 'align-justify', - label: 'Justify', - icon: AlignJustifyIcon, - group: 'alignment', - isActive: alignment === 'justify', - disabled: readOnly, - onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'), - }, - { - id: 'clear-formatting', - label: 'Clear Formatting', - icon: ClearFormattingIcon, - group: 'utility', - disabled: readOnly, - onClick: () => { - if (isBold) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); - } - if (isItalic) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); - } - if (isUnderline) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); - } - if (isStrikethrough) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); - } - if (isCode) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); - } - if (isLink) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); - } - editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); - editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'); - formatParagraph(); - }, - }, - ], - [ - alignment, - blockType, - canRedo, - canUndo, - editor, - formatHeading, - formatParagraph, - formatQuote, - isBold, - isCode, - isItalic, - isLink, - isStrikethrough, - isUnderline, - openImagePicker, - openTableModal, - readOnly, - toggleLink, - ], - ); - - const tableContextActions = useMemo( - () => [ - { - id: 'insert-column-before', - label: 'Insert column before', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: () => insertTableColumn(false), - }, - { - id: 'insert-column-after', - label: 'Insert column after', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: () => insertTableColumn(true), - }, - { - id: 'insert-row-before', - label: 'Insert row before', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: () => insertTableRow(false), - }, - { - id: 'insert-row-after', - label: 'Insert row after', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: () => insertTableRow(true), - }, - { - id: 'delete-row', - label: 'Delete row', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: deleteTableRow, - }, - { - id: 'delete-column', - label: 'Delete column', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: deleteTableColumn, - }, - { - id: 'delete-table', - label: 'Delete table', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: deleteTable, - }, - { - id: 'select-table', - label: 'Select table', - icon: TableIcon, - group: 'table', - disabled: readOnly || !isInTable, - onClick: selectTable, - }, - ], - [ - deleteTable, - deleteTableColumn, - deleteTableRow, - insertTableColumn, - insertTableRow, - isInTable, - readOnly, - selectTable, - ], - ); - - const contextMenuActions = useMemo( - () => [...toolbarButtons, ...tableContextActions], - [tableContextActions, toolbarButtons], - ); - - useEffect(() => { - onActionsChange(contextMenuActions); - }, [contextMenuActions, onActionsChange]); - - const renderedToolbarElements = useMemo( - () => { - const items: (ToolbarButtonConfig | { type: 'separator'; id: string })[] = []; - toolbarButtons.forEach((button, index) => { - const previous = toolbarButtons[index - 1]; - if (previous && previous.group !== button.group) { - items.push({ type: 'separator', id: `separator-${button.group}-${index}` }); - } - items.push(button); - }); - return items; - }, - [toolbarButtons], - ); + } +); - return ( - <> -
    - {renderedToolbarElements.map(element => - 'type' in element ? ( -
    - ) : ( - - ), - )} - -
    - - insertTableRow(false)} - onInsertRowBelow={() => insertTableRow(true)} - onInsertColumnLeft={() => insertTableColumn(false)} - onInsertColumnRight={() => insertTableColumn(true)} - onDeleteRow={deleteTableRow} - onDeleteColumn={deleteTableColumn} - onDeleteTable={deleteTable} - onToggleHeaderRow={toggleHeaderRow} - isInTable={isInTable} - hasHeaderRow={hasHeaderRow} - /> - - ); -}; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -const HtmlContentSynchronizer: React.FC<{ html: string; lastAppliedHtmlRef: React.MutableRefObject }> = ({ - html, - lastAppliedHtmlRef, -}) => { +// Helper plugin to expose the editor instance +const EditorRefPlugin = ({ setEditorRef }: { setEditorRef: (editor: any) => void }) => { const [editor] = useLexicalComposerContext(); - useEffect(() => { - applyHtmlToEditor(editor, html, lastAppliedHtmlRef); - }, [editor, html, lastAppliedHtmlRef]); - + setEditorRef(editor); + }, [editor, setEditorRef]); return null; }; -const ImperativeBridgePlugin: React.FC<{ - forwardRef: React.Ref; - scrollContainerRef: React.RefObject; - lastAppliedHtmlRef: React.MutableRefObject; - readOnly: boolean; -}> = ({ forwardRef, scrollContainerRef, lastAppliedHtmlRef, readOnly }) => { +// Helper plugin to track focus +const FocusPlugin = ({ onFocusChange }: { onFocusChange?: (hasFocus: boolean) => void }) => { const [editor] = useLexicalComposerContext(); - useEffect(() => { - editor.setEditable(!readOnly); - }, [editor, readOnly]); - - useImperativeHandle(forwardRef, () => ({ - focus: () => { - editor.focus(); - }, - format: () => { - const html = lastAppliedHtmlRef.current; - editor.update(() => { - const root = $getRoot(); - root.clear(); - const trimmed = html.trim(); - if (!trimmed) { - lastAppliedHtmlRef.current = ''; - return; - } - const parser = new DOMParser(); - const dom = parser.parseFromString(trimmed, 'text/html'); - const nodes = $generateNodesFromDOM(editor, dom); - nodes.forEach(node => root.append(node)); - lastAppliedHtmlRef.current = trimmed; - }); - }, - setScrollTop: (scrollTop: number) => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollTop; + return editor.registerRootListener((rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => { + if (prevRootElement) { + prevRootElement.removeEventListener('focus', handleFocus); + prevRootElement.removeEventListener('blur', handleBlur); } - }, - getScrollInfo: async () => { - const el = scrollContainerRef.current; - if (!el) { - return { scrollTop: 0, scrollHeight: 0, clientHeight: 0 }; + if (rootElement) { + rootElement.addEventListener('focus', handleFocus); + rootElement.addEventListener('blur', handleBlur); } - return { - scrollTop: el.scrollTop, - scrollHeight: el.scrollHeight, - clientHeight: el.clientHeight, - }; - }, - }), [editor, scrollContainerRef, lastAppliedHtmlRef]); - - return null; -}; - -const ImagePlugin: React.FC = () => { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([ImageNode])) { - throw new Error('ImageNode not registered on editor'); - } - - return editor.registerCommand( - INSERT_IMAGE_COMMAND, - payload => { - if (!payload?.src) { - return false; - } - - editor.update(() => { - const imageNode = $createImageNode(payload); - const selection = $getSelection(); - if ($isRangeSelection(selection) || $isNodeSelection(selection)) { - selection.insertNodes([imageNode]); - } else { - const root = $getRoot(); - root.append(imageNode); - } - }); - - return true; - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [editor]); - - return null; -}; - -const readFileAsDataUrl = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - resolve(typeof reader.result === 'string' ? reader.result : ''); - }; - reader.onerror = error => reject(error); - reader.readAsDataURL(file); - }); -}; - -const ClipboardImagePlugin: React.FC<{ readOnly: boolean }> = ({ readOnly }) => { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (readOnly) { - return undefined; - } - - return editor.registerCommand( - PASTE_COMMAND, - (event: ClipboardEvent) => { - const clipboardData = event.clipboardData; - if (!clipboardData) { - return false; - } - - const files = Array.from(clipboardData.items) - .filter(item => item.kind === 'file' && item.type.startsWith('image/')) - .map(item => item.getAsFile()) - .filter((file): file is File => Boolean(file)); - - if (files.length === 0) { - return false; - } - - event.preventDefault(); - files.forEach(file => { - void readFileAsDataUrl(file) - .then(src => { - if (!src) { - return; - } - editor.dispatchCommand(INSERT_IMAGE_COMMAND, { - src, - altText: file.name || 'Pasted image', - }); - }) - .catch(() => undefined); - }); - - return true; - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [editor, readOnly]); - - return null; -}; - -const sanitizeDomFromHtml = (html: string): Document => { - const parser = new DOMParser(); - const dom = parser.parseFromString(html, 'text/html'); - dom.querySelectorAll('script,style').forEach(node => node.remove()); - return dom; -}; - -const fallbackToPlainText = (text: string) => { - const parser = new DOMParser(); - const dom = parser.parseFromString(text, 'text/html'); - return (dom.body.textContent || '').trim(); -}; - -const applyHtmlToEditor = ( - editor: LexicalEditor, - html: string, - lastAppliedHtmlRef: React.MutableRefObject, -) => { - const normalizedIncoming = html.trim(); - - editor.update(() => { - const root = $getRoot(); - const currentHtml = $generateHtmlFromNodes(editor).trim(); - - if (currentHtml === normalizedIncoming) { - lastAppliedHtmlRef.current = normalizedIncoming; - return; - } - - if (normalizedIncoming === lastAppliedHtmlRef.current && currentHtml !== '') { - return; - } - - root.clear(); - - if (!normalizedIncoming) { - lastAppliedHtmlRef.current = ''; - root.append($createParagraphNode()); - return; - } - - try { - const dom = sanitizeDomFromHtml(normalizedIncoming); - const nodes = $generateNodesFromDOM(editor, dom); - if (nodes.length === 0) { - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode('')); - root.append(paragraph); - } else { - nodes.forEach(node => root.append(node)); - } - lastAppliedHtmlRef.current = normalizedIncoming; - } catch (error) { - console.error('Failed to sync HTML content into the rich text editor.', error); - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(fallbackToPlainText(normalizedIncoming))); - root.append(paragraph); - lastAppliedHtmlRef.current = paragraph.getTextContent(); - } - }); -}; - -const RichTextEditor = forwardRef( - ({ html, onChange, readOnly = false, onScroll, onFocusChange }, ref) => { - const scrollContainerRef = useRef(null); - const lastAppliedHtmlRef = useRef(''); - const initialHtmlRef = useRef(html); - const [contextMenuState, setContextMenuState] = useState({ - x: 0, - y: 0, - visible: false, }); - const [contextActions, setContextActions] = useState([]); - const contextMenuItems = useMemo(() => { - if (readOnly || contextActions.length === 0) { - return []; - } - - const mapActionsToMenuItems = (actions: ToolbarButtonConfig[]) => { - const items: ContextMenuItem[] = []; - actions.forEach((action, index) => { - const previous = actions[index - 1]; - if (previous && previous.group !== action.group) { - items.push({ type: 'separator' }); - } - items.push({ - label: action.label, - action: action.onClick, - icon: action.icon, - disabled: action.disabled, - }); - }); - return items; - }; - - const historyActions = contextActions.filter(action => action.group === 'history'); - const selectionActions = contextActions.filter(action => - ['inline-format', 'alignment', 'structure', 'utility'].includes(action.group), - ); - const tableActions = contextActions.filter(action => action.group === 'table'); - const insertActions = contextActions.filter(action => action.group === 'insert'); - - const items: ContextMenuItem[] = []; - items.push(...mapActionsToMenuItems(historyActions)); - if (items.length > 0 && selectionActions.length > 0 && items[items.length - 1]?.type !== 'separator') { - items.push({ type: 'separator' }); - } - - if (selectionActions.length > 0) { - items.push({ - label: 'Selection', - submenu: mapActionsToMenuItems(selectionActions), - disabled: selectionActions.every(action => action.disabled), - }); - } - - if (items.length > 0 && tableActions.length > 0 && items[items.length - 1]?.type !== 'separator') { - items.push({ type: 'separator' }); - } - - if (tableActions.length > 0) { - items.push({ - label: 'Table', - submenu: mapActionsToMenuItems(tableActions), - disabled: tableActions.every(action => action.disabled), - }); - } - - if (items.length > 0 && insertActions.length > 0) { - items.push({ type: 'separator' }); - } - - items.push(...mapActionsToMenuItems(insertActions)); - - const cleanedItems = items.filter((item, index, array) => { - if (item.type !== 'separator') { - return true; - } - - const isLeadingSeparator = index === 0; - const isTrailingSeparator = index === array.length - 1; - const isDuplicateSeparator = array[index - 1]?.type === 'separator' || array[index + 1]?.type === 'separator'; - - return !isLeadingSeparator && !isTrailingSeparator && !isDuplicateSeparator; - }); - - return cleanedItems; - }, [contextActions, readOnly]); - - const handleScroll = useCallback( - (event: React.UIEvent) => { - if (contextMenuState.visible) { - setContextMenuState(prev => ({ ...prev, visible: false })); - } - if (!onScroll) { - return; - } - const target = event.currentTarget; - onScroll({ - scrollTop: target.scrollTop, - scrollHeight: target.scrollHeight, - clientHeight: target.clientHeight, - }); - }, - [contextMenuState.visible, onScroll], - ); - - const handleFocus = useCallback(() => { + function handleFocus() { onFocusChange?.(true); - }, [onFocusChange]); - - const handleBlur = useCallback(() => { + } + function handleBlur() { onFocusChange?.(false); - }, [onFocusChange]); - - const handleChange = useCallback( - (editorState: EditorState, editor: LexicalEditor) => { - editorState.read(() => { - try { - const generated = $generateHtmlFromNodes(editor); - const normalized = generated.trim(); - if (normalized === lastAppliedHtmlRef.current) { - return; - } - lastAppliedHtmlRef.current = normalized; - onChange(normalized); - } catch (error) { - console.error('Failed to serialize rich text content to HTML.', error); - } - }); - }, - [onChange], - ); - - const handleContextMenu = useCallback( - (event: React.MouseEvent) => { - if (readOnly || contextMenuItems.length === 0) { - return; - } - event.preventDefault(); - setContextMenuState({ - x: event.clientX, - y: event.clientY, - visible: true, - }); - }, - [contextMenuItems.length, readOnly], - ); - - const closeContextMenu = useCallback(() => { - setContextMenuState(prev => ({ ...prev, visible: false })); - }, []); - - useEffect(() => { - if (!readOnly) { - return; - } - setContextMenuState(prev => ({ ...prev, visible: false })); - setContextActions([]); - }, [readOnly]); - - useEffect(() => { - if (contextMenuState.visible && contextMenuItems.length === 0) { - setContextMenuState(prev => ({ ...prev, visible: false })); - } - }, [contextMenuItems, contextMenuState.visible]); - - const initialConfig = useMemo( - () => ({ - namespace: 'docforge-rich-text', - editable: !readOnly, - theme: RICH_TEXT_THEME, - onError: (error: Error) => { - console.error('Rich text editor encountered an error.', error); - }, - nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, ImageNode, TableNode, TableCellNode, TableRowNode], - editorState: (editor: LexicalEditor) => { - const initialHtml = (initialHtmlRef.current ?? '').trim(); - if (!initialHtml) { - lastAppliedHtmlRef.current = ''; - return; - } - applyHtmlToEditor(editor, initialHtml, lastAppliedHtmlRef); - }, - }), - [readOnly], - ); - - return ( -
    - - {!readOnly && ( - { - setContextActions(actions); - }} - /> - )} - - -
    -
    - - )} - placeholder={} - ErrorBoundary={LexicalErrorBoundary} - /> -
    -
    - - {!readOnly && } - - {!readOnly && } - - - - - - {!readOnly && ( - 0} - position={{ x: contextMenuState.x, y: contextMenuState.y }} - items={contextMenuItems} - onClose={closeContextMenu} - /> - )} -
    -
    - ); - }, -); - -RichTextEditor.displayName = 'RichTextEditor'; + } + }, [editor, onFocusChange]); + return null; +} export default RichTextEditor; diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 51d723f..bf940ac 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -68,17 +68,17 @@ const DEFAULT_TEMPLATES_PANEL_HEIGHT = 160; const MIN_TEMPLATES_PANEL_HEIGHT = 80; // Helper function to find a node and its siblings in a tree structure -const findNodeAndSiblings = (nodes: DocumentNode[], id: string): {node: DocumentNode, siblings: DocumentNode[]} | null => { - for (const node of nodes) { - if (node.id === id) { - return { node, siblings: nodes }; - } - if (node.type === 'folder' && node.children.length > 0) { - const found = findNodeAndSiblings(node.children, id); - if (found) return found; - } +const findNodeAndSiblings = (nodes: DocumentNode[], id: string): { node: DocumentNode, siblings: DocumentNode[] } | null => { + for (const node of nodes) { + if (node.id === id) { + return { node, siblings: nodes }; + } + if (node.type === 'folder' && node.children.length > 0) { + const found = findNodeAndSiblings(node.children, id); + if (found) return found; } - return null; + } + return null; }; const Sidebar: React.FC = (props) => { @@ -173,23 +173,23 @@ const Sidebar: React.FC = (props) => { // Effect to scroll focused item into view useEffect(() => { if (focusedItemId && sidebarRef.current) { - const element = sidebarRef.current.querySelector(`[data-item-id='${focusedItemId}']`); - element?.scrollIntoView({ block: 'nearest' }); + const element = sidebarRef.current.querySelector(`[data-item-id='${focusedItemId}']`); + element?.scrollIntoView({ block: 'nearest' }); } }, [focusedItemId]); useEffect(() => { if (!pendingRevealId || !sidebarRef.current) { - return; + return; } const raf = requestAnimationFrame(() => { - const element = sidebarRef.current?.querySelector(`[data-item-id='${pendingRevealId}']`) as HTMLElement | null; - if (element) { - element.scrollIntoView({ block: 'center' }); - setFocusedItemId(pendingRevealId); - onRevealHandled(); - } + const element = sidebarRef.current?.querySelector(`[data-item-id='${pendingRevealId}']`) as HTMLElement | null; + if (element) { + element.scrollIntoView({ block: 'center' }); + setFocusedItemId(pendingRevealId); + onRevealHandled(); + } }); return () => cancelAnimationFrame(raf); @@ -209,7 +209,7 @@ const Sidebar: React.FC = (props) => { setIsTemplatesCollapsed(newCollapsedState); storageService.save(LOCAL_STORAGE_KEYS.SIDEBAR_TEMPLATES_COLLAPSED, newCollapsedState); }; - + // --- Resizing Logic for Templates Panel --- const handleTemplatesResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -220,11 +220,11 @@ const Sidebar: React.FC = (props) => { const handleGlobalMouseMove = useCallback((e: MouseEvent) => { if (!isResizingTemplates.current || !sidebarRef.current) return; - + const sidebarRect = sidebarRef.current.getBoundingClientRect(); const newHeight = sidebarRect.bottom - e.clientY; const maxTemplatesPanelHeight = sidebarRect.height - 200; // Ensure docs panel has at least 200px - + const clampedHeight = Math.max(MIN_TEMPLATES_PANEL_HEIGHT, Math.min(newHeight, maxTemplatesPanelHeight)); setTemplatesPanelHeight(clampedHeight); }, []); @@ -253,29 +253,29 @@ const Sidebar: React.FC = (props) => { const handleMoveUp = useCallback((id: string) => { - const result = findNodeAndSiblings(documentTree, id); - if (!result) return; - - const { siblings } = result; - const index = siblings.findIndex(s => s.id === id); - - if (index > 0) { - const targetSiblingId = siblings[index - 1].id; - props.onMoveNode([id], targetSiblingId, 'before'); - } + const result = findNodeAndSiblings(documentTree, id); + if (!result) return; + + const { siblings } = result; + const index = siblings.findIndex(s => s.id === id); + + if (index > 0) { + const targetSiblingId = siblings[index - 1].id; + props.onMoveNode([id], targetSiblingId, 'before'); + } }, [documentTree, props.onMoveNode]); - + const handleMoveDown = useCallback((id: string) => { - const result = findNodeAndSiblings(documentTree, id); - if (!result) return; - - const { siblings } = result; - const index = siblings.findIndex(s => s.id === id); - - if (index < siblings.length - 1) { - const targetSiblingId = siblings[index + 1].id; - props.onMoveNode([id], targetSiblingId, 'after'); - } + const result = findNodeAndSiblings(documentTree, id); + if (!result) return; + + const { siblings } = result; + const index = siblings.findIndex(s => s.id === id); + + if (index < siblings.length - 1) { + const targetSiblingId = siblings[index + 1].id; + props.onMoveNode([id], targetSiblingId, 'after'); + } }, [documentTree, props.onMoveNode]); const commandMap = useMemo(() => { @@ -398,9 +398,9 @@ const Sidebar: React.FC = (props) => { const key = e.key; if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) { - return; + return; } - + e.preventDefault(); const currentItem = navigableItems.find(item => item.id === focusedItemId); @@ -438,7 +438,7 @@ const Sidebar: React.FC = (props) => { const direction = key === 'ArrowUp' ? -1 : 1; const nextIndex = Math.max(0, Math.min(navigableItems.length - 1, currentIndex + direction)); const newItem = navigableItems[nextIndex]; - + if (e.shiftKey) { const anchorId = lastClickedId || focusedItemId; const anchorIndex = navigableItems.findIndex(i => i.id === anchorId); @@ -481,7 +481,7 @@ const Sidebar: React.FC = (props) => { } } }; - + const getTooltip = (commandId: string, baseText: string) => { const command = commands.find(c => c.id === commandId); return command?.shortcutString ? `${baseText} (${command.shortcutString})` : baseText; @@ -492,136 +492,136 @@ const Sidebar: React.FC = (props) => {
    - - setSearchTerm(e.target.value)} - onKeyDown={handleSearchKeyDown} - className={`w-full bg-background border border-border-color rounded-md pl-9 py-1 text-xs text-text-main focus:ring-2 focus:ring-primary focus:outline-none placeholder:text-text-secondary ${focusSearchShortcutString && !searchTerm.trim() ? 'pr-24' : 'pr-9'}`} - /> - {focusSearchShortcutString && !searchTerm.trim() && ( - - {focusSearchShortcutString} - - )} - {searchTerm.trim() && ( - - )} + + setSearchTerm(e.target.value)} + onKeyDown={handleSearchKeyDown} + className={`w-full bg-secondary/50 border-0 rounded-sm pl-9 py-1 text-xs text-text-main focus:bg-background focus:ring-1 focus:ring-primary/50 focus:outline-none placeholder:text-text-secondary/60 ${focusSearchShortcutString && !searchTerm.trim() ? 'pr-24' : 'pr-9'}`} + /> + {focusSearchShortcutString && !searchTerm.trim() && ( + + {focusSearchShortcutString} + + )} + {searchTerm.trim() && ( + + )}
    -
    - {/* Documents Panel */} -
    -
    -

    Documents

    -
    - - - - - - -
    - - - - - - - - - - - - -
    -
    -
    - -
    +
    + {/* Documents Panel */} +
    +
    +

    Documents

    +
    + + + + + + +
    + + + + + + + + + + + + +
    +
    +
    + +
    +
    + + {!isTemplatesCollapsed && ( +
    + )} + + {/* Templates Panel */} +
    +
    +
    + + {isTemplatesCollapsed ? : } + +

    Templates

    - {!isTemplatesCollapsed && ( -
    +
    + + + +
    )} - - {/* Templates Panel */} -
    -
    -
    - - {isTemplatesCollapsed ? : } - -

    Templates

    -
    - {!isTemplatesCollapsed && ( -
    - - - -
    - )} -
    - {!isTemplatesCollapsed && ( -
    - -
    - )} +
    + {!isTemplatesCollapsed && ( +
    +
    + )}
    +
    ); }; diff --git a/components/StatusBar.tsx b/components/StatusBar.tsx index 2088b79..c5e7fd1 100644 --- a/components/StatusBar.tsx +++ b/components/StatusBar.tsx @@ -117,31 +117,31 @@ const ZoomButton: React.FC = ({ hint, icon, className = '', dis }; const StatusBar: React.FC = ({ - status, - modelName, - llmProviderName, - llmProviderUrl, - documentCount, - lastSaved, - availableModels, - onModelChange, - discoveredServices, - onProviderChange, - appVersion, - databasePath, - databaseStatus, - onDatabaseMenu, - onOpenAbout, - previewScale, - onPreviewZoomIn, - onPreviewZoomOut, - onPreviewReset, - isPreviewZoomAvailable, - previewMinScale, - previewMaxScale, - previewInitialScale, - previewMetadata, - zoomTarget, + status, + modelName, + llmProviderName, + llmProviderUrl, + documentCount, + lastSaved, + availableModels, + onModelChange, + discoveredServices, + onProviderChange, + appVersion, + databasePath, + databaseStatus, + onDatabaseMenu, + onOpenAbout, + previewScale, + onPreviewZoomIn, + onPreviewZoomOut, + onPreviewReset, + isPreviewZoomAvailable, + previewMinScale, + previewMaxScale, + previewInitialScale, + previewMetadata, + zoomTarget, }) => { const { text, color, tooltip } = statusConfig[status]; const selectedService = discoveredServices.find(s => s.generateUrl === llmProviderUrl); @@ -273,7 +273,7 @@ const StatusBar: React.FC = ({ backgroundSize: '1.2em 1.2em', }; - const zoomButtonClass = 'p-1 rounded-sm text-text-secondary hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed'; + const zoomButtonClass = 'p-1 text-text-secondary hover:text-text-main focus:outline-none focus:text-text-main disabled:opacity-50 disabled:cursor-not-allowed'; const isZoomDisabled = !isPreviewZoomAvailable; const isAtMinZoom = previewScale <= previewMinScale + 0.001; const isAtMaxZoom = previewScale >= previewMaxScale - 0.001; @@ -292,8 +292,8 @@ const StatusBar: React.FC = ({ const sizeText = `${previewMetadata.width} × ${previewMetadata.height} px`; const typeText = previewMetadata.mimeType ? (previewMetadata.mimeType.startsWith('image/') - ? previewMetadata.mimeType.replace('image/', '').toUpperCase() - : previewMetadata.mimeType.toUpperCase()) + ? previewMetadata.mimeType.replace('image/', '').toUpperCase() + : previewMetadata.mimeType.toUpperCase()) : null; return { label: baseLabel, @@ -333,7 +333,7 @@ const StatusBar: React.FC = ({ value={selectedService?.id || ''} onChange={(e) => onProviderChange(e.target.value)} disabled={discoveredServices.length === 0} - className="bg-transparent font-semibold text-text-main rounded-md py-0.5 px-1 -my-1 hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary appearance-none pr-4" + className="bg-transparent font-semibold text-text-main py-0.5 px-1 -my-1 hover:text-primary focus:outline-none focus:text-primary appearance-none pr-4" aria-label="LLM provider" style={selectStyles} > @@ -352,7 +352,7 @@ const StatusBar: React.FC = ({ value={modelName} onChange={(e) => onModelChange(e.target.value)} disabled={availableModels.length === 0} - className="bg-transparent font-semibold text-text-main rounded-md py-0.5 px-1 -my-1 hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary appearance-none pr-4" + className="bg-transparent font-semibold text-text-main py-0.5 px-1 -my-1 hover:text-primary focus:outline-none focus:text-primary appearance-none pr-4" aria-label="LLM model" style={selectStyles} > @@ -373,7 +373,7 @@ const StatusBar: React.FC = ({ event.preventDefault(); handleDatabaseMenu(event); }} - className={`flex items-center gap-1.5 px-1.5 py-1 -my-1 rounded-md transition-colors ${onDatabaseMenu ? 'hover:bg-border-color focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer' : 'cursor-default'}`} + className={`flex items-center gap-1.5 px-1.5 py-1 -my-1 transition-colors ${onDatabaseMenu ? 'hover:text-text-main focus:outline-none focus:text-text-main cursor-pointer' : 'cursor-default'}`} disabled={!onDatabaseMenu} ref={databaseTriggerRef} onMouseEnter={() => setShowDatabaseTooltip(true)} diff --git a/components/TemplateList.tsx b/components/TemplateList.tsx index dae3798..a72622b 100644 --- a/components/TemplateList.tsx +++ b/components/TemplateList.tsx @@ -42,7 +42,7 @@ const TemplateList: React.FC = ({ templates, activeTemplateId renameInputRef.current?.select(); } }, [renamingId]); - + const handleDelete = (e: React.MouseEvent, id: string) => { e.stopPropagation(); onDeleteTemplate(id, e.shiftKey); @@ -54,53 +54,53 @@ const TemplateList: React.FC = ({ templates, activeTemplateId // Fix: Use template_id instead of id const isFocused = focusedItemId === template.template_id; return ( - // Fix: Use template_id for key and data-item-id -
  • + // Fix: Use template_id for key and data-item-id +
  • {renamingId === template.template_id ? ( -
    +
    setRenameValue(e.target.value)} - onBlur={handleRenameSubmit} - onKeyDown={handleRenameKeyDown} - className="w-full text-left text-xs px-1.5 py-1 rounded-md bg-background text-text-main border border-border-color focus:outline-none focus:ring-1 focus:ring-primary" + ref={renameInputRef} + type="text" + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + onBlur={handleRenameSubmit} + onKeyDown={handleRenameKeyDown} + className="w-full text-left text-xs px-1.5 py-1 rounded-md bg-background text-text-main border border-border-color focus:outline-none focus:ring-1 focus:ring-primary" /> -
    +
    ) : ( - + )} -
  • + ) })} - {templates.length === 0 && ( -
  • - No templates yet. -
  • + {templates.length === 0 && ( +
  • + No templates yet. +
  • )} ); diff --git a/components/ToggleSwitch.tsx b/components/ToggleSwitch.tsx index 3e3a55a..57f7609 100644 --- a/components/ToggleSwitch.tsx +++ b/components/ToggleSwitch.tsx @@ -14,14 +14,12 @@ const ToggleSwitch: React.FC = ({ id, checked, onChange }) => role="switch" aria-checked={checked} onClick={() => onChange(!checked)} - className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-secondary ${ - checked ? 'bg-primary' : 'bg-border-color' - }`} + className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors duration-100 focus:outline-none focus:ring-1 focus:ring-primary/50 ${checked ? 'bg-primary' : 'bg-border-color' + }`} > ); diff --git a/components/Tooltip.tsx b/components/Tooltip.tsx index 68ec1ef..5841b7a 100644 --- a/components/Tooltip.tsx +++ b/components/Tooltip.tsx @@ -90,7 +90,7 @@ const Tooltip: React.FC = ({ targetRef, content, position = 'top', const overlayRoot = document.getElementById('overlay-root'); if (!overlayRoot) return null; - const baseClassName = 'fixed z-50 w-max px-2 py-1 text-xs font-semibold text-tooltip-text bg-tooltip-bg rounded-md shadow-lg transition-opacity duration-200 pointer-events-none'; + const baseClassName = 'fixed z-50 w-max px-2 py-1 text-xs font-semibold text-tooltip-text bg-tooltip-bg rounded-sm border border-border-color transition-opacity duration-100 pointer-events-none'; const composedClassName = className ? `${baseClassName} ${className}` : `${baseClassName} max-w-xs`; return ReactDOM.createPortal( diff --git a/components/WelcomeScreen.tsx b/components/WelcomeScreen.tsx index c92e312..24164f2 100644 --- a/components/WelcomeScreen.tsx +++ b/components/WelcomeScreen.tsx @@ -4,13 +4,13 @@ import { PlusIcon, FileIcon } from './Icons'; import Button from './Button'; interface WelcomeScreenProps { - onNewDocument: () => void; + onNewDocument: () => void; } export const WelcomeScreen: React.FC = ({ onNewDocument }) => { return (
    -
    +

    Welcome to DocForge

    diff --git a/components/ZoomPanContainer.tsx b/components/ZoomPanContainer.tsx index 0fe62d6..72e7e4d 100644 --- a/components/ZoomPanContainer.tsx +++ b/components/ZoomPanContainer.tsx @@ -17,6 +17,11 @@ interface ZoomPanContainerProps extends React.HTMLAttributes { wrapperClassName?: string; layout?: 'overlay' | 'natural'; lockOverflow?: boolean; + /** + * When true, uses CSS `zoom` property instead of `transform: scale()`. + * This allows text to reflow when zooming, but panning becomes unavailable. + */ + useZoomProperty?: boolean; } const clamp = (value: number, min: number, max: number) => { @@ -49,6 +54,7 @@ const ZoomPanContainer = React.forwardRef disableZoom = false, layout = 'overlay', lockOverflow = true, + useZoomProperty = false, ...rest } = props; @@ -208,17 +214,35 @@ const ZoomPanContainer = React.forwardRef }, [initialScale, previewZoom, setOffset, setScale]); const transformStyle = useMemo(() => { + // When useZoomProperty is true, use CSS zoom for text reflow + if (useZoomProperty) { + return { + zoom: scale, + } as React.CSSProperties; + } + // Otherwise use transform: scale() for panning support return { transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})`, transformOrigin: disablePan ? '0 0' : undefined, } as React.CSSProperties; - }, [disablePan, offset.x, offset.y, scale]); + }, [disablePan, offset.x, offset.y, scale, useZoomProperty]); const naturalWrapperStyle = useMemo(() => { if (layout !== 'natural' || !disablePan) { return undefined; } + // When using CSS zoom, the wrapper width needs inverse scaling + // so the zoomed content fills the available space properly + if (useZoomProperty) { + // At 100% zoom, width is 100%. Below 100%, width stays 100%. + // Above 100%, we don't need to adjust as CSS zoom handles expansion + return { + width: '100%', + minHeight: '100%', + }; + } + const visualScale = Math.max(1, scale); return { @@ -226,7 +250,7 @@ const ZoomPanContainer = React.forwardRef minWidth: `${visualScale * 100}%`, minHeight: `${visualScale * 100}%`, }; - }, [disablePan, layout, scale]); + }, [disablePan, layout, scale, useZoomProperty]); const containerClasses = useMemo(() => { const classes = ['relative', 'bg-secondary']; @@ -244,8 +268,12 @@ const ZoomPanContainer = React.forwardRef }, [className, disablePan, isPanning, lockOverflow]); const renderContent = useCallback(() => { + // When using CSS zoom, no transform-gpu needed + const contentClass = useZoomProperty + ? contentClassName ?? '' + : `transform-gpu origin-center ${contentClassName ?? ''}`; const content = ( -

    +
    {children}
    ); diff --git a/components/rich-text/FontDropDown.tsx b/components/rich-text/FontDropDown.tsx new file mode 100644 index 0000000..3a4b6b1 --- /dev/null +++ b/components/rich-text/FontDropDown.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { ChevronDownIcon } from '../Icons'; + +interface FontDropDownProps { + value: string; + options: { label: string; value: string }[]; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + placeholder?: string; +} + +const FontDropDown: React.FC = ({ + value, + options, + onChange, + disabled = false, + className = '', + placeholder = 'Select...', +}) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const toggleOpen = useCallback(() => { + if (!disabled) { + setIsOpen((prev) => !prev); + } + }, [disabled]); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + close(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, close]); + + const handleSelect = (optionValue: string) => { + onChange(optionValue); + close(); + }; + + const selectedOption = options.find((opt) => opt.value === value); + + return ( +
    + + + {isOpen && ( +
    +
    + {options.map((option) => ( + + ))} +
    +
    + )} +
    + ); +}; + +export default FontDropDown; diff --git a/components/rich-text/ImageNode.tsx b/components/rich-text/ImageNode.tsx index 13d4079..e688400 100644 --- a/components/rich-text/ImageNode.tsx +++ b/components/rich-text/ImageNode.tsx @@ -1,15 +1,30 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useLexicalEditable } from '@lexical/react/useLexicalEditable'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, DecoratorNode, + DRAGEND_COMMAND, + DRAGSTART_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + SELECTION_CHANGE_COMMAND, createCommand, type DOMConversionMap, type DOMConversionOutput, type LexicalCommand, + type LexicalEditor, type LexicalNode, type NodeKey, type Spread, type SerializedLexicalNode, } from 'lexical'; +import { mergeRegister } from '@lexical/utils'; export type ImagePayload = { src: string; @@ -32,29 +47,348 @@ export type SerializedImageNode = Spread< export const INSERT_IMAGE_COMMAND: LexicalCommand = createCommand('INSERT_IMAGE_COMMAND'); -class ImageComponent extends React.Component { - render(): React.ReactNode { - const { src, altText, width, height } = this.props; - const resolvedWidth = typeof width === 'number' ? `${width}px` : width ?? 'auto'; - const resolvedHeight = typeof height === 'number' ? `${height}px` : height ?? 'auto'; +const MIN_DIMENSION = 64; - return ( +type ImageComponentProps = ImagePayload & { + nodeKey: NodeKey; +}; + +type ResizeDirection = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'; + +type PointerState = { + startX: number; + startY: number; + startWidth: number; + startHeight: number; + direction: ResizeDirection; + aspectRatio: number; +}; + +const ImageComponent: React.FC = ({ src, altText, width, height, nodeKey }) => { + const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); + const [isResizing, setIsResizing] = useState(false); + const imageRef = useRef(null); + const pointerStateRef = useRef(null); + const [currentWidth, setCurrentWidth] = useState(width ?? 'inherit'); + const [currentHeight, setCurrentHeight] = useState(height ?? 'inherit'); + const [naturalSize, setNaturalSize] = useState({ + width: typeof width === 'number' ? width : 0, + height: typeof height === 'number' ? height : 0, + }); + + useEffect(() => { + setCurrentWidth(width ?? 'inherit'); + }, [width]); + + useEffect(() => { + setCurrentHeight(height ?? 'inherit'); + }, [height]); + + const updateDimensions = useCallback( + (nextWidth: number | 'inherit', nextHeight: number | 'inherit') => { + setCurrentWidth(nextWidth); + setCurrentHeight(nextHeight); + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.setWidthAndHeight(nextWidth, nextHeight); + } + }); + }, + [editor, nodeKey], + ); + + const onDelete = useCallback( + (event: KeyboardEvent) => { + if (isSelected && $isNodeSelection($getSelection())) { + event.preventDefault(); + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.remove(); + } + }); + return true; + } + return false; + }, + [editor, isSelected, nodeKey], + ); + + const onClick = useCallback( + (event: MouseEvent) => { + if (!imageRef.current) { + return false; + } + + const target = event.target as HTMLElement | null; + if (target && (target === imageRef.current || target.dataset.type === 'image-handle')) { + if (event.shiftKey) { + setSelected(!isSelected); + return true; + } + + clearSelection(); + setSelected(true); + return true; + } + + return false; + }, + [clearSelection, isSelected, setSelected], + ); + + const onDragStart = useCallback( + (event: DragEvent) => { + if (!isEditable || !event.dataTransfer || !imageRef.current) { + return false; + } + clearSelection(); + setSelected(true); + event.dataTransfer.setData('text/plain', '_lexical_image'); + event.dataTransfer.setData('application/x-lexical-dragged-nodes', JSON.stringify([nodeKey])); + event.dataTransfer.setDragImage(imageRef.current, imageRef.current.clientWidth / 2, imageRef.current.clientHeight / 2); + event.dataTransfer.effectAllowed = 'move'; + return true; + }, + [clearSelection, isEditable, nodeKey, setSelected], + ); + + const onDragEnd = useCallback(() => { + setIsResizing(false); + return false; + }, []); + + useEffect( + () => + mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, _newEditor: LexicalEditor) => { + const selection = $getSelection(); + if ($isNodeSelection(selection)) { + const isNodeSelected = selection.has(nodeKey); + setSelected(isNodeSelected); + return false; + } + if (isSelected) { + setSelected(false); + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW), + editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGSTART_COMMAND, onDragStart, COMMAND_PRIORITY_LOW), + editor.registerCommand(DRAGEND_COMMAND, onDragEnd, COMMAND_PRIORITY_LOW), + ), + [editor, isSelected, nodeKey, onClick, onDelete, onDragEnd, onDragStart, setSelected], + ); + + const resolvedWidth = useMemo(() => (typeof currentWidth === 'number' ? `${currentWidth}px` : currentWidth ?? 'auto'), [ + currentWidth, + ]); + const resolvedHeight = useMemo( + () => (typeof currentHeight === 'number' ? `${currentHeight}px` : currentHeight ?? 'auto'), + [currentHeight], + ); + + const measuredWidth = useMemo(() => { + if (typeof currentWidth === 'number') return currentWidth; + if (imageRef.current?.width) return imageRef.current.width; + if (naturalSize.width) return naturalSize.width; + return undefined; + }, [currentWidth, naturalSize.width]); + + const measuredHeight = useMemo(() => { + if (typeof currentHeight === 'number') return currentHeight; + if (imageRef.current?.height) return imageRef.current.height; + if (naturalSize.height) return naturalSize.height; + return undefined; + }, [currentHeight, naturalSize.height]); + + const handlePointerMove = useCallback((event: PointerEvent) => { + const state = pointerStateRef.current; + if (!state) { + return; + } + + const deltaX = event.clientX - state.startX; + const deltaY = event.clientY - state.startY; + + let nextWidth = state.startWidth; + let nextHeight = state.startHeight; + + if (state.direction.includes('e')) { + nextWidth += deltaX; + } + if (state.direction.includes('w')) { + nextWidth -= deltaX; + } + if (state.direction.includes('s')) { + nextHeight += deltaY; + } + if (state.direction.includes('n')) { + nextHeight -= deltaY; + } + + const lockAspect = event.shiftKey || state.direction.length === 2; + if (lockAspect) { + const widthBasedHeight = nextWidth / state.aspectRatio; + const heightBasedWidth = nextHeight * state.aspectRatio; + if (Math.abs(widthBasedHeight - nextHeight) > Math.abs(heightBasedWidth - nextWidth)) { + nextHeight = widthBasedHeight; + } else { + nextWidth = heightBasedWidth; + } + } + + setCurrentWidth(Math.max(MIN_DIMENSION, nextWidth)); + setCurrentHeight(Math.max(MIN_DIMENSION, nextHeight)); + }, []); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + const state = pointerStateRef.current; + if (state) { + const deltaX = event.clientX - state.startX; + const deltaY = event.clientY - state.startY; + let nextWidth = state.startWidth; + let nextHeight = state.startHeight; + + if (state.direction.includes('e')) { + nextWidth += deltaX; + } + if (state.direction.includes('w')) { + nextWidth -= deltaX; + } + if (state.direction.includes('s')) { + nextHeight += deltaY; + } + if (state.direction.includes('n')) { + nextHeight -= deltaY; + } + + const lockAspect = event.shiftKey || state.direction.length === 2; + if (lockAspect) { + const widthBasedHeight = nextWidth / state.aspectRatio; + const heightBasedWidth = nextHeight * state.aspectRatio; + if (Math.abs(widthBasedHeight - nextHeight) > Math.abs(heightBasedWidth - nextWidth)) { + nextHeight = widthBasedHeight; + } else { + nextWidth = heightBasedWidth; + } + } + + nextWidth = Math.max(MIN_DIMENSION, nextWidth); + nextHeight = Math.max(MIN_DIMENSION, nextHeight); + updateDimensions(nextWidth, nextHeight); + } + + pointerStateRef.current = null; + setIsResizing(false); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }, + [handlePointerMove, updateDimensions], + ); + + const handlePointerDown = useCallback( + (event: React.PointerEvent, direction: ResizeDirection) => { + if (!isEditable || !imageRef.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + const rect = imageRef.current.getBoundingClientRect(); + pointerStateRef.current = { + startX: event.clientX, + startY: event.clientY, + startWidth: rect.width, + startHeight: rect.height, + direction, + aspectRatio: rect.width / rect.height, + }; + + setIsResizing(true); + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + }, + [handlePointerMove, handlePointerUp, isEditable], + ); + + useEffect(() => { + return () => { + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }; + }, [handlePointerMove, handlePointerUp]); + + const showHandles = isEditable && isSelected; + + return ( + {altText} { + setNaturalSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); + }} + onDragStart={(event) => { + onDragStart(event.nativeEvent); + }} + onDragEnd={(event) => { + event.preventDefault(); + onDragEnd(); }} - className="block border border-border-color/60 bg-secondary" - draggable={false} /> - ); - } -} + {showHandles ? ( +
    + {( + [ + ['nw', '-top-2 -left-2 cursor-nw-resize'], + ['n', '-top-2 left-1/2 -translate-x-1/2 cursor-n-resize'], + ['ne', '-top-2 -right-2 cursor-ne-resize'], + ['e', 'top-1/2 -right-2 -translate-y-1/2 cursor-e-resize'], + ['se', '-bottom-2 -right-2 cursor-se-resize'], + ['s', '-bottom-2 left-1/2 -translate-x-1/2 cursor-s-resize'], + ['sw', '-bottom-2 -left-2 cursor-sw-resize'], + ['w', 'top-1/2 -left-2 -translate-y-1/2 cursor-w-resize'], + ] as const + ).map(([direction, positionClass]) => ( +
    + ) : null} + {isResizing ?
    : null} + + ); +}; export class ImageNode extends DecoratorNode { __src: string; @@ -80,7 +414,7 @@ export class ImageNode extends DecoratorNode { createDOM(): HTMLElement { const dom = document.createElement('span'); - dom.className = 'inline-block my-3 w-full'; + dom.className = 'inline-block my-3 max-w-full'; dom.style.maxWidth = '100%'; return dom; } @@ -96,6 +430,7 @@ export class ImageNode extends DecoratorNode { altText={this.__altText} width={this.__width} height={this.__height} + nodeKey={this.__key} /> ); } @@ -134,6 +469,12 @@ export class ImageNode extends DecoratorNode { return { element }; } + setWidthAndHeight(width?: number | 'inherit', height?: number | 'inherit') { + const writable = this.getWritable(); + writable.__width = width; + writable.__height = height; + } + static importDOM(): DOMConversionMap | null { return { img: (domNode: Node) => { diff --git a/components/rich-text/LinkModal.tsx b/components/rich-text/LinkModal.tsx new file mode 100644 index 0000000..b66cc74 --- /dev/null +++ b/components/rich-text/LinkModal.tsx @@ -0,0 +1,65 @@ +import React, { useRef, useState, useEffect } from 'react'; +import Modal from '../Modal'; +import Button from '../Button'; + +interface LinkModalProps { + isOpen: boolean; + initialUrl: string; + onSubmit: (url: string) => void; + onRemove: () => void; + onClose: () => void; +} + +export const LinkModal: React.FC = ({ isOpen, initialUrl, onSubmit, onRemove, onClose }) => { + const inputRef = useRef(null); + const [url, setUrl] = useState(initialUrl); + + useEffect(() => { + setUrl(initialUrl); + }, [initialUrl]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(url); + }; + + if (!isOpen) { + return null; + } + + return ( + +
    +
    + + setUrl(event.target.value)} + className="w-full rounded-md border border-border-color bg-background px-3 py-2 text-sm text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30" + placeholder="https://example.com" + /> +

    + Enter a valid URL. If you omit the protocol, https:// will be added automatically. +

    +
    +
    + + + +
    +
    +
    + ); +}; diff --git a/components/rich-text/TableColumnResizePlugin.tsx b/components/rich-text/TableColumnResizePlugin.tsx new file mode 100644 index 0000000..517fcf3 --- /dev/null +++ b/components/rich-text/TableColumnResizePlugin.tsx @@ -0,0 +1,267 @@ +import React, { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $nodesOfType, + type LexicalEditor, + $getNodeByKey, +} from 'lexical'; +import { + TableNode, + TableRowNode, + TableCellNode, +} from '@lexical/table'; + +const MIN_COLUMN_WIDTH = 72; + +const ensureColGroupWithWidths = ( + tableElement: HTMLTableElement, + preferredWidths: number[] = [], +): HTMLTableColElement[] => { + const firstRow = tableElement.rows[0]; + const columnCount = firstRow?.cells.length ?? 0; + if (columnCount === 0) { + return []; + } + + let colGroup = tableElement.querySelector('colgroup'); + if (!colGroup) { + colGroup = document.createElement('colgroup'); + tableElement.insertBefore(colGroup, tableElement.firstChild); + } + + while (colGroup.children.length < columnCount) { + const col = document.createElement('col'); + colGroup.appendChild(col); + } + + while (colGroup.children.length > columnCount) { + colGroup.lastElementChild?.remove(); + } + + const colElements = Array.from(colGroup.children) as HTMLTableColElement[]; + + if (preferredWidths.length === columnCount && preferredWidths.some(width => width > 0)) { + colElements.forEach((col, index) => { + const width = preferredWidths[index]; + if (Number.isFinite(width) && width > 0) { + col.style.width = `${Math.max(MIN_COLUMN_WIDTH, width)}px`; + } + }); + } else { + const existingWidths = colElements.map(col => parseFloat(col.style.width || '')); + const needInitialization = existingWidths.some(width => Number.isNaN(width) || width <= 0); + + if (needInitialization) { + const columnWidths = Array.from(firstRow.cells).map(cell => cell.getBoundingClientRect().width || MIN_COLUMN_WIDTH); + colElements.forEach((col, index) => { + const width = Math.max(MIN_COLUMN_WIDTH, columnWidths[index] ?? MIN_COLUMN_WIDTH); + col.style.width = `${width}px`; + }); + } + } + + return colElements; +}; + +const getColumnWidthsFromState = (editor: LexicalEditor, tableKey: string): number[] => { + let widths: number[] = []; + + editor.getEditorState().read(() => { + const tableNode = $getNodeByKey(tableKey); + if (!tableNode) { + return; + } + + const firstRow = tableNode.getChildren()[0]; + if (!firstRow) { + return; + } + + widths = firstRow + .getChildren() + .map(cell => cell.getWidth()) + .filter((width): width is number => Number.isFinite(width)); + }); + + return widths; +}; + +const attachColumnResizeHandles = ( + tableElement: HTMLTableElement, + editor: LexicalEditor, + tableKey: string, +): (() => void) => { + const container = tableElement.parentElement ?? tableElement; + const originalContainerPosition = container.style.position; + const restoreContainerPosition = originalContainerPosition === '' && getComputedStyle(container).position === 'static'; + + if (restoreContainerPosition) { + container.style.position = 'relative'; + } + + tableElement.style.tableLayout = 'fixed'; + + const overlay = document.createElement('div'); + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '10'; + container.appendChild(overlay); + + const cleanupHandles: Array<() => void> = []; + const resizeObserver = new ResizeObserver(() => renderHandles()); + + function renderHandles() { + overlay.replaceChildren(); + + const firstRow = tableElement.rows[0]; + if (!firstRow) { + return; + } + + const storedColumnWidths = getColumnWidthsFromState(editor, tableKey); + const cols = ensureColGroupWithWidths(tableElement, storedColumnWidths); + const containerRect = container.getBoundingClientRect(); + const cells = Array.from(firstRow.cells); + + cells.forEach((cell, columnIndex) => { + if (columnIndex === cells.length - 1) { + return; + } + + const cellRect = cell.getBoundingClientRect(); + const handle = document.createElement('div'); + handle.setAttribute('role', 'presentation'); + handle.contentEditable = 'false'; + handle.style.position = 'absolute'; + handle.style.top = `${tableElement.offsetTop}px`; + handle.style.left = `${cellRect.right - containerRect.left - 3}px`; + handle.style.width = '6px'; + handle.style.height = `${tableElement.offsetHeight}px`; + handle.style.cursor = 'col-resize'; + handle.style.pointerEvents = 'auto'; + handle.style.userSelect = 'none'; + + let startX = 0; + let leftWidth = 0; + let rightWidth = 0; + + const handleMouseMove = (event: MouseEvent) => { + const deltaX = event.clientX - startX; + const nextLeftWidth = Math.max(MIN_COLUMN_WIDTH, leftWidth + deltaX); + const nextRightWidth = Math.max(MIN_COLUMN_WIDTH, rightWidth - deltaX); + + cols[columnIndex].style.width = `${nextLeftWidth}px`; + cols[columnIndex + 1].style.width = `${nextRightWidth}px`; + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + const updatedWidths = cols.map(col => parseFloat(col.style.width || '')); + editor.update(() => { + const tableNode = $getNodeByKey(tableKey); + if (!tableNode) { + return; + } + + const rows = tableNode.getChildren(); + rows.forEach(row => { + const cellsInRow = row.getChildren(); + cellsInRow.forEach((cellNode, cellIndex) => { + const width = updatedWidths[cellIndex]; + if (Number.isFinite(width) && width > 0) { + cellNode.setWidth(Math.max(MIN_COLUMN_WIDTH, width)); + } + }); + }); + }); + }; + + const handleMouseDown = (event: MouseEvent) => { + event.preventDefault(); + startX = event.clientX; + leftWidth = parseFloat(cols[columnIndex].style.width || `${cell.offsetWidth}`); + rightWidth = parseFloat( + cols[columnIndex + 1].style.width || `${cells[columnIndex + 1]?.offsetWidth ?? MIN_COLUMN_WIDTH}`, + ); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + handle.addEventListener('mousedown', handleMouseDown); + cleanupHandles.push(() => handle.removeEventListener('mousedown', handleMouseDown)); + overlay.appendChild(handle); + }); + } + + resizeObserver.observe(tableElement); + renderHandles(); + + return () => { + cleanupHandles.forEach(cleanup => cleanup()); + resizeObserver.disconnect(); + overlay.remove(); + + if (restoreContainerPosition) { + container.style.position = originalContainerPosition; + } + }; +}; + +export const TableColumnResizePlugin: React.FC = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const cleanupMap = new Map void>(); + + const cleanupTable = (key: string) => { + const cleanup = cleanupMap.get(key); + if (cleanup) { + cleanup(); + cleanupMap.delete(key); + } + }; + + const initializeTable = (tableNode: TableNode) => { + const tableKey = tableNode.getKey(); + const tableElement = editor.getElementByKey(tableKey); + if (tableElement instanceof HTMLTableElement) { + cleanupTable(tableKey); + cleanupMap.set(tableKey, attachColumnResizeHandles(tableElement, editor, tableKey)); + } + }; + + editor.getEditorState().read(() => { + const tableNodes = $nodesOfType(TableNode); + tableNodes.forEach(tableNode => { + initializeTable(tableNode); + }); + }); + + const unregisterMutationListener = editor.registerMutationListener(TableNode, mutations => { + editor.getEditorState().read(() => { + mutations.forEach((mutation, key) => { + if (mutation === 'created') { + const tableNode = $getNodeByKey(key); + if (tableNode) { + initializeTable(tableNode); + } + } else if (mutation === 'destroyed') { + cleanupTable(key); + } + }); + }); + }); + + return () => { + unregisterMutationListener(); + cleanupMap.forEach(cleanup => cleanup()); + cleanupMap.clear(); + }; + }, [editor]); + + return null; +}; diff --git a/components/rich-text/TableModal.tsx b/components/rich-text/TableModal.tsx new file mode 100644 index 0000000..14d5925 --- /dev/null +++ b/components/rich-text/TableModal.tsx @@ -0,0 +1,198 @@ +import React, { useState, useRef, useEffect } from 'react'; +import Modal from '../Modal'; +import Button from '../Button'; + +interface TableModalProps { + isOpen: boolean; + onClose: () => void; + onInsertTable: (rows: number, columns: number, includeHeaderRow: boolean) => void; + onInsertRowAbove: () => void; + onInsertRowBelow: () => void; + onInsertColumnLeft: () => void; + onInsertColumnRight: () => void; + onDeleteRow: () => void; + onDeleteColumn: () => void; + onDeleteTable: () => void; + onToggleHeaderRow: () => void; + isInTable: boolean; + hasHeaderRow: boolean; +} + +export const TableModal: React.FC = ({ + isOpen, + onClose, + onInsertTable, + onInsertRowAbove, + onInsertRowBelow, + onInsertColumnLeft, + onInsertColumnRight, + onDeleteRow, + onDeleteColumn, + onDeleteTable, + onToggleHeaderRow, + isInTable, + hasHeaderRow, +}) => { + const [rows, setRows] = useState(3); + const [columns, setColumns] = useState(3); + const [includeHeaderRow, setIncludeHeaderRow] = useState(true); + const [hoveredGrid, setHoveredGrid] = useState<{ rows: number; columns: number } | null>(null); + const inputRef = useRef(null); + + const displayRows = hoveredGrid?.rows ?? rows; + const displayColumns = hoveredGrid?.columns ?? columns; + + useEffect(() => { + if (!isOpen) { + return; + } + setRows(3); + setColumns(3); + setIncludeHeaderRow(true); + setHoveredGrid(null); + }, [isOpen]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onInsertTable(displayRows, displayColumns, includeHeaderRow); + }; + + const handleGridClick = (row: number, column: number) => { + setRows(row); + setColumns(column); + setHoveredGrid(null); + onInsertTable(row, column, includeHeaderRow); + }; + + if (!isOpen) { + return null; + } + + return ( + +
    +
    +
    +
    + Select size + + {displayRows} × {displayColumns} cells + +
    +
    + {Array.from({ length: 6 }).map((_, rowIndex) => ( + + {Array.from({ length: 6 }).map((_, columnIndex) => { + const rowNumber = rowIndex + 1; + const columnNumber = columnIndex + 1; + const isActive = + (hoveredGrid?.rows ?? rows) >= rowNumber && (hoveredGrid?.columns ?? columns) >= columnNumber; + return ( +
    +
    +
    + +
    +
    + Rows + setRows(Math.max(1, Math.min(20, Number(event.target.value) || 1)))} + className="w-full rounded-md border border-border-color bg-primary-text/5 px-3 py-2 text-sm text-text-main focus:border-primary focus:ring-1 focus:ring-primary" + /> +
    +
    + Columns + setColumns(Math.max(1, Math.min(20, Number(event.target.value) || 1)))} + className="w-full rounded-md border border-border-color bg-primary-text/5 px-3 py-2 text-sm text-text-main focus:border-primary focus:ring-1 focus:ring-primary" + /> +
    +
    + +
    + +
    +
    +
    + +
    +
    + Current table tools + {isInTable ? 'Actions apply to the selected cell' : 'Place the caret inside a table to enable tools'} +
    +
    + + + + + + + + +
    +
    +
    +
    + ); +}; diff --git a/components/rich-text/ToolbarColorPicker.tsx b/components/rich-text/ToolbarColorPicker.tsx new file mode 100644 index 0000000..53bd84b --- /dev/null +++ b/components/rich-text/ToolbarColorPicker.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Compact from '@uiw/react-color-compact'; +import type { ColorResult } from '@uiw/color-convert'; +import IconButton from '../IconButton'; + +interface ToolbarColorPickerProps { + color: string; + onChange: (value: string) => void; + icon: React.FC<{ className?: string }>; + label: string; + disabled?: boolean; +} + +const ToolbarColorPicker: React.FC = ({ + color, + onChange, + icon: Icon, + label, + disabled = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const toggleOpen = useCallback(() => { + if (!disabled) { + setIsOpen((previous) => !previous); + } + }, [disabled]); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + useEffect(() => { + if (!isOpen || typeof document === 'undefined') { + return undefined; + } + + const handleClickOutside = (event: MouseEvent) => { + if (!containerRef.current) { + return; + } + + if (!containerRef.current.contains(event.target as Node)) { + close(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + close(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [close, isOpen]); + + const handleColorChange = useCallback((result: ColorResult) => { + const next = result.hexa || result.hex; + onChange(next); + // Don't close on change to allow trying multiple colors + }, [onChange]); + + return ( +
    + +
    + +
    +
    + + {isOpen && ( +
    + +
    + )} +
    + ); +}; + +export default ToolbarColorPicker; diff --git a/components/rich-text/ToolbarPlugin.tsx b/components/rich-text/ToolbarPlugin.tsx new file mode 100644 index 0000000..4e07a4d --- /dev/null +++ b/components/rich-text/ToolbarPlugin.tsx @@ -0,0 +1,1052 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $createHeadingNode, + $createQuoteNode, + $isHeadingNode, + HeadingTagType, +} from '@lexical/rich-text'; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, + $isListNode, + ListType, +} from '@lexical/list'; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; +import { mergeRegister } from '@lexical/utils'; +import { $setBlocksType } from '@lexical/selection'; +import { + $createParagraphNode, + $getRoot, + $getSelection, + $isRangeSelection, + $isNodeSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_CRITICAL, + FORMAT_ELEMENT_COMMAND, + FORMAT_TEXT_COMMAND, + REDO_COMMAND, + SELECTION_CHANGE_COMMAND, + UNDO_COMMAND, + LexicalEditor, + NodeSelection, + RangeSelection, + $createNodeSelection, + $createRangeSelection, + $getNodeByKey, + $setSelection, +} from 'lexical'; +import { + $createTableSelection, + $deleteTableColumn__EXPERIMENTAL, + $deleteTableRow__EXPERIMENTAL, + $getTableCellNodeFromLexicalNode, + $getTableNodeFromLexicalNodeOrThrow, + $insertTableColumn__EXPERIMENTAL, + $insertTableRow__EXPERIMENTAL, + $isTableCellNode, + $isTableRowNode, + $isTableSelection, + INSERT_TABLE_COMMAND, + TableCellHeaderStates, + TableSelection, +} from '@lexical/table'; + +import IconButton from '../IconButton'; +import { RedoIcon, UndoIcon } from '../Icons'; +import { + AlignCenterIcon, + AlignJustifyIcon, + AlignLeftIcon, + AlignRightIcon, + BoldIcon, + BulletListIcon, + ClearFormattingIcon, + CodeInlineIcon, + HeadingOneIcon, + HeadingThreeIcon, + HeadingTwoIcon, + ImageIcon as ToolbarImageIcon, + ItalicIcon, + LinkIcon as ToolbarLinkIcon, + NumberListIcon, + ParagraphIcon, + QuoteIcon, + StrikethroughIcon, + TableIcon, + UnderlineIcon, +} from './RichTextToolbarIcons'; +import { ImagePayload, INSERT_IMAGE_COMMAND } from './ImageNode'; +import { LinkModal } from './LinkModal'; +import { TableModal } from './TableModal'; +import { normalizeUrl } from './utils'; +import type { ToolbarButtonConfig, BlockType, SelectionSnapshot } from './types'; + +const ToolbarButton: React.FC = ({ label, icon: Icon, isActive = false, disabled = false, onClick }) => ( + { + // Prevent the toolbar button from stealing focus, which would clear the + // user's selection in the editor before the command executes. + event.preventDefault(); + }} + onClick={onClick} + disabled={disabled} + aria-pressed={isActive} + aria-label={label} + className={`transition-all duration-200 ${isActive + ? 'bg-primary/10 text-primary hover:bg-primary/20 hover:text-primary shadow-sm' + : 'text-text-secondary hover:text-text-main hover:bg-secondary-hover' + } disabled:opacity-30 disabled:pointer-events-none`} + > + + +); + +export const ToolbarPlugin: React.FC<{ + readOnly: boolean; + onActionsChange: (actions: ToolbarButtonConfig[]) => void; +}> = ({ readOnly, onActionsChange }) => { + const [editor] = useLexicalComposerContext(); + const fileInputRef = useRef(null); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + const [isLink, setIsLink] = useState(false); + const [blockType, setBlockType] = useState('paragraph'); + const [alignment, setAlignment] = useState<'left' | 'center' | 'right' | 'justify'>('left'); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const [linkDraftUrl, setLinkDraftUrl] = useState(''); + const [isTableModalOpen, setIsTableModalOpen] = useState(false); + const [isInTable, setIsInTable] = useState(false); + const [hasHeaderRow, setHasHeaderRow] = useState(false); + const pendingLinkSelectionRef = useRef(null); + const pendingTableSelectionRef = useRef(null); + + const closeLinkModal = useCallback(() => { + setIsLinkModalOpen(false); + }, []); + + const restoreSelectionFromSnapshot = useCallback( + (snapshot: SelectionSnapshot | null | undefined = pendingLinkSelectionRef.current) => { + const snapshotToUse = snapshot ?? pendingLinkSelectionRef.current; + + if (!snapshotToUse) { + return null; + } + + if (snapshotToUse.type === 'table') { + const selection = $createTableSelection(); + const tableNode = $getNodeByKey(snapshotToUse.tableKey); + const anchorCell = $getNodeByKey(snapshotToUse.anchorCellKey); + const focusCell = $getNodeByKey(snapshotToUse.focusCellKey); + + if (!tableNode || !anchorCell || !focusCell) { + return null; + } + + selection.set(snapshotToUse.tableKey, snapshotToUse.anchorCellKey, snapshotToUse.focusCellKey); + return selection; + } + + if (snapshotToUse.type === 'range') { + const selection = $createRangeSelection(); + const anchorNode = $getNodeByKey(snapshotToUse.anchorKey); + const focusNode = $getNodeByKey(snapshotToUse.focusKey); + + if (!anchorNode || !focusNode) { + return null; + } + + selection.anchor.set(snapshotToUse.anchorKey, snapshotToUse.anchorOffset, snapshotToUse.anchorType); + selection.focus.set(snapshotToUse.focusKey, snapshotToUse.focusOffset, snapshotToUse.focusType); + return selection; + } + + const selection = $createNodeSelection(); + snapshotToUse.keys.forEach(key => { + const node = $getNodeByKey(key); + if (node) { + selection.add(node.getKey()); + } + }); + + return selection.getNodes().length > 0 ? selection : null; + }, []); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + const hasValidSelection = $isRangeSelection(selection) || $isTableSelection(selection); + + if (!hasValidSelection) { + setIsBold(false); + setIsItalic(false); + setIsUnderline(false); + setIsStrikethrough(false); + setIsCode(false); + setIsLink(false); + setBlockType('paragraph'); + setAlignment('left'); + setIsInTable(false); + setHasHeaderRow(false); + return; + } + + const anchorNode = selection.anchor.getNode(); + const tableCellNode = $getTableCellNodeFromLexicalNode(anchorNode); + if (tableCellNode) { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + setIsInTable(true); + const firstRow = tableNode.getFirstChild(); + let hasHeader = false; + if (firstRow && $isTableRowNode(firstRow)) { + hasHeader = firstRow.getChildren().some(child => $isTableCellNode(child) && child.hasHeaderState(TableCellHeaderStates.ROW)); + } + setHasHeaderRow(hasHeader); + } else { + setIsInTable(false); + setHasHeaderRow(false); + } + + if (!$isRangeSelection(selection)) { + setIsBold(false); + setIsItalic(false); + setIsUnderline(false); + setIsStrikethrough(false); + setIsCode(false); + setIsLink(false); + setBlockType('paragraph'); + setAlignment('left'); + return; + } + + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsCode(selection.hasFormat('code')); + + const element = anchorNode.getTopLevelElementOrThrow(); + + if ($isHeadingNode(element)) { + setBlockType(element.getTag()); + } else if ($isListNode(element)) { + setBlockType(element.getListType()); + } else if (element.getType() === 'quote') { + setBlockType('quote'); + } else { + setBlockType('paragraph'); + } + + const elementAlignment = element.getFormatType(); + setAlignment((elementAlignment || 'left') as 'left' | 'center' | 'right' | 'justify'); + + const nodes = selection.getNodes(); + setIsLink(nodes.some(node => $isLinkNode(node) || $isLinkNode(node.getParent()))); + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateToolbar(); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }), + editor.registerCommand( + CAN_UNDO_COMMAND, + payload => { + setCanUndo(payload); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + CAN_REDO_COMMAND, + payload => { + setCanRedo(payload); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + }, [editor, updateToolbar]); + + const formatHeading = useCallback( + (heading: HeadingTagType) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(heading)); + } + }); + }, + [editor], + ); + + const formatParagraph = useCallback(() => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + }, [editor]); + + const formatQuote = useCallback(() => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createQuoteNode()); + } + }); + }, [editor]); + + const captureLinkState = useCallback(() => { + let detectedUrl = ''; + + editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + pendingLinkSelectionRef.current = { + type: 'range', + anchorKey: selection.anchor.key, + anchorOffset: selection.anchor.offset, + anchorType: selection.anchor.type, + focusKey: selection.focus.key, + focusOffset: selection.focus.offset, + focusType: selection.focus.type, + }; + + const selectionNodes = selection.getNodes(); + if (selectionNodes.length === 0) { + return; + } + + const firstNode = selectionNodes[0]; + const linkNode = $isLinkNode(firstNode) + ? firstNode + : $isLinkNode(firstNode.getParent()) + ? firstNode.getParent() + : null; + + if ($isLinkNode(linkNode)) { + detectedUrl = linkNode.getURL(); + } + return; + } + + if ($isNodeSelection(selection)) { + const nodes = selection.getNodes(); + pendingLinkSelectionRef.current = { type: 'node', keys: nodes.map(node => node.getKey()) }; + } else { + pendingLinkSelectionRef.current = null; + } + }); + + if (!pendingLinkSelectionRef.current) { + return false; + } + + setLinkDraftUrl(detectedUrl); + setIsLinkModalOpen(true); + return true; + }, [editor]); + + const captureTableSelection = useCallback(() => { + let snapshot: SelectionSnapshot = null; + + editor.getEditorState().read(() => { + const selection = $getSelection(); + + if ($isTableSelection(selection)) { + const anchorCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); + const focusCell = $getTableCellNodeFromLexicalNode(selection.focus.getNode()); + + if (!anchorCell || !focusCell) { + return; + } + + const tableNode = $getTableNodeFromLexicalNodeOrThrow(anchorCell); + snapshot = { + type: 'table', + tableKey: tableNode.getKey(), + anchorCellKey: anchorCell.getKey(), + focusCellKey: focusCell.getKey(), + }; + return; + } + + if ($isRangeSelection(selection)) { + const anchorCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); + const focusCell = $getTableCellNodeFromLexicalNode(selection.focus.getNode()); + + if (!anchorCell || !focusCell) { + return; + } + + snapshot = { + type: 'range', + anchorKey: selection.anchor.key, + anchorOffset: selection.anchor.offset, + anchorType: selection.anchor.type, + focusKey: selection.focus.key, + focusOffset: selection.focus.offset, + focusType: selection.focus.type, + }; + } + }); + + pendingTableSelectionRef.current = snapshot; + return snapshot !== null; + }, [editor]); + + const applyLink = useCallback( + (url: string) => { + closeLinkModal(); + + const selectionSnapshot = pendingLinkSelectionRef.current; + pendingLinkSelectionRef.current = null; + + const normalizedUrl = normalizeUrl(url); + if (!normalizedUrl) { + editor.focus(); + return; + } + + editor.update(() => { + const selectionFromSnapshot = restoreSelectionFromSnapshot(selectionSnapshot); + const selectionToUse = selectionFromSnapshot ?? (() => { + const activeSelection = $getSelection(); + if ($isRangeSelection(activeSelection) || $isNodeSelection(activeSelection)) { + return activeSelection; + } + const root = $getRoot(); + return root.selectEnd(); + })(); + + if (!selectionToUse) { + return; + } + + $setSelection(selectionToUse); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, normalizedUrl); + }); + editor.focus(); + }, + [closeLinkModal, editor, restoreSelectionFromSnapshot], + ); + + const removeLink = useCallback(() => { + closeLinkModal(); + + const selectionSnapshot = pendingLinkSelectionRef.current; + pendingLinkSelectionRef.current = null; + + editor.update(() => { + const selectionFromSnapshot = restoreSelectionFromSnapshot(selectionSnapshot); + const selectionToUse = selectionFromSnapshot ?? (() => { + const activeSelection = $getSelection(); + if ($isRangeSelection(activeSelection) || $isNodeSelection(activeSelection)) { + return activeSelection; + } + const root = $getRoot(); + return root.selectEnd(); + })(); + + if (!selectionToUse) { + return; + } + + $setSelection(selectionToUse); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + }); + editor.focus(); + }, [closeLinkModal, editor, restoreSelectionFromSnapshot]); + + const toggleLink = useCallback(() => { + if (readOnly) { + return; + } + + const hasSelection = captureLinkState(); + if (!hasSelection) { + editor.focus(); + } + }, [captureLinkState, editor, readOnly]); + + const insertImage = useCallback( + (payload: ImagePayload) => { + if (!payload.src) { + return; + } + editor.dispatchCommand(INSERT_IMAGE_COMMAND, payload); + }, + [editor], + ); + + const openTableModal = useCallback(() => { + if (readOnly) { + return; + } + + captureTableSelection(); + setIsTableModalOpen(true); + }, [captureTableSelection, readOnly]); + + const runWithActiveTable = useCallback( + (action: (selection: RangeSelection | NodeSelection | TableSelection) => void) => { + editor.update(() => { + let selection = $getSelection(); + if (!selection || (!$isRangeSelection(selection) && !$isTableSelection(selection))) { + const restoredSelection = restoreSelectionFromSnapshot(pendingTableSelectionRef.current); + if (restoredSelection) { + $setSelection(restoredSelection); + selection = restoredSelection; + } + } + + if (!selection || (!$isRangeSelection(selection) && !$isTableSelection(selection))) { + return; + } + const anchorNode = selection.anchor.getNode(); + if (!$getTableCellNodeFromLexicalNode(anchorNode)) { + return; + } + action(selection); + }); + }, + [editor, restoreSelectionFromSnapshot], + ); + + const insertTable = useCallback( + (rows: number, columns: number, includeHeaderRow: boolean) => { + if (readOnly) { + return; + } + const normalizedRows = Math.max(1, Math.min(20, rows)); + const normalizedColumns = Math.max(1, Math.min(20, columns)); + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: String(normalizedColumns), + rows: String(normalizedRows), + includeHeaders: includeHeaderRow ? { rows: true, columns: false } : false, + }); + setIsTableModalOpen(false); + editor.focus(); + }, + [editor, readOnly], + ); + + const insertTableRow = useCallback( + (insertAfter: boolean) => + runWithActiveTable(() => { + $insertTableRow__EXPERIMENTAL(insertAfter); + }), + [runWithActiveTable], + ); + + const insertTableColumn = useCallback( + (insertAfter: boolean) => + runWithActiveTable(() => { + $insertTableColumn__EXPERIMENTAL(insertAfter); + }), + [runWithActiveTable], + ); + + const deleteTableRow = useCallback( + () => + runWithActiveTable(() => { + $deleteTableRow__EXPERIMENTAL(); + }), + [runWithActiveTable], + ); + + const deleteTableColumn = useCallback( + () => + runWithActiveTable(() => { + $deleteTableColumn__EXPERIMENTAL(); + }), + [runWithActiveTable], + ); + + const deleteTable = useCallback(() => { + runWithActiveTable(selection => { + const tableCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); + if (!tableCell) { + return; + } + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCell); + tableNode.remove(); + }); + setIsTableModalOpen(false); + }, [runWithActiveTable]); + + /* + const selectTable = useCallback( + () => + runWithActiveTable(selection => { + const tableCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); + if (!tableCell) { + return; + } + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCell); + const firstRow = tableNode.getFirstChild(); + const lastRow = tableNode.getLastChild(); + + if (!$isTableRowNode(firstRow) || !$isTableRowNode(lastRow)) { + return; + } + + const firstCell = firstRow.getFirstChild(); + const lastCell = lastRow.getLastChild(); + + if (!$isTableCellNode(firstCell) || !$isTableCellNode(lastCell)) { + return; + } + + const tableSelection = $createTableSelection(); + tableSelection.set(tableNode.getKey(), firstCell.getKey(), lastCell.getKey()); + $setSelection(tableSelection); + }), + [runWithActiveTable], + ); + */ + + const toggleHeaderRow = useCallback( + () => + runWithActiveTable(selection => { + const tableCell = $getTableCellNodeFromLexicalNode(selection.anchor.getNode()); + if (!tableCell) { + return; + } + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCell); + const firstRow = tableNode.getFirstChild(); + if (!firstRow || !$isTableRowNode(firstRow)) { + return; + } + const shouldAddHeader = !firstRow + .getChildren() + .some(child => $isTableCellNode(child) && child.hasHeaderState(TableCellHeaderStates.ROW)); + + firstRow.getChildren().forEach(child => { + if ($isTableCellNode(child)) { + child.setHeaderStyles(shouldAddHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS); + } + }); + }), + [runWithActiveTable], + ); + + const openImagePicker = useCallback(() => { + if (readOnly) { + return; + } + fileInputRef.current?.click(); + }, [readOnly]); + + const handleImageFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.addEventListener('load', () => { + const result = reader.result; + if (typeof result === 'string') { + const altText = file.name.replace(/\.[^/.]+$/, ''); + insertImage({ src: result, altText }); + } + }); + reader.readAsDataURL(file); + }, + [insertImage], + ); + + const toolbarButtons = useMemo( + () => [ + { + id: 'undo', + label: 'Undo', + icon: UndoIcon, + group: 'history', + disabled: readOnly || !canUndo, + onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined), + }, + { + id: 'redo', + label: 'Redo', + icon: RedoIcon, + group: 'history', + disabled: readOnly || !canRedo, + onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined), + }, + { + id: 'bold', + label: 'Bold', + icon: BoldIcon, + group: 'inline-format', + isActive: isBold, + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'), + }, + { + id: 'italic', + label: 'Italic', + icon: ItalicIcon, + group: 'inline-format', + isActive: isItalic, + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'), + }, + { + id: 'underline', + label: 'Underline', + icon: UnderlineIcon, + group: 'inline-format', + isActive: isUnderline, + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'), + }, + { + id: 'strikethrough', + label: 'Strikethrough', + icon: StrikethroughIcon, + group: 'inline-format', + isActive: isStrikethrough, + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'), + }, + { + id: 'code', + label: 'Inline Code', + icon: CodeInlineIcon, + group: 'inline-format', + isActive: isCode, + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'), + }, + { + id: 'paragraph', + label: 'Paragraph', + icon: ParagraphIcon, + group: 'structure', + isActive: blockType === 'paragraph', + disabled: readOnly, + onClick: formatParagraph, + }, + { + id: 'h1', + label: 'Heading 1', + icon: HeadingOneIcon, + group: 'structure', + isActive: blockType === 'h1', + disabled: readOnly, + onClick: () => formatHeading('h1'), + }, + { + id: 'h2', + label: 'Heading 2', + icon: HeadingTwoIcon, + group: 'structure', + isActive: blockType === 'h2', + disabled: readOnly, + onClick: () => formatHeading('h2'), + }, + { + id: 'h3', + label: 'Heading 3', + icon: HeadingThreeIcon, + group: 'structure', + isActive: blockType === 'h3', + disabled: readOnly, + onClick: () => formatHeading('h3'), + }, + { + id: 'bulleted', + label: 'Bulleted List', + icon: BulletListIcon, + group: 'structure', + isActive: blockType === 'bullet', + disabled: readOnly, + onClick: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), + }, + { + id: 'numbered', + label: 'Numbered List', + icon: NumberListIcon, + group: 'structure', + isActive: blockType === 'number', + disabled: readOnly, + onClick: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), + }, + { + id: 'quote', + label: 'Block Quote', + icon: QuoteIcon, + group: 'structure', + isActive: blockType === 'quote', + disabled: readOnly, + onClick: formatQuote, + }, + { + id: 'link', + label: isLink ? 'Edit or Remove Link' : 'Insert Link', + icon: ToolbarLinkIcon, + group: 'insert', + isActive: isLink, + disabled: readOnly, + onClick: toggleLink, + }, + { + id: 'table', + label: isInTable ? 'Table tools' : 'Insert Table', + icon: TableIcon, + group: 'insert', + disabled: readOnly, + onClick: openTableModal, + }, + { + id: 'image', + label: 'Insert Image', + icon: ToolbarImageIcon, + group: 'insert', + disabled: readOnly, + onClick: openImagePicker, + }, + { + id: 'align-left', + label: 'Align Left', + icon: AlignLeftIcon, + group: 'alignment', + isActive: alignment === 'left', + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'), + }, + { + id: 'align-center', + label: 'Align Center', + icon: AlignCenterIcon, + group: 'alignment', + isActive: alignment === 'center', + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'), + }, + { + id: 'align-right', + label: 'Align Right', + icon: AlignRightIcon, + group: 'alignment', + isActive: alignment === 'right', + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'), + }, + { + id: 'align-justify', + label: 'Justify', + icon: AlignJustifyIcon, + group: 'alignment', + isActive: alignment === 'justify', + disabled: readOnly, + onClick: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'), + }, + { + id: 'clear-formatting', + label: 'Clear Formatting', + icon: ClearFormattingIcon, + group: 'utility', + disabled: readOnly, + onClick: () => { + if (isBold) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); + } + if (isItalic) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); + } + if (isUnderline) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); + } + if (isStrikethrough) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); + } + if (isCode) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code'); + } + if (isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'); + formatParagraph(); + }, + }, + ], + [ + alignment, + blockType, + canRedo, + canUndo, + editor, + formatHeading, + formatParagraph, + formatQuote, + isBold, + isCode, + isItalic, + isLink, + isStrikethrough, + isUnderline, + openImagePicker, + openTableModal, + readOnly, + toggleLink, + ], + ); + + const tableContextActions = useMemo( + () => [ + { + id: 'insert-column-before', + label: 'Insert column before', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: () => insertTableColumn(false), + }, + { + id: 'insert-column-after', + label: 'Insert column after', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: () => insertTableColumn(true), + }, + { + id: 'insert-row-before', + label: 'Insert row before', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: () => insertTableRow(false), + }, + { + id: 'insert-row-after', + label: 'Insert row after', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: () => insertTableRow(true), + }, + { + id: 'delete-row', + label: 'Delete row', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: deleteTableRow, + }, + { + id: 'delete-column', + label: 'Delete column', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: deleteTableColumn, + }, + { + id: 'delete-table', + label: 'Delete table', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: deleteTable, + }, + { + id: 'toggle-header-row', + label: hasHeaderRow ? 'Remove header row' : 'Add header row', + icon: TableIcon, + group: 'table', + disabled: readOnly || !isInTable, + onClick: toggleHeaderRow, + }, + ], + [ + deleteTable, + deleteTableColumn, + deleteTableRow, + hasHeaderRow, + insertTableColumn, + insertTableRow, + isInTable, + readOnly, + toggleHeaderRow, + ], + ); + + const allButtons = useMemo(() => { + return [...toolbarButtons]; + }, [toolbarButtons]); + + useEffect(() => { + onActionsChange(allButtons); + }, [allButtons, onActionsChange]); + + return ( + <> + + + setIsTableModalOpen(false)} + onInsertTable={insertTable} + onInsertRowAbove={() => insertTableRow(false)} + onInsertRowBelow={() => insertTableRow(true)} + onInsertColumnLeft={() => insertTableColumn(false)} + onInsertColumnRight={() => insertTableColumn(true)} + onDeleteRow={deleteTableRow} + onDeleteColumn={deleteTableColumn} + onDeleteTable={deleteTable} + onToggleHeaderRow={toggleHeaderRow} + isInTable={isInTable} + hasHeaderRow={hasHeaderRow} + /> + + ); +}; diff --git a/components/rich-text/types.ts b/components/rich-text/types.ts new file mode 100644 index 0000000..2629215 --- /dev/null +++ b/components/rich-text/types.ts @@ -0,0 +1,40 @@ +import React from 'react'; +import type { HeadingTagType } from '@lexical/rich-text'; +import type { ListType } from '@lexical/list'; + +export interface ToolbarButtonConfig { + id: string; + label: string; + icon: React.FC<{ className?: string }>; + group: 'history' | 'inline-format' | 'structure' | 'insert' | 'alignment' | 'utility' | 'table'; + isActive?: boolean; + disabled?: boolean; + onClick: () => void; +} + +export type BlockType = 'paragraph' | HeadingTagType | ListType | 'quote'; + +export interface ContextMenuState { + x: number; + y: number; + visible: boolean; +} + +export type SelectionSnapshot = + | { + type: 'range'; + anchorKey: string; + anchorOffset: number; + anchorType: 'text' | 'element'; + focusKey: string; + focusOffset: number; + focusType: 'text' | 'element'; + } + | { type: 'node'; keys: string[] } + | { + type: 'table'; + tableKey: string; + anchorCellKey: string; + focusCellKey: string; + } + | null; diff --git a/components/rich-text/utils.ts b/components/rich-text/utils.ts new file mode 100644 index 0000000..e5c50f5 --- /dev/null +++ b/components/rich-text/utils.ts @@ -0,0 +1,12 @@ +export const normalizeUrl = (url: string): string => { + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + + if (/^[a-zA-Z][\w+.-]*:/.test(trimmed)) { + return trimmed; + } + + return `https://${trimmed}`; +}; diff --git a/docs/releases/0.8.2.md b/docs/releases/0.8.2.md new file mode 100644 index 0000000..de78df9 --- /dev/null +++ b/docs/releases/0.8.2.md @@ -0,0 +1,51 @@ +# v0.8.2 - The Antigravity Styling Update + +**Release Date:** 2025-12-19 + +## Summary + +This release introduces the Antigravity design language—a cleaner, VS Code-inspired aesthetic with sharper corners, reduced focus rings, and flatter UI elements. The Markdown preview zoom has been improved to properly reflow text, and tree selection visibility has been fixed in light mode. + +## Improvements + +### Antigravity Design Language + +Applied a comprehensive styling update across the application for a more professional, IDE-like appearance: + +- **Sharper corners:** Buttons, modals, tooltips, dropdowns, and cards now use `rounded-sm` instead of `rounded-md`/`rounded-lg`. +- **Reduced focus rings:** Focus indicators are now more subtle (1px, lower opacity) to avoid visual clutter. +- **Flatter design:** Removed shadows from modals, tooltips, context menus, and dropdowns, relying on borders for definition. +- **Status bar polish:** LLM provider/model selectors, database dropdown, and zoom buttons now use text-only hover/focus states instead of visible rings or backgrounds. + +### Markdown Preview Zoom + +Text now reflows properly when zooming in or out in the Markdown preview pane. This eliminates the whitespace issue that occurred when zooming out, where content would shrink but not reflow to use the available width. + +### Tree Selection Visibility + +Fixed the tree item selection highlight in light mode to be clearly visible. The previous implementation used 20% opacity on the selection color, making it nearly invisible. + +## Fixes + +- **Scroll sync disabled:** Removed the scroll synchronization between the code editor and Markdown preview panes, as the sync behavior was unreliable. The panes now scroll independently. +- **Search box styling:** Improved the sidebar search box with a subtle background, no visible border, and sharper corners for a more professional look. + +## Technical Changes + +### Files Modified + +- `components/Button.tsx` - Sharper corners, reduced focus ring +- `components/Modal.tsx` - Removed shadow, sharper corners +- `components/Tooltip.tsx` - Removed shadow, added border, sharper corners +- `components/IconButton.tsx` - Sharper corners, subtle hover +- `components/ToggleSwitch.tsx` - Reduced focus ring +- `components/LanguageDropdown.tsx` - Sharper corners, removed shadow +- `components/WelcomeScreen.tsx` - Sharper card corners +- `components/StatusBar.tsx` - Removed focus rings from selects and zoom buttons +- `components/Sidebar.tsx` - Improved search box styling +- `components/PromptTreeItem.tsx` - Fixed selection opacity +- `components/TemplateList.tsx` - Fixed selection opacity +- `components/ZoomPanContainer.tsx` - Added CSS zoom support for text reflow +- `components/PromptEditor.tsx` - Disabled scroll sync +- `services/preview/markdownRenderer.tsx` - Enabled CSS zoom for preview +- `services/themeCustomization.ts` - Adjusted tree selection color diff --git a/index.html b/index.html index 60d9375..9054175 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,6 @@ + @@ -34,64 +35,149 @@ 'tree-selected': 'rgb(var(--color-tree-selected) / )', }, fontFamily: { - sans: ['Inter', 'sans-serif'], - mono: ['JetBrains Mono', 'monospace'], + sans: ['Segoe WPC', 'Segoe UI', 'sans-serif'], + mono: ['Consolas', 'Courier New', 'monospace'], }, } } } - - - + +
    - + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ebbaa9e..f7601b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docforge", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docforge", - "version": "0.8.0", + "version": "0.8.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index eb459c2..00c9c41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docforge", - "version": "0.8.0", + "version": "0.8.2", "description": "An application to manage and refine documents.", "main": "dist/main.js", "scripts": { @@ -138,4 +138,4 @@ "icon": "assets/icon.png" } } -} +} \ No newline at end of file diff --git a/services/preview/markdownRenderer.tsx b/services/preview/markdownRenderer.tsx index 2aec369..f52d9c7 100644 --- a/services/preview/markdownRenderer.tsx +++ b/services/preview/markdownRenderer.tsx @@ -312,6 +312,7 @@ const MarkdownViewer = forwardRef(({ conten disablePan layout="natural" lockOverflow={false} + useZoomProperty className="min-h-full" wrapperClassName="df-markdown-shell" contentClassName="df-markdown-stage origin-top" diff --git a/services/themeCustomization.ts b/services/themeCustomization.ts index 08da636..b52cdda 100644 --- a/services/themeCustomization.ts +++ b/services/themeCustomization.ts @@ -300,7 +300,7 @@ const BASE_PALETTES: Record = { modalBackdrop: '0 0 0 / 0.5', tooltipBg: '23 23 23', tooltipText: '245 245 245', - treeSelected: '212 212 212', + treeSelected: '200 200 200', selectArrowBackground: DEFAULT_LIGHT_SELECT_ARROW, }, dark: { @@ -333,22 +333,22 @@ const TONE_OVERRIDES: Record> light: { neutral: {}, warm: { - background: '253 246 239', - secondary: '255 240 228', - textMain: '49 27 11', - textSecondary: '139 94 52', - border: '242 209 179', - accent: '249 115 22', + background: '255 252 245', // Warmer, lighter off-white + secondary: '255 247 237', // Subtle warm secondary + textMain: '67 20 7', // Deep warm brown + textSecondary: '124 45 18', // Muted warm reddish-brown + border: '253 230 138', // Soft warm border + accent: '245 158 11', // Amber/Orange accentText: '255 255 255', }, cool: { - background: '241 245 255', - secondary: '226 235 255', - textMain: '15 23 42', - textSecondary: '71 85 105', - border: '199 210 254', - accent: '37 99 235', - accentText: '248 250 252', + background: '248 250 252', // Very subtle cool slate-50 + secondary: '241 245 249', // Slate-100 + textMain: '15 23 42', // Slate-900 + textSecondary: '71 85 105', // Slate-600 + border: '226 232 240', // Slate-200 + accent: '59 130 246', // Blue-500 + accentText: '255 255 255', }, }, dark: {