Compare commits

...

9 Commits

11 changed files with 492 additions and 21 deletions

View File

@ -1,15 +1,16 @@
import { Icon, IconProps } from '@iconify/react/dist/iconify.js';
import cx from 'clsx';
import { MouseEvent, MouseEventHandler, useCallback } from 'react';
import { MouseEvent, MouseEventHandler, RefObject, useCallback } from 'react';
import styles from './ActionIcon.module.css';
type ActionIconProps = {
icon: IconProps['icon'];
onClick?: MouseEventHandler<HTMLButtonElement>;
extendClassName?: HTMLButtonElement['className'];
ref: RefObject<HTMLButtonElement>;
};
export function ActionIcon({ icon, onClick, extendClassName }: ActionIconProps) {
export function ActionIcon({ icon, onClick, extendClassName, ref }: ActionIconProps) {
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
@ -18,7 +19,11 @@ export function ActionIcon({ icon, onClick, extendClassName }: ActionIconProps)
);
return (
<button type="button" onClick={handleClick} className={cx(styles.action_icon, extendClassName)}>
<button
type="button"
onClick={handleClick}
className={cx(styles.action_icon, extendClassName)}
ref={ref}>
<Icon icon={icon} className={styles.icon} />
</button>
);

View 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);
}
}
}
}

View File

@ -0,0 +1,390 @@
import { SwatchEntry } from 'color-module';
import { useAtomValue, useSetAtom } from 'jotai';
import { capitalize, size } from 'lodash-es';
import {
FC,
MouseEvent,
RefObject,
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 ContextMenuBodyProps {
color: string;
afterClick?: () => void;
x?: number;
y?: number;
ref?: RefObject<HTMLDivElement>;
}
export const ContextMenuBody: FC<ContextMenuBodyProps> = ({ color, afterClick, x, y, ref }) => {
const activeScheme = useActiveScheme();
const schemeMenu = useMemo(() => {
const sharedProps: ContextMenuItemProps = {
color,
afterClick,
};
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, color, afterClick]);
return (
<div className={styles.menu_body} ref={ref} style={{ top: y, left: x }}>
<SetPickerMenu color={color} afterClick={afterClick} />
{schemeMenu}
</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 handleOpenMenu = useCallback(() => {
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);
}
}, [isOpen]);
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],
);
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 && (
<ContextMenuBody
color={color}
afterClick={handleCloseMenu}
x={renderPosition.x}
y={renderPosition.y}
ref={menuRef}
/>
)}
</div>
);
};
export default ContextMenu;

View File

@ -11,12 +11,19 @@
.color_block {
flex: 1 0;
}
.color_value {
.operate_row {
padding: var(--spacing-xxs) var(--spacing-xs);
font-size: var(--font-size-xs);
text-align: right;
text-transform: uppercase;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-s);
.color_value {
text-align: right;
text-transform: uppercase;
cursor: pointer;
}
}
}
}

View File

@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useColorFunction } from '../ColorFunctionContext';
import { useCopyColor } from '../hooks/useCopyColor';
import ContextMenu from './ContextMenu';
import styles from './FlexColorStand.module.css';
type FlexColorStandProps = {
@ -51,8 +52,11 @@ export function FlexColorStand({ color, valueMode = 'hex' }: FlexColorStandProps
return (
<div className={styles.color_stand}>
<div className={styles.color_block} style={{ backgroundColor: bgColor }} />
<div className={styles.color_value} onClick={() => copyToClipboard(colorValue)}>
{bgColor}
<div className={styles.operate_row}>
<div className={styles.color_value} onClick={() => copyToClipboard(colorValue)}>
{bgColor}
</div>
<ContextMenu color={color} />
</div>
</div>
);

View File

@ -8,7 +8,6 @@
line-height: var(--font-size-xxs);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-xs);
cursor: pointer;
}
.color_block {
width: 100%;
@ -19,6 +18,7 @@
flex-direction: row;
align-items: center;
padding: var(--spacing-xs) var(--spacing-s);
gap: var(--spacing-s);
}
.title {
display: flex;
@ -38,5 +38,6 @@
}
.color_value {
text-transform: uppercase;
cursor: pointer;
}
}

View File

