Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -237,6 +237,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 @@ -763,6 +766,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 @@ -849,6 +849,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 @@ -1022,7 +1025,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
68 changes: 56 additions & 12 deletions src/renderer/components/main/Grid/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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 @@ -619,7 +623,13 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
});

// 배치 편집용 로컬 ColorPicker 상태
type BatchPickerTarget = 'noteColor' | 'glowColor' | 'borderColor' | 'fill' | 'stroke' | null;
type BatchPickerTarget =
| 'noteColor'
| 'glowColor'
| 'borderColor'
| 'fill'
| 'stroke'
| null;
const [batchPickerFor, setBatchPickerFor] = useState<BatchPickerTarget>(null);
const [batchCounterColorState, setBatchCounterColorState] = useState<
'idle' | 'active'
Expand All @@ -629,6 +639,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
noteColor: NoteColor;
glowColor: NoteColor;
borderColor: string;
borderOpacity: number;
fillIdle: string;
fillActive: string;
strokeIdle: string;
Expand All @@ -637,6 +648,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
noteColor: '#FFFFFF',
glowColor: '#FFFFFF',
borderColor: '#FFFFFF',
borderOpacity: 100,
fillIdle: '#FFFFFF',
fillActive: '#FFFFFF',
strokeIdle: '#000000',
Expand Down Expand Up @@ -1745,6 +1757,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 @@ -1882,7 +1905,8 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
? schemaValue.default
: 0;
// step 값에서 소수 자릿수 자동 추론
const stepStr = schemaValue.step != null ? String(schemaValue.step) : '';
const stepStr =
schemaValue.step != null ? String(schemaValue.step) : '';
const dotIdx = stepStr.indexOf('.');
const hasDecimal = dotIdx !== -1;
const decimalScale = hasDecimal ? stepStr.length - dotIdx - 1 : 0;
Expand Down Expand Up @@ -2019,6 +2043,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 @@ -2046,7 +2071,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 @@ -2086,10 +2114,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 @@ -2133,6 +2162,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 @@ -2145,10 +2186,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 @@ -2186,14 +2228,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 @@ -116,9 +120,7 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
{/* 오프셋 */}
<PropertyRow label={t('keySetting.noteOffset') || '오프셋'}>
<OptionalNumberInput
value={
getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined
}
value={getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined}
onChange={(value) =>
handleBatchStyleChangeComplete('noteOffsetX', value)
}
Expand All @@ -130,9 +132,7 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
isMixed={getMixedValue((pos) => pos.noteOffsetX, 0).isMixed}
/>
<OptionalNumberInput
value={
getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined
}
value={getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined}
onChange={(value) =>
handleBatchStyleChangeComplete('noteOffsetY', value)
}
Expand Down Expand Up @@ -219,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 @@ -61,7 +61,13 @@ const RenameIcon: React.FC = () => (
// Mixed key-like + graph batch selection panel
// ============================================================================

type BatchPickerTarget = 'noteColor' | 'glowColor' | 'borderColor' | 'fill' | 'stroke' | null;
type BatchPickerTarget =
| 'noteColor'
| 'glowColor'
| 'borderColor'
| 'fill'
| 'stroke'
| null;

type MixedValueResult<T> = { isMixed: boolean; value: T };
type MixedValueGetter<P> = <T>(
Expand All @@ -80,6 +86,7 @@ interface BatchLocalColors {
noteColor: NoteColor;
glowColor: NoteColor;
borderColor: string;
borderOpacity: number;
fillIdle: string;
fillActive: string;
strokeIdle: string;
Expand Down Expand Up @@ -396,6 +403,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 @@ -757,6 +792,7 @@ export const BatchKeyLikePanel: React.FC<BatchKeyLikePanelProps> = ({
}
getBatchNoteColorDisplay={getBatchNoteColorDisplay}
getBatchGlowColorDisplay={getBatchGlowColorDisplay}
getBatchBorderColorDisplay={getBatchBorderColorDisplay}
onNoteColorPickerToggle={() =>
handleBatchPickerToggle('noteColor')
}
Expand All @@ -772,7 +808,6 @@ export const BatchKeyLikePanel: React.FC<BatchKeyLikePanelProps> = ({
batchNoteColorButtonRef={batchNoteColorButtonRef}
batchGlowColorButtonRef={batchGlowColorButtonRef}
batchBorderColorButtonRef={batchBorderColorButtonRef}
batchLocalColors={batchLocalColors}
t={t}
/>
</div>
Expand Down
Loading
Loading