diff --git a/app/page.tsx b/app/page.tsx index 0de19d0..e7cbd98 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,10 @@ import { import { demoGenome } from "@/lib/mocks/demoGenome"; import type { GenomeData, PrimerDesignResponseUI } from "@/types"; import { useViewStore } from "@/store/useViewStore"; +import { + getInvalidStep1TemplateSequenceChar, + normalizeStep1TemplateSequence, +} from "../src/lib/parsers/step1TemplateSequence"; import Step1TemplateEssential from "@/components/steps/Step1TemplateEssential"; import Step2PrimerProperties from "@/components/steps/Step2PrimerProperties"; import Step3BindingLocation from "@/components/steps/Step3BindingLocation"; @@ -56,13 +60,61 @@ export default function Home() { const [isLoading, setIsLoading] = useState(false); const [apiResult, setApiResult] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [step1WarningMessage, setStep1WarningMessage] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const totalSteps = steps.length; const handleStepChange = (next: number) => { const clamped = Math.min(Math.max(next, 1), totalSteps) as 1 | 2 | 3 | 4; setStep(clamped); }; - const handleNext = () => handleStepChange(step + 1); + const clearStep1Warning = () => { + if (step1WarningMessage) { + setStep1WarningMessage(null); + } + }; + + const reportStep1ValidationMessage = ( + mode: "step-transition" | "generate", + message: string | null, + ) => { + // Step 전환 검증은 Step1 인라인 경고를 사용하고, + // Generate 직전 검증은 하단 error banner를 사용해 중복 노출을 피한다. + if (mode === "step-transition") { + setStep1WarningMessage(message); + return; + } + setErrorMessage(message); + }; + + const validateStep1Sequence = (mode: "step-transition" | "generate") => { + const rawInputSequence = sequenceInputRef.current; + const normalizedSequence = normalizeStep1TemplateSequence(rawInputSequence); + const invalidChar = getInvalidStep1TemplateSequenceChar(rawInputSequence); + if (!invalidChar) { + if (mode === "generate" && rawInputSequence.trim().length > 0 && !normalizedSequence) { + // Generate 직전 검증: 입력이 있었는데 정규화 후 빈 문자열이면 요청 중단. + const warningMessage = + "전송할 수 있는 유효한 염기서열이 없습니다. A, T, G, C 문자만 입력해 주세요."; + reportStep1ValidationMessage(mode, warningMessage); + return { isValid: false, normalizedSequence }; + } + + // 단계 이동 검증: Step1 경고를 초기화하고 다음 동작 진행. + reportStep1ValidationMessage(mode, null); + return { isValid: true, normalizedSequence }; + } + + const warningMessage = `대소문자 구분 없이 A, T, G, C만 입력 가능합니다. 잘못된 문자를 제거해 주세요.`; + reportStep1ValidationMessage(mode, warningMessage); + return { isValid: false, normalizedSequence }; + }; + const handleNext = () => { + if (step === 1 && !validateStep1Sequence("step-transition").isValid) { + return; + } + + handleStepChange(step + 1); + }; const handleBack = () => handleStepChange(step - 1); const isLastStep = step === totalSteps; @@ -74,11 +126,16 @@ export default function Home() { ); const handleGenerate = async () => { - const inputSequence = sequenceInputRef.current; + const validation = validateStep1Sequence("generate"); + if (!validation.isValid) { + return; + } + const targetSeq = - inputSequence && inputSequence.trim().length > 0 - ? inputSequence.trim() + validation.normalizedSequence && validation.normalizedSequence.trim().length > 0 + ? validation.normalizedSequence.trim() : "ATGCGTACGTAGCTAGCTAGCTAGCTAATGCGTACGTAGCTAGCTAGCTAGCTA"; + const payload: AnalyzeRequestInput = { target_sequence: targetSeq, species: "Homo sapiens", @@ -122,7 +179,6 @@ export default function Home() { setIsModalOpen(false); // Surface the error for visibility during development. console.error("Generate Primers failed", error); - alert(message); } finally { setIsLoading(false); } @@ -149,6 +205,8 @@ export default function Home() { {step === 1 && ( )} diff --git a/components/steps/Step1TemplateEssential.tsx b/components/steps/Step1TemplateEssential.tsx index c464be4..8af7381 100644 --- a/components/steps/Step1TemplateEssential.tsx +++ b/components/steps/Step1TemplateEssential.tsx @@ -3,15 +3,22 @@ import { type ClipboardEvent, type ChangeEvent, + type FormEvent, type MutableRefObject, useEffect, useRef, useState, } from "react"; import { SlidersHorizontal } from "lucide-react"; +import { + getInvalidStep1TemplateSequenceChars, + sanitizeStep1TemplateSequenceInput, +} from "../../src/lib/parsers/step1TemplateSequence"; type Step1TemplateEssentialProps = { sequenceRef: MutableRefObject; + validationMessage?: string | null; + onSequenceChange?: (value: string) => void; }; const countAlphabeticChars = (value: string) => { @@ -47,10 +54,16 @@ const getPreviewValue = (value: string) => { export default function Step1TemplateEssential({ sequenceRef, + validationMessage, + onSequenceChange, }: Step1TemplateEssentialProps) { const fileInputRef = useRef(null); const textareaRef = useRef(null); const countTimeoutRef = useRef(null); + const invalidRemovalResolverRef = useRef<((approved: boolean) => void) | null>(null); + const [invalidRemovalDialogMessage, setInvalidRemovalDialogMessage] = useState( + null, + ); const [isLargeSequenceMode, setIsLargeSequenceMode] = useState( sequenceRef.current.length > MAX_EDITOR_CHARS, ); @@ -77,6 +90,7 @@ export default function Step1TemplateEssential({ const updateSequence = (value: string, countMode: "defer" | "immediate" = "defer") => { sequenceRef.current = value; + onSequenceChange?.(value); const nextIsLargeSequence = value.length > MAX_EDITOR_CHARS; setIsLargeSequenceMode(nextIsLargeSequence); @@ -98,27 +112,93 @@ export default function Step1TemplateEssential({ useEffect( () => () => { clearPendingCountTimer(); + if (invalidRemovalResolverRef.current) { + invalidRemovalResolverRef.current(false); + invalidRemovalResolverRef.current = null; + } }, [], ); const focusTextarea = () => textareaRef.current?.focus(); - const appendSequence = (next: string) => { - const sanitized = next.trim(); + const requestInvalidRemovalConsent = (message: string) => + new Promise((resolve) => { + if (invalidRemovalResolverRef.current) { + invalidRemovalResolverRef.current(false); + } + invalidRemovalResolverRef.current = resolve; + setInvalidRemovalDialogMessage(message); + }); + + const resolveInvalidRemovalConsent = (approved: boolean) => { + if (invalidRemovalResolverRef.current) { + invalidRemovalResolverRef.current(approved); + invalidRemovalResolverRef.current = null; + } + setInvalidRemovalDialogMessage(null); + }; + + const confirmInvalidRemoval = async (rawSequence: string) => { + const invalidChars = getInvalidStep1TemplateSequenceChars(rawSequence); + if (invalidChars.length === 0) return true; + + const previewInvalidChars = invalidChars.slice(0, 8).join(", "); + const extraKinds = invalidChars.length > 8 ? ` 외 ${invalidChars.length - 8}종` : ""; + return requestInvalidRemovalConsent( + `붙여넣으려는 데이터에 A, T, G, C 이외 문자가 포함되어 있습니다 (${previewInvalidChars}${extraKinds}). 해당 문자를 제거하고 계속할까요?`, + ); + }; + + const appendSequence = async ( + next: string, + options: { requireInvalidRemovalConsent?: boolean } = {}, + ) => { + if (options.requireInvalidRemovalConsent && !(await confirmInvalidRemoval(next))) { + focusTextarea(); + return; + } - if (!sanitized) { + const sanitizedChunk = sanitizeStep1TemplateSequenceInput(next); + if (!sanitizedChunk) { focusTextarea(); return; } const currentValue = sequenceRef.current; - const appendedValue = currentValue ? `${currentValue}\n${sanitized}` : sanitized; + const appendedValue = `${currentValue}${sanitizedChunk}`; updateSequence(appendedValue, "immediate"); focusTextarea(); }; + const insertChunkAtSelection = ( + textarea: HTMLTextAreaElement, + rawChunk: string, + options: { sanitize: boolean }, + ) => { + const nextChunk = options.sanitize + ? sanitizeStep1TemplateSequenceInput(rawChunk) + : rawChunk; + const currentValue = sequenceRef.current; + const selectionStart = textarea.selectionStart ?? currentValue.length; + const selectionEnd = textarea.selectionEnd ?? currentValue.length; + const nextValue = + currentValue.slice(0, selectionStart) + + nextChunk + + currentValue.slice(selectionEnd); + + if (nextValue !== currentValue) { + updateSequence(nextValue, "immediate"); + } + + if (textareaRef.current && nextValue.length <= MAX_EDITOR_CHARS) { + const cursor = selectionStart + nextChunk.length; + textareaRef.current.selectionStart = cursor; + textareaRef.current.selectionEnd = cursor; + } + }; + const handleUploadClick = () => fileInputRef.current?.click(); const handleFileChange = async (event: ChangeEvent) => { @@ -128,7 +208,7 @@ export default function Step1TemplateEssential({ try { const text = await file.text(); - appendSequence(text); + await appendSequence(text, { requireInvalidRemovalConsent: true }); } catch (error) { console.error("Failed to read FASTA file", error); } finally { @@ -144,43 +224,51 @@ export default function Step1TemplateEssential({ try { const text = await navigator.clipboard.readText(); - appendSequence(text); + await appendSequence(text, { requireInvalidRemovalConsent: true }); } catch (error) { console.error("Failed to read from clipboard", error); } }; - const handleTextareaPaste = (event: ClipboardEvent) => { + const handleTextareaPaste = async (event: ClipboardEvent) => { if (isLargeSequenceMode || event.currentTarget.readOnly) { event.preventDefault(); return; } - event.preventDefault(); - + const textarea = event.currentTarget; const pastedText = event.clipboardData.getData("text"); if (!pastedText) return; + event.preventDefault(); + const shouldSanitize = await confirmInvalidRemoval(pastedText); + insertChunkAtSelection(textarea, pastedText, { sanitize: shouldSanitize }); + focusTextarea(); + }; - const currentValue = sequenceRef.current; - const selectionStart = event.currentTarget.selectionStart ?? currentValue.length; - const selectionEnd = event.currentTarget.selectionEnd ?? currentValue.length; - const nextValue = - currentValue.slice(0, selectionStart) + - pastedText + - currentValue.slice(selectionEnd); + const handleTextareaBeforeInput = (event: FormEvent) => { + if (isLargeSequenceMode || event.currentTarget.readOnly) { + event.preventDefault(); + return; + } - updateSequence(nextValue, "immediate"); + const nativeEvent = event.nativeEvent as InputEvent; + const inputType = nativeEvent.inputType ?? ""; + if (inputType === "insertFromPaste") return; + if (!inputType.startsWith("insert")) return; - if (textareaRef.current && nextValue.length <= MAX_EDITOR_CHARS) { - const cursor = selectionStart + pastedText.length; - textareaRef.current.selectionStart = cursor; - textareaRef.current.selectionEnd = cursor; - } + const rawChunk = nativeEvent.data ?? ""; + if (!rawChunk) return; + + const sanitizedChunk = sanitizeStep1TemplateSequenceInput(rawChunk); + if (sanitizedChunk === rawChunk) return; + + event.preventDefault(); + insertChunkAtSelection(event.currentTarget, rawChunk, { sanitize: true }); }; const handleTextareaChange = (event: ChangeEvent) => { if (isLargeSequenceMode || event.currentTarget.readOnly) return; - updateSequence(event.currentTarget.value); + updateSequence(sanitizeStep1TemplateSequenceInput(event.currentTarget.value)); }; const handleCleanClick = () => { @@ -189,10 +277,11 @@ export default function Step1TemplateEssential({ }; return ( -
-
- Step 1. 템플릿 시퀀스와 기본 설정을 입력하세요. -
+ <> +
+
+ Step 1. 템플릿 시퀀스와 기본 설정을 입력하세요. +
@@ -221,6 +310,7 @@ export default function Step1TemplateEssential({ readOnly={isLargeSequenceMode} defaultValue={getPreviewValue(sequenceRef.current)} onChange={handleTextareaChange} + onBeforeInput={handleTextareaBeforeInput} onPaste={handleTextareaPaste} />
@@ -231,6 +321,14 @@ export default function Step1TemplateEssential({ shows only a preview.

)} +

+ A, T, G, C 이외 문자는 입력 시 자동으로 제거됩니다. +
+ Paste 버튼, Ctrl+V, Upload FASTA에서는 제거 전에 확인을 요청합니다. +

+ {validationMessage && ( +

{validationMessage}

+ )}
-
+
+ {invalidRemovalDialogMessage && ( +
{ + if (event.target === event.currentTarget) { + resolveInvalidRemovalConsent(false); + } + }} + > +
+

문자 제거 확인

+

+ {invalidRemovalDialogMessage} +

+
+ + +
+
+
+ )} + ); } diff --git a/src/lib/parsers/.gitkeep b/src/lib/parsers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/parsers/step1TemplateSequence.ts b/src/lib/parsers/step1TemplateSequence.ts new file mode 100644 index 0000000..8637feb --- /dev/null +++ b/src/lib/parsers/step1TemplateSequence.ts @@ -0,0 +1,32 @@ +const FASTA_HEADER_PREFIX = ">"; +const NON_ATGC_PATTERN = /[^ATGCatgc]/; +const NON_ATGC_GLOBAL_PATTERN = /[^ATGCatgc]/g; + +const stripFastaHeadersAndWhitespace = (rawSequence: string) => + rawSequence + .split(/\r?\n/) + .filter((line) => !line.trim().startsWith(FASTA_HEADER_PREFIX)) + .join("") + .replace(/\s+/g, ""); + +export const sanitizeStep1TemplateSequenceInput = (rawSequence: string) => + stripFastaHeadersAndWhitespace(rawSequence) + .replace(NON_ATGC_GLOBAL_PATTERN, "") + .toUpperCase(); + +export const normalizeStep1TemplateSequence = (rawSequence: string) => + sanitizeStep1TemplateSequenceInput(rawSequence); + +export const getInvalidStep1TemplateSequenceChars = (rawSequence: string) => { + const matches = stripFastaHeadersAndWhitespace(rawSequence).match(NON_ATGC_GLOBAL_PATTERN); + if (!matches) return []; + + const uniqueChars = new Set(); + for (const char of matches) { + uniqueChars.add(char); + } + return [...uniqueChars]; +}; + +export const getInvalidStep1TemplateSequenceChar = (rawSequence: string) => + stripFastaHeadersAndWhitespace(rawSequence).match(NON_ATGC_PATTERN)?.[0] ?? null;