diff --git a/src/components/ContextMenu.module.css b/src/components/ContextMenu.module.css new file mode 100644 index 0000000..d34a373 --- /dev/null +++ b/src/components/ContextMenu.module.css @@ -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); + } + } + } +} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx new file mode 100644 index 0000000..16d68c7 --- /dev/null +++ b/src/components/ContextMenu.tsx @@ -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 = ({ color, afterClick }) => { + const setCurrentPicker = useSetAtom(currentPickedColor); + const handleClickAction = useCallback(() => { + setCurrentPicker(color); + afterClick?.(); + }, [afterClick, color]); + + return ( +
+ Set to default Picker +
+ ); +}; + +const QSchemeMenu: FC = ({ 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 ( + <> +
+
updateSchemeContent('primary')}> + Set as Primary color +
+
updateSchemeContent('secondary')}> + Set as Secondary color +
+
updateSchemeContent('tertiary')}> + Set as Tertiary color +
+
updateSchemeContent('accent')}> + Set as Accent color +
+
updateSchemeContent('danger')}> + Set as Danger color +
+
updateSchemeContent('success')}> + Set as Success color +
+
updateSchemeContent('warning')}> + Set as Warn color +
+
updateSchemeContent('info')}> + Set as Info color +
+
updateSchemeContent('foreground')}> + Set as Foreground color +
+
updateSchemeContent('background')}> + Set as Background color +
+ + ); +}; + +const SwatchSchemeMenu: FC = ({ 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 ( + <> +
+
+ Add to swatch color +
+ + ); +}; + +const Material2SchemeMenu: FC = ({ 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 ( + <> +
+
updateSchemeColor('primary')}> + Set as Primary color +
+
updateSchemeColor('secondary')}> + Set as Secondary color +
+
updateSchemeColor('error')}> + Set as Error color +
+
addToCustomColor()}> + Add to Custom colors +
+ + ); +}; + +const Material3SchemeMenu: FC = ({ 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 ( + <> +
+
updateSchemeContent('source')}> + Set as Source color +
+
updateSchemeContent('error')}> + Set as Error color +
+
+ Add to Custom colors +
+ + ); +}; + +interface ContextMenuProps { + color: string; +} + +const ContextMenu: FC = ({ 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(null); + const triggerRef = useRef(null); + const menuRef = useRef(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) => { + 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 ; + case 'swatch_scheme': + return ; + case 'material_2': + return ; + case 'material_3': + case 'material_3_dynamic': + return ; + 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 ( +
+ + {isOpen && ( +
+ + {schemeMenu} +
+ )} +
+ ); +}; + +export default ContextMenu;