"use client"; import { Box, Text, useInput } from "ink"; import { useEffect, useState } from "react"; import { useStdoutDimensions } from "../useStdoutDimensions"; interface MultiScrollListProps { logList: { title: string; logs: { type: string; content: string; }[]; color: string; }[]; maxLength?: number; } /** * @param logList 로그 목록 * @param maxLength 최대 로그 길이 (기본 100) */ const HEADER_HEIGHT = 1; const FOOTER_HEIGHT = 1; const OUTER_BORDER_HEIGHT = 2; const BORDER_HEIGHT = 2; const ANSI_ESCAPE_RE = new RegExp(`${String.fromCharCode(27)}(?:[@-Z\\\\-_]|\\[[\\s\\S]*?[@-~])`, "g"); export const MultiScrollList = ({ logList, maxLength = 100 }: MultiScrollListProps) => { // 창 너비, 높이 const [width, height] = useStdoutDimensions(); // 탭별 로그 렌더링 const [focusLog, setFocusLog] = useState<{ type: string; content: string }[]>([]); // 탭별 로그 길이 저장 (스크롤시 로그 길이 변화로 위치 조정) const [lengthMap, setLengthMap] = useState>(new Map()); // 스크롤 위치 const [scrollPos, setScrollPos] = useState(0); // 현재 포커싱탭 인덱스 const [tabIndex, setTabIndex] = useState(0); // 스크롤 실행 여부 const [isRunning, setIsRunning] = useState(false); // 박스 높이 (보더 사이즈 2, 헤더 사이즈 1, 푸터 사이즈 5 기본 1) const [boxHeight, setBoxHeight] = useState( height - HEADER_HEIGHT - OUTER_BORDER_HEIGHT - FOOTER_HEIGHT - BORDER_HEIGHT, ); const boxWidth = width - 27; // maxLength에 따라 로그 배열을 제한하는 유틸리티 함수 const getLimitedLogs = (logs: { type: string; content: string }[]) => { const sortedLogs = logs.reduce<{ type: string; content: string }[]>((acc, log) => { // log.content.length가 boxWidth보다 큰 경우 잘라서 줄 수 만큼 추가 // ANSI 코드 보관 const content = log.content.replace(ANSI_ESCAPE_RE, ""); if (content.length > boxWidth) { const lines = Math.ceil(content.length / boxWidth); for (let i = 0; i < lines; i++) { acc.push({ type: log.type, content: content.slice(i * boxWidth, (i + 1) * boxWidth) }); } } else { acc.push(log); } return acc; }, []); return sortedLogs.length > maxLength ? sortedLogs.slice(sortedLogs.length - maxLength) : sortedLogs; }; // 입력 이벤트 처리 useInput((input, key) => { // 탭 키 이벤트 처리(포커싱 탭 변경) if (key.tab) { setTabIndex((prev: number) => (prev + 1) % logList.length); setScrollPos(0); setIsRunning(false); } // 스크롤 중지 이벤트 처리 (포커싱까지 완전히 종료) if (key.escape) { setScrollPos(0); setIsRunning(false); } // 스크롤 중지 이벤트 처리 (포커싱 유지) if (input === " ") { setScrollPos(0); setIsRunning(false); } // 스크롤 다운 이벤트 처리 if (key.downArrow && scrollPos > 0) { if (key.shift) { const newScrollPos = scrollPos - 10; // 스크롤 최소 위치에 도달 경우에 대한 예외 처리 if (newScrollPos < 0) { setScrollPos(0); } else { setScrollPos(newScrollPos); } } else { const newScrollPos = scrollPos - 1; setScrollPos(newScrollPos); } } // 스크롤 업 이벤트 처리 if (key.upArrow && scrollPos < logList[tabIndex].logs.length - boxHeight) { const limitedLogs = getLimitedLogs(logList[tabIndex].logs); if (scrollPos < limitedLogs.length - boxHeight) { // 스크롤 업 이벤트 처리 (스크롤 속도 증가) if (key.shift) { const newScrollPos = scrollPos + 10; // 스크롤 최대 위치에 도달 경우에 대한 예외 처리 if (newScrollPos > limitedLogs.length - boxHeight) { setScrollPos(limitedLogs.length - boxHeight); } else { setScrollPos(newScrollPos); } } else { setScrollPos(scrollPos + 1); } if (!isRunning) setIsRunning(true); } } }); useEffect(() => { // 공통 로직을 함수로 추출 const getLogsToRender = (logs: { type: string; content: string }[], index: number) => { // maxLength에 따라 로그 제한 const limitedLogs = getLimitedLogs(logs); // 로그 중 boxWidth보다 긴 것이 있는 경우 잘라서 출력 // 활성 탭이고 스크롤 중인 특별한 경우 if (scrollPos > 0 && tabIndex === index) { return limitedLogs.slice(limitedLogs.length - boxHeight - scrollPos, limitedLogs.length - scrollPos); } // 로그가 표시 영역보다 큰 경우 (공통 로직) else if (limitedLogs.length > boxHeight) { return limitedLogs.slice(limitedLogs.length - boxHeight, limitedLogs.length); } // 로그가 표시 영역에 모두 들어가는 경우 (공통 로직) else { return limitedLogs; } }; // isRunning일 때 로직 if (isRunning) { // 선택된 탭의 로그 길이 변화 확인 및 스크롤 포지션 업데이트 if (lengthMap.has(tabIndex)) { const tabLength = lengthMap.get(tabIndex); const limitedLogsLength = Math.min(logList[tabIndex].logs.length, maxLength); if (tabLength && tabLength < limitedLogsLength) { setScrollPos(scrollPos + 1); lengthMap.set(tabIndex, limitedLogsLength); } } // 스크롤 위치에 따른 로그 렌더링 업데이트 setFocusLog(getLogsToRender(logList[tabIndex].logs, tabIndex)); } // isRunning이 아닐 때 로직 else { // lengthMap 업데이트 및 초기 렌더링 setFocusLog(getLogsToRender(logList[tabIndex].logs, tabIndex)); } }, [logList, isRunning, scrollPos, tabIndex, boxHeight, maxLength]); useEffect(() => { setBoxHeight(height - HEADER_HEIGHT - OUTER_BORDER_HEIGHT - FOOTER_HEIGHT - BORDER_HEIGHT); }, [height]); // 초기 로그 사이즈 설정 useEffect(() => { setLengthMap(new Map(logList.map((log, index) => [index, Math.min(log.logs.length, maxLength)]))); }, [logList, maxLength]); return ( List {tabIndex + 1}/{logList.length} {logList.map((log, index) => { return (   {log.title.length > 25 ? `${log.title.slice(0, 25)}...` : log.title} ); })} {logList[tabIndex].title} {logList[tabIndex].logs.length} {scrollPos > 0 ? ( <> {focusLog.slice(0, focusLog.length - 1).map((log, index) => { return ( {log.content} ); })} Scrolling... +{scrollPos} ) : ( focusLog.map((log, index) => { return ( {log.content} ); }) )} {/* {renderMultiLogs[tabIndex][0]} */} Tab : switch tab | Up / Down(shift : x10) : scroll | Space : back last position | Esc : stop scrolling ); };