diff --git a/component/common/ICommon.ts b/component/common/ICommon.ts index c98d15b7..c8a78d59 100644 --- a/component/common/ICommon.ts +++ b/component/common/ICommon.ts @@ -3,6 +3,13 @@ export declare namespace ICommon { * 각 Section Payload 의 공통 요소 */ export interface Payload { + /** + * Section ID + * + * @description 목차 이동을 위한 ID 정보 + */ + sectionId: string; + /** * Section Enable Flag * diff --git a/component/common/PreProcessingComponent.tsx b/component/common/PreProcessingComponent.tsx index c39072dc..7896bbde 100644 --- a/component/common/PreProcessingComponent.tsx +++ b/component/common/PreProcessingComponent.tsx @@ -15,5 +15,5 @@ export function PreProcessingComponent({ return <>; } - return component({ payload }); + return
{component({ payload })}
; } diff --git a/component/common/Style.ts b/component/common/Style.ts index 8dc01487..6743bc79 100644 --- a/component/common/Style.ts +++ b/component/common/Style.ts @@ -41,7 +41,7 @@ export const Style: Record = { backgroundColor: '#f5f5f5', paddingLeft: 0, paddingRight: 0, - marginTop: '50px', + marginTop: '100px', height: '80px', }, @@ -53,3 +53,104 @@ export const Style: Record = { fontWeight: 400, }, }; + +type TTocStyleKey = + | 'tocBarContainer' + | 'progressBar' + | 'progressBarActive' + | 'toc' + | 'tocButton' + | 'tocItem' + | 'tocLink' + | 'tocLinkActive' + | 'tocItemActive' + | 'tocItemHover' + | 'tocItemDisabled'; + +export const TocStyle: Record = { + tocBarContainer: { + width: '30px', + position: 'fixed', + right: '10%', + top: '15%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + zIndex: 1000, + }, + + progressBar: { + borderRadius: '5px', + backgroundColor: '#E3E2E0', + height: '5px', + width: '5px', + margin: '10px 0', + transition: 'background 0.2s', + }, + + progressBarActive: { + borderRadius: '5px', + backgroundColor: '#3c78d8', + height: '5px', + width: '5px', + margin: '10px 0', + transition: 'background 0.2s', + }, + + toc: { + position: 'fixed', + right: '10%', + top: '15%', + maxHeight: '400px', + width: '150px', + display: 'flex', + flexDirection: 'column', + backgroundColor: '#f9f9f9', + padding: '5px', + borderRadius: '5px', + boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)', + zIndex: 1000, + opacity: 1, + transition: 'opacity 0.3s ease-in-out', + }, + + tocButton: { + position: 'fixed', + right: '20px', + top: '20%', + width: '30px', + height: '100px', + backgroundColor: '#ccc', + cursor: 'pointer', + }, + + tocItem: { + margin: '5px 0', + cursor: 'pointer', + color: '#333', + }, + + tocLink: { + display: 'block', + color: '#1D1B16', // 링크의 기본 색상을 회색으로 변경 + textDecoration: 'none', // 링크의 밑줄 제거 + }, + + tocLinkActive: { + display: 'block', + color: '#3c78d8', // 활성화된 링크는 파란색으로 + }, + + tocItemActive: { + fontWeight: 'bold', + color: '#3c78d8', // 활성화된 항목의 색상 + }, + + tocItemHover: { + backgroundColor: '#e0e0e0', // 호버 시 배경색 (짙은 회색) + }, + + tocItemDisabled: { + color: '#a0a0a0', // 비활성화된 항목 색상 (회색) + }, +}; diff --git a/component/toc/index.tsx b/component/toc/index.tsx new file mode 100644 index 00000000..55a11392 --- /dev/null +++ b/component/toc/index.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState } from 'react'; +import { TocStyle } from '../common/Style'; +import { sectionIds } from '../../payload'; // Payload import + +interface TableOfContentsProps { + showToc: boolean; // TOC를 표시할지 여부를 props로 제어 +} + +export const TableOfContents = { + Component: ({ showToc }: TableOfContentsProps) => { + const [activeSection, setActiveSection] = useState(null); // 활성화된 섹션 + const [isTocVisible, setIsTocVisible] = useState(false); // TODO) CSSProperties는 hover를 지원하지 않아 의사클래스 지원이 되는 라이브러리 사용시 state제거 필요 + const [hoveredItem, setHoveredItem] = useState(null); // 호버된 항목 + const [, setVisibleSections] = useState([]); // 보이는 섹션 목록 + const [isScreenSmall, setIsScreenSmall] = useState(false); // 작은 화면인지 여부 + const prevScrollTopRef = useRef(0); + const isManualScrollRef = useRef(false); // 수동 스크롤(클릭) 여부 + const manualScrollTimeoutRef = useRef(null); // 수동 스크롤 후 타이머 + + const getTocItemStyle = (id: string) => { + let style = { ...TocStyle.tocItem }; + + if (activeSection === id) { + style = { ...style, ...TocStyle.tocItemActive }; // 활성화된 항목 스타일 + } else if (hoveredItem === id) { + style = { ...style, ...TocStyle.tocItemHover }; // 호버된 항목 스타일 + } + + return style; + }; + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (isManualScrollRef.current) return; // 수동으로 스크롤 중일 때는 자동 스크롤 감지 비활성화 + + setVisibleSections((prevVisibleSections) => { + let updatedVisibleSections = [...prevVisibleSections]; + + entries.forEach((entry) => { + const sectionId = entry.target.id; + if (entry.isIntersecting) { + // 섹션이 화면에 나타났다면 리스트에 추가 + if (!updatedVisibleSections.includes(sectionId)) { + updatedVisibleSections.push(sectionId); + } + } else { + // 섹션이 화면에서 사라졌다면 리스트에서 제거 + updatedVisibleSections = updatedVisibleSections.filter((id) => id !== sectionId); + } + }); + + updatedVisibleSections = updatedVisibleSections.sort((a, b) => { + const elementA = document.getElementById(a); + const elementB = document.getElementById(b); + + if (elementA && elementB) { + return elementA.getBoundingClientRect().top - elementB.getBoundingClientRect().top; + } + + return 0; + }); + + if (updatedVisibleSections.length > 0) { + const currentScrollTop = + document.documentElement.scrollTop || document.body.scrollTop; + + // 스크롤 방향에 따라 활성화할 섹션을 설정 + if (currentScrollTop > prevScrollTopRef.current) { + // 스크롤을 내릴 때: 첫 번째 보이는 섹션을 활성화 + setActiveSection(updatedVisibleSections[updatedVisibleSections.length - 1]); + } else { + // 스크롤을 올릴 때: 마지막 보이는 섹션을 활성화 + setActiveSection(updatedVisibleSections[0]); + } + prevScrollTopRef.current = currentScrollTop; + } + + return updatedVisibleSections; + }); + }, + { + threshold: Array.from({ length: 101 }, (_, i) => i / 100), // 0% ~ 100% 비율을 감지하도록 설정 + }, + ); + + // 각 섹션에 대해 observer 적용 + sectionIds.forEach((id) => { + const sectionElement = document.getElementById(id); + if (sectionElement) { + observer.observe(sectionElement); + } + }); + + // cleanup: 컴포넌트가 언마운트될 때 observer 해제 + return () => { + sectionIds.forEach((id) => { + const sectionElement = document.getElementById(id); + if (sectionElement) { + observer.unobserve(sectionElement); + } + }); + + if (manualScrollTimeoutRef.current) { + clearTimeout(manualScrollTimeoutRef.current); + } + }; + }, [sectionIds]); + + useEffect(() => { + // TODO) 반응형 기준 TOC를 어떻게 처리할까에 대한 고민이 되면 좋을것 같음. + const handleResize = () => { + setIsScreenSmall(window.innerWidth <= 960); // 1920px / 2 + }; + + // 처음 로딩 시 화면 크기 체크 + handleResize(); + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + if (!showToc || isScreenSmall) { + return null; // TOC를 숨김 + } + + const handleClick = (id: string) => { + setActiveSection(id); // 수동으로 활성화된 섹션 설정 + isManualScrollRef.current = true; // 수동 스크롤 상태로 설정 + + // 일정 시간 동안만 수동 스크롤 상태를 유지 + if (manualScrollTimeoutRef.current) { + clearTimeout(manualScrollTimeoutRef.current); + } + manualScrollTimeoutRef.current = setTimeout(() => { + isManualScrollRef.current = false; // 자동 스크롤 감지 다시 활성화 + }, 1000); // 1초 동안 수동 스크롤 상태 유지 + }; + + return ( + <> +
setIsTocVisible(true)}> + {sectionIds.map((id: string) => ( +
+ ))} +
+
setIsTocVisible(false)} + > + +
+ + ); + }, +}; diff --git a/pages/_app.tsx b/pages/_app.tsx index 97bbed86..a4a7d8ba 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,5 +1,6 @@ import 'jquery/dist/jquery.slim'; import 'bootstrap/dist/css/bootstrap.min.css'; +import '../style/global.css'; import { NextComponentType } from 'next'; diff --git a/pages/index.tsx b/pages/index.tsx index 7d577f57..41703492 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -3,6 +3,7 @@ import { Container } from 'reactstrap'; import Head from 'next/head'; import { NextSeo } from 'next-seo'; +import { useLayoutEffect } from 'react'; import { Education } from '../component/education'; import { Etc } from '../component/etc'; import { Experience } from '../component/experience'; @@ -14,10 +15,17 @@ import { Profile } from '../component/profile'; import { Project } from '../component/project'; import { Skill } from '../component/skill'; import { Style } from '../component/common/Style'; -import Payload from '../payload'; +import { Payload } from '../payload'; import { Article } from '../component/article'; +import { TableOfContents } from '../component/toc'; function Yosume() { + useLayoutEffect(() => { + if (window.location.hash) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + } + }, []); + return ( <> @@ -38,6 +46,7 @@ function Yosume() { + ); } diff --git a/payload/article.ts b/payload/article.ts index 6d6b5758..01e9d642 100644 --- a/payload/article.ts +++ b/payload/article.ts @@ -1,6 +1,7 @@ import { IArticle } from '../component/article/IArticle'; const article: IArticle.Payload = { + sectionId: 'article', disable: false, list: [ diff --git a/payload/education.ts b/payload/education.ts index 78c9da4e..3d32c42e 100644 --- a/payload/education.ts +++ b/payload/education.ts @@ -1,6 +1,7 @@ import { IEducation } from '../component/education/IEducation'; const education: IEducation.Payload = { + sectionId: 'education', disable: false, list: [ diff --git a/payload/etc.ts b/payload/etc.ts index 39ba64fe..846654c5 100644 --- a/payload/etc.ts +++ b/payload/etc.ts @@ -1,6 +1,7 @@ import { IEtc } from '../component/etc/IEtc'; const etc: IEtc.Payload = { + sectionId: 'etc', disable: false, list: [ diff --git a/payload/experience.ts b/payload/experience.ts index 9323490f..d0a4aae6 100644 --- a/payload/experience.ts +++ b/payload/experience.ts @@ -1,6 +1,7 @@ import { IExperience } from '../component/experience/IExperience'; const experience: IExperience.Payload = { + sectionId: 'experience', disable: false, disableTotalPeriod: false, list: [ diff --git a/payload/index.ts b/payload/index.ts index e9086c40..72249563 100644 --- a/payload/index.ts +++ b/payload/index.ts @@ -25,7 +25,7 @@ import { IFooter } from '../component/footer/IFooter'; import { IGlobal } from '../component/common/IGlobal'; import { IArticle } from '../component/article/IArticle'; -const Payload: Payload = { +export const Payload: Payload = { profile, introduce, skill, @@ -41,7 +41,7 @@ const Payload: Payload = { _global, }; -interface Payload { +export interface Payload { profile: IProfile.Payload; introduce: IIntroduce.Payload; skill: ISkill.Payload; @@ -57,4 +57,15 @@ interface Payload { _global: IGlobal.Payload; } -export default Payload; +export const sectionIds = [ + !Payload.profile.disable && Payload.profile.sectionId, + !Payload.introduce.disable && Payload.introduce.sectionId, + !Payload.skill.disable && Payload.skill.sectionId, + !Payload.experience.disable && Payload.experience.sectionId, + !Payload.project.disable && Payload.project.sectionId, + !Payload.openSource.disable && Payload.openSource.sectionId, + !Payload.presentation.disable && Payload.presentation.sectionId, + !Payload.article.disable && Payload.article.sectionId, + !Payload.education.disable && Payload.education.sectionId, + !Payload.etc.disable && Payload.etc.sectionId, +].filter(Boolean) as string[]; diff --git a/payload/introduce.ts b/payload/introduce.ts index 50001507..3aa1a9e1 100644 --- a/payload/introduce.ts +++ b/payload/introduce.ts @@ -2,6 +2,7 @@ import { IIntroduce } from '../component/introduce/IIntroduce'; import { lastestUpdatedAt } from '../package.json'; const introduce: IIntroduce.Payload = { + sectionId: 'introduce', disable: false, contents: [ diff --git a/payload/openSource.ts b/payload/openSource.ts index f8a44e13..574c494a 100644 --- a/payload/openSource.ts +++ b/payload/openSource.ts @@ -1,6 +1,7 @@ import { IOpenSource } from '../component/openSource/IOpenSource'; const openSource: IOpenSource.Payload = { + sectionId: 'openSource', disable: false, list: [ { diff --git a/payload/presentation.ts b/payload/presentation.ts index d8a8e801..8fba129a 100644 --- a/payload/presentation.ts +++ b/payload/presentation.ts @@ -1,6 +1,7 @@ import { IPresentation } from '../component/presentation/IPresentation'; const presentation: IPresentation.Payload = { + sectionId: 'presentation', disable: false, list: [ diff --git a/payload/profile.ts b/payload/profile.ts index 65ce93cf..3a007821 100644 --- a/payload/profile.ts +++ b/payload/profile.ts @@ -6,6 +6,7 @@ import { IProfile } from '../component/profile/IProfile'; import image from '../asset/sample_tux.png'; const profile: IProfile.Payload = { + sectionId: 'profile', disable: false, // image: 'https://resume.yowu.dev/static/image/profile_2019.png', diff --git a/payload/project.ts b/payload/project.ts index 5dcc88f5..29611a00 100644 --- a/payload/project.ts +++ b/payload/project.ts @@ -1,6 +1,7 @@ import { IProject } from '../component/project/IProject'; const project: IProject.Payload = { + sectionId: 'project', disable: false, list: [ { diff --git a/payload/skill.ts b/payload/skill.ts index 24d36120..c99fccca 100644 --- a/payload/skill.ts +++ b/payload/skill.ts @@ -163,6 +163,7 @@ const misc: ISkill.Skill = { }; const skill: ISkill.Payload = { + sectionId: 'skill', disable: false, skills: [ programmingLanguages, diff --git a/style/global.css b/style/global.css new file mode 100644 index 00000000..98c5b656 --- /dev/null +++ b/style/global.css @@ -0,0 +1,3 @@ +html { + scroll-behavior: smooth; +} \ No newline at end of file