홈페이지에서 포스트 페이지 링크 연결하기
<nav class="navbar">
<ul>
<li><a href="#">
<span class="material-icons">
notifications
</span>
</a></li>
<li><a href="#">
<div class="account">
<img src="../imgs/avatar.jpg" alt="">
</div>
</a></li>
<li><a href="post.html"><button>글쓰기</button></a></li>
</ul>
</nav>
home.html 파일의 해당 코드 부분에 글쓰기 버튼을 추가한다. 그리고 글쓰기 버튼 클릭시 포스트 페이지로 이동하도록 링크를 걸어준다.
header .navbar ul li a button{
width: 5rem;
height: 2rem;
background: var(--primary-color);
color: #fff;
border: none;
border-radius: 2rem;
cursor: pointer;
font-size: .9rem;
transition: .2s;
}
header .navbar ul li a button:hover{
background: var(--secondary-color);
letter-spacing: .1rem;
}
home.css 파일에 버튼에 대한 스타일 코드를 추가한다.
포스트 페이지 뼈대코드 구성하기
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sunrise 블로그 - 포스트</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet">
<link rel="stylesheet" href="../css/post.css">
</head>
<body>
<script src="../js/scroll.js"></script>
<script src="../js/post.js"></script>
</body>
</html>
html 폴더에 post.html 파일을 생성하고 위와 같이 작성한다. 폰트아우썸 아이콘과 구글 머터리얼 아이콘을 함께 사용한다.
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet">
구글 머터리얼 아이콘은 CDN 형태로 위와 같이 추가하면 사용이 가능하다.
헤더 디자인하기
<!-- 헤더 -->
<header>
<a href="#" class="logo"><i class="fa-brands fa-blogger"></i>Sunblog</a>
<div class="menu">
<nav class="navbar">
<ul>
<li><a href="#">
<span class="material-icons">
notifications
</span>
</a></li>
<li><a href="#">
<div class="account">
<img src="../imgs/avatar.jpg" alt="">
</div>
</a></li>
</ul>
</nav>
<div class="mode">
<i class="fa-solid fa-toggle-on"></i>
<i class="fa-solid fa-toggle-off active"></i>
</div>
</div>
</header>
헤더는 홈페이지와 유사하므로 일단 home.html 파일에서 복사해서 post.html 파일로 붙여넣기 한다.
@import url('reset.css');
@import url('global.css');
@import url('header.css');
@import url('footer.css');
/* 헤더 */
header .navbar ul li .material-icons{ /* 종모양 스타일링 추가 */
font-size: 2rem;
}
header .navbar ul li .account img{ /* 아바타 스타일링 추가 */
width: 2rem;
height: 2rem;
border-radius: 50%;
}
css 폴더에 post.css 파일을 생성하고 위와 같이 작성한다. 미리 만들어둔 헤더/푸터 스타일을 재사용한다. 종모양과 아바타 아이콘 스타일도 추가한다. 헤더 스타일은 재사용하기 때문에 코드량이 많이 줄어든 모습이다.
이제 블로그 글을 작성하기 위한 툴박스를 헤더 중앙에 만들어보자.
<header>
<a href="#" class="logo"><i class="fa-brands fa-blogger"></i>Sunblog</a>
<!-- 툴박스 -->
<div class="toolbox">
<div class="file-tool"></div>
<div class="text-tool"></div>
<div class="align-tool"></div>
<div class="link-tool"></div>
</div>
<!-- 코드 생략 -->
</header>
post.html 파일에 위와 같이 툴박스 엘리먼트의 위치를 잡아놓는다.
<div class="file-tool">
<a href="#"><span class="material-icons">crop_original</span></a>
<a href="#"><span class="material-icons">folder_open</span></a>
<a href="#"><span class="material-icons">slideshow</span></a>
</div>
편집기에서 파일에 관련된 툴박스이다. 구글 머터리얼 아이콘에서 해당 아이콘을 찾아서 복사붙여넣기 한다.
<div class="text-tool">
<a href="#"><span class="material-icons">format_bold</span></a>
<a href="#"><span class="material-icons">format_italic</span></a>
<a href="#"><span class="material-icons">format_underlined</span></a>
<a href="#"><span class="material-icons">format_strikethrough</span></a>
<a href="#"><span class="material-icons">format_color_text</span></a>
<a href=""><span class="material-icons">format_color_fill</span></a>
<a href="#"><span class="material-icons">format_size</span></a>
</div>
편집기에서 텍스트 포맷에 관련된 툴박스이다. 구글 머터리얼 아이콘에서 해당 아이콘을 찾아서 복사붙여넣기 한다.
<div class="align-tool">
<a href="#"><span class="material-icons">format_align_left</span></a>
<a href="#"><span class="material-icons">format_align_center</span></a>
<a href="#"><span class="material-icons">format_align_right</span></a>
<a href="#"><span class="material-icons">format_align_justify</span></a>
</div>
편집기에서 텍스트 포맷에 관련된 툴박스이다. 구글 머터리얼 아이콘에서 해당 아이콘을 찾아서 복사붙여넣기 한다.
<div class="link-tool">
<a href="#"><span class="material-icons">sentiment_satisfied</span></a>
<a href="#"><span class="material-icons">table_view</span></a>
<a href="#"><span class="material-icons">link</span></a>
<a href="#"><span class="material-icons">format_list_bulleted</span></a>
</div>
편집기에서 부가기능과 관련된 툴박스이다. 구글 머터리얼 아이콘에서 해당 아이콘을 찾아서 복사붙여넣기 한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sunrise 블로그 - 포스트</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet">
<link rel="stylesheet" href="../css/post.css">
</head>
<body>
<!-- 헤더 -->
<header>
<a href="#" class="logo"><i class="fa-brands fa-blogger"></i>Sunblog</a>
<!-- 툴박스 -->
<div class="toolbox">
<div class="file-tool">
<a href="#"><label for="post-files"><span class="material-icons">crop_original</span></label></a>
<a href="#"><label for="post-files"><span class="material-icons">folder_open</span></label></a>
<a href="#"><label for="post-files"><span class="material-icons">slideshow</span></label></a>
</div>
<div class="text-tool">
<a href="#"><span class="material-icons">format_bold</span></a>
<a href="#"><span class="material-icons">format_italic</span></a>
<a href="#"><span class="material-icons">format_underlined</span></a>
<a href="#"><span class="material-icons">format_strikethrough</span></a>
<a href="#"><span class="material-icons">format_color_text</span></a>
<a href=""><span class="material-icons">format_color_fill</span></a>
<a href="#"><span class="material-icons">format_size</span></a>
</div>
<div class="align-tool">
<a href="#"><span class="material-icons">format_align_left</span></a>
<a href="#"><span class="material-icons">format_align_center</span></a>
<a href="#"><span class="material-icons">format_align_right</span></a>
<a href="#"><span class="material-icons">format_align_justify</span></a>
</div>
<div class="link-tool">
<a href="#"><span class="material-icons">sentiment_satisfied</span></a>
<a href="#"><span class="material-icons">table_view</span></a>
<a href="#"><span class="material-icons">link</span></a>
<a href="#"><span class="material-icons">format_list_bulleted</span></a>
</div>
</div>
<div class="menu">
<nav class="navbar">
<ul>
<li><a href="#">
<span class="material-icons">
notifications
</span>
</a></li>
<li><a href="#">
<div class="account">
<img src="../imgs/avatar.jpg" alt="">
</div>
</a></li>
</ul>
</nav>
<div class="mode">
<i class="fa-solid fa-toggle-on"></i>
<i class="fa-solid fa-toggle-off active"></i>
</div>
</div>
</header>
<script src="../js/scroll.js"></script>
<script src="../js/post.js"></script>
</body>
</html>
현재까지의 post.html 파일은 위와 같다.
/* 툴박스 */
header .toolbox{
display: flex;
flex-wrap: wrap;
}
header .toolbox > div{
border-right: 1px solid #eee;
padding: 0 .5rem;
}
header .toolbox > div > a{
margin: 0 .2rem;
transition: .3s;
}
header .toolbox > div > a:hover{
color: var(--primary-color);
}
header .toolbox > div> a label{
cursor: pointer;
}
post.css 파일에 해당 코드를 작성한다. 툴박스에 관련된 스타일 코드이다. 툴박스 아이템을 수평으로 나열하고, 각각의 툴박스 모음 우측에는 보더를 적용해서 구분한다.
@import url('reset.css');
@import url('global.css');
@import url('header.css');
@import url('footer.css');
/* 헤더 */
header .navbar ul li .material-icons{ /* 종모양 스타일링 추가 */
font-size: 2rem;
}
header .navbar ul li .account img{ /* 아바타 스타일링 추가 */
width: 2rem;
height: 2rem;
border-radius: 50%;
}
/* 툴박스 */
header .toolbox{
display: flex;
flex-wrap: wrap;
}
header .toolbox > div{
border-right: 1px solid #eee;
padding: 0 .5rem;
}
header .toolbox > div > a{
margin: 0 .2rem;
transition: .3s;
}
header .toolbox > div > a:hover{
color: var(--primary-color);
}
header .toolbox > div> a label{
cursor: pointer;
}
현재까지의 post.css 파일은 위와 같다.
블로그 편집기 디자인하기
<!-- 블로그 편집기 -->
<section class="post-container">
<div class="post-category"></div>
<div class="post-title"></div>
<div class="post-contents" contentEditable></div>
<div class="post-tags"></div>
</section>
post.html 파일에 위 코드를 추가한다. 편집기에 대한 기본적인 뼈대 코드를 만들어준다.
/* 블로그 편집기 */
.post-container{
width: 50vw;
margin: 0 auto;
min-height: calc(100vh - 3 * var(--header-height));
display: flex;
flex-flow: column;
justify-content: flex-start;
}
post.css 파일에 위 코드를 추가한다. 편집기의 구성요소는 세로방향으로 나열하고 min-height 을 설정해서 편집기의 기본적인 크기를 설정한다. 편집기의 최소높이는 브라우저 전체높이에서 헤더높이의 3배를 제외한 크기로 적용한다. 만약 사용자가 최소높이보다 더 많은 텍스트를 입력하면 편집기 높이는 아래방향으로 증가한다.
이제 포스팅할 블로그의 카테고리를 선택하기 위한 드롭다운 메뉴를 만들어보자!
<div class="post-category">
<select name="category" id="category">
<option value="">카테고리</option>
<option value="맛집">맛집</option>
<option value="라이프">라이프</option>
<option value="IT">IT</option>
<option value="연애">연애</option>
<option value="시사">시사</option>
</select>
</div>
post.html 파일에 위 코드를 추가한다. select, option 태그를 사용하여 손쉽게 드롭다운 메뉴를 만들었다.
/* 카테고리 */
.post-container .post-category{
height: 2rem;
margin-top: 3rem;
}
.post-container .post-category select{
width: 10rem;
height: 100%;
padding-left: .5rem;
}
post.css 파일에 위 코드를 추가한다. 카테고리 선택을 위한 드롭다운 메뉴의 스타일을 적용한다.
이제 포스팅할 블로그의 제목을 작성하기 위한 입력창을 만들어보자!
<div class="post-title">
<input type="text" placeholder="제목을 입력하세요">
</div>
post.html 파일에 위 코드를 추가한다. 블로그 제목을 입력하기 위한 입력창을 추가한다.
/* 제목 */
.post-container .post-title{
height: 4rem;
margin: 1rem 0;
padding: .7rem 0;
border-bottom: 1px solid #eee;
}
.post-container .post-title input{
outline: none;
border: none;
height: 100%;
font-size: 2rem;
}
post.css 파일에 위 코드를 추가한다. 블로그 제목을 입력하기 위한 입력창에 대한 스타일을 추가한다. 입력창 하단에 보더를 추가한다.
이제 실제 블로그 내용을 작성할 컨텐츠 영역을 추가해보자!
https://developer.mozilla.org/ko/docs/Web/API/HTMLElement/contentEditable
<div class="post-contents" contentEditable>
</div>
contentEditable 속성은 편집이 가능한 요소이다.
/* 컨텐츠 영역 */
.post-container .post-contents{
flex-grow: 1;
border: none;
outline: none;
font-size: 1rem;
}
post.css 파일에 위 코드를 추가한다. 편집기의 폰트 크기는 1rem 으로 하고, flex-grow: 1을 설정하여 카테고리/제목/태그 영역을 제외한 나머지 공간을 컨텐츠 영역으로 설정한다.
.post-container img,
.post-contents .video-file { /* 이미지 & 비디오 파일 */
max-width: 100%;
}
.post-contents .audio-file{ /* 오디오 파일 */
outline: none;
}
post.css 파일에 위 코드를 추가한다. 해당 코드는 사용자가 파일을 업로드한 경우 자바스크립트를 이용하여 동적으로 생성된 파일 요소에 대한 스타일이다. 이미지/비디오 파일은 편집기의 너비를 벗어나지 않도록 너비를 제한한다. 오디오 파일은 클릭시 보여지는 아웃라인을 제거한다.
.post-contents .normal-file{ /* 일반 파일 */
display: flex;
align-items: center;
padding: 1rem 1rem;
box-shadow: 0 0 .2rem rgba(0, 0, 0, .3);
}
.post-contents .normal-file .file-icon{
padding-right: 0.5rem;
}.post-contents .normal-file .file-icon span{
font-size: 2.5rem;
}
.post-contents .normal-file .file-info h3{
margin: .3rem 0;
}
.post-contents .normal-file .file-info p{
margin: .3rem 0;
color: #aaa;
}
post.css 파일에 위 코드를 추가한다. 해당 코드는 사용자가 파일을 업로드한 경우 자바스크립트를 이용하여 동적으로 생성된 파일 요소에 대한 스타일이다. 이미지/비디오/오디오를 제외한 일반적인 파일 요소는 브라우저에서 디폴트 스타일을 제공하지 않기 때문에 아이콘과 태그를 이용하여 직접 스타일링한다. 아이콘과 파일명을 보여주기 위한 텍스트를 디자인한다.
이제 포스팅할 블로그의 태그를 작성하기 위한 입력창을 추가해보자!
<div class="post-tags">
<ul>
<li>#<input type="text" placeholder="태그입력"></li>
</ul>
</div>
post.html 파일에 위 코드를 추가한다. 블로그를 분류하기 위한 태그를 추가하는 입력창이다.
/* 태그 */
.post-container .post-tags ul{
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
}
.post-container .post-tags ul li:nth-child(1){
color: #aaa;
}
.post-container .post-tags ul li{
margin-right: 1rem;
}
.post-container .post-tags ul input{
width: 10rem;
border: none;
outline: none;
}
.post-container .post-tags ul li a{
color: #aaa;
font-size: 1.2rem;
font-weight: bold;
margin-left: .2rem;
transition: .3s;
}
.post-container .post-tags ul li a:hover{
color: var(--primary-color);
}
post.css 파일에 위 코드를 추가한다. 태그 목록은 수평으로 나열하고 좌측으로 정렬한다. a 태그는 자바스크립트를 이용하여 동적으로 추가된 태그를 삭제하기 위한 취소(X) 버튼이다.
파일 업로드 섹션 디자인하기
<!-- 파일처리 섹션 -->
<section class="upload hidden">
<input type="file" id="post-files" name="post-files" multiple>
</section>
post.html 파일에 위 코드를 추가한다. 다수의 파일을 업로드할 수 있도록 multiple 속성을 지정한다.
/* 파일처리 섹션 */
.hidden{
display: none;
}
post.css 파일에 위 코드를 추가한다. 브라우저에서 기본적으로 제공하는 파일 입력창은 스타일이 좋지 않으므로 가린다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sunrise 블로그 - 포스트</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link href="https://fonts.googleapis.com/css?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Two+Tone|Material+Icons+Round|Material+Icons+Sharp" rel="stylesheet">
<link rel="stylesheet" href="../css/post.css">
</head>
<body>
<!-- 헤더 -->
<header>
<a href="#" class="logo"><i class="fa-brands fa-blogger"></i>Sunblog</a>
<!-- 툴박스 -->
<div class="toolbox">
<div class="file-tool">
<a href="#"><label for="post-files"><span class="material-icons">crop_original</span></label></a>
<a href="#"><label for="post-files"><span class="material-icons">folder_open</span></label></a>
<a href="#"><label for="post-files"><span class="material-icons">slideshow</span></label></a>
</div>
<div class="text-tool">
<a href="#"><span class="material-icons">format_bold</span></a>
<a href="#"><span class="material-icons">format_italic</span></a>
<a href="#"><span class="material-icons">format_underlined</span></a>
<a href="#"><span class="material-icons">format_strikethrough</span></a>
<a href="#"><span class="material-icons">format_color_text</span></a>
<a href=""><span class="material-icons">format_color_fill</span></a>
<a href="#"><span class="material-icons">format_size</span></a>
</div>
<div class="align-tool">
<a href="#"><span class="material-icons">format_align_left</span></a>
<a href="#"><span class="material-icons">format_align_center</span></a>
<a href="#"><span class="material-icons">format_align_right</span></a>
<a href="#"><span class="material-icons">format_align_justify</span></a>
</div>
<div class="link-tool">
<a href="#"><span class="material-icons">sentiment_satisfied</span></a>
<a href="#"><span class="material-icons">table_view</span></a>
<a href="#"><span class="material-icons">link</span></a>
<a href="#"><span class="material-icons">format_list_bulleted</span></a>
</div>
</div>
<div class="menu">
<nav class="navbar">
<ul>
<li><a href="#">
<span class="material-icons">
notifications
</span>
</a></li>
<li><a href="#">
<div class="account">
<img src="../imgs/avatar.jpg" alt="">
</div>
</a></li>
</ul>
</nav>
<div class="mode">
<i class="fa-solid fa-toggle-on"></i>
<i class="fa-solid fa-toggle-off active"></i>
</div>
</div>
</header>
<!-- 블로그 편집기 -->
<section class="post-container">
<div class="post-category">
<select name="category" id="category">
<option value="">카테고리</option>
<option value="맛집">맛집</option>
<option value="라이프">라이프</option>
<option value="IT">IT</option>
<option value="연애">연애</option>
<option value="시사">시사</option>
</select>
</div>
<div class="post-title">
<input type="text" placeholder="제목을 입력하세요">
</div>
<div class="post-contents" contentEditable>
</div>
<div class="post-tags">
<ul>
<li>#<input type="text" placeholder="태그입력"></li>
</ul>
</div>
</section>
<!-- 파일처리 섹션 -->
<section class="upload hidden">
<input type="file" id="post-files" name="post-files" multiple>
</section>
<script src="../js/scroll.js"></script>
<script src="../js/post.js"></script>
</body>
</html>
완성된 post.html 파일은 위와 같다.
@import url('reset.css');
@import url('global.css');
@import url('header.css');
@import url('footer.css');
/* 헤더 */
header .navbar ul li .material-icons{ /* 종모양 스타일링 추가 */
font-size: 2rem;
}
header .navbar ul li .account img{ /* 아바타 스타일링 추가 */
width: 2rem;
height: 2rem;
border-radius: 50%;
}
/* 툴박스 */
header .toolbox{
display: flex;
flex-wrap: wrap;
}
header .toolbox > div{
border-right: 1px solid #eee;
padding: 0 .5rem;
}
header .toolbox > div > a{
margin: 0 .2rem;
transition: .3s;
}
header .toolbox > div > a:hover{
color: var(--primary-color);
}
header .toolbox > div> a label{
cursor: pointer;
}
/* 블로그 편집기 */
.post-container{
width: 50vw;
margin: 0 auto;
min-height: calc(100vh - 3 * var(--header-height));
display: flex;
flex-flow: column;
justify-content: flex-start;
}
/* 카테고리 */
.post-container .post-category{
height: 2rem;
margin-top: 3rem;
}
.post-container .post-category select{
width: 10rem;
height: 100%;
padding-left: .5rem;
}
/* 제목 */
.post-container .post-title{
height: 4rem;
margin: 1rem 0;
padding: .7rem 0;
border-bottom: 1px solid #eee;
}
.post-container .post-title input{
outline: none;
border: none;
height: 100%;
font-size: 2rem;
}
/* 컨텐츠 영역 */
.post-container .post-contents{
flex-grow: 1;
border: none;
outline: none;
font-size: 1rem;
}
.post-container img,
.post-contents .video-file { /* 이미지 & 비디오 파일 */
max-width: 100%;
}
.post-contents .audio-file{ /* 오디오 파일 */
outline: none;
}
.post-contents .normal-file{ /* 일반 파일 */
display: flex;
align-items: center;
padding: 1rem 1rem;
box-shadow: 0 0 .2rem rgba(0, 0, 0, .3);
}
.post-contents .normal-file .file-icon{
padding-right: 0.5rem;
}.post-contents .normal-file .file-icon span{
font-size: 2.5rem;
}
.post-contents .normal-file .file-info h3{
margin: .3rem 0;
}
.post-contents .normal-file .file-info p{
margin: .3rem 0;
color: #aaa;
}
/* 태그 */
.post-container .post-tags ul{
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
}
.post-container .post-tags ul li:nth-child(1){
color: #aaa;
}
.post-container .post-tags ul li{
margin-right: 1rem;
}
.post-container .post-tags ul input{
width: 10rem;
border: none;
outline: none;
}
.post-container .post-tags ul li a{
color: #aaa;
font-size: 1.2rem;
font-weight: bold;
margin-left: .2rem;
transition: .3s;
}
.post-container .post-tags ul li a:hover{
color: var(--primary-color);
}
/* 파일처리 섹션 */
.hidden{
display: none;
}
완성된 post.css 파일은 위와 같다.
페이지 모드(다크/일반) 기능 구현하기
/* 다크모드 */
.post-container .post-title input.dark,
.post-container .post-contents textarea.dark,
.post-container .post-tags input.dark{
color: #fff;
background: #333;
}
post.css 파일에 위 코드를 추가한다. 편집기의 제목, 컨텐츠, 태그입력 부분에 대한 다크테마 스타일을 추가한다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
})
js 폴더에 post.js 파일을 추가하고 위와 같이 작성한다. 편집기의 제목, 컨텐츠, 태그입력 부분에 대한 다크테마 기능을 추가한다.
태그입력 기능 구현하기
post.js 파일에 아래의 코드를 추가한다. input 을 li 태그에서 따로 빼내려고 하였으나 그렇게 하면 한줄로 정렬하기가 힘들어진다.
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
태그 갯수(10개)와 태그 글자수(10글자)를 제한한다. 태그 갯수는 입력창을 포함하기 때문에 실제로는 9개이다. 문자열의 trim() 메서드는 문자열 양쪽의 빈 공백을 모두 제거해준다. 태그 입력시 사용자가 스페이스바를 여러번 누르는 경우 trim() 메서드를 사용하지 않으면 빈 문자열('')을 검사할때 검사가 제대로 되지 않고 태그가 추가된다. 태그 입력창에서 엔터키를 누른 경우 빈 문자열/태그 글자수/태그 갯수에 대한 조건을 모두 만족하면 새로운 태그를 생성하고 태그 목록에 추가한다. 그리고 태그 입력창을 초기화한다.
// 태그는 한번만 생성되고 더이상 실행되지 않는 코드
tagList.innerHTML += `<li>#${this.value.trim()}<a href='#'>x</a></li>`
tagInput.value = ''
위와 같이 tagList 의 innerHTML 에 곧바로 태그를 추가하게 되면 태그가 한번만 생성되고 더이상 추가되지 않는다.
이유는 tagList 의 innerHTML 에는 이미 input 엘리먼트가 존재하는데 innerHTML 에 새로운 문자열 형태의 엘리먼트를 추가하면서 기존의 input 엘리먼트에 연결된 이벤트핸들러가 사라진다. 디버깅도구에서 태그 입력후 엔터를 치고 input 엘리먼트의 이벤트리스너 목록을 확인해보면 사라지는 것이 보인다. 이러한 이유로 input 엘리먼트에 등록된 이벤트리스너가 사라지지 않도록 하기 위하여 tagList 의 innerHTML 에 곧바로 li 태그를 추가하지 않고 li 태그를 생성한 다음 appendChild 형태로 주입하였다.
태그삭제 기능 구현하기
https://developer.mozilla.org/en-US/docs/Web/API/Node/removeChild
post.js 파일에 아래의 코드를 추가한다. 태그삭제는 이벤트 위임을 사용한다. 이벤트 위임은 리스트 목록이 유동적으로 변하는 경우 각 리스트 항목에 이벤트핸들러를 연결하지 않고 항목들을 감싸고 있는 부모 엘리먼트(ul)에 이벤트핸들러를 하나만 등록한다. 그런 다음 항목이 클릭되면 이벤트 캡쳐링과 버블링에 의하여 부모 엘리먼트 안에서 어느 엘리먼트가 클릭되었는지 알 수 있다. 현재는 태그의 갯수가 블로그마다 다르고, 태그가 삭제되면서 유동적으로 변하기 때문에 이벤트 위임을 사용하는 것이 좋다. 여기서는 ul 태그 안에서 x 표시가 되어있는 a 태그가 클릭되었을때만 특별한 동작을 하도록 하였다.
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
DOM 객체의 removeChild() 메서드는 부모의 내부에 위치하는 특정 자식요소를 제거한다. event.preventDefault() 는 태그 삭제후 페이지가 브라우저 상단으로 이동하는 문제를 해결해준다. 해당 코드가 없으면 a 태그(#)를 클릭하였으므로 페이지가 브라우저 상단으로 스크롤링한다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
})
현재까지의 post.js 파일은 위와 같다.
파일업로드 & 파일 프리뷰 기능 구현하기
<div class="file-tool">
<a href="#"><label for="post-files"><span class="material-icons">crop_original</span></label></a>
<a href="#"><label for="post-files"><span class="material-icons">folder_open</span></label></a>
<a href="#"><label for="post-files"><span class="material-icons">slideshow</span></label></a>
</div>
<!-- 파일처리 섹션 -->
<section class="upload hidden">
<input type="file" id="post-files" name="post-files" multiple>
</section>
앞서 파일관련 아이콘(이미지, 파일, 비디오)을 label 태그로 감싸주고 for 속성을 지정해주었다. 이렇게 하면 label 태그를 클릭할때 for 속성과 같은 id 값을 가진 input 태그에 포커스를 주거나 파일창이 열리도록 한다.
// 파일입력 처리하기
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
}else if(fileType.includes('video')){
console.log('video')
}else if(fileType.includes('audio')){
console.log('audio')
}else{
console.log('file')
}
}
}
})
post.js 파일에 위 코드를 추가한다. 이미지/비디오/오디오/기타파일로 나눠서 다른 프리뷰를 보여줄수 있도록 한다.
이미지 프리뷰 보여주기
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 생성 (이미지 경로)
postContents.appendChild(img)
}
post.js 파일의 해당부분에 위 코드를 추가한다. img 요소를 생성하고 파일의 임시경로를 src 속성값으로 설정한다.
이미지가 잘 추가되기는 하지만 편집기(컨텐츠 영역) 맨 아래쪽에만 추가된다. appendChild 를 사용하였기 때문이다. 우리가 원하는건 커서가 있는 위치에 사진이 삽입되는 것이다.
커서 위치에 파일 삽입하기
https://developer.mozilla.org/en-US/docs/Web/API/Selection
https://stackoverflow.com/questions/1197401/get-element-node-at-caret-position-in-contenteditable
핵심 알고리즘은 커서가 특정 위치에서 포커스(편집기 안에서 깜빡임)되어 있다가 파일을 추가하기 위하여 아이콘을 클릭하는 순간 커서의 포커스는 사라진다. 이때 blur 이벤트가 발생한다. blur 이벤트가 발생할때 커서가 위치한 라인의 엘리먼트를 저장해두면 추후에 커서가 위치한 라인에 파일을 삽입할 수 있다.
// 파일입력 처리하기
let lastCaretLine = null // 추가
const uploadInput = document.querySelector('.upload input')
// 코드생략
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){ // 추가
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
post.js 파일에 위 코드를 추가한다. document.getSelection() 은 편집기에서 사용자에 의하여 선택된 텍스트의 범위를 보여주거나 커서의 현재위치를 보여준다. lastCaretLine 은 blur 이벤트가 발생했을때 커서가 마지막으로 위치한 라인의 엘리먼트를 저장한다. 편집기 특정 라인의 텍스트를 드래그하거나 커서를 위치시킨 다음 파일 아이콘을 클릭해서 콘솔에 출력되는 내용을 확인해보자!
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
let lastCaretLine = null // 추가
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 (이미지 경로)
postContents.appendChild(img)
}else if(fileType.includes('video')){
console.log('video')
}else if(fileType.includes('audio')){
console.log('audio')
}else{
console.log('file')
}
}
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){ // 추가
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
})
현재까지의 post.js 파일은 위와 같다.
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 (이미지 경로)
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 편집기의 마지막 커서 위치에 파일 추가
}
post.js 파일의 해당 부분을 위와 같이 수정한다. 먼저 URL 객체의 createObjectURL 메서드는 파일 객체(file)를 인자로 받아서 파일의 임시경로를 생성한다. 해당 경로를 img 태그의 src 속성에 추가해주면 사진이 화면에 보이게 된다. 이제 남은건 아웃포커싱될때 마지막으로 커서가 위치했던 라인에 엘리먼트(이미지)를 추가해주면 된다. 해당 코드블럭은 편집기에 다른 파일을 삽입할때도 동일하게 사용되므로 addFileToCurrentLine 이라는 함수로 만들어 재사용한다.
https://developer.mozilla.org/en-US/docs/Web/API/Element/insertAdjacentElement
post.js 파일에 아래 코드를 추가한다. line 은 blur 일때 마지막으로 커서가 위치한 라인의 엘리먼트이다. 먼저 마지막 커서가 위치한 라인의 바로 아래줄에 새로운 공백 라인을 추가한다. line.nextSibling 은 새로 추가한 공백 라인을 가리킨다. 새로 추가한 공백 라인에 파일을 삽입한다. 삽인한 파일의 바로 아래에 다시 새로운 공백 라인을 추가한다. 그런 다음 삽입한 파일 바로 아래의 공백 라인(line.nextSibling.nextSibling) 을 반환하여 lastCaretLIne 으로 설정된 커서 위치를 업데이트한다. 다음 파일을 업데이트된 커서 위치에 추가하기 위함이다.
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
단, blur 일때 마지막으로 커서가 위치한 라인이 텍스트 노드인 경우(line.nodeType = 3) DOM 객체가 아니기 때문에 insertAdjacentElement 메서드를 사용하지 못한다. 하지만 부모노드(line.parentNode) 는 div 엘리먼트이기 때문에 해당 메서드를 사용할 수 있다. 정리하면 addFileToCurrentLine 함수는 blur 일때 마지막으로 커서가 위치한 라인 바로 아랫줄에 새로운 라인을 생성하고 파일을 삽입한다. 그리고 삽입한 파일 바로 아랫줄에 새로운 라인을 생성하고 커서 위치를 삽입한 파일 아랫줄로 이동한다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
let lastCaretLine = null // 추가된 부분
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 (이미지 경로)
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 편집기의 마지막 커서 위치에 파일 추가
}else if(fileType.includes('video')){
console.log('video')
}else if(fileType.includes('audio')){
console.log('audio')
}else{
console.log('file')
}
}
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
})
현재까지의 post.js 파일은 위와 같다.
다수의 파일 추가후 맨 마지막 파일 아래에 커서 위치 설정하기
https://developer.mozilla.org/en-US/docs/Web/API/Range/selectNodeContents
if(files.length > 0){
for(const file of files){
// 코드생략
}
// 커서위치를 맨 마지막으로 추가된 파일 아래쪽에 위치시키기
const selection = document.getSelection()
selection.removeAllRanges()
console.log(lastCaretLine)
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
range.collapse()
selection.addRange(range)
postContents.focus() // 파일을 모두 업로드한 후 커서 보여주기
}
post.js 파일의 해당 부분에 위 코드를 추가한다.
const selection = document.getSelection()
selection.removeAllRanges()
이전에 마우스 드래그로 설정된 범위나 커서 위치를 모두 초기화한다.
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // blur일때 마지막 커서 위치의 엘리먼트 저장하기
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
앞서 blur 일때는 커서가 위치한 라인의 엘리먼트를 lastCaretLine 변수에 저장하였다. 즉, 커서 위치로 엘리먼트를 조회하였다.
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
이번에는 반대로 range 객체의 selectNodeContents 메서드를 이용하여 lastCaretLine 이 가리키는 엘리먼트를 범위로 지정한다. 반복문을 빠져나온 이후 lastCaretLine 은 여러개의 파일이 추가되고 맨 마지막 파일의 아랫줄에 위치한 라인이다. 해당 라인에 위치한 엘리먼트를 커서의 범위로 잡는다.
range.collapse()
하지만 우리는 범위가 아니라 커서가 필요하다. range 객체의 collapse 메서드는 범위가 아니라 한 지점에 커서를 만든다.
selection.addRange(range)
새로운 커서의 범위를 설정하거나 커서를 위치시킬때 반드시 위와 같이 selection 객체의 addRange 메서드를 이용하여 업데이트해줘야 한다. 이렇게 하면 결국 여러개의 파일이 추가되고 맨마지막 파일의 아랫줄에 커서가 보여지게 된다.
postContents.focus()
selection 의 addRange 는 커서의 범위만 설정할뿐 화면에 커서가 보이게 하지는 못한다. 위 코드를 사용하여 화면에 커서가 보이도록 해준다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
let lastCaretLine = null // 추가된 부분
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 (이미지 경로)
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 편집기의 마지막 커서 위치에 파일 추가
}else if(fileType.includes('video')){
console.log('video')
}else if(fileType.includes('audio')){
console.log('audio')
}else{
console.log('file')
}
}
// 커서위치를 맨 마지막으로 추가된 파일 아래쪽에 위치시키기
const selection = document.getSelection()
selection.removeAllRanges()
console.log(lastCaretLine)
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
range.collapse()
selection.addRange(range)
postContents.focus() // 파일을 모두 업로드한 후 커서 보여주기
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
})
현재까지의 post.js 파일은 위와 같다.
에러 처리하기
post.js 파일의 해당 부분을 아래와 같이 수정한다. 웹페이지 초기 로딩시 lastCaretLine 에는 아무런 값도 들어가 있지 않기 때문에 로딩후 곧바로 파일을 추가해보면 에러가 발생한다. 즉, 화면 로딩시 편집기 안에는 아무런 엘리먼트가 없기 때문에 파일을 추가할 수 있는 위치(라인)가 없다.
// 파일입력 처리하기
postContents.focus() // 첫로딩때 커서 보여주기
postContents.insertAdjacentElement('afterbegin', createNewline()) // 첫번째 줄에 새로운 공백라인 추가
let lastCaretLine = postContents.firstChild // 아웃포커싱될때 첫번째 줄을 마지막 커서 위치로 설정
const uploadInput = document.querySelector('.upload input')
그러므로 초기 로딩시에도 파일을 삽입할 수 있도록 편집기 첫번째 줄에 새로운 라인을 생성하고, 해당 라인에 파일을 삽입할 수 있도록 준비한다. 또한, 첫 로딩시 사용자가 내용을 입력할 수 있도록 포커스를 주어 커서를 화면에 보여준다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
postContents.focus() // 첫로딩때 커서 보여주기
postContents.insertAdjacentElement('afterbegin', createNewline()) // 첫번째 줄에 새로운 공백라인 추가
let lastCaretLine = postContents.firstChild // 아웃포커싱될때 첫번째 줄을 마지막 커서 위치로 설정
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 (이미지 경로)
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 편집기의 마지막 커서 위치에 파일 추가
}else if(fileType.includes('video')){
console.log('video')
}else if(fileType.includes('audio')){
console.log('audio')
}else{
console.log('file')
}
}
// 커서위치를 맨 마지막으로 추가된 파일 아래쪽에 위치시키기
const selection = document.getSelection()
selection.removeAllRanges()
console.log(lastCaretLine)
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
range.collapse()
selection.addRange(range)
postContents.focus() // 파일을 모두 업로드한 후 커서 보여주기
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
})
현재까지의 post.js 파일은 위와 같다.
비디오 프리뷰 보여주기
else if(fileType.includes('video')){
console.log('video')
const video = document.createElement('video')
video.className = 'video-file'
video.controls = true
video.src = URL.createObjectURL(file)
lastCaretLine = addFileToCurrentLine(lastCaretLine, video)
}
post.js 파일의 해당 부분을 위와 같이 수정한다. 이미지 프리뷰를 보여주는 코드와 거의 동일하다. video 태그의 controls 속성을 true 로 설정하여 프리뷰 화면에서도 영상을 감상할 수 있도록 한다.
오디오 프리뷰 보여주기
else if(fileType.includes('audio')){
console.log('audio')
const audio = document.createElement('audio')
audio.className = 'audio-file'
audio.controls = true
audio.src = URL.createObjectURL(file)
lastCaretLine = addFileToCurrentLine(lastCaretLine, audio)
}
post.js 파일의 해당 부분을 위와 같이 수정한다. 이미지 프리뷰를 보여주는 코드와 거의 동일하다. video 태그의 controls 속성을 true 로 설정하여 프리뷰 화면에서도 음악을 들을수 있도록 한다.
파일 프리뷰 보여주기
이미지, 비디오, 오디오를 제외한 파일의 프리뷰를 보여주는 코드를 작성해보자!
post.js 파일의 해당부분을 아래와 같이 수정한다. div 엘리먼트를 생성하고 div 엘리먼트 내부에 innerHTML 형태로 파일의 프리뷰를 보여주기 위한 html 코드를 추가한다. file 객체에는 몇가지 속성들이 존재하는데 name 은 파일명, size 는 bytes 단위의 파일크기를 의미한다. 생성한 div 엘리먼트(파일 프리뷰를 보여주기 위한 엘리먼트)는 이전에 만들어둔 addFileToCurrentLine 함수를 이용하여 lastCaretLine 의 커서 위치에 추가된다.
else{
console.log('file')
const div = document.createElement('div')
div.className = 'normal-file'
div.contentEditable = false
div.innerHTML = `
<div class='file-icon'>
<span class="material-icons">folder</span>
</div>
<div class='file-info'>
<h3>${getFileName(file.name, 70)}</h3>
<p>${getFileSize(file.size)}</p>
</div>
`
lastCaretLine = addFileToCurrentLine(lastCaretLine, div) // 에디터에 파일추가 및 파일이 추가될때마다 커서위치 업데이트하기
}
파일 프리뷰 엘리먼트(div 엘리먼트) 의 contentEditable 속성값으로 false 를 설정한 이유는 이렇게 하지 않으면 편집기(post-contents 엘리먼트)에 설정된 contentEditable 속성이 상속되어 파일 프리뷰 내부에서도 글자를 작성하거나 사진을 삽입할 수 있게 된다.
https://developer.mozilla.org/ko/docs/Web/HTML/Element/input/file
post.js 파일에 아래의 코드를 추가한다. getFileName 함수는 파일명을 가공한다. 파일명의 글자수가 limit (현재는 70자로 설정) 을 넘어가면 글자수를 limit 까지만 끊어서 보여주고 나머지는 ... 으로 표시된다. 그리고 문자열의 lastIndexOf 메서드를 이용하여 파일 확장자가 위치한 마지막 점(.)을 찾아 확장자를 보여준다.
function getFileName(name, limit){
return name.length > limit ? `${name.slice(0, limit)}... ${name.slice(name.lastIndexOf('.'), name.length)}` : name
}
function getFileSize(number) {
if(number < 1024) {
return number + 'bytes';
} else if(number >= 1024 && number < 1048576) {
return (number/1024).toFixed(1) + 'KB';
} else if(number >= 1048576) {
return (number/1048576).toFixed(1) + 'MB';
}
}
getFileSize 함수는 MDN 문서에서 그대로 복사 붙여넣기 하였다. 파일크기는 바이트 단위이므로 1024 Bytes 보다 크고 1MB 보다 작으면 KB 로 환산하고 1MB 보다 크면 MB 로 환산한다. toFixed(1) 는 소수점 한자리로 끊어서 보여준다.
코드 리팩토링하기
if(fileType.includes('image')){
console.log('image')
const img = document.createElement('img')
img.src = URL.createObjectURL(file) // 업로드한 파일의 임시경로 (이미지 경로)
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 편집기의 마지막 커서 위치에 파일 추가
}else if(fileType.includes('video')){
console.log('video')
const video = document.createElement('video')
video.className = 'video-file'
video.controls = true
video.src = URL.createObjectURL(file)
lastCaretLine = addFileToCurrentLine(lastCaretLine, video)
}else if(fileType.includes('audio')){
console.log('audio')
const audio = document.createElement('audio')
audio.className = 'audio-file'
audio.controls = true
audio.src = URL.createObjectURL(file)
lastCaretLine = addFileToCurrentLine(lastCaretLine, audio)
}
해당 코드는 같은 패턴의 중복된 코드가 많다. 악취가 난다. 함수를 만들어서 코드를 간결하게 정리해보자!
function buildMediaElement(tag, options){
const mediaElement = document.createElement(tag)
for(const option in options){
mediaElement[option] = options[option]
}
return mediaElement
}
중복되는 코드를 함수로 만들었다. 기본적으로 이미지, 비디오, 오디오 파일은 엘리먼트를 생성하고, 클래스명이나 controls 같은 속성값을 설정한다. 이를 함수로 만들면 위와 같다. 속성값들은 options 라는 객체로 전달받아서 for in 문을 사용하여 한번에 설정하면 된다. 그래서 코드를 리팩토링하면 아래와 같다.
if(fileType.includes('image')){
console.log('image')
const img = buildMediaElement('img', { src: URL.createObjectURL(file) })
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 에디터에 파일추가
}else if(fileType.includes('video')){
console.log('video')
const video = buildMediaElement('video', { src: URL.createObjectURL(file), className: 'video-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, video)
}else if(fileType.includes('audio')){
console.log('audio')
const audio = buildMediaElement('audio', { src: URL.createObjectURL(file), className: 'audio-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, audio)
}
파이어폭스 브라우저에서 글자가 편집기를 벗어나는 문제 해결하기
/* 컨텐츠 영역 */
.post-container .post-contents{
flex-grow: 1;
border: none;
outline: none;
font-size: 1rem;
word-break: break-all; /* 파이어폭스 글자가 div 영역을 벗어나는 문제 */
}
현재까지의 post.js 파일은 아래와 같다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
postContents.focus() // 첫로딩때 커서 보여주기
postContents.insertAdjacentElement('afterbegin', createNewline()) // 첫번째 줄에 새로운 공백라인 추가
let lastCaretLine = postContents.firstChild // 아웃포커싱될때 첫번째 줄을 마지막 커서 위치로 설정
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = buildMediaElement('img', { src: URL.createObjectURL(file) })
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 에디터에 파일추가
}else if(fileType.includes('video')){
console.log('video')
const video = buildMediaElement('video', { src: URL.createObjectURL(file), className: 'video-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, video)
}else if(fileType.includes('audio')){
console.log('audio')
const audio = buildMediaElement('audio', { src: URL.createObjectURL(file), className: 'audio-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, audio)
}else{
console.log('file')
const div = document.createElement('div')
div.className = 'normal-file'
div.contentEditable = false
div.innerHTML = `
<div class='file-icon'>
<span class="material-icons">folder</span>
</div>
<div class='file-info'>
<h3>${getFileName(file.name, 70)}</h3>
<p>${getFileSize(file.size)}</p>
</div>
`
lastCaretLine = addFileToCurrentLine(lastCaretLine, div) // 에디터에 파일추가 및 파일이 추가될때마다 커서위치 업데이트하기
}
}
// 커서위치를 맨 마지막으로 추가된 파일 아래쪽에 위치시키기
const selection = document.getSelection()
selection.removeAllRanges()
console.log(lastCaretLine)
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
range.collapse()
selection.addRange(range)
postContents.focus() // 파일을 모두 업로드한 후 커서 보여주기
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
function getFileName(name, limit){
return name.length > limit ? `${name.slice(0, limit)}... ${name.slice(name.lastIndexOf('.'), name.length)}` : name
}
function getFileSize(number) {
if(number < 1024) {
return number + 'bytes';
} else if(number >= 1024 && number < 1048576) {
return (number/1024).toFixed(1) + 'KB';
} else if(number >= 1048576) {
return (number/1048576).toFixed(1) + 'MB';
}
}
function buildMediaElement(tag, options){
const mediaElement = document.createElement(tag)
for(const option in options){
mediaElement[option] = options[option]
}
return mediaElement
}
})
텍스트 포맷 설정하기 (작업중...)
/* font: inherit; */
reset.css 파일의 해당 부분을 주석처리한다. 이렇게 하지 않으면 텍스트 포맷이 제대로 동작하지 않는다.
// 텍스트 포맷
const textTool = document.querySelector('.text-tool')
textTool.addEventListener('click', function(event){
console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_bold':
changeTextFormat('bold')
break
case 'format_italic':
changeTextFormat('italic')
break
case 'format_underlined':
changeTextFormat('underline')
break
case 'format_strikethrough':
changeTextFormat('strikeThrough')
break
case 'format_color_text':
changeTextFormat('foreColor', 'orange')
break
case 'format_color_fill':
changeTextFormat('backColor', 'black')
break
case 'format_size':
changeTextFormat('fontSize', 7)
break
}
postContents.focus({preventScroll: true})
})
// 텍스트 정렬
const alignTool = document.querySelector('.align-tool')
alignTool.addEventListener('click', function(event){
console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_align_left':
changeTextFormat('justifyLeft')
break
case 'format_align_center':
changeTextFormat('justifyCenter')
break
case 'format_align_right':
changeTextFormat('justifyRight')
break
case 'format_align_justify':
changeTextFormat('justifyFull')
break
}
})
위 코드를 로드 이벤트핸들러 함수 안에 작성한다.
https://developer.mozilla.org/ko/docs/Web/API/Document/execCommand
// 텍스트 포맷
function changeTextFormat(style, param){
console.log(style)
document.execCommand(style, false, param)
}
위 코드는 전역 스코프에 작성한다.
https://dev-bak.tistory.com/16
https://www.codiga.io/blog/display-code-snippets-in-html/
드롭다운 메뉴 만들기
<a href="#"><span class="material-icons">format_color_text</span></a>
<a href=""><span class="material-icons">format_color_fill</span></a>
<a href="#"><span class="material-icons">format_size</span></a>
post.html 파일의 해당 부분을 아래와 같이 수정한다.
<div class="select-menu">
<a href="#"><span class="material-icons">format_color_text</span></a>
<div class="select-menu-dropdown color-box">
<span style="background-color: red;"></span>
<span style="background-color: blue;"></span>
<span style="background-color: green;"></span>
<span style="background-color: orange;"></span>
<span style="background-color: purple;"></span>
<span style="background-color: brown;"></span>
<span style="background-color: aquamarine;"></span>
<span style="background-color: silver;"></span>
<span style="background-color: red;"></span>
<span style="background-color: blue;"></span>
<span style="background-color: green;"></span>
<span style="background-color: orange;"></span>
<span style="background-color: purple;"></span>
<span style="background-color: brown;"></span>
<span style="background-color: aquamarine;"></span>
<span style="background-color: silver;"></span>
</div>
</div>
<div class="select-menu">
<a href="#"><span class="material-icons">format_color_fill</span></a>
<div class="select-menu-dropdown color-box">
<span style="background-color: red;"></span>
<span style="background-color: blue;"></span>
<span style="background-color: green;"></span>
<span style="background-color: orange;"></span>
<span style="background-color: purple;"></span>
<span style="background-color: brown;"></span>
<span style="background-color: aquamarine;"></span>
<span style="background-color: silver;"></span>
<span style="background-color: red;"></span>
<span style="background-color: blue;"></span>
<span style="background-color: green;"></span>
<span style="background-color: orange;"></span>
<span style="background-color: purple;"></span>
<span style="background-color: brown;"></span>
<span style="background-color: aquamarine;"></span>
<span style="background-color: silver;"></span>
</div>
</div>
<div class="select-menu">
<a href="#"><span class="material-icons">format_size</span></a>
<div class="select-menu-dropdown font-box">
<span style="font-size: 1.6rem;">폰트 크기</span>
<span style="font-size: 1.5rem;">폰트 크기</span>
<span style="font-size: 1.4rem;">폰트 크기</span>
<span style="font-size: 1.3rem;">폰트 크기</span>
<span style="font-size: 1.2rem;">폰트 크기</span>
<span style="font-size: 1.1rem;">폰트 크기</span>
<span style="font-size: 1.0rem;">폰트 크기</span>
</div>
</div>
아이콘(색상/폰트)을 select-menu 요소로 감싸준다. 그런 다음 select-menu-dropdown 요소를 추가한다. 리스트의 아이템(span)에서 서로 다른 스타일은 nth-child() 를 사용하지 않고 인라인 스타일로 정의한다.
header .toolbox > div > a{
margin: 0 .2rem;
transition: .3s;
}
header .toolbox > div > a:hover{
color: var(--primary-color);
}
header .toolbox > div> a label{
cursor: pointer;
}
post.css 파일의 해당 부분을 아래와 같이 수정한다.
header .toolbox > div a{
margin: 0 .2rem;
transition: .3s;
}
header .toolbox > div a:hover{
color: var(--primary-color);
}
header .toolbox > div > a label{
cursor: pointer;
}
드롭다운 메뉴가 적용될 아이콘은 .select-menu 라는 컨테이너로 한번더 감싸주었기 때문에 해당 아이콘에도 여전히 동일한 스타일을 적용하기 위하여 아래와 같이 자식연산자(>)는 제거한다.
/* 드롭다운 메뉴 */
.select-menu{
max-width: 2rem;
display: inline-flex;
flex-flow: column;
align-items: center;
position: relative;
}
.select-menu-dropdown{
background-color: #fff;
position: absolute;
display: none;
top: 45px; /* 아이콘 높이만큼 아래쪽에 드롭다운 배치 */
border: 1px solid #ddd;
padding: 1rem;
}
.select-menu-dropdown::before{
content: '';
width: 1.4rem;
height: 1.4rem;
background-color: #fff;
position: absolute;
left: 50%; top: -0.7rem;
transform: translateX(-50%) rotateZ(45deg);
border: 1px solid #ddd;
border-right: none;
border-bottom: none;
}
.select-menu-dropdown.show{
display: flex;
justify-content: center;
align-items: center;
}
post.css 파일에 위 코드를 추가한다.
/* 드롭다운 메뉴 */
.select-menu{
max-width: 2rem;
display: inline-flex;
flex-flow: column;
align-items: center;
position: relative;
}
아이콘과 드롭다운 메뉴를 감싸는 컨테이너 요소이다. display: inline-flex 로 설정하여 다른 아이콘의 레이아웃이 틀어지지 않도록 한다. flex-flow: column 을 적용하여 아이콘과 드롭다운 메뉴를 세로방향으로 나열한다. align-items: center 를 설정하여 아이콘과 드롭다운 메뉴를 가로중앙에 정렬한다. position: relative 를 설정하여 드롭다운 메뉴가 컨테이너를 기준으로 화면에 배치될 수 있도록 한다.
.select-menu-dropdown{
background-color: #fff;
position: absolute;
display: none;
top: 45px; /* 아이콘 높이만큼 아래쪽에 드롭다운 배치 */
border: 1px solid #ddd;
padding: 1rem;
}
.select-menu-dropdown::before{
content: '';
width: 1.4rem;
height: 1.4rem;
background-color: #fff;
position: absolute;
left: 50%; top: -0.7rem;
transform: translateX(-50%) rotateZ(45deg);
border: 1px solid #ddd;
border-right: none;
border-bottom: none;
}
드롭다운 메뉴에 대한 공통적인 스타일을 정의한다. 드롭다운 메뉴 스타일은 한번 정의해두면 여러번 재사용이 가능하다. 그래서 추후에 모듈화가 가능하다. 드롭다운 메뉴는 클릭시 화면에 보여주기 때문에 display: none 을 설정하여 초기에 화면에서 숨긴다. 드롭다운 메뉴는 position: ablsoulte 와 top: 45px 을 설정하여 아이콘 하단에 배치한다.
드롭다운 메뉴의 가상클래스(::before) 를 이용하여 드롭다운 메뉴를 말풍선처럼 보이도록 한다. 작은 정사각형을 45도 회전하여 다이아몬드 형태로 만든 다음 드롭다운 메뉴의 가로 중앙에 배치한다. position: absolute 와 top: -0.7rem 을 설정하여 드롭다운 메뉴의 상단으로부터 정사각형의 절반만큼 아래로 내린다. border-right 과 border-bottom 을 none 으로 지정하여 정사각형의 하단에 보이는 경계선은 화면에서 숨긴다.
.select-menu-dropdown.show{
display: flex;
justify-content: center;
align-items: center;
}
드롭다운 메뉴에 show 클래스를 동적으로 적용하여 화면에 보여준다. 화면에 보일때 display: flex 를 적용하여 드롭다운 메뉴 안에 있는 옵션메뉴에 대한 레이아웃을 잡아준다.
/* 색상 드롭다운 */
.color-box{
width: 10rem;
}
.color-box span{
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
margin: .2rem;
cursor: pointer;
flex: 0 0 auto;
}
.color-box.show{
flex-wrap: wrap;
}
post.css 파일에 위 코드를 추가한다. 색상에 관련된 드롭다운 메뉴 스타일이다. 색상 선택에 대한 옵션을 제공하기 위하여 원모양으로 만든다. color-box 드롭다운 메뉴가 화면에 보여질때는 flex-wrap: wrap 을 설정하여 반응형이 되도록 한다.
/* 폰트 드롭다운 */
.font-box{
width: 12rem;
}
.font-box span{
width: 100%;
padding: .5rem;
cursor: pointer;
}
.font-box span:hover{
background-color: #eee;
}
.font-box.show{
flex-flow: column;
}
post.css 파일에 위 코드를 추가한다. 폰트에 관련된 드롭다운 메뉴 스타일이다. 폰트크기 선택에 대한 옵션을 제공하기 위하여 너비는 12rem 으로 설정하고 마우스 호버일때 배경색을 지정한다. font-box 드롭다운 메뉴가 화면에 보여질때는 flex-flow: column 을 설정하여 세로방향으로 나열한다.
// 텍스트 포맷
const textTool = document.querySelector('.text-tool')
textTool.addEventListener('click', function(event){
console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_bold':
changeTextFormat('bold')
break
case 'format_italic':
changeTextFormat('italic')
break
case 'format_underlined':
changeTextFormat('underline')
break
case 'format_strikethrough':
changeTextFormat('strikeThrough')
break
case 'format_color_text':
changeTextFormat('foreColor', 'orange')
break
case 'format_color_fill':
changeTextFormat('backColor', 'black')
break
case 'format_size':
changeTextFormat('fontSize', 7)
break
}
postContents.focus({preventScroll: true})
})
post.js 에서 해당 코드 부분을 아래와 같이 수정한다.
// 텍스트 포맷
const textTool = document.querySelector('.text-tool')
const colorBoxes = textTool.querySelectorAll('.text-tool .color-box') // 추가
const fontBox = textTool.querySelector('.text-tool .font-box') // 추가
textTool.addEventListener('click', function(event){
event.stopPropagation() // document 의 click 이벤트와 충돌하지 않도록 함
// console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_bold':
changeTextFormat('bold')
break
case 'format_italic':
changeTextFormat('italic')
break
case 'format_underlined':
changeTextFormat('underline')
break
case 'format_strikethrough':
changeTextFormat('strikeThrough')
break
case 'format_color_text':
changeTextFormat('foreColor', 'orange')
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
changeTextFormat('backColor', 'black')
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
case 'format_size':
changeTextFormat('fontSize', 7)
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
}
postContents.focus({preventScroll: true})
})
수정된 부분은 아래와 같다.
const colorBoxes = textTool.querySelectorAll('.text-tool .color-box') // 추가
const fontBox = textTool.querySelector('.text-tool .font-box') // 추가
드롭다운 메뉴를 보여주기 위하여 색상/폰트 드롭다운 요소를 가져온다.
event.stopPropagation() // document 의 click 이벤트와 충돌하지 않도록 함
문서전체에 클릭 이벤트를 등록하고, 사용자가 클릭한 요소가 드롭다운 메뉴가 아니면 화면에서 드롭다운 메뉴를 가릴 예정인데 해당 코드가 없으면 아이콘을 클릭할때와 문서전체의 클릭 이벤트가 충돌한다. 이벤트 버블링 때문이다. 아이콘을 클릭할때는 드롭다운 메뉴를 보여줄수도 있는데 이때 문서전체에 등록된 클릭 이벤트가 같이 동작하여 드롭다운 메뉴가 보이지 않는 문제가 있다.
case 'format_color_text':
changeTextFormat('foreColor', 'orange')
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
changeTextFormat('backColor', 'black')
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
색상 아이콘을 클릭하면 색상을 선택하기 위한 드롭다운 메뉴를 화면에서 보여주거나 숨긴다. 현재 클릭한 아이콘의 드롭다운 메뉴를 화면에 보여주기 위하여 이전에 열려있던 드롭다운 메뉴는 화면에서 숨긴다. 이때 hideDropdown 함수를 사용한다.
case 'format_size':
changeTextFormat('fontSize', 7)
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
폰트 아이콘을 클릭하면 폰트 크기를 선택하기 위한 드롭다운 메뉴를 화면에서 보여주거나 숨긴다. 현재 클릭한 아이콘의 드롭다운 메뉴를 화면에 보여주기 위하여 이전에 열려있던 드롭다운 메뉴는 화면에서 숨긴다. 이때 hideDropdown 함수를 사용한다.
function hideDropdown(toolbox, currentDropdwon){
const dropdown = toolbox.querySelector('.select-menu-dropdown.show')
// 현재 보이는 드롭다운 메뉴가 현재 클릭한 드롭다운 메뉴가 아닌 경우
// 현재 클릭한 드롭다운 메뉴만 토글하고 나머지 메뉴는 화면에서 가린다
if(dropdown && dropdown.parentElement.querySelector('a span').innerText !== currentDropdwon)
dropdown.classList.remove('show')
}
hideDropdown 함수는 현재 화면에 보이는 드롭다운 메뉴를 숨긴다. 현재 화면에 보이는 드롭다운(dropdown) 메뉴가 방금 클릭한 아이콘에 대한 드롭다운 메뉴가 아닌 경우에만 화면에서 숨긴다. 그리고 방금 클릭한 드롭다운 메뉴는 토글기능을 이용하여 보여주고 숨긴다. 만약 조건문이 없으면 이전에 열려있는 모든 드롭다운 메뉴를 숨기고, 현재 클릭한 드롭다운 메뉴를 보여주기만 할뿐 영원히 숨기지 못한다. remove 하고 toggle 해봤자 add 만 할뿐이다.
document.addEventListener('click', function(e){
const dropdownMenu = document.querySelector('.select-menu-dropdown.show')
if(dropdownMenu && !dropdownMenu.contains(e.target)){ // 현재 열려있는 드롭다운이 존재하고 현재 클릭한 곳이 드롭다운 메뉴가 아닌 경우
dropdownMenu.classList.remove('show')
}
})
load 이벤트 외부에 해당 코드를 추가한다. 문서전체에 클릭 이벤트를 등록하고, 사용자가 클릭한 요소가 드롭다운 메뉴가 아니고 현재 열려있는 드롭다운 메뉴가 있으면 화면에서 드롭다운 메뉴를 숨긴다.
현재까지 post.js 파일은 아래와 같다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
postContents.focus() // 첫로딩때 커서 보여주기
postContents.insertAdjacentElement('afterbegin', createNewline()) // 첫번째 줄에 새로운 공백라인 추가
let lastCaretLine = postContents.firstChild // 아웃포커싱될때 첫번째 줄을 마지막 커서 위치로 설정
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = buildMediaElement('img', { src: URL.createObjectURL(file) })
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 에디터에 파일추가
}else if(fileType.includes('video')){
console.log('video')
const video = buildMediaElement('video', { src: URL.createObjectURL(file), className: 'video-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, video)
}else if(fileType.includes('audio')){
console.log('audio')
const audio = buildMediaElement('audio', { src: URL.createObjectURL(file), className: 'audio-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, audio)
}else{
console.log('file')
const div = document.createElement('div')
div.className = 'normal-file'
div.contentEditable = false
div.innerHTML = `
<div class='file-icon'>
<span class="material-icons">folder</span>
</div>
<div class='file-info'>
<h3>${getFileName(file.name, 70)}</h3>
<p>${getFileSize(file.size)}</p>
</div>
`
lastCaretLine = addFileToCurrentLine(lastCaretLine, div) // 에디터에 파일추가 및 파일이 추가될때마다 커서위치 업데이트하기
}
}
// 커서위치를 맨 마지막으로 추가된 파일 아래쪽에 위치시키기
const selection = document.getSelection()
selection.removeAllRanges()
console.log(lastCaretLine)
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
range.collapse()
selection.addRange(range)
postContents.focus() // 파일을 모두 업로드한 후 커서 보여주기
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
// console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
// 텍스트 포맷
const textTool = document.querySelector('.text-tool')
const colorBoxes = textTool.querySelectorAll('.text-tool .color-box') // 추가
const fontBox = textTool.querySelector('.text-tool .font-box') // 추가
textTool.addEventListener('click', function(event){
event.stopPropagation() // document 의 click 이벤트와 충돌하지 않도록 함
// console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_bold':
changeTextFormat('bold')
break
case 'format_italic':
changeTextFormat('italic')
break
case 'format_underlined':
changeTextFormat('underline')
break
case 'format_strikethrough':
changeTextFormat('strikeThrough')
break
case 'format_color_text':
changeTextFormat('foreColor', 'orange')
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
changeTextFormat('backColor', 'black')
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
case 'format_size':
changeTextFormat('fontSize', 7)
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
}
postContents.focus({preventScroll: true})
})
// 텍스트 정렬
const alignTool = document.querySelector('.align-tool')
alignTool.addEventListener('click', function(event){
console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_align_left':
changeTextFormat('justifyLeft')
break
case 'format_align_center':
changeTextFormat('justifyCenter')
break
case 'format_align_right':
changeTextFormat('justifyRight')
break
case 'format_align_justify':
changeTextFormat('justifyFull')
break
}
})
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
function getFileName(name, limit){
return name.length > limit ? `${name.slice(0, limit)}... ${name.slice(name.lastIndexOf('.'), name.length)}` : name
}
function getFileSize(number) {
if(number < 1024) {
return number + 'bytes';
} else if(number >= 1024 && number < 1048576) {
return (number/1024).toFixed(1) + 'KB';
} else if(number >= 1048576) {
return (number/1048576).toFixed(1) + 'MB';
}
}
function buildMediaElement(tag, options){
const mediaElement = document.createElement(tag)
for(const option in options){
mediaElement[option] = options[option]
}
return mediaElement
}
function changeTextFormat(style, param){
// console.log(style)
document.execCommand(style, false, param)
}
function hideDropdown(toolbox, currentDropdwon){
const dropdown = toolbox.querySelector('.select-menu-dropdown.show')
// 현재 보이는 드롭다운 메뉴가 현재 클릭한 드롭다운 메뉴가 아닌 경우
// 현재 클릭한 드롭다운 메뉴만 토글하고 나머지 메뉴는 화면에서 가린다
if(dropdown && dropdown.parentElement.querySelector('a span').innerText !== currentDropdwon)
dropdown.classList.remove('show')
}
})
document.addEventListener('click', function(e){
const dropdownMenu = document.querySelector('.select-menu-dropdown.show')
if(dropdownMenu && !dropdownMenu.contains(e.target)){ // 현재 열려있는 드롭다운이 존재하고 현재 클릭한 곳이 드롭다운 메뉴가 아닌 경우
dropdownMenu.classList.remove('show')
}
})
색상 드롭다운 메뉴(배경색/폰트색상) 기능 구현하기
case 'format_color_text':
// changeTextFormat('foreColor', 'orange')
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
// changeTextFormat('backColor', 'black')
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
changeTextFormat('foreColor', 'orange') 은 더이상 필요한 코드가 아니므로 주석처리하거나 제거한다.
// 텍스트 포맷
const textTool = document.querySelector('.text-tool')
const colorBoxes = textTool.querySelectorAll('.text-tool .color-box') // 추가
const fontBox = textTool.querySelector('.text-tool .font-box') // 추가
textTool.addEventListener('click', function(event){
event.stopPropagation() // document 의 click 이벤트와 충돌하지 않도록 함
// console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_bold':
changeTextFormat('bold')
break
case 'format_italic':
changeTextFormat('italic')
break
case 'format_underlined':
changeTextFormat('underline')
break
case 'format_strikethrough':
changeTextFormat('strikeThrough')
break
case 'format_color_text':
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
case 'format_size':
changeTextFormat('fontSize', 7)
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
}
// postContents.focus({preventScroll: true})
})
텍스트 포맷 코드에서 하단에 편집기 포커스 주는 부분은 주석처리한다.
function changeTextFormat(style, param){
// console.log(style)
document.execCommand(style, false, param)
postContents.focus({preventScroll: true}) // 추가
}
텍스트 포맷을 변경하고 편집기에 다시 포커스를 설정하는 것은 한 세트로 같이 있으면 좋기 때문에 위와 같이 작성한다.
colorBoxes[0].addEventListener('click', (event) => changeColor(event, 'foreground'))
colorBoxes[1].addEventListener('click', (event) => changeColor(event, 'background'))
색상에 관련된 드롭다운 메뉴에 클릭 이벤트를 등록한다. 사용자가 드롭다운 메뉴에서 바꾸고 싶은 색상을 선택하면 changeColor 함수가 실행되어 색상이 변경된다.
function changeColor(event, mode){
event.stopPropagation()
// console.log(mode, event.target)
if(!event.target.classList.contains('select-menu-dropdown')){
console.log(mode, event.target.style.backgroundColor)
switch(mode){
case 'foreground':
changeTextFormat('foreColor', event.target.style.backgroundColor) // 글자색 변경
break
case 'background':
changeTextFormat('backColor', event.target.style.backgroundColor) // 배경색 변경
break
}
event.target.parentElement.classList.remove('show') // 드롭다운 메뉴 숨기기
}
}
post.js 파일에 해당 코드를 추가한다. event.stopPropagation() 을 설정하여 드롭다운 메뉴의 클릭 이벤트가 상위로 버블링되지 않도록 한다. 해당 코드가 없으면 text-tool 요소까지 클릭 이벤트가 전달되어 text-tool 에 등록된 이벤트핸들러 함수도 같이 실행된다. 이벤트 위임을 사용하였기 때문에 select-menu-dropdown 요소 자체가 아니라 내부에 존재하는 span 요소를 클릭한 경우에만 색상이 변경될 수 있도록 조건문을 적용한다. mode 가 'foreground' 이면 글자색을 변경하고, 'background' 이면 배경색을 변경한다.
현재까지 post.js 파일은 아래와 같다.
const scroller = new Scroller(false)
window.addEventListener("load", (event) => {
// 테마변경 (다크모드/ 일반모드)
const mode = document.querySelector('.mode')
const icons = mode.querySelectorAll('.fa-solid')
const header = document.querySelector('header')
const footer = document.querySelector('.footer')
const title = document.querySelector('.post-container .post-title input') // 추가
const postContents = document.querySelector('.post-container .post-contents') // 추가
const tagInput = document.querySelector('.post-container .post-tags input') // 추가
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
})
// 태그입력 기능
const tagList = document.querySelector('.post-container .post-tags ul')
// const tagInput = document.querySelector('.post-container .post-tags input')
const tagslimit = 10 // 태그 갯수 제한
const tagLength = 10 // 태그 글자수 제한
tagInput.addEventListener('keyup', function(event){
console.log('태그 입력중...', event.key)
const trimTag = this.value.trim()
if(event.key === 'Enter' && trimTag !== '' && trimTag.length <= tagLength && tagList.children.length < tagslimit){
const tag = document.createElement('li') // 태그 생성
tag.innerHTML = `#${trimTag}<a href='#'>x</a>`
tagList.appendChild(tag) // 태그 추가
this.value = '' // 태그 입력창 초기화
}
})
// 태그 삭제 (이벤트 위임 사용)
tagList.addEventListener('click', function(event){
console.log(event.target, event.target.parentElement, event.target.hasAttribute('href'))
event.preventDefault() // 삭제후 브라우저 상단으로 이동하는 문제 해결
if(event.target.hasAttribute('href')){ // 취소(x)를 클릭하는 경우 (a 태그인지 확인)
tagList.removeChild(event.target.parentElement) // 클릭이 발생한 a 요소의 부모요소인 li 태그 삭제
}
})
// 파일입력 처리하기
postContents.focus() // 첫로딩때 커서 보여주기
postContents.insertAdjacentElement('afterbegin', createNewline()) // 첫번째 줄에 새로운 공백라인 추가
let lastCaretLine = postContents.firstChild // 아웃포커싱될때 첫번째 줄을 마지막 커서 위치로 설정
const uploadInput = document.querySelector('.upload input')
uploadInput.addEventListener('change', function(event){
const files = this.files
if(files.length > 0){
for(const file of files){
const fileType = file.type
if(fileType.includes('image')){
console.log('image')
const img = buildMediaElement('img', { src: URL.createObjectURL(file) })
lastCaretLine = addFileToCurrentLine(lastCaretLine, img) // 에디터에 파일추가
}else if(fileType.includes('video')){
console.log('video')
const video = buildMediaElement('video', { src: URL.createObjectURL(file), className: 'video-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, video)
}else if(fileType.includes('audio')){
console.log('audio')
const audio = buildMediaElement('audio', { src: URL.createObjectURL(file), className: 'audio-file', controls: true })
lastCaretLine = addFileToCurrentLine(lastCaretLine, audio)
}else{
console.log('file')
const div = document.createElement('div')
div.className = 'normal-file'
div.contentEditable = false
div.innerHTML = `
<div class='file-icon'>
<span class="material-icons">folder</span>
</div>
<div class='file-info'>
<h3>${getFileName(file.name, 70)}</h3>
<p>${getFileSize(file.size)}</p>
</div>
`
lastCaretLine = addFileToCurrentLine(lastCaretLine, div) // 에디터에 파일추가 및 파일이 추가될때마다 커서위치 업데이트하기
}
}
// 커서위치를 맨 마지막으로 추가된 파일 아래쪽에 위치시키기
const selection = document.getSelection()
selection.removeAllRanges()
console.log(lastCaretLine)
const range = document.createRange()
range.selectNodeContents(lastCaretLine)
range.collapse()
selection.addRange(range)
postContents.focus() // 파일을 모두 업로드한 후 커서 보여주기
}
})
// blur일때 마지막 커서 위치의 엘리먼트 저장하기
postContents.addEventListener('blur', function(event){
lastCaretLine = document.getSelection().anchorNode // 편집기 내부 커서가 위치한 곳의 엘리먼트
// console.log(lastCaretLine.parentNode, lastCaretLine, lastCaretLine.length)
})
// 텍스트 포맷
const textTool = document.querySelector('.text-tool')
const colorBoxes = textTool.querySelectorAll('.text-tool .color-box') // 추가
const fontBox = textTool.querySelector('.text-tool .font-box') // 추가
textTool.addEventListener('click', function(event){
event.stopPropagation() // document 의 click 이벤트와 충돌하지 않도록 함
// console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_bold':
changeTextFormat('bold')
break
case 'format_italic':
changeTextFormat('italic')
break
case 'format_underlined':
changeTextFormat('underline')
break
case 'format_strikethrough':
changeTextFormat('strikeThrough')
break
case 'format_color_text':
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
case 'format_size':
changeTextFormat('fontSize', 7)
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
}
// postContents.focus({preventScroll: true})
})
// 텍스트 정렬
const alignTool = document.querySelector('.align-tool')
alignTool.addEventListener('click', function(event){
console.log(event.target.innerText)
switch(event.target.innerText){
case 'format_align_left':
changeTextFormat('justifyLeft')
break
case 'format_align_center':
changeTextFormat('justifyCenter')
break
case 'format_align_right':
changeTextFormat('justifyRight')
break
case 'format_align_justify':
changeTextFormat('justifyFull')
break
}
})
colorBoxes[0].addEventListener('click', (event) => changeColor(event, 'foreground'))
colorBoxes[1].addEventListener('click', (event) => changeColor(event, 'background'))
function createNewline(){
const newline = document.createElement('div')
newline.innerHTML = `<br/>`
return newline
}
// blur 일때 마지막 커서 위치 바로 아래쪽에 새로운 줄을 생성하고 파일을 추가함
// 파일 추가후 다시 아래쪽에 새로운 줄 생성함
function addFileToCurrentLine(line, file){
if(line.nodeType === 3) line = line.parentNode // 커서 위치에 글자가 있는 경우 line 은 텍스트노드기 때문에 insertAdjacentElement 적용하지 못함 (부모는 div 엘리먼트라 적용가능)
line.insertAdjacentElement("afterend", createNewline()) // 추가
line.nextSibling.insertAdjacentElement("afterbegin", file)
line.nextSibling.insertAdjacentElement("afterend", createNewline())
return line.nextSibling.nextSibling // 추가된 파일 아래쪽에 새로 생성된 줄
}
function getFileName(name, limit){
return name.length > limit ? `${name.slice(0, limit)}... ${name.slice(name.lastIndexOf('.'), name.length)}` : name
}
function getFileSize(number) {
if(number < 1024) {
return number + 'bytes';
} else if(number >= 1024 && number < 1048576) {
return (number/1024).toFixed(1) + 'KB';
} else if(number >= 1048576) {
return (number/1048576).toFixed(1) + 'MB';
}
}
function buildMediaElement(tag, options){
const mediaElement = document.createElement(tag)
for(const option in options){
mediaElement[option] = options[option]
}
return mediaElement
}
function changeTextFormat(style, param){
// console.log(style)
document.execCommand(style, false, param)
postContents.focus({preventScroll: true})
}
function hideDropdown(toolbox, currentDropdwon){
const dropdown = toolbox.querySelector('.select-menu-dropdown.show')
// 현재 보이는 드롭다운 메뉴가 현재 클릭한 드롭다운 메뉴가 아닌 경우
// 현재 클릭한 드롭다운 메뉴만 토글하고 나머지 메뉴는 화면에서 가린다
if(dropdown && dropdown.parentElement.querySelector('a span').innerText !== currentDropdwon)
dropdown.classList.remove('show')
}
function changeColor(event, mode){
event.stopPropagation()
// console.log(mode, event.target)
if(!event.target.classList.contains('select-menu-dropdown')){
console.log(mode, event.target.style.backgroundColor)
switch(mode){
case 'foreground':
changeTextFormat('foreColor', event.target.style.backgroundColor)
break
case 'background':
changeTextFormat('backColor', event.target.style.backgroundColor)
break
}
event.target.parentElement.classList.remove('show')
}
}
})
document.addEventListener('click', function(e){
const dropdownMenu = document.querySelector('.select-menu-dropdown.show')
if(dropdownMenu && !dropdownMenu.contains(e.target)){ // 현재 열려있는 드롭다운이 존재하고 현재 클릭한 곳이 드롭다운 메뉴가 아닌 경우
dropdownMenu.classList.remove('show')
}
})
하지만 이렇게 해도 제대로 동작하지 않는다. 문제는 아래와 같이 해결하면 된다.
.select-menu-dropdown{
background-color: #fff;
position: absolute;
display: none;
top: 45px; /* 아이콘 높이만큼 아래쪽에 드롭다운 배치 */
border: 1px solid #ddd;
padding: 1rem;
user-select: none; /* 드롭다운 메뉴에서 아이템을 선택하면 편집기에서 사용자가 선택한 범위가 사라지는데 드롭다운 메뉴가 새로운 범위로 선택되지 않게 함 */
}
post.css 파일의 해당 부분을 위와 같이 수정한다. 사용자가 드롭다운 메뉴에서 색상을 선택하는 순간 이전에 편집기에서 스타일을 변경하기 위해 범위로 설정한 영역이 사라진다. 그리고 사용자가 드롭다운 메뉴에서 선택한 색상 요소가 새로운 범위로 선택된다. 이렇게 되면 편집기의 범위(selection)가 사라지면서 더이상 텍스트 색상을 변경하지 못한다. 이를 방지하려면 드롭다운 메뉴에 user-select: none 을 설정하여 드롭다운 메뉴에서 색상을 선택하더라도 선택한 요소가 새로운 범위로 설정되지 않도록 한다.
폰트크기 드롭다운 메뉴의 기능 구현하기
<div class="select-menu">
<a href="#"><span class="material-icons">format_size</span></a>
<div class="select-menu-dropdown font-box">
<span style="font-size: 1.6rem;">폰트 크기</span>
<span style="font-size: 1.5rem;">폰트 크기</span>
<span style="font-size: 1.4rem;">폰트 크기</span>
<span style="font-size: 1.3rem;">폰트 크기</span>
<span style="font-size: 1.2rem;">폰트 크기</span>
<span style="font-size: 1.1rem;">폰트 크기</span>
<span style="font-size: 1.0rem;">폰트 크기</span>
</div>
</div>
post.html 파일의 해당 부분을 아래와 같이 변경한다.
<div class="select-menu">
<a href="#"><span class="material-icons">format_size</span></a>
<div class="select-menu-dropdown font-box">
<span style="font-size: 1.6rem;" id="7">폰트 크기</span>
<span style="font-size: 1.5rem;" id="6">폰트 크기</span>
<span style="font-size: 1.4rem;" id="5">폰트 크기</span>
<span style="font-size: 1.3rem;" id="4">폰트 크기</span>
<span style="font-size: 1.2rem;" id="3">폰트 크기</span>
<span style="font-size: 1.1rem;" id="2">폰트 크기</span>
<span style="font-size: 1.0rem;" id="1">폰트 크기</span>
</div>
</div>
폰트크기를 설정하기 위한 id 값을 추가한다. 7이 가장 큰 폰트이고, 1이 가장 작은 폰트이다.
case 'format_size':
// changeTextFormat('fontSize', 7)
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
해당 코드 부분의 changeTextFormat('fontSize', 7) 은 주석처리하거나 삭제한다.
fontBox.addEventListener('click', changeFontSize)
폰트크기와 관련된 드롭다운 메뉴에 클릭 이벤트를 등록한다.
function changeFontSize(event){
event.stopPropagation()
console.log(event.target)
if(!event.target.classList.contains('select-menu-dropdown')){
changeTextFormat('fontSize', event.target.id) // 폰트크기 변경
event.target.parentElement.classList.remove('show') // 드롭다운 메뉴 숨기기
}
}
post.js 파일에 해당 코드를 추가한다. event.stopPropagation() 을 설정하여 드롭다운 메뉴의 클릭 이벤트가 상위로 버블링되지 않도록 한다. 해당 코드가 없으면 text-tool 요소까지 클릭 이벤트가 전달되어 text-tool 에 등록된 이벤트핸들러 함수도 같이 실행된다. 이벤트 위임을 사용하였기 때문에 select-menu-dropdown 요소 자체가 아니라 내부에 존재하는 span 요소를 클릭한 경우에만 폰트 크기가 변경될 수 있도록 조건문을 적용한다. event.target.id 값은 1~7이며, 이를 활용하여 폰트 크기를 설정한다.
이모티콘 드롭다운 메뉴 기능 구현하기
https://fonts.google.com/noto/specimen/Noto+Emoji
<div class="select-menu">
<a href="#"><span class="material-icons">sentiment_satisfied</span></a>
<div class="select-menu-dropdown imoticon-box">
<span>🥰</span>
<span>✌️</span>
<span>🌴</span>
<span>🐢</span>
<span>🐐</span>
<span>🍄</span>
<span>⚽</span>
<span>🍻</span>
<span>👑</span>
<span>📸</span>
<span>😬</span>
<span>🚨</span>
<span>🏡</span>
<span>🕊️</span>
<span>🏆</span>
<span>😻</span>
<span>🌟</span>
<span>🍀</span>
<span>🎨</span>
<span>🍜</span>
</div>
</div>
post.html 파일의 해당 부분을 위와 같이 수정한다. 구글폰트의 Noto Imoji 를 이용하여 이모티콘을 추가한다.
/* 이모티콘 드롭다운 */
.imoticon-box{
width: 12rem;
}
.imoticon-box span{
font-size: 1.5rem;
margin: .2rem;
cursor: pointer;
flex: 0 0 auto;
}
.imoticon-box.show{
flex-wrap: wrap;
}
post.css 파일에 해당 코드를 추가한다. font-size: 1.5rem 으로 지정하여 드롭다운 메뉴에서 보이는 이모티콘의 크기를 설정한다. flex: 0 0 auto 로 설정하여 이모티콘이 늘어나거나 줄어들지 않고 원래 크기를 유지하도록 한다. 이모티콘 드롭다운 메뉴가 나타날때 flex-wrap: wrap 으로 설정하여 반응형이 되도록 한다.
const toolBox = document.querySelector('.toolbox')
// 텍스트 포맷
toolbox 요소 안에서 현재 열려있는 드롭다운 메뉴가 있는지 검사하기 위하여 해당 요소를 가져온다.
case 'format_color_text':
hideDropdown(textTool, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
hideDropdown(textTool, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
case 'format_size':
hideDropdown(textTool, 'format_size')
fontBox.classList.toggle('show')
break
post.js 파일의 해당 코드를 아래와 같이 수정한다.
case 'format_color_text':
hideDropdown(toolBox, 'format_color_text')
colorBoxes[0].classList.toggle('show')
break
case 'format_color_fill':
hideDropdown(toolBox, 'format_color_fill')
colorBoxes[1].classList.toggle('show')
break
case 'format_size':
hideDropdown(toolBox, 'format_size')
fontBox.classList.toggle('show')
break
hideDropdown 함수의 첫번째 인자를 textTool 에서 toolBox 로 변경하였다. textTool 로 지정하면 해당 영역 안에서만 열려있는 드롭다운 메뉴가 있는지 검사하기 때문에 이모티콘 드롭다운 메뉴를 화면에 보여주려고 할때 text-tool 요소 안에 있는 드롭다운 메뉴는 닫히지 않는다. 그래서 열려있는 드롭다운 메뉴가 있는지 검사하는 범위를 전체 toolBox 요소로 넓혀서 다른 영역에 있는 드롭다운 메뉴도 닫히도록 한다.
// 부가기능
const linkTool = document.querySelector('.link-tool')
const imoticonBox = document.querySelector('.link-tool .imoticon-box')
linkTool.addEventListener('click', function(event){
event.stopPropagation() // document 의 click 이벤트와 충돌하지 않도록 함
console.log(event.target.innerText)
switch(event.target.innerText){
case 'sentiment_satisfied':
hideDropdown(toolBox, 'sentiment_satisfied')
imoticonBox.classList.toggle('show')
break
case 'table_view':
break
case 'link':
break
case 'format_list_bulleted':
break
}
})
imoticonBox.addEventListener('click', addImoticon)
post.js 파일에 해당 코드를 추가한다. link-tool 요소 내부에 존재하는 아이콘을 클릭할때 실행할 이벤트핸들러 함수를 등록한다. 이모티콘 아이콘을 클릭하면 이모티콘 드롭다운 메뉴를 화면에 보여준다. 또한, 이모티콘 드롭다운 메뉴에 클릭 이벤트를 등록하여 특정 이모티콘을 선택하면 편집기의 커서 위치에 선택한 아이콘이 추가되도록 한다.
function addImoticon(event){
event.stopPropagation()
console.log(event.target)
if(!event.target.classList.contains('select-menu-dropdown')){
changeTextFormat('insertText', event.target.innerText) // 아이콘 추가
event.target.parentElement.classList.remove('show') // 드롭다운 메뉴 숨기기
}
}
post.js 파일에 해당 코드를 추가한다. event.stopPropagation() 을 설정하여 드롭다운 메뉴의 클릭 이벤트가 상위로 버블링되지 않도록 한다. 해당 코드가 없으면 text-tool 요소까지 클릭 이벤트가 전달되어 text-tool 에 등록된 이벤트핸들러 함수도 같이 실행된다. 이벤트 위임을 사용하였기 때문에 select-menu-dropdown 요소 자체가 아니라 내부에 존재하는 span 요소를 클릭한 경우에만 이모티콘이 추가될 수 있도록 조건문을 적용한다. event.target.innerText 값은 이모티콘 폰트이며, 이를 활용하여 커서 위치에 선택한 이모티콘을 추가한다.
드롭다운 다크모드
/* 다크모드 */
header .select-menu-dropdown.dark,
header .select-menu-dropdown.dark::before,
.post-container .post-title input.dark,
.post-container .post-contents textarea.dark,
.post-container .post-tags input.dark{
color: #fff;
background: #333;
}
const dropdowns = document.querySelectorAll('header .select-menu-dropdown') // 추가
console.log(dropdowns)
mode.addEventListener('click', (event) => {
document.body.classList.toggle('dark')
header.classList.toggle('dark')
title.classList.toggle('dark') // 추가
postContents.classList.toggle('dark') // 추가
tagInput.classList.toggle('dark') // 추가
for(const icon of icons){
icon.classList.contains('active') ?
icon.classList.remove('active')
: icon.classList.add('active')
}
for(const dropdown of dropdowns){ // 드롭다운 다크모드 적용하기
dropdown.classList.toggle('dark')
}
})
'프로젝트 > 블로그 사이트' 카테고리의 다른 글
4. 홈페이지 구현하기 (0) | 2023.07.09 |
---|---|
3. 스토리 페이지 구현하기 (0) | 2023.07.08 |
2. 랜딩 페이지 구현하기 (0) | 2023.07.07 |
1. 프로젝트 준비 (0) | 2023.07.07 |