增加用于快捷向Scheme添加颜色的上下文菜单。
This commit is contained in:
		
							
								
								
									
										42
									
								
								src/components/ContextMenu.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/components/ContextMenu.module.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | @layer components { | ||||||
|  |   .context_menu_locationer { | ||||||
|  |     position: relative; | ||||||
|  |     display: inline-block; | ||||||
|  |     .action_icon { | ||||||
|  |       background-color: transparent; | ||||||
|  |       &:hover { | ||||||
|  |         background-color: var(--color-neutral-hover); | ||||||
|  |       } | ||||||
|  |       &:active { | ||||||
|  |         background-color: var(--color-neutral-active); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     .menu_body { | ||||||
|  |       position: absolute; | ||||||
|  |       width: max-content; | ||||||
|  |       background-color: var(--color-wumeizi); | ||||||
|  |       color: var(--color-yudubai); | ||||||
|  |       border: 1px solid var(--color-xuanqing); | ||||||
|  |       border-radius: var(--border-radius-xs); | ||||||
|  |       padding-block: var(--spacing-xs); | ||||||
|  |       display: flex; | ||||||
|  |       flex-direction: column; | ||||||
|  |       z-index: 300; | ||||||
|  |       .menu_item { | ||||||
|  |         width: 100%; | ||||||
|  |         padding: var(--spacing-xs) var(--spacing-s); | ||||||
|  |         &:hover { | ||||||
|  |           background-color: var(--color-primary-hover); | ||||||
|  |         } | ||||||
|  |         &:active { | ||||||
|  |           background-color: var(--color-primary-active); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       hr { | ||||||
|  |         width: 100%; | ||||||
|  |         border: none; | ||||||
|  |         border-top: 1px solid var(--color-border); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										363
									
								
								src/components/ContextMenu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										363
									
								
								src/components/ContextMenu.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,363 @@ | |||||||
|  | import { SwatchEntry } from 'color-module'; | ||||||
|  | import { useAtomValue, useSetAtom } from 'jotai'; | ||||||
|  | import { capitalize, size } from 'lodash-es'; | ||||||
|  | import { FC, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||||
|  | import { MaterialDesign2SchemeSource } from '../material-2-scheme'; | ||||||
|  | import { | ||||||
|  |   MaterialDesign3DynamicSchemeSource, | ||||||
|  |   MaterialDesign3SchemeSource, | ||||||
|  | } from '../material-3-scheme'; | ||||||
|  | import { QSchemeSource } from '../q-scheme'; | ||||||
|  | import { currentPickedColor } from '../stores/colors'; | ||||||
|  | import { activeSchemeAtom, useActiveScheme, useUpdateScheme } from '../stores/schemes'; | ||||||
|  | import { SwatchSchemeSource } from '../swatch_scheme'; | ||||||
|  | import { ActionIcon } from './ActionIcon'; | ||||||
|  | import styles from './ContextMenu.module.css'; | ||||||
|  | import { NotificationType, useNotification } from './Notifications'; | ||||||
|  |  | ||||||
|  | interface ContextMenuItemProps { | ||||||
|  |   color: string; | ||||||
|  |   afterClick?: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const SetPickerMenu: FC<ContextMenuItemProps> = ({ color, afterClick }) => { | ||||||
|  |   const setCurrentPicker = useSetAtom(currentPickedColor); | ||||||
|  |   const handleClickAction = useCallback(() => { | ||||||
|  |     setCurrentPicker(color); | ||||||
|  |     afterClick?.(); | ||||||
|  |   }, [afterClick, color]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.menu_item} onClick={handleClickAction}> | ||||||
|  |       Set to default Picker | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const QSchemeMenu: FC<ContextMenuItemProps> = ({ color, afterClick }) => { | ||||||
|  |   const { showToast } = useNotification(); | ||||||
|  |   const activeSchemeId = useAtomValue(activeSchemeAtom); | ||||||
|  |   const updateScheme = useUpdateScheme(activeSchemeId); | ||||||
|  |   const updateSchemeContent = useCallback( | ||||||
|  |     (content: keyof QSchemeSource) => { | ||||||
|  |       updateScheme((prev) => { | ||||||
|  |         prev.schemeStorage.source[content] = color; | ||||||
|  |         return prev; | ||||||
|  |       }); | ||||||
|  |       showToast( | ||||||
|  |         NotificationType.SUCCESS, | ||||||
|  |         `${capitalize(content)} color in active scheme updated.`, | ||||||
|  |         'tabler:settings-up', | ||||||
|  |         3000, | ||||||
|  |       ); | ||||||
|  |       afterClick?.(); | ||||||
|  |     }, | ||||||
|  |     [color, activeSchemeId, updateScheme], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <hr /> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('primary')}> | ||||||
|  |         Set as Primary color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('secondary')}> | ||||||
|  |         Set as Secondary color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('tertiary')}> | ||||||
|  |         Set as Tertiary color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('accent')}> | ||||||
|  |         Set as Accent color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('danger')}> | ||||||
|  |         Set as Danger color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('success')}> | ||||||
|  |         Set as Success color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('warning')}> | ||||||
|  |         Set as Warn color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('info')}> | ||||||
|  |         Set as Info color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('foreground')}> | ||||||
|  |         Set as Foreground color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('background')}> | ||||||
|  |         Set as Background color | ||||||
|  |       </div> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const SwatchSchemeMenu: FC<ContextMenuItemProps> = ({ color, afterClick }) => { | ||||||
|  |   const { showToast } = useNotification(); | ||||||
|  |   const activeSchemeId = useAtomValue(activeSchemeAtom); | ||||||
|  |   const updateScheme = useUpdateScheme(activeSchemeId); | ||||||
|  |   const addColorEntry = useCallback(() => { | ||||||
|  |     updateScheme((prev) => { | ||||||
|  |       const source = prev.schemeStorage.source as SwatchSchemeSource; | ||||||
|  |  | ||||||
|  |       const colorAmount = source.colors.length; | ||||||
|  |       const newEntry = new SwatchEntry(`Custom Color ${colorAmount + 1}`, color); | ||||||
|  |       source.colors.push(newEntry.toJsValue()); | ||||||
|  |  | ||||||
|  |       return prev; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     showToast(NotificationType.SUCCESS, 'New color entry added.', 'tabler:settings-up', 3000); | ||||||
|  |  | ||||||
|  |     afterClick?.(); | ||||||
|  |   }, [color, activeSchemeId, updateScheme]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <hr /> | ||||||
|  |       <div className={styles.menu_item} onClick={addColorEntry}> | ||||||
|  |         Add to swatch color | ||||||
|  |       </div> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const Material2SchemeMenu: FC<ContextMenuItemProps> = ({ color, afterClick }) => { | ||||||
|  |   const { showToast } = useNotification(); | ||||||
|  |   const activeSchemeId = useAtomValue(activeSchemeAtom); | ||||||
|  |   const updateScheme = useUpdateScheme(activeSchemeId); | ||||||
|  |   const updateSchemeColor = useCallback( | ||||||
|  |     (content: keyof MaterialDesign2SchemeSource) => { | ||||||
|  |       updateScheme((prev) => { | ||||||
|  |         prev.schemeStorage.source[content] = color; | ||||||
|  |         return prev; | ||||||
|  |       }); | ||||||
|  |       showToast( | ||||||
|  |         NotificationType.SUCCESS, | ||||||
|  |         `${capitalize(content)} color in active scheme updated.`, | ||||||
|  |         'tabler:settings-up', | ||||||
|  |         3000, | ||||||
|  |       ); | ||||||
|  |       afterClick?.(); | ||||||
|  |     }, | ||||||
|  |     [color, activeSchemeId, updateScheme], | ||||||
|  |   ); | ||||||
|  |   const addToCustomColor = useCallback(() => { | ||||||
|  |     updateScheme((prev) => { | ||||||
|  |       const source = prev.schemeStorage.source as MaterialDesign2SchemeSource; | ||||||
|  |       const colorAmount = size(source.custom_colors); | ||||||
|  |       source.custom_colors[`Custom Color ${colorAmount + 1}`] = color; | ||||||
|  |       return prev; | ||||||
|  |     }); | ||||||
|  |     showToast(NotificationType.SUCCESS, `New color entry added.`, 'tabler:settings-up', 3000); | ||||||
|  |     afterClick?.(); | ||||||
|  |   }, [color, activeSchemeId, updateScheme]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <hr /> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeColor('primary')}> | ||||||
|  |         Set as Primary color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeColor('secondary')}> | ||||||
|  |         Set as Secondary color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeColor('error')}> | ||||||
|  |         Set as Error color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => addToCustomColor()}> | ||||||
|  |         Add to Custom colors | ||||||
|  |       </div> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const Material3SchemeMenu: FC<ContextMenuItemProps> = ({ color, afterClick }) => { | ||||||
|  |   const { showToast } = useNotification(); | ||||||
|  |   const activeSchemeId = useAtomValue(activeSchemeAtom); | ||||||
|  |   const updateScheme = useUpdateScheme(activeSchemeId); | ||||||
|  |   const updateSchemeContent = useCallback( | ||||||
|  |     (content: keyof MaterialDesign3SchemeSource) => { | ||||||
|  |       updateScheme((prev) => { | ||||||
|  |         prev.schemeStorage.source[content] = color; | ||||||
|  |         return prev; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       showToast( | ||||||
|  |         NotificationType.SUCCESS, | ||||||
|  |         `${capitalize(content)} color in active scheme updated.`, | ||||||
|  |         'tabler:settings-up', | ||||||
|  |         3000, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       afterClick?.(); | ||||||
|  |     }, | ||||||
|  |     [color, activeSchemeId, updateScheme], | ||||||
|  |   ); | ||||||
|  |   const addCustomColor = useCallback(() => { | ||||||
|  |     updateScheme((prev) => { | ||||||
|  |       const source = prev.schemeStorage.source as | ||||||
|  |         | MaterialDesign3DynamicSchemeSource | ||||||
|  |         | MaterialDesign3SchemeSource; | ||||||
|  |       const colorAmount = size(source.custom_colors); | ||||||
|  |       source.custom_colors[`Custom Color ${colorAmount + 1}`] = color; | ||||||
|  |       return prev; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     showToast(NotificationType.SUCCESS, `New color entry added.`, 'tabler:settings-up', 3000); | ||||||
|  |  | ||||||
|  |     afterClick?.(); | ||||||
|  |   }, [color, activeSchemeId, updateScheme]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <hr /> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('source')}> | ||||||
|  |         Set as Source color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={() => updateSchemeContent('error')}> | ||||||
|  |         Set as Error color | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.menu_item} onClick={addCustomColor}> | ||||||
|  |         Add to Custom colors | ||||||
|  |       </div> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface ContextMenuProps { | ||||||
|  |   color: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const ContextMenu: FC<ContextMenuProps> = ({ color }) => { | ||||||
|  |   const [isOpen, setIsOpen] = useState(false); | ||||||
|  |   const [initialPosition, setInitialPosition] = useState({ x: 0, y: 0 }); | ||||||
|  |   const [renderPosition, setRenderPosition] = useState({ x: 0, y: 0 }); | ||||||
|  |   const containerRef = useRef<HTMLDivElement>(null); | ||||||
|  |   const triggerRef = useRef<HTMLButtonElement>(null); | ||||||
|  |   const menuRef = useRef<HTMLDivElement>(null); | ||||||
|  |   const activeScheme = useActiveScheme(); | ||||||
|  |  | ||||||
|  |   const handleOpenMenu = () => { | ||||||
|  |     if (isOpen) { | ||||||
|  |       setIsOpen(false); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     if (triggerRef.current && containerRef.current) { | ||||||
|  |       const triggerRect = triggerRef.current.getBoundingClientRect(); | ||||||
|  |       const containerRect = containerRef.current.getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |       const x = triggerRect.left - containerRect.left; | ||||||
|  |       const y = triggerRect.bottom - containerRect.top; | ||||||
|  |  | ||||||
|  |       setInitialPosition({ x, y }); | ||||||
|  |       setRenderPosition({ x, y }); | ||||||
|  |       setIsOpen(true); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |   const handleCloseMenu = useCallback(() => { | ||||||
|  |     setIsOpen(false); | ||||||
|  |   }, []); | ||||||
|  |   const handleLeaveClose = useCallback( | ||||||
|  |     (evt: MouseEvent<HTMLDivElement>) => { | ||||||
|  |       if (!isOpen) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       const relatedTarget = evt.relatedTarget as Node | null; | ||||||
|  |  | ||||||
|  |       if (menuRef.current && menuRef.current.contains(relatedTarget)) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (triggerRef.current && triggerRef.current.contains(relatedTarget)) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       handleCloseMenu(); | ||||||
|  |     }, | ||||||
|  |     [handleCloseMenu, isOpen], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const schemeMenu = useMemo(() => { | ||||||
|  |     const sharedProps: ContextMenuItemProps = { | ||||||
|  |       color, | ||||||
|  |       afterClick: handleCloseMenu, | ||||||
|  |     }; | ||||||
|  |     switch (activeScheme?.type) { | ||||||
|  |       case 'q_scheme': | ||||||
|  |         return <QSchemeMenu {...sharedProps} />; | ||||||
|  |       case 'swatch_scheme': | ||||||
|  |         return <SwatchSchemeMenu {...sharedProps} />; | ||||||
|  |       case 'material_2': | ||||||
|  |         return <Material2SchemeMenu {...sharedProps} />; | ||||||
|  |       case 'material_3': | ||||||
|  |       case 'material_3_dynamic': | ||||||
|  |         return <Material3SchemeMenu {...sharedProps} />; | ||||||
|  |       default: | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |   }, [activeScheme]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isOpen && menuRef.current && containerRef.current && triggerRef.current) { | ||||||
|  |       const menuElemenet = menuRef.current; | ||||||
|  |       const triggerRect = triggerRef.current.getBoundingClientRect(); | ||||||
|  |       const containerRect = containerRef.current.getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |       const menuHeight = menuElemenet.offsetHeight; | ||||||
|  |       const menuWidth = menuElemenet.offsetWidth; | ||||||
|  |  | ||||||
|  |       const viewportHeight = window.innerHeight; | ||||||
|  |       const viewportWidth = window.innerWidth; | ||||||
|  |  | ||||||
|  |       const viewportX = containerRect.left + initialPosition.x; | ||||||
|  |       const viewportY = containerRect.top + initialPosition.y; | ||||||
|  |  | ||||||
|  |       let adjustedX = initialPosition.x; | ||||||
|  |       let adjustedY = initialPosition.y; | ||||||
|  |  | ||||||
|  |       if (viewportX + menuWidth > viewportWidth) { | ||||||
|  |         adjustedX = initialPosition.x - menuWidth + triggerRect.width; | ||||||
|  |         if (containerRect.left + adjustedX < 0) { | ||||||
|  |           adjustedX = -containerRect.left + 5; // 留5px边距 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (viewportY + menuHeight > viewportHeight) { | ||||||
|  |         adjustedY = initialPosition.y - menuHeight - triggerRect.height; | ||||||
|  |         if (containerRect.top + adjustedY < 0) { | ||||||
|  |           adjustedY = -containerRect.top + 5; // 留5px边距 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (adjustedX !== renderPosition.x || adjustedY !== renderPosition.y) { | ||||||
|  |         setRenderPosition({ x: adjustedX, y: adjustedY }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [isOpen, initialPosition, renderPosition.x, renderPosition.y]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       className={styles.context_menu_locationer} | ||||||
|  |       ref={containerRef} | ||||||
|  |       onMouseLeave={handleLeaveClose}> | ||||||
|  |       <ActionIcon | ||||||
|  |         icon="tabler:dots-vertical" | ||||||
|  |         extendClassName={styles.action_icon} | ||||||
|  |         onClick={handleOpenMenu} | ||||||
|  |         ref={triggerRef} | ||||||
|  |       /> | ||||||
|  |       {isOpen && ( | ||||||
|  |         <div | ||||||
|  |           className={styles.menu_body} | ||||||
|  |           ref={menuRef} | ||||||
|  |           style={{ top: renderPosition.y, left: renderPosition.x }}> | ||||||
|  |           <SetPickerMenu color={color} afterClick={handleCloseMenu} /> | ||||||
|  |           {schemeMenu} | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ContextMenu; | ||||||
		Reference in New Issue
	
	Block a user