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 { Icon, IconProps } from '@iconify/react/dist/iconify.js';
import cx from 'clsx'; import cx from 'clsx';
import { MouseEvent, MouseEventHandler, useCallback } from 'react'; import { MouseEvent, MouseEventHandler, RefObject, useCallback } from 'react';
import styles from './ActionIcon.module.css'; import styles from './ActionIcon.module.css';
type ActionIconProps = { type ActionIconProps = {
icon: IconProps['icon']; icon: IconProps['icon'];
onClick?: MouseEventHandler<HTMLButtonElement>; onClick?: MouseEventHandler<HTMLButtonElement>;
extendClassName?: HTMLButtonElement['className']; extendClassName?: HTMLButtonElement['className'];
ref: RefObject<HTMLButtonElement>;
}; };
export function ActionIcon({ icon, onClick, extendClassName }: ActionIconProps) { export function ActionIcon({ icon, onClick, extendClassName, ref }: ActionIconProps) {
const handleClick = useCallback( const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => { (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event); onClick?.(event);
@ -18,7 +19,11 @@ export function ActionIcon({ icon, onClick, extendClassName }: ActionIconProps)
); );
return ( 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} /> <Icon icon={icon} className={styles.icon} />
</button> </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 { .color_block {
flex: 1 0; flex: 1 0;
} }
.color_value { .operate_row {
padding: var(--spacing-xxs) var(--spacing-xs); padding: var(--spacing-xxs) var(--spacing-xs);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-s);
.color_value {
text-align: right; text-align: right;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
} }
} }
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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