From d8c3774f3d4c6ad1c9c19ef992ebaad2ffaecfdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 25 Feb 2026 23:46:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8A=B8=EB=9E=99=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=AA=A8=EB=8B=AC=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models.rs | 1 + .../main/Modal/content/NoteSetting.jsx | 32 +++++++++++-- .../components/main/common/Dropdown.tsx | 6 +-- .../components/overlay/WebGLTracks.jsx | 46 ++++++++----------- .../components/overlay/WebGLTracksOGL.jsx | 43 ++++++++--------- src/renderer/locales/en.json | 1 + src/renderer/locales/ko.json | 1 + src/renderer/locales/ru.json | 1 + src/renderer/locales/zh-Hant.json | 1 + src/renderer/locales/zh-cn.json | 1 + src/types/noteSettings.ts | 14 ++++++ 11 files changed, 90 insertions(+), 57 deletions(-) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a6660e4..0bcee76 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -512,6 +512,7 @@ pub enum FadePosition { Top, Bottom, None, + Both, } /// 이미지 맞춤 설정 (CSS object-fit과 동일) diff --git a/src/renderer/components/main/Modal/content/NoteSetting.jsx b/src/renderer/components/main/Modal/content/NoteSetting.jsx index bb5f814..47f9f7b 100644 --- a/src/renderer/components/main/Modal/content/NoteSetting.jsx +++ b/src/renderer/components/main/Modal/content/NoteSetting.jsx @@ -94,11 +94,12 @@ export default function NoteSetting({ onClose, settings, onSave }) { const tabContentRef = useRef(null); const [tabContentHeight, setTabContentHeight] = useState(null); const [disableHeightTransition, setDisableHeightTransition] = useState(true); + const [isAnimating, setIsAnimating] = useState(false); const updateTabContentHeight = useCallback(() => { const element = tabContentRef.current; if (!element) return; - const nextHeight = element.scrollHeight; + const nextHeight = element.offsetHeight; setTabContentHeight((prev) => (prev === nextHeight ? prev : nextHeight)); }, []); @@ -106,6 +107,7 @@ export default function NoteSetting({ onClose, settings, onSave }) { { label: t("noteSetting.auto"), value: "auto" }, { label: t("noteSetting.top"), value: "top" }, { label: t("noteSetting.bottom"), value: "bottom" }, + { label: t("noteSetting.both"), value: "both" }, { label: t("noteSetting.none"), value: "none" }, ]; @@ -150,6 +152,20 @@ export default function NoteSetting({ onClose, settings, onSave }) { return () => cancelAnimationFrame(rafId); }, []); + // transitionend 미발화 시 (높이 동일, 트랜지션 비활성 등) 안전 해제 + useEffect(() => { + if (!isAnimating) return; + const timer = setTimeout(() => setIsAnimating(false), 150); + return () => clearTimeout(timer); + }, [isAnimating]); + + // overflow-hidden 제거 후 BFC 변경으로 인한 높이 차이 보정 + useEffect(() => { + if (!isAnimating && !disableHeightTransition) { + requestAnimationFrame(() => updateTabContentHeight()); + } + }, [isAnimating, disableHeightTransition, updateTabContentHeight]); + const handleSave = async () => { const normalized = { ...settings, @@ -331,15 +347,25 @@ export default function NoteSetting({ onClose, settings, onSave }) { className="flex flex-col bg-[#1A191E] rounded-[13px] border-[1px] border-[#2A2A30] p-[20px]" onClick={(e) => e.stopPropagation()} > - + { + if (tab !== activeTab) { + setIsAnimating(true); + setActiveTab(tab); + } + }} />
{ + if (e.propertyName === "height") { + setIsAnimating(false); + } + }} >
{activeTab === NOTE_TAB ? renderNoteTab() : renderAdvancedTab()} diff --git a/src/renderer/components/main/common/Dropdown.tsx b/src/renderer/components/main/common/Dropdown.tsx index 713b6ca..e47cf68 100644 --- a/src/renderer/components/main/common/Dropdown.tsx +++ b/src/renderer/components/main/common/Dropdown.tsx @@ -27,7 +27,6 @@ const Dropdown: React.FC = ({ const [openUpward, setOpenUpward] = useState(false); const ref = useRef(null); const buttonRef = useRef(null); - const menuRef = useRef(null); // 드롭다운 열릴 때 위치 계산 useEffect(() => { @@ -97,9 +96,8 @@ const Dropdown: React.FC = ({ {open && ( -
diff --git a/src/renderer/components/overlay/WebGLTracks.jsx b/src/renderer/components/overlay/WebGLTracks.jsx index 9f1c2a5..2015d93 100644 --- a/src/renderer/components/overlay/WebGLTracks.jsx +++ b/src/renderer/components/overlay/WebGLTracks.jsx @@ -14,6 +14,7 @@ import { SRGBColorSpace, } from "three"; import { animationScheduler } from "../../utils/animationScheduler"; +import { fadePositionToUniform } from "../../../types/noteSettings"; const MAX_NOTES = 2048; // 씬에서 동시에 렌더링할 수 있는 최대 노트 수 @@ -235,16 +236,19 @@ const fragmentShader = ` float trackRelativeY = gradientRatio; float fadePosFlag = uFadePosition; - bool fadeDisabled = fadePosFlag > 2.5; + bool fadeDisabled = abs(fadePosFlag - 3.0) < 0.1; + bool fadeBoth = fadePosFlag > 3.5; bool invertForFade = false; - if (!fadeDisabled && fadePosFlag < 0.5) { - invertForFade = (vReverse > 0.5); - } else if (!fadeDisabled && abs(fadePosFlag - 1.0) < 0.1) { - invertForFade = false; - } else if (!fadeDisabled) { - invertForFade = true; + if (!fadeDisabled && !fadeBoth) { + if (fadePosFlag < 0.5) { + invertForFade = (vReverse > 0.5); + } else if (abs(fadePosFlag - 1.0) < 0.1) { + invertForFade = false; + } else if (abs(fadePosFlag - 2.0) < 0.1) { + invertForFade = true; + } } - if (!fadeDisabled && invertForFade) { + if (!fadeDisabled && !fadeBoth && invertForFade) { trackRelativeY = 1.0 - trackRelativeY; } @@ -267,8 +271,12 @@ const fragmentShader = ` alpha *= smoothAlpha; } - // 트랙 페이드 영역 적용 (상단 또는 하단) - if (!fadeDisabled && trackRelativeY < fadeRatio) { + // 트랙 페이드 영역 적용 + if (fadeBoth) { + float topFade = clamp(trackRelativeY / fadeRatio, 0.0, 1.0); + float bottomFade = clamp((1.0 - trackRelativeY) / fadeRatio, 0.0, 1.0); + alpha *= min(topFade, bottomFade); + } else if (!fadeDisabled && trackRelativeY < fadeRatio) { alpha *= clamp(trackRelativeY / fadeRatio, 0.0, 1.0); } @@ -350,16 +358,8 @@ export const WebGLTracks = memo( uDelayEnabled: { value: noteSettings.delayedNoteEnabled ? 1.0 : 0.0, }, - // fadePosition: 'auto' | 'top' | 'bottom' | 'none' -> 0 | 1 | 2 | 3 uFadePosition: { - value: - noteSettings.fadePosition === "top" - ? 1.0 - : noteSettings.fadePosition === "bottom" - ? 2.0 - : noteSettings.fadePosition === "none" - ? 3.0 - : 0.0, + value: fadePositionToUniform(noteSettings.fadePosition), }, }, vertexShader, @@ -700,13 +700,7 @@ export const WebGLTracks = memo( materialRef.current.uniforms.uDelayEnabled.value = noteSettings.delayedNoteEnabled ? 1.0 : 0.0; materialRef.current.uniforms.uFadePosition.value = - noteSettings.fadePosition === "top" - ? 1.0 - : noteSettings.fadePosition === "bottom" - ? 2.0 - : noteSettings.fadePosition === "none" - ? 3.0 - : 0.0; + fadePositionToUniform(noteSettings.fadePosition); } }, [ noteSettings.speed, diff --git a/src/renderer/components/overlay/WebGLTracksOGL.jsx b/src/renderer/components/overlay/WebGLTracksOGL.jsx index b7f5327..3ad48b3 100644 --- a/src/renderer/components/overlay/WebGLTracksOGL.jsx +++ b/src/renderer/components/overlay/WebGLTracksOGL.jsx @@ -1,6 +1,7 @@ import React, { memo, useEffect, useRef } from "react"; import { Renderer, Camera, Transform, Program, Geometry, Mesh } from "ogl"; import { animationScheduler } from "../../utils/animationScheduler"; +import { fadePositionToUniform } from "../../../types/noteSettings"; import { MAX_NOTES } from "@stores/noteBuffer"; import { isMac } from "@utils/platform"; @@ -167,16 +168,19 @@ const fragmentShader = ` float trackRelativeY = gradientRatio; float fadePosFlag = uFadePosition; - bool fadeDisabled = fadePosFlag > 2.5; + bool fadeDisabled = abs(fadePosFlag - 3.0) < 0.1; + bool fadeBoth = fadePosFlag > 3.5; bool invertForFade = false; - if (!fadeDisabled && fadePosFlag < 0.5) { - invertForFade = (vReverse > 0.5); - } else if (!fadeDisabled && abs(fadePosFlag - 1.0) < 0.1) { - invertForFade = false; - } else if (!fadeDisabled) { - invertForFade = true; + if (!fadeDisabled && !fadeBoth) { + if (fadePosFlag < 0.5) { + invertForFade = (vReverse > 0.5); + } else if (abs(fadePosFlag - 1.0) < 0.1) { + invertForFade = false; + } else if (abs(fadePosFlag - 2.0) < 0.1) { + invertForFade = true; + } } - if (!fadeDisabled && invertForFade) { + if (!fadeDisabled && !fadeBoth && invertForFade) { trackRelativeY = 1.0 - trackRelativeY; } @@ -202,7 +206,11 @@ const fragmentShader = ` } float fadeMask = 1.0; - if (!fadeDisabled && trackRelativeY < fadeRatio) { + if (fadeBoth) { + float topFade = clamp(trackRelativeY / fadeRatio, 0.0, 1.0); + float bottomFade = clamp((1.0 - trackRelativeY) / fadeRatio, 0.0, 1.0); + fadeMask = min(topFade, bottomFade); + } else if (!fadeDisabled && trackRelativeY < fadeRatio) { fadeMask = clamp(trackRelativeY / fadeRatio, 0.0, 1.0); } bodyAlpha *= fadeMask; @@ -435,14 +443,7 @@ export const WebGLTracksOGL = memo( uTrackHeight: { value: noteSettings.trackHeight || 150 }, uReverse: { value: noteSettings.reverse ? 1.0 : 0.0 }, uFadePosition: { - value: - noteSettings.fadePosition === "top" - ? 1.0 - : noteSettings.fadePosition === "bottom" - ? 2.0 - : noteSettings.fadePosition === "none" - ? 3.0 - : 0.0, + value: fadePositionToUniform(noteSettings.fadePosition), }, }, }); @@ -657,13 +658,7 @@ export const WebGLTracksOGL = memo( uniforms.uTrackHeight.value = noteSettings.trackHeight || 150; uniforms.uReverse.value = noteSettings.reverse ? 1.0 : 0.0; uniforms.uFadePosition.value = - noteSettings.fadePosition === "top" - ? 1.0 - : noteSettings.fadePosition === "bottom" - ? 2.0 - : noteSettings.fadePosition === "none" - ? 3.0 - : 0.0; + fadePositionToUniform(noteSettings.fadePosition); }, [noteSettings]); return ( diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index a3b8e7f..454bf21 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -156,6 +156,7 @@ "top": "Top", "bottom": "Bottom", "none": "None", + "both": "Full", "reverseEffect": "Reverse Effect", "save": "Apply", "cancel": "Cancel" diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 18ccd56..6cbe2a9 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -156,6 +156,7 @@ "top": "상단", "bottom": "하단", "none": "없음", + "both": "전체", "reverseEffect": "리버스 효과", "save": "저장", "cancel": "취소" diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index a334aba..785badd 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -156,6 +156,7 @@ "top": "Сверху", "bottom": "Снизу", "none": "Нет", + "both": "Полный", "reverseEffect": "Обратный эффект", "save": "Применить", "cancel": "Отмена" diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 56a802e..744fd98 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -156,6 +156,7 @@ "top": "頂部", "bottom": "底部", "none": "無", + "both": "全部", "reverseEffect": "反向鍵雨", "save": "應用", "cancel": "取消" diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 88f6625..4fdeb48 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -156,6 +156,7 @@ "top": "顶部", "bottom": "底部", "none": "无", + "both": "全部", "reverseEffect": "反向键雨", "save": "应用", "cancel": "取消" diff --git a/src/types/noteSettings.ts b/src/types/noteSettings.ts index 5f57bd3..e5cc503 100644 --- a/src/types/noteSettings.ts +++ b/src/types/noteSettings.ts @@ -6,6 +6,7 @@ export const fadePositionSchema = z.union([ z.literal("top"), z.literal("bottom"), z.literal("none"), + z.literal("both"), ]); export const noteSettingsSchema = z.object({ @@ -58,6 +59,19 @@ export const NOTE_SETTINGS_DEFAULTS: NoteSettings = Object.freeze({ keyDisplayDelayMs: NOTE_SETTINGS_CONSTRAINTS.keyDisplayDelayMs.default, }); +/** fadePosition 문자열을 셰이더 uniform 값으로 변환 */ +const FADE_POSITION_UNIFORM: Record = { + auto: 0.0, + top: 1.0, + bottom: 2.0, + none: 3.0, + both: 4.0, +}; + +export function fadePositionToUniform(pos: string): number { + return FADE_POSITION_UNIFORM[pos] ?? 0.0; +} + export function normalizeNoteSettings(raw: unknown): NoteSettings { const parsed = noteSettingsSchema.safeParse({ ...NOTE_SETTINGS_DEFAULTS,