노마드코더 JS로 그림앱 만들기) #3 MEME MAKER - file URL, Image(), dblclick, download, transition
-그림 파일 추가
accept="image/*" : 이미지 파일만 허용한다. video 등도 가능하며 .pdf 등으로 확장자 제한을 걸 수도 있다.
multiple 속성 추가시 여러 파일을 등록할 수 있다.
// index.html 추가
<input type="file" accept="image/*" id="file" />
const fileInput = document.getElementById("file");
function onFileChange(event) {
console.dir(event.target);
}
fileInput.addEventListener("change", onFileChange);
콘솔 확인은 다음과 같다.
브라우저의 자바스크립트는 유저의 파일을 읽을 수 없다. 유저가 선택했을 때 메모리에 있게 된다. 그러면 브라우저가 파일을 활용할 수 있다. 메모리에 있는 파일을 URL을 쳐서 브라우저에서 볼수 있다.
아래처럼 수정하면 콘솔에서 url 확인 가능하다.
event.target.files[0] : 파일이 한 개라 0이다. multiple로 여러 파일 올리면 배열 숫자로 여러 파일 가져올 수 있다.
function onFileChange(event) {
const file = event.target.files[0];
const url = URL.createObjectURL(file); // 브라우저 안에서만 접근 가능한 URL
console.log(url);
}
const image = new Image() : <img> 태그 입력하는 것과 같다.
image.src : <img src=""> 와 같다.
image.onload : 이미지가 로딩 됐을 때 함수 실행
ctx.drawImage(image, 시작x좌표, 시작y좌표, 너비, 높이)
// app.js 추가
const fileInput = document.getElementById("file");
function onFileChange(event) {
const file = event.target.files[0];
const url = URL.createObjectURL(file);
const image = new Image();
image.src = url;
image.onload = function () {
ctx.drawImage(image, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
};
fileInput = null;
}
fileInput.addEventListener("change", onFileChange);
-텍스트 추가하기
텍스트 입력 공간 추가
// index.html
<input type="text" placeholder="Write and then double click" id="text" />
더블클릭시 콘솔에 위치 정보가 표시된다.
function onDoubleClick(event) {
console.log(event.offsetX, event.offsetY);
}
canvas.addEventListener("dblclick", onDoubleClick);
ctx.lineCap = "round" : 선의 끝 부분을 둥글게 처리한다.
ctx.save() : 현재 context 상태를 저장한다.
ctx.restore() : 저장된 context 상태를 불러온다.
ctx.font = 크기, 폰트를 지정한다.
ctx.fillText : text를 입력된 좌표에 나타낸다. 글자 내부를 채운 글씨가 나타난다. stroke로 하면 내부가 빈 채로 나타난다.
// app.js 추가
const textInput = document.getElementById("text");
ctx.lineCap = "round";
function onDoubleClick(event) {
ctx.save();
if (text !== "") {
const text = textInput.value;
ctx.lineWidth = 1;
ctx.font = "68px serif";
ctx.fillText(text, event.offsetX, event.offsetY);
ctx.restore();
}
}
canvas.addEventListener("dblclick", onDoubleClick);
-그림 저장
// index.html 추가
<button id="save">Save Image</button>
canvas.toDataURL() : 캔버스의 이미지를 URL로 변환하는 메서드. 그림을 문자로 바꿔준다.
const saveBtn = document.getElementById("save");
function onSaveClick() {
console.log(canvas.toDataURL());
}
saveBtn.addEventListener("click", onSaveClick);
document.createElement("a") : <a> 태그 입력과 같다.
a.href : <a href="">과 같다.
a.download : <a download="myDrawing.png">와 같다. 입력시 다운로드 서비스를 제공한다.
a.click() : 링크를 클릭하는 함수
const saveBtn = document.getElementById("save");
function onSaveClick() {
const url = canvas.toDataURL();
const a = document.createElement("a");
a.href = url;
a.download = "myDrawing.png";
a.click();
}
saveBtn.addEventListener("click", onSaveClick);
■ CSS 적용
Reset 관련사이트 : https://meyerweb.com/eric/tools/css/reset/
<label for="">에 다른 태그의 id를 입력하면 해당 id의 역할을 할 수 있다. <label> 태그로 감싸지 않아도 작동된다.
<label for="file">
Add Photo
<input type="file" accept="image/*" id="file" />
</label>
-전체 코드
<!-- index.html -->
<!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>Meme Maker</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="color-options">
<input type="color" name="" id="color" />
<div class="color-option" style="background-color: #1abc9c;" data-color="#1abc9c"></div>
<div class="color-option" style="background-color: #2ecc71;" data-color="#2ecc71"></div>
<div class="color-option" style="background-color: #3498db;" data-color="#3498db"></div>
<div class="color-option" style="background-color: #9b59b6;" data-color="#9b59b6"></div>
<div class="color-option" style="background-color: #34495e;" data-color="#34495e"></div>
<div class="color-option" style="background-color: #f1c40f;" data-color="#f1c40f"></div>
<div class="color-option" style="background-color: #e67e22;" data-color="#e67e22"></div>
<div class="color-option" style="background-color: #ecf0f1;" data-color="#ecf0f1"></div>
<div class="color-option" style="background-color: #95a5a6;" data-color="#95a5a6"></div>
</div>
<canvas></canvas>
<div class="btns">
<input id="line-width" type="range" min="1" max="10" value="5" step="0.1" />
<button id="mode-btn">💧Fill</button>
<button id="destroy-btn">🚽Destory</button>
<button id="erase-btn">❌Erase</button>
<label for="file">
📂Add Photo
<input type="file" accept="image/*" id="file" />
</label>
<input type="text" id="text" placeholder="Add text here... :)" />
<button id="save">💾Save Image</button>
</div>
<script src="app.js"></script>
</body>
</html>
*gap : 행과 열 사이 간격 설정
*transition 단축 문법 : <property> <duration> <timing-function> <delay>
transition-property : 트랜지션을 적용해야 하는 CSS 속성의 이름 혹은 이름들을 명시
transition-duration : 트랜지션이 일어나는 지속 시간
transition-timing-function : 속성의 중간값을 계산하는 방법을 정의하는 함수
transition-delay : 속성이 변한 시점과 트랜지션이 실제로 시작하는 사이에 기다리는 시간
*all: unset : 모든 CSS 속성 제거
*padding 순서 : 시계방향. 1개(전체) 2개(상하, 우좌), 3개(상, 우좌, 하), 4개(상, 우, 하, 좌)
/* style.css */
@import "reset.css";
body {
display: flex;
gap: 20px;
justify-content: space-between;
align-items: flex-start;
background-color: gainsboro;
padding: 20px;
}
canvas {
width: 800px;
height: 800px;
background-color: white;
border-radius: 10px;
}
body {
display: flex;
justify-content: center;
align-items: center;
}
.btns {
display: flex;
flex-direction: column;
gap: 10px;
}
.color-options {
display: flex;
flex-direction: column;
gap: 20px;
}
.color-option {
width: 50px;
height: 50px;
border-radius: 50%;
cursor: pointer;
border: 5px solid white;
transition: transform ease-in-out .1s;
}
.color-option:hover {
transform: scale(1.2);
}
input#color {
background-color: white;
}
button,
label {
all: unset;
padding: 10px 0px;
text-align: center;
background-color: royalblue;
color: white;
font-weight: 500;
cursor: pointer;
border-radius: 10px;
transition: opacity linear .1s;
}
button:hover {
opacity: 0.85;
}
input#text {
all: unset;
padding: 10px 0px;
border-radius: 10px;
}
input#file {
display: none;
}
// app.js
const saveBtn = document.getElementById("save");
const textInput = document.getElementById("text");
const fileInput = document.getElementById("file");
const eraseBtn = document.getElementById("erase-btn");
const destoryBtn = document.getElementById("destroy-btn");
const modeBtn = document.getElementById("mode-btn");
const colorOptions = Array.from(document.getElementsByClassName("color-option"));
const color = document.getElementById("color");
const lineWidth = document.getElementById("line-width");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 800;
canvas.width = 800;
canvas.height = 800;
ctx.lineCap = "round";
ctx.lineWidth = lineWidth.value;
let isPainting = false;
let isFilling = false;
function onMove(event) {
if (isPainting) {
ctx.lineTo(event.offsetX, event.offsetY);
ctx.stroke();
return;
}
ctx.beginPath();
ctx.moveTo(event.offsetX, event.offsetY);
}
function startPainting() {
isPainting = true;
}
function cancelPainting() {
isPainting = false;
}
function onLineWidthChange(event) {
ctx.lineWidth = event.target.value;
}
function onColorChange(event) {
ctx.strokeStyle = event.target.value;
ctx.fillStyle = event.target.value;
}
function onColorClick(event) {
const colorValue = event.target.dataset.color;
ctx.strokeStyle = colorValue;
ctx.fillStyle = colorValue;
color.value = colorValue;
}
function onModeClick() {
if (isFilling) {
isFilling = false;
modeBtn.innerText = "Fill";
} else {
isFilling = true;
modeBtn.innerText = "Draw";
}
}
function onCanvasClick() {
if (isFilling) {
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}
}
function onDestroyClick() {
const confirm = window.confirm("Are you sure destroy all image?");
if (confirm) {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
}
}
function onEraserClick() {
ctx.strokeStyle = "white";
isFilling = false;
modeBtn.innerText = "Fill";
}
function onFileChange(event) {
const file = event.target.files[0];
const url = URL.createObjectURL(file);
const image = new Image();
image.src = url;
image.onload = function () {
ctx.drawImage(image, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
};
fileInput = null;
}
function onDoubleClick(event) {
ctx.save();
if (text !== "") {
const text = textInput.value;
ctx.lineWidth = 1;
ctx.font = "68px serif";
ctx.fillText(text, event.offsetX, event.offsetY);
ctx.restore();
}
}
function onSaveClick() {
const url = canvas.toDataURL();
const a = document.createElement("a");
a.href = url;
a.download = "myDrawing.png";
a.click();
}
canvas.addEventListener("dblclick", onDoubleClick);
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mousedown", startPainting);
canvas.addEventListener("mouseup", cancelPainting);
canvas.addEventListener("mouseleave", cancelPainting);
canvas.addEventListener("click", onCanvasClick);
lineWidth.addEventListener("change", onLineWidthChange);
color.addEventListener("change", onColorChange);
colorOptions.forEach(color => color.addEventListener("click", onColorClick));
modeBtn.addEventListener("click", onModeClick);
destoryBtn.addEventListener("click", onDestroyClick);
eraseBtn.addEventListener("click", onEraserClick);
fileInput.addEventListener("change", onFileChange);
saveBtn.addEventListener("click", onSaveClick);
챌린지1. 텍스트 크기, 폰트 종류 변경 만들기
챌린지2. 텍스트를 fill, stroke 중 선택
챌린지3. 선을 그려서 안을 채우는 옵션 선택(안함)
최종 코드
// index.html
<!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>Meme Maker</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="color-options">
<div class="color-option" style="background-color: #1abc9c;" data-color="#1abc9c"></div>
<div class="color-option" style="background-color: #2ecc71;" data-color="#2ecc71"></div>
<div class="color-option" style="background-color: #3498db;" data-color="#3498db"></div>
<div class="color-option" style="background-color: #9b59b6;" data-color="#9b59b6"></div>
<div class="color-option" style="background-color: #34495e;" data-color="#34495e"></div>
<div class="color-option" style="background-color: #f1c40f;" data-color="#f1c40f"></div>
<div class="color-option" style="background-color: #e67e22;" data-color="#e67e22"></div>
<div class="color-option" style="background-color: #ecf0f1;" data-color="#ecf0f1"></div>
<div class="color-option" style="background-color: #95a5a6;" data-color="#95a5a6"></div>
</div>
<canvas></canvas>
<div id="btns">
<input type="range" id="line-width" min="1" max="20" value="10" step="1">
<input type="color" name="" id="color" />
<button id="mode-btn">Fill</button>
<button id="destroy-btn">Destroy</button>
<button id="erase-btn">Erase</button>
<label for="file">
Add photo
<input type="file" accept="image/*" id="file" />
</label>
<!-- font size 버튼들 div로 묶음. 5개중 1개 선택하기 위해 radio 방식으로 변경. radio 버튼 hidden 처리, label로 감싸고 for로 라디오 버튼 지정 -->
<div class="font-sizes">
<label for="font-size-1" class="font-size-label" style="font-size: 15px">1
<input type="radio" name="font-size" id="font-size-1" class="font-size" data-fontsize="15px"
hidden /></label>
<label for="font-size-2" class="font-size-label" style="font-size: 25px">2
<input type="radio" name="font-size" id="font-size-2" class="font-size" data-fontsize="25px"
hidden /></label>
<label for="font-size-3" class="font-size-label" style="font-size: 35px">3
<input type="radio" name="font-size" id="font-size-3" class="font-size" data-fontsize="35px" hidden
checked /></label>
<label for="font-size-4" class="font-size-label" style="font-size: 45px">4
<input type="radio" name="font-size" id="font-size-4" class="font-size" data-fontsize="45px"
hidden /></label>
<label for="font-size-5" class="font-size-label" style="font-size: 55px">5
<input type="radio" name="font-size" id="font-size-5" class="font-size" data-fontsize="55px"
hidden /></label>
</div>
<!-- font-family, fill-stroke 버튼들 div로 묶음. 위와 동일-->
<div class="font-family-fill-stroke">
<label for="font-family-1" class="font-family-label" style="font-family: serif;">serif
<input type="radio" name="font-family" id="font-family-1" class="font-family" data-fontfamily="serif"
hidden checked /></label>
<label for="font-family-2" class="font-family-label"
style="font-family: sans-serif; margin-right: 20px">sans-serif
<input type="radio" name="font-family" id="font-family-2" class="font-family"
data-fontfamily="sans-serif" hidden /></label>
<label for="fill-stroke-1" class="fill-stroke-label">Fill
<input type="radio" name="fill-stroke" id="fill-stroke-1" class="fill-stroke" data-fillstroke="fill"
hidden /></label>
<label for="fill-stroke-2" class="fill-stroke-label">Stroke
<input type="radio" name="fill-stroke" id="fill-stroke-2" class="fill-stroke" data-fillstroke="stroke"
hidden /></label>
</div>
<input type="text" placeholder="Add text.." id="text" style="background-color: white;" />
<button id="save">Save Image</button>
</div>
<script src="app.js"></script>
</body>
</html>
// style.css
@import "reset.css";
canvas {
width: 600px;
height: 600px;
background-color: white;
border-radius: 35px;
}
body {
display: flex;
gap: 20px;
justify-content: center;
align-items: center;
background-color: darkkhaki;
padding: 20px;
}
.color-option {
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
border: 2px solid white;
transition: transform ease-in-out .1s;
}
.color-options {
display: flex;
flex-direction: column;
gap: 10px
}
.color-option:hover {
transform: scale(1.3);
}
input#color {
background-color: white;
}
button,
label {
all: unset;
padding: 10px 2px;
text-align: center;
background-color: deeppink;
color: white;
font-weight: 700;
cursor: pointer;
border-radius: 7px;
transition: opacity linear .1s;
}
button:hover {
opacity: 0.65;
}
#btns {
display: flex;
flex-direction: column;
gap: 5px
}
input#text {
all: unset;
padding: 10px 0px;
border-radius: 5px;
}
input#file {
display: none;
}
.font-sizes {
display: flex;
justify-content: space-between;
gap: 5px;
}
.font-size-label {
width: 40px;
height: 30px;
text-align: center;
font-weight: 500;
font-family: serif;
}
.font-size:hover {
opacity: 1;
}
.font-family-fill-stroke {
display: flex;
justify-content: space-between;
}
.font-size-label {
display: flex;
align-items: center;
justify-content: center;
}
/* 선택시 색상 효과 주기 위한 스타일 */
.font-size-label-sel,
.font-family-label-sel,
.fill-stroke-label-sel {
background-color: cornflowerblue;
}
// app.js
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const lineWidth = document.getElementById("line-width");
const color = document.getElementById("color");
const colorOptions = Array.from(document.getElementsByClassName("color-option"));
const modeBtn = document.getElementById("mode-btn");
const destroyBtn = document.getElementById("destroy-btn");
const eraseBtn = document.getElementById("erase-btn")
const fileInput = document.getElementById("file");
const textInput = document.getElementById("text");
const saveBtn = document.getElementById("save");
const fontSizes = Array.from(document.getElementsByClassName("font-size"));
const fontFamilies = Array.from(document.getElementsByClassName("font-family"))
const fillStrokes = Array.from(document.getElementsByClassName("fill-stroke"));
canvas.width = 600;
canvas.height = 600;
ctx.lineWidth = lineWidth.value;
ctx.lineCap = "round";
let isPainting = false;
let isFilling = false;
// 초기값 지정
let currentFontSize = "35px";
let currentFontFamily = "serif";
let currentFillStroke = "fill"
const CANVAS_WIDTH = 600;
const CANVAS_HEIGHT = 600;
// 초기 fontSize 3 색 지정
fontSizes.forEach(fontSizeId => {
const fontSizeIdParent = fontSizeId.parentElement;
if (fontSizeId.checked === true) {
fontSizeIdParent.classList.add("font-size-label-sel");
} else {
fontSizeIdParent.classList.remove("font-size-label-sel");
}
});
// 초기 fontFamily 색 지정
fontFamilies[0].parentElement.classList.add("font-family-label-sel");
// 초기 fill 지정
fillStrokes[0].parentElement.classList.add("fill-stroke-label-sel")
/* 함수 */
function onMove(event) {
if (isPainting) {
ctx.lineTo(event.offsetX, event.offsetY);
ctx.stroke();
return;
}
ctx.beginPath();
ctx.moveTo(event.offsetX, event.offsetY);
}
function startPainting() {
isPainting = true;
}
function cancelPainting() {
isPainting = false;
}
function onLineWidthChange(event) {
ctx.lineWidth = event.target.value;
}
function onColorChange(event) {
ctx.storkeStyle = event.target.value;
ctx.fillStyle = event.target.value;
}
function onColorClick(event) {
const colorValue = event.target.dataset.color;
ctx.strokeStyle = colorValue;
ctx.fillStyle = colorValue;
color.value = colorValue;
}
function onModeClick() {
if (isFilling) {
isFilling = false;
modeBtn.innerText = "Fill";
} else {
isFilling = true;
modeBtn.innerText = "Draw";
}
}
function onCanvasClick() {
if (isFilling) {
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}
}
function onDestroyClick() {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}
function onEraserClick() {
ctx.strokeStyle = "white";
isFilling = false;
modeBtn.innerText = "Fill";
}
function onFileChange(event) {
const file = event.target.files[0];
const url = URL.createObjectURL(file);
const image = new Image();
image.src = url;
image.onload = function () {
ctx.drawImage(image, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
};
fileInput = null;
}
function onDoubleClick(event) {
ctx.save();
if (text !== "") {
ctx.lineWidth = 1;
const text = textInput.value;
ctx.font = currentFontSize + " " + currentFontFamily; // 폰트 사이즈, 폰트 패밀리 현재 값 지정
if (currentFillStroke === "fill") { // fill, stroke 선택에 따른 차이
ctx.fillText(text, event.offsetX, event.offsetY);
} else {
ctx.strokeText(text, event.offsetX, event.offsetY);
}
}
ctx.restore();
}
function onSaveClick() {
const url = canvas.toDataURL();
const a = document.createElement("a");
a.href = url;
a.download = "myDrawing.png";
a.click();
}
// 폰트 사이즈 클릭 선택
function onFontsizeClick(event) {
const fontSizeValue = event.target.dataset.fontsize;
ctx.font = fontSizeValue + " " + currentFontFamily;
currentFontSize = fontSizeValue;
const fontSizeId = document.getElementById(event.target.id);
// 색상 변경
fontSizes.forEach(fontSizeId => {
const fontSizeIdParent = fontSizeId.parentElement;
if (fontSizeId.checked === true) {
fontSizeIdParent.classList.add("font-size-label-sel");
} else {
fontSizeIdParent.classList.remove("font-size-label-sel");
}
});
}
// 폰트 패밀리 클릭 선택
function onFontFamilyClick(event) {
const fontFamily = event.target.dataset.fontfamily;
ctx.font = currentFontSize + " " + fontFamily;
currentFontFamily = fontFamily;
const fontFamilyId = document.getElementById(event.target.id);
// 버튼 색상 지정
fontFamilies.forEach(fontFamilyId => {
const fontFamilyIdParent = fontFamilyId.parentElement;
if (fontFamilyId.checked === true) {
fontFamilyIdParent.classList.add("font-family-label-sel");
} else {
fontFamilyIdParent.classList.remove("font-family-label-sel");
}
});
}
// fill, stroke 클릭 선택
function onFillStroke(event) {
const fillStroke = event.target.dataset.fillstroke;
const fillStrokeId = document.getElementById(event.target.id);
if (fillStroke === "fill") {
currentFillStroke = "fill";
} else {
currentFillStroke = "stroke";
}
// 버튼 색상 지정
fillStrokes.forEach(fillStrokeId => {
const fillStrokeIdParent = fillStrokeId.parentElement;
if (fillStrokeId.checked == true) {
fillStrokeIdParent.classList.add("fill-stroke-label-sel");
} else {
fillStrokeIdParent.classList.remove("fill-stroke-label-sel");
}
})
}
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mousedown", startPainting);
canvas.addEventListener("mouseup", cancelPainting);
canvas.addEventListener("mouseleave", cancelPainting);
canvas.addEventListener("click", onCanvasClick);
canvas.addEventListener("dblclick", onDoubleClick);
lineWidth.addEventListener("change", onLineWidthChange);
color.addEventListener("change", onColorChange);
colorOptions.forEach(color => color.addEventListener("click", onColorClick));
modeBtn.addEventListener("click", onModeClick);
destroyBtn.addEventListener("click", onDestroyClick);
eraseBtn.addEventListener("click", onEraserClick);
fileInput.addEventListener("change", onFileChange);
saveBtn.addEventListener("click", onSaveClick);
// 폰트 사이즈, 폰트 패밀리, fill, stroke 이벤트 리스너
fontSizes.forEach(fontsize => fontsize.addEventListener("click", onFontsizeClick));
fontFamilies.forEach(fontfamily => fontfamily.addEventListener("click", onFontFamilyClick));
fillStrokes.forEach(fillStroke => fillStroke.addEventListener("click", onFillStroke));