@ -1,6 +1,7 @@
import { capitalize, isEmpty } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { useColorFunction } from '../../ColorFunctionContext';
import ContextMenu from '../../components/ContextMenu';
import { useCopyColor } from '../../hooks/useCopyColor';
import { ColorDescription } from '../../models';
import styles from './ColorCard.module.css';
@ -57,7 +58,7 @@ export function ColorCard({ color, copyMode }: ColorCardProps) {
}, [copytToClipboard, color, copyMode, colorHex]);
return (
<div className={styles.card} onClick={handleCopy}>
<div className={styles.card}>
<div
className={styles.color_block}
style={{ backgroundColor: `rgb(${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})` }}
@ -69,7 +70,10 @@ export function ColorCard({ color, copyMode }: ColorCardProps) {
<span className={styles.en_name}>{color.pinyin.map(capitalize).join(' ')}</span>
)}
</div>
<div className={styles.color_value}>#{colorHex}</div>
<div className={styles.color_value} onClick={handleCopy}>
#{colorHex}
</div>
<ContextMenu color={colorHex} />
</div>
</div>
);

View File

@ -1,6 +1,7 @@
@layer pages {
.preview {
width: 100%;
height: 100%;
padding: 0 var(--spacing-m);
display: flex;
flex-direction: column;
@ -9,6 +10,7 @@
font-size: var(--font-size-m);
}
.color_blocks {
flex: 1;
display: flex;
flex-direction: row;
justify-content: flex-start;
@ -16,6 +18,7 @@
flex-wrap: nowrap;
gap: var(--spacing-s);
.color_block {
max-height: 23em;
display: flex;
flex-direction: column;
align-items: stretch;
@ -42,14 +45,25 @@
padding-inline: var(--spacing-s);
font-size: var(--font-size-s);
}
.color_code {
.color_code_row {
height: 1.5em;
padding-inline: var(--spacing-s);
font-size: var(--font-size-s);
text-transform: uppercase;
text-align: right;
> span {
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xs);
overflow: visible;
.color_code {
height: 1.5em;
padding-inline: var(--spacing-s);
font-size: var(--font-size-s);
text-transform: uppercase;
text-align: right;
> span {
cursor: pointer;
}
}
}
}

View File

@ -1,6 +1,7 @@
import cx from 'clsx';
import { constant, flatten, isEqual, take, times } from 'lodash-es';
import { useMemo } from 'react';
import ContextMenu from '../../components/ContextMenu';
import { useCopyColor } from '../../hooks/useCopyColor';
import { HarmonyColor } from '../../models';
import styles from './HarmonyPreview.module.css';
@ -33,8 +34,11 @@ export function HarmonyPreview({ colors = [] }: HarmonyPreviewProps) {
style={{ flexGrow: ratio }}>
<div className={styles.color_ratio}>{ratio > 0 && `Ratio: ${ratio}`}</div>
<div className={styles.color_square} style={{ backgroundColor: `#${color}` }}></div>
<div className={styles.color_code}>
{ratio > 0 && <span onClick={() => copyColor(color)}>#{color}</span>}
<div className={styles.color_code_row}>
<div className={styles.color_code}>
{ratio > 0 && <span onClick={() => copyColor(color)}>#{color}</span>}
</div>
<ContextMenu color={color} />
</div>
</div>
))}

View File

@ -31,7 +31,7 @@ function SchemeItem({ item }: SchemeItemProps) {
const isActived = useMemo(() => isEqual(activedScheme, item.id), [activedScheme, item.id]);
const isSelected = useMemo(() => isEqual(navParams['id'], item.id), [navParams, item.id]);
const handleActiveScheme = useCallback(() => {
setActiveScheme((prev) => (prev ? null : item.id));
setActiveScheme((prev) => (prev === item.id ? null : item.id));
}, [item]);
const handleRemoveScheme = useCallback(() => {
removeScheme();

View File

@ -70,7 +70,7 @@ export function useScheme(id?: string | null): SchemeContent<SchemeStorage> | nu
export function useActiveScheme(): SchemeContent<SchemeStorage> | null {
const activeSchemeId = useAtomValue(activeSchemeAtom);
const activeScheme = useScheme(activeSchemeId ?? 'UNEXISTS');
const activeScheme = useScheme(activeSchemeId ?? null);
return activeScheme;
}