Skip to content
3 changes: 3 additions & 0 deletions docs/content/en/api-reference/keys/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ interface KeyStateEvent {
key: string; // e.g., 'KeyA', 'MouseLeft'
state: "UP" | "DOWN";
mode: "keyboard" | "mouse";
// Elapsed time (ms) between input capture and event emit.
// Recover the real input time with `performance.now() - eventAgeMs`.
eventAgeMs?: number;
}

const unsub = dmn.keys.onKeyState(({ key, state, mode }) => {
Expand Down
3 changes: 3 additions & 0 deletions docs/content/ko/api-reference/keys/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ interface KeyStatePayload {
key: string; // 키 코드 (예: "KeyD")
state: string; // "DOWN" | "UP"
mode: string; // 현재 모드
// 입력 수신~emit 경과 시간(ms).
// `performance.now() - eventAgeMs`로 실제 입력 시각 복원
eventAgeMs?: number;
}
```

Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ pub struct KeyPosition {
/// 노트 테두리 색상
#[serde(default)]
pub note_border_color: Option<String>,
/// 노트 테두리 투명도 (0~100). 노트 배경 투명도와 독립. 기본 100.
#[serde(default = "default_note_border_opacity")]
pub note_border_opacity: u32,
/// 노트 테두리 방향 (all/vertical/horizontal)
#[serde(default)]
pub note_border_side: Option<String>,
Expand Down Expand Up @@ -787,6 +790,10 @@ fn default_note_glow_enabled() -> bool {
fn default_note_glow_size() -> u32 {
20
}

fn default_note_border_opacity() -> u32 {
100
}
fn default_note_glow_opacity() -> u32 {
70
}
Expand Down
9 changes: 8 additions & 1 deletion src-tauri/src/state/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,9 @@ impl AppState {
continue;
}

// 입력 수신 시각 — 노트 위치의 프레임 양자화 보정용 age 측정 기준
let recv_at = Instant::now();

// 우선 형식: JSON 인코딩된 HookMessage (device 포함)
let parsed: Option<crate::ipc::HookMessage> =
serde_json::from_str(s).ok();
Expand Down Expand Up @@ -1039,7 +1042,11 @@ impl AppState {
}
}
}
let payload = json!({ "key": key_label, "state": state, "mode": mode });
// 입력 수신~emit 사이 경과 시간(ms). 오버레이가
// performance.now() - eventAgeMs로 실제 입력 시각을 복원해
// 노트 시작 위치가 렌더 프레임 경계에 양자화되는 것을 방지
let event_age_ms = recv_at.elapsed().as_secs_f64() * 1000.0;
let payload = json!({ "key": key_label, "state": state, "mode": mode, "eventAgeMs": event_age_ms });

let mut emitted = false;
if let Some(overlay) = overlay_window.as_ref() {
Expand Down
57 changes: 47 additions & 10 deletions src/renderer/components/main/Grid/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { usePropertiesPanelStore } from '@stores/grid/usePropertiesPanelStore';
import { useLayerGroupStore } from '@stores/data/useLayerGroupStore';
import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps';
import { translatePluginMessage } from '@utils/plugin/pluginI18n';
import { toRgbHexColor } from '@utils/color/colorUtils';
import {
toRgbHexColor,
parseAlphaPercent,
hexWithAlphaPercent,
} from '@utils/color/colorUtils';
import type { KeyPosition } from '@src/types/key/keys';
import type { StatItemPosition, StatItemType } from '@src/types/key/statItems';
import type { GraphItemPosition } from '@src/types/key/graphItems';
Expand Down Expand Up @@ -682,6 +686,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
noteColor: NoteColor;
glowColor: NoteColor;
borderColor: string;
borderOpacity: number;
fillIdle: string;
fillActive: string;
strokeIdle: string;
Expand All @@ -690,6 +695,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
noteColor: '#FFFFFF',
glowColor: '#FFFFFF',
borderColor: '#FFFFFF',
borderOpacity: 100,
fillIdle: '#FFFFFF',
fillActive: '#FFFFFF',
strokeIdle: '#000000',
Expand Down Expand Up @@ -2020,6 +2026,17 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
dispatchKeyOnlyBatchUpdates(updates, 'commit');
};

const handleBatchKeyOnlyStyleChange = (
property: keyof KeyPosition,
value: KeyPosition[keyof KeyPosition],
) => {
const updates = getSelectedKeyOnlyPositions().map(({ index }) => ({
index,
[property]: value,
})) as Array<{ index: number } & Partial<KeyPosition>>;
dispatchKeyOnlyBatchUpdates(updates, 'preview');
};

const handleBatchNoteColorChangeKeysOnly = (value: NoteColor) => {
const updates = getSelectedKeyOnlyPositions().map(({ index }) => ({
index,
Expand Down Expand Up @@ -2304,6 +2321,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
return typeof gc === 'string' ? gc : '#FFFFFF';
})(),
borderColor: firstPos.noteBorderColor ?? '#FFFFFF',
borderOpacity: firstPos.noteBorderOpacity ?? 100,
fillIdle: counterSettings.fill.idle,
fillActive: counterSettings.fill.active,
strokeIdle: counterSettings.stroke.idle,
Expand Down Expand Up @@ -2331,7 +2349,10 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
case 'glowColor':
return batchLocalColors.glowColor;
case 'borderColor':
return batchLocalColors.borderColor;
return hexWithAlphaPercent(
batchLocalColors.borderColor,
batchLocalColors.borderOpacity,
);
case 'fill':
return batchCounterColorState === 'active'
? batchLocalColors.fillActive
Expand Down Expand Up @@ -2371,10 +2392,11 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
[batchPickerFor]: newColor,
}));
} else if (batchPickerFor === 'borderColor') {
const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF';
const raw = typeof newColor === 'string' ? newColor : undefined;
setBatchLocalColors((prev) => ({
...prev,
borderColor: solidColor,
borderColor: toRgbHexColor(raw),
borderOpacity: parseAlphaPercent(raw, prev.borderOpacity),
}));
} else if (batchPickerFor === 'fill') {
const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF';
Expand Down Expand Up @@ -2418,6 +2440,18 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
} else {
handleBatchGlowColorChange(newColor);
}
} else if (batchPickerFor === 'borderColor') {
// noteBorderColor는 #RRGGBB 계약 — 색은 hex로 정규화, 알파는 noteBorderOpacity로 분리
const raw = typeof newColor === 'string' ? newColor : undefined;
const solidColor = toRgbHexColor(raw);
const opacity = parseAlphaPercent(raw, batchLocalColors.borderOpacity);
if (selectedKeyElements.length > 0 && selectedStatElements.length > 0) {
handleBatchKeyOnlyStyleChange('noteBorderColor', solidColor);
handleBatchKeyOnlyStyleChange('noteBorderOpacity', opacity);
} else {
handleBatchStyleChange('noteBorderColor', solidColor);
handleBatchStyleChange('noteBorderOpacity', opacity);
}
}
};

Expand All @@ -2430,10 +2464,11 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
[batchPickerFor]: newColor,
}));
} else if (batchPickerFor === 'borderColor') {
const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF';
const raw = typeof newColor === 'string' ? newColor : undefined;
setBatchLocalColors((prev) => ({
...prev,
borderColor: solidColor,
borderColor: toRgbHexColor(raw),
borderOpacity: parseAlphaPercent(raw, prev.borderOpacity),
}));
} else if (batchPickerFor === 'fill') {
const solidColor = typeof newColor === 'string' ? newColor : '#FFFFFF';
Expand Down Expand Up @@ -2471,14 +2506,16 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
handleBatchGlowColorChangeComplete(newColor);
}
} else if (batchPickerFor === 'borderColor') {
// noteBorderColor는 #RRGGBB 계약 — 피커의 rgba(...) 출력을 hex로 정규화 (이슈 #73)
const solidColor = toRgbHexColor(
typeof newColor === 'string' ? newColor : undefined,
);
// noteBorderColor는 #RRGGBB 계약 — 색은 hex로 정규화(이슈 #73), 알파는 noteBorderOpacity로 분리
const raw = typeof newColor === 'string' ? newColor : undefined;
const solidColor = toRgbHexColor(raw);
const opacity = parseAlphaPercent(raw, batchLocalColors.borderOpacity);
if (selectedKeyElements.length > 0 && selectedStatElements.length > 0) {
handleBatchKeyOnlyStyleChangeComplete('noteBorderColor', solidColor);
handleBatchKeyOnlyStyleChangeComplete('noteBorderOpacity', opacity);
} else {
handleBatchStyleChangeComplete('noteBorderColor', solidColor);
handleBatchStyleChangeComplete('noteBorderOpacity', opacity);
}
} else if (batchPickerFor === 'fill') {
const fillColor = typeof newColor === 'string' ? newColor : '#FFFFFF';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ interface BatchNoteTabContentProps {
label: string;
isMixed: boolean;
};
getBatchBorderColorDisplay: () => {
style: React.CSSProperties;
label: string;
isMixed: boolean;
};
// 컬러 피커 토글
onNoteColorPickerToggle: () => void;
onGlowColorPickerToggle: () => void;
Expand All @@ -43,7 +48,6 @@ interface BatchNoteTabContentProps {
batchNoteColorButtonRef: React.RefObject<HTMLButtonElement>;
batchGlowColorButtonRef: React.RefObject<HTMLButtonElement>;
batchBorderColorButtonRef: React.RefObject<HTMLButtonElement>;
batchLocalColors: { borderColor: string };
// 번역
t: (key: string) => string;
}
Expand All @@ -53,6 +57,7 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
handleBatchStyleChangeComplete,
getBatchNoteColorDisplay,
getBatchGlowColorDisplay,
getBatchBorderColorDisplay,
onNoteColorPickerToggle,
onGlowColorPickerToggle,
onBorderColorPickerToggle,
Expand All @@ -62,7 +67,6 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
batchNoteColorButtonRef,
batchGlowColorButtonRef,
batchBorderColorButtonRef,
batchLocalColors,
t,
}) => {
const { noteEffect: _noteEffect } = useSettingsStore();
Expand Down Expand Up @@ -215,14 +219,8 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
? 'border-[#459BF8]'
: 'border-[#3A3943] hover:border-[#505058]'
}`}
style={{
backgroundColor: getMixedValue(
(pos) => pos.noteBorderColor,
'#FFFFFF',
).isMixed
? undefined
: batchLocalColors.borderColor,
}}
style={getBatchBorderColorDisplay().style}
title={getBatchBorderColorDisplay().label}
/>
<Dropdown
iconTrigger={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ interface BatchLocalColors {
noteColor: NoteColor;
glowColor: NoteColor;
borderColor: string;
borderOpacity: number;
fillIdle: string;
fillActive: string;
strokeIdle: string;
Expand Down Expand Up @@ -403,6 +404,34 @@ export const BatchKeyLikePanel: React.FC<BatchKeyLikePanelProps> = ({
};
};

// 테두리 색은 단색만 지원. 피커 열림 시 로컬값, 닫힘 시 실제 공통값/Mixed 표시
const getBatchBorderColorDisplay = () => {
if (batchPickerFor === 'borderColor') {
const color = batchLocalColors.borderColor;
return {
style: { backgroundColor: color },
label: color.replace(/^#/, ''),
isMixed: false,
};
}

const mixedFn =
selectedKeyElements.length > 0 ? getMixedValueKeysOnly : getMixedValue;
const { isMixed, value } = mixedFn((pos) => pos.noteBorderColor, '#FFFFFF');
if (isMixed)
return {
style: { backgroundColor: '#666' },
label: 'Mixed',
isMixed: true,
};
const color = typeof value === 'string' ? value : '#FFFFFF';
return {
style: { backgroundColor: color },
label: color.replace(/^#/, ''),
isMixed: false,
};
};

const keysData = getSelectedKeysData();
const batchCounterSettings = keysData[0]?.position
? normalizeCounterSettings(keysData[0].position.counter)
Expand Down Expand Up @@ -764,6 +793,7 @@ export const BatchKeyLikePanel: React.FC<BatchKeyLikePanelProps> = ({
}
getBatchNoteColorDisplay={getBatchNoteColorDisplay}
getBatchGlowColorDisplay={getBatchGlowColorDisplay}
getBatchBorderColorDisplay={getBatchBorderColorDisplay}
onNoteColorPickerToggle={() =>
handleBatchPickerToggle('noteColor')
}
Expand All @@ -779,7 +809,6 @@ export const BatchKeyLikePanel: React.FC<BatchKeyLikePanelProps> = ({
batchNoteColorButtonRef={batchNoteColorButtonRef}
batchGlowColorButtonRef={batchGlowColorButtonRef}
batchBorderColorButtonRef={batchBorderColorButtonRef}
batchLocalColors={batchLocalColors}
t={t}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import {
import Checkbox from '@components/main/common/Checkbox';
import Dropdown from '@components/main/common/Dropdown';
import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker';
import { isGradientColor, toRgbHexColor } from '@utils/color/colorUtils';
import {
isGradientColor,
toRgbHexColor,
parseAlphaPercent,
hexWithAlphaPercent,
} from '@utils/color/colorUtils';
import { NOTE_SETTINGS_CONSTRAINTS } from '@src/types/settings/noteSettingsConstraints';
import { useSettingsStore } from '@stores/useSettingsStore';

Expand Down Expand Up @@ -258,11 +263,16 @@ const NoteTabContent: React.FC<NoteTabContentProps> = ({
const [borderColor, setBorderColor] = useState<string>(
() => keyPosition.noteBorderColor ?? '#FFFFFF',
);
// 테두리 투명도(0~100). 노트 배경 투명도와 독립
const [localBorderOpacity, setLocalBorderOpacity] = useState<number>(
() => keyPosition.noteBorderOpacity ?? 100,
);

useEffect(() => {
if (pickerFor === 'border') return;
setBorderColor(keyPosition.noteBorderColor ?? '#FFFFFF');
}, [keyPosition.noteBorderColor, pickerFor]);
setLocalBorderOpacity(keyPosition.noteBorderOpacity ?? 100);
}, [keyPosition.noteBorderColor, keyPosition.noteBorderOpacity, pickerFor]);

const interactiveRefs = [
noteColorButtonRef,
Expand Down Expand Up @@ -721,22 +731,35 @@ const NoteTabContent: React.FC<NoteTabContentProps> = ({
? notePickerColor
: pickerFor === 'glow'
? glowPickerColor
: borderColor
: hexWithAlphaPercent(borderColor, localBorderOpacity)
}
onColorChange={(c: NoteColor) => {
if (pickerFor === 'border') {
const hex = toRgbHexColor(typeof c === 'string' ? c : undefined);
const raw = typeof c === 'string' ? c : undefined;
const hex = toRgbHexColor(raw);
const opacity = parseAlphaPercent(raw, localBorderOpacity);
setBorderColor(hex);
setLocalBorderOpacity(opacity);
return;
}
handleColorChange(pickerFor, c);
}}
onColorChangeComplete={(c: NoteColor) => {
if (pickerFor === 'border') {
const hex = toRgbHexColor(typeof c === 'string' ? c : undefined);
const raw = typeof c === 'string' ? c : undefined;
const hex = toRgbHexColor(raw);
const opacity = parseAlphaPercent(raw, localBorderOpacity);
setBorderColor(hex);
onKeyPreview?.(keyIndex, { noteBorderColor: hex });
onKeyUpdate({ index: keyIndex, noteBorderColor: hex });
setLocalBorderOpacity(opacity);
onKeyPreview?.(keyIndex, {
noteBorderColor: hex,
noteBorderOpacity: opacity,
});
onKeyUpdate({
index: keyIndex,
noteBorderColor: hex,
noteBorderOpacity: opacity,
});
return;
}
handleColorChangeComplete(pickerFor, c);
Expand Down
Loading
Loading